Skip to content

Commit 2c0ae30

Browse files
OpenSource03claude
andcommitted
fix: resolve project-switch freeze from sync file I/O and watcher storm
- Convert listFilesWalk and listAllFiles from sync to async with event loop yielding - Replace per-directory fs.watch swarm with single recursive watcher per project - Gate ProjectFilesPanel fetching/watching behind enabled prop to skip work when panel is hidden Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 225389d commit 2c0ae30

File tree

6 files changed

+79
-127
lines changed

6 files changed

+79
-127
lines changed

electron/src/ipc/files.ts

Lines changed: 56 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,22 @@ function isIgnoredByPatterns(name: string, patterns: string[]): boolean {
4848
return false;
4949
}
5050

51-
function listFilesWalk(cwd: string, maxFiles = 10000): string[] {
51+
function yieldToEventLoop(): Promise<void> {
52+
return new Promise((resolve) => setImmediate(resolve));
53+
}
54+
55+
async function listFilesWalk(cwd: string, maxFiles = 10000): Promise<string[]> {
5256
const files: string[] = [];
5357
const queue: string[] = [""];
58+
let visitedDirs = 0;
5459

5560
while (queue.length > 0 && files.length < maxFiles) {
5661
const rel = queue.shift()!;
5762
const abs = rel ? path.join(cwd, rel) : cwd;
5863

5964
let entries: fs.Dirent[];
6065
try {
61-
entries = fs.readdirSync(abs, { withFileTypes: true });
66+
entries = await fs.promises.readdir(abs, { withFileTypes: true });
6267
} catch {
6368
continue;
6469
}
@@ -78,6 +83,11 @@ function listFilesWalk(cwd: string, maxFiles = 10000): string[] {
7883
files.push(entryRel);
7984
}
8085
}
86+
87+
visitedDirs += 1;
88+
if (visitedDirs % 25 === 0) {
89+
await yieldToEventLoop();
90+
}
8191
}
8292

8393
return files.sort();
@@ -88,119 +98,27 @@ async function listProjectFiles(cwd: string): Promise<string[]> {
8898
return await listFilesGit(cwd);
8999
} catch {
90100
log("FILES:LIST", "Not a git repo, falling back to filesystem walk");
91-
return listFilesWalk(cwd);
101+
return await listFilesWalk(cwd);
92102
}
93103
}
94104

95105
/** Dirs to skip in the full filesystem walk (VCS internals + massive dependency dirs). */
96106
const EXPLORER_SKIP = new Set([".git", ".hg", ".svn", "node_modules"]);
97107

108+
// ── Recursive file watcher ──
109+
// Uses a single fs.watch(cwd, { recursive: true }) per project root.
110+
// macOS (FSEvents) and Windows (ReadDirectoryChangesW) handle this natively
111+
// with one kernel-level watcher for the entire subtree — no directory walking,
112+
// no thousands of individual watchers, instant setup and teardown.
113+
98114
interface ProjectWatchState {
99115
refCount: number;
100-
watchers: Map<string, fs.FSWatcher>;
116+
watcher: fs.FSWatcher;
101117
notifyTimer?: ReturnType<typeof setTimeout>;
102-
syncTimer?: ReturnType<typeof setTimeout>;
103118
}
104119

105120
const projectWatchers = new Map<string, ProjectWatchState>();
106121

