mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
Removed depth:0 restriction so images saved in src/, assets/, etc. are detected. Added filtering for node_modules, .git, dist, .next for performance. chore: bump version to 0.1407 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
271 lines
7.7 KiB
TypeScript
271 lines
7.7 KiB
TypeScript
/**
|
|
* @fileoverview Image Watcher - Detects new image files in session working directories
|
|
*
|
|
* Watches session working directories for new image files (screenshots, generated images)
|
|
* and emits events to trigger automatic popup display in the web UI.
|
|
*
|
|
* Uses chokidar for reliable cross-platform file watching with awaitWriteFinish
|
|
* to ensure files are fully written before emitting detection events.
|
|
*/
|
|
|
|
import { EventEmitter } from 'events';
|
|
import { watch, type FSWatcher } from 'chokidar';
|
|
import { basename, extname } from 'path';
|
|
import { statSync } from 'fs';
|
|
import type { ImageDetectedEvent } from './types.js';
|
|
|
|
// ========== Types ==========
|
|
|
|
export interface ImageWatcherEvents {
|
|
'image:detected': (event: ImageDetectedEvent) => void;
|
|
'image:error': (error: Error, sessionId?: string) => void;
|
|
}
|
|
|
|
// ========== Constants ==========
|
|
|
|
/** Supported image file extensions (lowercase) */
|
|
const IMAGE_EXTENSIONS = new Set([
|
|
'.png',
|
|
'.jpg',
|
|
'.jpeg',
|
|
'.gif',
|
|
'.webp',
|
|
'.bmp',
|
|
'.svg',
|
|
]);
|
|
|
|
/** Time to wait for file writes to stabilize (ms) */
|
|
const STABILITY_THRESHOLD_MS = 500;
|
|
|
|
/** Poll interval for checking file write stability (ms) */
|
|
const POLL_INTERVAL_MS = 100;
|
|
|
|
/** Debounce delay for rapid image creation (ms) */
|
|
const DEBOUNCE_DELAY_MS = 200;
|
|
|
|
// ========== ImageWatcher Class ==========
|
|
|
|
/**
|
|
* Watches session working directories for new image files.
|
|
*
|
|
* Follows the SubagentWatcher pattern: extends EventEmitter, manages
|
|
* file watchers in Maps, emits typed events.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const watcher = new ImageWatcher();
|
|
* watcher.on('image:detected', (event) => {
|
|
* console.log(`New image in session ${event.sessionId}: ${event.fileName}`);
|
|
* });
|
|
* watcher.watchSession('session-123', '/path/to/working/dir');
|
|
* ```
|
|
*/
|
|
export class ImageWatcher extends EventEmitter {
|
|
/** Map of sessionId -> FSWatcher for per-session directory watching */
|
|
private sessionWatchers = new Map<string, FSWatcher>();
|
|
|
|
/** Map of sessionId -> working directory path */
|
|
private sessionDirs = new Map<string, string>();
|
|
|
|
/** Debounce timers for rapid image creation */
|
|
private debounceTimers = new Map<string, NodeJS.Timeout>();
|
|
|
|
/** Whether the watcher is currently running */
|
|
private _isRunning = false;
|
|
|
|
constructor() {
|
|
super();
|
|
}
|
|
|
|
// ========== Public API ==========
|
|
|
|
/**
|
|
* Check if the watcher is currently running
|
|
*/
|
|
isRunning(): boolean {
|
|
return this._isRunning;
|
|
}
|
|
|
|
/**
|
|
* Start the image watcher.
|
|
* After calling start(), use watchSession() to add directories to monitor.
|
|
*/
|
|
start(): void {
|
|
if (this._isRunning) return;
|
|
this._isRunning = true;
|
|
}
|
|
|
|
/**
|
|
* Stop the image watcher and clean up all resources.
|
|
*/
|
|
stop(): void {
|
|
this._isRunning = false;
|
|
|
|
// Close all session watchers
|
|
for (const [sessionId, watcher] of this.sessionWatchers) {
|
|
try {
|
|
watcher.close();
|
|
} catch (error) {
|
|
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
|
|
}
|
|
}
|
|
this.sessionWatchers.clear();
|
|
this.sessionDirs.clear();
|
|
|
|
// Clear all debounce timers
|
|
for (const timer of this.debounceTimers.values()) {
|
|
clearTimeout(timer);
|
|
}
|
|
this.debounceTimers.clear();
|
|
}
|
|
|
|
/**
|
|
* Start watching a session's working directory for new images.
|
|
*
|
|
* @param sessionId - Claudeman session ID
|
|
* @param workingDir - Path to the session's working directory
|
|
*/
|
|
watchSession(sessionId: string, workingDir: string): void {
|
|
if (!this._isRunning) {
|
|
this.start();
|
|
}
|
|
|
|
// Don't double-watch the same session
|
|
if (this.sessionWatchers.has(sessionId)) {
|
|
// If working directory changed, unwatch old and watch new
|
|
if (this.sessionDirs.get(sessionId) !== workingDir) {
|
|
this.unwatchSession(sessionId);
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
this.sessionDirs.set(sessionId, workingDir);
|
|
|
|
try {
|
|
// Create chokidar watcher for the directory
|
|
const watcher = watch(workingDir, {
|
|
// Only detect NEW files, not existing ones
|
|
ignoreInitial: true,
|
|
// Wait for file writes to stabilize before emitting
|
|
awaitWriteFinish: {
|
|
stabilityThreshold: STABILITY_THRESHOLD_MS,
|
|
pollInterval: POLL_INTERVAL_MS,
|
|
},
|
|
// Watch all subdirectories (images may be saved in src/, assets/, etc.)
|
|
// Ignore common heavy directories for performance
|
|
ignored: (path: string) => {
|
|
// Skip node_modules, .git, and other heavy directories
|
|
if (path.includes('/node_modules/') || path.includes('/.git/') || path.includes('/dist/') || path.includes('/.next/')) {
|
|
return true;
|
|
}
|
|
const ext = extname(path).toLowerCase();
|
|
// Don't ignore directories (needed for watching to work)
|
|
// Ignore files that aren't images
|
|
return ext !== '' && !IMAGE_EXTENSIONS.has(ext);
|
|
},
|
|
});
|
|
|
|
// Handle new file detection
|
|
watcher.on('add', (filePath: string) => {
|
|
this.handleNewFile(sessionId, filePath);
|
|
});
|
|
|
|
// Handle watcher errors
|
|
watcher.on('error', (error: Error) => {
|
|
this.emit('image:error', error, sessionId);
|
|
});
|
|
|
|
this.sessionWatchers.set(sessionId, watcher);
|
|
} catch (error) {
|
|
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop watching a session's working directory.
|
|
*
|
|
* @param sessionId - Claudeman session ID to stop watching
|
|
*/
|
|
unwatchSession(sessionId: string): void {
|
|
const watcher = this.sessionWatchers.get(sessionId);
|
|
if (watcher) {
|
|
try {
|
|
watcher.close();
|
|
} catch (error) {
|
|
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
|
|
}
|
|
this.sessionWatchers.delete(sessionId);
|
|
}
|
|
this.sessionDirs.delete(sessionId);
|
|
|
|
// Clear any pending debounce timer for this session
|
|
const timer = this.debounceTimers.get(sessionId);
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
this.debounceTimers.delete(sessionId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get list of currently watched session IDs.
|
|
*/
|
|
getWatchedSessions(): string[] {
|
|
return Array.from(this.sessionWatchers.keys());
|
|
}
|
|
|
|
// ========== Private Methods ==========
|
|
|
|
/**
|
|
* Handle a new file being detected.
|
|
* Verifies it's an image and emits the detection event.
|
|
*/
|
|
private handleNewFile(sessionId: string, filePath: string): void {
|
|
const ext = extname(filePath).toLowerCase();
|
|
|
|
// Double-check it's an image extension
|
|
if (!IMAGE_EXTENSIONS.has(ext)) {
|
|
return;
|
|
}
|
|
|
|
// Debounce rapid file creation (e.g., multiple screenshots quickly)
|
|
const existingTimer = this.debounceTimers.get(filePath);
|
|
if (existingTimer) {
|
|
clearTimeout(existingTimer);
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
this.debounceTimers.delete(filePath);
|
|
this.emitImageDetected(sessionId, filePath);
|
|
}, DEBOUNCE_DELAY_MS);
|
|
|
|
this.debounceTimers.set(filePath, timer);
|
|
}
|
|
|
|
/**
|
|
* Emit the image:detected event with file metadata.
|
|
*/
|
|
private emitImageDetected(sessionId: string, filePath: string): void {
|
|
try {
|
|
const stat = statSync(filePath);
|
|
const fileName = basename(filePath);
|
|
|
|
const event: ImageDetectedEvent = {
|
|
sessionId,
|
|
filePath,
|
|
fileName,
|
|
timestamp: Date.now(),
|
|
size: stat.size,
|
|
};
|
|
|
|
this.emit('image:detected', event);
|
|
} catch (error) {
|
|
// File may have been deleted between detection and stat
|
|
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Export singleton instance for convenience
|
|
export const imageWatcher = new ImageWatcher();
|