diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 49894db3c8cf..1803d327290f 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -30,7 +30,7 @@ interface FileBrowserPanelProps { environmentId: EnvironmentId; cwd: string; projectName: string; - /** File currently open in the preview pane; revealed and selected in the tree. */ + /** Entry currently open in the surface; revealed and selected in the tree. A directory is expanded. */ selectedPath: string | null; /** Bumped when the same path should be revealed again (e.g. re-opened from search). */ selectedPathRevealId: number; @@ -299,8 +299,13 @@ export default function FileBrowserPanel({ ) { return; } - if (entryKinds.get(selectedPath) !== "file") return; - const selectedItem = model.getItem(selectedPath); + const selectedKind = entryKinds.get(selectedPath); + if (selectedKind === undefined) return; + // Directory rows are registered with a trailing slash (see treePath). + const selectedItem = + selectedKind === "directory" + ? model.getItem(`${selectedPath}/`) + : model.getItem(selectedPath); if (!selectedItem) return; // A selection that originated inside the tree (clicking a row, possibly @@ -334,8 +339,12 @@ export default function FileBrowserPanel({ if (item && "expand" in item) item.expand(); } + if (selectedKind === "directory" && "expand" in selectedItem) selectedItem.expand(); selectedItem.select(); - model.scrollToPath(selectedPath, { focus: true, offset: "center" }); + model.scrollToPath(selectedKind === "directory" ? `${selectedPath}/` : selectedPath, { + focus: true, + offset: "center", + }); queueMicrotask(() => { syncingSelectionRef.current = false; }); diff --git a/apps/web/src/components/files/FilePreviewPanel.test.ts b/apps/web/src/components/files/FilePreviewPanel.test.ts index 5ef590847c4b..f65c9b03ace5 100644 --- a/apps/web/src/components/files/FilePreviewPanel.test.ts +++ b/apps/web/src/components/files/FilePreviewPanel.test.ts @@ -6,6 +6,7 @@ import { remapFileCommentAnnotations, } from "./fileCommentAnnotations"; import { + isDirectoryEntry, isMarkdownPreviewFile, setMarkdownTaskChecked, shouldShowFileExplorer, @@ -120,3 +121,22 @@ describe("setMarkdownTaskChecked", () => { expect(setMarkdownTaskChecked(markdown, 200, true)).toBe(markdown); }); }); + +describe("isDirectoryEntry", () => { + const entries = [ + { kind: "directory" as const, path: ".agents" }, + { kind: "directory" as const, path: ".agents/skills" }, + { kind: "file" as const, path: ".agents/skills/SKILL.md" }, + ]; + + it("recognizes a listed directory with or without a trailing slash", () => { + expect(isDirectoryEntry(entries, ".agents/skills")).toBe(true); + expect(isDirectoryEntry(entries, ".agents/skills/")).toBe(true); + }); + + it("does not treat files or unknown paths as directories", () => { + expect(isDirectoryEntry(entries, ".agents/skills/SKILL.md")).toBe(false); + expect(isDirectoryEntry(entries, "missing")).toBe(false); + expect(isDirectoryEntry(undefined, ".agents")).toBe(false); + }); +}); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index b739d120da63..767b0f7536c7 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -68,6 +68,7 @@ import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { + isDirectoryEntry, isMarkdownPreviewFile, setMarkdownTaskChecked, shouldShowFileExplorer, @@ -76,6 +77,7 @@ import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; import { getOptimisticProjectFileQueryData, setProjectFileQueryData, + useProjectEntriesQuery, useProjectFileQuery, } from "./projectFilesQueryState"; @@ -956,7 +958,7 @@ export default function FilePreviewPanel({ environmentId, cwd, projectName, - relativePath, + relativePath: selectedPath, attachment, threadRef, composerDraftTarget, @@ -980,6 +982,15 @@ export default function FilePreviewPanel({ const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); + // A chat link cannot tell a folder from a file, so a folder arrives as a file + // surface. The tree already knows every entry; a folder is revealed there + // and gets no preview pane instead of a read error. + const entries = useProjectEntriesQuery(environmentId, cwd); + const isDirectory = + selectedPath !== null && + attachment === undefined && + isDirectoryEntry(entries.data?.entries, selectedPath); + const relativePath = isDirectory ? null : selectedPath; const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath); const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath); const isMedia = isImage || isVideo; @@ -1342,7 +1353,7 @@ export default function FilePreviewPanel({ environmentId={environmentId} cwd={cwd} projectName={projectName} - selectedPath={relativePath} + selectedPath={selectedPath} selectedPathRevealId={revealRequestId} onOpenFile={onOpenFile} workspaceMutationId={workspaceMutationId} diff --git a/apps/web/src/components/files/filePreviewMode.ts b/apps/web/src/components/files/filePreviewMode.ts index 9770d36fa2c3..f8b249d8fe07 100644 --- a/apps/web/src/components/files/filePreviewMode.ts +++ b/apps/web/src/components/files/filePreviewMode.ts @@ -13,6 +13,19 @@ export function shouldShowFileExplorer(input: { return input.explorerOpen || input.relativePath === null; } +/** Whether a workspace path names a directory in the listed entries, with or without a trailing slash. */ +export function isDirectoryEntry( + entries: + | ReadonlyArray<{ readonly kind: "file" | "directory"; readonly path: string }> + | undefined, + relativePath: string, +): boolean { + const normalizedPath = relativePath.replace(/\/+$/, ""); + return ( + entries?.some((entry) => entry.kind === "directory" && entry.path === normalizedPath) ?? false + ); +} + export function setMarkdownTaskChecked( markdown: string, markerOffset: number,