watcher.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. /**
  2. * File Watcher
  3. *
  4. * Watches the project directory for file changes and triggers
  5. * debounced sync operations to keep the code graph up-to-date.
  6. *
  7. * Uses Node.js native fs.watch with recursive mode (macOS FSEvents,
  8. * Windows ReadDirectoryChangesW, Linux inotify on Node 19+).
  9. */
  10. import * as fs from 'fs';
  11. import { CodeGraphConfig } from '../types';
  12. import { shouldIncludeFile } from '../extraction';
  13. import { logDebug, logWarn } from '../errors';
  14. import { normalizePath } from '../utils';
  15. /**
  16. * Options for the file watcher
  17. */
  18. export interface WatchOptions {
  19. /**
  20. * Debounce delay in milliseconds.
  21. * After the last file change, wait this long before triggering sync.
  22. * Default: 2000ms
  23. */
  24. debounceMs?: number;
  25. /**
  26. * Callback when a sync completes (for logging/diagnostics).
  27. */
  28. onSyncComplete?: (result: { filesChanged: number; durationMs: number }) => void;
  29. /**
  30. * Callback when a sync errors (for logging/diagnostics).
  31. */
  32. onSyncError?: (error: Error) => void;
  33. }
  34. /**
  35. * FileWatcher monitors a project directory for changes and triggers
  36. * debounced sync operations via a provided callback.
  37. *
  38. * Design goals:
  39. * - Minimal resource usage (native OS file events, no polling)
  40. * - Debounced to avoid thrashing on rapid saves
  41. * - Filters against CodeGraph include/exclude patterns
  42. * - Ignores .codegraph/ directory changes
  43. */
  44. export class FileWatcher {
  45. private watcher: fs.FSWatcher | null = null;
  46. private debounceTimer: ReturnType<typeof setTimeout> | null = null;
  47. private hasChanges = false;
  48. private syncing = false;
  49. private stopped = false;
  50. private readonly projectRoot: string;
  51. private readonly config: CodeGraphConfig;
  52. private readonly debounceMs: number;
  53. private readonly syncFn: () => Promise<{ filesChanged: number; durationMs: number }>;
  54. private readonly onSyncComplete?: WatchOptions['onSyncComplete'];
  55. private readonly onSyncError?: WatchOptions['onSyncError'];
  56. constructor(
  57. projectRoot: string,
  58. config: CodeGraphConfig,
  59. syncFn: () => Promise<{ filesChanged: number; durationMs: number }>,
  60. options: WatchOptions = {}
  61. ) {
  62. this.projectRoot = projectRoot;
  63. this.config = config;
  64. this.syncFn = syncFn;
  65. this.debounceMs = options.debounceMs ?? 2000;
  66. this.onSyncComplete = options.onSyncComplete;
  67. this.onSyncError = options.onSyncError;
  68. }
  69. /**
  70. * Start watching for file changes.
  71. * Returns true if watching started successfully, false otherwise.
  72. */
  73. start(): boolean {
  74. if (this.watcher) return true; // Already watching
  75. this.stopped = false;
  76. try {
  77. this.watcher = fs.watch(
  78. this.projectRoot,
  79. { recursive: true },
  80. (_eventType, filename) => {
  81. if (!filename || this.stopped) return;
  82. // Normalize path separators
  83. const normalized = normalizePath(filename);
  84. // Ignore .codegraph/ directory changes (our own DB writes)
  85. if (
  86. normalized === '.codegraph' ||
  87. normalized.startsWith('.codegraph/') ||
  88. normalized.startsWith('.codegraph\\')
  89. ) {
  90. return;
  91. }
  92. // Filter against include/exclude patterns
  93. if (!shouldIncludeFile(normalized, this.config)) {
  94. return;
  95. }
  96. logDebug('File change detected', { file: normalized });
  97. this.hasChanges = true;
  98. this.scheduleSync();
  99. }
  100. );
  101. // Handle watcher errors gracefully
  102. this.watcher.on('error', (err) => {
  103. logWarn('File watcher error', { error: String(err) });
  104. // Don't crash — watcher may recover or user can restart
  105. });
  106. logDebug('File watcher started', { projectRoot: this.projectRoot, debounceMs: this.debounceMs });
  107. return true;
  108. } catch (err) {
  109. // Recursive watch not supported (e.g., Linux < Node 19)
  110. logWarn('Could not start file watcher — recursive fs.watch not supported on this platform', { error: String(err) });
  111. return false;
  112. }
  113. }
  114. /**
  115. * Stop watching for file changes.
  116. */
  117. stop(): void {
  118. this.stopped = true;
  119. if (this.debounceTimer) {
  120. clearTimeout(this.debounceTimer);
  121. this.debounceTimer = null;
  122. }
  123. if (this.watcher) {
  124. this.watcher.close();
  125. this.watcher = null;
  126. }
  127. this.hasChanges = false;
  128. logDebug('File watcher stopped');
  129. }
  130. /**
  131. * Whether the watcher is currently active.
  132. */
  133. isActive(): boolean {
  134. return this.watcher !== null && !this.stopped;
  135. }
  136. /**
  137. * Schedule a debounced sync.
  138. */
  139. private scheduleSync(): void {
  140. if (this.debounceTimer) {
  141. clearTimeout(this.debounceTimer);
  142. }
  143. this.debounceTimer = setTimeout(() => {
  144. this.debounceTimer = null;
  145. this.flush();
  146. }, this.debounceMs);
  147. }
  148. /**
  149. * Flush pending changes by running sync.
  150. */
  151. private async flush(): Promise<void> {
  152. // If already syncing, the post-sync check will re-trigger
  153. if (this.syncing || this.stopped) return;
  154. this.hasChanges = false;
  155. this.syncing = true;
  156. try {
  157. const result = await this.syncFn();
  158. this.onSyncComplete?.(result);
  159. } catch (err) {
  160. const error = err instanceof Error ? err : new Error(String(err));
  161. logWarn('Watch sync failed', { error: error.message });
  162. this.onSyncError?.(error);
  163. } finally {
  164. this.syncing = false;
  165. // If new changes arrived during sync, schedule another
  166. if (this.hasChanges && !this.stopped) {
  167. this.scheduleSync();
  168. }
  169. }
  170. }
  171. }