Skip to content

Commit 07c9efc

Browse files
committed
fix(app): prune stale file-explorer paths during hydration
When a subdirectory in expandedPathsByWorkspace no longer exists on disk (e.g. removed externally while the app was closed), opening the file explorer was fire-and-forgetting a directory listing for each persisted path. A single ENOENT wrote to the shared lastError, so the whole panel rendered as an error banner that only Retry could clear, and the stale path stayed in the persisted state so the error returned on the next open. Await the hydration listings, pass a new silent flag so a failed listing does not overwrite lastError, and prune any failed paths from expandedPathsByWorkspace so subsequent opens stay clean. Fixes #1145.
1 parent d40e4b6 commit 07c9efc

3 files changed

Lines changed: 105 additions & 25 deletions

File tree

packages/app/src/components/file-explorer-pane.tsx

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,11 @@ import {
4545
DropdownMenuSeparator,
4646
DropdownMenuTrigger,
4747
} from "@/components/ui/dropdown-menu";
48-
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
49-
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
48+
import {
49+
buildWorkspaceExplorerStateKey,
50+
dropFailedExpandedPaths,
51+
useFileExplorerActions,
52+
} from "@/hooks/use-file-explorer-actions";
5053
import { usePanelStore, type SortOption } from "@/stores/panel-store";
5154
import { formatTimeAgo } from "@/utils/time";
5255
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
@@ -168,22 +171,45 @@ export function FileExplorerPane({
168171
return;
169172
}
170173
hasInitializedRef.current = true;
171-
void requestDirectoryListing(".", {
172-
recordHistory: false,
173-
setCurrentPath: false,
174-
});
175-
const persistedPaths =
176-
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
177-
if (persistedPaths) {
178-
for (const path of persistedPaths) {
179-
if (path !== ".") {
180-
void requestDirectoryListing(path, {
174+
void (async () => {
175+
await requestDirectoryListing(".", {
176+
recordHistory: false,
177+
setCurrentPath: false,
178+
});
179+
if (!workspaceStateKey) {
180+
return;
181+
}
182+
const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey];
183+
if (!persistedPaths || persistedPaths.length === 0) {
184+
return;
185+
}
186+
const candidates = persistedPaths.filter((path) => path !== ".");
187+
if (candidates.length === 0) {
188+
return;
189+
}
190+
// Hydrate persisted expansions silently so that one missing subdirectory
191+
// (e.g. deleted on disk while the app was closed) does not poison the
192+
// whole panel with a panel-level error. Prune the failed entries from
193+
// the persisted state so subsequent opens stay clean.
194+
const results = await Promise.all(
195+
candidates.map((path) =>
196+
requestDirectoryListing(path, {
181197
recordHistory: false,
182198
setCurrentPath: false,
183-
});
184-
}
199+
silent: true,
200+
}).then((ok) => ({ path, ok })),
201+
),
202+
);
203+
const failed = new Set(results.filter((r) => !r.ok).map((r) => r.path));
204+
if (failed.size === 0) {
205+
return;
185206
}
186-
}
207+
const latest = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey] ?? [];
208+
const pruned = dropFailedExpandedPaths(latest, failed);
209+
if (pruned.length !== latest.length) {
210+
usePanelStore.getState().setExpandedPathsForWorkspace(workspaceStateKey, pruned);
211+
}
212+
})();
187213
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
188214

189215
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, it } from "vitest";
2+
import { dropFailedExpandedPaths } from "./use-file-explorer-actions";
3+
4+
describe("dropFailedExpandedPaths", () => {
5+
it("returns a copy of the input when nothing failed", () => {
6+
const input = [".", "src", "src/utils"];
7+
const result = dropFailedExpandedPaths(input, new Set());
8+
expect(result).toEqual([".", "src", "src/utils"]);
9+
expect(result).not.toBe(input);
10+
});
11+
12+
it("removes failed paths and keeps survivors plus '.'", () => {
13+
expect(
14+
dropFailedExpandedPaths([".", "vendor", "src", "src/utils"], new Set(["vendor"])),
15+
).toEqual([".", "src", "src/utils"]);
16+
});
17+
18+
it("removes multiple failed paths in a nested tree", () => {
19+
expect(
20+
dropFailedExpandedPaths(
21+
[".", "a/b", "vendor/foo", "vendor/bar", "src"],
22+
new Set(["vendor/foo", "vendor/bar"]),
23+
),
24+
).toEqual([".", "a/b", "src"]);
25+
});
26+
27+
it("ignores failures that are not in the current list", () => {
28+
expect(dropFailedExpandedPaths([".", "src"], new Set(["does-not-exist"]))).toEqual([
29+
".",
30+
"src",
31+
]);
32+
});
33+
});

