History

fix(web): make file breadcrumbs navigable

t3-code[bot] committed 2 months ago

Commit d2e209d1 parent d4847353 files · +100 −3Browse files

Unified · Split

3 files
  1. .macroscope/check-run-agents/ui-consistency.md +1 −0
  2. apps/web/src/components/files/FileBrowserPanel.tsx +78 −0
  3. apps/web/src/components/files/FilePreviewPanel.tsx +21 −3
.macroscope/check-run-agents/ui-consistency.md+1−0
@@ -58,6 +58,7 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put · 57 unmodified lines
5858 - Repeated native or virtualized overflow fades should use the shared virtualized-scroll-fade contract rather than component-named mask selectors.
5959 - Preserve runtime top and bottom overflow state. Do not replace dynamic fades with an always-on static mask.
6060 - Preserve fade geometry and keep the native scrollbar lane opaque so the track and thumb stay visible and usable. A visually similar mask that fades the scrollbar is a regression.
61+- For path-driven tree actions with flattened directory rows, do not assume that every registered item path is currently visible. Resolve the target through the tree's visible projection before selecting, focusing, or scrolling, and verify intermediate directory actions against a real flattened chain.
6162 - Verify actual scroll behavior when changing virtualizers, masks, overflow ownership, or scrollbar selectors. Source-level class comparison is not enough.
6263
6364 ## Visual and layout preservation
apps/web/src/components/files/FileBrowserPanel.tsx+78−0
@@ -30,6 +30,9 @@ interface FileBrowserPanelProps { · 29 unmodified lines
3030 selectedPath: string | null;
3131 /** Bumped when the same path should be revealed again (e.g. re-opened from search). */
3232 selectedPathRevealId: number;
33+ /** Breadcrumb target to reveal in the tree. An empty path reveals the project root. */
34+ breadcrumbRevealPath: string | null;
35+ breadcrumbRevealId: number;
3336 onOpenFile: (relativePath: string) => void;
3437 }
3538
@@ -104,6 +107,8 @@ export default function FileBrowserPanel({ · 68 unmodified lines
104107 projectName,
105108 selectedPath,
106109 selectedPathRevealId,
110+ breadcrumbRevealPath,
111+ breadcrumbRevealId,
107112 onOpenFile,
108113 }: FileBrowserPanelProps) {
109114 const { resolvedTheme } = useTheme();
@@ -120,6 +125,7 @@ export default function FileBrowserPanel({ · 10 unmodified lines
120125 const syncingSelectionRef = useRef(false);
121126 const treeSelectionPathRef = useRef<string | null>(null);
122127 const handledRevealRef = useRef<{ path: string; revealId: number } | null>(null);
128+ const handledBreadcrumbRevealRef = useRef<{ path: string; revealId: number } | null>(null);
123129
124130 // The tree renders rows in shadow DOM and its anchor rect is unreliable, so
125131 // capture the right-click position ourselves; contextmenu is a composed
@@ -319,6 +325,78 @@ export default function FileBrowserPanel({ · 193 unmodified lines
319325 });
320326 }, [entryKinds, model, selectedPath, selectedPathRevealId, treePaths]);
321327
328+ useEffect(() => {
329+ if (breadcrumbRevealPath === null) return;
330+ const revealRequest = { path: breadcrumbRevealPath, revealId: breadcrumbRevealId };
331+ const handledReveal = handledBreadcrumbRevealRef.current;
332+ if (
333+ handledReveal?.path === revealRequest.path &&
334+ handledReveal.revealId === revealRequest.revealId
335+ ) {
336+ return;
337+ }
338+
339+ const entryKind = breadcrumbRevealPath ? entryKinds.get(breadcrumbRevealPath) : "directory";
340+ if (!entryKind) return;
341+ const itemPath =
342+ entryKind === "directory" && breadcrumbRevealPath
343+ ? `${breadcrumbRevealPath}/`
344+ : breadcrumbRevealPath;
345+ const item = itemPath ? (model.getItem(itemPath) ?? model.getItem(breadcrumbRevealPath)) : null;
346+ if ((breadcrumbRevealPath && !item) || (!breadcrumbRevealPath && !treePaths[0])) return;
347+
348+ model.closeSearch();
349+
350+ const segments = breadcrumbRevealPath.split("/").filter(Boolean);
351+ let ancestorPath = "";
352+ for (const segment of entryKind === "directory" ? segments : segments.slice(0, -1)) {
353+ ancestorPath = ancestorPath ? `${ancestorPath}/${segment}` : segment;
354+ const ancestor = model.getItem(`${ancestorPath}/`) ?? model.getItem(ancestorPath);
355+ if (ancestor && "expand" in ancestor) ancestor.expand();
356+ }
357+
358+ let visibleItemPath: string | null = null;
359+ if (itemPath) {
360+ // Hidden segments in a flattened chain resolve to their visible ancestor,
361+ // so probe descendants until the projected row belongs to the clicked directory.
362+ const candidates = [
363+ itemPath,
364+ ...(entryKind === "directory"
365+ ? treePaths.filter(
366+ (path) => path !== itemPath && path.startsWith(itemPath) && path.endsWith("/"),
367+ )
368+ : []),
369+ ];
370+ for (const candidate of candidates) {
371+ const projectedPath = model.focusNearestPath(candidate);
372+ const matchesTarget =
373+ projectedPath === itemPath ||
374+ (entryKind === "directory" && projectedPath?.startsWith(itemPath));
375+ if (projectedPath && matchesTarget) {
376+ visibleItemPath = projectedPath;
377+ break;
378+ }
379+ }
380+ if (!visibleItemPath) return;
381+ }
382+
383+ handledBreadcrumbRevealRef.current = revealRequest;
384+ syncingSelectionRef.current = true;
385+ for (const path of model.getSelectedPaths()) {
386+ model.getItem(path)?.deselect();
387+ }
388+
389+ if (visibleItemPath) {
390+ model.getItem(visibleItemPath)?.select();
391+ model.scrollToPath(visibleItemPath, { focus: true, offset: "center" });
392+ } else if (treePaths[0]) {
393+ model.scrollToPath(treePaths[0], { focus: true, offset: "top" });
394+ }
395+ queueMicrotask(() => {
396+ syncingSelectionRef.current = false;
397+ });
398+ }, [breadcrumbRevealId, breadcrumbRevealPath, entryKinds, model, treePaths]);
399+
322400 // Tag tree drags with the composer mention payload. The row is read from
323401 // the composed event path (the tree's shadow root is open), so this does
324402 // not depend on running after the tree's own dragstart handler; the drag
apps/web/src/components/files/FilePreviewPanel.tsx+21−3
@@ -783,6 +783,9 @@ export default function FilePreviewPanel({ · 782 unmodified lines
783783 const isImage = relativePath !== null && isWorkspaceImagePreviewPath(relativePath);
784784 const file = useProjectFileQuery(environmentId, cwd, relativePath, !isImage);
785785 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
786+ const [breadcrumbReveal, setBreadcrumbReveal] = useState<{ path: string; id: number } | null>(
787+ null,
788+ );
786789 // Reading markdown rendered is a preference, not a property of one file. Keeping
787790 // it on the panel meant a thread switch dropped it and forced source back.
788791 const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage(
@@ -832,6 +835,16 @@ export default function FilePreviewPanel({ · 43 unmodified lines
832835 });
833836 };
834837
838+ const revealBreadcrumb = (path: string) => {
839+ setExplorerOpen(true);
840+ try {
841+ setLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, true, Schema.Boolean);
842+ } catch (error) {
843+ console.error(error);
844+ }
845+ setBreadcrumbReveal((current) => ({ path, id: (current?.id ?? 0) + 1 }));
846+ };
847+
835848 const handleOpenInBrowser = useCallback(() => {
836849 if (!absolutePath || !environmentHttpBaseUrl) return;
837850 void (async () => {
@@ -880,17 +893,20 @@ export default function FilePreviewPanel({ · 42 unmodified lines
880893 {index > 0 ? (
881894 <ChevronRight className="mx-1 size-3.5 shrink-0 text-muted-foreground/60" />
882895 ) : null}
883− <span
896+ <button
897+ type="button"
884898 className={cn(
885− "max-w-40 truncate",
899+ "max-w-40 cursor-pointer truncate rounded-sm px-0.5 hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
886900 crumb.kind === "file"
887901 ? "font-medium text-foreground"
888902 : "text-muted-foreground",
889903 )}
890904 title={crumb.path || projectName}
905+ aria-label={`Reveal ${crumb.label} in file explorer`}
906+ onClick={() => revealBreadcrumb(crumb.path)}
891907 >
892908 {crumb.label}
893− </span>
909+ </button>
894910 </div>
895911 ))}
896912 </div>
@@ -1071,6 +1087,8 @@ export default function FilePreviewPanel({ · 174 unmodified lines
10711087 projectName={projectName}
10721088 selectedPath={relativePath}
10731089 selectedPathRevealId={revealRequestId}
1090+ breadcrumbRevealPath={breadcrumbReveal?.path ?? null}
1091+ breadcrumbRevealId={breadcrumbReveal?.id ?? 0}
10741092 onOpenFile={onOpenFile}
10751093 />
10761094 </aside>