authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-06 20:50:48-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 20:50:48-03:00
log876f8656e310f365eab98678e68bcc45615fec7f
treefc42744a7b629421e758b829949eae42df6a420e
parentd8d037eae1b77a77f372fd1afd2662a31ebe37c8
signature Signed by PGP key B5690EEEBB952194

fix(web): show attempted paths in file preview errors (#15628)


5 files changed, 137 insertions(+), 3 deletions(-)

apps/web/src/components/files/FilePreviewPanel.test.ts+47
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { ProjectReadFileError } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
1import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
24
3import {5import {
...@@ -6,12 +8,57 @@ import {...@@ -6,12 +8,57 @@ import {
6 remapFileCommentAnnotations,8 remapFileCommentAnnotations,
7} from "./fileCommentAnnotations";9} from "./fileCommentAnnotations";
8import {10import {
11 filePreviewReadErrorMessage,
9 isMarkdownPreviewFile,12 isMarkdownPreviewFile,
10 resolveFilePreviewPath,13 resolveFilePreviewPath,
11 setMarkdownTaskChecked,14 setMarkdownTaskChecked,
12 shouldShowFileExplorer,15 shouldShowFileExplorer,
13} from "./filePreviewMode";16} from "./filePreviewMode";
1417
18const decodeReadError = Schema.decodeSync(ProjectReadFileError);
19
20describe("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
15describe("file comment annotations", () => {62describe("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";
82import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";82import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";
83import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision";83import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision";
84import {84import {
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 as962 // 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 preview963 // 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. Mutation964 // 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 @@
1import { workspaceRelativeFilePath } from "@t3tools/client-runtime/markdown-links";1import { workspaceRelativeFilePath } from "@t3tools/client-runtime/markdown-links";
2import type { ProjectReadFileError } from "@t3tools/contracts";
2import { isAbsolutePath } from "~/terminal-links";3import { isAbsolutePath } from "~/terminal-links";
34
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
910
10export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path);11export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path);
1112
13/** Describe existing failure codes without exposing the underlying platform cause. */
14export 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
12export function shouldShowFileExplorer(input: {34export 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";
7import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
8import * as Schema from "effect/Schema";
8import { Atom, AtomRegistry } from "effect/reactivity";9import { Atom, AtomRegistry } from "effect/reactivity";
9import { beforeEach, describe, expect, it, vi } from "vite-plus/test";10import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
1011
...@@ -79,6 +80,7 @@ import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh...@@ -79,6 +80,7 @@ import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh
79import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState";80import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState";
8081
81const environmentId = EnvironmentId.make("environment-1");82const environmentId = EnvironmentId.make("environment-1");
83const decodeReadError = Schema.decodeSync(ProjectReadFileError);
8284
83function deferred<A>() {85function 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();
246249
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}
3838
39interface ProjectFileQueryState extends ProjectQueryState<ProjectReadFileResult> {39interface 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;
220222
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 };