diff --git a/apps/web/src/components/files/FilePreviewPanel.test.ts b/apps/web/src/components/files/FilePreviewPanel.test.ts index ebebf510994bc1bfef081ba16abb1010b7f8b313..84fb69e79cf7b91f005046bb06e45c8ad63db885 100644 --- a/apps/web/src/components/files/FilePreviewPanel.test.ts +++ b/apps/web/src/components/files/FilePreviewPanel.test.ts @@ -1,3 +1,5 @@ +import { ProjectReadFileError } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; import { @@ -6,12 +8,57 @@ import { remapFileCommentAnnotations, } from "./fileCommentAnnotations"; import { + filePreviewReadErrorMessage, isMarkdownPreviewFile, resolveFilePreviewPath, setMarkdownTaskChecked, shouldShowFileExplorer, } from "./filePreviewMode"; +const decodeReadError = Schema.decodeSync(ProjectReadFileError); + +describe("file preview read errors", () => { + it.each([ + ["path_not_file", "The path is a directory or special file, not a regular file."], + ["binary_file", "The file is binary and cannot be displayed as text."], + ["workspace_path_outside_root", "The requested path is outside the workspace."], + ["resolved_path_outside_root", "The path resolves to a location outside the workspace."], + [ + "operation_failed", + "The file could not be accessed or read. It may be missing or inaccessible.", + ], + ] as const)("describes %s without revealing the platform cause", (failure, message) => { + const error = new ProjectReadFileError({ + cwd: "/workspace", + relativePath: "workspace/outline.md", + failure, + operation: "realpath-target", + resolvedPath: "/workspace/workspace/outline.md", + cause: new Error("EACCES: sensitive platform detail"), + }); + expect(filePreviewReadErrorMessage(error)).toBe(message); + }); + + it("distinguishes an inaccessible workspace from an inaccessible file", () => { + const error = new ProjectReadFileError({ + cwd: "/workspace", + relativePath: "outline.md", + failure: "operation_failed", + operation: "realpath-workspace-root", + operationPath: "/workspace", + }); + expect(filePreviewReadErrorMessage(error)).toBe("The workspace folder could not be accessed."); + }); + + it("preserves the public message from older servers", () => { + const error = decodeReadError({ + _tag: "ProjectReadFileError", + message: "Legacy file read failure.", + }); + expect(filePreviewReadErrorMessage(error)).toBe("Legacy file read failure."); + }); +}); + describe("file comment annotations", () => { it("normalizes and formats selected line ranges", () => { expect(normalizeFileCommentRange({ start: 16, end: 7 })).toEqual({ diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 560c9a1336a5327f60ba3855508e623e61bec550..5ae0753429fd1cca73bdec7297ec42cec889194f 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -82,6 +82,7 @@ import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { + filePreviewReadErrorMessage, isMarkdownPreviewFile, resolveFilePreviewPath, setMarkdownTaskChecked, @@ -957,6 +958,7 @@ export default function FilePreviewPanel({ relativePath, attachment === undefined && relativePath !== null, ); + const attemptedPath = file.readError?.resolvedPath ?? file.readError?.operationPath; // A chat link cannot tell a folder from a file, so a folder arrives here as // a file surface and the read fails. Keep the breadcrumbs, drop the preview // pane, and let the tree fill the surface with the folder revealed. Mutation @@ -1236,8 +1238,29 @@ export default function FilePreviewPanel({ workspaceMutationId={workspaceMutationId} /> ) : relativePath && file.error && file.data === null ? ( -
- {file.error} +
+
+

+ {file.readError ? filePreviewReadErrorMessage(file.readError) : file.error} +

+ {attemptedPath ? ( +

+ Attempted path + + {attemptedPath} + +

+ ) : null} + {!isHostFile ? ( +

+ Workspace folder:{" "} + + {file.readError?.cwd ?? cwd} + + . Check the link's path or locate the file in Files. +

+ ) : null} +
) : relativePath && file.data === null ? (
diff --git a/apps/web/src/components/files/filePreviewMode.ts b/apps/web/src/components/files/filePreviewMode.ts index 12a3eb95a3e81b48ce11baed22e3001ef80ed868..764d44bfd83d4d927037050289eeda7d160ecb71 100644 --- a/apps/web/src/components/files/filePreviewMode.ts +++ b/apps/web/src/components/files/filePreviewMode.ts @@ -1,4 +1,5 @@ import { workspaceRelativeFilePath } from "@t3tools/client-runtime/markdown-links"; +import type { ProjectReadFileError } from "@t3tools/contracts"; import { isAbsolutePath } from "~/terminal-links"; /** 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 export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path); +/** Describe existing failure codes without exposing the underlying platform cause. */ +export function filePreviewReadErrorMessage(error: ProjectReadFileError): string { + switch (error.failure) { + case "path_not_file": + return "The path is a directory or special file, not a regular file."; + case "binary_file": + return "The file is binary and cannot be displayed as text."; + case "workspace_path_outside_root": + return "The requested path is outside the workspace."; + case "resolved_path_outside_root": + return "The path resolves to a location outside the workspace."; + case "operation_failed": + // A realpath failure can mean a missing path, permissions, or another I/O error. + return error.operation === "realpath-workspace-root" + ? "The workspace folder could not be accessed." + : "The file could not be accessed or read. It may be missing or inaccessible."; + default: + return error.message; + } +} + export function shouldShowFileExplorer(input: { readonly relativePath: string | null; readonly explorerOpen: boolean; diff --git a/apps/web/src/components/files/projectFilesQueryState.test.tsx b/apps/web/src/components/files/projectFilesQueryState.test.tsx index a33465f6cb19e8a045373c5c180acc78ed0892b5..49bade395980d1cc3552786ae127ab1e3b0d872a 100644 --- a/apps/web/src/components/files/projectFilesQueryState.test.tsx +++ b/apps/web/src/components/files/projectFilesQueryState.test.tsx @@ -5,6 +5,7 @@ import { type ProjectReadFileResult, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { Atom, AtomRegistry } from "effect/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -79,6 +80,7 @@ import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState"; const environmentId = EnvironmentId.make("environment-1"); +const decodeReadError = Schema.decodeSync(ProjectReadFileError); function deferred() { let resolve!: (value: A) => void; @@ -137,6 +139,7 @@ describe("project query refresh", () => { const render = (mutationId: string | null) => { reactHooks.beginRender(); const query = useProjectFileQuery(environmentId, "/repo", "src/preview.ts"); + expect(query.readError).toBeNull(); renderedContents = query.data?.contents ?? null; useWorkspaceMutationRefresh({ mutationId, @@ -245,6 +248,7 @@ describe("project query refresh", () => { await flushEffects(); expect(projectMocks.readFile).not.toHaveBeenCalled(); + expect(query.readError).toBeNull(); expect(requests).toHaveLength(0); } finally { registry.dispose(); @@ -273,6 +277,7 @@ describe("project query refresh", () => { reactHooks.beginRender(); const query = useProjectFileQuery(environmentId, "/repo", "assets.png"); expect(query.isNotFile).toBe(true); + expect(query.readError?.failure).toBe("path_not_file"); expect(query.data).toBeNull(); } finally { unmount(); @@ -302,6 +307,40 @@ describe("project query refresh", () => { reactHooks.beginRender(); const query = useProjectFileQuery(environmentId, "/repo", ".agents/skills"); expect(query.isNotFile).toBe(true); + expect(query.readError?.failure).toBe("path_not_file"); + expect(query.data).toBeNull(); + } finally { + unmount(); + registry.dispose(); + atomHooks.registry = null; + } + }); + + it("retains decoded read failure context for the connected environment", async () => { + const error = decodeReadError({ + _tag: "ProjectReadFileError", + cwd: "C:\\workspace", + relativePath: "workspace/outline.md", + failure: "operation_failed", + operation: "realpath-target", + operationPath: "C:\\workspace\\workspace\\outline.md", + resolvedPath: "C:\\workspace\\workspace\\outline.md", + message: "Failed to read workspace file.", + }); + const readAtom = Atom.make(Effect.fail(error)); + const registry = AtomRegistry.make(); + const unmount = registry.mount(readAtom); + projectMocks.readFile.mockReturnValue(readAtom); + projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); + atomHooks.registry = registry; + + try { + await flushEffects(); + reactHooks.beginRender(); + const query = useProjectFileQuery(environmentId, "C:\\workspace", "workspace/outline.md"); + expect(query.readError).toBe(error); + expect(query.error).toBe(error.message); + expect(query.isNotFile).toBe(false); expect(query.data).toBeNull(); } finally { unmount(); diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index fa4657ca815f1d03ffd95996cebefd81533f3f66..0b3ca2040e88c081d29da8ec896a92f9a2e8ed35 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -37,6 +37,7 @@ interface ProjectQueryState { } interface ProjectFileQueryState extends ProjectQueryState { + readonly readError: ProjectReadFileError | null; /** The path exists but is not a regular file, typically a directory. */ readonly isNotFile: boolean; } @@ -217,11 +218,13 @@ export function useProjectFileQuery( ); const optimisticFile = relativePath === null ? null : optimisticResult; const cause = failureCause(result); + const readError = isProjectReadFileError(cause) ? cause : null; return { data: optimisticFile?.data ?? data, error: errorMessage(cause), - isNotFile: isProjectReadFileError(cause) && cause.failure === "path_not_file", + readError, + isNotFile: readError?.failure === "path_not_file", isPending: result.waiting, refresh, };