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:
parent
e1585b4f71
commit
51df8847a9
9 changed files with 171 additions and 30 deletions
|
|
@ -22,6 +22,7 @@ import {
|
||||||
EDITOR_READ_FILE,
|
EDITOR_READ_FILE,
|
||||||
EDITOR_RENAME_FILE,
|
EDITOR_RENAME_FILE,
|
||||||
EDITOR_SEARCH_IN_FILES,
|
EDITOR_SEARCH_IN_FILES,
|
||||||
|
EDITOR_SET_WATCHED_FILES,
|
||||||
EDITOR_WATCH_DIR,
|
EDITOR_WATCH_DIR,
|
||||||
EDITOR_WRITE_FILE,
|
EDITOR_WRITE_FILE,
|
||||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
// 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
|
// Registration
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -384,6 +398,7 @@ export function registerEditorHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(EDITOR_READ_BINARY_PREVIEW, handleEditorReadBinaryPreview);
|
ipcMain.handle(EDITOR_READ_BINARY_PREVIEW, handleEditorReadBinaryPreview);
|
||||||
ipcMain.handle(EDITOR_GIT_STATUS, handleEditorGitStatus);
|
ipcMain.handle(EDITOR_GIT_STATUS, handleEditorGitStatus);
|
||||||
ipcMain.handle(EDITOR_WATCH_DIR, handleEditorWatchDir);
|
ipcMain.handle(EDITOR_WATCH_DIR, handleEditorWatchDir);
|
||||||
|
ipcMain.handle(EDITOR_SET_WATCHED_FILES, handleEditorSetWatchedFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeEditorHandlers(ipcMain: IpcMain): void {
|
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_READ_BINARY_PREVIEW);
|
||||||
ipcMain.removeHandler(EDITOR_GIT_STATUS);
|
ipcMain.removeHandler(EDITOR_GIT_STATUS);
|
||||||
ipcMain.removeHandler(EDITOR_WATCH_DIR);
|
ipcMain.removeHandler(EDITOR_WATCH_DIR);
|
||||||
|
ipcMain.removeHandler(EDITOR_SET_WATCHED_FILES);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,8 @@ const log = createLogger('EditorFileWatcher');
|
||||||
// Constants
|
// Constants
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/** Directories to ignore (regex for chokidar's `ignored` option) */
|
const STARTUP_IGNORE_CHANGE_MS = 3000;
|
||||||
const IGNORED_PATTERN =
|
const MAX_EMITTED_EVENTS_PER_FLUSH = 300;
|
||||||
/(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;
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Service
|
// Service
|
||||||
|
|
@ -40,34 +37,69 @@ export class EditorFileWatcher {
|
||||||
private onChangeCallback: ((event: EditorFileChangeEvent) => void) | null = null;
|
private onChangeCallback: ((event: EditorFileChangeEvent) => void) | null = null;
|
||||||
// Higher debounce = fewer IPC events during large bursts (checkout/build/format).
|
// Higher debounce = fewer IPC events during large bursts (checkout/build/format).
|
||||||
private readonly DEBOUNCE_MS = 350;
|
private readonly DEBOUNCE_MS = 350;
|
||||||
|
private ignoreChangeUntilMs = 0;
|
||||||
|
private watchedFilesKey = '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start watching a project directory.
|
* Initialize watcher context for a project root.
|
||||||
* Idempotent: stops any existing watcher first.
|
*
|
||||||
|
* Performance: does NOT watch the entire project directory.
|
||||||
|
* Use setWatchedFiles() to watch only open files (tabs).
|
||||||
*/
|
*/
|
||||||
start(projectRoot: string, onChange: (event: EditorFileChangeEvent) => void): void {
|
start(projectRoot: string, onChange: (event: EditorFileChangeEvent) => void): void {
|
||||||
this.stop();
|
this.stop();
|
||||||
this.projectRoot = projectRoot;
|
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,
|
ignoreInitial: true,
|
||||||
ignorePermissionErrors: true,
|
ignorePermissionErrors: true,
|
||||||
followSymlinks: false,
|
followSymlinks: false,
|
||||||
depth: MAX_DEPTH,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.onChangeCallback = onChange;
|
|
||||||
|
|
||||||
const emitSafe = (type: EditorFileChangeEvent['type'], filePath: string): void => {
|
const emitSafe = (type: EditorFileChangeEvent['type'], filePath: string): void => {
|
||||||
// SEC-2: validate path is within project root before sending to renderer
|
if (type === 'change' && Date.now() < this.ignoreChangeUntilMs) {
|
||||||
if (!isPathWithinRoot(filePath, projectRoot)) {
|
return;
|
||||||
|
}
|
||||||
|
if (!isPathWithinRoot(filePath, this.projectRoot!)) {
|
||||||
log.warn('Watcher event outside project root, ignoring:', filePath);
|
log.warn('Watcher event outside project root, ignoring:', filePath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Aggregate rapid events — only the last event type per path is kept
|
|
||||||
this.pendingEvents.set(filePath, type);
|
this.pendingEvents.set(filePath, type);
|
||||||
this.scheduleFlush();
|
this.scheduleFlush();
|
||||||
};
|
};
|
||||||
|
|
@ -91,6 +123,8 @@ export class EditorFileWatcher {
|
||||||
}
|
}
|
||||||
this.pendingEvents.clear();
|
this.pendingEvents.clear();
|
||||||
this.onChangeCallback = null;
|
this.onChangeCallback = null;
|
||||||
|
this.ignoreChangeUntilMs = 0;
|
||||||
|
this.watchedFilesKey = '';
|
||||||
if (this.watcher) {
|
if (this.watcher) {
|
||||||
log.info('Stopping file watcher');
|
log.info('Stopping file watcher');
|
||||||
void this.watcher.close();
|
void this.watcher.close();
|
||||||
|
|
@ -110,9 +144,28 @@ export class EditorFileWatcher {
|
||||||
const events = new Map(this.pendingEvents);
|
const events = new Map(this.pendingEvents);
|
||||||
this.pendingEvents.clear();
|
this.pendingEvents.clear();
|
||||||
if (!this.onChangeCallback) return;
|
if (!this.onChangeCallback) return;
|
||||||
for (const [filePath, type] of events) {
|
// Cap emitted events per flush to protect renderer from floods.
|
||||||
this.onChangeCallback({ type, path: filePath });
|
// 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);
|
}, this.DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -458,6 +458,9 @@ export const EDITOR_GIT_STATUS = 'editor:gitStatus';
|
||||||
/** Enable/disable file watcher for current project */
|
/** Enable/disable file watcher for current project */
|
||||||
export const EDITOR_WATCH_DIR = 'editor:watchDir';
|
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 */
|
/** Read binary file as base64 for inline preview */
|
||||||
export const EDITOR_READ_BINARY_PREVIEW = 'editor:readBinaryPreview';
|
export const EDITOR_READ_BINARY_PREVIEW = 'editor:readBinaryPreview';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
EDITOR_READ_FILE,
|
EDITOR_READ_FILE,
|
||||||
EDITOR_RENAME_FILE,
|
EDITOR_RENAME_FILE,
|
||||||
EDITOR_SEARCH_IN_FILES,
|
EDITOR_SEARCH_IN_FILES,
|
||||||
|
EDITOR_SET_WATCHED_FILES,
|
||||||
EDITOR_WATCH_DIR,
|
EDITOR_WATCH_DIR,
|
||||||
EDITOR_WRITE_FILE,
|
EDITOR_WRITE_FILE,
|
||||||
HTTP_SERVER_GET_STATUS,
|
HTTP_SERVER_GET_STATUS,
|
||||||
|
|
@ -1032,6 +1033,8 @@ const electronAPI: ElectronAPI = {
|
||||||
invokeIpcWithResult<BinaryPreviewResult>(EDITOR_READ_BINARY_PREVIEW, filePath),
|
invokeIpcWithResult<BinaryPreviewResult>(EDITOR_READ_BINARY_PREVIEW, filePath),
|
||||||
gitStatus: () => invokeIpcWithResult<GitStatusResult>(EDITOR_GIT_STATUS),
|
gitStatus: () => invokeIpcWithResult<GitStatusResult>(EDITOR_GIT_STATUS),
|
||||||
watchDir: (enable: boolean) => invokeIpcWithResult<void>(EDITOR_WATCH_DIR, enable),
|
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) => {
|
onEditorChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => {
|
||||||
const listener = (_event: Electron.IpcRendererEvent, data: EditorFileChangeEvent): void =>
|
const listener = (_event: Electron.IpcRendererEvent, data: EditorFileChangeEvent): void =>
|
||||||
callback(data);
|
callback(data);
|
||||||
|
|
|
||||||
|
|
@ -988,6 +988,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
watchDir: async () => {
|
watchDir: async () => {
|
||||||
throw new Error('Editor not available in browser mode');
|
throw new Error('Editor not available in browser mode');
|
||||||
},
|
},
|
||||||
|
setWatchedFiles: async () => {
|
||||||
|
throw new Error('Editor not available in browser mode');
|
||||||
|
},
|
||||||
onEditorChange: () => {
|
onEditorChange: () => {
|
||||||
return () => {};
|
return () => {};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,28 @@ let watcherEventCounts: Record<EditorFileChangeEvent['type'], number> = {
|
||||||
delete: 0,
|
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.
|
* Open request sequence for editor initialization.
|
||||||
* Cancels stale async work (notably React 18 StrictMode dev effect mount/unmount).
|
* Cancels stale async work (notably React 18 StrictMode dev effect mount/unmount).
|
||||||
|
|
@ -308,7 +330,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
|
|
||||||
scheduleIdleWork(() => {
|
scheduleIdleWork(() => {
|
||||||
if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return;
|
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.
|
// Defer initial git status a bit more — it can be expensive on large repos.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return;
|
if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return;
|
||||||
|
|
@ -465,6 +488,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
editorOpenTabs: newTabs,
|
editorOpenTabs: newTabs,
|
||||||
editorActiveTabId: tab.id,
|
editorActiveTabId: tab.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
scheduleSyncWatchedFiles(get);
|
||||||
},
|
},
|
||||||
|
|
||||||
closeEditorTab: (tabId: string) => {
|
closeEditorTab: (tabId: string) => {
|
||||||
|
|
@ -505,6 +530,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
editorModifiedFiles: restModified,
|
editorModifiedFiles: restModified,
|
||||||
editorSaveError: restErrors,
|
editorSaveError: restErrors,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
scheduleSyncWatchedFiles(get);
|
||||||
},
|
},
|
||||||
|
|
||||||
closeOtherEditorTabs: (keepTabId: string) => {
|
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
|
// Remap bridge state for each affected tab
|
||||||
const { editorOpenTabs } = get();
|
const { editorOpenTabs } = get();
|
||||||
for (const tab of editorOpenTabs) {
|
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
|
// Remap bridge state
|
||||||
const { editorOpenTabs } = get();
|
const { editorOpenTabs } = get();
|
||||||
for (const tab of editorOpenTabs) {
|
for (const tab of editorOpenTabs) {
|
||||||
|
|
@ -992,6 +1025,12 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
} catch {
|
} catch {
|
||||||
// localStorage may not be available
|
// localStorage may not be available
|
||||||
}
|
}
|
||||||
|
if (enable) {
|
||||||
|
scheduleSyncWatchedFiles(get);
|
||||||
|
} else {
|
||||||
|
// Ensure main process stops watching files promptly.
|
||||||
|
lastWatchedFilesKey = '';
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Failed to toggle watcher:', error);
|
log.error('Failed to toggle watcher:', error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,11 @@ export interface EditorAPI {
|
||||||
readBinaryPreview: (filePath: string) => Promise<BinaryPreviewResult>;
|
readBinaryPreview: (filePath: string) => Promise<BinaryPreviewResult>;
|
||||||
gitStatus: () => Promise<GitStatusResult>;
|
gitStatus: () => Promise<GitStatusResult>;
|
||||||
watchDir: (enable: boolean) => Promise<void>;
|
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. */
|
/** Subscribe to file change events (main → renderer). Returns cleanup function. */
|
||||||
onEditorChange: (callback: (event: EditorFileChangeEvent) => void) => () => void;
|
onEditorChange: (callback: (event: EditorFileChangeEvent) => void) => () => void;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,10 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
||||||
EDITOR_RENAME_FILE: 'editor:renameFile',
|
EDITOR_RENAME_FILE: 'editor:renameFile',
|
||||||
EDITOR_SEARCH_IN_FILES: 'editor:searchInFiles',
|
EDITOR_SEARCH_IN_FILES: 'editor:searchInFiles',
|
||||||
EDITOR_LIST_FILES: 'editor:listFiles',
|
EDITOR_LIST_FILES: 'editor:listFiles',
|
||||||
|
EDITOR_READ_BINARY_PREVIEW: 'editor:readBinaryPreview',
|
||||||
EDITOR_GIT_STATUS: 'editor:gitStatus',
|
EDITOR_GIT_STATUS: 'editor:gitStatus',
|
||||||
EDITOR_WATCH_DIR: 'editor:watchDir',
|
EDITOR_WATCH_DIR: 'editor:watchDir',
|
||||||
|
EDITOR_SET_WATCHED_FILES: 'editor:setWatchedFiles',
|
||||||
EDITOR_CHANGE: 'editor:change',
|
EDITOR_CHANGE: 'editor:change',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -146,8 +148,8 @@ describe('Editor IPC handlers', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('registration', () => {
|
describe('registration', () => {
|
||||||
it('registers all 14 editor channels', () => {
|
it('registers all 16 editor channels', () => {
|
||||||
expect(mockIpc.handle).toHaveBeenCalledTimes(14);
|
expect(mockIpc.handle).toHaveBeenCalledTimes(16);
|
||||||
expect(mockIpc._handlers.has('editor:open')).toBe(true);
|
expect(mockIpc._handlers.has('editor:open')).toBe(true);
|
||||||
expect(mockIpc._handlers.has('editor:close')).toBe(true);
|
expect(mockIpc._handlers.has('editor:close')).toBe(true);
|
||||||
expect(mockIpc._handlers.has('editor:readDir')).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:renameFile')).toBe(true);
|
||||||
expect(mockIpc._handlers.has('editor:searchInFiles')).toBe(true);
|
expect(mockIpc._handlers.has('editor:searchInFiles')).toBe(true);
|
||||||
expect(mockIpc._handlers.has('editor:listFiles')).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:gitStatus')).toBe(true);
|
||||||
expect(mockIpc._handlers.has('editor:watchDir')).toBe(true);
|
expect(mockIpc._handlers.has('editor:watchDir')).toBe(true);
|
||||||
|
expect(mockIpc._handlers.has('editor:setWatchedFiles')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removeEditorHandlers clears all channels', () => {
|
it('removeEditorHandlers clears all channels', () => {
|
||||||
removeEditorHandlers(mockIpc as unknown as IpcMain);
|
removeEditorHandlers(mockIpc as unknown as IpcMain);
|
||||||
expect(mockIpc.removeHandler).toHaveBeenCalledTimes(14);
|
expect(mockIpc.removeHandler).toHaveBeenCalledTimes(16);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import { EditorFileWatcher } from '../../../../src/main/services/editor/EditorFi
|
||||||
describe('EditorFileWatcher', () => {
|
describe('EditorFileWatcher', () => {
|
||||||
let watcher: EditorFileWatcher;
|
let watcher: EditorFileWatcher;
|
||||||
const FLUSH_DEBOUNCE_MS = 350;
|
const FLUSH_DEBOUNCE_MS = 350;
|
||||||
|
const STARTUP_IGNORE_CHANGE_MS = 3000;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
@ -55,22 +56,26 @@ describe('EditorFileWatcher', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('start', () => {
|
describe('start', () => {
|
||||||
it('creates chokidar watcher with correct options', () => {
|
it('creates chokidar watcher with correct options (open files only)', () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
watcher.start('/Users/test/project', onChange);
|
watcher.start('/Users/test/project', onChange);
|
||||||
|
|
||||||
expect(watch).toHaveBeenCalledWith('/Users/test/project', {
|
// start() does not create a watcher until we provide watched files
|
||||||
ignored: expect.any(RegExp),
|
expect(watch).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
watcher.setWatchedFiles(['/Users/test/project/src/index.ts']);
|
||||||
|
|
||||||
|
expect(watch).toHaveBeenCalledWith(['/Users/test/project/src/index.ts'], {
|
||||||
ignoreInitial: true,
|
ignoreInitial: true,
|
||||||
ignorePermissionErrors: true,
|
ignorePermissionErrors: true,
|
||||||
followSymlinks: false,
|
followSymlinks: false,
|
||||||
depth: 20,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('registers change, add, unlink, and error handlers', () => {
|
it('registers change, add, unlink, and error handlers', () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
watcher.start('/Users/test/project', onChange);
|
watcher.start('/Users/test/project', onChange);
|
||||||
|
watcher.setWatchedFiles(['/Users/test/project/src/index.ts']);
|
||||||
|
|
||||||
const registeredEvents = mockOn.mock.calls.map((c) => c[0]);
|
const registeredEvents = mockOn.mock.calls.map((c) => c[0]);
|
||||||
expect(registeredEvents).toContain('change');
|
expect(registeredEvents).toContain('change');
|
||||||
|
|
@ -82,9 +87,12 @@ describe('EditorFileWatcher', () => {
|
||||||
it('emits normalized events through onChange callback', () => {
|
it('emits normalized events through onChange callback', () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
watcher.start('/Users/test/project', onChange);
|
watcher.start('/Users/test/project', onChange);
|
||||||
|
watcher.setWatchedFiles(['/Users/test/project/src/index.ts']);
|
||||||
|
|
||||||
// Simulate chokidar 'change' event
|
// Simulate chokidar 'change' event
|
||||||
const changeHandler = mockOn.mock.calls.find((c) => c[0] === 'change')?.[1];
|
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');
|
changeHandler?.('/Users/test/project/src/index.ts');
|
||||||
vi.advanceTimersByTime(FLUSH_DEBOUNCE_MS);
|
vi.advanceTimersByTime(FLUSH_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
|
@ -97,6 +105,7 @@ describe('EditorFileWatcher', () => {
|
||||||
it('emits create event for add', () => {
|
it('emits create event for add', () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
watcher.start('/Users/test/project', onChange);
|
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];
|
const addHandler = mockOn.mock.calls.find((c) => c[0] === 'add')?.[1];
|
||||||
addHandler?.('/Users/test/project/new-file.ts');
|
addHandler?.('/Users/test/project/new-file.ts');
|
||||||
|
|
@ -111,6 +120,7 @@ describe('EditorFileWatcher', () => {
|
||||||
it('emits delete event for unlink', () => {
|
it('emits delete event for unlink', () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
watcher.start('/Users/test/project', onChange);
|
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];
|
const unlinkHandler = mockOn.mock.calls.find((c) => c[0] === 'unlink')?.[1];
|
||||||
unlinkHandler?.('/Users/test/project/old-file.ts');
|
unlinkHandler?.('/Users/test/project/old-file.ts');
|
||||||
|
|
@ -123,12 +133,12 @@ describe('EditorFileWatcher', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores events outside project root (SEC-2)', () => {
|
it('ignores events outside project root (SEC-2)', () => {
|
||||||
vi.mocked(isPathWithinRoot).mockReturnValueOnce(false);
|
|
||||||
|
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
watcher.start('/Users/test/project', onChange);
|
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];
|
const changeHandler = mockOn.mock.calls.find((c) => c[0] === 'change')?.[1];
|
||||||
|
vi.advanceTimersByTime(STARTUP_IGNORE_CHANGE_MS);
|
||||||
changeHandler?.('/etc/passwd');
|
changeHandler?.('/etc/passwd');
|
||||||
|
|
||||||
expect(onChange).not.toHaveBeenCalled();
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
|
@ -137,10 +147,11 @@ describe('EditorFileWatcher', () => {
|
||||||
it('stops previous watcher on re-start (idempotent)', () => {
|
it('stops previous watcher on re-start (idempotent)', () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
watcher.start('/Users/test/project1', onChange);
|
watcher.start('/Users/test/project1', onChange);
|
||||||
|
watcher.setWatchedFiles(['/Users/test/project1/a.ts']);
|
||||||
watcher.start('/Users/test/project2', onChange);
|
watcher.start('/Users/test/project2', onChange);
|
||||||
|
|
||||||
expect(mockClose).toHaveBeenCalledTimes(1);
|
expect(mockClose).toHaveBeenCalledTimes(1);
|
||||||
expect(watch).toHaveBeenCalledTimes(2);
|
expect(watch).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -148,6 +159,7 @@ describe('EditorFileWatcher', () => {
|
||||||
it('closes the watcher', () => {
|
it('closes the watcher', () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
watcher.start('/Users/test/project', onChange);
|
watcher.start('/Users/test/project', onChange);
|
||||||
|
watcher.setWatchedFiles(['/Users/test/project/a.ts']);
|
||||||
|
|
||||||
watcher.stop();
|
watcher.stop();
|
||||||
|
|
||||||
|
|
@ -166,13 +178,16 @@ describe('EditorFileWatcher', () => {
|
||||||
expect(watcher.isWatching()).toBe(false);
|
expect(watcher.isWatching()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true after start', () => {
|
it('returns true after setWatchedFiles', () => {
|
||||||
watcher.start('/Users/test/project', vi.fn());
|
watcher.start('/Users/test/project', vi.fn());
|
||||||
|
expect(watcher.isWatching()).toBe(false);
|
||||||
|
watcher.setWatchedFiles(['/Users/test/project/a.ts']);
|
||||||
expect(watcher.isWatching()).toBe(true);
|
expect(watcher.isWatching()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false after stop', () => {
|
it('returns false after stop', () => {
|
||||||
watcher.start('/Users/test/project', vi.fn());
|
watcher.start('/Users/test/project', vi.fn());
|
||||||
|
watcher.setWatchedFiles(['/Users/test/project/a.ts']);
|
||||||
watcher.stop();
|
watcher.stop();
|
||||||
expect(watcher.isWatching()).toBe(false);
|
expect(watcher.isWatching()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue