History

fix(web): harden breadcrumb reveal state

t3-code[bot] committed 3 months ago

Commit bfdf3c61 parent d2e209d3 files · +58 −37Browse files

Unified · Split

3 files
  1. .macroscope/check-run-agents/ui-consistency.md +1 −0
  2. apps/web/src/components/files/FileBrowserPanel.tsx +49 −35
  3. apps/web/src/components/files/FilePreviewPanel.tsx +8 −2
.macroscope/check-run-agents/ui-consistency.md+1−0
@@ -59,6 +59,7 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put · 58 unmodified lines
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.
6161 - 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.
62+- Treat external tree-reveal requests as one-shot events: resolve and validate the visible target without mutating search, focus, or selection, then acknowledge or clear the request after success so component remounts cannot replay stale navigation.
6263 - Verify actual scroll behavior when changing virtualizers, masks, overflow ownership, or scrollbar selectors. Source-level class comparison is not enough.
6364
6465 ## Visual and layout preservation
apps/web/src/components/files/FileBrowserPanel.tsx+49−35
@@ -33,6 +33,7 @@ interface FileBrowserPanelProps { · 32 unmodified lines
3333 /** Breadcrumb target to reveal in the tree. An empty path reveals the project root. */
3434 breadcrumbRevealPath: string | null;
3535 breadcrumbRevealId: number;
36+ onBreadcrumbRevealHandled: (revealId: number) => void;
3637 onOpenFile: (relativePath: string) => void;
3738 }
3839
@@ -52,6 +53,22 @@ function treePath(entry: ProjectEntry): string { · 13 unmodified lines
5253 return entry.kind === "directory" ? `${entry.path}/` : entry.path;
5354 }
5455
56+function visibleDirectoryTreePath(entries: readonly ProjectEntry[], directoryPath: string): string {
57+ // Pierre flattens a directory row only while its sole direct child is another directory.
58+ let currentPath = directoryPath;
59+ while (true) {
60+ const prefix = `${currentPath}/`;
61+ const directChildren = entries.filter((entry) => {
62+ if (!entry.path.startsWith(prefix)) return false;
63+ return !entry.path.slice(prefix.length).includes("/");
64+ });
65+ if (directChildren.length !== 1 || directChildren[0]?.kind !== "directory") {
66+ return `${currentPath}/`;
67+ }
68+ currentPath = directChildren[0].path;
69+ }
70+}
71+
5572 function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void }) {
5673 return (
5774 <Tooltip>
@@ -109,6 +126,7 @@ export default function FileBrowserPanel({ · 51 unmodified lines
109126 selectedPathRevealId,
110127 breadcrumbRevealPath,
111128 breadcrumbRevealId,
129+ onBreadcrumbRevealHandled,
112130 onOpenFile,
113131 }: FileBrowserPanelProps) {
114132 const { resolvedTheme } = useTheme();
@@ -343,9 +361,27 @@ export default function FileBrowserPanel({ · 228 unmodified lines
343361 ? `${breadcrumbRevealPath}/`
344362 : breadcrumbRevealPath;
345363 const item = itemPath ? (model.getItem(itemPath) ?? model.getItem(breadcrumbRevealPath)) : null;
346− if ((breadcrumbRevealPath && !item) || (!breadcrumbRevealPath && !treePaths[0])) return;
364+ const firstEntry = entries[0];
365+ const rootItemPath = firstEntry
366+ ? firstEntry.kind === "directory"
367+ ? visibleDirectoryTreePath(entries, firstEntry.path)
368+ : firstEntry.path
369+ : null;
370+ if ((breadcrumbRevealPath && !item) || (!breadcrumbRevealPath && !rootItemPath)) return;
371+
372+ const visibleItemPath = itemPath
373+ ? entryKind === "directory"
374+ ? visibleDirectoryTreePath(entries, breadcrumbRevealPath)
375+ : itemPath
376+ : null;
377+ if (visibleItemPath && !model.getItem(visibleItemPath)) return;
347378
379+ handledBreadcrumbRevealRef.current = revealRequest;
380+ syncingSelectionRef.current = true;
348381 model.closeSearch();
382+ for (const path of model.getSelectedPaths()) {
383+ model.getItem(path)?.deselect();
384+ }
349385
350386 const segments = breadcrumbRevealPath.split("/").filter(Boolean);
351387 let ancestorPath = "";
@@ -355,47 +391,25 @@ export default function FileBrowserPanel({ · 3 unmodified lines
355391 if (ancestor && "expand" in ancestor) ancestor.expand();
356392 }
357393
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−
389394 if (visibleItemPath) {
390395 model.getItem(visibleItemPath)?.select();
391396 model.scrollToPath(visibleItemPath, { focus: true, offset: "center" });
392− } else if (treePaths[0]) {
393− model.scrollToPath(treePaths[0], { focus: true, offset: "top" });
397+ } else if (rootItemPath) {
398+ model.scrollToPath(rootItemPath, { focus: true, offset: "top" });
394399 }
400+ onBreadcrumbRevealHandled(breadcrumbRevealId);
395401 queueMicrotask(() => {
396402 syncingSelectionRef.current = false;
397403 });
398− }, [breadcrumbRevealId, breadcrumbRevealPath, entryKinds, model, treePaths]);
404+ }, [
405+ breadcrumbRevealId,
406+ breadcrumbRevealPath,
407+ entries,
408+ entryKinds,
409+ model,
410+ onBreadcrumbRevealHandled,
411+ treePaths,
412+ ]);
399413
400414 // Tag tree drags with the composer mention payload. The row is read from
401415 // the composed event path (the tree's shadow root is open), so this does
apps/web/src/components/files/FilePreviewPanel.tsx+8−2
@@ -786,6 +786,7 @@ export default function FilePreviewPanel({ · 785 unmodified lines
786786 const [breadcrumbReveal, setBreadcrumbReveal] = useState<{ path: string; id: number } | null>(
787787 null,
788788 );
789+ const breadcrumbRevealIdRef = useRef(0);
789790 // Reading markdown rendered is a preference, not a property of one file. Keeping
790791 // it on the panel meant a thread switch dropped it and forced source back.
791792 const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage(
@@ -842,8 +843,12 @@ export default function FilePreviewPanel({ · 50 unmodified lines
842843 } catch (error) {
843844 console.error(error);
844845 }
845− setBreadcrumbReveal((current) => ({ path, id: (current?.id ?? 0) + 1 }));
846+ breadcrumbRevealIdRef.current += 1;
847+ setBreadcrumbReveal({ path, id: breadcrumbRevealIdRef.current });
846848 };
849+ const handleBreadcrumbRevealHandled = useCallback((revealId: number) => {
850+ setBreadcrumbReveal((current) => (current?.id === revealId ? null : current));
851+ }, []);
847852
848853 const handleOpenInBrowser = useCallback(() => {
849854 if (!absolutePath || !environmentHttpBaseUrl) return;
@@ -896,7 +901,7 @@ export default function FilePreviewPanel({ · 46 unmodified lines
896901 <button
897902 type="button"
898903 className={cn(
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",
904+ "max-w-40 cursor-pointer truncate rounded-sm px-0.5 text-left hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
900905 crumb.kind === "file"
901906 ? "font-medium text-foreground"
902907 : "text-muted-foreground",
@@ -1089,6 +1094,7 @@ export default function FilePreviewPanel({ · 186 unmodified lines
10891094 selectedPathRevealId={revealRequestId}
10901095 breadcrumbRevealPath={breadcrumbReveal?.path ?? null}
10911096 breadcrumbRevealId={breadcrumbReveal?.id ?? 0}
1097+ onBreadcrumbRevealHandled={handleBreadcrumbRevealHandled}
10921098 onOpenFile={onOpenFile}
10931099 />
10941100 </aside>

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t