| @@ -33,6 +33,7 @@ interface FileBrowserPanelProps { · 32 unmodified lines |
| 33 | 33 | /** Breadcrumb target to reveal in the tree. An empty path reveals the project root. */ |
| 34 | 34 | breadcrumbRevealPath: string | null; |
| 35 | 35 | breadcrumbRevealId: number; |
| 36 | + onBreadcrumbRevealHandled: (revealId: number) => void; |
| 36 | 37 | onOpenFile: (relativePath: string) => void; |
| 37 | 38 | } |
| 38 | 39 | |
| @@ -52,6 +53,22 @@ function treePath(entry: ProjectEntry): string { · 13 unmodified lines |
| 52 | 53 | return entry.kind === "directory" ? `${entry.path}/` : entry.path; |
| 53 | 54 | } |
| 54 | 55 | |
| 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 | + |
| 55 | 72 | function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void }) { |
| 56 | 73 | return ( |
| 57 | 74 | <Tooltip> |
| @@ -109,6 +126,7 @@ export default function FileBrowserPanel({ · 51 unmodified lines |
| 109 | 126 | selectedPathRevealId, |
| 110 | 127 | breadcrumbRevealPath, |
| 111 | 128 | breadcrumbRevealId, |
| 129 | + onBreadcrumbRevealHandled, |
| 112 | 130 | onOpenFile, |
| 113 | 131 | }: FileBrowserPanelProps) { |
| 114 | 132 | const { resolvedTheme } = useTheme(); |
| @@ -343,9 +361,27 @@ export default function FileBrowserPanel({ · 228 unmodified lines |
| 343 | 361 | ? `${breadcrumbRevealPath}/` |
| 344 | 362 | : breadcrumbRevealPath; |
| 345 | 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 | 381 | model.closeSearch(); |
| 382 | + for (const path of model.getSelectedPaths()) { |
| 383 | + model.getItem(path)?.deselect(); |
| 384 | + } |
| 349 | 385 | |
| 350 | 386 | const segments = breadcrumbRevealPath.split("/").filter(Boolean); |
| 351 | 387 | let ancestorPath = ""; |
| @@ -355,47 +391,25 @@ export default function FileBrowserPanel({ · 3 unmodified lines |
| 355 | 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 | 394 | if (visibleItemPath) { |
| 390 | 395 | model.getItem(visibleItemPath)?.select(); |
| 391 | 396 | 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" }); |
| 394 | 399 | } |
| 400 | + onBreadcrumbRevealHandled(breadcrumbRevealId); |
| 395 | 401 | queueMicrotask(() => { |
| 396 | 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 | 414 | // Tag tree drags with the composer mention payload. The row is read from |
| 401 | 415 | // the composed event path (the tree's shadow root is open), so this does |