| @@ -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 | |
| | 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 | function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void }) { | 72 | function 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 from | 414 | // 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 does | 415 | // the composed event path (the tree's shadow root is open), so this does |