claude-code-templates
Version:
CLI tool to setup Claude Code configurations with framework-specific commands, automation hooks and MCP Servers for your projects
420 lines (359 loc) • 12.4 kB
JavaScript
const chalk = require('chalk');
const chokidar = require('chokidar');
const path = require('path');
/**
* FileWatcher - Handles file system watching and automatic data refresh
* Extracted from monolithic analytics.js for better maintainability
*/
class FileWatcher {
constructor() {
this.watchers = [];
this.intervals = [];
this.isActive = false;
this.fileActivity = new Map(); // Track file activity for typing detection
this.typingTimeout = new Map(); // Track typing timeouts
}
/**
* Setup file watchers for real-time updates
* @param {string} claudeDir - Path to Claude directory
* @param {Function} dataRefreshCallback - Callback to refresh data
* @param {Function} processRefreshCallback - Callback to refresh process data
* @param {Object} dataCache - DataCache instance for invalidation
*/
setupFileWatchers(claudeDir, dataRefreshCallback, processRefreshCallback, dataCache = null, conversationChangeCallback = null) {
console.log(chalk.blue('👀 Setting up file watchers for real-time updates...'));
this.claudeDir = claudeDir;
this.dataRefreshCallback = dataRefreshCallback;
this.processRefreshCallback = processRefreshCallback;
this.dataCache = dataCache;
this.conversationChangeCallback = conversationChangeCallback;
this.setupConversationWatcher();
this.setupProjectWatcher();
this.setupPeriodicRefresh();
this.isActive = true;
}
/**
* Setup watcher for conversation files (.jsonl)
*/
setupConversationWatcher() {
const conversationWatcher = chokidar.watch([
path.join(this.claudeDir, '**/*.jsonl')
], {
persistent: true,
ignoreInitial: true,
});
conversationWatcher.on('change', async (filePath) => {
// Extract conversation ID from file path
const conversationId = this.extractConversationId(filePath);
// Enhanced file activity detection for typing
await this.handleFileActivity(conversationId, filePath);
// Invalidate cache for the changed file
if (this.dataCache && filePath) {
this.dataCache.invalidateFile(filePath);
}
// Notify specific conversation change if callback exists
if (this.conversationChangeCallback && conversationId) {
await this.conversationChangeCallback(conversationId, filePath);
}
await this.triggerDataRefresh();
});
conversationWatcher.on('add', async () => {
await this.triggerDataRefresh();
});
this.watchers.push(conversationWatcher);
}
/**
* Setup watcher for project directories
*/
setupProjectWatcher() {
const projectWatcher = chokidar.watch(this.claudeDir, {
persistent: true,
ignoreInitial: true,
depth: 2, // Increased depth to catch subdirectories
});
projectWatcher.on('addDir', async () => {
await this.triggerDataRefresh();
});
projectWatcher.on('change', async () => {
await this.triggerDataRefresh();
});
this.watchers.push(projectWatcher);
}
/**
* Setup periodic refresh intervals
*/
setupPeriodicRefresh() {
// Periodic refresh to catch any missed changes (reduced frequency)
const dataRefreshInterval = setInterval(async () => {
await this.triggerDataRefresh();
}, 120000); // Every 2 minutes (reduced from 30 seconds)
this.intervals.push(dataRefreshInterval);
// Process updates for active processes (reduced frequency)
const processRefreshInterval = setInterval(async () => {
if (this.processRefreshCallback) {
await this.processRefreshCallback();
}
}, 30000); // Every 30 seconds (reduced from 10 seconds)
this.intervals.push(processRefreshInterval);
}
/**
* Extract conversation ID from file path
* @param {string} filePath - Path to the conversation file
* @returns {string|null} Conversation ID or null if not found
*/
extractConversationId(filePath) {
try {
// Handle different path formats:
// /Users/user/.claude/projects/PROJECT_NAME/conversation.jsonl -> PROJECT_NAME
// /Users/user/.claude/CONVERSATION_ID.jsonl -> CONVERSATION_ID
const pathParts = filePath.split(path.sep);
const fileName = pathParts[pathParts.length - 1];
if (fileName === 'conversation.jsonl') {
// Project-based conversation
const projectName = pathParts[pathParts.length - 2];
return projectName;
} else if (fileName.endsWith('.jsonl')) {
// Direct conversation file
return fileName.replace('.jsonl', '');
}
return null;
} catch (error) {
console.error(chalk.red('Error extracting conversation ID:'), error);
return null;
}
}
/**
* Handle file activity for typing detection
* @param {string} conversationId - Conversation ID
* @param {string} filePath - File path that changed
*/
async handleFileActivity(conversationId, filePath) {
if (!conversationId) return;
const fs = require('fs');
try {
// Get file stats
const stats = fs.statSync(filePath);
const now = Date.now();
const fileSize = stats.size;
const mtime = stats.mtime.getTime();
// Get previous activity
const previousActivity = this.fileActivity.get(conversationId) || {
lastSize: 0,
lastMtime: 0,
lastMessageCheck: 0
};
// Check if this is just a file touch/modification without significant content change
const sizeChanged = fileSize !== previousActivity.lastSize;
const timeChanged = mtime !== previousActivity.lastMtime;
const timeSinceLastCheck = now - previousActivity.lastMessageCheck;
// Update activity tracking
this.fileActivity.set(conversationId, {
lastSize: fileSize,
lastMtime: mtime,
lastMessageCheck: now
});
// If file changed but we haven't checked for complete messages recently
if ((sizeChanged || timeChanged) && timeSinceLastCheck > 1000) {
// Clear any existing typing timeout
const existingTimeout = this.typingTimeout.get(conversationId);
if (existingTimeout) {
clearTimeout(existingTimeout);
}
// Set a timeout to detect if this is typing activity
const typingTimeout = setTimeout(async () => {
// After delay, check if a complete message was added
await this.checkForTypingActivity(conversationId, filePath);
}, 2000); // Wait 2 seconds to see if a complete message appears
this.typingTimeout.set(conversationId, typingTimeout);
}
} catch (error) {
console.error(chalk.red(`Error handling file activity for ${conversationId}:`), error);
}
}
/**
* Check if file activity indicates user typing
* @param {string} conversationId - Conversation ID
* @param {string} filePath - File path to check
*/
async checkForTypingActivity(conversationId, filePath) {
try {
// Parse the conversation to see if new complete messages were added
const ConversationAnalyzer = require('./ConversationAnalyzer');
const analyzer = new ConversationAnalyzer();
const messages = await analyzer.getParsedConversation(filePath);
if (messages && messages.length > 0) {
const lastMessage = messages[messages.length - 1];
const lastMessageTime = new Date(lastMessage.timestamp).getTime();
const now = Date.now();
const messageAge = now - lastMessageTime;
// If the last message is very recent (< 5 seconds), it's probably a new complete message
// If it's older, the file activity might indicate typing
if (messageAge > 5000 && lastMessage.role === 'assistant') {
// File activity after assistant message suggests user is typing
// Send typing notification if we have access to notification manager
if (this.notificationManager) {
this.notificationManager.notifyConversationStateChange(conversationId, 'User typing...', {
detectionMethod: 'file_activity',
timestamp: new Date().toISOString()
});
}
}
}
} catch (error) {
console.error(chalk.red(`Error checking typing activity for ${conversationId}:`), error);
}
}
/**
* Set notification manager for state notifications
* @param {Object} notificationManager - NotificationManager instance
*/
setNotificationManager(notificationManager) {
this.notificationManager = notificationManager;
}
/**
* Trigger data refresh with error handling
*/
async triggerDataRefresh() {
try {
if (this.dataRefreshCallback) {
await this.dataRefreshCallback();
}
} catch (error) {
console.error(chalk.red('Error during data refresh:'), error.message);
}
}
/**
* Add a custom watcher
* @param {Object} watcher - Chokidar watcher instance
*/
addWatcher(watcher) {
this.watchers.push(watcher);
}
/**
* Add a custom interval
* @param {number} intervalId - Interval ID from setInterval
*/
addInterval(intervalId) {
this.intervals.push(intervalId);
}
/**
* Pause all watchers and intervals
*/
pause() {
console.log(chalk.yellow('⏸️ Pausing file watchers...'));
// Pause watchers (they will still exist but not trigger events)
this.watchers.forEach(watcher => {
if (watcher.unwatch) {
// Temporarily remove all watched paths
const watchedPaths = watcher.getWatched();
Object.keys(watchedPaths).forEach(dir => {
watchedPaths[dir].forEach(file => {
watcher.unwatch(path.join(dir, file));
});
});
}
});
this.isActive = false;
}
/**
* Resume all watchers
*/
resume() {
if (!this.isActive && this.claudeDir) {
console.log(chalk.green('▶️ Resuming file watchers...'));
// Clear existing watchers
this.stop();
// Restart watchers
this.setupFileWatchers(
this.claudeDir,
this.dataRefreshCallback,
this.processRefreshCallback
);
}
}
/**
* Stop and cleanup all watchers and intervals
*/
stop() {
console.log(chalk.red('🛑 Stopping file watchers...'));
// Close all watchers
this.watchers.forEach(watcher => {
try {
watcher.close();
} catch (error) {
console.warn(chalk.yellow('Warning: Error closing watcher:'), error.message);
}
});
// Clear all intervals
this.intervals.forEach(intervalId => {
clearInterval(intervalId);
});
// Reset arrays
this.watchers = [];
this.intervals = [];
this.isActive = false;
}
/**
* Get watcher status
* @returns {Object} Status information
*/
getStatus() {
return {
isActive: this.isActive,
watcherCount: this.watchers.length,
intervalCount: this.intervals.length,
watchedDir: this.claudeDir
};
}
/**
* Check if watchers are active
* @returns {boolean} True if watchers are active
*/
isWatching() {
return this.isActive && this.watchers.length > 0;
}
/**
* Get list of watched paths (for debugging)
* @returns {Array} Array of watched paths
*/
getWatchedPaths() {
const watchedPaths = [];
this.watchers.forEach(watcher => {
if (watcher.getWatched) {
const watched = watcher.getWatched();
Object.keys(watched).forEach(dir => {
watched[dir].forEach(file => {
watchedPaths.push(path.join(dir, file));
});
});
}
});
return watchedPaths;
}
/**
* Set debounced refresh to avoid spam
* @param {number} debounceMs - Debounce time in milliseconds
*/
setDebounce(debounceMs = 200) {
let debounceTimeout;
const originalCallback = this.dataRefreshCallback;
this.dataRefreshCallback = async (...args) => {
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(async () => {
if (originalCallback) {
await originalCallback(...args);
}
}, debounceMs);
};
}
/**
* Force immediate refresh
*/
async forceRefresh() {
await this.triggerDataRefresh();
if (this.processRefreshCallback) {
await this.processRefreshCallback();
}
}
}
module.exports = FileWatcher;