From a24a8d8cd2e97afb01b72a48dde1f21256ee8665 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 21 Jan 2026 12:25:06 +0800 Subject: [PATCH] feat: replace chokidar with native fs.watch for file tree monitoring - Use native fs.watch({ recursive: true }) instead of chokidar - Fixes EMFILE "too many open files" errors on large directories - Remove redundant tool-based file change detection (fallback mechanism) - Add session-to-directory mapping for shared watchers - Add 100ms debouncing for file change events - Add directory existence check before watching (Craft Agents pattern) Co-Authored-By: Claude Opus 4.5 --- src/main/index.ts | 17 +- src/main/ipc/handlers.ts | 20 +- src/main/watcher/FileWatcher.ts | 281 +++++++++++++++++++++ src/main/watcher/index.ts | 1 + src/preload/index.ts | 14 + src/renderer/src/components/FileTree.tsx | 2 +- src/renderer/src/hooks/useApp.ts | 90 ++++--- src/renderer/src/stores/fileChangeStore.ts | 29 ++- src/shared/electron-api.d.ts | 15 ++ src/shared/ipc-channels.ts | 5 + 10 files changed, 419 insertions(+), 55 deletions(-) create mode 100644 src/main/watcher/FileWatcher.ts create mode 100644 src/main/watcher/index.ts diff --git a/src/main/index.ts b/src/main/index.ts index 155d2c1e05..5190f474cd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -18,12 +18,14 @@ import { IPC_CHANNELS } from '../shared/ipc-channels' import type { PermissionResponse } from '../shared/electron-api' import { PermissionManager } from './permission' import { createUpdater, AutoUpdater } from './updater' +import { FileWatcher } from './watcher' // Global instances let conductor: Conductor let mainWindow: BrowserWindow | null = null let permissionManager: PermissionManager let updater: AutoUpdater +let fileWatcher: FileWatcher function createWindow(): BrowserWindow { const window = new BrowserWindow({ @@ -130,8 +132,14 @@ app.whenReady().then(async () => { permissionManager.handlePermissionResponse(response) }) + // Initialize file watcher + fileWatcher = new FileWatcher({ + debounceMs: 300, + getMainWindow: () => mainWindow + }) + // Register IPC handlers - registerIPCHandlers(conductor) + registerIPCHandlers(conductor, fileWatcher) mainWindow = createWindow() @@ -180,3 +188,10 @@ app.on('window-all-closed', () => { app.quit() } }) + +// Clean up file watcher on quit +app.on('will-quit', () => { + if (fileWatcher) { + fileWatcher.unwatchAll() + } +}) diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index fcee144096..b7f5447afc 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -8,6 +8,7 @@ import { DEFAULT_AGENTS } from '../config/defaults' import { checkAgents, checkAgent } from '../utils/agent-check' import { installAgent } from '../utils/agent-install' import type { Conductor } from '../conductor/Conductor' +import type { FileWatcher } from '../watcher' import type { ListSessionsOptions, MulticaSession, @@ -89,7 +90,7 @@ function extractErrorMessage(err: unknown): string { return String(err) } -export function registerIPCHandlers(conductor: Conductor): void { +export function registerIPCHandlers(conductor: Conductor, fileWatcher: FileWatcher): void { // --- Agent handlers (per-session) --- ipcMain.handle( @@ -442,6 +443,23 @@ export function registerIPCHandlers(conductor: Conductor): void { } ) + // --- File watcher handlers --- + + ipcMain.handle( + IPC_CHANNELS.FS_WATCH_START, + async (_event, sessionId: string, directory: string) => { + if (!isValidPath(directory)) { + console.warn(`[IPC] Invalid watch path rejected: ${directory}`) + throw new Error('Invalid directory path') + } + fileWatcher.watch(sessionId, directory) + } + ) + + ipcMain.handle(IPC_CHANNELS.FS_WATCH_STOP, async (_event, sessionId: string) => { + fileWatcher.unwatch(sessionId) + }) + // Terminal: Run command in a new terminal window ipcMain.handle(IPC_CHANNELS.TERMINAL_RUN, async (_event, command: string) => { if (!command || typeof command !== 'string') { diff --git a/src/main/watcher/FileWatcher.ts b/src/main/watcher/FileWatcher.ts new file mode 100644 index 0000000000..5ed0f1c57c --- /dev/null +++ b/src/main/watcher/FileWatcher.ts @@ -0,0 +1,281 @@ +/** + * FileWatcher - Manages native fs.watch file system watchers for sessions + * + * Uses native fs.watch with recursive: true to leverage platform-specific + * efficient watching mechanisms: + * - macOS: FSEvents (kernel-level API, no per-file descriptor needed) + * - Linux: inotify + * - Windows: ReadDirectoryChangesW + * + * This avoids the EMFILE (too many open files) error that can occur with + * chokidar when watching large directories with many subdirectories. + * + * Responsibilities: + * - Create watchers for session working directories + * - Debounce file change events + * - Filter out ignored directories (node_modules, .git, etc.) + * - Notify renderer process of changes + * - Clean up watchers when sessions close + */ +import { watch, existsSync, type FSWatcher } from 'fs' +import path from 'path' +import type { BrowserWindow } from 'electron' +import { IPC_CHANNELS } from '../../shared/ipc-channels' + +interface FileWatcherOptions { + debounceMs?: number + getMainWindow: () => BrowserWindow | null +} + +// Directories to ignore - checked by path component +const IGNORED_DIRS = new Set([ + 'node_modules', + '.git', + 'dist', + 'build', + '.next', + '.cache', + 'coverage', + '.vscode', + '.idea', + 'venv', + '__pycache__', + 'target', + '.gradle', + 'out', + '.turbo', + '.svn', + '.hg' +]) + +// Files to ignore by name +const IGNORED_FILES = new Set(['.DS_Store', 'Thumbs.db']) + +const DEFAULT_DEBOUNCE_MS = 100 + +export class FileWatcher { + private watchers: Map = new Map() // directory -> FSWatcher + private sessionToDirectory: Map = new Map() // sessionId -> directory + private directoryToSessions: Map> = new Map() // directory -> sessionIds + private debounceTimers: Map = new Map() // debounceKey -> timer + private debounceMs: number + private getMainWindow: () => BrowserWindow | null + + constructor(options: FileWatcherOptions) { + this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS + this.getMainWindow = options.getMainWindow + } + + /** + * Check if a relative path should be ignored based on its components + */ + private shouldIgnore(relativePath: string): boolean { + const parts = relativePath.split(path.sep) + // Check if any path component is an ignored directory + for (const part of parts) { + if (IGNORED_DIRS.has(part)) { + return true + } + } + // Check if the filename is ignored + const filename = parts[parts.length - 1] + if (filename && IGNORED_FILES.has(filename)) { + return true + } + // Ignore log files + if (filename && filename.endsWith('.log')) { + return true + } + return false + } + + /** + * Debounce a handler by key + */ + private debounce(key: string, handler: () => void): void { + const existing = this.debounceTimers.get(key) + if (existing) { + clearTimeout(existing) + } + + const timer = setTimeout(() => { + this.debounceTimers.delete(key) + handler() + }, this.debounceMs) + + this.debounceTimers.set(key, timer) + } + + /** + * Start watching a directory for a session + */ + watch(sessionId: string, directory: string): void { + // Normalize directory path + const normalizedDir = path.normalize(directory) + + // Check if directory exists (reference: Craft Agents pattern) + if (!existsSync(normalizedDir)) { + console.warn(`[FileWatcher] Directory does not exist: ${normalizedDir}`) + return + } + + // Track session -> directory mapping + this.sessionToDirectory.set(sessionId, normalizedDir) + + // Add to directory -> sessions mapping + let sessions = this.directoryToSessions.get(normalizedDir) + if (!sessions) { + sessions = new Set() + this.directoryToSessions.set(normalizedDir, sessions) + } + sessions.add(sessionId) + + // Check if we already have a watcher for this directory + if (this.watchers.has(normalizedDir)) { + console.log(`[FileWatcher] Session ${sessionId} joined existing watcher for ${normalizedDir}`) + return + } + + // Create new watcher + console.log(`[FileWatcher] Creating watcher for ${normalizedDir}`) + + try { + // Use native fs.watch with recursive: true + // On macOS, this uses FSEvents which doesn't have EMFILE issues + const watcher = watch(normalizedDir, { recursive: true }, (eventType, filename) => { + // filename can be null in some edge cases + if (!filename) return + + // Check if this path should be ignored + if (this.shouldIgnore(filename)) { + return + } + + const fullPath = path.join(normalizedDir, filename) + + // Use directory + filename as debounce key for per-file debouncing + const debounceKey = `${normalizedDir}:${filename}` + + this.debounce(debounceKey, () => { + this.notifyChange(normalizedDir, eventType, fullPath) + }) + }) + + watcher.on('error', (error) => { + const errCode = (error as NodeJS.ErrnoException).code + console.error(`[FileWatcher] Error watching ${normalizedDir}:`, error) + + // If EMFILE error, clean up this watcher to prevent spam + // (Reference: Craft Agents just logs errors, but we add cleanup for safety) + if (errCode === 'EMFILE') { + console.warn(`[FileWatcher] EMFILE error, closing watcher for ${normalizedDir}`) + // Clear pending debounce timers for this directory + for (const [key, timer] of this.debounceTimers) { + if (key.startsWith(`${normalizedDir}:`)) { + clearTimeout(timer) + this.debounceTimers.delete(key) + } + } + watcher.close() + this.watchers.delete(normalizedDir) + } + }) + + this.watchers.set(normalizedDir, watcher) + } catch (error) { + console.error(`[FileWatcher] Failed to create watcher for ${normalizedDir}:`, error) + } + } + + /** + * Stop watching a directory for a session + */ + unwatch(sessionId: string): void { + const directory = this.sessionToDirectory.get(sessionId) + if (!directory) return + + this.sessionToDirectory.delete(sessionId) + + const sessions = this.directoryToSessions.get(directory) + if (sessions) { + sessions.delete(sessionId) + + // If no more sessions watching this directory, close the watcher + if (sessions.size === 0) { + this.directoryToSessions.delete(directory) + + // Clear any pending debounce timers for this directory + for (const [key, timer] of this.debounceTimers) { + if (key.startsWith(`${directory}:`)) { + clearTimeout(timer) + this.debounceTimers.delete(key) + } + } + + const watcher = this.watchers.get(directory) + if (watcher) { + console.log(`[FileWatcher] Closing watcher for ${directory}`) + watcher.close() + this.watchers.delete(directory) + } + } + } + } + + /** + * Stop all watchers + */ + unwatchAll(): void { + // Clear all debounce timers + for (const timer of this.debounceTimers.values()) { + clearTimeout(timer) + } + this.debounceTimers.clear() + + // Close all watchers + for (const [directory, watcher] of this.watchers) { + console.log(`[FileWatcher] Closing watcher for ${directory}`) + watcher.close() + } + this.watchers.clear() + this.sessionToDirectory.clear() + this.directoryToSessions.clear() + } + + /** + * Notify renderer of file change + */ + private notifyChange(directory: string, eventType: string, filePath: string): void { + const mainWindow = this.getMainWindow() + if (!mainWindow || mainWindow.isDestroyed()) return + + const sessions = this.directoryToSessions.get(directory) + if (!sessions || sessions.size === 0) return + + // Map fs.watch eventType to our event types + // fs.watch only provides 'rename' (add/delete) and 'change' (modify) + const normalizedEventType = eventType === 'rename' ? 'change' : eventType + + // Send change notification with affected session IDs + mainWindow.webContents.send(IPC_CHANNELS.FS_FILE_CHANGED, { + directory, + eventType: normalizedEventType, + path: filePath, + sessionIds: Array.from(sessions) + }) + } + + /** + * Get watching status for a session + */ + isWatching(sessionId: string): boolean { + return this.sessionToDirectory.has(sessionId) + } + + /** + * Get directory being watched for a session + */ + getWatchedDirectory(sessionId: string): string | undefined { + return this.sessionToDirectory.get(sessionId) + } +} diff --git a/src/main/watcher/index.ts b/src/main/watcher/index.ts new file mode 100644 index 0000000000..f4e4798be2 --- /dev/null +++ b/src/main/watcher/index.ts @@ -0,0 +1 @@ +export { FileWatcher } from './FileWatcher' diff --git a/src/preload/index.ts b/src/preload/index.ts index 52842b9748..faa715541a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -86,6 +86,20 @@ const electronAPI: ElectronAPI = { detectApps: () => ipcRenderer.invoke(IPC_CHANNELS.FS_DETECT_APPS), openWith: (options: OpenWithOptions) => ipcRenderer.invoke(IPC_CHANNELS.FS_OPEN_WITH, options), + // File watcher + startFileWatch: (sessionId: string, directory: string) => + ipcRenderer.invoke(IPC_CHANNELS.FS_WATCH_START, sessionId, directory), + stopFileWatch: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.FS_WATCH_STOP, sessionId), + onFileChanged: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, changeEvent: unknown): void => { + callback(changeEvent as Parameters[0]) + } + ipcRenderer.on(IPC_CHANNELS.FS_FILE_CHANGED, listener) + return (): void => { + ipcRenderer.removeListener(IPC_CHANNELS.FS_FILE_CHANGED, listener) + } + }, + // Event listeners onAgentMessage: (callback) => { const listener = (_event: Electron.IpcRendererEvent, message: unknown): void => { diff --git a/src/renderer/src/components/FileTree.tsx b/src/renderer/src/components/FileTree.tsx index bbfcf749b0..c76f21985e 100644 --- a/src/renderer/src/components/FileTree.tsx +++ b/src/renderer/src/components/FileTree.tsx @@ -358,7 +358,7 @@ export function FileTree({ rootPath, directoryExists = true }: FileTreeProps): R expandedPathsRef.current = expandedPaths }, [expandedPaths]) - // Refresh file tree when files change (from agent tool calls) + // Refresh file tree when files change (from file system watcher) useEffect(() => { // Skip on initial mount (already loaded above) if (isInitialMount.current) { diff --git a/src/renderer/src/hooks/useApp.ts b/src/renderer/src/hooks/useApp.ts index 2208ce55bc..a7d73daf3b 100644 --- a/src/renderer/src/hooks/useApp.ts +++ b/src/renderer/src/hooks/useApp.ts @@ -18,11 +18,6 @@ import { useCommandStore } from '../stores/commandStore' import { toast } from 'sonner' import { getErrorMessage } from '../utils/error' -// ACP standard tool kinds that modify files (used for Codex and other agents) -const FILE_MODIFYING_KINDS = new Set(['edit', 'write', 'delete', 'execute']) -// Actual tool names from _meta.claudeCode.toolName (used for Claude Code) -const FILE_MODIFYING_TOOL_NAMES = new Set(['write', 'edit', 'bash', 'notebookedit']) - // Auth commands for each agent const AGENT_AUTH_COMMANDS: Record = { 'claude-code': 'claude login', @@ -80,10 +75,6 @@ export interface AppActions { } export function useApp(): AppState & AppActions { - // Store toolCallId -> kind mapping for file tree refresh - // This is needed because tool_call_update events don't include kind - const toolKindMapRef = useRef>(new Map()) - // Track pending session selection to handle rapid switching const pendingSessionRef = useRef(null) @@ -111,7 +102,7 @@ export function useApp(): AppState & AppActions { ? runningSessionsStatus.processingSessionIds.includes(currentSession.id) : false - // Note: File tree refresh is triggered by tool completion (see onAgentMessage handler below) + // Note: File tree refresh is handled by file system watcher (see fs:file-changed handler below) // No need for periodic refresh - it causes performance issues // Load sessions on mount @@ -152,12 +143,53 @@ export function useApp(): AppState & AppActions { } }, [currentSessionId]) + // Subscribe to file system change events (runs once on mount) + useEffect(() => { + const handleFileChange = useFileChangeStore.getState().handleFileChange + + const unsubFileChange = window.electronAPI.onFileChanged((event) => { + // Notify the store of file changes for each affected session + for (const sessionId of event.sessionIds) { + handleFileChange(sessionId) + } + }) + + return () => { + unsubFileChange() + } + }, []) + + // Start/stop file watching when current session changes + useEffect(() => { + const setWatchedSession = useFileChangeStore.getState().setWatchedSession + + if (currentSession) { + // Start watching the session's working directory + setWatchedSession(currentSession.id) + window.electronAPI + .startFileWatch(currentSession.id, currentSession.workingDirectory) + .catch((err) => { + console.error('[useApp] Failed to start file watch:', err) + }) + } else { + setWatchedSession(null) + } + + // Cleanup: stop watching when session changes or component unmounts + return () => { + if (currentSession) { + window.electronAPI.stopFileWatch(currentSession.id).catch((err) => { + console.error('[useApp] Failed to stop file watch:', err) + }) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentional: only re-run when id or workingDirectory changes + }, [currentSession?.id, currentSession?.workingDirectory]) + // Subscribe to agent events (persistent subscription - only runs once on mount) // Uses refs to access current session ID, avoiding race condition where subscription // is being recreated while events are arriving useEffect(() => { - // Get triggerRefresh from store for file change detection - const triggerRefresh = useFileChangeStore.getState().triggerRefresh // Get setAvailableCommands from store for command updates const setAvailableCommands = useCommandStore.getState().setAvailableCommands @@ -170,36 +202,7 @@ export function useApp(): AppState & AppActions { return } - // Check for file-modifying tool completion to trigger FileTree refresh const update = message.update - const status = update?.status?.toLowerCase() || '' - const toolCallId = update?.toolCallId - - // Get tool name from _meta.claudeCode.toolName (Claude Code specific) - const meta = update?._meta as { claudeCode?: { toolName?: string } } | undefined - const toolName = meta?.claudeCode?.toolName?.toLowerCase() || '' - - // Handle tool_call event: store toolCallId -> kind mapping - // This is needed because tool_call_update events don't include kind (for Codex etc.) - if (update?.sessionUpdate === 'tool_call' && toolCallId && update?.kind) { - const kind = update.kind.toLowerCase() - toolKindMapRef.current.set(toolCallId, kind) - } - - // Handle tool_call_update event: check if we should trigger refresh - if (update?.sessionUpdate === 'tool_call_update' && toolCallId) { - // Get kind from our stored mapping (for Codex) or from toolName (for Claude Code) - const storedKind = toolKindMapRef.current.get(toolCallId) || '' - const isFileModifying = - FILE_MODIFYING_KINDS.has(storedKind) || FILE_MODIFYING_TOOL_NAMES.has(toolName) - const isCompleted = status === 'completed' || status === 'failed' - - if (isFileModifying && isCompleted) { - triggerRefresh() - // Clean up the mapping - toolKindMapRef.current.delete(toolCallId) - } - } // Handle available_commands_update event: update slash commands store if (update?.sessionUpdate === 'available_commands_update') { @@ -250,11 +253,6 @@ export function useApp(): AppState & AppActions { } }, []) - // Clear toolKindMap when session changes to avoid stale data - useEffect(() => { - toolKindMapRef.current.clear() - }, [currentSessionId]) - // Actions const loadSessions = useCallback(async () => { try { diff --git a/src/renderer/src/stores/fileChangeStore.ts b/src/renderer/src/stores/fileChangeStore.ts index 7f360465ca..c0064e9e0d 100644 --- a/src/renderer/src/stores/fileChangeStore.ts +++ b/src/renderer/src/stores/fileChangeStore.ts @@ -1,6 +1,6 @@ /** * File change state management - * Tracks file changes from agent tool calls to trigger FileTree refresh + * Tracks file changes from the file system watcher (native fs.watch) */ import { create } from 'zustand' @@ -8,14 +8,31 @@ interface FileChangeStore { // Counter that increments when files change - used to trigger refresh refreshCounter: number - // Trigger a file refresh - triggerRefresh: () => void + // Active session being watched + watchedSessionId: string | null + + // Handle file system change event from watcher + handleFileChange: (sessionId: string) => void + + // Set watched session + setWatchedSession: (sessionId: string | null) => void } -export const useFileChangeStore = create((set) => ({ +export const useFileChangeStore = create((set, get) => ({ refreshCounter: 0, + watchedSessionId: null, - triggerRefresh: () => { - set((state) => ({ refreshCounter: state.refreshCounter + 1 })) + handleFileChange: (sessionId: string) => { + const state = get() + // Only trigger refresh if the change is for the watched session + if (state.watchedSessionId === sessionId) { + set((state) => ({ + refreshCounter: state.refreshCounter + 1 + })) + } + }, + + setWatchedSession: (sessionId: string | null) => { + set({ watchedSessionId: sessionId }) } })) diff --git a/src/shared/electron-api.d.ts b/src/shared/electron-api.d.ts index 55a1f32e3c..ca38798dc2 100644 --- a/src/shared/electron-api.d.ts +++ b/src/shared/electron-api.d.ts @@ -116,6 +116,16 @@ export interface OpenWithOptions { appId: string } +// File watcher types +// Note: fs.watch only provides 'rename' (add/delete) and 'change' (modify) +// We normalize 'rename' to 'change' for simplicity +export interface FileChangeEvent { + directory: string + eventType: 'change' | 'rename' + path: string + sessionIds: string[] +} + // Agent installation export type InstallStep = 'check-npm' | 'install-cli' | 'install-acp' @@ -201,6 +211,11 @@ export interface ElectronAPI { detectApps(): Promise openWith(options: OpenWithOptions): Promise + // File watcher + startFileWatch(sessionId: string, directory: string): Promise + stopFileWatch(sessionId: string): Promise + onFileChanged(callback: (event: FileChangeEvent) => void): () => void + // Event listeners (return unsubscribe function) onAgentMessage(callback: (message: AgentMessage) => void): () => void onAgentStatus(callback: (status: RunningSessionsStatus) => void): () => void diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index fb9e29cae6..855fd150b0 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -54,6 +54,11 @@ export const IPC_CHANNELS = { FS_DETECT_APPS: 'fs:detect-apps', FS_OPEN_WITH: 'fs:open-with', + // File watcher + FS_WATCH_START: 'fs:watch-start', + FS_WATCH_STOP: 'fs:watch-stop', + FS_FILE_CHANGED: 'fs:file-changed', + // Terminal TERMINAL_RUN: 'terminal:run',