From b1cd9f90989a5a555d532a00c65237c7e84c73f9 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Sun, 10 Aug 2025 21:44:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A1=B5=E9=9D=A2=E5=B8=83?= =?UTF-8?q?=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/Cargo.lock | 82 +++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/commands/filesystem.rs | 44 +++-- src-tauri/src/file_watcher.rs | 195 +++++++++++++++++++ src-tauri/src/lib.rs | 1 + src-tauri/src/main.rs | 11 +- src/components/ClaudeCodeSession.tsx | 175 ++++++++++------- src/components/FileEditorEnhanced.tsx | 151 ++++++++++++++- src/components/ui/grid-layout.tsx | 218 +++++++++++++++++++++ src/hooks/index.ts | 1 + src/hooks/useLayoutManager.ts | 268 ++++++++++++++++++++++++++ src/styles.css | 1 + src/styles/grid-layout.css | 181 +++++++++++++++++ 13 files changed, 1242 insertions(+), 87 deletions(-) create mode 100644 src-tauri/src/file_watcher.rs create mode 100644 src/components/ui/grid-layout.tsx create mode 100644 src/hooks/useLayoutManager.ts create mode 100644 src/styles/grid-layout.css diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0b2d709..7a4f01e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -631,6 +631,7 @@ dependencies = [ "glob", "libc", "log", + "notify", "objc", "once_cell", "regex", @@ -1439,6 +1440,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futf" version = "0.1.5" @@ -2292,6 +2302,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "intl-memoizer" version = "0.5.3" @@ -2492,6 +2522,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.2" @@ -2709,6 +2759,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.0.4" @@ -2823,6 +2885,24 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.9.1", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "notify-rust" version = "4.11.7" @@ -5283,7 +5363,7 @@ dependencies = [ "backtrace", "bytes", "libc", - "mio", + "mio 1.0.4", "parking_lot", "pin-project-lite", "signal-hook-registry", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 554019f..dd2e9b5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -52,6 +52,7 @@ sha2 = "0.10" zstd = "0.13" uuid = { version = "1.6", features = ["v4", "serde"] } walkdir = "2" +notify = { version = "6.1", default-features = false, features = ["macos_fsevent"] } serde_yaml = "0.9" fluent = "0.16" fluent-bundle = "0.15" diff --git a/src-tauri/src/commands/filesystem.rs b/src-tauri/src/commands/filesystem.rs index 664814f..eaea375 100644 --- a/src-tauri/src/commands/filesystem.rs +++ b/src-tauri/src/commands/filesystem.rs @@ -1,7 +1,8 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::path::Path; -use tauri::Emitter; +use tauri::State; +use crate::file_watcher::FileWatcherState; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct FileNode { @@ -245,22 +246,39 @@ pub async fn get_file_info(path: String) -> Result { }) } -/// 监听文件系统变化(简化版本) +/// 监听文件系统变化 #[tauri::command] pub async fn watch_directory( - app: tauri::AppHandle, + watcher_state: State<'_, FileWatcherState>, + path: String, + recursive: Option, +) -> Result<(), String> { + let recursive = recursive.unwrap_or(false); + + watcher_state.with_manager(|manager| { + manager.watch_path(&path, recursive) + }) +} + +/// 停止监听指定路径 +#[tauri::command] +pub async fn unwatch_directory( + watcher_state: State<'_, FileWatcherState>, path: String, ) -> Result<(), String> { - // 这里可以集成 notify crate 来实现文件系统监听 - // 为了简化,先返回成功 - - // 发送测试事件 - app.emit("file-system-change", FileSystemChange { - path: path.clone(), - change_type: String::from("watching"), - }).map_err(|e| e.to_string())?; - - Ok(()) + watcher_state.with_manager(|manager| { + manager.unwatch_path(&path) + }) +} + +/// 获取当前监听的路径列表 +#[tauri::command] +pub async fn get_watched_paths( + watcher_state: State<'_, FileWatcherState>, +) -> Result, String> { + watcher_state.with_manager(|manager| { + Ok(manager.get_watched_paths()) + }) } /// 获取文件树(简化版,供文件浏览器使用) diff --git a/src-tauri/src/file_watcher.rs b/src-tauri/src/file_watcher.rs new file mode 100644 index 0000000..641d5f0 --- /dev/null +++ b/src-tauri/src/file_watcher.rs @@ -0,0 +1,195 @@ +use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime}; +use tauri::{AppHandle, Emitter}; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct FileChangeEvent { + pub path: String, + pub change_type: String, + pub timestamp: u64, +} + +pub struct FileWatcherManager { + watchers: Arc>>, + app_handle: AppHandle, + // 用于去重,避免短时间内重复事件 + last_events: Arc>>, +} + +impl FileWatcherManager { + pub fn new(app_handle: AppHandle) -> Self { + Self { + watchers: Arc::new(Mutex::new(HashMap::new())), + app_handle, + last_events: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// 监听指定路径(文件或目录) + pub fn watch_path(&self, path: &str, recursive: bool) -> Result<(), String> { + let path_buf = PathBuf::from(path); + + // 检查路径是否存在 + if !path_buf.exists() { + return Err(format!("Path does not exist: {}", path)); + } + + // 检查是否已经在监听 + { + let watchers = self.watchers.lock().unwrap(); + if watchers.contains_key(path) { + log::debug!("Already watching path: {}", path); + return Ok(()); + } + } + + let app_handle = self.app_handle.clone(); + let last_events = self.last_events.clone(); + let watch_path = path.to_string(); + + // 创建文件监听器 + let mut watcher = RecommendedWatcher::new( + move |res: Result| { + match res { + Ok(event) => { + Self::handle_event(event, &app_handle, &last_events); + } + Err(e) => { + log::error!("Watch error: {:?}", e); + } + } + }, + Config::default() + .with_poll_interval(Duration::from_secs(1)) + .with_compare_contents(false), + ).map_err(|e| format!("Failed to create watcher: {}", e))?; + + // 开始监听 + let mode = if recursive { + RecursiveMode::Recursive + } else { + RecursiveMode::NonRecursive + }; + + watcher + .watch(&path_buf, mode) + .map_err(|e| format!("Failed to watch path: {}", e))?; + + // 存储监听器 + let mut watchers = self.watchers.lock().unwrap(); + watchers.insert(watch_path, watcher); + + log::info!("Started watching path: {} (recursive: {})", path, recursive); + Ok(()) + } + + /// 停止监听指定路径 + pub fn unwatch_path(&self, path: &str) -> Result<(), String> { + let mut watchers = self.watchers.lock().unwrap(); + + if watchers.remove(path).is_some() { + log::info!("Stopped watching path: {}", path); + Ok(()) + } else { + Err(format!("Path not being watched: {}", path)) + } + } + + /// 停止所有监听 + #[allow(dead_code)] + pub fn unwatch_all(&self) { + let mut watchers = self.watchers.lock().unwrap(); + let count = watchers.len(); + watchers.clear(); + log::info!("Stopped watching {} paths", count); + } + + /// 处理文件系统事件 + fn handle_event(event: Event, app_handle: &AppHandle, last_events: &Arc>>) { + // 过滤不需要的事件 + let change_type = match event.kind { + EventKind::Create(_) => "created", + EventKind::Modify(_) => "modified", + EventKind::Remove(_) => "deleted", + _ => return, // 忽略其他事件(包括 Access 等) + }; + + // 处理每个受影响的路径 + for path in event.paths { + // 去重:检查是否在短时间内已经发送过相同路径的事件 + let now = SystemTime::now(); + let should_emit = { + let mut last_events = last_events.lock().unwrap(); + + if let Some(last_time) = last_events.get(&path) { + // 如果距离上次事件不到500ms,忽略 + if now.duration_since(*last_time).unwrap_or(Duration::ZERO) < Duration::from_millis(500) { + false + } else { + last_events.insert(path.clone(), now); + true + } + } else { + last_events.insert(path.clone(), now); + true + } + }; + + if should_emit { + let change_event = FileChangeEvent { + path: path.to_string_lossy().to_string(), + change_type: change_type.to_string(), + timestamp: now + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(), + }; + + // 发送事件到前端 + if let Err(e) = app_handle.emit("file-system-change", &change_event) { + log::error!("Failed to emit file change event: {}", e); + } else { + log::debug!( + "Emitted file change event: {} ({})", + change_event.path, + change_event.change_type + ); + } + } + } + } + + /// 获取当前监听的路径列表 + pub fn get_watched_paths(&self) -> Vec { + let watchers = self.watchers.lock().unwrap(); + watchers.keys().cloned().collect() + } +} + +// 全局文件监听管理器状态 +pub struct FileWatcherState(pub Arc>>); + +impl FileWatcherState { + pub fn new() -> Self { + Self(Arc::new(Mutex::new(None))) + } + + pub fn init(&self, app_handle: AppHandle) { + let mut state = self.0.lock().unwrap(); + *state = Some(FileWatcherManager::new(app_handle)); + } + + pub fn with_manager(&self, f: F) -> Result + where + F: FnOnce(&FileWatcherManager) -> Result, + { + let state = self.0.lock().unwrap(); + match state.as_ref() { + Some(manager) => f(manager), + None => Err("File watcher manager not initialized".to_string()), + } + } +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5507207..102bae5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ pub mod claude_config; pub mod commands; pub mod process; pub mod i18n; +pub mod file_watcher; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 21182ed..5a342b1 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -7,6 +7,7 @@ mod commands; mod process; mod i18n; mod claude_config; +mod file_watcher; use checkpoint::state::CheckpointState; use commands::agents::{ @@ -68,12 +69,13 @@ use commands::packycode_nodes::{ }; use commands::filesystem::{ read_directory_tree, search_files_by_name, get_file_info, watch_directory, - read_file, write_file, get_file_tree, + read_file, write_file, get_file_tree, unwatch_directory, get_watched_paths, }; use commands::git::{ get_git_status, get_git_history, get_git_branches, get_git_diff, get_git_commits, }; use process::ProcessRegistryState; +use file_watcher::FileWatcherState; use std::sync::Mutex; use tauri::Manager; @@ -162,6 +164,11 @@ fn main() { // Initialize process registry app.manage(ProcessRegistryState::default()); + + // Initialize file watcher state + let file_watcher_state = FileWatcherState::new(); + file_watcher_state.init(app.handle().clone()); + app.manage(file_watcher_state); // Initialize Claude process state app.manage(ClaudeProcessState::default()); @@ -332,6 +339,8 @@ fn main() { search_files_by_name, get_file_info, watch_directory, + unwatch_directory, + get_watched_paths, read_file, write_file, get_file_tree, diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index e98afb4..7738ffe 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -12,7 +12,9 @@ import { ChevronUp, X, Hash, - Command + Command, + PanelLeftOpen, + PanelRightOpen } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -38,7 +40,8 @@ import { GitPanelEnhanced } from "./GitPanelEnhanced"; import { FileEditorEnhanced } from "./FileEditorEnhanced"; import type { ClaudeStreamMessage } from "./AgentExecution"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { useTrackEvent, useComponentMetrics, useWorkflowTracking } from "@/hooks"; +import { useTrackEvent, useComponentMetrics, useWorkflowTracking, useLayoutManager } from "@/hooks"; +import { GridLayoutContainer, ResponsivePanel } from "@/components/ui/grid-layout"; interface ClaudeCodeSessionProps { /** @@ -82,6 +85,19 @@ export const ClaudeCodeSession: React.FC = ({ onStreamingChange, }) => { const { t } = useTranslation(); + const layoutManager = useLayoutManager(initialProjectPath || session?.project_path); + const { + layout, + breakpoints, + toggleFileExplorer, + toggleGitPanel, + toggleTimeline, + setPanelWidth, + setSplitPosition: setLayoutSplitPosition, + getGridTemplateColumns, + getResponsiveClasses + } = layoutManager; + const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || ""); const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -92,7 +108,6 @@ export const ClaudeCodeSession: React.FC = ({ const [totalTokens, setTotalTokens] = useState(0); const [extractedSessionInfo, setExtractedSessionInfo] = useState<{ sessionId: string; projectId: string } | null>(null); const [claudeSessionId, setClaudeSessionId] = useState(null); - const [showTimeline, setShowTimeline] = useState(false); const [timelineVersion, setTimelineVersion] = useState(0); const [showSettings, setShowSettings] = useState(false); const [showForkDialog, setShowForkDialog] = useState(false); @@ -107,16 +122,11 @@ export const ClaudeCodeSession: React.FC = ({ const [showPreview, setShowPreview] = useState(false); const [previewUrl, setPreviewUrl] = useState(""); const [showPreviewPrompt, setShowPreviewPrompt] = useState(false); - const [splitPosition, setSplitPosition] = useState(50); const [isPreviewMaximized, setIsPreviewMaximized] = useState(false); // Add collapsed state for queued prompts const [queuedPromptsCollapsed, setQueuedPromptsCollapsed] = useState(false); - // New state for file explorer and git panel - const [showFileExplorer, setShowFileExplorer] = useState(false); - const [showGitPanel, setShowGitPanel] = useState(false); - // File editor state const [editingFile, setEditingFile] = useState(null); @@ -1080,7 +1090,7 @@ export const ClaudeCodeSession: React.FC = ({ setIsPreviewMaximized(!isPreviewMaximized); // Reset split position when toggling maximize if (isPreviewMaximized) { - setSplitPosition(50); + setLayoutSplitPosition(50); } }; @@ -1260,7 +1270,7 @@ export const ClaudeCodeSession: React.FC = ({ } return ( -
+
{/* Header */} = ({ @@ -1319,10 +1329,10 @@ export const ClaudeCodeSession: React.FC = ({ @@ -1397,10 +1407,10 @@ export const ClaudeCodeSession: React.FC = ({ @@ -1450,36 +1460,45 @@ export const ClaudeCodeSession: React.FC = ({
- {/* Main Content Area with panels */} -
+ {/* Main Content Area with Grid Layout */} + {/* File Explorer Panel */} - { - // Add file path to prompt input (double click) - floatingPromptRef.current?.addImage(path); - }} - onFileOpen={(path) => { - // Open file in editor (single click) - setEditingFile(path); - }} - onToggle={() => setShowFileExplorer(!showFileExplorer)} - /> - - {/* Main Content with Input */} -
setPanelWidth('fileExplorer', width)} + minWidth={200} + maxWidth={500} + > + { + floatingPromptRef.current?.addImage(path); + }} + onFileOpen={(path) => { + setEditingFile(path); + }} + onToggle={toggleFileExplorer} + /> + )} - style={{ - marginLeft: showFileExplorer ? '15vw' : 'auto', - marginRight: showGitPanel ? '15vw' : 'auto', - width: (!showFileExplorer && !showGitPanel) ? '90%' : 'calc(100% - ' + ((showFileExplorer ? 15 : 0) + (showGitPanel ? 15 : 0)) + 'vw)', - maxWidth: (!showFileExplorer && !showGitPanel) ? '100%' : 'none' - }}> + + {/* Main Content */} +
{showPreview ? ( // Split pane layout when preview is active
@@ -1512,8 +1531,10 @@ export const ClaudeCodeSession: React.FC = ({ onUrlChange={handlePreviewUrlChange} /> } - initialSplit={splitPosition} - onSplitChange={setSplitPosition} + initialSplit={layout.splitPosition} + onSplitChange={(position) => { + setLayoutSplitPosition(position); + }} minLeftWidth={400} minRightWidth={400} className="h-full" @@ -1705,38 +1726,52 @@ export const ClaudeCodeSession: React.FC = ({
{/* Git Panel */} - setShowGitPanel(!showGitPanel)} - /> -
- - {/* Timeline */} - - {showTimeline && effectiveSession && ( - setPanelWidth('gitPanel', width)} + minWidth={200} + maxWidth={500} + > + + + )} + + {/* Timeline Panel - Only on desktop */} + {layout.showTimeline && effectiveSession && !breakpoints.isMobile && ( + setPanelWidth('timeline', width)} + minWidth={320} + maxWidth={600} + className="border-l" >
- {/* Timeline Header */}

{t('app.sessionTimeline')}

- - {/* Timeline Content */}
= ({ />
-
+ )} -
+
{/* Fork Dialog */} diff --git a/src/components/FileEditorEnhanced.tsx b/src/components/FileEditorEnhanced.tsx index a228898..d506f5d 100644 --- a/src/components/FileEditorEnhanced.tsx +++ b/src/components/FileEditorEnhanced.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useCallback, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { X, Save, @@ -156,11 +157,15 @@ export const FileEditorEnhanced: React.FC = ({ const [minimap, setMinimap] = useState(true); const [wordWrap, setWordWrap] = useState<'on' | 'off'>('on'); const [autoSave, setAutoSave] = useState(false); + const [lastCheckTime, setLastCheckTime] = useState(Date.now()); + const [fileChanged, setFileChanged] = useState(false); const [cursorPosition, setCursorPosition] = useState({ line: 1, column: 1 }); const editorRef = useRef(null); const monacoRef = useRef(null); const autoSaveTimerRef = useRef(null); + const fileCheckIntervalRef = useRef(null); + const unlistenRef = useRef(null); const fileName = filePath.split("/").pop() || filePath; const language = getLanguageFromPath(filePath); @@ -180,13 +185,15 @@ export const FileEditorEnhanced: React.FC = ({ setContent(fileContent); setOriginalContent(fileContent); setHasChanges(false); + setFileChanged(false); + setLastCheckTime(Date.now()); } catch (err) { console.error("Failed to load file:", err); setError(err instanceof Error ? err.message : "Failed to load file"); } finally { setLoading(false); } - }, [filePath]); + }, [filePath, hasChanges]); // 保存文件 const saveFile = useCallback(async () => { @@ -204,6 +211,8 @@ export const FileEditorEnhanced: React.FC = ({ setOriginalContent(content); setHasChanges(false); setSaved(true); + setLastCheckTime(Date.now()); + setFileChanged(false); // 显示保存成功提示 setTimeout(() => setSaved(false), 2000); @@ -368,12 +377,128 @@ export const FileEditorEnhanced: React.FC = ({ return () => window.removeEventListener("keydown", handleKeyDown); }, [hasChanges, saveFile, isFullscreen]); + // 使用真正的文件系统监听 + useEffect(() => { + const setupFileWatcher = async () => { + if (!filePath) return; + + try { + // 监听文件所在目录 + const dirPath = filePath.substring(0, filePath.lastIndexOf('/')); + await invoke('watch_directory', { + path: dirPath, + recursive: false + }); + + // 监听文件变化事件 + unlistenRef.current = await listen('file-system-change', (event: any) => { + const { path, change_type } = event.payload; + + // 检查是否是当前文件的变化 + if (path === filePath && (change_type === 'modified' || change_type === 'created')) { + // 检查时间间隔,避免自己保存触发的事件 + const timeSinceLastSave = Date.now() - lastCheckTime; + + if (timeSinceLastSave > 1000) { // 超过1秒,可能是外部修改 + console.log('File changed externally:', path, change_type); + setFileChanged(true); + + // 如果没有未保存的更改,自动重新加载 + if (!hasChanges) { + loadFile(); + } else { + // 显示提示 + setError("文件已被外部程序修改,点击重新加载按钮查看最新内容"); + } + } + } + }); + } catch (err) { + console.error('Failed to setup file watcher:', err); + // 如果文件监听失败,回退到轮询模式 + fallbackToPolling(); + } + }; + + // 回退到轮询模式 + const fallbackToPolling = () => { + const checkFileChanges = async () => { + if (!filePath || !editorRef.current) return; + + try { + const fileInfo = await invoke('get_file_info', { path: filePath }); + + if (fileInfo && fileInfo.modified) { + const fileModifiedTime = new Date(fileInfo.modified).getTime(); + + if (fileModifiedTime > lastCheckTime && !hasChanges) { + const newContent = await invoke('read_file', { path: filePath }); + + if (newContent !== originalContent) { + setFileChanged(true); + if (!hasChanges) { + setContent(newContent); + setOriginalContent(newContent); + setFileChanged(false); + setLastCheckTime(Date.now()); + } + } + } + } + } catch (err) { + console.debug('File check error:', err); + } + }; + + // 每3秒检查一次文件变化 + fileCheckIntervalRef.current = setInterval(checkFileChanges, 3000); + }; + + setupFileWatcher(); + + // 清理函数 + return () => { + // 停止监听 + if (filePath) { + const dirPath = filePath.substring(0, filePath.lastIndexOf('/')); + invoke('unwatch_directory', { path: dirPath }).catch(console.error); + } + + // 清理事件监听 + if (unlistenRef.current) { + unlistenRef.current(); + unlistenRef.current = null; + } + + // 清理轮询定时器 + if (fileCheckIntervalRef.current) { + clearInterval(fileCheckIntervalRef.current); + } + }; + }, [filePath, hasChanges, lastCheckTime, originalContent, loadFile]); + + // 移除旧的轮询实现 + + // 重新加载文件 + const reloadFile = useCallback(async () => { + if (!filePath) return; + + if (hasChanges) { + const shouldReload = window.confirm( + "您有未保存的更改。重新加载将丢失这些更改。是否继续?" + ); + if (!shouldReload) return; + } + + await loadFile(); + }, [filePath, hasChanges, loadFile]); + // 加载文件 useEffect(() => { if (filePath) { loadFile(); } - }, [filePath, loadFile]); + }, [filePath]); // 移除 loadFile 依赖,避免循环 // 计算诊断统计 const diagnosticStats = { @@ -587,6 +712,28 @@ export const FileEditorEnhanced: React.FC = ({ + {/* 文件外部修改提示 */} + {fileChanged && ( + + + + + + +

文件已被外部程序修改,点击重新加载最新内容

+
+
+
+ )} + {/* 保存按钮 */} {hasChanges && ( + )} +
+ {children} +
+ + ); + } + + // Desktop: Integrated panel with optional resize + return ( +
+ {resizable && ( +
+ )} +
+ {children} +
+
+ ); +}; \ No newline at end of file diff --git a/src/hooks/index.ts b/src/hooks/index.ts index ac7189a..142079a 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -25,3 +25,4 @@ export { } from './usePerformanceMonitor'; export { TAB_SCREEN_NAMES } from './useAnalytics'; export { useTranslation, getLanguageDisplayName } from './useTranslation'; +export { useLayoutManager } from './useLayoutManager'; diff --git a/src/hooks/useLayoutManager.ts b/src/hooks/useLayoutManager.ts new file mode 100644 index 0000000..698b80e --- /dev/null +++ b/src/hooks/useLayoutManager.ts @@ -0,0 +1,268 @@ +import { useState, useEffect, useCallback } from 'react'; + +interface LayoutState { + fileExplorerWidth: number; + gitPanelWidth: number; + timelineWidth: number; + showFileExplorer: boolean; + showGitPanel: boolean; + showTimeline: boolean; + splitPosition: number; + isCompactMode: boolean; +} + +interface LayoutBreakpoints { + isMobile: boolean; + isTablet: boolean; + isDesktop: boolean; + isWidescreen: boolean; + screenWidth: number; + screenHeight: number; +} + +const DEFAULT_LAYOUT: LayoutState = { + fileExplorerWidth: 280, + gitPanelWidth: 320, + timelineWidth: 384, + showFileExplorer: false, + showGitPanel: false, + showTimeline: false, + splitPosition: 50, + isCompactMode: false, +}; + +const STORAGE_KEY = 'claudia_layout_preferences'; + +/** + * Custom hook for managing responsive layout with persistent state + */ +export function useLayoutManager(projectPath?: string) { + const [layout, setLayout] = useState(DEFAULT_LAYOUT); + const [breakpoints, setBreakpoints] = useState({ + isMobile: false, + isTablet: false, + isDesktop: true, + isWidescreen: false, + screenWidth: window.innerWidth, + screenHeight: window.innerHeight, + }); + + // Load saved layout preferences + useEffect(() => { + const loadLayout = async () => { + try { + // Try to load project-specific layout first + const key = projectPath ? `${STORAGE_KEY}_${projectPath.replace(/[^a-zA-Z0-9]/g, '_')}` : STORAGE_KEY; + const saved = localStorage.getItem(key); + + if (saved) { + const savedLayout = JSON.parse(saved) as Partial; + setLayout(prev => ({ ...prev, ...savedLayout })); + } + } catch (error) { + console.error('Failed to load layout preferences:', error); + } + }; + + loadLayout(); + }, [projectPath]); + + // Save layout changes + const saveLayout = useCallback((newLayout: Partial) => { + const updated = { ...layout, ...newLayout }; + setLayout(updated); + + // Save to localStorage + try { + const key = projectPath ? `${STORAGE_KEY}_${projectPath.replace(/[^a-zA-Z0-9]/g, '_')}` : STORAGE_KEY; + localStorage.setItem(key, JSON.stringify(updated)); + } catch (error) { + console.error('Failed to save layout preferences:', error); + } + }, [layout, projectPath]); + + // Update breakpoints on resize + useEffect(() => { + const updateBreakpoints = () => { + const width = window.innerWidth; + const height = window.innerHeight; + + setBreakpoints({ + isMobile: width < 640, + isTablet: width >= 640 && width < 1024, + isDesktop: width >= 1024 && width < 1536, + isWidescreen: width >= 1536, + screenWidth: width, + screenHeight: height, + }); + + // Auto-adjust layout for mobile + if (width < 640) { + saveLayout({ + isCompactMode: true, + showFileExplorer: false, + showGitPanel: false, + showTimeline: false, + }); + } + }; + + updateBreakpoints(); + window.addEventListener('resize', updateBreakpoints); + return () => window.removeEventListener('resize', updateBreakpoints); + }, [saveLayout]); + + // Panel toggle functions + const toggleFileExplorer = useCallback(() => { + const newState = !layout.showFileExplorer; + + // On mobile, close other panels when opening one + if (breakpoints.isMobile && newState) { + saveLayout({ + showFileExplorer: true, + showGitPanel: false, + showTimeline: false, + }); + } else { + saveLayout({ showFileExplorer: newState }); + } + }, [layout.showFileExplorer, breakpoints.isMobile, saveLayout]); + + const toggleGitPanel = useCallback(() => { + const newState = !layout.showGitPanel; + + // On mobile, close other panels when opening one + if (breakpoints.isMobile && newState) { + saveLayout({ + showFileExplorer: false, + showGitPanel: true, + showTimeline: false, + }); + } else { + saveLayout({ showGitPanel: newState }); + } + }, [layout.showGitPanel, breakpoints.isMobile, saveLayout]); + + const toggleTimeline = useCallback(() => { + const newState = !layout.showTimeline; + + // On mobile, close other panels when opening one + if (breakpoints.isMobile && newState) { + saveLayout({ + showFileExplorer: false, + showGitPanel: false, + showTimeline: true, + }); + } else { + saveLayout({ showTimeline: newState }); + } + }, [layout.showTimeline, breakpoints.isMobile, saveLayout]); + + // Update panel width + const setPanelWidth = useCallback((panel: 'fileExplorer' | 'gitPanel' | 'timeline', width: number) => { + const key = `${panel}Width` as keyof LayoutState; + saveLayout({ [key]: width }); + }, [saveLayout]); + + // Set split position + const setSplitPosition = useCallback((position: number) => { + saveLayout({ splitPosition: position }); + }, [saveLayout]); + + // Toggle compact mode + const toggleCompactMode = useCallback(() => { + saveLayout({ isCompactMode: !layout.isCompactMode }); + }, [layout.isCompactMode, saveLayout]); + + // Reset layout to defaults + const resetLayout = useCallback(() => { + setLayout(DEFAULT_LAYOUT); + try { + const key = projectPath ? `${STORAGE_KEY}_${projectPath.replace(/[^a-zA-Z0-9]/g, '_')}` : STORAGE_KEY; + localStorage.removeItem(key); + } catch (error) { + console.error('Failed to reset layout:', error); + } + }, [projectPath]); + + // Calculate available content width + const getContentWidth = useCallback(() => { + let width = breakpoints.screenWidth; + + if (layout.showFileExplorer && !breakpoints.isMobile) { + width -= layout.fileExplorerWidth; + } + if (layout.showGitPanel && !breakpoints.isMobile) { + width -= layout.gitPanelWidth; + } + if (layout.showTimeline && !breakpoints.isMobile) { + width -= layout.timelineWidth; + } + + return width; + }, [breakpoints, layout]); + + // Get grid template columns for CSS Grid layout + const getGridTemplateColumns = useCallback(() => { + const parts: string[] = []; + + // Mobile: stack everything + if (breakpoints.isMobile) { + return '1fr'; + } + + // Desktop: dynamic grid + if (layout.showFileExplorer) { + parts.push(`${layout.fileExplorerWidth}px`); + } + + parts.push('1fr'); // Main content + + if (layout.showGitPanel) { + parts.push(`${layout.gitPanelWidth}px`); + } + + if (layout.showTimeline) { + parts.push(`${layout.timelineWidth}px`); + } + + return parts.join(' '); + }, [breakpoints.isMobile, layout]); + + // Get responsive class names + const getResponsiveClasses = useCallback(() => { + const classes: string[] = []; + + if (breakpoints.isMobile) { + classes.push('mobile-layout'); + } else if (breakpoints.isTablet) { + classes.push('tablet-layout'); + } else if (breakpoints.isDesktop) { + classes.push('desktop-layout'); + } else if (breakpoints.isWidescreen) { + classes.push('widescreen-layout'); + } + + if (layout.isCompactMode) { + classes.push('compact-mode'); + } + + return classes.join(' '); + }, [breakpoints, layout.isCompactMode]); + + return { + layout, + breakpoints, + toggleFileExplorer, + toggleGitPanel, + toggleTimeline, + setPanelWidth, + setSplitPosition, + toggleCompactMode, + resetLayout, + getContentWidth, + getGridTemplateColumns, + getResponsiveClasses, + saveLayout, + }; +} \ No newline at end of file diff --git a/src/styles.css b/src/styles.css index b4dab2e..fbbaca4 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "./styles/grid-layout.css"; /* Custom scrollbar hiding */ .scrollbar-hide { diff --git a/src/styles/grid-layout.css b/src/styles/grid-layout.css new file mode 100644 index 0000000..e176c76 --- /dev/null +++ b/src/styles/grid-layout.css @@ -0,0 +1,181 @@ +/* Grid Layout Styles for ClaudeCodeSession */ + +/* Base layout classes */ +.mobile-layout { + @apply relative; +} + +.tablet-layout { + @apply relative; +} + +.desktop-layout { + @apply relative; +} + +.widescreen-layout { + @apply relative; +} + +/* Compact mode adjustments */ +.compact-mode { + @apply text-sm; +} + +.compact-mode .floating-prompt-input { + @apply h-10; +} + +.compact-mode .message-container { + @apply py-2; +} + +/* Panel transitions */ +.panel-transition { + transition: width 300ms cubic-bezier(0.4, 0, 0.2, 1), + transform 300ms cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Mobile panel overlays */ +@media (max-width: 639px) { + .mobile-panel-overlay { + @apply fixed inset-0 bg-black/50 z-40; + } + + .mobile-panel { + @apply fixed inset-y-0 z-50 bg-background shadow-2xl; + max-width: 85vw; + } + + .mobile-panel-left { + @apply left-0; + } + + .mobile-panel-right { + @apply right-0; + } +} + +/* Tablet adjustments */ +@media (min-width: 640px) and (max-width: 1023px) { + .tablet-sidebar { + width: min(320px, 40vw); + } +} + +/* Desktop grid layout */ +@media (min-width: 1024px) { + .desktop-grid { + display: grid; + grid-template-rows: auto 1fr; + height: 100%; + } + + .desktop-content-grid { + display: grid; + gap: 0; + height: 100%; + transition: grid-template-columns 300ms cubic-bezier(0.4, 0, 0.2, 1); + } +} + +/* Widescreen optimizations */ +@media (min-width: 1536px) { + .widescreen-content { + max-width: 1400px; + margin: 0 auto; + } +} + +/* Resize handle styles */ +.resize-handle { + @apply absolute top-0 bottom-0 w-1 cursor-col-resize; + @apply hover:bg-primary/20 transition-colors; +} + +.resize-handle:active { + @apply bg-primary/30; +} + +.resize-handle-left { + @apply right-0; +} + +.resize-handle-right { + @apply left-0; +} + +/* Panel content scrolling */ +.panel-content { + @apply h-full overflow-y-auto; + scrollbar-width: thin; +} + +.panel-content::-webkit-scrollbar { + width: 6px; +} + +.panel-content::-webkit-scrollbar-track { + @apply bg-transparent; +} + +.panel-content::-webkit-scrollbar-thumb { + @apply bg-muted-foreground/20 rounded-full; +} + +.panel-content::-webkit-scrollbar-thumb:hover { + @apply bg-muted-foreground/30; +} + +/* Animation classes */ +@keyframes slideInLeft { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} + +@keyframes slideInRight { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } +} + +@keyframes slideOutLeft { + from { + transform: translateX(0); + } + to { + transform: translateX(-100%); + } +} + +@keyframes slideOutRight { + from { + transform: translateX(0); + } + to { + transform: translateX(100%); + } +} + +.animate-slide-in-left { + animation: slideInLeft 300ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.animate-slide-in-right { + animation: slideInRight 300ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.animate-slide-out-left { + animation: slideOutLeft 300ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.animate-slide-out-right { + animation: slideOutRight 300ms cubic-bezier(0.4, 0, 0.2, 1); +} \ No newline at end of file