UNPKG

ai-index

Version:

AI-powered local code indexing and search system for any codebase

279 lines (230 loc) • 7.48 kB
import chokidar from 'chokidar'; import pDebounce from 'p-debounce'; import path from 'path'; import fs from 'fs/promises'; import crypto from 'crypto'; import { EventEmitter } from 'events'; export class FileMonitor extends EventEmitter { constructor(options = {}) { super(); this.rootPath = options.rootPath || process.cwd(); this.debounceDelay = options.debounceDelay || 3000; // 3 seconds default this.fileHashes = new Map(); this.pendingChanges = new Set(); this.watcher = null; this.isProcessing = false; // Debounced reindex function this.processChanges = pDebounce( this._processChanges.bind(this), this.debounceDelay ); } async start() { console.log(`šŸ” Starting file monitor for: ${this.rootPath}`); // Load existing file hashes await this.loadFileHashes(); // Setup file watcher for JS/TS files only this.watcher = chokidar.watch(['**/*.{js,jsx,ts,tsx,mjs}'], { cwd: this.rootPath, ignored: [ '**/node_modules/**', '**/dist/**', '**/build/**', '**/.git/**', '**/coverage/**', '**/*.min.js', '**/*.test.{js,ts}', '**/*.spec.{js,ts}' ], persistent: true, ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 1000, pollInterval: 100 } }); // Setup event handlers this.watcher .on('add', this.handleFileAdd.bind(this)) .on('change', this.handleFileChange.bind(this)) .on('unlink', this.handleFileDelete.bind(this)) .on('error', error => console.error('Watcher error:', error)) .on('ready', () => { console.log('šŸ“” File monitor ready and watching for changes'); this.emit('ready'); }); } async stop() { if (this.watcher) { await this.watcher.close(); console.log('File monitor stopped'); } } async handleFileAdd(relativePath) { const fullPath = path.join(this.rootPath, relativePath); console.log(`āž• File added: ${relativePath}`); this.pendingChanges.add({ type: 'add', path: relativePath, fullPath }); await this.processChanges(); } async handleFileChange(relativePath) { const fullPath = path.join(this.rootPath, relativePath); // Check if file actually changed by comparing hash const newHash = await this.calculateFileHash(fullPath); const oldHash = this.fileHashes.get(relativePath); if (newHash !== oldHash) { console.log(`šŸ“ File changed: ${relativePath}`); this.pendingChanges.add({ type: 'change', path: relativePath, fullPath, oldHash, newHash }); await this.processChanges(); } } async handleFileDelete(relativePath) { console.log(`šŸ—‘ļø File deleted: ${relativePath}`); this.pendingChanges.add({ type: 'delete', path: relativePath }); this.fileHashes.delete(relativePath); await this.processChanges(); } async _processChanges() { if (this.isProcessing || this.pendingChanges.size === 0) { return; } this.isProcessing = true; const changes = Array.from(this.pendingChanges); this.pendingChanges.clear(); console.log(`\nšŸ”„ Processing ${changes.length} file changes...`); try { // Group changes by type const grouped = { add: [], change: [], delete: [] }; changes.forEach(change => { grouped[change.type].push(change); }); // Process deletes first if (grouped.delete.length > 0) { await this.processDeletes(grouped.delete); } // Process adds and changes together const toReindex = [...grouped.add, ...grouped.change]; if (toReindex.length > 0) { await this.processReindex(toReindex); } // Save updated hashes await this.saveFileHashes(); // Emit event for completed processing this.emit('changes-processed', { added: grouped.add.length, changed: grouped.change.length, deleted: grouped.delete.length, timestamp: new Date().toISOString() }); console.log(`āœ… Processed ${changes.length} changes successfully\n`); } catch (error) { console.error('Error processing changes:', error); this.emit('error', error); } finally { this.isProcessing = false; } } async processDeletes(deleteChanges) { const filePaths = deleteChanges.map(change => change.path); this.emit('files-deleted', filePaths); // Remove from hashes filePaths.forEach(path => { this.fileHashes.delete(path); }); } async processReindex(reindexChanges) { const filesToIndex = []; for (const change of reindexChanges) { try { const content = await fs.readFile(change.fullPath, 'utf-8'); const hash = await this.calculateHash(content); this.fileHashes.set(change.path, hash); filesToIndex.push({ path: change.path, fullPath: change.fullPath, content, hash, changeType: change.type }); } catch (error) { console.error(`Error reading ${change.path}:`, error.message); } } if (filesToIndex.length > 0) { this.emit('files-to-index', filesToIndex); } } async calculateFileHash(filePath) { try { const content = await fs.readFile(filePath, 'utf-8'); return this.calculateHash(content); } catch (error) { console.error(`Error calculating hash for ${filePath}:`, error.message); return null; } } calculateHash(content) { return crypto.createHash('sha256').update(content, 'utf-8').digest('hex'); } async loadFileHashes() { const hashFilePath = path.join(this.rootPath, 'ai_index/file_hashes.json'); try { const content = await fs.readFile(hashFilePath, 'utf-8'); const hashes = JSON.parse(content); // Convert to Map and filter only JS/TS files Object.entries(hashes).forEach(([file, hash]) => { if (this.isJavaScriptFile(file)) { this.fileHashes.set(file, hash); } }); console.log(`Loaded ${this.fileHashes.size} file hashes`); } catch (error) { if (error.code !== 'ENOENT') { console.error('Error loading file hashes:', error); } } } async saveFileHashes() { const hashFilePath = path.join(this.rootPath, 'ai_index/file_hashes.json'); const hashDir = path.dirname(hashFilePath); try { await fs.mkdir(hashDir, { recursive: true }); const hashes = Object.fromEntries(this.fileHashes); await fs.writeFile(hashFilePath, JSON.stringify(hashes, null, 2)); } catch (error) { console.error('Error saving file hashes:', error); } } isJavaScriptFile(filePath) { const ext = path.extname(filePath).toLowerCase(); return ['.js', '.jsx', '.ts', '.tsx', '.mjs'].includes(ext); } getStats() { return { monitoring: this.watcher !== null, rootPath: this.rootPath, filesTracked: this.fileHashes.size, pendingChanges: this.pendingChanges.size, isProcessing: this.isProcessing }; } } export function createFileMonitor(options) { return new FileMonitor(options); }