agent-ecosystem/src/main/services/editor/EditorFileWatcher.ts
iliya 51df8847a9 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.
2026-03-02 00:15:00 +02:00

178 lines
5.7 KiB
TypeScript

/**
* File watcher for the project editor using chokidar v4.
*
* Watches project directory for external file changes and emits
* normalized events. chokidar handles platform differences (FSEvents on macOS,
* inotify on Linux), recursive watching, and ENOSPC fallback.
*
* Security: paths emitted in events are validated against project root
* before being sent to renderer (SEC-2).
*/
import { isPathWithinRoot } from '@main/utils/pathValidation';
import { createLogger } from '@shared/utils/logger';
import { watch } from 'chokidar';
import type { EditorFileChangeEvent } from '@shared/types/editor';
import type { FSWatcher } from 'chokidar';
const log = createLogger('EditorFileWatcher');
// =============================================================================
// Constants
// =============================================================================
const STARTUP_IGNORE_CHANGE_MS = 3000;
const MAX_EMITTED_EVENTS_PER_FLUSH = 300;
// =============================================================================
// Service
// =============================================================================
export class EditorFileWatcher {
private watcher: FSWatcher | null = null;
private projectRoot: string | null = null;
private pendingEvents = new Map<string, EditorFileChangeEvent['type']>();
private flushTimer: ReturnType<typeof setTimeout> | null = null;
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 = '';
/**
* 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 (open files only) for:', projectRoot);
this.onChangeCallback = onChange;
}
/**
* 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,
});
const emitSafe = (type: EditorFileChangeEvent['type'], filePath: string): void => {
if (type === 'change' && Date.now() < this.ignoreChangeUntilMs) {
return;
}
if (!isPathWithinRoot(filePath, this.projectRoot!)) {
log.warn('Watcher event outside project root, ignoring:', filePath);
return;
}
this.pendingEvents.set(filePath, type);
this.scheduleFlush();
};
this.watcher.on('change', (p) => emitSafe('change', p));
this.watcher.on('add', (p) => emitSafe('create', p));
this.watcher.on('unlink', (p) => emitSafe('delete', p));
this.watcher.on('error', (error) => {
log.error('Watcher error:', error);
});
}
/**
* Stop watching. Safe to call multiple times.
*/
stop(): void {
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
this.pendingEvents.clear();
this.onChangeCallback = null;
this.ignoreChangeUntilMs = 0;
this.watchedFilesKey = '';
if (this.watcher) {
log.info('Stopping file watcher');
void this.watcher.close();
this.watcher = null;
}
this.projectRoot = null;
}
/**
* Flush pending events — debounced to aggregate rapid FS changes
* (e.g. git checkout, bulk format). Fires once after 150ms of quiet.
*/
private scheduleFlush(): void {
if (this.flushTimer) return;
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
const events = new Map(this.pendingEvents);
this.pendingEvents.clear();
if (!this.onChangeCallback) return;
// 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);
}
/**
* Whether the watcher is currently active.
*/
isWatching(): boolean {
return this.watcher !== null;
}
}