agent-ecosystem/src/main/services/editor/EditorFileWatcher.ts
iliya 075523dc42 fix: resolve 245 eslint errors across 12 files
- rendererLogs: inline IPC channel constants to fix boundary violation
- EditorFileWatcher/TaskBoundaryParser: rename UPPER_CASE class props to camelCase
- TeamConfigReader: remove unused readFileHead/extractQuotedString functions
- TeamFsWorkerClient: use crypto.randomUUID(), remove duplicated branches
- team-fs-worker: full type safety rewrite — 10 interfaces replacing ~150 any casts,
  extract readTasksDirForTeam diagnostics to avoid param reassign
- useDiffNavigation/editorSlice/buildSelectionAction: add missing return types
- useFileSuggestions: suppress intentional setState-in-effect
- store/index: suppress intentional deprecated navigator.platform fallback
- platformPath: replace vulnerable regexes with manual loops
2026-03-04 01:03:45 +02:00

241 lines
7.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 dirWatcher: 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 debounceMs = 350;
private ignoreChangeUntilMs = 0;
private watchedFilesKey = '';
private watchedDirsKey = '';
/**
* 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 = '';
this.watchedDirsKey = '';
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) {
return; // Watcher not initialized yet — will sync when start() is called
}
const normalized = filePaths
.filter((p): p is string => typeof p === 'string' && p.length > 0)
.filter((p) => isPathWithinRoot(p, this.projectRoot!));
normalized.sort((a, b) => a.localeCompare(b));
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);
});
}
/**
* Update list of watched directory paths (shallow: depth=0).
* Watches only immediate children changes (create/delete/rename) in those folders.
*/
setWatchedDirs(dirPaths: string[]): void {
if (!this.projectRoot) {
return; // Watcher not initialized yet — will sync when start() is called
}
const normalized = dirPaths
.filter((p): p is string => typeof p === 'string' && p.length > 0)
.filter((p) => isPathWithinRoot(p, this.projectRoot!));
normalized.sort((a, b) => a.localeCompare(b));
const key = normalized.join('\n');
if (key === this.watchedDirsKey) return;
this.watchedDirsKey = key;
if (this.dirWatcher) {
void this.dirWatcher.close();
this.dirWatcher = null;
}
if (normalized.length === 0) {
return;
}
this.dirWatcher = watch(normalized, {
ignoreInitial: true,
ignorePermissionErrors: true,
followSymlinks: false,
depth: 0,
});
const emitSafe = (type: EditorFileChangeEvent['type'], filePath: string): void => {
if (!isPathWithinRoot(filePath, this.projectRoot!)) {
log.warn('Watcher event outside project root, ignoring:', filePath);
return;
}
this.pendingEvents.set(filePath, type);
this.scheduleFlush();
};
// For directories, we only care about structural changes.
this.dirWatcher.on('add', (p) => emitSafe('create', p));
this.dirWatcher.on('unlink', (p) => emitSafe('delete', p));
this.dirWatcher.on('addDir', (p) => emitSafe('create', p));
this.dirWatcher.on('unlinkDir', (p) => emitSafe('delete', p));
this.dirWatcher.on('error', (error) => {
log.error('Dir 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 = '';
this.watchedDirsKey = '';
if (this.watcher) {
log.info('Stopping file watcher');
void this.watcher.close();
this.watcher = null;
}
if (this.dirWatcher) {
log.info('Stopping directory watcher');
void this.dirWatcher.close();
this.dirWatcher = 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.debounceMs);
}
/**
* Whether the watcher is currently active.
*/
isWatching(): boolean {
return this.watcher !== null;
}
}