| author | |
| committer | |
| log | 876f8656e310f365eab98678e68bcc45615fec7f |
| tree | fc42744a7b629421e758b829949eae42df6a420e |
| parent | d8d037eae1b77a77f372fd1afd2662a31ebe37c8 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 137 insertions(+), 3 deletions(-)
apps/web/src/components/files/FilePreviewPanel.test.ts+47| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import { ProjectReadFileError } from "@t3tools/contracts"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 4 | ||
| 3 | import { | 5 | import { |
| ... | @@ -6,12 +8,57 @@ import { | ... | @@ -6,12 +8,57 @@ import { |
| 6 | remapFileCommentAnnotations, | 8 | remapFileCommentAnnotations, |
| 7 | } from "./fileCommentAnnotations"; | 9 | } from "./fileCommentAnnotations"; |
| 8 | import { | 10 | import { |
| 11 | filePreviewReadErrorMessage, | ||
| 9 | isMarkdownPreviewFile, | 12 | isMarkdownPreviewFile, |
| 10 | resolveFilePreviewPath, | 13 | resolveFilePreviewPath, |
| 11 | setMarkdownTaskChecked, | 14 | setMarkdownTaskChecked, |
| 12 | shouldShowFileExplorer, | 15 | shouldShowFileExplorer, |
| 13 | } from "./filePreviewMode"; | 16 | } from "./filePreviewMode"; |
| 14 | 17 | ||
| 18 | const decodeReadError = Schema.decodeSync(ProjectReadFileError); | ||
| 19 | |||
| 20 | describe("file preview read errors", () => { | ||
| 21 | it.each([ | ||
| 22 | ["path_not_file", "The path is a directory or special file, not a regular file."], | ||
| 23 | ["binary_file", "The file is binary and cannot be displayed as text."], | ||
| 24 | ["workspace_path_outside_root", "The requested path is outside the workspace."], | ||
| 25 | ["resolved_path_outside_root", "The path resolves to a location outside the workspace."], | ||
| 26 | [ | ||
| 27 | "operation_failed", | ||
| 28 | "The file could not be accessed or read. It may be missing or inaccessible.", | ||
| 29 | ], | ||
| 30 | ] as const)("describes %s without revealing the platform cause", (failure, message) => { | ||
| 31 | const error = new ProjectReadFileError({ | ||
| 32 | cwd: "/workspace", | ||
| 33 | relativePath: "workspace/outline.md", | ||
| 34 | failure, | ||
| 35 | operation: "realpath-target", | ||
| 36 | resolvedPath: "/workspace/workspace/outline.md", | ||
| 37 | cause: new Error("EACCES: sensitive platform detail"), | ||
| 38 | }); | ||
| 39 | expect(filePreviewReadErrorMessage(error)).toBe(message); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("distinguishes an inaccessible workspace from an inaccessible file", () => { | ||
| 43 | const error = new ProjectReadFileError({ | ||
| 44 | cwd: "/workspace", | ||
| 45 | relativePath: "outline.md", | ||
| 46 | failure: "operation_failed", | ||
| 47 | operation: "realpath-workspace-root", | ||
| 48 | operationPath: "/workspace", | ||
| 49 | }); | ||
| 50 | expect(filePreviewReadErrorMessage(error)).toBe("The workspace folder could not be accessed."); | ||
| 51 | }); | ||
| 52 | |||
| 53 | it("preserves the public message from older servers", () => { | ||
| 54 | const error = decodeReadError({ | ||
| 55 | _tag: "ProjectReadFileError", | ||
| 56 | message: "Legacy file read failure.", | ||
| 57 | }); | ||
| 58 | expect(filePreviewReadErrorMessage(error)).toBe("Legacy file read failure."); | ||
| 59 | }); | ||
| 60 | }); | ||
| 61 | |||
| 15 | describe("file comment annotations", () => { | 62 | describe("file comment annotations", () => { |
| 16 | it("normalizes and formats selected line ranges", () => { | 63 | it("normalizes and formats selected line ranges", () => { |
| 17 | expect(normalizeFileCommentRange({ start: 16, end: 7 })).toEqual({ | 64 | expect(normalizeFileCommentRange({ start: 16, end: 7 })).toEqual({ |
apps/web/src/components/files/FilePreviewPanel.tsx+25-2| ... | @@ -82,6 +82,7 @@ import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; | ... | @@ -82,6 +82,7 @@ import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; |
| 82 | import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; | 82 | import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; |
| 83 | import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; | 83 | import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; |
| 84 | import { | 84 | import { |
| 85 | filePreviewReadErrorMessage, | ||
| 85 | isMarkdownPreviewFile, | 86 | isMarkdownPreviewFile, |
| 86 | resolveFilePreviewPath, | 87 | resolveFilePreviewPath, |
| 87 | setMarkdownTaskChecked, | 88 | setMarkdownTaskChecked, |
| ... | @@ -957,6 +958,7 @@ export default function FilePreviewPanel({ | ... | @@ -957,6 +958,7 @@ export default function FilePreviewPanel({ |
| 957 | relativePath, | 958 | relativePath, |
| 958 | attachment === undefined && relativePath !== null, | 959 | attachment === undefined && relativePath !== null, |
| 959 | ); | 960 | ); |
| 961 | const attemptedPath = file.readError?.resolvedPath ?? file.readError?.operationPath; | ||
| 960 | // A chat link cannot tell a folder from a file, so a folder arrives here as | 962 | // A chat link cannot tell a folder from a file, so a folder arrives here as |
| 961 | // a file surface and the read fails. Keep the breadcrumbs, drop the preview | 963 | // a file surface and the read fails. Keep the breadcrumbs, drop the preview |
| 962 | // pane, and let the tree fill the surface with the folder revealed. Mutation | 964 | // pane, and let the tree fill the surface with the folder revealed. Mutation |
| ... | @@ -1236,8 +1238,29 @@ export default function FilePreviewPanel({ | ... | @@ -1236,8 +1238,29 @@ export default function FilePreviewPanel({ |
| 1236 | workspaceMutationId={workspaceMutationId} | 1238 | workspaceMutationId={workspaceMutationId} |
| 1237 | /> | 1239 | /> |
| 1238 | ) : relativePath && file.error && file.data === null ? ( | 1240 | ) : relativePath && file.error && file.data === null ? ( |
| 1239 | <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive"> | 1241 | <div role="alert" className="flex min-h-0 flex-1 flex-col overflow-auto"> |
| 1240 | {file.error} | 1242 | <div className="my-auto flex shrink-0 flex-col gap-3 px-6 py-6 text-center text-xs leading-relaxed"> |
| 1243 | <p className="text-destructive"> | ||
| 1244 | {file.readError ? filePreviewReadErrorMessage(file.readError) : file.error} | ||
| 1245 | </p> | ||
| 1246 | {attemptedPath ? ( | ||
| 1247 | <p className="text-muted-foreground"> | ||
| 1248 | Attempted path | ||
| 1249 | <code className="block break-all font-mono text-foreground select-all"> | ||
| 1250 | {attemptedPath} | ||
| 1251 | </code> | ||
| 1252 | </p> | ||
| 1253 | ) : null} | ||
| 1254 | {!isHostFile ? ( | ||
| 1255 | <p className="text-muted-foreground"> | ||
| 1256 | Workspace folder:{" "} | ||
| 1257 | <code className="break-all font-mono select-all"> | ||
| 1258 | {file.readError?.cwd ?? cwd} | ||
| 1259 | </code> | ||
| 1260 | . Check the link's path or locate the file in Files. | ||
| 1261 | </p> | ||
| 1262 | ) : null} | ||
| 1263 | </div> | ||
| 1241 | </div> | 1264 | </div> |
| 1242 | ) : relativePath && file.data === null ? ( | 1265 | ) : relativePath && file.data === null ? ( |
| 1243 | <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground"> | 1266 | <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground"> |
apps/web/src/components/files/filePreviewMode.ts+22| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { workspaceRelativeFilePath } from "@t3tools/client-runtime/markdown-links"; | 1 | import { workspaceRelativeFilePath } from "@t3tools/client-runtime/markdown-links"; |
| 2 | import type { ProjectReadFileError } from "@t3tools/contracts"; | ||
| 2 | import { isAbsolutePath } from "~/terminal-links"; | 3 | import { isAbsolutePath } from "~/terminal-links"; |
| 3 | 4 | ||
| 4 | /** Resolve workspace links before choosing between the explorer and a file preview. */ | 5 | /** Resolve workspace links before choosing between the explorer and a file preview. */ |
| ... | @@ -9,6 +10,27 @@ export function resolveFilePreviewPath(path: string | null, cwd: string): string | ... | @@ -9,6 +10,27 @@ export function resolveFilePreviewPath(path: string | null, cwd: string): string |
| 9 | 10 | ||
| 10 | export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path); | 11 | export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path); |
| 11 | 12 | ||
| 13 | /** Describe existing failure codes without exposing the underlying platform cause. */ | ||
| 14 | export function filePreviewReadErrorMessage(error: ProjectReadFileError): string { | ||
| 15 | switch (error.failure) { | ||
| 16 | case "path_not_file": | ||
| 17 | return "The path is a directory or special file, not a regular file."; | ||
| 18 | case "binary_file": | ||
| 19 | return "The file is binary and cannot be displayed as text."; | ||
| 20 | case "workspace_path_outside_root": | ||
| 21 | return "The requested path is outside the workspace."; | ||
| 22 | case "resolved_path_outside_root": | ||
| 23 | return "The path resolves to a location outside the workspace."; | ||
| 24 | case "operation_failed": | ||
| 25 | // A realpath failure can mean a missing path, permissions, or another I/O error. | ||
| 26 | return error.operation === "realpath-workspace-root" | ||
| 27 | ? "The workspace folder could not be accessed." | ||
| 28 | : "The file could not be accessed or read. It may be missing or inaccessible."; | ||
| 29 | default: | ||
| 30 | return error.message; | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 12 | export function shouldShowFileExplorer(input: { | 34 | export function shouldShowFileExplorer(input: { |
| 13 | readonly relativePath: string | null; | 35 | readonly relativePath: string | null; |
| 14 | readonly explorerOpen: boolean; | 36 | readonly explorerOpen: boolean; |
apps/web/src/components/files/projectFilesQueryState.test.tsx+39| ... | @@ -5,6 +5,7 @@ import { | ... | @@ -5,6 +5,7 @@ import { |
| 5 | type ProjectReadFileResult, | 5 | type ProjectReadFileResult, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Schema from "effect/Schema"; | ||
| 8 | import { Atom, AtomRegistry } from "effect/reactivity"; | 9 | import { Atom, AtomRegistry } from "effect/reactivity"; |
| 9 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 10 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 10 | 11 | ||
| ... | @@ -79,6 +80,7 @@ import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh | ... | @@ -79,6 +80,7 @@ import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh |
| 79 | import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState"; | 80 | import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState"; |
| 80 | 81 | ||
| 81 | const environmentId = EnvironmentId.make("environment-1"); | 82 | const environmentId = EnvironmentId.make("environment-1"); |
| 83 | const decodeReadError = Schema.decodeSync(ProjectReadFileError); | ||
| 82 | 84 | ||
| 83 | function deferred<A>() { | 85 | function deferred<A>() { |
| 84 | let resolve!: (value: A) => void; | 86 | let resolve!: (value: A) => void; |
| ... | @@ -137,6 +139,7 @@ describe("project query refresh", () => { | ... | @@ -137,6 +139,7 @@ describe("project query refresh", () => { |
| 137 | const render = (mutationId: string | null) => { | 139 | const render = (mutationId: string | null) => { |
| 138 | reactHooks.beginRender(); | 140 | reactHooks.beginRender(); |
| 139 | const query = useProjectFileQuery(environmentId, "/repo", "src/preview.ts"); | 141 | const query = useProjectFileQuery(environmentId, "/repo", "src/preview.ts"); |
| 142 | expect(query.readError).toBeNull(); | ||
| 140 | renderedContents = query.data?.contents ?? null; | 143 | renderedContents = query.data?.contents ?? null; |
| 141 | useWorkspaceMutationRefresh({ | 144 | useWorkspaceMutationRefresh({ |
| 142 | mutationId, | 145 | mutationId, |
| ... | @@ -245,6 +248,7 @@ describe("project query refresh", () => { | ... | @@ -245,6 +248,7 @@ describe("project query refresh", () => { |
| 245 | await flushEffects(); | 248 | await flushEffects(); |
| 246 | 249 | ||
| 247 | expect(projectMocks.readFile).not.toHaveBeenCalled(); | 250 | expect(projectMocks.readFile).not.toHaveBeenCalled(); |
| 251 | expect(query.readError).toBeNull(); | ||
| 248 | expect(requests).toHaveLength(0); | 252 | expect(requests).toHaveLength(0); |
| 249 | } finally { | 253 | } finally { |
| 250 | registry.dispose(); | 254 | registry.dispose(); |
| ... | @@ -273,6 +277,7 @@ describe("project query refresh", () => { | ... | @@ -273,6 +277,7 @@ describe("project query refresh", () => { |
| 273 | reactHooks.beginRender(); | 277 | reactHooks.beginRender(); |
| 274 | const query = useProjectFileQuery(environmentId, "/repo", "assets.png"); | 278 | const query = useProjectFileQuery(environmentId, "/repo", "assets.png"); |
| 275 | expect(query.isNotFile).toBe(true); | 279 | expect(query.isNotFile).toBe(true); |
| 280 | expect(query.readError?.failure).toBe("path_not_file"); | ||
| 276 | expect(query.data).toBeNull(); | 281 | expect(query.data).toBeNull(); |
| 277 | } finally { | 282 | } finally { |
| 278 | unmount(); | 283 | unmount(); |
| ... | @@ -302,6 +307,40 @@ describe("project query refresh", () => { | ... | @@ -302,6 +307,40 @@ describe("project query refresh", () => { |
| 302 | reactHooks.beginRender(); | 307 | reactHooks.beginRender(); |
| 303 | const query = useProjectFileQuery(environmentId, "/repo", ".agents/skills"); | 308 | const query = useProjectFileQuery(environmentId, "/repo", ".agents/skills"); |
| 304 | expect(query.isNotFile).toBe(true); | 309 | expect(query.isNotFile).toBe(true); |
| 310 | expect(query.readError?.failure).toBe("path_not_file"); | ||
| 311 | expect(query.data).toBeNull(); | ||
| 312 | } finally { | ||
| 313 | unmount(); | ||
| 314 | registry.dispose(); | ||
| 315 | atomHooks.registry = null; | ||
| 316 | } | ||
| 317 | }); | ||
| 318 | |||
| 319 | it("retains decoded read failure context for the connected environment", async () => { | ||
| 320 | const error = decodeReadError({ | ||
| 321 | _tag: "ProjectReadFileError", | ||
| 322 | cwd: "C:\\workspace", | ||
| 323 | relativePath: "workspace/outline.md", | ||
| 324 | failure: "operation_failed", | ||
| 325 | operation: "realpath-target", | ||
| 326 | operationPath: "C:\\workspace\\workspace\\outline.md", | ||
| 327 | resolvedPath: "C:\\workspace\\workspace\\outline.md", | ||
| 328 | message: "Failed to read workspace file.", | ||
| 329 | }); | ||
| 330 | const readAtom = Atom.make(Effect.fail(error)); | ||
| 331 | const registry = AtomRegistry.make(); | ||
| 332 | const unmount = registry.mount(readAtom); | ||
| 333 | projectMocks.readFile.mockReturnValue(readAtom); | ||
| 334 | projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); | ||
| 335 | atomHooks.registry = registry; | ||
| 336 | |||
| 337 | try { | ||
| 338 | await flushEffects(); | ||
| 339 | reactHooks.beginRender(); | ||
| 340 | const query = useProjectFileQuery(environmentId, "C:\\workspace", "workspace/outline.md"); | ||
| 341 | expect(query.readError).toBe(error); | ||
| 342 | expect(query.error).toBe(error.message); | ||
| 343 | expect(query.isNotFile).toBe(false); | ||
| 305 | expect(query.data).toBeNull(); | 344 | expect(query.data).toBeNull(); |
| 306 | } finally { | 345 | } finally { |
| 307 | unmount(); | 346 | unmount(); |
apps/web/src/components/files/projectFilesQueryState.ts+4-1| ... | @@ -37,6 +37,7 @@ interface ProjectQueryState<A> { | ... | @@ -37,6 +37,7 @@ interface ProjectQueryState<A> { |
| 37 | } | 37 | } |
| 38 | 38 | ||
| 39 | interface ProjectFileQueryState extends ProjectQueryState<ProjectReadFileResult> { | 39 | interface ProjectFileQueryState extends ProjectQueryState<ProjectReadFileResult> { |
| 40 | readonly readError: ProjectReadFileError | null; | ||
| 40 | /** The path exists but is not a regular file, typically a directory. */ | 41 | /** The path exists but is not a regular file, typically a directory. */ |
| 41 | readonly isNotFile: boolean; | 42 | readonly isNotFile: boolean; |
| 42 | } | 43 | } |
| ... | @@ -217,11 +218,13 @@ export function useProjectFileQuery( | ... | @@ -217,11 +218,13 @@ export function useProjectFileQuery( |
| 217 | ); | 218 | ); |
| 218 | const optimisticFile = relativePath === null ? null : optimisticResult; | 219 | const optimisticFile = relativePath === null ? null : optimisticResult; |
| 219 | const cause = failureCause(result); | 220 | const cause = failureCause(result); |
| 221 | const readError = isProjectReadFileError(cause) ? cause : null; | ||
| 220 | 222 | ||
| 221 | return { | 223 | return { |
| 222 | data: optimisticFile?.data ?? data, | 224 | data: optimisticFile?.data ?? data, |
| 223 | error: errorMessage(cause), | 225 | error: errorMessage(cause), |
| 224 | isNotFile: isProjectReadFileError(cause) && cause.failure === "path_not_file", | 226 | readError, |
| 227 | isNotFile: readError?.failure === "path_not_file", | ||
| 225 | isPending: result.waiting, | 228 | isPending: result.waiting, |
| 226 | refresh, | 229 | refresh, |
| 227 | }; | 230 | }; |