| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196 |
- /**
- * File Watcher
- *
- * Watches the project directory for file changes and triggers
- * debounced sync operations to keep the code graph up-to-date.
- *
- * Uses Node.js native fs.watch with recursive mode (macOS FSEvents,
- * Windows ReadDirectoryChangesW, Linux inotify on Node 19+).
- */
- import * as fs from 'fs';
- import { CodeGraphConfig } from '../types';
- import { shouldIncludeFile } from '../extraction';
- import { logDebug, logWarn } from '../errors';
- import { normalizePath } from '../utils';
- /**
- * Options for the file watcher
- */
- export interface WatchOptions {
- /**
- * Debounce delay in milliseconds.
- * After the last file change, wait this long before triggering sync.
- * Default: 2000ms
- */
- debounceMs?: number;
- /**
- * Callback when a sync completes (for logging/diagnostics).
- */
- onSyncComplete?: (result: { filesChanged: number; durationMs: number }) => void;
- /**
- * Callback when a sync errors (for logging/diagnostics).
- */
- onSyncError?: (error: Error) => void;
- }
- /**
- * FileWatcher monitors a project directory for changes and triggers
- * debounced sync operations via a provided callback.
- *
- * Design goals:
- * - Minimal resource usage (native OS file events, no polling)
- * - Debounced to avoid thrashing on rapid saves
- * - Filters against CodeGraph include/exclude patterns
- * - Ignores .codegraph/ directory changes
- */
- export class FileWatcher {
- private watcher: fs.FSWatcher | null = null;
- private debounceTimer: ReturnType<typeof setTimeout> | null = null;
- private hasChanges = false;
- private syncing = false;
- private stopped = false;
- private readonly projectRoot: string;
- private readonly config: CodeGraphConfig;
- private readonly debounceMs: number;
- private readonly syncFn: () => Promise<{ filesChanged: number; durationMs: number }>;
- private readonly onSyncComplete?: WatchOptions['onSyncComplete'];
- private readonly onSyncError?: WatchOptions['onSyncError'];
- constructor(
- projectRoot: string,
- config: CodeGraphConfig,
- syncFn: () => Promise<{ filesChanged: number; durationMs: number }>,
- options: WatchOptions = {}
- ) {
- this.projectRoot = projectRoot;
- this.config = config;
- this.syncFn = syncFn;
- this.debounceMs = options.debounceMs ?? 2000;
- this.onSyncComplete = options.onSyncComplete;
- this.onSyncError = options.onSyncError;
- }
- /**
- * Start watching for file changes.
- * Returns true if watching started successfully, false otherwise.
- */
- start(): boolean {
- if (this.watcher) return true; // Already watching
- this.stopped = false;
- try {
- this.watcher = fs.watch(
- this.projectRoot,
- { recursive: true },
- (_eventType, filename) => {
- if (!filename || this.stopped) return;
- // Normalize path separators
- const normalized = normalizePath(filename);
- // Ignore .codegraph/ directory changes (our own DB writes)
- if (
- normalized === '.codegraph' ||
- normalized.startsWith('.codegraph/') ||
- normalized.startsWith('.codegraph\\')
- ) {
- return;
- }
- // Filter against include/exclude patterns
- if (!shouldIncludeFile(normalized, this.config)) {
- return;
- }
- logDebug('File change detected', { file: normalized });
- this.hasChanges = true;
- this.scheduleSync();
- }
- );
- // Handle watcher errors gracefully
- this.watcher.on('error', (err) => {
- logWarn('File watcher error', { error: String(err) });
- // Don't crash — watcher may recover or user can restart
- });
- logDebug('File watcher started', { projectRoot: this.projectRoot, debounceMs: this.debounceMs });
- return true;
- } catch (err) {
- // Recursive watch not supported (e.g., Linux < Node 19)
- logWarn('Could not start file watcher — recursive fs.watch not supported on this platform', { error: String(err) });
- return false;
- }
- }
- /**
- * Stop watching for file changes.
- */
- stop(): void {
- this.stopped = true;
- if (this.debounceTimer) {
- clearTimeout(this.debounceTimer);
- this.debounceTimer = null;
- }
- if (this.watcher) {
- this.watcher.close();
- this.watcher = null;
- }
- this.hasChanges = false;
- logDebug('File watcher stopped');
- }
- /**
- * Whether the watcher is currently active.
- */
- isActive(): boolean {
- return this.watcher !== null && !this.stopped;
- }
- /**
- * Schedule a debounced sync.
- */
- private scheduleSync(): void {
- if (this.debounceTimer) {
- clearTimeout(this.debounceTimer);
- }
- this.debounceTimer = setTimeout(() => {
- this.debounceTimer = null;
- this.flush();
- }, this.debounceMs);
- }
- /**
- * Flush pending changes by running sync.
- */
- private async flush(): Promise<void> {
- // If already syncing, the post-sync check will re-trigger
- if (this.syncing || this.stopped) return;
- this.hasChanges = false;
- this.syncing = true;
- try {
- const result = await this.syncFn();
- this.onSyncComplete?.(result);
- } catch (err) {
- const error = err instanceof Error ? err : new Error(String(err));
- logWarn('Watch sync failed', { error: error.message });
- this.onSyncError?.(error);
- } finally {
- this.syncing = false;
- // If new changes arrived during sync, schedule another
- if (this.hasChanges && !this.stopped) {
- this.scheduleSync();
- }
- }
- }
- }
|