agent-ecosystem/src/main/services/editor/EditorFileWatcher.ts
iliya 533f9b9e06 feat: enhance EditorFileWatcher and FileSearchService for improved performance and event handling
- Introduced debouncing in EditorFileWatcher to aggregate rapid file system events, reducing unnecessary callbacks.
- Enhanced FileSearchService to parallelize file checks and improve efficiency in collecting file paths from directories.
- Updated EditorFileTree and ProjectEditorOverlay components to optimize state management and reduce re-renders using Zustand's shallow comparison.
- Added tests for EditorFileWatcher to ensure proper event handling and debouncing functionality.
2026-03-01 22:40:02 +02:00

123 lines
3.9 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
// =============================================================================
/** Directories to ignore (regex for chokidar's `ignored` option) */
const IGNORED_PATTERN =
/(node_modules|\.git|dist|__pycache__|\.cache|\.next|\.venv|\.tox|vendor|\.DS_Store)/;
const MAX_DEPTH = 20;
// =============================================================================
// 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;
private readonly DEBOUNCE_MS = 150;
/**
* Start watching a project directory.
* Idempotent: stops any existing watcher first.
*/
start(projectRoot: string, onChange: (event: EditorFileChangeEvent) => void): void {
this.stop();
this.projectRoot = projectRoot;
log.info('Starting file watcher for:', projectRoot);
this.watcher = watch(projectRoot, {
ignored: IGNORED_PATTERN,
ignoreInitial: 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)) {
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();
};
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;
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;
for (const [filePath, type] of events) {
this.onChangeCallback({ type, path: filePath });
}
}, this.DEBOUNCE_MS);
}
/**
* Whether the watcher is currently active.
*/
isWatching(): boolean {
return this.watcher !== null;
}
}