diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 90af20b..a8b426e 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -284,6 +284,92 @@ fn create_system_command( cmd } +/// Starts watching the Claude projects directory for the specific project +#[tauri::command] +pub async fn watch_claude_project_directory( + project_path: String, + app_handle: tauri::AppHandle, +) -> Result<(), String> { + use crate::file_watcher::FileWatcherState; + + log::info!("Starting to watch Claude project directory for project: {}", project_path); + + let claude_dir = get_claude_dir().map_err(|e| e.to_string())?; + let projects_dir = claude_dir.join("projects"); + + if !projects_dir.exists() { + return Err("Claude projects directory does not exist".to_string()); + } + + // 找到对应项目的目录 + if let Ok(entries) = std::fs::read_dir(&projects_dir) { + for entry in entries { + if let Ok(entry) = entry { + let path = entry.path(); + if path.is_dir() { + // 检查是否是当前项目的目录 + if let Ok(found_project_path) = get_project_path_from_sessions(&path) { + if found_project_path == project_path { + // 找到了对应的项目目录,开始监控 + let file_watcher_state = app_handle.state::(); + let path_str = path.to_string_lossy().to_string(); + + return file_watcher_state.with_manager(|manager| { + manager.watch_path(&path_str, false) + }).map_err(|e| format!("Failed to watch Claude project directory: {}", e)); + } + } + } + } + } + } + + Err("Could not find Claude project directory for the given project path".to_string()) +} + +/// Stops watching the Claude projects directory +#[tauri::command] +pub async fn unwatch_claude_project_directory( + project_path: String, + app_handle: tauri::AppHandle, +) -> Result<(), String> { + use crate::file_watcher::FileWatcherState; + + log::info!("Stopping watch on Claude project directory for project: {}", project_path); + + let claude_dir = get_claude_dir().map_err(|e| e.to_string())?; + let projects_dir = claude_dir.join("projects"); + + if !projects_dir.exists() { + return Ok(()); // 目录不存在,视为成功 + } + + // 找到对应项目的目录 + if let Ok(entries) = std::fs::read_dir(&projects_dir) { + for entry in entries { + if let Ok(entry) = entry { + let path = entry.path(); + if path.is_dir() { + // 检查是否是当前项目的目录 + if let Ok(found_project_path) = get_project_path_from_sessions(&path) { + if found_project_path == project_path { + // 找到了对应的项目目录,停止监控 + let file_watcher_state = app_handle.state::(); + let path_str = path.to_string_lossy().to_string(); + + return file_watcher_state.with_manager(|manager| { + manager.unwatch_path(&path_str) + }).map_err(|e| format!("Failed to stop watching Claude project directory: {}", e)); + } + } + } + } + } + } + + Ok(()) +} + /// Lists all projects in the ~/.claude/projects directory #[tauri::command] pub async fn list_projects() -> Result, String> { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b692616..2d03d7a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -30,6 +30,7 @@ use commands::claude::{ save_claude_md_file, save_claude_settings, save_system_prompt, search_files, track_checkpoint_message, track_session_messages, update_checkpoint_settings, get_hooks_config, update_hooks_config, validate_hook_command, + watch_claude_project_directory, unwatch_claude_project_directory, ClaudeProcessState, }; use commands::mcp::{ @@ -237,6 +238,8 @@ fn main() { check_claude_version, save_system_prompt, save_claude_settings, + watch_claude_project_directory, + unwatch_claude_project_directory, find_claude_md_files, read_claude_md_file, save_claude_md_file, diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index 0a30521..7386b30 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -37,7 +37,7 @@ import { StreamMessage } from "./StreamMessage"; import { FloatingPromptInput, type FloatingPromptInputRef } from "./FloatingPromptInput"; import { TimelineNavigator } from "./TimelineNavigator"; import { CheckpointSettings } from "./CheckpointSettings"; -import { SlashCommandsManager } from "./SlashCommandsManager"; +import { fileSyncManager } from "@/lib/fileSyncManager"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { SplitPane } from "@/components/ui/split-pane"; @@ -165,6 +165,7 @@ export const ClaudeCodeSession: React.FC = ({ const [showFileMonitor, setShowFileMonitor] = useState(false); const [isFileWatching, setIsFileWatching] = useState(false); const [fileMonitorCollapsed, setFileMonitorCollapsed] = useState(false); + const [fileMonitorExpanded, setFileMonitorExpanded] = useState(false); // File editor state // 移除重复的状态,使用 layout 中的状态 @@ -211,10 +212,19 @@ export const ClaudeCodeSession: React.FC = ({ try { console.log('[FileMonitor] Starting file watching for:', projectPath); - // 启动文件监控 + // 启动项目目录文件监控 await api.watchDirectory(projectPath, true); // recursive = true + + // 启动 Claude 项目目录监控 + try { + await api.watchClaudeProjectDirectory(projectPath); + console.log('[FileMonitor] Claude project directory watching started for:', projectPath); + } catch (claudeErr) { + console.warn('[FileMonitor] Failed to start Claude project directory watching:', claudeErr); + // 不影响主要的文件监控功能 + } + setIsFileWatching(true); - setShowFileMonitor(true); console.log('[FileMonitor] File watching started successfully'); @@ -231,8 +241,17 @@ export const ClaudeCodeSession: React.FC = ({ return; } + // 通知文件同步管理器 + fileSyncManager.notifyFileChange(path, change_type); + + // 判断是否是 Claude 项目文件变化 + const isClaudeProjectFile = path.includes('/.claude/projects/'); + const displayPath = isClaudeProjectFile + ? path.replace(/.*\/\.claude\/projects\/[^/]+\//, '[Claude] ') // 简化 Claude 项目文件路径显示 + : path.replace(projectPath + '/', ''); // 项目文件相对路径 + const newChange: FileChange = { - path: path.replace(projectPath + '/', ''), // 相对路径 + path: displayPath, changeType: change_type, timestamp: Date.now(), }; @@ -242,13 +261,25 @@ export const ClaudeCodeSession: React.FC = ({ const updated = [newChange, ...prev].slice(0, 100); return updated; }); + + // 如果是 Claude 项目文件变化且文件被修改,重新加载会话历史 + if (isClaudeProjectFile && change_type === 'modified' && session) { + const fileName = path.split('/').pop() || ''; + // 检查是否是当前会话的 JSONL 文件 + if (fileName === `${session.id}.jsonl`) { + console.log('[FileMonitor] Claude session file updated, reloading history'); + // 使用 setTimeout 避免频繁刷新 + setTimeout(() => { + loadSessionHistory(); + }, 500); + } + } }); fileWatcherUnlistenRef.current = unlisten; } catch (err) { console.error('[FileMonitor] Failed to start file watching:', err); setIsFileWatching(false); - setShowFileMonitor(false); } }, [projectPath, isFileWatching]); @@ -265,8 +296,18 @@ export const ClaudeCodeSession: React.FC = ({ fileWatcherUnlistenRef.current = null; } - // 停止文件监控 + // 停止项目目录文件监控 await api.unwatchDirectory(projectPath); + + // 停止 Claude 项目目录监控 + try { + await api.unwatchClaudeProjectDirectory(projectPath); + console.log('[FileMonitor] Claude project directory watching stopped for:', projectPath); + } catch (claudeErr) { + console.warn('[FileMonitor] Failed to stop Claude project directory watching:', claudeErr); + // 不影响主要的停止功能 + } + setIsFileWatching(false); // 清空文件变化记录 @@ -478,6 +519,11 @@ export const ClaudeCodeSession: React.FC = ({ // After loading history, we're continuing a conversation setIsFirstPrompt(false); + + // 加载完成后自动滚动到底部 + setTimeout(() => { + scrollToBottom(); + }, 200); } catch (err) { console.error("Failed to load session history:", err); setError("Failed to load session history"); @@ -1397,15 +1443,46 @@ export const ClaudeCodeSession: React.FC = ({ )} - {/* 滚动按钮 */} + {/* 滚动按钮和文件监控小点 */} - {showScrollButtons && ( + {(showScrollButtons || isFileWatching) && ( + {/* 文件监控小绿点 */} + {isFileWatching && !fileMonitorExpanded && ( +
setFileMonitorExpanded(true)} + className="relative cursor-pointer group self-center" + > +
+ {/* 脉冲效果 */} + {isFileWatching && fileChanges.length > 0 && ( +
+ )} +
+ + {/* 悬浮提示 */} +
+ 文件监控 {fileChanges.length > 0 && `(${fileChanges.length})`} +
+ + {/* 变化数量小徽章 */} + {fileChanges.length > 0 && ( +
+ {fileChanges.length > 9 ? '9+' : fileChanges.length} +
+ )} +
+ )} + + {/* 滚动到顶部按钮 */} {!isAtTop && ( @@ -1425,6 +1502,8 @@ export const ClaudeCodeSession: React.FC = ({ )} + + {/* 滚动到底部按钮 */} {!isAtBottom && ( @@ -1825,14 +1904,14 @@ export const ClaudeCodeSession: React.FC = ({ } floatingElements={ <> - {/* 文件监控面板 */} + {/* 文件监控展开面板 */} - {showFileMonitor && fileChanges.length > 0 && ( + {isFileWatching && fileMonitorExpanded && (
@@ -1861,6 +1940,14 @@ export const ClaudeCodeSession: React.FC = ({ > +
diff --git a/src/components/FileEditorEnhanced.tsx b/src/components/FileEditorEnhanced.tsx index 7fcb381..5983570 100644 --- a/src/components/FileEditorEnhanced.tsx +++ b/src/components/FileEditorEnhanced.tsx @@ -41,6 +41,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { fileSyncManager } from "@/lib/fileSyncManager"; interface FileEditorEnhancedProps { filePath: string; @@ -472,8 +473,39 @@ export const FileEditorEnhanced: React.FC = ({ return () => window.removeEventListener("keydown", handleKeyDown); }, [hasChanges, saveFile, isFullscreen]); - // 使用真正的文件系统监听 + // 使用文件同步管理器监听文件变化 useEffect(() => { + if (!filePath) return; + + const listenerId = `file-editor-${filePath}`; + + // 注册文件变化监听器 + fileSyncManager.registerChangeListener( + listenerId, + filePath, + (changedPath, changeType) => { + // 检查是否是当前文件的变化 + if (changedPath === filePath && (changeType === 'modified' || changeType === 'created')) { + // 检查时间间隔,避免自己保存触发的事件 + const timeSinceLastSave = Date.now() - lastCheckTime; + + if (timeSinceLastSave > 1000) { // 超过1秒,可能是外部修改 + console.log('[FileEditor] File changed externally via FileSyncManager:', changedPath, changeType); + setFileChanged(true); + + // 如果没有未保存的更改,自动重新加载 + if (!hasChanges) { + console.log('[FileEditor] Auto-reloading file content'); + loadFile(); + } else { + // 显示提示 + setError("文件已被外部程序修改,点击重新加载按钮查看最新内容"); + } + } + } + } + ); + const setupFileWatcher = async () => { if (!filePath) return; @@ -485,7 +517,7 @@ export const FileEditorEnhanced: React.FC = ({ recursive: false }); - // 监听文件变化事件 + // 监听文件变化事件(作为备用) unlistenRef.current = await listen('file-system-change', (event: any) => { const { path, change_type } = event.payload; @@ -495,7 +527,7 @@ export const FileEditorEnhanced: React.FC = ({ const timeSinceLastSave = Date.now() - lastCheckTime; if (timeSinceLastSave > 1000) { // 超过1秒,可能是外部修改 - console.log('File changed externally:', path, change_type); + console.log('[FileEditor] File changed externally (fallback):', path, change_type); setFileChanged(true); // 如果没有未保存的更改,自动重新加载 @@ -553,6 +585,9 @@ export const FileEditorEnhanced: React.FC = ({ // 清理函数 return () => { + // 注销文件同步管理器监听器 + fileSyncManager.unregisterListener(listenerId, filePath); + // 停止监听 if (filePath) { const dirPath = filePath.substring(0, filePath.lastIndexOf('/')); diff --git a/src/lib/api.ts b/src/lib/api.ts index ef3d22f..6c72f55 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2482,5 +2482,35 @@ export const api = { console.error("Failed to unwatch directory:", error); throw error; } + }, + + // ============= Claude Project Directory Watching ============= + + /** + * Starts watching Claude project directory for the given project path + * @param projectPath - The project path to find the corresponding Claude directory + * @returns Promise resolving when watching starts + */ + async watchClaudeProjectDirectory(projectPath: string): Promise { + try { + return await invoke("watch_claude_project_directory", { projectPath }); + } catch (error) { + console.error("Failed to watch Claude project directory:", error); + throw error; + } + }, + + /** + * Stops watching Claude project directory for the given project path + * @param projectPath - The project path to find the corresponding Claude directory + * @returns Promise resolving when watching stops + */ + async unwatchClaudeProjectDirectory(projectPath: string): Promise { + try { + return await invoke("unwatch_claude_project_directory", { projectPath }); + } catch (error) { + console.error("Failed to unwatch Claude project directory:", error); + throw error; + } } }; diff --git a/src/lib/fileSyncManager.ts b/src/lib/fileSyncManager.ts new file mode 100644 index 0000000..17d1303 --- /dev/null +++ b/src/lib/fileSyncManager.ts @@ -0,0 +1,255 @@ +/** + * 文件同步管理器 - 管理文件变化通知和内容同步 + */ + +interface FileChangeListener { + id: string; + filePath: string; + callback: (filePath: string, changeType: string) => void; +} + +interface FileContentListener { + id: string; + filePath: string; + callback: (filePath: string, newContent: string) => void; +} + +class FileSyncManager { + private changeListeners: Map = new Map(); + private contentListeners: Map = new Map(); + private static instance: FileSyncManager | null = null; + + public static getInstance(): FileSyncManager { + if (!FileSyncManager.instance) { + FileSyncManager.instance = new FileSyncManager(); + } + return FileSyncManager.instance; + } + + /** + * 注册文件变化监听器 + * @param id 监听器唯一标识 + * @param filePath 文件路径(可以是相对路径或绝对路径) + * @param callback 变化回调函数 + */ + public registerChangeListener( + id: string, + filePath: string, + callback: (filePath: string, changeType: string) => void + ): void { + const normalizedPath = this.normalizePath(filePath); + + if (!this.changeListeners.has(normalizedPath)) { + this.changeListeners.set(normalizedPath, []); + } + + const listeners = this.changeListeners.get(normalizedPath)!; + + // 移除已存在的相同ID监听器 + const existingIndex = listeners.findIndex(l => l.id === id); + if (existingIndex !== -1) { + listeners.splice(existingIndex, 1); + } + + // 添加新监听器 + listeners.push({ id, filePath: normalizedPath, callback }); + + console.log(`[FileSyncManager] Registered change listener ${id} for ${normalizedPath}`); + } + + /** + * 注册文件内容监听器 + * @param id 监听器唯一标识 + * @param filePath 文件路径 + * @param callback 内容更新回调函数 + */ + public registerContentListener( + id: string, + filePath: string, + callback: (filePath: string, newContent: string) => void + ): void { + const normalizedPath = this.normalizePath(filePath); + + if (!this.contentListeners.has(normalizedPath)) { + this.contentListeners.set(normalizedPath, []); + } + + const listeners = this.contentListeners.get(normalizedPath)!; + + // 移除已存在的相同ID监听器 + const existingIndex = listeners.findIndex(l => l.id === id); + if (existingIndex !== -1) { + listeners.splice(existingIndex, 1); + } + + // 添加新监听器 + listeners.push({ id, filePath: normalizedPath, callback }); + + console.log(`[FileSyncManager] Registered content listener ${id} for ${normalizedPath}`); + } + + /** + * 注销监听器 + * @param id 监听器ID + * @param filePath 文件路径(可选) + */ + public unregisterListener(id: string, filePath?: string): void { + if (filePath) { + const normalizedPath = this.normalizePath(filePath); + + // 从变化监听器中移除 + const changeListeners = this.changeListeners.get(normalizedPath); + if (changeListeners) { + const index = changeListeners.findIndex(l => l.id === id); + if (index !== -1) { + changeListeners.splice(index, 1); + if (changeListeners.length === 0) { + this.changeListeners.delete(normalizedPath); + } + } + } + + // 从内容监听器中移除 + const contentListeners = this.contentListeners.get(normalizedPath); + if (contentListeners) { + const index = contentListeners.findIndex(l => l.id === id); + if (index !== -1) { + contentListeners.splice(index, 1); + if (contentListeners.length === 0) { + this.contentListeners.delete(normalizedPath); + } + } + } + } else { + // 移除所有该ID的监听器 + for (const [path, listeners] of this.changeListeners.entries()) { + const index = listeners.findIndex(l => l.id === id); + if (index !== -1) { + listeners.splice(index, 1); + if (listeners.length === 0) { + this.changeListeners.delete(path); + } + } + } + + for (const [path, listeners] of this.contentListeners.entries()) { + const index = listeners.findIndex(l => l.id === id); + if (index !== -1) { + listeners.splice(index, 1); + if (listeners.length === 0) { + this.contentListeners.delete(path); + } + } + } + } + + console.log(`[FileSyncManager] Unregistered listener ${id}${filePath ? ` for ${filePath}` : ''}`); + } + + /** + * 通知文件变化 + * @param filePath 变化的文件路径 + * @param changeType 变化类型 + */ + public notifyFileChange(filePath: string, changeType: string): void { + const normalizedPath = this.normalizePath(filePath); + const listeners = this.changeListeners.get(normalizedPath); + + if (listeners && listeners.length > 0) { + console.log(`[FileSyncManager] Notifying ${listeners.length} change listeners for ${normalizedPath}`); + + listeners.forEach(listener => { + try { + listener.callback(normalizedPath, changeType); + } catch (error) { + console.error(`[FileSyncManager] Error in change listener ${listener.id}:`, error); + } + }); + } + } + + /** + * 通知文件内容更新 + * @param filePath 文件路径 + * @param newContent 新内容 + */ + public notifyContentUpdate(filePath: string, newContent: string): void { + const normalizedPath = this.normalizePath(filePath); + const listeners = this.contentListeners.get(normalizedPath); + + if (listeners && listeners.length > 0) { + console.log(`[FileSyncManager] Notifying ${listeners.length} content listeners for ${normalizedPath}`); + + listeners.forEach(listener => { + try { + listener.callback(normalizedPath, newContent); + } catch (error) { + console.error(`[FileSyncManager] Error in content listener ${listener.id}:`, error); + } + }); + } + } + + /** + * 规范化文件路径 + * @param filePath 原始文件路径 + * @returns 规范化后的路径 + */ + private normalizePath(filePath: string): string { + return filePath + .replace(/\\/g, '/') // 统一使用正斜杠 + .replace(/\/+/g, '/') // 移除重复斜杠 + .replace(/\/$/, ''); // 移除结尾斜杠 + } + + /** + * 检查路径是否匹配(支持相对路径匹配) + * @param watchPath 监听的路径 + * @param changePath 变化的路径 + * @returns 是否匹配 + */ + public pathMatches(watchPath: string, changePath: string): boolean { + const normalizedWatchPath = this.normalizePath(watchPath); + const normalizedChangePath = this.normalizePath(changePath); + + // 完全匹配 + if (normalizedWatchPath === normalizedChangePath) { + return true; + } + + // 检查是否为相对路径匹配 + const watchPathParts = normalizedWatchPath.split('/'); + const changePathParts = normalizedChangePath.split('/'); + + // 如果监听路径更短,检查是否为后缀匹配 + if (watchPathParts.length <= changePathParts.length) { + const offset = changePathParts.length - watchPathParts.length; + for (let i = 0; i < watchPathParts.length; i++) { + if (watchPathParts[i] !== changePathParts[i + offset]) { + return false; + } + } + return true; + } + + return false; + } + + /** + * 获取当前注册的监听器统计 + */ + public getStats(): { changeListeners: number; contentListeners: number; totalFiles: number } { + const changeListenerCount = Array.from(this.changeListeners.values()).reduce((sum, arr) => sum + arr.length, 0); + const contentListenerCount = Array.from(this.contentListeners.values()).reduce((sum, arr) => sum + arr.length, 0); + const totalFiles = new Set([...this.changeListeners.keys(), ...this.contentListeners.keys()]).size; + + return { + changeListeners: changeListenerCount, + contentListeners: contentListenerCount, + totalFiles + }; + } +} + +export const fileSyncManager = FileSyncManager.getInstance(); +export default fileSyncManager; \ No newline at end of file