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 @@
1import { ProjectReadFileError } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
13import { describe, expect, it } from "vite-plus/test";
24
35import {
......@@ -6,12 +8,57 @@ import {
68 remapFileCommentAnnotations,
79} from "./fileCommentAnnotations";
810import {
11 filePreviewReadErrorMessage,
912 isMarkdownPreviewFile,
1013 resolveFilePreviewPath,
1114 setMarkdownTaskChecked,
1215 shouldShowFileExplorer,
1316} 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
1562describe("file comment annotations", () => {
1663 it("normalizes and formats selected line ranges", () => {
1764 expect(normalizeFileCommentRange({ start: 16, end: 7 })).toEqual({
apps/web/src/components/files/FilePreviewPanel.tsx+25-2
......@@ -82,6 +82,7 @@ import { resolveCenteredFileLineScrollTop } from "./fileLineReveal";
8282import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";
8383import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision";
8484import {
85 filePreviewReadErrorMessage,
8586 isMarkdownPreviewFile,
8687 resolveFilePreviewPath,
8788 setMarkdownTaskChecked,
......@@ -957,6 +958,7 @@ export default function FilePreviewPanel({
957958 relativePath,
958959 attachment === undefined && relativePath !== null,
959960 );
961 const attemptedPath = file.readError?.resolvedPath ?? file.readError?.operationPath;
960962 // A chat link cannot tell a folder from a file, so a folder arrives here as
961963 // a file surface and the read fails. Keep the breadcrumbs, drop the preview
962964 // pane, and let the tree fill the surface with the folder revealed. Mutation
......@@ -1236,8 +1238,29 @@ export default function FilePreviewPanel({
12361238 workspaceMutationId={workspaceMutationId}
12371239 />
12381240 ) : 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>
12411264 </div>
12421265 ) : relativePath && file.data === null ? (
12431266 <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 @@
11import { workspaceRelativeFilePath } from "@t3tools/client-runtime/markdown-links";
2import type { ProjectReadFileError } from "@t3tools/contracts";
23import { isAbsolutePath } from "~/terminal-links";
34
45/** 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
910
1011export 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
1234export function shouldShowFileExplorer(input: {
1335 readonly relativePath: string | null;
1436 readonly explorerOpen: boolean;
apps/web/src/components/files/projectFilesQueryState.test.tsx+39
......@@ -5,6 +5,7 @@ import {
55 type ProjectReadFileResult,
66} from "@t3tools/contracts";
77import * as Effect from "effect/Effect";
8import * as Schema from "effect/Schema";
89import { Atom, AtomRegistry } from "effect/reactivity";
910import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
1011
......@@ -79,6 +80,7 @@ import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh
7980import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState";
8081
8182const environmentId = EnvironmentId.make("environment-1");
83const decodeReadError = Schema.decodeSync(ProjectReadFileError);
8284
8385function deferred<A>() {
8486 let resolve!: (value: A) => void;
......@@ -137,6 +139,7 @@ describe("project query refresh", () => {
137139 const render = (mutationId: string | null) => {
138140 reactHooks.beginRender();
139141 const query = useProjectFileQuery(environmentId, "/repo", "src/preview.ts");
142 expect(query.readError).toBeNull();
140143 renderedContents = query.data?.contents ?? null;
141144 useWorkspaceMutationRefresh({
142145 mutationId,
......@@ -245,6 +248,7 @@ describe("project query refresh", () => {
245248 await flushEffects();
246249
247250 expect(projectMocks.readFile).not.toHaveBeenCalled();
251 expect(query.readError).toBeNull();
248252 expect(requests).toHaveLength(0);
249253 } finally {
250254 registry.dispose();
......@@ -273,6 +277,7 @@ describe("project query refresh", () => {
273277 reactHooks.beginRender();
274278 const query = useProjectFileQuery(environmentId, "/repo", "assets.png");
275279 expect(query.isNotFile).toBe(true);
280 expect(query.readError?.failure).toBe("path_not_file");
276281 expect(query.data).toBeNull();
277282 } finally {
278283 unmount();
......@@ -302,6 +307,40 @@ describe("project query refresh", () => {
302307 reactHooks.beginRender();
303308 const query = useProjectFileQuery(environmentId, "/repo", ".agents/skills");
304309 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);
305344 expect(query.data).toBeNull();
306345 } finally {
307346 unmount();
apps/web/src/components/files/projectFilesQueryState.ts+4-1
......@@ -37,6 +37,7 @@ interface ProjectQueryState<A> {
3737}
3838
3939interface ProjectFileQueryState extends ProjectQueryState<ProjectReadFileResult> {
40 readonly readError: ProjectReadFileError | null;
4041 /** The path exists but is not a regular file, typically a directory. */
4142 readonly isNotFile: boolean;
4243}
......@@ -217,11 +218,13 @@ export function useProjectFileQuery(
217218 );
218219 const optimisticFile = relativePath === null ? null : optimisticResult;
219220 const cause = failureCause(result);
221 const readError = isProjectReadFileError(cause) ? cause : null;
220222
221223 return {
222224 data: optimisticFile?.data ?? data,
223225 error: errorMessage(cause),
224 isNotFile: isProjectReadFileError(cause) && cause.failure === "path_not_file",
226 readError,
227 isNotFile: readError?.failure === "path_not_file",
225228 isPending: result.waiting,
226229 refresh,
227230 };