feat: enhance EditorFileWatcher and IPC for improved file change handling

- Introduced a grace period in EditorFileWatcher to ignore rapid 'change' events during startup, preventing performance issues in large repositories.
- Added a new IPC channel for setting watched file paths, optimizing file monitoring by focusing on currently open tabs.
- Updated the editor API to include the new setWatchedFiles method, enhancing performance by avoiding unnecessary project-wide file watching.
- Enhanced tests for EditorFileWatcher to validate the new startup behavior and ensure proper event handling.
This commit is contained in:
iliya 2026-03-02 00:15:00 +02:00
parent e1585b4f71
commit 51df8847a9
9 changed files with 171 additions and 30 deletions

View file

@ -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<IpcResult<void>> {
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);
}
/**

View file

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

View file

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

View file

@ -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<BinaryPreviewResult>(EDITOR_READ_BINARY_PREVIEW, filePath),
gitStatus: () => invokeIpcWithResult<GitStatusResult>(EDITOR_GIT_STATUS),
watchDir: (enable: boolean) => invokeIpcWithResult<void>(EDITOR_WATCH_DIR, enable),
setWatchedFiles: (filePaths: string[]) =>
invokeIpcWithResult<void>(EDITOR_SET_WATCHED_FILES, filePaths),
onEditorChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: EditorFileChangeEvent): void =>
callback(data);

View file

@ -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 () => {};
},

View file

@ -65,6 +65,28 @@ let watcherEventCounts: Record<EditorFileChangeEvent['type'], number> = {
delete: 0,
};
let watchedFilesSyncTimer: ReturnType<typeof setTimeout> | 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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (s
editorOpenTabs: newTabs,
editorActiveTabId: tab.id,
});
scheduleSyncWatchedFiles(get);
},
closeEditorTab: (tabId: string) => {
@ -505,6 +530,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
editorModifiedFiles: restModified,
editorSaveError: restErrors,
});
scheduleSyncWatchedFiles(get);
},
closeOtherEditorTabs: (keepTabId: string) => {
@ -809,6 +836,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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);
}

View file

@ -196,6 +196,11 @@ export interface EditorAPI {
readBinaryPreview: (filePath: string) => Promise<BinaryPreviewResult>;
gitStatus: () => Promise<GitStatusResult>;
watchDir: (enable: boolean) => Promise<void>;
/**
* 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<void>;
/** Subscribe to file change events (main → renderer). Returns cleanup function. */
onEditorChange: (callback: (event: EditorFileChangeEvent) => void) => () => void;
}

View file

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

View file

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