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
58- Repeated native or virtualized overflow fades should use the shared virtualized-scroll-fade contract rather than component-named mask selectors.58- Repeated native or virtualized overflow fades should use the shared virtualized-scroll-fade contract rather than component-named mask selectors.
59- Preserve runtime top and bottom overflow state. Do not replace dynamic fades with an always-on static mask.59- Preserve runtime top and bottom overflow state. Do not replace dynamic fades with an always-on static mask.
60- 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.60- 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.
61- Verify actual scroll behavior when changing virtualizers, masks, overflow ownership, or scrollbar selectors. Source-level class comparison is not enough.62- Verify actual scroll behavior when changing virtualizers, masks, overflow ownership, or scrollbar selectors. Source-level class comparison is not enough.
62 63
63## Visual and layout preservation64## Visual and layout preservation
apps/web/src/components/files/FileBrowserPanel.tsx+78−0
@@ -30,6 +30,9 @@ interface FileBrowserPanelProps { · 29 unmodified lines
30 selectedPath: string | null;30 selectedPath: string | null;
31 /** Bumped when the same path should be revealed again (e.g. re-opened from search). */31 /** Bumped when the same path should be revealed again (e.g. re-opened from search). */
32 selectedPathRevealId: number;32 selectedPathRevealId: number;
33 /** Breadcrumb target to reveal in the tree. An empty path reveals the project root. */
34 breadcrumbRevealPath: string | null;
35 breadcrumbRevealId: number;
33 onOpenFile: (relativePath: string) => void;36 onOpenFile: (relativePath: string) => void;
34}37}
35 38
@@ -104,6 +107,8 @@ export default function FileBrowserPanel({ · 68 unmodified lines
104 projectName,107 projectName,
105 selectedPath,108 selectedPath,
106 selectedPathRevealId,109 selectedPathRevealId,
110 breadcrumbRevealPath,
111 breadcrumbRevealId,
107 onOpenFile,112 onOpenFile,
108}: FileBrowserPanelProps) {113}: FileBrowserPanelProps) {
109 const { resolvedTheme } = useTheme();114 const { resolvedTheme } = useTheme();
@@ -120,6 +125,7 @@ export default function FileBrowserPanel({ · 10 unmodified lines
120 const syncingSelectionRef = useRef(false);125 const syncingSelectionRef = useRef(false);
121 const treeSelectionPathRef = useRef<string | null>(null);126 const treeSelectionPathRef = useRef<string | null>(null);
122 const handledRevealRef = useRef<{ path: string; revealId: number } | null>(null);127 const handledRevealRef = useRef<{ path: string; revealId: number } | null>(null);
128 const handledBreadcrumbRevealRef = useRef<{ path: string; revealId: number } | null>(null);
123 129
124 // The tree renders rows in shadow DOM and its anchor rect is unreliable, so130 // The tree renders rows in shadow DOM and its anchor rect is unreliable, so
125 // capture the right-click position ourselves; contextmenu is a composed131 // capture the right-click position ourselves; contextmenu is a composed
@@ -319,6 +325,78 @@ export default function FileBrowserPanel({ · 193 unmodified lines
319 });325 });
320 }, [entryKinds, model, selectedPath, selectedPathRevealId, treePaths]);326 }, [entryKinds, model, selectedPath, selectedPathRevealId, treePaths]);
321 327
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
322 // Tag tree drags with the composer mention payload. The row is read from400 // Tag tree drags with the composer mention payload. The row is read from
323 // the composed event path (the tree's shadow root is open), so this does401 // the composed event path (the tree's shadow root is open), so this does
324 // not depend on running after the tree's own dragstart handler; the drag402 // 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
783 const isImage = relativePath !== null && isWorkspaceImagePreviewPath(relativePath);783 const isImage = relativePath !== null && isWorkspaceImagePreviewPath(relativePath);
784 const file = useProjectFileQuery(environmentId, cwd, relativePath, !isImage);784 const file = useProjectFileQuery(environmentId, cwd, relativePath, !isImage);
785 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);785 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
786 const [breadcrumbReveal, setBreadcrumbReveal] = useState<{ path: string; id: number } | null>(
787 null,
788 );
786 // Reading markdown rendered is a preference, not a property of one file. Keeping789 // Reading markdown rendered is a preference, not a property of one file. Keeping
787 // it on the panel meant a thread switch dropped it and forced source back.790 // it on the panel meant a thread switch dropped it and forced source back.
788 const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage(791 const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage(
@@ -832,6 +835,16 @@ export default function FilePreviewPanel({ · 43 unmodified lines
832 });835 });
833 };836 };
834 837
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
835 const handleOpenInBrowser = useCallback(() => {848 const handleOpenInBrowser = useCallback(() => {
836 if (!absolutePath || !environmentHttpBaseUrl) return;849 if (!absolutePath || !environmentHttpBaseUrl) return;
837 void (async () => {850 void (async () => {
@@ -880,17 +893,20 @@ export default function FilePreviewPanel({ · 42 unmodified lines
880 {index > 0 ? (893 {index > 0 ? (
881 <ChevronRight className="mx-1 size-3.5 shrink-0 text-muted-foreground/60" />894 <ChevronRight className="mx-1 size-3.5 shrink-0 text-muted-foreground/60" />
882 ) : null}895 ) : null}
883 <span896 <button
897 type="button"
884 className={cn(898 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",
886 crumb.kind === "file"900 crumb.kind === "file"
887 ? "font-medium text-foreground"901 ? "font-medium text-foreground"
888 : "text-muted-foreground",902 : "text-muted-foreground",
889 )}903 )}
890 title={crumb.path || projectName}904 title={crumb.path || projectName}
905 aria-label={`Reveal ${crumb.label} in file explorer`}
906 onClick={() => revealBreadcrumb(crumb.path)}
891 >907 >
892 {crumb.label}908 {crumb.label}
893 </span>909 </button>
894 </div>910 </div>
895 ))}911 ))}
896 </div>912 </div>
@@ -1071,6 +1087,8 @@ export default function FilePreviewPanel({ · 174 unmodified lines
1071 projectName={projectName}1087 projectName={projectName}
1072 selectedPath={relativePath}1088 selectedPath={relativePath}
1073 selectedPathRevealId={revealRequestId}1089 selectedPathRevealId={revealRequestId}
1090 breadcrumbRevealPath={breadcrumbReveal?.path ?? null}
1091 breadcrumbRevealId={breadcrumbReveal?.id ?? 0}
1074 onOpenFile={onOpenFile}1092 onOpenFile={onOpenFile}
1075 />1093 />
1076 </aside>1094 </aside>