Unified · Split
| @@ -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 preservation | 64 | ## Visual and layout preservation |
| @@ -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, so | 130 | // The tree renders rows in shadow DOM and its anchor rect is unreliable, so |
| 125 | // capture the right-click position ourselves; contextmenu is a composed | 131 | // 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 from | 400 | // 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 does | 401 | // 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 drag | 402 | // not depend on running after the tree's own dragstart handler; the drag |
| @@ -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. Keeping | 789 | // 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 | <span | 896 | <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> |