From e8a976ff8ffe4eefa092555df444645d65a0b0f5 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:58:37 +0200 Subject: [PATCH 1/3] feat(web): add file tree expansion controls --- .../src/components/files/FileBrowserPanel.tsx | 59 +++++- .../files/fileTreeExpansion.test.ts | 192 ++++++++++++++++++ .../src/components/files/fileTreeExpansion.ts | 90 ++++++++ 3 files changed, 336 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/files/fileTreeExpansion.test.ts create mode 100644 apps/web/src/components/files/fileTreeExpansion.ts diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 3f53d65533d..3e1b8a1d393 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -5,8 +5,8 @@ import type { import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import { FileTree, useFileTree } from "@pierre/trees/react"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; -import { RefreshCw, Search } from "lucide-react"; -import { useEffect, useMemo, useRef } from "react"; +import { ChevronsDownUp, ChevronsUpDown, RefreshCw, Search } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react"; import { toastManager } from "~/components/ui/toast"; import { useComposerHandleContext } from "~/composerHandleContext"; @@ -17,6 +17,14 @@ import { readLocalApi } from "~/localApi"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; +import { + COLLAPSE_ALL_FOLDERS_LABEL, + directoryTreePaths, + EXPAND_ALL_FOLDERS_LABEL, + getFileTreeExpansionSnapshot, + initiallyExpandedDirectoryPaths, + setAllDirectoriesExpanded, +} from "./fileTreeExpansion"; import { useProjectEntriesQuery } from "./projectFilesQueryState"; interface FileBrowserPanelProps { @@ -58,6 +66,7 @@ export default function FileBrowserPanel({ ); const entryKindsRef = useRef>(entryKinds); const treePaths = useMemo(() => entries.map(treePath), [entries]); + const directoryPaths = useMemo(() => directoryTreePaths(entries), [entries]); const previousTreePathsRef = useRef([]); // The tree renders rows in shadow DOM and its anchor rect is unreliable, so @@ -161,7 +170,7 @@ export default function FileBrowserPanel({ density: "compact", fileTreeSearchMode: "hide-non-matches", flattenEmptyDirectories: true, - initialExpansion: 1, + initialExpansion: "closed", icons: T3_PIERRE_ICONS, onSelectionChange: (selectedPaths) => { dragMention.handleSelectionChange(selectedPaths); @@ -179,13 +188,35 @@ export default function FileBrowserPanel({ search: true, unsafeCSS: TREE_UNSAFE_CSS, }); + const subscribeToTree = useCallback((listener: () => void) => model.subscribe(listener), [model]); + const getExpansionSnapshot = useCallback( + () => getFileTreeExpansionSnapshot(model, directoryPaths), + [directoryPaths, model], + ); + const expansionSnapshot = useSyncExternalStore( + subscribeToTree, + getExpansionSnapshot, + getExpansionSnapshot, + ); + const expansionControlsDisabled = + expansionSnapshot === "empty" || expansionSnapshot === "searching"; + const canCollapseAll = + !expansionControlsDisabled && + (expansionSnapshot === "expanded" || expansionSnapshot === "mixed"); + const canExpandAll = + !expansionControlsDisabled && + (expansionSnapshot === "collapsed" || expansionSnapshot === "mixed"); useEffect(() => { if (previousTreePathsRef.current === treePaths) return; entryKindsRef.current = entryKinds; previousTreePathsRef.current = treePaths; - model.resetPaths(treePaths); - }, [entryKinds, model, treePaths]); + // A closed default lets bulk operations replace expansion in one reset; + // explicitly retain the explorer's existing depth-one initial state. + model.resetPaths(treePaths, { + initialExpandedPaths: initiallyExpandedDirectoryPaths(directoryPaths), + }); + }, [directoryPaths, entryKinds, model, treePaths]); const fileCount = useMemo( () => entries.reduce((count, entry) => count + (entry.kind === "file" ? 1 : 0), 0), @@ -233,6 +264,24 @@ export default function FileBrowserPanel({ {entriesQuery.data?.truncated ? " ยท partial" : ""} + + -