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
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- 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.
62- Verify actual scroll behavior when changing virtualizers, masks, overflow ownership, or scrollbar selectors. Source-level class comparison is not enough.63- Verify actual scroll behavior when changing virtualizers, masks, overflow ownership, or scrollbar selectors. Source-level class comparison is not enough.
63 64
64## Visual and layout preservation65## Visual and layout preservation
apps/web/src/components/files/FileBrowserPanel.tsx+49−35
@@ -33,6 +33,7 @@ interface FileBrowserPanelProps { · 32 unmodified lines
33 /** Breadcrumb target to reveal in the tree. An empty path reveals the project root. */33 /** Breadcrumb target to reveal in the tree. An empty path reveals the project root. */
34 breadcrumbRevealPath: string | null;34 breadcrumbRevealPath: string | null;
35 breadcrumbRevealId: number;35 breadcrumbRevealId: number;
36 onBreadcrumbRevealHandled: (revealId: number) => void;
36 onOpenFile: (relativePath: string) => void;37 onOpenFile: (relativePath: string) => void;
37}38}
38 39
@@ -52,6 +53,22 @@ function treePath(entry: ProjectEntry): string { · 13 unmodified lines
52 return entry.kind === "directory" ? `${entry.path}/` : entry.path;53 return entry.kind === "directory" ? `${entry.path}/` : entry.path;
53}54}
54 55
56function 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
55function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void }) {72function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void }) {
56 return (73 return (
57 <Tooltip>74 <Tooltip>
@@ -109,6 +126,7 @@ export default function FileBrowserPanel({ · 51 unmodified lines
109 selectedPathRevealId,126 selectedPathRevealId,
110 breadcrumbRevealPath,127 breadcrumbRevealPath,
111 breadcrumbRevealId,128 breadcrumbRevealId,
129 onBreadcrumbRevealHandled,
112 onOpenFile,130 onOpenFile,
113}: FileBrowserPanelProps) {131}: FileBrowserPanelProps) {
114 const { resolvedTheme } = useTheme();132 const { resolvedTheme } = useTheme();
@@ -343,9 +361,27 @@ export default function FileBrowserPanel({ · 228 unmodified lines
343 ? `${breadcrumbRevealPath}/`361 ? `${breadcrumbRevealPath}/`
344 : breadcrumbRevealPath;362 : breadcrumbRevealPath;
345 const item = itemPath ? (model.getItem(itemPath) ?? model.getItem(breadcrumbRevealPath)) : null;363 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;
347 378
379 handledBreadcrumbRevealRef.current = revealRequest;
380 syncingSelectionRef.current = true;
348 model.closeSearch();381 model.closeSearch();
382 for (const path of model.getSelectedPaths()) {
383 model.getItem(path)?.deselect();
384 }
349 385
350 const segments = breadcrumbRevealPath.split("/").filter(Boolean);386 const segments = breadcrumbRevealPath.split("/").filter(Boolean);
351 let ancestorPath = "";387 let ancestorPath = "";
@@ -355,47 +391,25 @@ export default function FileBrowserPanel({ · 3 unmodified lines
355 if (ancestor && "expand" in ancestor) ancestor.expand();391 if (ancestor && "expand" in ancestor) ancestor.expand();
356 }392 }
357 393
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) {394 if (visibleItemPath) {
390 model.getItem(visibleItemPath)?.select();395 model.getItem(visibleItemPath)?.select();
391 model.scrollToPath(visibleItemPath, { focus: true, offset: "center" });396 model.scrollToPath(visibleItemPath, { focus: true, offset: "center" });
392 } else if (treePaths[0]) {397 } else if (rootItemPath) {
393 model.scrollToPath(treePaths[0], { focus: true, offset: "top" });398 model.scrollToPath(rootItemPath, { focus: true, offset: "top" });
394 }399 }
400 onBreadcrumbRevealHandled(breadcrumbRevealId);
395 queueMicrotask(() => {401 queueMicrotask(() => {
396 syncingSelectionRef.current = false;402 syncingSelectionRef.current = false;
397 });403 });
398 }, [breadcrumbRevealId, breadcrumbRevealPath, entryKinds, model, treePaths]);404 }, [
405 breadcrumbRevealId,
406 breadcrumbRevealPath,
407 entries,
408 entryKinds,
409 model,
410 onBreadcrumbRevealHandled,
411 treePaths,
412 ]);
399 413
400 // Tag tree drags with the composer mention payload. The row is read from414 // Tag tree drags with the composer mention payload. The row is read from
401 // the composed event path (the tree's shadow root is open), so this does415 // 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
786 const [breadcrumbReveal, setBreadcrumbReveal] = useState<{ path: string; id: number } | null>(786 const [breadcrumbReveal, setBreadcrumbReveal] = useState<{ path: string; id: number } | null>(
787 null,787 null,
788 );788 );
789 const breadcrumbRevealIdRef = useRef(0);
789 // Reading markdown rendered is a preference, not a property of one file. Keeping790 // Reading markdown rendered is a preference, not a property of one file. Keeping
790 // it on the panel meant a thread switch dropped it and forced source back.791 // it on the panel meant a thread switch dropped it and forced source back.
791 const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage(792 const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage(
@@ -842,8 +843,12 @@ export default function FilePreviewPanel({ · 50 unmodified lines
842 } catch (error) {843 } catch (error) {
843 console.error(error);844 console.error(error);
844 }845 }
845 setBreadcrumbReveal((current) => ({ path, id: (current?.id ?? 0) + 1 }));846 breadcrumbRevealIdRef.current += 1;
847 setBreadcrumbReveal({ path, id: breadcrumbRevealIdRef.current });
846 };848 };
849 const handleBreadcrumbRevealHandled = useCallback((revealId: number) => {
850 setBreadcrumbReveal((current) => (current?.id === revealId ? null : current));
851 }, []);
847 852
848 const handleOpenInBrowser = useCallback(() => {853 const handleOpenInBrowser = useCallback(() => {
849 if (!absolutePath || !environmentHttpBaseUrl) return;854 if (!absolutePath || !environmentHttpBaseUrl) return;
@@ -896,7 +901,7 @@ export default function FilePreviewPanel({ · 46 unmodified lines
896 <button901 <button
897 type="button"902 type="button"
898 className={cn(903 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",
900 crumb.kind === "file"905 crumb.kind === "file"
901 ? "font-medium text-foreground"906 ? "font-medium text-foreground"
902 : "text-muted-foreground",907 : "text-muted-foreground",
@@ -1089,6 +1094,7 @@ export default function FilePreviewPanel({ · 186 unmodified lines
1089 selectedPathRevealId={revealRequestId}1094 selectedPathRevealId={revealRequestId}
1090 breadcrumbRevealPath={breadcrumbReveal?.path ?? null}1095 breadcrumbRevealPath={breadcrumbReveal?.path ?? null}
1091 breadcrumbRevealId={breadcrumbReveal?.id ?? 0}1096 breadcrumbRevealId={breadcrumbReveal?.id ?? 0}
1097 onBreadcrumbRevealHandled={handleBreadcrumbRevealHandled}
1092 onOpenFile={onOpenFile}1098 onOpenFile={onOpenFile}
1093 />1099 />
1094 </aside>1100 </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