Merge pull request #74 from multica-ai/feat/native-file-watcher

feat: replace chokidar with native fs.watch for file tree monitoring
This commit is contained in:
Naiyuan Qing
2026-01-21 12:58:01 +08:00
committed by GitHub
10 changed files with 419 additions and 55 deletions

View File

@@ -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()
}
})

View File

@@ -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') {

View File

@@ -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<string, FSWatcher> = new Map() // directory -> FSWatcher
private sessionToDirectory: Map<string, string> = new Map() // sessionId -> directory
private directoryToSessions: Map<string, Set<string>> = new Map() // directory -> sessionIds
private debounceTimers: Map<string, NodeJS.Timeout> = 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)
}
}

View File

@@ -0,0 +1 @@
export { FileWatcher } from './FileWatcher'

View File

@@ -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<typeof callback>[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 => {

View File

@@ -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) {

View File

@@ -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<string, string> = {
'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<Map<string, string>>(new Map())
// Track pending session selection to handle rapid switching
const pendingSessionRef = useRef<string | null>(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 {

View File

@@ -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<FileChangeStore>((set) => ({
export const useFileChangeStore = create<FileChangeStore>((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 })
}
}))

View File

@@ -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<DetectedApp[]>
openWith(options: OpenWithOptions): Promise<void>
// File watcher
startFileWatch(sessionId: string, directory: string): Promise<void>
stopFileWatch(sessionId: string): Promise<void>
onFileChanged(callback: (event: FileChangeEvent) => void): () => void
// Event listeners (return unsubscribe function)
onAgentMessage(callback: (message: AgentMessage) => void): () => void
onAgentStatus(callback: (status: RunningSessionsStatus) => void): () => void

View File

@@ -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',