107-
function collectWatchDirs(cwd: string, maxDirs = 5000): string[] {
108-
const dirs = [cwd];
109-
const queue = [cwd];
110-
111-
while (queue.length > 0 && dirs.length < maxDirs) {
112-
const dir = queue.shift()!;
113-
114-
let entries: fs.Dirent[];
115-
try {
116-
entries = fs.readdirSync(dir, { withFileTypes: true });
117-
} catch {
118-
continue;
119-
}
120-
121-
for (const entry of entries) {
122-
if (!entry.isDirectory() || EXPLORER_SKIP.has(entry.name)) continue;
123-
const childDir = path.join(dir, entry.name);
124-
dirs.push(childDir);
125-
queue.push(childDir);
126-
if (dirs.length >= maxDirs) break;
127-
}
128-
}
129-
130-
return dirs;
131-
}
132-
133-
function closeProjectWatchers(state: ProjectWatchState): void {
134-
if (state.notifyTimer) clearTimeout(state.notifyTimer);
135-
if (state.syncTimer) clearTimeout(state.syncTimer);
136-
for (const watcher of state.watchers.values()) {
137-
watcher.close();
138-
}
139-
state.watchers.clear();
140-
}
141-
142-
function scheduleWatcherNotify(
143-
cwd: string,
144-
getMainWindow: () => BrowserWindow | null,
145-
): void {
146-
const state = projectWatchers.get(cwd);
147-
if (!state || state.notifyTimer) return;
148-
149-
state.notifyTimer = setTimeout(() => {
150-
const current = projectWatchers.get(cwd);
151-
if (!current) return;
152-
current.notifyTimer = undefined;
153-
safeSend(getMainWindow, "files:changed", { cwd });
154-
}, 150);
155-
}
156-
157-
function syncProjectWatchers(
158-
cwd: string,
159-
getMainWindow: () => BrowserWindow | null,
160-
): void {
161-
const state = projectWatchers.get(cwd);
162-
if (!state) return;
163-
164-
const nextDirs = new Set(collectWatchDirs(cwd));
165-
166-
for (const [dir, watcher] of state.watchers) {
167-
if (nextDirs.has(dir)) continue;
168-
watcher.close();
169-
state.watchers.delete(dir);
170-
}
171-
172-
for (const dir of nextDirs) {
173-
if (state.watchers.has(dir)) continue;
174-
try {
175-
const watcher = fs.watch(dir, { persistent: false }, () => {
176-
scheduleWatcherNotify(cwd, getMainWindow);
177-
scheduleWatcherSync(cwd, getMainWindow);
178-
});
179-
watcher.on("error", () => {
180-
scheduleWatcherSync(cwd, getMainWindow);
181-
});
182-
state.watchers.set(dir, watcher);
183-
} catch {
184-
// Ignore transient watch failures; the next sync will retry.
185-
}
186-
}
187-
}
188-
189-
function scheduleWatcherSync(
190-
cwd: string,
191-
getMainWindow: () => BrowserWindow | null,
192-
): void {
193-
const state = projectWatchers.get(cwd);
194-
if (!state || state.syncTimer) return;
195-
196-
state.syncTimer = setTimeout(() => {
197-
const current = projectWatchers.get(cwd);
198-
if (!current) return;
199-
current.syncTimer = undefined;
200-
syncProjectWatchers(cwd, getMainWindow);
201-
}, 250);
202-
}
203-
204122
function startProjectWatcher(
205123
cwd: string,
206124
getMainWindow: () => BrowserWindow | null,
@@ -211,12 +129,31 @@ function startProjectWatcher(
211129
return;
212130
}
213131

214-
const state: ProjectWatchState = {
215-
refCount: 1,
216-
watchers: new Map(),
217-
};
218-
projectWatchers.set(cwd, state);
219-
syncProjectWatchers(cwd, getMainWindow);
132+
const watcher = fs.watch(cwd, { recursive: true, persistent: false }, (_eventType, filename) => {
133+
// Ignore changes in directories we don't care about (node_modules, .git, etc.)
134+
if (filename) {
135+
const firstSegment = filename.split(path.sep)[0];
136+
if (ALWAYS_SKIP.has(firstSegment) || firstSegment.startsWith(".")) return;
137+
}
138+
139+
const state = projectWatchers.get(cwd);
140+
if (!state || state.notifyTimer) return;
141+
142+
// Debounce: coalesce rapid changes into a single notification
143+
state.notifyTimer = setTimeout(() => {
144+
const current = projectWatchers.get(cwd);
145+
if (!current) return;
146+
current.notifyTimer = undefined;
147+
safeSend(getMainWindow, "files:changed", { cwd });
148+
}, 200);
149+
});
150+
151+
watcher.on("error", () => {
152+
// Watcher died (directory deleted, permissions, etc.) — clean up silently
153+
stopProjectWatcher(cwd);
154+
});
155+
156+
projectWatchers.set(cwd, { refCount: 1, watcher });
220157
}
221158

