| @@ -30,6 +30,9 @@ interface FileBrowserPanelProps { · 29 unmodified lines |
| 30 | 30 | selectedPath: string | null; |
| 31 | 31 | /** Bumped when the same path should be revealed again (e.g. re-opened from search). */ |
| 32 | 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 | 36 | onOpenFile: (relativePath: string) => void; |
| 34 | 37 | } |
| 35 | 38 | |
| @@ -104,6 +107,8 @@ export default function FileBrowserPanel({ · 68 unmodified lines |
| 104 | 107 | projectName, |
| 105 | 108 | selectedPath, |
| 106 | 109 | selectedPathRevealId, |
| 110 | + breadcrumbRevealPath, |
| 111 | + breadcrumbRevealId, |
| 107 | 112 | onOpenFile, |
| 108 | 113 | }: FileBrowserPanelProps) { |
| 109 | 114 | const { resolvedTheme } = useTheme(); |
| @@ -120,6 +125,7 @@ export default function FileBrowserPanel({ · 10 unmodified lines |
| 120 | 125 | const syncingSelectionRef = useRef(false); |
| 121 | 126 | const treeSelectionPathRef = useRef<string | null>(null); |
| 122 | 127 | const handledRevealRef = useRef<{ path: string; revealId: number } | null>(null); |
| 128 | + const handledBreadcrumbRevealRef = useRef<{ path: string; revealId: number } | null>(null); |
| 123 | 129 | |
| 124 | 130 | // The tree renders rows in shadow DOM and its anchor rect is unreliable, so |
| 125 | 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 | 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 | 400 | // Tag tree drags with the composer mention payload. The row is read from |
| 323 | 401 | // the composed event path (the tree's shadow root is open), so this does |
| 324 | 402 | // not depend on running after the tree's own dragstart handler; the drag |