ai-index
Version:
AI-powered local code indexing and search system for any codebase
279 lines (230 loc) ⢠7.48 kB
JavaScript
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);
}