packages/app/src/hooks/use-file-explorer-actions.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ export function buildWorkspaceExplorerStateKey(scope: FileExplorerWorkspaceScope
4949
return `root:${normalizedWorkspaceRoot}`;
5050
}
5151

52+
export function dropFailedExpandedPaths(
53+
current: readonly string[],
54+
failed: ReadonlySet<string>,
55+
): string[] {
56+
if (failed.size === 0) {
57+
return [...current];
58+
}
59+
return current.filter((path) => !failed.has(path));
60+
}
61+
5262
export function useFileExplorerActions(params: { serverId: string } & FileExplorerWorkspaceScope) {
5363
const { serverId, workspaceId, workspaceRoot } = params;
5464
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
@@ -82,18 +92,22 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
8292
);
8393

8494
const requestDirectoryListing = useCallback(
85-
async (path: string, options?: { recordHistory?: boolean; setCurrentPath?: boolean }) => {
95+
async (
96+
path: string,
97+
options?: { recordHistory?: boolean; setCurrentPath?: boolean; silent?: boolean },
98+
): Promise<boolean> => {
8699
if (!workspaceStateKey) {
87-
return;
100+
return false;
88101
}
89102
const normalizedPath = path && path.length > 0 ? path : ".";
90103
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
91104
const shouldRecordHistory = options?.recordHistory ?? (shouldSetCurrentPath ? true : false);
105+
const silent = options?.silent ?? false;
92106

93107
updateExplorerState((state) => ({
94108
...state,
95109
isLoading: true,
96-
lastError: null,
110+
lastError: silent ? state.lastError : null,
97111
pendingRequest: { path: normalizedPath, mode: "list" },
98112
...(shouldSetCurrentPath
99113
? {
@@ -110,20 +124,20 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
110124
updateExplorerState((state) => ({
111125
...state,
112126
isLoading: false,
113-
lastError: "Workspace is unavailable",
127+
lastError: silent ? state.lastError : "Workspace is unavailable",
114128
pendingRequest: null,
115129
}));
116-
return;
130+
return false;
117131
}
118132

119133
if (!client) {
120134
updateExplorerState((state) => ({
121135
...state,
122136
isLoading: false,
123-
lastError: "Host is not connected",
137+
lastError: silent ? state.lastError : "Host is not connected",
124138
pendingRequest: null,
125139
}));
126-
return;
140+
return false;
127141
}
128142

129143
try {
@@ -132,31 +146,38 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
132146
normalizedPath,
133147
"list",
134148
);
149+
const succeeded = !payload.error && Boolean(payload.directory);
135150
updateExplorerState((state) => {
136151
const nextState: AgentFileExplorerState = {
137152
...state,
138153
isLoading: false,
139-
lastError: payload.error ?? null,
154+
lastError: succeeded ? null : silent ? state.lastError : (payload.error ?? null),
140155
pendingRequest: null,
141156
directories: state.directories,
142157
files: state.files,
143158
};
144159

145-
if (!payload.error && payload.directory) {
160+
if (succeeded && payload.directory) {
146161
const directories = new Map(state.directories);
147162
directories.set(payload.directory.path, payload.directory);
148163
nextState.directories = directories;
149164
}
150165

151166
return nextState;
152167
});
168+
return succeeded;
153169
} catch (error) {
154170
updateExplorerState((state) => ({
155171
...state,
156172
isLoading: false,
157-
lastError: error instanceof Error ? error.message : "Failed to list directory",
173+
lastError: silent
174+
? state.lastError
175+
: error instanceof Error
176+
? error.message
177+
: "Failed to list directory",
158178
pendingRequest: null,
159179
}));
180+
return false;
160181
}
161182
},
162183
[client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey],

0 commit comments

Comments
 (0)