mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
Show new files in collapsed folders after Refresh (#8582)
* fix(app): show current contents of collapsed folders after Files refresh Refresh only re-listed expanded folders, and expanding a folder skips the request when a listing is cached, so a folder collapsed during Refresh kept showing its old contents. Drop the cached listings Refresh does not re-list. The refresh helper moves to file-explorer/tree.ts so it can be tested. * test(app): cover refreshing collapsed folders through the browser Exercise the reported file creation, refresh and reopen sequence through a real daemon, then check refresh while expanded. Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn> * fix(app): ignore directory replies invalidated by Refresh A directory request started before Refresh could finish afterwards and restore a stale collapsed listing. Track the refresh generation in explorer state and accept completion only from the current generation. Keep directory loading and invalidation in the file-explorer model. * fix(app): preserve expansion when Refresh cancels restoration Keep an invalidated directory operation distinct from a failed listing. Propagate cancellation through saved-folder restoration so it preserves the current expansion state instead of committing an incomplete restored path set. --------- Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
hadan8977
parent
d2cc18b3a1
commit
e06adf7a3e
@@ -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<void> {
|
||||
const refresh = page.getByRole("button", { name: "Refresh files", exact: true });
|
||||
await refresh.click();
|
||||
await expect(refresh).toBeEnabled();
|
||||
}
|
||||
|
||||
async function createFileOutsidePaseo(filePath: string): Promise<void> {
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -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<void>((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<void>((resolve) => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<ExplorerDirectory | null>;
|
||||
) => Promise<DirectoryListingResult>;
|
||||
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<ExplorerDirectory | null>;
|
||||
) => Promise<DirectoryListingResult>;
|
||||
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
|
||||
}): Promise<void> {
|
||||
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<string>;
|
||||
requestDirectoryListing: (
|
||||
path: string,
|
||||
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||
) => Promise<ExplorerDirectory | null>;
|
||||
}): Promise<null> {
|
||||
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 ".";
|
||||
|
||||
@@ -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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((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<ExplorerDirectory>) =>
|
||||
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<ExplorerDirectory>();
|
||||
const oldLoad = model.load("docs", old.promise);
|
||||
model.refresh(["."]);
|
||||
const fresh = deferred<ExplorerDirectory>();
|
||||
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<ExplorerDirectory>();
|
||||
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<ExplorerDirectory>();
|
||||
const oldLoad = model.load("docs", old.promise);
|
||||
model.refresh(["."]);
|
||||
const fresh = deferred<ExplorerDirectory>();
|
||||
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<ExplorerDirectory>();
|
||||
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]);
|
||||
},
|
||||
);
|
||||
@@ -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<ExplorerDirectory>;
|
||||
fallbackError: string;
|
||||
}): Promise<DirectoryListingResult> {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
@@ -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<string, string[]>, cached: Record<string, string[]>) {
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>;
|
||||
showHiddenFiles: boolean;
|
||||
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
|
||||
requestDirectoryListing: (path: string) => Promise<DirectoryListingResult>;
|
||||
}
|
||||
|
||||
interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
|
||||
@@ -33,7 +36,7 @@ interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
|
||||
interface ReconcileRestoredExpandedPathsInput {
|
||||
persistedExpandedPaths: ReadonlySet<string>;
|
||||
currentExpandedPaths: ReadonlySet<string>;
|
||||
restoredExpandedPaths: string[];
|
||||
restoredExpandedPaths: RestoredExpandedPaths;
|
||||
}
|
||||
|
||||
interface SetExpandedDirectoryPathInput {
|
||||
@@ -85,7 +88,7 @@ export async function restoreExpandedDirectories({
|
||||
persistedExpandedPaths,
|
||||
showHiddenFiles,
|
||||
requestDirectoryListing,
|
||||
}: RestoreExpandedDirectoriesInput): Promise<string[]> {
|
||||
}: RestoreExpandedDirectoriesInput): Promise<RestoredExpandedPaths> {
|
||||
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<string[]> {
|
||||
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<RestoredExpandedPaths> {
|
||||
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<ExplorerDirectory | null>;
|
||||
requestDirectoryListing: (path: string) => Promise<DirectoryListingResult>;
|
||||
expandPaths: (paths: string[]) => void;
|
||||
showHiddenFiles: () => void;
|
||||
}): Promise<void> {
|
||||
@@ -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<string>;
|
||||
showHiddenFiles: boolean;
|
||||
requestDirectoryListing: (
|
||||
path: string,
|
||||
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||
) => Promise<DirectoryListingResult>;
|
||||
invalidateDirectoryListings: (retainedPaths: readonly string[]) => void;
|
||||
}): Promise<null> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<ExplorerDirectory | null> => {
|
||||
): Promise<DirectoryListingResult> => {
|
||||
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,
|
||||
|
||||
@@ -260,6 +260,7 @@ interface ExplorerRequestState {
|
||||
}
|
||||
|
||||
export interface AgentFileExplorerState {
|
||||
listingGeneration: number;
|
||||
directories: Map<string, ExplorerDirectory>;
|
||||
isLoading: boolean;
|
||||
lastError: string | null;
|
||||
|
||||
Reference in New Issue
Block a user