From 68a215d40c8102342df2d7720c364afd110a86ab Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 29 Jul 2026 16:10:51 +0200 Subject: [PATCH] Prevent restored file trees from crashing desktop startup (#2595) * fix(explorer): prevent persisted trees from crashing startup Persisted expansion restored every directory and recursively flattened the resulting tree during render. Limit automatic restoration to five rendered directory levels and flatten iteratively so deep or broad workspaces cannot exhaust the JavaScript stack. * fix(explorer): preserve expansion changes during restore Reconcile completed restoration against the current expansion set atomically so folder interactions made while directory requests are in flight are not overwritten. * fix(explorer): show hidden files before restoration * fix(explorer): apply directory toggles atomically --- .../app/src/components/file-explorer-pane.tsx | 259 ++++++++---------- packages/app/src/file-explorer/tree.test.ts | 197 +++++++++++++ packages/app/src/file-explorer/tree.ts | 203 ++++++++++++++ .../src/hooks/use-file-explorer-actions.ts | 18 +- packages/app/src/stores/panel-store/index.ts | 17 +- packages/app/src/stores/session-store.ts | 2 +- 6 files changed, 536 insertions(+), 160 deletions(-) create mode 100644 packages/app/src/file-explorer/tree.test.ts create mode 100644 packages/app/src/file-explorer/tree.ts diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 540a02bd9..e2cc7759e 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -24,16 +24,28 @@ import { } from "@/components/tree-primitives"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import type { Theme } from "@/styles/theme"; -import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store"; +import type { + AgentFileExplorerState, + ExplorerDirectory, + ExplorerEntry, +} from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; import { FileActionsMenu } from "@/components/file-actions-menu"; import { useFileDownload } from "@/hooks/use-file-download"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; -import { usePanelStore, type SortOption } from "@/stores/panel-store"; +import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store"; import { formatTimeAgo } from "@/utils/time"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; -import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility"; +import { isHiddenExplorerPath } from "@/file-explorer/visibility"; +import { + flattenExplorerTree, + reconcileRestoredExpandedPaths, + restoreExpandedDirectories, + setExpandedDirectoryPath, + showHiddenFilesAndRestoreExpandedDirectories, + type ExplorerTreeRow, +} from "@/file-explorer/tree"; import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source"; const SORT_OPTIONS: { value: SortOption }[] = [ @@ -83,7 +95,7 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho return [styles.iconButton, (Boolean(hovered) || pressed) && styles.iconButtonHovered]; } -function treeRowKeyExtractor(row: TreeRow) { +function treeRowKeyExtractor(row: ExplorerTreeRow) { return row.entry.path; } @@ -199,11 +211,6 @@ interface FileExplorerPaneProps { onAddToChat?: (path: string) => void; } -interface TreeRow { - entry: ExplorerEntry; - depth: number; -} - export function FileExplorerPane({ serverId, workspaceId, @@ -263,7 +270,7 @@ export function FileExplorerPane({ [isExplorerLoading, pendingRequest], ); - const treeListRef = useRef>(null); + const treeListRef = useRef>(null); const hasInitializedRef = useRef(false); @@ -276,9 +283,19 @@ export function FileExplorerPane({ hasWorkspaceScope, hasInitializedRef, workspaceStateKey, + persistedExpandedPaths: expandedPaths, + showHiddenFiles, requestDirectoryListing, + setExpandedPathsForWorkspace, }); - }, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]); + }, [ + expandedPaths, + hasWorkspaceScope, + requestDirectoryListing, + setExpandedPathsForWorkspace, + showHiddenFiles, + workspaceStateKey, + ]); const handleToggleDirectory = useCallback( (entry: ExplorerEntry) => @@ -351,11 +368,42 @@ export function FileExplorerPane({ const handleToggleHiddenFiles = useCallback(() => { const willShow = !usePanelStore.getState().explorerShowHiddenFiles; - toggleExplorerShowHiddenFiles(); - if (willShow) { - requestPersistedExpandedPaths({ workspaceStateKey, requestDirectoryListing }); + if (!willShow) { + toggleExplorerShowHiddenFiles(); + return; } - }, [requestDirectoryListing, toggleExplorerShowHiddenFiles, workspaceStateKey]); + const rootDirectory = directories.get("."); + if (!rootDirectory || !workspaceStateKey) { + toggleExplorerShowHiddenFiles(); + return; + } + void showHiddenFilesAndRestoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths: expandedPaths, + showHiddenFiles: toggleExplorerShowHiddenFiles, + requestDirectoryListing: (path) => + requestDirectoryListing(path, { + recordHistory: false, + setCurrentPath: false, + }), + }).then((restoredPaths) => { + setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) => + reconcileRestoredExpandedPaths({ + persistedExpandedPaths: expandedPaths, + currentExpandedPaths: new Set(currentPaths), + restoredExpandedPaths: restoredPaths, + }), + ); + return null; + }); + }, [ + directories, + expandedPaths, + requestDirectoryListing, + setExpandedPathsForWorkspace, + toggleExplorerShowHiddenFiles, + workspaceStateKey, + ]); const refreshExplorer = useCallback( () => @@ -387,7 +435,7 @@ export function FileExplorerPane({ const currentSortLabel = resolveCurrentSortLabel(sortOption, sortLabels); const treeRows = useMemo( - () => resolveTreeRows({ directories, expandedPaths, sortOption, showHiddenFiles }), + () => flattenExplorerTree({ directories, expandedPaths, sortOption, showHiddenFiles }), [directories, expandedPaths, showHiddenFiles, sortOption], ); @@ -400,7 +448,7 @@ export function FileExplorerPane({ const errorRecoveryPath = useMemo(() => getErrorRecoveryPath(explorerState), [explorerState]); const renderTreeRow = useCallback( - (info: ListRenderItemInfo) => ( + (info: ListRenderItemInfo) => ( | null>; - renderTreeRow: (info: ListRenderItemInfo) => ReactElement; + treeListRef: RefObject | null>; + renderTreeRow: (info: ListRenderItemInfo) => ReactElement; handleSortCycle: () => void; handleToggleHiddenFiles: () => void; handleRefresh: () => void; @@ -637,71 +685,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { ); } -function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] { - const sorted = [...entries]; - sorted.sort((a, b) => { - if (a.kind !== b.kind) { - return a.kind === "directory" ? -1 : 1; - } - switch (sortOption) { - case "name": - return a.name.localeCompare(b.name); - case "modified": - return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime(); - case "size": - return b.size - a.size; - default: - return 0; - } - }); - return sorted; -} - -function buildTreeRows({ - directories, - expandedPaths, - sortOption, - showHiddenFiles, - path, - depth, -}: { - directories: Map; - expandedPaths: Set; - sortOption: SortOption; - showHiddenFiles: boolean; - path: string; - depth: number; -}): TreeRow[] { - const directory = directories.get(path); - if (!directory) { - return []; - } - - const rows: TreeRow[] = []; - const entries = sortEntries( - filterVisibleExplorerEntries(directory.entries, showHiddenFiles), - sortOption, - ); - - for (const entry of entries) { - rows.push({ entry, depth }); - if (entry.kind === "directory" && expandedPaths.has(entry.path)) { - rows.push( - ...buildTreeRows({ - directories, - expandedPaths, - sortOption, - showHiddenFiles, - path: entry.path, - depth: depth + 1, - }), - ); - } - } - - return rows; -} - function deriveExplorerFields(state: AgentFileExplorerState | undefined) { return { directories: @@ -751,30 +734,6 @@ function resolveCurrentSortLabel( return labels[sortOption] ?? labels.name; } -function resolveTreeRows({ - directories, - expandedPaths, - sortOption, - showHiddenFiles, -}: { - directories: Map; - expandedPaths: Set; - sortOption: SortOption; - showHiddenFiles: boolean; -}): TreeRow[] { - if (!directories.get(".")) { - return []; - } - return buildTreeRows({ - directories, - expandedPaths, - sortOption, - showHiddenFiles, - path: ".", - depth: 0, - }); -} - function toggleDirectory({ entry, workspaceStateKey, @@ -786,26 +745,25 @@ function toggleDirectory({ entry: ExplorerEntry; workspaceStateKey: string | null; expandedPaths: Set; - directories: Map; + directories: Map; requestDirectoryListing: ( path: string, opts?: { recordHistory?: boolean; setCurrentPath?: boolean }, - ) => Promise; - setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: string[]) => void; + ) => Promise; + setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void; }): void { if (!workspaceStateKey) { return; } const isExpanded = expandedPaths.has(entry.path); - if (isExpanded) { - setExpandedPathsForWorkspace( - workspaceStateKey, - Array.from(expandedPaths).filter((path) => path !== entry.path), - ); - return; - } - setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), entry.path]); - if (!directories.has(entry.path)) { + setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) => + setExpandedDirectoryPath({ + currentExpandedPaths: currentPaths, + directoryPath: entry.path, + expanded: !isExpanded, + }), + ); + if (!isExpanded && !directories.has(entry.path)) { void requestDirectoryListing(entry.path, { recordHistory: false, setCurrentPath: false, @@ -827,7 +785,7 @@ function TreeRowDispatcher({ }: { serverId: string; workspaceId?: string | null; - info: ListRenderItemInfo; + info: ListRenderItemInfo; expandedPaths: Set; selectedEntryPath: string | null; isDirectoryLoading: (path: string) => boolean; @@ -865,54 +823,59 @@ async function initializeExplorer({ hasWorkspaceScope, hasInitializedRef, workspaceStateKey, + persistedExpandedPaths, + showHiddenFiles, requestDirectoryListing, + setExpandedPathsForWorkspace, }: { hasWorkspaceScope: boolean; hasInitializedRef: RefObject; workspaceStateKey: string | null; + persistedExpandedPaths: ReadonlySet; + showHiddenFiles: boolean; requestDirectoryListing: ( path: string, opts?: { recordHistory?: boolean; setCurrentPath?: boolean }, - ) => Promise; + ) => Promise; + setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void; }): Promise { if (!hasWorkspaceScope || hasInitializedRef.current) { return; } hasInitializedRef.current = true; - const succeeded = await requestDirectoryListing(".", { + const rootDirectory = await requestDirectoryListing(".", { recordHistory: false, setCurrentPath: false, }); - if (!succeeded) { + if (!rootDirectory) { hasInitializedRef.current = false; return; } - requestPersistedExpandedPaths({ workspaceStateKey, requestDirectoryListing }); -} - -function requestPersistedExpandedPaths({ - workspaceStateKey, - requestDirectoryListing, -}: { - workspaceStateKey: string | null; - requestDirectoryListing: ( - path: string, - opts?: { recordHistory?: boolean; setCurrentPath?: boolean }, - ) => Promise; -}): void { - const showHiddenFiles = usePanelStore.getState().explorerShowHiddenFiles; - const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""]; - if (!persistedPaths) { + if (!workspaceStateKey) { return; } - for (const path of persistedPaths) { - if (path !== "." && (showHiddenFiles || !isHiddenExplorerPath(path))) { - void requestDirectoryListing(path, { + + const restoredPaths = await restoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths, + showHiddenFiles, + requestDirectoryListing: (path) => + requestDirectoryListing(path, { recordHistory: false, setCurrentPath: false, - }); - } - } + }), + }); + const hiddenPersistedPaths = showHiddenFiles + ? [] + : Array.from(persistedExpandedPaths).filter(isHiddenExplorerPath); + const restoredPathsWithHidden = [...restoredPaths, ...hiddenPersistedPaths]; + setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) => + reconcileRestoredExpandedPaths({ + persistedExpandedPaths, + currentExpandedPaths: new Set(currentPaths), + restoredExpandedPaths: restoredPathsWithHidden, + }), + ); } async function refreshExplorerDirectories({ @@ -925,7 +888,7 @@ async function refreshExplorerDirectories({ requestDirectoryListing: ( path: string, opts?: { recordHistory?: boolean; setCurrentPath?: boolean }, - ) => Promise; + ) => Promise; }): Promise { if (!hasWorkspaceScope) { return null; diff --git a/packages/app/src/file-explorer/tree.test.ts b/packages/app/src/file-explorer/tree.test.ts new file mode 100644 index 000000000..e3df026c0 --- /dev/null +++ b/packages/app/src/file-explorer/tree.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; +import type { ExplorerEntry } from "@/stores/session-store"; +import { + MAX_AUTO_EXPANDED_DIRECTORY_DEPTH, + flattenExplorerTree, + reconcileRestoredExpandedPaths, + restoreExpandedDirectories, + setExpandedDirectoryPath, + showHiddenFilesAndRestoreExpandedDirectories, +} from "./tree"; + +function makeDirectoryEntry(name: string, path: string): ExplorerEntry { + return { + name, + path, + kind: "directory", + size: 0, + modifiedAt: "2026-01-01T00:00:00.000Z", + }; +} + +describe("file explorer tree", () => { + it("flattens a deeply expanded tree without consuming the call stack", () => { + const depth = 10_000; + const directories = new Map(); + const expandedPaths = new Set(["."]); + let parentPath = "."; + + for (let index = 1; index <= depth; index += 1) { + const childPath = `directory-${index}`; + directories.set(parentPath, { + path: parentPath, + entries: [makeDirectoryEntry(childPath, childPath)], + }); + expandedPaths.add(childPath); + parentPath = childPath; + } + directories.set(parentPath, { path: parentPath, entries: [] }); + + const rows = flattenExplorerTree({ + directories, + expandedPaths, + sortOption: "name", + showHiddenFiles: true, + }); + + expect(rows).toHaveLength(depth); + expect(rows[0]).toEqual({ + entry: makeDirectoryEntry("directory-1", "directory-1"), + depth: 0, + }); + expect(rows.at(-1)).toEqual({ + entry: makeDirectoryEntry(`directory-${depth}`, `directory-${depth}`), + depth: depth - 1, + }); + }); + + it("flattens a large expanded directory without spreading its rows into the parent", () => { + const fileCount = 150_000; + const files = Array.from( + { length: fileCount }, + (_, index): ExplorerEntry => ({ + name: `file-${index.toString().padStart(6, "0")}`, + path: `generated/file-${index}`, + kind: "file", + size: index, + modifiedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const child = makeDirectoryEntry("generated", "generated"); + const directories = new Map([ + [".", { path: ".", entries: [child] }], + ["generated", { path: "generated", entries: files }], + ]); + + const rows = flattenExplorerTree({ + directories, + expandedPaths: new Set([".", "generated"]), + sortOption: "name", + showHiddenFiles: true, + }); + + expect(rows).toHaveLength(fileCount + 1); + expect(rows[0]).toEqual({ entry: child, depth: 0 }); + expect(rows.at(-1)).toEqual({ entry: files[fileCount - 1], depth: 1 }); + }); + + it("restores five rendered directory levels rather than counting path segments", async () => { + const paths = [ + "generated/cache/level-1", + "generated/cache/level-1/level-2", + "generated/cache/level-1/level-2/level-3", + "generated/cache/level-1/level-2/level-3/level-4", + "generated/cache/level-1/level-2/level-3/level-4/level-5", + "generated/cache/level-1/level-2/level-3/level-4/level-5/level-6", + ]; + const directories = new Map(); + const rootDirectory = { + path: ".", + entries: [makeDirectoryEntry("level-1", paths[0])], + }; + directories.set(".", rootDirectory); + for (let index = 0; index < paths.length - 1; index += 1) { + directories.set(paths[index], { + path: paths[index], + entries: [makeDirectoryEntry(`level-${index + 2}`, paths[index + 1])], + }); + } + + const requestedPaths: string[] = []; + const expandedPaths = await restoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths: new Set(paths), + showHiddenFiles: true, + requestDirectoryListing: async (path) => { + requestedPaths.push(path); + return directories.get(path) ?? null; + }, + }); + + expect(MAX_AUTO_EXPANDED_DIRECTORY_DEPTH).toBe(5); + expect(requestedPaths).toEqual(paths.slice(0, 5)); + expect(expandedPaths).toEqual([".", ...paths.slice(0, 5)]); + }); + + it("does not restore persisted descendants beneath a collapsed rendered directory", async () => { + const rootDirectory = { + path: ".", + entries: [makeDirectoryEntry("parent", "parent")], + }; + const requestedPaths: string[] = []; + + const expandedPaths = await restoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths: new Set(["parent/child", "parent/child/grandchild"]), + showHiddenFiles: true, + requestDirectoryListing: async (path) => { + requestedPaths.push(path); + return null; + }, + }); + + expect(requestedPaths).toEqual([]); + expect(expandedPaths).toEqual(["."]); + }); + + it("preserves expansion changes made while persisted directories are restoring", () => { + const paths = reconcileRestoredExpandedPaths({ + persistedExpandedPaths: new Set([".", "parent", "parent/child"]), + currentExpandedPaths: new Set([".", "parent/child", "manual"]), + restoredExpandedPaths: [".", "parent"], + }); + + expect(paths).toEqual([".", "manual"]); + }); + + it("applies a directory click to the latest restored expansion paths", () => { + const expanded = setExpandedDirectoryPath({ + currentExpandedPaths: [".", "restored"], + directoryPath: "manual", + expanded: true, + }); + const collapsed = setExpandedDirectoryPath({ + currentExpandedPaths: expanded, + directoryPath: "manual", + expanded: false, + }); + + expect(expanded).toEqual([".", "restored", "manual"]); + expect(collapsed).toEqual([".", "restored"]); + }); + + it("shows hidden files before waiting for expanded directories to restore", async () => { + const rootDirectory = { + path: ".", + entries: [makeDirectoryEntry(".hidden", ".hidden")], + }; + let resolveDirectory!: (directory: { path: string; entries: ExplorerEntry[] }) => void; + const directoryListing = new Promise<{ path: string; entries: ExplorerEntry[] }>((resolve) => { + resolveDirectory = resolve; + }); + let hiddenFilesAreShown = false; + + const restoration = showHiddenFilesAndRestoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths: new Set([".hidden"]), + showHiddenFiles: () => { + hiddenFilesAreShown = true; + }, + requestDirectoryListing: () => directoryListing, + }); + + expect(hiddenFilesAreShown).toBe(true); + resolveDirectory({ path: ".hidden", entries: [] }); + await expect(restoration).resolves.toEqual([".", ".hidden"]); + }); +}); diff --git a/packages/app/src/file-explorer/tree.ts b/packages/app/src/file-explorer/tree.ts new file mode 100644 index 000000000..472c3d707 --- /dev/null +++ b/packages/app/src/file-explorer/tree.ts @@ -0,0 +1,203 @@ +import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store"; +import type { SortOption } from "@/stores/panel-store/state"; +import { filterVisibleExplorerEntries } from "./visibility"; + +export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5; + +export interface ExplorerTreeRow { + entry: ExplorerEntry; + depth: number; +} + +interface FlattenExplorerTreeInput { + directories: ReadonlyMap; + expandedPaths: ReadonlySet; + sortOption: SortOption; + showHiddenFiles: boolean; +} + +interface RestoreExpandedDirectoriesInput { + rootDirectory: ExplorerDirectory; + persistedExpandedPaths: ReadonlySet; + showHiddenFiles: boolean; + requestDirectoryListing: (path: string) => Promise; +} + +interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit< + RestoreExpandedDirectoriesInput, + "showHiddenFiles" +> { + showHiddenFiles: () => void; +} + +interface ReconcileRestoredExpandedPathsInput { + persistedExpandedPaths: ReadonlySet; + currentExpandedPaths: ReadonlySet; + restoredExpandedPaths: string[]; +} + +interface SetExpandedDirectoryPathInput { + currentExpandedPaths: readonly string[]; + directoryPath: string; + expanded: boolean; +} + +export function flattenExplorerTree({ + directories, + expandedPaths, + sortOption, + showHiddenFiles, +}: FlattenExplorerTreeInput): ExplorerTreeRow[] { + const root = directories.get("."); + if (!root) { + return []; + } + + const rows: ExplorerTreeRow[] = []; + const pending = rowsForDirectory(root, 0, sortOption, showHiddenFiles).toReversed(); + + while (pending.length > 0) { + const row = pending.pop(); + if (!row) { + break; + } + rows.push(row); + + const entry = row.entry; + if (entry.kind !== "directory" || !expandedPaths.has(entry.path)) { + continue; + } + const childDirectory = directories.get(entry.path); + if (!childDirectory) { + continue; + } + const childRows = rowsForDirectory(childDirectory, row.depth + 1, sortOption, showHiddenFiles); + for (let index = childRows.length - 1; index >= 0; index -= 1) { + pending.push(childRows[index]); + } + } + + return rows; +} + +export async function restoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths, + showHiddenFiles, + requestDirectoryListing, +}: RestoreExpandedDirectoriesInput): Promise { + const restoredPaths = ["."]; + const restoredPathSet = new Set(restoredPaths); + let parentDirectories = [rootDirectory]; + + for (let depth = 1; depth <= MAX_AUTO_EXPANDED_DIRECTORY_DEPTH; depth += 1) { + const pathsToRequest: string[] = []; + for (const directory of parentDirectories) { + const entries = filterVisibleExplorerEntries(directory.entries, showHiddenFiles); + for (const entry of entries) { + const isPersistedExpandedDirectory = + entry.kind === "directory" && persistedExpandedPaths.has(entry.path); + if (isPersistedExpandedDirectory && !restoredPathSet.has(entry.path)) { + pathsToRequest.push(entry.path); + restoredPathSet.add(entry.path); + } + } + } + if (pathsToRequest.length === 0) { + break; + } + + const requestedDirectories = await Promise.all( + pathsToRequest.map((path) => requestDirectoryListing(path)), + ); + parentDirectories = []; + for (const directory of requestedDirectories) { + if (!directory) { + continue; + } + restoredPaths.push(directory.path); + parentDirectories.push(directory); + } + } + + return restoredPaths; +} + +export function showHiddenFilesAndRestoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths, + showHiddenFiles, + requestDirectoryListing, +}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise { + showHiddenFiles(); + return restoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths, + showHiddenFiles: true, + requestDirectoryListing, + }); +} + +export function reconcileRestoredExpandedPaths({ + persistedExpandedPaths, + currentExpandedPaths, + restoredExpandedPaths, +}: ReconcileRestoredExpandedPathsInput): string[] { + const reconciledPaths = new Set(restoredExpandedPaths); + + for (const path of persistedExpandedPaths) { + if (!currentExpandedPaths.has(path)) { + reconciledPaths.delete(path); + } + } + for (const path of currentExpandedPaths) { + if (!persistedExpandedPaths.has(path)) { + reconciledPaths.add(path); + } + } + + return Array.from(reconciledPaths); +} + +export function setExpandedDirectoryPath({ + currentExpandedPaths, + directoryPath, + expanded, +}: SetExpandedDirectoryPathInput): string[] { + const nextPaths = new Set(currentExpandedPaths); + if (expanded) { + nextPaths.add(directoryPath); + } else { + nextPaths.delete(directoryPath); + } + return Array.from(nextPaths); +} + +function rowsForDirectory( + directory: ExplorerDirectory, + depth: number, + sortOption: SortOption, + showHiddenFiles: boolean, +): ExplorerTreeRow[] { + const visibleEntries = filterVisibleExplorerEntries(directory.entries, showHiddenFiles); + const sortedEntries = sortExplorerEntries(visibleEntries, sortOption); + return sortedEntries.map((entry) => ({ entry, depth })); +} + +function sortExplorerEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] { + const sorted = [...entries]; + sorted.sort((a, b) => { + if (a.kind !== b.kind) { + return a.kind === "directory" ? -1 : 1; + } + switch (sortOption) { + case "name": + return a.name.localeCompare(b.name); + case "modified": + return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime(); + case "size": + return b.size - a.size; + } + }); + return sorted; +} diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index 0502b0820..d1bb4c239 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -1,6 +1,10 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store"; +import { + useSessionStore, + type AgentFileExplorerState, + type ExplorerDirectory, +} from "@/stores/session-store"; import { explorerFileFromReadResult } from "@/file-explorer/read-result"; function createExplorerState(): AgentFileExplorerState { @@ -88,9 +92,9 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor async ( path: string, options?: { recordHistory?: boolean; setCurrentPath?: boolean }, - ): Promise => { + ): Promise => { if (!workspaceStateKey) { - return false; + return null; } const normalizedPath = path && path.length > 0 ? path : "."; const shouldSetCurrentPath = options?.setCurrentPath ?? true; @@ -119,7 +123,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor lastError: t("workspace.fileExplorer.states.unavailable"), pendingRequest: null, })); - return false; + return null; } if (!client) { @@ -129,7 +133,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor lastError: t("workspace.terminal.hostDisconnected"), pendingRequest: null, })); - return false; + return null; } try { @@ -150,7 +154,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor return nextState; }); - return true; + return directory; } catch (error) { updateExplorerState((state) => ({ ...state, @@ -161,7 +165,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor : t("workspace.fileExplorer.errors.failedToListDirectory"), pendingRequest: null, })); - return false; + return null; } }, [client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey], diff --git a/packages/app/src/stores/panel-store/index.ts b/packages/app/src/stores/panel-store/index.ts index 655cb8783..52d2aa0cc 100644 --- a/packages/app/src/stores/panel-store/index.ts +++ b/packages/app/src/stores/panel-store/index.ts @@ -63,6 +63,8 @@ export { selectPanelVisibility, }; +export type ExpandedPathsUpdate = string[] | ((currentPaths: string[]) => string[]); + export interface PanelState { // Mobile: React's durable target plus the generation that owns it. mobilePanel: MobilePanelSelection; @@ -104,7 +106,7 @@ export interface PanelState { // File explorer settings actions setExplorerTab: (tab: ExplorerTab) => void; setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void; - setExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void; + setExpandedPathsForWorkspace: (workspaceKey: string, paths: ExpandedPathsUpdate) => void; setDiffExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void; setDiffCollapsedFoldersForWorkspace: (workspaceKey: string, dirPaths: string[]) => void; activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void; @@ -253,9 +255,16 @@ export const usePanelStore = create()( return nextState; }), setExpandedPathsForWorkspace: (workspaceKey, paths) => - set((state) => ({ - expandedPathsByWorkspace: { ...state.expandedPathsByWorkspace, [workspaceKey]: paths }, - })), + set((state) => { + const currentPaths = state.expandedPathsByWorkspace[workspaceKey] ?? ["."]; + const nextPaths = typeof paths === "function" ? paths(currentPaths) : paths; + return { + expandedPathsByWorkspace: { + ...state.expandedPathsByWorkspace, + [workspaceKey]: nextPaths, + }, + }; + }), setDiffExpandedPathsForWorkspace: (workspaceKey, paths) => set((state) => ({ diffExpandedPathsByWorkspace: { diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 1d61867f2..7ff1bc6a2 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -285,7 +285,7 @@ export interface ExplorerFile { modifiedAt: string; } -interface ExplorerDirectory { +export interface ExplorerDirectory { path: string; entries: ExplorerEntry[]; }