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[]; }