Project Files
src / documents / fileWatcher.ts
/**
* File Watcher for Draw Things Index
* Watches all source directories for changes and invalidates cache
*/
import { watch, type FSWatcher } from "fs";
import { existsSync } from "fs";
import path from "path";
import { invalidateCache } from "../indexer";
// ═══════════════════════════════════════════════════════════════
// Multi-Directory Watcher
// ═══════════════════════════════════════════════════════════════
interface WatcherEntry {
watcher: FSWatcher;
extensions: string[]; // e.g. ['.jsonl'] or ['.png', '.jpg']
}
const watchers = new Map<string, WatcherEntry>();
/**
* File extensions for each source type
*/
const SOURCE_EXTENSIONS = {
jsonl: ['.jsonl'],
images: ['.png', '.jpg', '.jpeg', '.webp'],
projects: ['.sqlite3'],
};
interface WatchOptions {
/**
* If set, only these basenames trigger invalidation (e.g. ['generate-image-plugin.audit.jsonl']).
* Useful to avoid watching unrelated .jsonl files in the same directory.
*/
includeBasenames?: string[];
}
export function shouldInvalidateForFile(
filename: string,
extensions: string[],
options: WatchOptions = {},
): boolean {
const normalized = filename.toString();
const basename = path.basename(normalized);
const matchesExt = extensions.some((ext) => normalized.toLowerCase().endsWith(ext));
if (!matchesExt) return false;
if (options.includeBasenames?.length) {
const allow = options.includeBasenames.some((x) => x.toLowerCase() === basename.toLowerCase());
if (!allow) return false;
}
return true;
}
/**
* Start watching a directory for specific file types
*/
export function startWatching(
dirPath: string,
extensions: string[] = SOURCE_EXTENSIONS.jsonl,
options: WatchOptions = {}
): void {
// Already watching this path?
if (watchers.has(dirPath)) {
return;
}
if (!existsSync(dirPath)) {
console.warn(`[FileWatcher] Directory does not exist: ${dirPath}`);
return;
}
try {
const fsWatcher = watch(dirPath, { recursive: true }, (eventType, filename) => {
if (!filename) return;
const normalized = filename.toString();
if (!shouldInvalidateForFile(normalized, extensions, options)) return;
console.log(`[FileWatcher] ${eventType}: ${normalized}`);
invalidateCache();
});
watchers.set(dirPath, { watcher: fsWatcher, extensions });
console.log(`[FileWatcher] Watching: ${dirPath} (${extensions.join(', ')})`);
fsWatcher.on("error", (err) => {
console.warn(`[FileWatcher] Error on ${dirPath}: ${err.message}`);
stopWatching(dirPath);
});
} catch (e) {
console.warn(`[FileWatcher] Failed to start on ${dirPath}: ${e}`);
}
}
/**
* Convenience functions for each source type
*/
export function watchJsonlLogs(dirPath: string): void {
// Only react to the one supported audit log file.
startWatching(dirPath, SOURCE_EXTENSIONS.jsonl, {
includeBasenames: ["generate-image-plugin.audit.jsonl"],
});
}
export function watchImages(dirPath: string): void {
startWatching(dirPath, SOURCE_EXTENSIONS.images);
}
export function watchProjects(dirPath: string): void {
startWatching(dirPath, SOURCE_EXTENSIONS.projects);
}
/**
* Stop watching a specific directory
*/
export function stopWatching(dirPath?: string): void {
if (dirPath) {
const entry = watchers.get(dirPath);
if (entry) {
entry.watcher.close();
watchers.delete(dirPath);
console.log(`[FileWatcher] Stopped: ${dirPath}`);
}
} else {
// Stop all watchers
for (const [path, entry] of watchers) {
entry.watcher.close();
console.log(`[FileWatcher] Stopped: ${path}`);
}
watchers.clear();
}
}
/**
* Check if watching a specific path
*/
export function isWatching(dirPath?: string): boolean {
if (dirPath) {
return watchers.has(dirPath);
}
return watchers.size > 0;
}
/**
* Get all watched paths
*/
export function getWatchedPaths(): string[] {
return Array.from(watchers.keys());
}