diff --git a/packages/app/e2e/browser/file-explorer-collapse.spec.ts b/packages/app/e2e/browser/file-explorer-collapse.spec.ts index 705f667260..b00101c2ff 100644 --- a/packages/app/e2e/browser/file-explorer-collapse.spec.ts +++ b/packages/app/e2e/browser/file-explorer-collapse.spec.ts @@ -1,4 +1,6 @@ -import { test } from "../support/fixtures"; +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, test } from "../support/fixtures"; import { collapseFolder, expandFolder, @@ -8,6 +10,7 @@ import { openFileExplorer, openFileFromExplorer, } from "../support/helpers/file-explorer"; +import { delayDirectoryListingResponse } from "../support/helpers/directory-listing-gate"; import { gotoWorkspace } from "../support/helpers/launcher"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; @@ -49,3 +52,66 @@ test.describe("File explorer collapse", () => { await expectExplorerEntryVisible(page, "guide.md"); }); }); + +test("Files Refresh reveals new files when a cached collapsed folder is reopened", async ({ + page, +}) => { + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + await expandFolder(page, "docs"); + await expectExplorerEntryVisible(page, "guide.md"); + await collapseFolder(page, "docs"); + await expectExplorerEntryHidden(page, "guide.md"); + await createFileOutsidePaseo("docs/NEW.md"); + await refreshFiles(page); + await expandFolder(page, "docs"); + await expectExplorerEntryVisible(page, "guide.md"); + await expectExplorerEntryVisible(page, "NEW.md"); + + await createFileOutsidePaseo("docs/NEXT.md"); + await refreshFiles(page); + await expectExplorerEntryVisible(page, "NEXT.md"); +}); + +async function refreshFiles(page: import("@playwright/test").Page): Promise { + const refresh = page.getByRole("button", { name: "Refresh files", exact: true }); + await refresh.click(); + await expect(refresh).toBeEnabled(); +} + +async function createFileOutsidePaseo(filePath: string): Promise { + await writeFile(path.join(workspace.repoPath, filePath), "# New output\n"); +} + +test("Files Refresh ignores an old reply for a folder collapsed while loading", async ({ + page, +}) => { + const reply = await delayDirectoryListingResponse(page, "docs"); + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + await expandFolder(page, "docs"); + await reply.waitUntilHeld(); + await collapseFolder(page, "docs"); + await createFileOutsidePaseo("docs/DELAYED.md"); + await refreshFiles(page); + await reply.release(); + await expandFolder(page, "docs"); + await expectExplorerEntryVisible(page, "DELAYED.md"); +}); + +test("Files Refresh preserves expanded folders while their saved view is restoring", async ({ + page, +}) => { + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + await expandFolder(page, "docs"); + await expectExplorerEntryVisible(page, "guide.md"); + const reply = await delayDirectoryListingResponse(page, "docs"); + await page.reload(); + await openFileExplorer(page); + await reply.waitUntilHeld(); + await refreshFiles(page); + await expectExplorerEntryVisible(page, "guide.md"); + await reply.release(); + await expectExplorerEntryVisible(page, "guide.md"); +}); diff --git a/packages/app/e2e/support/helpers/directory-listing-gate.ts b/packages/app/e2e/support/helpers/directory-listing-gate.ts new file mode 100644 index 0000000000..4e9dd8dc10 --- /dev/null +++ b/packages/app/e2e/support/helpers/directory-listing-gate.ts @@ -0,0 +1,44 @@ +import type { Page, WebSocketRoute } from "@playwright/test"; +import { daemonWsRoutePattern } from "./daemon-port"; + +function listingResponse(message: string | Buffer) { + try { + const envelope = JSON.parse(message.toString()); + return envelope.message?.type === "file_explorer_response" ? envelope.message.payload : null; + } catch { + return null; + } +} + +/** Delay one real directory response after the daemon has read its contents. */ +export async function delayDirectoryListingResponse(page: Page, directoryPath: string) { + let held: { socket: WebSocketRoute; message: string | Buffer; requestId: string } | null = null; + let resolveHeld!: () => void; + const heldResponse = new Promise((resolve) => { + resolveHeld = resolve; + }); + await page.routeWebSocket(daemonWsRoutePattern(), (socket) => { + const server = socket.connectToServer(); + socket.onMessage((message) => server.send(message)); + server.onMessage((message) => { + const response = listingResponse(message); + if (!held && response?.mode === "list" && response.path === directoryPath) { + held = { socket, message, requestId: response.requestId }; + resolveHeld(); + return; + } + socket.send(message); + }); + }); + return { + waitUntilHeld: () => heldResponse, + release: async () => { + if (!held) throw new Error("Directory response must be held before release"); + held.socket.send(held.message); + // Let the delivered reply update the UI before the user reopens the folder. + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => resolve())), + ); + }, + }; +} diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 80bc9b0257..2625407d60 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -64,10 +64,15 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; +import { + DIRECTORY_LISTING_INVALIDATED, + type DirectoryListingResult, +} from "@/file-explorer/directory-listings"; import { isHiddenExplorerPath } from "@/file-explorer/visibility"; import { flattenExplorerTree, reconcileRestoredExpandedPaths, + refreshExplorerDirectories, restoreExpandedDirectories, setExpandedDirectoryPath, showHiddenFilesAndRestoreExpandedDirectories, @@ -435,6 +440,7 @@ export function FileExplorerPane({ const { requestDirectoryListing, + invalidateDirectoryListings, createEntry, renameEntry, duplicateEntry, @@ -886,9 +892,11 @@ export function FileExplorerPane({ refreshExplorerDirectories({ hasWorkspaceScope, expandedPaths, + showHiddenFiles: usePanelStore.getState().explorerShowHiddenFiles, requestDirectoryListing, + invalidateDirectoryListings, }), - [expandedPaths, hasWorkspaceScope, requestDirectoryListing], + [invalidateDirectoryListings, expandedPaths, hasWorkspaceScope, requestDirectoryListing], ); const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({ queryKey: ["fileExplorerRefresh", serverId, workspaceStateKey], @@ -1424,7 +1432,7 @@ function toggleDirectory({ requestDirectoryListing: ( path: string, opts?: { recordHistory?: boolean; setCurrentPath?: boolean }, - ) => Promise; + ) => Promise; setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void; }): void { if (!workspaceStateKey) { @@ -1561,7 +1569,7 @@ async function initializeExplorer({ requestDirectoryListing: ( path: string, opts?: { recordHistory?: boolean; setCurrentPath?: boolean }, - ) => Promise; + ) => Promise; setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void; }): Promise { if (!hasWorkspaceScope || hasInitializedRef.current) { @@ -1572,6 +1580,7 @@ async function initializeExplorer({ recordHistory: false, setCurrentPath: false, }); + if (rootDirectory === DIRECTORY_LISTING_INVALIDATED) return; if (!rootDirectory) { hasInitializedRef.current = false; return; @@ -1593,7 +1602,10 @@ async function initializeExplorer({ const hiddenPersistedPaths = showHiddenFiles ? [] : Array.from(persistedExpandedPaths).filter(isHiddenExplorerPath); - const restoredPathsWithHidden = [...restoredPaths, ...hiddenPersistedPaths]; + const restoredPathsWithHidden = + restoredPaths === DIRECTORY_LISTING_INVALIDATED + ? DIRECTORY_LISTING_INVALIDATED + : [...restoredPaths, ...hiddenPersistedPaths]; setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) => reconcileRestoredExpandedPaths({ persistedExpandedPaths, @@ -1603,39 +1615,6 @@ async function initializeExplorer({ ); } -async function refreshExplorerDirectories({ - hasWorkspaceScope, - expandedPaths, - requestDirectoryListing, -}: { - hasWorkspaceScope: boolean; - expandedPaths: Set; - requestDirectoryListing: ( - path: string, - opts?: { recordHistory?: boolean; setCurrentPath?: boolean }, - ) => Promise; -}): Promise { - if (!hasWorkspaceScope) { - return null; - } - const showHiddenFiles = usePanelStore.getState().explorerShowHiddenFiles; - const directoryPaths = Array.from(expandedPaths).filter( - (path) => showHiddenFiles || !isHiddenExplorerPath(path), - ); - if (!directoryPaths.includes(".")) { - directoryPaths.unshift("."); - } - await Promise.all( - directoryPaths.map((path) => - requestDirectoryListing(path, { - recordHistory: false, - setCurrentPath: false, - }), - ), - ); - return null; -} - function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string { if (!state) { return "."; diff --git a/packages/app/src/file-explorer/directory-listings.test.ts b/packages/app/src/file-explorer/directory-listings.test.ts new file mode 100644 index 0000000000..8e56b39b2b --- /dev/null +++ b/packages/app/src/file-explorer/directory-listings.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import type { AgentFileExplorerState, ExplorerDirectory } from "@/stores/session-store"; +import { + refreshExplorerDirectories, + restoreExpandedDirectories, + showHiddenFilesAndRestoreExpandedDirectories, + reconcileRestoredExpandedPaths, +} from "./tree"; +import { + DIRECTORY_LISTING_INVALIDATED, + invalidateExplorerDirectoryListings, + loadExplorerDirectory, +} from "./directory-listings"; + +function directory(path: string, names: string[]): ExplorerDirectory { + return { + path, + entries: names.map((name) => ({ + path: `${path}/${name}`, + name, + kind: "file", + size: 0, + modifiedAt: "2026-01-01T00:00:00.000Z", + })), + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +function explorer() { + let state: AgentFileExplorerState = { + listingGeneration: 0, + directories: new Map([[".", directory(".", ["docs"])]]), + isLoading: false, + lastError: null, + pendingRequest: null, + currentPath: ".", + history: ["."], + lastVisitedPath: ".", + selectedEntryPath: null, + }; + const updateState = (update: (state: AgentFileExplorerState) => AgentFileExplorerState) => { + state = update(state); + return state; + }; + return { + state: () => state, + load: (path: string, result: Promise) => + loadExplorerDirectory({ + path, + updateState, + listDirectory: () => result, + fallbackError: "Directory listing failed", + options: { recordHistory: false, setCurrentPath: false }, + }), + refresh: (retained: readonly string[]) => + invalidateExplorerDirectoryListings(updateState, retained), + }; +} + +describe("directory listings across Refresh", () => { + it("ignores an earlier reply for a collapsed folder while the fresh root is loading", async () => { + const model = explorer(); + const old = deferred(); + const oldLoad = model.load("docs", old.promise); + model.refresh(["."]); + const fresh = deferred(); + const freshLoad = model.load(".", fresh.promise); + old.resolve(directory("docs", ["OLD.md"])); + + await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED); + expect(model.state().directories.has("docs")).toBe(false); + expect(model.state().isLoading).toBe(true); + expect(model.state().pendingRequest?.path).toBe("."); + + const root = directory(".", ["docs", "NEW.md"]); + fresh.resolve(root); + await expect(freshLoad).resolves.toEqual(root); + expect(model.state().directories.get(".")).toEqual(root); + expect(model.state().isLoading).toBe(false); + }); + + it("does not let a pre-refresh reply replace a newer listing of the same folder", async () => { + const model = explorer(); + const old = deferred(); + const oldLoad = model.load("docs", old.promise); + model.refresh([".", "docs"]); + const fresh = directory("docs", ["NEW.md"]); + await model.load("docs", Promise.resolve(fresh)); + old.resolve(directory("docs", ["OLD.md"])); + + await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED); + expect(model.state().directories.get("docs")).toEqual(fresh); + }); + + it("ignores errors from requests invalidated by Refresh", async () => { + const model = explorer(); + const old = deferred(); + const oldLoad = model.load("docs", old.promise); + model.refresh(["."]); + const fresh = deferred(); + const freshLoad = model.load(".", fresh.promise); + old.reject(new Error("Old request failed")); + + await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED); + expect(model.state().lastError).toBeNull(); + expect(model.state().isLoading).toBe(true); + fresh.resolve(directory(".", ["docs"])); + await freshLoad; + }); + + it("keeps expanded listings visible and discards collapsed listings on Refresh", async () => { + const model = explorer(); + const docs = directory("docs", ["guide.md"]); + await model.load("docs", Promise.resolve(docs)); + await model.load("assets", Promise.resolve(directory("assets", ["logo.png"]))); + model.refresh([".", "docs"]); + + expect(model.state().directories.get("docs")).toEqual(docs); + expect(model.state().directories.has("assets")).toBe(false); + expect(model.state().currentPath).toBe("."); + expect(model.state().history).toEqual(["."]); + }); +}); + +it.each([false, true])( + "preserves saved expansion when Refresh invalidates restoration (hidden: %s)", + async (hidden) => { + const model = explorer(); + const folder = hidden ? ".hidden" : "docs"; + const root: ExplorerDirectory = { + path: ".", + entries: [ + { + name: folder, + path: folder, + kind: "directory", + size: 0, + modifiedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }; + const saved = new Set([".", folder]); + const pending = deferred(); + const input = { + rootDirectory: root, + persistedExpandedPaths: saved, + requestDirectoryListing: (path: string) => model.load(path, pending.promise), + }; + const restoration = hidden + ? showHiddenFilesAndRestoreExpandedDirectories({ ...input, showHiddenFiles: () => {} }) + : restoreExpandedDirectories({ ...input, showHiddenFiles: false }); + const fresh = directory(folder, ["NEW.md"]); + await refreshExplorerDirectories({ + hasWorkspaceScope: true, + expandedPaths: saved, + showHiddenFiles: hidden, + invalidateDirectoryListings: model.refresh, + requestDirectoryListing: (path) => + model.load(path, Promise.resolve(path === "." ? root : fresh)), + }); + pending.resolve(directory(folder, ["OLD.md"])); + const restored = await restoration; + const expanded = reconcileRestoredExpandedPaths({ + persistedExpandedPaths: saved, + currentExpandedPaths: saved, + restoredExpandedPaths: restored, + }); + expect(model.state().directories.get(folder)).toEqual(fresh); + expect(expanded).toEqual([".", folder]); + }, +); diff --git a/packages/app/src/file-explorer/directory-listings.ts b/packages/app/src/file-explorer/directory-listings.ts new file mode 100644 index 0000000000..4d52aaf782 --- /dev/null +++ b/packages/app/src/file-explorer/directory-listings.ts @@ -0,0 +1,91 @@ +import type { AgentFileExplorerState, ExplorerDirectory } from "@/stores/session-store"; + +export const DIRECTORY_LISTING_INVALIDATED = Symbol("directory-listing-invalidated"); +export type DirectoryListingResult = + | ExplorerDirectory + | null + | typeof DIRECTORY_LISTING_INVALIDATED; + +type UpdateExplorerState = ( + update: (state: AgentFileExplorerState) => AgentFileExplorerState, +) => AgentFileExplorerState | null; + +function pushHistory(history: string[], path: string): string[] { + const normalizedHistory = history.length === 0 ? ["."] : history; + return normalizedHistory.at(-1) === path ? normalizedHistory : [...normalizedHistory, path]; +} + +export async function loadExplorerDirectory({ + path, + options, + updateState, + listDirectory, + fallbackError, +}: { + path: string; + options?: { recordHistory?: boolean; setCurrentPath?: boolean }; + updateState: UpdateExplorerState; + listDirectory: () => Promise; + fallbackError: string; +}): Promise { + const shouldSetCurrentPath = options?.setCurrentPath ?? true; + const shouldRecordHistory = options?.recordHistory ?? shouldSetCurrentPath; + const initial = updateState((state) => ({ + ...state, + isLoading: true, + lastError: null, + pendingRequest: { path, mode: "list" }, + ...(shouldSetCurrentPath + ? { + currentPath: path, + history: shouldRecordHistory ? pushHistory(state.history, path) : state.history, + lastVisitedPath: path, + } + : {}), + })); + if (!initial) return null; + const generation = initial.listingGeneration; + try { + const directory = await listDirectory(); + let accepted = false; + updateState((state) => { + if (state.listingGeneration !== generation) return state; + accepted = true; + const directories = new Map(state.directories); + directories.set(directory.path, directory); + return { ...state, directories, isLoading: false, lastError: null, pendingRequest: null }; + }); + return accepted ? directory : DIRECTORY_LISTING_INVALIDATED; + } catch (error) { + let invalidated = false; + updateState((state) => { + if (state.listingGeneration !== generation) { + invalidated = true; + return state; + } + return { + ...state, + isLoading: false, + lastError: error instanceof Error ? error.message : fallbackError, + pendingRequest: null, + }; + }); + return invalidated ? DIRECTORY_LISTING_INVALIDATED : null; + } +} + +/** Refresh invalidates earlier replies as well as listings of collapsed folders. */ +export function invalidateExplorerDirectoryListings( + updateState: UpdateExplorerState, + retainedPaths: readonly string[], +): void { + const retained = new Set(retainedPaths); + updateState((state) => ({ + ...state, + listingGeneration: state.listingGeneration + 1, + directories: new Map([...state.directories].filter(([path]) => retained.has(path))), + isLoading: false, + pendingRequest: null, + lastError: null, + })); +} diff --git a/packages/app/src/file-explorer/tree.test.ts b/packages/app/src/file-explorer/tree.test.ts index e3df026c0d..82d4bcc61a 100644 --- a/packages/app/src/file-explorer/tree.test.ts +++ b/packages/app/src/file-explorer/tree.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; -import type { ExplorerEntry } from "@/stores/session-store"; +import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store"; import { MAX_AUTO_EXPANDED_DIRECTORY_DEPTH, flattenExplorerTree, reconcileRestoredExpandedPaths, + refreshExplorerDirectories, restoreExpandedDirectories, setExpandedDirectoryPath, showHiddenFilesAndRestoreExpandedDirectories, @@ -195,3 +196,76 @@ describe("file explorer tree", () => { await expect(restoration).resolves.toEqual([".", ".hidden"]); }); }); + +describe("refreshExplorerDirectories", () => { + function makeListing(path: string, names: string[]): ExplorerDirectory { + return { + path, + entries: names.map((name) => ({ + name, + path: path === "." ? name : `${path}/${name}`, + kind: "file" as const, + size: 0, + modifiedAt: "2026-01-01T00:00:00.000Z", + })), + }; + } + + function createExplorer(disk: Record, cached: Record) { + const directories = new Map( + Object.entries(cached).map(([path, names]) => [path, makeListing(path, names)]), + ); + return { + directories, + requestDirectoryListing: async (path: string) => { + const listing = makeListing(path, disk[path] ?? []); + directories.set(path, listing); + return listing; + }, + invalidateDirectoryListings: (retainedPaths: readonly string[]) => { + const retained = new Set(retainedPaths); + for (const path of directories.keys()) if (!retained.has(path)) directories.delete(path); + }, + }; + } + + it("does not keep a stale listing for a collapsed folder", async () => { + // docs/ was expanded and collapsed earlier, then NEW.md was created in it. + const explorer = createExplorer( + { ".": ["docs"], docs: ["README.md", "NEW.md"] }, + { ".": ["docs"], docs: ["README.md"] }, + ); + + await refreshExplorerDirectories({ + hasWorkspaceScope: true, + expandedPaths: new Set(["."]), + showHiddenFiles: false, + requestDirectoryListing: explorer.requestDirectoryListing, + invalidateDirectoryListings: explorer.invalidateDirectoryListings, + }); + + // Expanding docs/ only fetches when it has no cached listing. + expect(explorer.directories.has("docs")).toBe(false); + expect(explorer.directories.get(".")?.entries.map((entry) => entry.name)).toEqual(["docs"]); + }); + + it("refreshes expanded folders and keeps their listings", async () => { + const explorer = createExplorer( + { ".": ["src"], src: ["a.ts", "b.ts"] }, + { ".": ["src"], src: ["a.ts"] }, + ); + + await refreshExplorerDirectories({ + hasWorkspaceScope: true, + expandedPaths: new Set([".", "src"]), + showHiddenFiles: false, + requestDirectoryListing: explorer.requestDirectoryListing, + invalidateDirectoryListings: explorer.invalidateDirectoryListings, + }); + + expect(explorer.directories.get("src")?.entries.map((entry) => entry.name)).toEqual([ + "a.ts", + "b.ts", + ]); + }); +}); diff --git a/packages/app/src/file-explorer/tree.ts b/packages/app/src/file-explorer/tree.ts index 8b75ae7b25..4402cd82bc 100644 --- a/packages/app/src/file-explorer/tree.ts +++ b/packages/app/src/file-explorer/tree.ts @@ -1,5 +1,6 @@ import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store"; import type { SortOption } from "@/stores/panel-store/state"; +import { DIRECTORY_LISTING_INVALIDATED, type DirectoryListingResult } from "./directory-listings"; import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "./visibility"; export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5; @@ -16,11 +17,13 @@ interface FlattenExplorerTreeInput { showHiddenFiles: boolean; } +type RestoredExpandedPaths = string[] | typeof DIRECTORY_LISTING_INVALIDATED; + interface RestoreExpandedDirectoriesInput { rootDirectory: ExplorerDirectory; persistedExpandedPaths: ReadonlySet; showHiddenFiles: boolean; - requestDirectoryListing: (path: string) => Promise; + requestDirectoryListing: (path: string) => Promise; } interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit< @@ -33,7 +36,7 @@ interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit< interface ReconcileRestoredExpandedPathsInput { persistedExpandedPaths: ReadonlySet; currentExpandedPaths: ReadonlySet; - restoredExpandedPaths: string[]; + restoredExpandedPaths: RestoredExpandedPaths; } interface SetExpandedDirectoryPathInput { @@ -85,7 +88,7 @@ export async function restoreExpandedDirectories({ persistedExpandedPaths, showHiddenFiles, requestDirectoryListing, -}: RestoreExpandedDirectoriesInput): Promise { +}: RestoreExpandedDirectoriesInput): Promise { const restoredPaths = ["."]; const restoredPathSet = new Set(restoredPaths); let parentDirectories = [rootDirectory]; @@ -110,9 +113,11 @@ export async function restoreExpandedDirectories({ const requestedDirectories = await Promise.all( pathsToRequest.map((path) => requestDirectoryListing(path)), ); + if (requestedDirectories.includes(DIRECTORY_LISTING_INVALIDATED)) + return DIRECTORY_LISTING_INVALIDATED; parentDirectories = []; for (const directory of requestedDirectories) { - if (!directory) { + if (!directory || directory === DIRECTORY_LISTING_INVALIDATED) { continue; } restoredPaths.push(directory.path); @@ -128,7 +133,7 @@ export function showHiddenFilesAndRestoreExpandedDirectories({ persistedExpandedPaths, showHiddenFiles, requestDirectoryListing, -}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise { +}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise { showHiddenFiles(); return restoreExpandedDirectories({ rootDirectory, @@ -143,6 +148,8 @@ export function reconcileRestoredExpandedPaths({ currentExpandedPaths, restoredExpandedPaths, }: ReconcileRestoredExpandedPathsInput): string[] { + if (restoredExpandedPaths === DIRECTORY_LISTING_INVALIDATED) + return Array.from(currentExpandedPaths); const reconciledPaths = new Set(restoredExpandedPaths); for (const path of persistedExpandedPaths) { @@ -205,7 +212,7 @@ function sortExplorerEntries(entries: ExplorerEntry[], sortOption: SortOption): /** Load and expand the path to a directory selected outside the tree. */ export async function revealExplorerDirectory(input: { directoryPath: string; - requestDirectoryListing: (path: string) => Promise; + requestDirectoryListing: (path: string) => Promise; expandPaths: (paths: string[]) => void; showHiddenFiles: () => void; }): Promise { @@ -218,7 +225,51 @@ export async function revealExplorerDirectory(input: { } } for (const path of paths) { - if (!(await input.requestDirectoryListing(path))) return; + const directory = await input.requestDirectoryListing(path); + if (!directory || directory === DIRECTORY_LISTING_INVALIDATED) return; } input.expandPaths(paths); } + +/** + * Files Refresh: re-lists the expanded directories (and the root), and drops the cached + * listing of every other directory. Expanding a directory only fetches when it has no + * cached listing, so keeping a collapsed directory's old listing would show it as it was + * before the refresh. + */ +export async function refreshExplorerDirectories({ + hasWorkspaceScope, + expandedPaths, + showHiddenFiles, + requestDirectoryListing, + invalidateDirectoryListings, +}: { + hasWorkspaceScope: boolean; + expandedPaths: Set; + showHiddenFiles: boolean; + requestDirectoryListing: ( + path: string, + opts?: { recordHistory?: boolean; setCurrentPath?: boolean }, + ) => Promise; + invalidateDirectoryListings: (retainedPaths: readonly string[]) => void; +}): Promise { + if (!hasWorkspaceScope) { + return null; + } + const directoryPaths = Array.from(expandedPaths).filter( + (path) => showHiddenFiles || !isHiddenExplorerPath(path), + ); + if (!directoryPaths.includes(".")) { + directoryPaths.unshift("."); + } + invalidateDirectoryListings(directoryPaths); + await Promise.all( + directoryPaths.map((path) => + requestDirectoryListing(path, { + recordHistory: false, + setCurrentPath: false, + }), + ), + ); + return null; +} diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index d54131eb4a..82edab6a13 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -1,16 +1,18 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { - useSessionStore, - type AgentFileExplorerState, - type ExplorerDirectory, -} from "@/stores/session-store"; +import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store"; import { usePanelStore } from "@/stores/panel-store"; import { revealExplorerDirectory } from "@/file-explorer/tree"; +import { + loadExplorerDirectory, + type DirectoryListingResult, + invalidateExplorerDirectoryListings, +} from "@/file-explorer/directory-listings"; import { parentExplorerPath } from "@/utils/explorer-paths"; function createExplorerState(): AgentFileExplorerState { return { + listingGeneration: 0, directories: new Map(), isLoading: false, lastError: null, @@ -22,15 +24,6 @@ function createExplorerState(): AgentFileExplorerState { }; } -function pushHistory(history: string[], path: string): string[] { - const normalizedHistory = history.length === 0 ? ["."] : history; - const last = normalizedHistory[normalizedHistory.length - 1]; - if (last === path) { - return normalizedHistory; - } - return [...normalizedHistory, path]; -} - export interface FileExplorerWorkspaceScope { workspaceId?: string | null; workspaceRoot?: string | null; @@ -75,16 +68,21 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor ); const updateExplorerState = useCallback( - (updater: (prev: AgentFileExplorerState) => AgentFileExplorerState) => { + ( + updater: (prev: AgentFileExplorerState) => AgentFileExplorerState, + ): AgentFileExplorerState | null => { if (!workspaceStateKey) { - return; + return null; } + let updated: AgentFileExplorerState | null = null; setFileExplorer(serverId, (prev) => { const next = new Map(prev); const current = next.get(workspaceStateKey) ?? createExplorerState(); - next.set(workspaceStateKey, updater(current)); + updated = updater(current); + next.set(workspaceStateKey, updated); return next; }); + return updated; }, [serverId, setFileExplorer, workspaceStateKey], ); @@ -93,80 +91,23 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor async ( path: string, options?: { recordHistory?: boolean; setCurrentPath?: boolean }, - ): Promise => { + ): Promise => { if (!workspaceStateKey) { return null; } const normalizedPath = path && path.length > 0 ? path : "."; - const shouldSetCurrentPath = options?.setCurrentPath ?? true; - const shouldRecordHistory = options?.recordHistory ?? shouldSetCurrentPath; - - updateExplorerState((state) => ({ - ...state, - isLoading: true, - lastError: null, - pendingRequest: { path: normalizedPath, mode: "list" }, - ...(shouldSetCurrentPath - ? { - currentPath: normalizedPath, - history: shouldRecordHistory - ? pushHistory(state.history, normalizedPath) - : state.history, - lastVisitedPath: normalizedPath, - } - : {}), - })); - - if (!normalizedWorkspaceRoot) { - updateExplorerState((state) => ({ - ...state, - isLoading: false, - lastError: t("workspace.fileExplorer.states.unavailable"), - pendingRequest: null, - })); - return null; - } - - if (!client) { - updateExplorerState((state) => ({ - ...state, - isLoading: false, - lastError: t("workspace.terminal.hostDisconnected"), - pendingRequest: null, - })); - return null; - } - - try { - const directory = await client.listDirectory(normalizedWorkspaceRoot, normalizedPath); - updateExplorerState((state) => { - const nextState: AgentFileExplorerState = { - ...state, - isLoading: false, - lastError: null, - pendingRequest: null, - directories: state.directories, - }; - - const directories = new Map(state.directories); - directories.set(directory.path, directory); - nextState.directories = directories; - - return nextState; - }); - return directory; - } catch (error) { - updateExplorerState((state) => ({ - ...state, - isLoading: false, - lastError: - error instanceof Error - ? error.message - : t("workspace.fileExplorer.errors.failedToListDirectory"), - pendingRequest: null, - })); - return null; - } + return loadExplorerDirectory({ + path: normalizedPath, + options, + updateState: updateExplorerState, + fallbackError: t("workspace.fileExplorer.errors.failedToListDirectory"), + listDirectory: async () => { + if (!normalizedWorkspaceRoot) + throw new Error(t("workspace.fileExplorer.states.unavailable")); + if (!client) throw new Error(t("workspace.terminal.hostDisconnected")); + return client.listDirectory(normalizedWorkspaceRoot, normalizedPath); + }, + }); }, [client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey], ); @@ -308,10 +249,17 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor [updateExplorerState], ); + const invalidateDirectoryListings = useCallback( + (retainedPaths: readonly string[]) => + invalidateExplorerDirectoryListings(updateExplorerState, retainedPaths), + [updateExplorerState], + ); + return { workspaceStateKey, revealDirectory, requestDirectoryListing, + invalidateDirectoryListings, requestFileDownloadToken, requestFileBytes, createEntry, diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 911573762f..001ad8eed6 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -260,6 +260,7 @@ interface ExplorerRequestState { } export interface AgentFileExplorerState { + listingGeneration: number; directories: Map; isLoading: boolean; lastError: string | null;