| 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 | import { ProjectReadFileError } from "@t3tools/contracts"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 1 | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 4 | |
| 3 | 5 | import { |
| ... | ... | @@ -6,12 +8,57 @@ import { |
| 6 | 8 | remapFileCommentAnnotations, |
| 7 | 9 | } from "./fileCommentAnnotations"; |
| 8 | 10 | import { |
| 11 | filePreviewReadErrorMessage, | |
| 9 | 12 | isMarkdownPreviewFile, |
| 10 | 13 | resolveFilePreviewPath, |
| 11 | 14 | setMarkdownTaskChecked, |
| 12 | 15 | shouldShowFileExplorer, |
| 13 | 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 | 62 | describe("file comment annotations", () => { |
| 16 | 63 | it("normalizes and formats selected line ranges", () => { |
| 17 | 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 | 82 | import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; |
| 83 | 83 | import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; |
| 84 | 84 | import { |
| 85 | filePreviewReadErrorMessage, | |
| 85 | 86 | isMarkdownPreviewFile, |
| 86 | 87 | resolveFilePreviewPath, |
| 87 | 88 | setMarkdownTaskChecked, |
| ... | ... | @@ -957,6 +958,7 @@ export default function FilePreviewPanel({ |
| 957 | 958 | relativePath, |
| 958 | 959 | attachment === undefined && relativePath !== null, |
| 959 | 960 | ); |
| 961 | const attemptedPath = file.readError?.resolvedPath ?? file.readError?.operationPath; | |
| 960 | 962 | // A chat link cannot tell a folder from a file, so a folder arrives here as |
| 961 | 963 | // a file surface and the read fails. Keep the breadcrumbs, drop the preview |
| 962 | 964 | // pane, and let the tree fill the surface with the folder revealed. Mutation |
| ... | ... | @@ -1236,8 +1238,29 @@ export default function FilePreviewPanel({ |
| 1236 | 1238 | workspaceMutationId={workspaceMutationId} |
| 1237 | 1239 | /> |
| 1238 | 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"> | |
| 1240 | {file.error} | |
| 1241 | <div role="alert" className="flex min-h-0 flex-1 flex-col overflow-auto"> | |
| 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 | 1264 | </div> |
| 1242 | 1265 | ) : relativePath && file.data === null ? ( |
| 1243 | 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 | 1 | import { workspaceRelativeFilePath } from "@t3tools/client-runtime/markdown-links"; |
| 2 | import type { ProjectReadFileError } from "@t3tools/contracts"; | |
| 2 | 3 | import { isAbsolutePath } from "~/terminal-links"; |
| 3 | 4 | |
| 4 | 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 | 10 | |
| 10 | 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 | 34 | export function shouldShowFileExplorer(input: { |
| 13 | 35 | readonly relativePath: string | null; |
| 14 | 36 | readonly explorerOpen: boolean; |
apps/web/src/components/files/projectFilesQueryState.test.tsx+39| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | type ProjectReadFileResult, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Schema from "effect/Schema"; | |
| 8 | 9 | import { Atom, AtomRegistry } from "effect/reactivity"; |
| 9 | 10 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 10 | 11 | |
| ... | ... | @@ -79,6 +80,7 @@ import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh |
| 79 | 80 | import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState"; |
| 80 | 81 | |
| 81 | 82 | const environmentId = EnvironmentId.make("environment-1"); |
| 83 | const decodeReadError = Schema.decodeSync(ProjectReadFileError); | |
| 82 | 84 | |
| 83 | 85 | function deferred<A>() { |
| 84 | 86 | let resolve!: (value: A) => void; |
| ... | ... | @@ -137,6 +139,7 @@ describe("project query refresh", () => { |
| 137 | 139 | const render = (mutationId: string | null) => { |
| 138 | 140 | reactHooks.beginRender(); |
| 139 | 141 | const query = useProjectFileQuery(environmentId, "/repo", "src/preview.ts"); |
| 142 | expect(query.readError).toBeNull(); | |
| 140 | 143 | renderedContents = query.data?.contents ?? null; |
| 141 | 144 | useWorkspaceMutationRefresh({ |
| 142 | 145 | mutationId, |
| ... | ... | @@ -245,6 +248,7 @@ describe("project query refresh", () => { |
| 245 | 248 | await flushEffects(); |
| 246 | 249 | |
| 247 | 250 | expect(projectMocks.readFile).not.toHaveBeenCalled(); |
| 251 | expect(query.readError).toBeNull(); | |
| 248 | 252 | expect(requests).toHaveLength(0); |
| 249 | 253 | } finally { |
| 250 | 254 | registry.dispose(); |
| ... | ... | @@ -273,6 +277,7 @@ describe("project query refresh", () => { |
| 273 | 277 | reactHooks.beginRender(); |
| 274 | 278 | const query = useProjectFileQuery(environmentId, "/repo", "assets.png"); |
| 275 | 279 | expect(query.isNotFile).toBe(true); |
| 280 | expect(query.readError?.failure).toBe("path_not_file"); | |
| 276 | 281 | expect(query.data).toBeNull(); |
| 277 | 282 | } finally { |
| 278 | 283 | unmount(); |
| ... | ... | @@ -302,6 +307,40 @@ describe("project query refresh", () => { |
| 302 | 307 | reactHooks.beginRender(); |
| 303 | 308 | const query = useProjectFileQuery(environmentId, "/repo", ".agents/skills"); |
| 304 | 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 | 344 | expect(query.data).toBeNull(); |
| 306 | 345 | } finally { |
| 307 | 346 | unmount(); |
apps/web/src/components/files/projectFilesQueryState.ts+4-1| ... | ... | @@ -37,6 +37,7 @@ interface ProjectQueryState<A> { |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | 39 | interface ProjectFileQueryState extends ProjectQueryState<ProjectReadFileResult> { |
| 40 | readonly readError: ProjectReadFileError | null; | |
| 40 | 41 | /** The path exists but is not a regular file, typically a directory. */ |
| 41 | 42 | readonly isNotFile: boolean; |
| 42 | 43 | } |
| ... | ... | @@ -217,11 +218,13 @@ export function useProjectFileQuery( |
| 217 | 218 | ); |
| 218 | 219 | const optimisticFile = relativePath === null ? null : optimisticResult; |
| 219 | 220 | const cause = failureCause(result); |
| 221 | const readError = isProjectReadFileError(cause) ? cause : null; | |
| 220 | 222 | |
| 221 | 223 | return { |
| 222 | 224 | data: optimisticFile?.data ?? data, |
| 223 | 225 | error: errorMessage(cause), |
| 224 | isNotFile: isProjectReadFileError(cause) && cause.failure === "path_not_file", | |
| 226 | readError, | |
| 227 | isNotFile: readError?.failure === "path_not_file", | |
| 225 | 228 | isPending: result.waiting, |
| 226 | 229 | refresh, |
| 227 | 230 | }; |