diff --git a/src/main/ipc/editor.ts b/src/main/ipc/editor.ts index d6211078..56f34ff9 100644 --- a/src/main/ipc/editor.ts +++ b/src/main/ipc/editor.ts @@ -22,6 +22,7 @@ import { EDITOR_READ_FILE, EDITOR_RENAME_FILE, EDITOR_SEARCH_IN_FILES, + EDITOR_SET_WATCHED_FILES, EDITOR_WATCH_DIR, EDITOR_WRITE_FILE, // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design @@ -352,6 +353,19 @@ async function handleEditorWatchDir( }); } +/** + * Update watched file list (open tabs). + */ +async function handleEditorSetWatchedFiles( + _event: IpcMainInvokeEvent, + filePaths: string[] +): Promise> { + return wrapHandler('setWatchedFiles', async () => { + if (!activeProjectRoot) throw new Error('Editor not initialized'); + editorFileWatcher.setWatchedFiles(Array.isArray(filePaths) ? filePaths : []); + }); +} + // ============================================================================= // Registration // ============================================================================= @@ -384,6 +398,7 @@ export function registerEditorHandlers(ipcMain: IpcMain): void { ipcMain.handle(EDITOR_READ_BINARY_PREVIEW, handleEditorReadBinaryPreview); ipcMain.handle(EDITOR_GIT_STATUS, handleEditorGitStatus); ipcMain.handle(EDITOR_WATCH_DIR, handleEditorWatchDir); + ipcMain.handle(EDITOR_SET_WATCHED_FILES, handleEditorSetWatchedFiles); } export function removeEditorHandlers(ipcMain: IpcMain): void { @@ -402,6 +417,7 @@ export function removeEditorHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(EDITOR_READ_BINARY_PREVIEW); ipcMain.removeHandler(EDITOR_GIT_STATUS); ipcMain.removeHandler(EDITOR_WATCH_DIR); + ipcMain.removeHandler(EDITOR_SET_WATCHED_FILES); } /** diff --git a/src/main/services/editor/EditorFileWatcher.ts b/src/main/services/editor/EditorFileWatcher.ts index 0fdd5b14..97e26f09 100644 --- a/src/main/services/editor/EditorFileWatcher.ts +++ b/src/main/services/editor/EditorFileWatcher.ts @@ -22,11 +22,8 @@ const log = createLogger('EditorFileWatcher'); // Constants // ============================================================================= -/** Directories to ignore (regex for chokidar's `ignored` option) */ -const IGNORED_PATTERN = - /(node_modules|\.git|dist|build|out|coverage|__pycache__|\.cache|\.next|\.turbo|\.parcel-cache|\.vite|\.venv|\.tox|vendor|target|Pods|DerivedData|\.idea|\.vscode|\.DS_Store)/; - -const MAX_DEPTH = 20; +const STARTUP_IGNORE_CHANGE_MS = 3000; +const MAX_EMITTED_EVENTS_PER_FLUSH = 300; // ============================================================================= // Service @@ -40,34 +37,69 @@ export class EditorFileWatcher { private onChangeCallback: ((event: EditorFileChangeEvent) => void) | null = null; // Higher debounce = fewer IPC events during large bursts (checkout/build/format). private readonly DEBOUNCE_MS = 350; + private ignoreChangeUntilMs = 0; + private watchedFilesKey = ''; /** - * Start watching a project directory. - * Idempotent: stops any existing watcher first. + * Initialize watcher context for a project root. + * + * Performance: does NOT watch the entire project directory. + * Use setWatchedFiles() to watch only open files (tabs). */ start(projectRoot: string, onChange: (event: EditorFileChangeEvent) => void): void { this.stop(); this.projectRoot = projectRoot; + this.ignoreChangeUntilMs = Date.now() + STARTUP_IGNORE_CHANGE_MS; + this.watchedFilesKey = ''; - log.info('Starting file watcher for:', projectRoot); + log.info('Starting file watcher (open files only) for:', projectRoot); + this.onChangeCallback = onChange; + } - this.watcher = watch(projectRoot, { - ignored: IGNORED_PATTERN, + /** + * Update list of watched file paths (open tabs). + * Rebuilds chokidar watcher when the set changes. + */ + setWatchedFiles(filePaths: string[]): void { + if (!this.projectRoot) { + throw new Error('Watcher not initialized'); + } + + const normalized = filePaths + .filter((p): p is string => typeof p === 'string' && p.length > 0) + .filter((p) => isPathWithinRoot(p, this.projectRoot!)); + + normalized.sort(); + const key = normalized.join('\n'); + if (key === this.watchedFilesKey) return; + this.watchedFilesKey = key; + + // Close existing watcher first (if any) + if (this.watcher) { + void this.watcher.close(); + this.watcher = null; + } + + if (normalized.length === 0) { + return; + } + + // Build a new watcher for the given file set. + // disableGlobbing prevents chokidar from treating file names as patterns. + this.watcher = watch(normalized, { ignoreInitial: true, ignorePermissionErrors: true, followSymlinks: false, - depth: MAX_DEPTH, }); - this.onChangeCallback = onChange; - const emitSafe = (type: EditorFileChangeEvent['type'], filePath: string): void => { - // SEC-2: validate path is within project root before sending to renderer - if (!isPathWithinRoot(filePath, projectRoot)) { + if (type === 'change' && Date.now() < this.ignoreChangeUntilMs) { + return; + } + if (!isPathWithinRoot(filePath, this.projectRoot!)) { log.warn('Watcher event outside project root, ignoring:', filePath); return; } - // Aggregate rapid events — only the last event type per path is kept this.pendingEvents.set(filePath, type); this.scheduleFlush(); }; @@ -91,6 +123,8 @@ export class EditorFileWatcher { } this.pendingEvents.clear(); this.onChangeCallback = null; + this.ignoreChangeUntilMs = 0; + this.watchedFilesKey = ''; if (this.watcher) { log.info('Stopping file watcher'); void this.watcher.close(); @@ -110,9 +144,28 @@ export class EditorFileWatcher { const events = new Map(this.pendingEvents); this.pendingEvents.clear(); if (!this.onChangeCallback) return; - for (const [filePath, type] of events) { - this.onChangeCallback({ type, path: filePath }); + // Cap emitted events per flush to protect renderer from floods. + // Prefer create/delete events over change events. + let emitted = 0; + + if (events.size > MAX_EMITTED_EVENTS_PER_FLUSH) { + log.warn( + `Watcher burst: ${events.size} events pending, capping to ${MAX_EMITTED_EVENTS_PER_FLUSH}` + ); } + + const emit = (type: EditorFileChangeEvent['type']): void => { + for (const [filePath, t] of events) { + if (t !== type) continue; + this.onChangeCallback?.({ type: t, path: filePath }); + emitted++; + if (emitted >= MAX_EMITTED_EVENTS_PER_FLUSH) return; + } + }; + + emit('delete'); + if (emitted < MAX_EMITTED_EVENTS_PER_FLUSH) emit('create'); + if (emitted < MAX_EMITTED_EVENTS_PER_FLUSH) emit('change'); }, this.DEBOUNCE_MS); } diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index a0f9551e..a9a110c1 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -458,6 +458,9 @@ export const EDITOR_GIT_STATUS = 'editor:gitStatus'; /** Enable/disable file watcher for current project */ export const EDITOR_WATCH_DIR = 'editor:watchDir'; +/** Update list of watched file paths (open tabs) */ +export const EDITOR_SET_WATCHED_FILES = 'editor:setWatchedFiles'; + /** Read binary file as base64 for inline preview */ export const EDITOR_READ_BINARY_PREVIEW = 'editor:readBinaryPreview'; diff --git a/src/preload/index.ts b/src/preload/index.ts index ab23d69e..b39019d7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -24,6 +24,7 @@ import { EDITOR_READ_FILE, EDITOR_RENAME_FILE, EDITOR_SEARCH_IN_FILES, + EDITOR_SET_WATCHED_FILES, EDITOR_WATCH_DIR, EDITOR_WRITE_FILE, HTTP_SERVER_GET_STATUS, @@ -1032,6 +1033,8 @@ const electronAPI: ElectronAPI = { invokeIpcWithResult(EDITOR_READ_BINARY_PREVIEW, filePath), gitStatus: () => invokeIpcWithResult(EDITOR_GIT_STATUS), watchDir: (enable: boolean) => invokeIpcWithResult(EDITOR_WATCH_DIR, enable), + setWatchedFiles: (filePaths: string[]) => + invokeIpcWithResult(EDITOR_SET_WATCHED_FILES, filePaths), onEditorChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: EditorFileChangeEvent): void => callback(data); diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index f621c81a..eaca4f50 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -988,6 +988,9 @@ export class HttpAPIClient implements ElectronAPI { watchDir: async () => { throw new Error('Editor not available in browser mode'); }, + setWatchedFiles: async () => { + throw new Error('Editor not available in browser mode'); + }, onEditorChange: () => { return () => {}; }, diff --git a/src/renderer/store/slices/editorSlice.ts b/src/renderer/store/slices/editorSlice.ts index 3b349e6f..75408a85 100644 --- a/src/renderer/store/slices/editorSlice.ts +++ b/src/renderer/store/slices/editorSlice.ts @@ -65,6 +65,28 @@ let watcherEventCounts: Record = { delete: 0, }; +let watchedFilesSyncTimer: ReturnType | null = null; +let lastWatchedFilesKey = ''; + +function scheduleSyncWatchedFiles(get: () => AppState): void { + if (!api.editor) return; + const state = get(); + if (!state.editorWatcherEnabled) return; + if (!state.editorProjectPath) return; + + const filePaths = state.editorOpenTabs.map((t) => t.filePath).filter(Boolean); + filePaths.sort(); + const key = filePaths.join('\n'); + if (key === lastWatchedFilesKey) return; + lastWatchedFilesKey = key; + + if (watchedFilesSyncTimer) clearTimeout(watchedFilesSyncTimer); + watchedFilesSyncTimer = setTimeout(() => { + watchedFilesSyncTimer = null; + void api.editor.setWatchedFiles(filePaths); + }, 150); +} + /** * Open request sequence for editor initialization. * Cancels stale async work (notably React 18 StrictMode dev effect mount/unmount). @@ -308,7 +330,8 @@ export const createEditorSlice: StateCreator = (s scheduleIdleWork(() => { if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return; - if (watcherDesired) void get().toggleWatcher(true); + // TODO: temporarily disabled file watcher — re-enable when stabilized + if (watcherDesired) void get().toggleWatcher(false); // Defer initial git status a bit more — it can be expensive on large repos. setTimeout(() => { if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return; @@ -465,6 +488,8 @@ export const createEditorSlice: StateCreator = (s editorOpenTabs: newTabs, editorActiveTabId: tab.id, }); + + scheduleSyncWatchedFiles(get); }, closeEditorTab: (tabId: string) => { @@ -505,6 +530,8 @@ export const createEditorSlice: StateCreator = (s editorModifiedFiles: restModified, editorSaveError: restErrors, }); + + scheduleSyncWatchedFiles(get); }, closeOtherEditorTabs: (keepTabId: string) => { @@ -809,6 +836,9 @@ export const createEditorSlice: StateCreator = (s }; }); + // Keep open-files-only watcher in sync with remapped tab paths + scheduleSyncWatchedFiles(get); + // Remap bridge state for each affected tab const { editorOpenTabs } = get(); for (const tab of editorOpenTabs) { @@ -907,6 +937,9 @@ export const createEditorSlice: StateCreator = (s }; }); + // Keep open-files-only watcher in sync with remapped tab paths + scheduleSyncWatchedFiles(get); + // Remap bridge state const { editorOpenTabs } = get(); for (const tab of editorOpenTabs) { @@ -992,6 +1025,12 @@ export const createEditorSlice: StateCreator = (s } catch { // localStorage may not be available } + if (enable) { + scheduleSyncWatchedFiles(get); + } else { + // Ensure main process stops watching files promptly. + lastWatchedFilesKey = ''; + } } catch (error) { log.error('Failed to toggle watcher:', error); } diff --git a/src/shared/types/editor.ts b/src/shared/types/editor.ts index 93706c40..471ead12 100644 --- a/src/shared/types/editor.ts +++ b/src/shared/types/editor.ts @@ -196,6 +196,11 @@ export interface EditorAPI { readBinaryPreview: (filePath: string) => Promise; gitStatus: () => Promise; watchDir: (enable: boolean) => Promise; + /** + * Provide the list of currently-open file paths (tabs) to watch. + * Intended as a performance optimization: avoids watching the whole project tree. + */ + setWatchedFiles: (filePaths: string[]) => Promise; /** Subscribe to file change events (main → renderer). Returns cleanup function. */ onEditorChange: (callback: (event: EditorFileChangeEvent) => void) => () => void; } diff --git a/test/main/ipc/editor.test.ts b/test/main/ipc/editor.test.ts index 9c6e692d..487a4fa8 100644 --- a/test/main/ipc/editor.test.ts +++ b/test/main/ipc/editor.test.ts @@ -41,8 +41,10 @@ vi.mock('@preload/constants/ipcChannels', () => ({ EDITOR_RENAME_FILE: 'editor:renameFile', EDITOR_SEARCH_IN_FILES: 'editor:searchInFiles', EDITOR_LIST_FILES: 'editor:listFiles', + EDITOR_READ_BINARY_PREVIEW: 'editor:readBinaryPreview', EDITOR_GIT_STATUS: 'editor:gitStatus', EDITOR_WATCH_DIR: 'editor:watchDir', + EDITOR_SET_WATCHED_FILES: 'editor:setWatchedFiles', EDITOR_CHANGE: 'editor:change', })); @@ -146,8 +148,8 @@ describe('Editor IPC handlers', () => { }); describe('registration', () => { - it('registers all 14 editor channels', () => { - expect(mockIpc.handle).toHaveBeenCalledTimes(14); + it('registers all 16 editor channels', () => { + expect(mockIpc.handle).toHaveBeenCalledTimes(16); expect(mockIpc._handlers.has('editor:open')).toBe(true); expect(mockIpc._handlers.has('editor:close')).toBe(true); expect(mockIpc._handlers.has('editor:readDir')).toBe(true); @@ -160,13 +162,15 @@ describe('Editor IPC handlers', () => { expect(mockIpc._handlers.has('editor:renameFile')).toBe(true); expect(mockIpc._handlers.has('editor:searchInFiles')).toBe(true); expect(mockIpc._handlers.has('editor:listFiles')).toBe(true); + expect(mockIpc._handlers.has('editor:readBinaryPreview')).toBe(true); expect(mockIpc._handlers.has('editor:gitStatus')).toBe(true); expect(mockIpc._handlers.has('editor:watchDir')).toBe(true); + expect(mockIpc._handlers.has('editor:setWatchedFiles')).toBe(true); }); it('removeEditorHandlers clears all channels', () => { removeEditorHandlers(mockIpc as unknown as IpcMain); - expect(mockIpc.removeHandler).toHaveBeenCalledTimes(14); + expect(mockIpc.removeHandler).toHaveBeenCalledTimes(16); }); }); diff --git a/test/main/services/editor/EditorFileWatcher.test.ts b/test/main/services/editor/EditorFileWatcher.test.ts index 3049e485..10939745 100644 --- a/test/main/services/editor/EditorFileWatcher.test.ts +++ b/test/main/services/editor/EditorFileWatcher.test.ts @@ -42,6 +42,7 @@ import { EditorFileWatcher } from '../../../../src/main/services/editor/EditorFi describe('EditorFileWatcher', () => { let watcher: EditorFileWatcher; const FLUSH_DEBOUNCE_MS = 350; + const STARTUP_IGNORE_CHANGE_MS = 3000; beforeEach(() => { vi.useFakeTimers(); @@ -55,22 +56,26 @@ describe('EditorFileWatcher', () => { }); describe('start', () => { - it('creates chokidar watcher with correct options', () => { + it('creates chokidar watcher with correct options (open files only)', () => { const onChange = vi.fn(); watcher.start('/Users/test/project', onChange); - expect(watch).toHaveBeenCalledWith('/Users/test/project', { - ignored: expect.any(RegExp), + // start() does not create a watcher until we provide watched files + expect(watch).not.toHaveBeenCalled(); + + watcher.setWatchedFiles(['/Users/test/project/src/index.ts']); + + expect(watch).toHaveBeenCalledWith(['/Users/test/project/src/index.ts'], { ignoreInitial: true, ignorePermissionErrors: true, followSymlinks: false, - depth: 20, }); }); it('registers change, add, unlink, and error handlers', () => { const onChange = vi.fn(); watcher.start('/Users/test/project', onChange); + watcher.setWatchedFiles(['/Users/test/project/src/index.ts']); const registeredEvents = mockOn.mock.calls.map((c) => c[0]); expect(registeredEvents).toContain('change'); @@ -82,9 +87,12 @@ describe('EditorFileWatcher', () => { it('emits normalized events through onChange callback', () => { const onChange = vi.fn(); watcher.start('/Users/test/project', onChange); + watcher.setWatchedFiles(['/Users/test/project/src/index.ts']); // Simulate chokidar 'change' event const changeHandler = mockOn.mock.calls.find((c) => c[0] === 'change')?.[1]; + // Startup grace period ignores 'change' events for first 3s + vi.advanceTimersByTime(STARTUP_IGNORE_CHANGE_MS); changeHandler?.('/Users/test/project/src/index.ts'); vi.advanceTimersByTime(FLUSH_DEBOUNCE_MS); @@ -97,6 +105,7 @@ describe('EditorFileWatcher', () => { it('emits create event for add', () => { const onChange = vi.fn(); watcher.start('/Users/test/project', onChange); + watcher.setWatchedFiles(['/Users/test/project/new-file.ts']); const addHandler = mockOn.mock.calls.find((c) => c[0] === 'add')?.[1]; addHandler?.('/Users/test/project/new-file.ts'); @@ -111,6 +120,7 @@ describe('EditorFileWatcher', () => { it('emits delete event for unlink', () => { const onChange = vi.fn(); watcher.start('/Users/test/project', onChange); + watcher.setWatchedFiles(['/Users/test/project/old-file.ts']); const unlinkHandler = mockOn.mock.calls.find((c) => c[0] === 'unlink')?.[1]; unlinkHandler?.('/Users/test/project/old-file.ts'); @@ -123,12 +133,12 @@ describe('EditorFileWatcher', () => { }); it('ignores events outside project root (SEC-2)', () => { - vi.mocked(isPathWithinRoot).mockReturnValueOnce(false); - const onChange = vi.fn(); watcher.start('/Users/test/project', onChange); + watcher.setWatchedFiles(['/Users/test/project/src/index.ts']); const changeHandler = mockOn.mock.calls.find((c) => c[0] === 'change')?.[1]; + vi.advanceTimersByTime(STARTUP_IGNORE_CHANGE_MS); changeHandler?.('/etc/passwd'); expect(onChange).not.toHaveBeenCalled(); @@ -137,10 +147,11 @@ describe('EditorFileWatcher', () => { it('stops previous watcher on re-start (idempotent)', () => { const onChange = vi.fn(); watcher.start('/Users/test/project1', onChange); + watcher.setWatchedFiles(['/Users/test/project1/a.ts']); watcher.start('/Users/test/project2', onChange); expect(mockClose).toHaveBeenCalledTimes(1); - expect(watch).toHaveBeenCalledTimes(2); + expect(watch).toHaveBeenCalledTimes(1); }); }); @@ -148,6 +159,7 @@ describe('EditorFileWatcher', () => { it('closes the watcher', () => { const onChange = vi.fn(); watcher.start('/Users/test/project', onChange); + watcher.setWatchedFiles(['/Users/test/project/a.ts']); watcher.stop(); @@ -166,13 +178,16 @@ describe('EditorFileWatcher', () => { expect(watcher.isWatching()).toBe(false); }); - it('returns true after start', () => { + it('returns true after setWatchedFiles', () => { watcher.start('/Users/test/project', vi.fn()); + expect(watcher.isWatching()).toBe(false); + watcher.setWatchedFiles(['/Users/test/project/a.ts']); expect(watcher.isWatching()).toBe(true); }); it('returns false after stop', () => { watcher.start('/Users/test/project', vi.fn()); + watcher.setWatchedFiles(['/Users/test/project/a.ts']); watcher.stop(); expect(watcher.isWatching()).toBe(false); });