222159
function stopProjectWatcher(cwd: string): void {
@@ -226,7 +163,8 @@ function stopProjectWatcher(cwd: string): void {
226163
state.refCount = Math.max(0, state.refCount - 1);
227164
if (state.refCount > 0) return;
228165

229-
closeProjectWatchers(state);
166+
if (state.notifyTimer) clearTimeout(state.notifyTimer);
167+
state.watcher.close();
230168
projectWatchers.delete(cwd);
231169
}
232170

@@ -235,17 +173,18 @@ function stopProjectWatcher(cwd: string): void {
235173
* Only skips VCS internals and node_modules (too massive).
236174
* Used by the "Project Files" explorer panel.
237175
*/
238-
function listAllFiles(cwd: string, maxFiles = 10000): string[] {
176+
async function listAllFiles(cwd: string, maxFiles = 10000): Promise<string[]> {
239177
const files: string[] = [];
240178
const queue: string[] = [""];
179+
let visitedDirs = 0;
241180

242181
while (queue.length > 0 && files.length < maxFiles) {
243182
const rel = queue.shift()!;
244183
const abs = rel ? path.join(cwd, rel) : cwd;
245184

246185
let entries: fs.Dirent[];
247186
try {
248-
entries = fs.readdirSync(abs, { withFileTypes: true });
187+
entries = await fs.promises.readdir(abs, { withFileTypes: true });
249188
} catch {
250189
continue;
251190
}
@@ -260,6 +199,11 @@ function listAllFiles(cwd: string, maxFiles = 10000): string[] {
260199
files.push(entryRel);
261200
}
262201
}
202+
203+
visitedDirs += 1;
204+
if (visitedDirs % 25 === 0) {
205+
await yieldToEventLoop();
206+
}
263207
}
264208

265209
return files.sort();
@@ -348,7 +292,7 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
348292

349293
ipcMain.handle("files:list-all", async (_event, cwd: string) => {
350294
try {
351-
const files = listAllFiles(cwd);
295+
const files = await listAllFiles(cwd);
352296
const dirSet = new Set<string>();
353297
for (const file of files) {
354298
const parts = file.split("/");

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "harnss",
3-
"version": "0.18.0",
3+
"version": "0.18.1",
44
"productName": "Harnss",
55
"description": "Harness your AI coding agents — one desktop app for Claude Code, Codex, and any ACP agent",
66
"author": {
@@ -31,7 +31,7 @@
3131
"packageManager": "pnpm@10.26.0",
3232
"dependencies": {
3333
"@agentclientprotocol/sdk": "^0.15.0",
34-
"@anthropic-ai/claude-agent-sdk": "^0.2.71",
34+
"@anthropic-ai/claude-agent-sdk": "^0.2.72",
3535
"@huggingface/transformers": "^3.8.1",
3636
"@modelcontextprotocol/sdk": "^1.26.0",
3737
"@monaco-editor/react": "^4.7.0",

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/AppLayout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,7 @@ Link: ${issue.url}`;
694694
"project-files": (
695695
<ProjectFilesPanel
696696
cwd={activeProjectPath}
697+
enabled={activeTools.has("project-files")}
697698
onPreviewFile={handlePreviewFile}
698699
/>
699700
),
@@ -843,6 +844,7 @@ Link: ${issue.url}`;
843844
"project-files": (
844845
<ProjectFilesPanel
845846
cwd={activeProjectPath}
847+
enabled={activeTools.has("project-files")}
846848
onPreviewFile={handlePreviewFile}
847849
/>
848850
),

src/components/ProjectFilesPanel.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,18 @@ function getFileIconColor(extension?: string): string {
5353

5454
interface ProjectFilesPanelProps {
5555
cwd?: string;
56+
enabled: boolean;
5657
onPreviewFile?: (filePath: string, sourceRect: DOMRect) => void;
5758
}
5859

5960
// ── Component ──
6061

6162
export const ProjectFilesPanel = memo(function ProjectFilesPanel({
6263
cwd,
64+
enabled,
6365
onPreviewFile,
6466
}: ProjectFilesPanelProps) {
65-
const { tree, loading, error, refresh } = useProjectFiles(cwd);
67+
const { tree, loading, error, refresh } = useProjectFiles(cwd, enabled);
6668

6769
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(() => new Set());
6870
const [searchQuery, setSearchQuery] = useState("");

src/hooks/useProjectFiles.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ interface UseProjectFilesReturn {
1414
* Fetches the project file list via IPC and builds a nested tree.
1515
* Re-fetches when `cwd` changes. Returns loading/error states.
1616
*/
17-
export function useProjectFiles(cwd: string | undefined): UseProjectFilesReturn {
17+
export function useProjectFiles(
18+
cwd: string | undefined,
19+
enabled: boolean,
20+
): UseProjectFilesReturn {
1821
const [tree, setTree] = useState<FileTreeNode[] | null>(null);
1922
const [loading, setLoading] = useState(false);
2023
const [error, setError] = useState<string | null>(null);
@@ -46,15 +49,16 @@ export function useProjectFiles(cwd: string | undefined): UseProjectFilesReturn
4649
}, []);
4750

4851
useEffect(() => {
49-
if (!cwd) {
52+
if (!cwd || !enabled) {
53+
fetchIdRef.current += 1;
5054
setTree(null);
5155
setLoading(false);
5256
setError(null);
5357
return;
5458
}
5559

5660
fetchFiles(cwd);
57-
}, [cwd, fetchFiles]);
61+
}, [cwd, enabled, fetchFiles]);
5862

5963
const scheduleRefresh = useCallback((dir: string) => {
6064
clearTimeout(refreshTimerRef.current);
@@ -64,7 +68,7 @@ export function useProjectFiles(cwd: string | undefined): UseProjectFilesReturn
6468
}, [fetchFiles]);
6569

6670
useEffect(() => {
67-
if (!cwd) return;
71+
if (!cwd || !enabled) return;
6872

6973
void window.claude.files.watch(cwd);
7074
const unsubscribe = window.claude.files.onChanged(({ cwd: changedCwd }) => {
@@ -89,11 +93,11 @@ export function useProjectFiles(cwd: string | undefined): UseProjectFilesReturn
8993
clearTimeout(refreshTimerRef.current);
9094
void window.claude.files.unwatch(cwd);
9195
};
92-
}, [cwd, scheduleRefresh]);
96+
}, [cwd, enabled, scheduleRefresh]);
9397

9498
const refresh = useCallback(() => {
95-
if (cwd) fetchFiles(cwd);
96-
}, [cwd, fetchFiles]);
99+
if (cwd && enabled) fetchFiles(cwd);
100+
}, [cwd, enabled, fetchFiles]);
97101

98102
return { tree, loading, error, refresh };
99103
}

0 commit comments

Comments
 (0)