| author | |
| committer | |
| log | 17f00f60248374aafa2efb9b54ff08ce52e60a0a |
| tree | 1ceb7fee43a7261b5c339cf9db8fb28822350c8c |
| parent | c50b0b4ef8d61ffe3de1cfa6249f906709f7da1f |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 154 insertions(+), 2 deletions(-)
apps/web/src/components/files/FileBrowserPanel.tsx+39-2| ... | ... | @@ -3,9 +3,9 @@ import type { |
| 3 | 3 | ContextMenuOpenContext as TreeContextMenuOpenContext, |
| 4 | 4 | } from "@pierre/trees"; |
| 5 | 5 | import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; |
| 6 | import { FileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react"; | |
| 6 | import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; | |
| 7 | 7 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; |
| 8 | import { RotateCw } from "lucide-react"; | |
| 8 | import { ChevronsDownUpIcon, ChevronsUpDownIcon, RotateCw } from "lucide-react"; | |
| 9 | 9 | import { useEffect, useMemo, useRef } from "react"; |
| 10 | 10 | |
| 11 | 11 | import { Button } from "~/components/ui/button"; |
| ... | ... | @@ -21,6 +21,7 @@ import { readLocalApi } from "~/localApi"; |
| 21 | 21 | import { T3_PIERRE_ICONS } from "~/pierre-icons"; |
| 22 | 22 | |
| 23 | 23 | import { createFileTreeDragMentionController } from "./fileTreeDragMention"; |
| 24 | import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; | |
| 24 | 25 | import { useProjectEntriesQuery } from "./projectFilesQueryState"; |
| 25 | 26 | |
| 26 | 27 | interface FileBrowserPanelProps { |
| ... | ... | @@ -121,6 +122,10 @@ export default function FileBrowserPanel({ |
| 121 | 122 | ); |
| 122 | 123 | const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds); |
| 123 | 124 | const treePaths = useMemo(() => entries.map(treePath), [entries]); |
| 125 | const directoryPaths = useMemo( | |
| 126 | () => entries.filter((entry) => entry.kind === "directory").map(treePath), | |
| 127 | [entries], | |
| 128 | ); | |
| 124 | 129 | const previousTreePathsRef = useRef<readonly string[]>([]); |
| 125 | 130 | const syncingSelectionRef = useRef(false); |
| 126 | 131 | const treeSelectionPathRef = useRef<string | null>(null); |
| ... | ... | @@ -252,6 +257,12 @@ export default function FileBrowserPanel({ |
| 252 | 257 | unsafeCSS: TREE_UNSAFE_CSS, |
| 253 | 258 | }); |
| 254 | 259 | const search = useFileTreeSearch(model); |
| 260 | const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => | |
| 261 | areAllDirectoriesExpanded(currentModel, directoryPaths), | |
| 262 | ); | |
| 263 | const toggleAllDirectories = () => { | |
| 264 | setAllDirectoriesExpanded(model, directoryPaths, !allDirectoriesExpanded); | |
| 265 | }; | |
| 255 | 266 | const handleSearchValueChange = (value: string) => { |
| 256 | 267 | if (value.trim().length === 0) { |
| 257 | 268 | search.close(); |
| ... | ... | @@ -376,6 +387,32 @@ export default function FileBrowserPanel({ |
| 376 | 387 | onValueChange={handleSearchValueChange} |
| 377 | 388 | onClose={search.close} |
| 378 | 389 | /> |
| 390 | {directoryPaths.length > 0 ? ( | |
| 391 | <Tooltip> | |
| 392 | <TooltipTrigger | |
| 393 | render={ | |
| 394 | <Button | |
| 395 | type="button" | |
| 396 | size="icon-xs" | |
| 397 | variant="ghost" | |
| 398 | aria-label={ | |
| 399 | allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders" | |
| 400 | } | |
| 401 | onClick={toggleAllDirectories} | |
| 402 | /> | |
| 403 | } | |
| 404 | > | |
| 405 | {allDirectoriesExpanded ? ( | |
| 406 | <ChevronsDownUpIcon className="size-3.5" /> | |
| 407 | ) : ( | |
| 408 | <ChevronsUpDownIcon className="size-3.5" /> | |
| 409 | )} | |
| 410 | </TooltipTrigger> | |
| 411 | <TooltipPopup> | |
| 412 | {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} | |
| 413 | </TooltipPopup> | |
| 414 | </Tooltip> | |
| 415 | ) : null} | |
| 379 | 416 | </div> |
| 380 | 417 | {entriesQuery.error && entriesQuery.data === null ? ( |
| 381 | 418 | <div className="p-4 text-xs leading-relaxed text-destructive">{entriesQuery.error}</div> |
apps/web/src/components/files/fileTreeExpansion.test.ts created+60| ... | ... | @@ -0,0 +1,60 @@ |
| 1 | import { describe, expect, it, vi } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; | |
| 4 | ||
| 5 | type FakeDirectoryItem = { | |
| 6 | isDirectory: () => true; | |
| 7 | isExpanded: () => boolean; | |
| 8 | expand: () => void; | |
| 9 | collapse: () => void; | |
| 10 | }; | |
| 11 | ||
| 12 | function makeModel(expanded: Record<string, boolean>) { | |
| 13 | const items = new Map<string, FakeDirectoryItem>(); | |
| 14 | return { | |
| 15 | getItem: (path: string) => { | |
| 16 | const existing = items.get(path); | |
| 17 | if (existing !== undefined) return existing; | |
| 18 | const item: FakeDirectoryItem = { | |
| 19 | isDirectory: () => true, | |
| 20 | isExpanded: () => expanded[path] ?? false, | |
| 21 | expand: () => { | |
| 22 | expanded[path] = true; | |
| 23 | }, | |
| 24 | collapse: () => { | |
| 25 | expanded[path] = false; | |
| 26 | }, | |
| 27 | }; | |
| 28 | items.set(path, item); | |
| 29 | return item; | |
| 30 | }, | |
| 31 | }; | |
| 32 | } | |
| 33 | ||
| 34 | describe("file tree expansion", () => { | |
| 35 | it("requires at least one directory and detects whether all are expanded", () => { | |
| 36 | const model = makeModel({ "src/": true, "test/": true }); | |
| 37 | expect(areAllDirectoriesExpanded(model, [])).toBe(false); | |
| 38 | expect(areAllDirectoriesExpanded(model, ["src/", "test/"])).toBe(true); | |
| 39 | expect( | |
| 40 | areAllDirectoriesExpanded(makeModel({ "src/": true, "test/": false }), ["src/", "test/"]), | |
| 41 | ).toBe(false); | |
| 42 | }); | |
| 43 | ||
| 44 | it("expands and collapses every directory", () => { | |
| 45 | const expanded = { "src/": true, "test/": false }; | |
| 46 | const model = makeModel(expanded); | |
| 47 | setAllDirectoriesExpanded(model, ["src/", "test/"], true); | |
| 48 | expect(expanded).toEqual({ "src/": true, "test/": true }); | |
| 49 | setAllDirectoriesExpanded(model, ["src/", "test/"], false); | |
| 50 | expect(expanded).toEqual({ "src/": false, "test/": false }); | |
| 51 | }); | |
| 52 | ||
| 53 | it("skips directories already at the requested state", () => { | |
| 54 | const model = makeModel({ "src/": true }); | |
| 55 | const item = model.getItem("src/"); | |
| 56 | const collapse = vi.spyOn(item, "collapse"); | |
| 57 | setAllDirectoriesExpanded(model, ["src/"], true); | |
| 58 | expect(collapse).not.toHaveBeenCalled(); | |
| 59 | }); | |
| 60 | }); |
apps/web/src/components/files/fileTreeExpansion.ts created+55| ... | ... | @@ -0,0 +1,55 @@ |
| 1 | export interface FileTreeExpansionModel { | |
| 2 | getItem(path: string): unknown; | |
| 3 | } | |
| 4 | ||
| 5 | type DirectoryHandle = { | |
| 6 | isDirectory(): boolean; | |
| 7 | isExpanded(): boolean; | |
| 8 | expand(): void; | |
| 9 | collapse(): void; | |
| 10 | }; | |
| 11 | ||
| 12 | function asDirectoryHandle(item: unknown): DirectoryHandle | null { | |
| 13 | if ( | |
| 14 | typeof item !== "object" || | |
| 15 | item === null || | |
| 16 | !("isDirectory" in item) || | |
| 17 | typeof item.isDirectory !== "function" || | |
| 18 | !item.isDirectory() || | |
| 19 | !("isExpanded" in item) || | |
| 20 | typeof item.isExpanded !== "function" || | |
| 21 | !("expand" in item) || | |
| 22 | typeof item.expand !== "function" || | |
| 23 | !("collapse" in item) || | |
| 24 | typeof item.collapse !== "function" | |
| 25 | ) { | |
| 26 | return null; | |
| 27 | } | |
| 28 | return item as DirectoryHandle; | |
| 29 | } | |
| 30 | ||
| 31 | export function areAllDirectoriesExpanded( | |
| 32 | model: FileTreeExpansionModel, | |
| 33 | directoryPaths: readonly string[], | |
| 34 | ): boolean { | |
| 35 | return ( | |
| 36 | directoryPaths.length > 0 && | |
| 37 | directoryPaths.every((path) => { | |
| 38 | const item = asDirectoryHandle(model.getItem(path)); | |
| 39 | return item !== null && item.isExpanded(); | |
| 40 | }) | |
| 41 | ); | |
| 42 | } | |
| 43 | ||
| 44 | export function setAllDirectoriesExpanded( | |
| 45 | model: FileTreeExpansionModel, | |
| 46 | directoryPaths: readonly string[], | |
| 47 | expanded: boolean, | |
| 48 | ): void { | |
| 49 | for (const path of directoryPaths) { | |
| 50 | const item = asDirectoryHandle(model.getItem(path)); | |
| 51 | if (item === null || item.isExpanded() === expanded) continue; | |
| 52 | if (expanded) item.expand(); | |
| 53 | else item.collapse(); | |
| 54 | } | |
| 55 | } |