authorgravatar for 160283456+404khai@users.noreply.github.com404ҜĦΔƗ <160283456+404khai@users.noreply.github.com> 2026-09-02 04:15:04+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 23:15:04-04:00
log47a95332a2b8fe47da9fb0af97fd6f0b542a18ee
treec14925537a46aad8b70df4ebd618c33d620a2e98
parentfea1af81fdab64a19353224d60b4efa60f929740
signature Signed by PGP key B5690EEEBB952194

fix(web): browse folders from file breadcrumbs (#8910)

Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com> Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

5 files changed, 431 insertions(+), 40 deletions(-)

apps/web/src/components/files/FileBreadcrumbs.tsx created+276
......@@ -0,0 +1,276 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import { ArrowLeftIcon, ChevronRightIcon, LoaderCircleIcon, RotateCwIcon } from "lucide-react";
3import { useEffect, useMemo, useState } from "react";
4
5import { PierreEntryIcon } from "~/components/chat/PierreEntryIcon";
6import {
7 Menu,
8 MenuGroup,
9 MenuItem,
10 MenuPopup,
11 MenuSeparator,
12 MenuTrigger,
13} from "~/components/ui/menu";
14import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
15import { useTheme } from "~/hooks/useTheme";
16import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
17import { cn } from "~/lib/utils";
18import { isAbsolutePath } from "~/terminal-links";
19
20import {
21 type FileBreadcrumb,
22 fileBreadcrumbChildren,
23 fileBreadcrumbParent,
24 fileBreadcrumbs,
25} from "./filePath";
26import { useProjectEntriesQuery } from "./projectFilesQueryState";
27
28interface FileBreadcrumbsProps {
29 readonly cwd: string;
30 readonly environmentId: EnvironmentId;
31 readonly onOpenFile: (relativePath: string) => void;
32 readonly projectName: string;
33 readonly relativePath: string;
34 readonly workspaceMutationId: string | null;
35}
36
37function pathLabel(path: string, projectName: string): string {
38 return path.slice(path.lastIndexOf("/") + 1) || projectName;
39}
40
41function BreadcrumbLabel(props: {
42 readonly current?: boolean;
43 readonly label: string;
44 readonly pathLabel: string;
45}) {
46 return (
47 <Tooltip>
48 <TooltipTrigger
49 render={
50 <span
51 className={cn(
52 "block max-w-40 truncate rounded-sm px-0.5",
53 props.current ? "font-medium text-foreground" : "text-muted-foreground",
54 )}
55 />
56 }
57 >
58 {props.label}
59 </TooltipTrigger>
60 <TooltipPopup side="top" className="max-w-80">
61 {props.pathLabel}
62 </TooltipPopup>
63 </Tooltip>
64 );
65}
66
67function BreadcrumbMenuContent(props: {
68 readonly cwd: string;
69 readonly currentFilePath: string;
70 readonly directoryPath: string;
71 readonly environmentId: EnvironmentId;
72 readonly onDirectoryChange: (path: string) => void;
73 readonly onOpenChange: (open: boolean) => void;
74 readonly onOpenFile: (path: string) => void;
75 readonly projectName: string;
76 readonly rootPath: string;
77 readonly workspaceMutationId: string | null;
78}) {
79 const entriesQuery = useProjectEntriesQuery(props.environmentId, props.cwd);
80 useWorkspaceMutationRefresh({
81 mutationId: props.workspaceMutationId,
82 refresh: entriesQuery.refresh,
83 resourceKey: `files:${props.environmentId}:${props.cwd}`,
84 });
85 const { resolvedTheme } = useTheme();
86 const entries = entriesQuery.data?.entries ?? [];
87 const entriesTruncated = entriesQuery.data?.truncated ?? false;
88 const children = useMemo(
89 () => fileBreadcrumbChildren(entries, props.directoryPath),
90 [entries, props.directoryPath],
91 );
92 const directoryAvailable =
93 props.directoryPath === "" ||
94 entries.some((entry) => entry.kind === "directory" && entry.path === props.directoryPath);
95 const parentPath = fileBreadcrumbParent(props.directoryPath);
96 const canGoBack =
97 props.directoryPath !== props.rootPath &&
98 parentPath !== null &&
99 (props.rootPath === "" ||
100 parentPath === props.rootPath ||
101 parentPath.startsWith(`${props.rootPath}/`));
102
103 return (
104 <MenuPopup
105 align="start"
106 side="bottom"
107 className="w-max min-w-32 max-w-[min(19rem,var(--available-width))]"
108 onKeyDown={(event) => {
109 if (event.key !== "ArrowLeft" || !canGoBack || parentPath === null) return;
110 event.preventDefault();
111 event.stopPropagation();
112 props.onDirectoryChange(parentPath);
113 }}
114 >
115 {canGoBack && parentPath !== null ? (
116 <>
117 <MenuItem closeOnClick={false} onClick={() => props.onDirectoryChange(parentPath)}>
118 <ArrowLeftIcon />
119 <span className="truncate">Back to {pathLabel(parentPath, props.projectName)}</span>
120 </MenuItem>
121 <MenuSeparator />
122 </>
123 ) : null}
124 <MenuGroup key={props.directoryPath}>
125 {entriesQuery.isPending && entriesQuery.data === null ? (
126 <MenuItem disabled>
127 <LoaderCircleIcon className="animate-spin" />
128 Loading folder…
129 </MenuItem>
130 ) : entriesQuery.error && entriesQuery.data === null ? (
131 <MenuItem closeOnClick={false} onClick={entriesQuery.refresh}>
132 <RotateCwIcon />
133 <span className="min-w-0 flex-1 truncate">Retry loading folder</span>
134 </MenuItem>
135 ) : !directoryAvailable && !entriesTruncated ? (
136 <MenuItem disabled>This folder is no longer available.</MenuItem>
137 ) : children.length === 0 ? (
138 <MenuItem disabled>
139 {entriesTruncated
140 ? "No entries from this folder are available in the partial workspace index."
141 : "This folder is empty."}
142 </MenuItem>
143 ) : (
144 children.map((entry) => {
145 const isCurrentFile = entry.kind === "file" && entry.path === props.currentFilePath;
146 return (
147 <MenuItem
148 key={entry.path}
149 closeOnClick={entry.kind === "file"}
150 aria-current={isCurrentFile ? "page" : undefined}
151 className={cn(isCurrentFile && "bg-foreground/[0.08]")}
152 onClick={() => {
153 if (entry.kind === "directory") {
154 props.onDirectoryChange(entry.path);
155 return;
156 }
157 props.onOpenChange(false);
158 props.onOpenFile(entry.path);
159 }}
160 >
161 <PierreEntryIcon pathValue={entry.path} kind={entry.kind} theme={resolvedTheme} />
162 <Tooltip>
163 <TooltipTrigger render={<span className="min-w-0 flex-1 truncate" />}>
164 {entry.label}
165 </TooltipTrigger>
166 <TooltipPopup side="right" className="max-w-80">
167 {entry.path}
168 </TooltipPopup>
169 </Tooltip>
170 {entry.kind === "directory" ? <ChevronRightIcon /> : null}
171 </MenuItem>
172 );
173 })
174 )}
175 </MenuGroup>
176 {entriesQuery.error && entriesQuery.data !== null ? (
177 <>
178 <MenuSeparator />
179 <MenuItem closeOnClick={false} onClick={entriesQuery.refresh}>
180 <RotateCwIcon />
181 Refresh failed — retry
182 </MenuItem>
183 </>
184 ) : null}
185 {entriesTruncated ? (
186 <>
187 <MenuSeparator />
188 <MenuItem disabled>Some workspace entries are not shown.</MenuItem>
189 </>
190 ) : null}
191 </MenuPopup>
192 );
193}
194
195function DirectoryBreadcrumb(props: FileBreadcrumbsProps & { readonly crumb: FileBreadcrumb }) {
196 const [open, setOpen] = useState(false);
197 const [directoryPath, setDirectoryPath] = useState(props.crumb.path);
198
199 useEffect(() => {
200 setOpen(false);
201 setDirectoryPath(props.crumb.path);
202 }, [props.crumb.path, props.relativePath]);
203
204 const handleOpenChange = (nextOpen: boolean) => {
205 setOpen(nextOpen);
206 if (nextOpen) setDirectoryPath(props.crumb.path);
207 };
208
209 return (
210 <Menu open={open} onOpenChange={handleOpenChange}>
211 <Tooltip>
212 <TooltipTrigger
213 render={
214 <MenuTrigger
215 render={
216 <button
217 type="button"
218 aria-label={`Browse ${props.crumb.label}`}
219 className="relative block max-w-40 cursor-pointer rounded-sm px-0.5 text-left text-muted-foreground outline-none pointer-coarse:after:-inset-y-3 pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring data-popup-open:bg-accent data-popup-open:text-foreground"
220 />
221 }
222 />
223 }
224 >
225 <span className="block truncate">{props.crumb.label}</span>
226 </TooltipTrigger>
227 <TooltipPopup side="top" className="max-w-80">
228 {props.crumb.path || props.projectName}
229 </TooltipPopup>
230 </Tooltip>
231 {open ? (
232 <BreadcrumbMenuContent
233 cwd={props.cwd}
234 currentFilePath={props.relativePath}
235 directoryPath={directoryPath}
236 environmentId={props.environmentId}
237 onDirectoryChange={setDirectoryPath}
238 onOpenChange={handleOpenChange}
239 onOpenFile={props.onOpenFile}
240 projectName={props.projectName}
241 rootPath={props.crumb.path}
242 workspaceMutationId={props.workspaceMutationId}
243 />
244 ) : null}
245 </Menu>
246 );
247}
248
249export function FileBreadcrumbs(props: FileBreadcrumbsProps) {
250 const hostPath = isAbsolutePath(props.relativePath);
251 const breadcrumbs = useMemo(
252 () => fileBreadcrumbs(props.projectName, props.relativePath),
253 [props.projectName, props.relativePath],
254 );
255
256 return breadcrumbs.map((crumb, index) => (
257 <div
258 key={crumb.path || "project"}
259 className="flex min-w-0 shrink-0 items-center"
260 data-current-file-crumb={crumb.kind === "file"}
261 >
262 {index > 0 ? (
263 <ChevronRightIcon className="mx-1 size-3.5 shrink-0 text-muted-foreground/60" />
264 ) : null}
265 {crumb.kind === "file" ? (
266 <span aria-current="page">
267 <BreadcrumbLabel current label={crumb.label} pathLabel={crumb.path} />
268 </span>
269 ) : hostPath ? (
270 <BreadcrumbLabel label={crumb.label} pathLabel={crumb.path} />
271 ) : (
272 <DirectoryBreadcrumb {...props} crumb={crumb} />
273 )}
274 </div>
275 ));
276}
apps/web/src/components/files/FilePreviewPanel.tsx+10-36
......@@ -16,7 +16,7 @@ import {
1616 squashAtomCommandFailure,
1717} from "@t3tools/client-runtime/state/runtime";
1818import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
19import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react";
19import { Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react";
2020import * as Schema from "effect/Schema";
2121import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2222
......@@ -49,6 +49,7 @@ import { useAtomCommand } from "~/state/use-atom-command";
4949import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
5050
5151import FileBrowserPanel from "./FileBrowserPanel";
52import { FileBreadcrumbs } from "./FileBreadcrumbs";
5253import { FileMarkdownPreview } from "./FileMarkdownPreview";
5354import {
5455 type FileCommentAnnotationEntry,
......@@ -63,7 +64,6 @@ import { installFileEditorDismissal } from "./fileEditorDismissal";
6364import { resolveCenteredFileLineScrollTop } from "./fileLineReveal";
6465import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";
6566import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision";
66import { fileBreadcrumbs } from "./filePath";
6767import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode";
6868import { FileSaveCoordinator } from "./fileSaveCoordinator";
6969import {
......@@ -991,10 +991,6 @@ export default function FilePreviewPanel({
991991 isPreviewSupportedInRuntime() &&
992992 isBrowserPreviewFile(relativePath);
993993 const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null;
994 const breadcrumbs = useMemo(
995 () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []),
996 [projectName, relativePath],
997 );
998994 const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId);
999995 useWorkspaceMutationRefresh({
1000996 enabled: relativePath !== null && !isMedia && !isPdf && !selectedFilePending,
......@@ -1062,36 +1058,14 @@ export default function FilePreviewPanel({
10621058 data-file-breadcrumbs
10631059 >
10641060 <div className="flex h-full w-max min-w-full items-center text-xs">
1065 {breadcrumbs.map((crumb, index) => (
1066 <div
1067 key={crumb.path || "project"}
1068 className="flex min-w-0 shrink-0 items-center"
1069 data-current-file-crumb={crumb.kind === "file"}
1070 >
1071 {index > 0 ? (
1072 <ChevronRight className="mx-1 size-3.5 shrink-0 text-muted-foreground/60" />
1073 ) : null}
1074 <Tooltip>
1075 <TooltipTrigger
1076 render={
1077 <span
1078 className={cn(
1079 "max-w-40 truncate",
1080 crumb.kind === "file"
1081 ? "font-medium text-foreground"
1082 : "text-muted-foreground",
1083 )}
1084 />
1085 }
1086 >
1087 {crumb.label}
1088 </TooltipTrigger>
1089 <TooltipPopup side="top" className="max-w-80">
1090 {crumb.path || projectName}
1091 </TooltipPopup>
1092 </Tooltip>
1093 </div>
1094 ))}
1061 <FileBreadcrumbs
1062 cwd={cwd}
1063 environmentId={environmentId}
1064 onOpenFile={onOpenFile}
1065 projectName={projectName}
1066 relativePath={relativePath}
1067 workspaceMutationId={workspaceMutationId}
1068 />
10951069 </div>
10961070 </ScrollArea>
10971071 {absolutePath &&
apps/web/src/components/files/filePath.test.ts+50-1
......@@ -1,6 +1,6 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { fileBreadcrumbs } from "./filePath";
3import { fileBreadcrumbChildren, fileBreadcrumbParent, fileBreadcrumbs } from "./filePath";
44
55describe("fileBreadcrumbs", () => {
66 it("builds project, directory, and file crumbs", () => {
......@@ -39,3 +39,52 @@ describe("fileBreadcrumbs", () => {
3939 ]);
4040 });
4141});
42
43describe("fileBreadcrumbChildren", () => {
44 const entries = [
45 { path: "README.md", kind: "file" as const },
46 { path: "src", kind: "directory" as const },
47 { path: "src-old", kind: "directory" as const },
48 { path: "src/index.ts", kind: "file" as const },
49 { path: "src/lib", kind: "directory" as const },
50 { path: "src/lib/file10.ts", kind: "file" as const },
51 { path: "src/lib/file2.ts", kind: "file" as const },
52 { path: "src-old/index.ts", kind: "file" as const },
53 ];
54
55 it("returns only the immediate children of the project root", () => {
56 expect(fileBreadcrumbChildren(entries, "")).toEqual([
57 { path: "src", kind: "directory", label: "src" },
58 { path: "src-old", kind: "directory", label: "src-old" },
59 { path: "README.md", kind: "file", label: "README.md" },
60 ]);
61 });
62
63 it("honors segment boundaries and sorts folders before files", () => {
64 expect(fileBreadcrumbChildren(entries, "src")).toEqual([
65 { path: "src/lib", kind: "directory", label: "lib" },
66 { path: "src/index.ts", kind: "file", label: "index.ts" },
67 ]);
68 });
69
70 it("uses natural file-name ordering", () => {
71 expect(fileBreadcrumbChildren(entries, "src/lib").map((entry) => entry.label)).toEqual([
72 "file2.ts",
73 "file10.ts",
74 ]);
75 });
76
77 it("returns an empty list for an empty or missing directory", () => {
78 expect(fileBreadcrumbChildren(entries, "missing")).toEqual([]);
79 });
80});
81
82describe("fileBreadcrumbParent", () => {
83 it.each([
84 ["src/lib", "src"],
85 ["src", ""],
86 ["", null],
87 ])("returns the parent of %j", (path, expected) => {
88 expect(fileBreadcrumbParent(path)).toBe(expected);
89 });
90});
apps/web/src/components/files/filePath.ts+32
......@@ -1,3 +1,4 @@
1import type { ProjectEntry } from "@t3tools/contracts";
12import { isWindowsAbsolutePath } from "@t3tools/shared/path";
23
34import { isAbsolutePath } from "~/terminal-links";
......@@ -8,6 +9,10 @@ export interface FileBreadcrumb {
89 kind: "project" | "directory" | "file";
910}
1011
12export interface FileBreadcrumbChild extends ProjectEntry {
13 label: string;
14}
15
1116/**
1217 * Crumbs for a workspace-relative path start at the project. An absolute host
1318 * path is outside the workspace, so its crumbs start at the filesystem root.
......@@ -26,3 +31,30 @@ export function fileBreadcrumbs(projectName: string, relativePath: string): File
2631 })),
2732 ];
2833}
34
35export function fileBreadcrumbChildren(
36 entries: readonly ProjectEntry[],
37 directoryPath: string,
38): FileBreadcrumbChild[] {
39 const prefix = directoryPath ? `${directoryPath}/` : "";
40 return entries
41 .flatMap((entry) => {
42 if (!entry.path.startsWith(prefix)) return [];
43 const label = entry.path.slice(prefix.length);
44 if (!label || label.includes("/")) return [];
45 return [{ ...entry, label }];
46 })
47 .toSorted((left, right) => {
48 if (left.kind !== right.kind) return left.kind === "directory" ? -1 : 1;
49 return left.label.localeCompare(right.label, undefined, {
50 numeric: true,
51 sensitivity: "base",
52 });
53 });
54}
55
56export function fileBreadcrumbParent(directoryPath: string): string | null {
57 if (!directoryPath) return null;
58 const separatorIndex = directoryPath.lastIndexOf("/");
59 return separatorIndex === -1 ? "" : directoryPath.slice(0, separatorIndex);
60}
apps/web/src/components/files/projectFilesQueryState.test.tsx+63-3
......@@ -1,4 +1,8 @@
1import { EnvironmentId, type ProjectReadFileResult } from "@t3tools/contracts";
1import {
2 EnvironmentId,
3 type ProjectListEntriesResult,
4 type ProjectReadFileResult,
5} from "@t3tools/contracts";
26import * as Effect from "effect/Effect";
37import { Atom, AtomRegistry } from "effect/unstable/reactivity";
48import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
......@@ -71,7 +75,7 @@ vi.mock("~/state/queries", () => ({
7175}));
7276
7377import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
74import { useProjectFileQuery } from "./projectFilesQueryState";
78import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState";
7579
7680const environmentId = EnvironmentId.make("environment-1");
7781
......@@ -92,13 +96,20 @@ function file(contents: string): ProjectReadFileResult {
9296 };
9397}
9498
99function projectEntries(paths: readonly string[]): ProjectListEntriesResult {
100 return {
101 entries: paths.map((path) => ({ path, kind: "file" })),
102 truncated: false,
103 };
104}
105
95106async function flushEffects(): Promise<void> {
96107 await Promise.resolve();
97108 await Promise.resolve();
98109 await Promise.resolve();
99110}
100111
101describe("project file query refresh", () => {
112describe("project query refresh", () => {
102113 beforeEach(() => {
103114 projectMocks.listEntries.mockReset();
104115 projectMocks.optimisticFile.mockReset();
......@@ -158,6 +169,55 @@ describe("project file query refresh", () => {
158169 }
159170 });
160171
172 it("revalidates cached entries when a workspace mutation is observed after mounting", async () => {
173 const requests: Array<ReturnType<typeof deferred<ProjectListEntriesResult>>> = [];
174 const entriesAtom = Atom.make(
175 Effect.promise(() => {
176 const request = deferred<ProjectListEntriesResult>();
177 requests.push(request);
178 return request.promise;
179 }),
180 ).pipe(Atom.swr({ staleTime: 30_000, revalidateOnMount: true }));
181 const registry = AtomRegistry.make();
182 const unmount = registry.mount(entriesAtom);
183 projectMocks.listEntries.mockReturnValue(entriesAtom);
184 atomHooks.registry = registry;
185 let renderedPaths: readonly string[] = [];
186
187 const render = (mutationId: string | null) => {
188 reactHooks.beginRender();
189 const query = useProjectEntriesQuery(environmentId, "/repo");
190 renderedPaths = query.data?.entries.map((entry) => entry.path) ?? [];
191 useWorkspaceMutationRefresh({
192 mutationId,
193 refresh: query.refresh,
194 resourceKey: "files:environment-1:/repo",
195 });
196 };
197
198 try {
199 await flushEffects();
200 expect(requests).toHaveLength(1);
201 requests[0]!.resolve(projectEntries(["src/old.ts"]));
202 await flushEffects();
203
204 render("mutation-1");
205 expect(renderedPaths).toEqual(["src/old.ts"]);
206 await flushEffects();
207 expect(requests).toHaveLength(2);
208
209 requests[1]!.resolve(projectEntries(["src/new.ts"]));
210 await flushEffects();
211 render("mutation-1");
212 expect(renderedPaths).toEqual(["src/new.ts"]);
213 expect(requests).toHaveLength(2);
214 } finally {
215 unmount();
216 registry.dispose();
217 atomHooks.registry = null;
218 }
219 });
220
161221 it("does not issue a file read for a disabled image preview", async () => {
162222 const requests: Array<ReturnType<typeof deferred<ProjectReadFileResult>>> = [];
163223 const readAtom = Atom.make(