优化页面布局
This commit is contained in:
82
src-tauri/Cargo.lock
generated
82
src-tauri/Cargo.lock
generated
@@ -631,6 +631,7 @@ dependencies = [
|
|||||||
"glob",
|
"glob",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
|
"notify",
|
||||||
"objc",
|
"objc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"regex",
|
"regex",
|
||||||
@@ -1439,6 +1440,15 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"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]]
|
[[package]]
|
||||||
name = "futf"
|
name = "futf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -2292,6 +2302,26 @@ dependencies = [
|
|||||||
"cfb",
|
"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]]
|
[[package]]
|
||||||
name = "intl-memoizer"
|
name = "intl-memoizer"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -2492,6 +2522,26 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"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]]
|
[[package]]
|
||||||
name = "kuchikiki"
|
name = "kuchikiki"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
@@ -2709,6 +2759,18 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"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]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -2823,6 +2885,24 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"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]]
|
[[package]]
|
||||||
name = "notify-rust"
|
name = "notify-rust"
|
||||||
version = "4.11.7"
|
version = "4.11.7"
|
||||||
@@ -5283,7 +5363,7 @@ dependencies = [
|
|||||||
"backtrace",
|
"backtrace",
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio 1.0.4",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
|
@@ -52,6 +52,7 @@ sha2 = "0.10"
|
|||||||
zstd = "0.13"
|
zstd = "0.13"
|
||||||
uuid = { version = "1.6", features = ["v4", "serde"] }
|
uuid = { version = "1.6", features = ["v4", "serde"] }
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
|
notify = { version = "6.1", default-features = false, features = ["macos_fsevent"] }
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
fluent = "0.16"
|
fluent = "0.16"
|
||||||
fluent-bundle = "0.15"
|
fluent-bundle = "0.15"
|
||||||
|
@@ -1,7 +1,8 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tauri::Emitter;
|
use tauri::State;
|
||||||
|
use crate::file_watcher::FileWatcherState;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct FileNode {
|
pub struct FileNode {
|
||||||
@@ -245,22 +246,39 @@ pub async fn get_file_info(path: String) -> Result<FileNode, String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 监听文件系统变化(简化版本)
|
/// 监听文件系统变化
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn watch_directory(
|
pub async fn watch_directory(
|
||||||
app: tauri::AppHandle,
|
watcher_state: State<'_, FileWatcherState>,
|
||||||
|
path: String,
|
||||||
|
recursive: Option<bool>,
|
||||||
|
) -> 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,
|
path: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// 这里可以集成 notify crate 来实现文件系统监听
|
watcher_state.with_manager(|manager| {
|
||||||
// 为了简化,先返回成功
|
manager.unwatch_path(&path)
|
||||||
|
})
|
||||||
// 发送测试事件
|
}
|
||||||
app.emit("file-system-change", FileSystemChange {
|
|
||||||
path: path.clone(),
|
/// 获取当前监听的路径列表
|
||||||
change_type: String::from("watching"),
|
#[tauri::command]
|
||||||
}).map_err(|e| e.to_string())?;
|
pub async fn get_watched_paths(
|
||||||
|
watcher_state: State<'_, FileWatcherState>,
|
||||||
Ok(())
|
) -> Result<Vec<String>, String> {
|
||||||
|
watcher_state.with_manager(|manager| {
|
||||||
|
Ok(manager.get_watched_paths())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取文件树(简化版,供文件浏览器使用)
|
/// 获取文件树(简化版,供文件浏览器使用)
|
||||||
|
195
src-tauri/src/file_watcher.rs
Normal file
195
src-tauri/src/file_watcher.rs
Normal file
@@ -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<Mutex<HashMap<String, RecommendedWatcher>>>,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
// 用于去重,避免短时间内重复事件
|
||||||
|
last_events: Arc<Mutex<HashMap<PathBuf, SystemTime>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Event, notify::Error>| {
|
||||||
|
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<Mutex<HashMap<PathBuf, SystemTime>>>) {
|
||||||
|
// 过滤不需要的事件
|
||||||
|
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<String> {
|
||||||
|
let watchers = self.watchers.lock().unwrap();
|
||||||
|
watchers.keys().cloned().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局文件监听管理器状态
|
||||||
|
pub struct FileWatcherState(pub Arc<Mutex<Option<FileWatcherManager>>>);
|
||||||
|
|
||||||
|
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<F, R>(&self, f: F) -> Result<R, String>
|
||||||
|
where
|
||||||
|
F: FnOnce(&FileWatcherManager) -> Result<R, String>,
|
||||||
|
{
|
||||||
|
let state = self.0.lock().unwrap();
|
||||||
|
match state.as_ref() {
|
||||||
|
Some(manager) => f(manager),
|
||||||
|
None => Err("File watcher manager not initialized".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -7,6 +7,7 @@ pub mod claude_config;
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
|
pub mod file_watcher;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
@@ -7,6 +7,7 @@ mod commands;
|
|||||||
mod process;
|
mod process;
|
||||||
mod i18n;
|
mod i18n;
|
||||||
mod claude_config;
|
mod claude_config;
|
||||||
|
mod file_watcher;
|
||||||
|
|
||||||
use checkpoint::state::CheckpointState;
|
use checkpoint::state::CheckpointState;
|
||||||
use commands::agents::{
|
use commands::agents::{
|
||||||
@@ -68,12 +69,13 @@ use commands::packycode_nodes::{
|
|||||||
};
|
};
|
||||||
use commands::filesystem::{
|
use commands::filesystem::{
|
||||||
read_directory_tree, search_files_by_name, get_file_info, watch_directory,
|
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::{
|
use commands::git::{
|
||||||
get_git_status, get_git_history, get_git_branches, get_git_diff, get_git_commits,
|
get_git_status, get_git_history, get_git_branches, get_git_diff, get_git_commits,
|
||||||
};
|
};
|
||||||
use process::ProcessRegistryState;
|
use process::ProcessRegistryState;
|
||||||
|
use file_watcher::FileWatcherState;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
@@ -162,6 +164,11 @@ fn main() {
|
|||||||
|
|
||||||
// Initialize process registry
|
// Initialize process registry
|
||||||
app.manage(ProcessRegistryState::default());
|
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
|
// Initialize Claude process state
|
||||||
app.manage(ClaudeProcessState::default());
|
app.manage(ClaudeProcessState::default());
|
||||||
@@ -332,6 +339,8 @@ fn main() {
|
|||||||
search_files_by_name,
|
search_files_by_name,
|
||||||
get_file_info,
|
get_file_info,
|
||||||
watch_directory,
|
watch_directory,
|
||||||
|
unwatch_directory,
|
||||||
|
get_watched_paths,
|
||||||
read_file,
|
read_file,
|
||||||
write_file,
|
write_file,
|
||||||
get_file_tree,
|
get_file_tree,
|
||||||
|
@@ -12,7 +12,9 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
X,
|
X,
|
||||||
Hash,
|
Hash,
|
||||||
Command
|
Command,
|
||||||
|
PanelLeftOpen,
|
||||||
|
PanelRightOpen
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -38,7 +40,8 @@ import { GitPanelEnhanced } from "./GitPanelEnhanced";
|
|||||||
import { FileEditorEnhanced } from "./FileEditorEnhanced";
|
import { FileEditorEnhanced } from "./FileEditorEnhanced";
|
||||||
import type { ClaudeStreamMessage } from "./AgentExecution";
|
import type { ClaudeStreamMessage } from "./AgentExecution";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
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 {
|
interface ClaudeCodeSessionProps {
|
||||||
/**
|
/**
|
||||||
@@ -82,6 +85,19 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
onStreamingChange,
|
onStreamingChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
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 [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || "");
|
||||||
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
|
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -92,7 +108,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
const [totalTokens, setTotalTokens] = useState(0);
|
const [totalTokens, setTotalTokens] = useState(0);
|
||||||
const [extractedSessionInfo, setExtractedSessionInfo] = useState<{ sessionId: string; projectId: string } | null>(null);
|
const [extractedSessionInfo, setExtractedSessionInfo] = useState<{ sessionId: string; projectId: string } | null>(null);
|
||||||
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(null);
|
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(null);
|
||||||
const [showTimeline, setShowTimeline] = useState(false);
|
|
||||||
const [timelineVersion, setTimelineVersion] = useState(0);
|
const [timelineVersion, setTimelineVersion] = useState(0);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [showForkDialog, setShowForkDialog] = useState(false);
|
const [showForkDialog, setShowForkDialog] = useState(false);
|
||||||
@@ -107,16 +122,11 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
const [previewUrl, setPreviewUrl] = useState("");
|
const [previewUrl, setPreviewUrl] = useState("");
|
||||||
const [showPreviewPrompt, setShowPreviewPrompt] = useState(false);
|
const [showPreviewPrompt, setShowPreviewPrompt] = useState(false);
|
||||||
const [splitPosition, setSplitPosition] = useState(50);
|
|
||||||
const [isPreviewMaximized, setIsPreviewMaximized] = useState(false);
|
const [isPreviewMaximized, setIsPreviewMaximized] = useState(false);
|
||||||
|
|
||||||
// Add collapsed state for queued prompts
|
// Add collapsed state for queued prompts
|
||||||
const [queuedPromptsCollapsed, setQueuedPromptsCollapsed] = useState(false);
|
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
|
// File editor state
|
||||||
const [editingFile, setEditingFile] = useState<string | null>(null);
|
const [editingFile, setEditingFile] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -1080,7 +1090,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
setIsPreviewMaximized(!isPreviewMaximized);
|
setIsPreviewMaximized(!isPreviewMaximized);
|
||||||
// Reset split position when toggling maximize
|
// Reset split position when toggling maximize
|
||||||
if (isPreviewMaximized) {
|
if (isPreviewMaximized) {
|
||||||
setSplitPosition(50);
|
setLayoutSplitPosition(50);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1260,7 +1270,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col h-full bg-background relative", className)}>
|
<div className={cn("flex flex-col h-full bg-background relative", getResponsiveClasses(), className)}>
|
||||||
<div className="w-full h-full flex flex-col">
|
<div className="w-full h-full flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -1298,10 +1308,10 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setShowFileExplorer(!showFileExplorer)}
|
onClick={toggleFileExplorer}
|
||||||
className={cn("h-8 w-8", showFileExplorer && "text-primary")}
|
className={cn("h-8 w-8", layout.showFileExplorer && "text-primary")}
|
||||||
>
|
>
|
||||||
<FolderOpen className="h-4 w-4" />
|
<PanelLeftOpen className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -1319,10 +1329,10 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setShowGitPanel(!showGitPanel)}
|
onClick={toggleGitPanel}
|
||||||
className={cn("h-8 w-8", showGitPanel && "text-primary")}
|
className={cn("h-8 w-8", layout.showGitPanel && "text-primary")}
|
||||||
>
|
>
|
||||||
<GitBranch className="h-4 w-4" />
|
<PanelRightOpen className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -1397,10 +1407,10 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setShowTimeline(!showTimeline)}
|
onClick={toggleTimeline}
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
>
|
>
|
||||||
<GitBranch className={cn("h-4 w-4", showTimeline && "text-primary")} />
|
<GitBranch className={cn("h-4 w-4", layout.showTimeline && "text-primary")} />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -1450,36 +1460,45 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Main Content Area with panels */}
|
{/* Main Content Area with Grid Layout */}
|
||||||
<div className={cn(
|
<GridLayoutContainer
|
||||||
"flex-1 overflow-hidden transition-all duration-300 flex",
|
className="flex-1 overflow-hidden"
|
||||||
showTimeline && "sm:mr-96"
|
gridTemplateColumns={getGridTemplateColumns()}
|
||||||
)}>
|
isMobile={breakpoints.isMobile}
|
||||||
|
isTablet={breakpoints.isTablet}
|
||||||
|
showFileExplorer={layout.showFileExplorer}
|
||||||
|
showGitPanel={layout.showGitPanel}
|
||||||
|
showTimeline={layout.showTimeline}
|
||||||
|
>
|
||||||
{/* File Explorer Panel */}
|
{/* File Explorer Panel */}
|
||||||
<FileExplorerPanelEnhanced
|
{layout.showFileExplorer && (
|
||||||
projectPath={projectPath}
|
<ResponsivePanel
|
||||||
isVisible={showFileExplorer}
|
isVisible={layout.showFileExplorer}
|
||||||
onFileSelect={(path) => {
|
position="left"
|
||||||
// Add file path to prompt input (double click)
|
width={layout.fileExplorerWidth}
|
||||||
floatingPromptRef.current?.addImage(path);
|
isMobile={breakpoints.isMobile}
|
||||||
}}
|
onClose={toggleFileExplorer}
|
||||||
onFileOpen={(path) => {
|
resizable={!breakpoints.isMobile}
|
||||||
// Open file in editor (single click)
|
onResize={(width) => setPanelWidth('fileExplorer', width)}
|
||||||
setEditingFile(path);
|
minWidth={200}
|
||||||
}}
|
maxWidth={500}
|
||||||
onToggle={() => setShowFileExplorer(!showFileExplorer)}
|
>
|
||||||
/>
|
<FileExplorerPanelEnhanced
|
||||||
|
projectPath={projectPath}
|
||||||
{/* Main Content with Input */}
|
isVisible={true}
|
||||||
<div className={cn(
|
onFileSelect={(path) => {
|
||||||
"flex-1 transition-all duration-300 relative flex flex-col"
|
floatingPromptRef.current?.addImage(path);
|
||||||
|
}}
|
||||||
|
onFileOpen={(path) => {
|
||||||
|
setEditingFile(path);
|
||||||
|
}}
|
||||||
|
onToggle={toggleFileExplorer}
|
||||||
|
/>
|
||||||
|
</ResponsivePanel>
|
||||||
)}
|
)}
|
||||||
style={{
|
|
||||||
marginLeft: showFileExplorer ? '15vw' : 'auto',
|
{/* Main Content */}
|
||||||
marginRight: showGitPanel ? '15vw' : 'auto',
|
<div className="flex-1 relative flex flex-col overflow-hidden">
|
||||||
width: (!showFileExplorer && !showGitPanel) ? '90%' : 'calc(100% - ' + ((showFileExplorer ? 15 : 0) + (showGitPanel ? 15 : 0)) + 'vw)',
|
|
||||||
maxWidth: (!showFileExplorer && !showGitPanel) ? '100%' : 'none'
|
|
||||||
}}>
|
|
||||||
{showPreview ? (
|
{showPreview ? (
|
||||||
// Split pane layout when preview is active
|
// Split pane layout when preview is active
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
@@ -1512,8 +1531,10 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
onUrlChange={handlePreviewUrlChange}
|
onUrlChange={handlePreviewUrlChange}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
initialSplit={splitPosition}
|
initialSplit={layout.splitPosition}
|
||||||
onSplitChange={setSplitPosition}
|
onSplitChange={(position) => {
|
||||||
|
setLayoutSplitPosition(position);
|
||||||
|
}}
|
||||||
minLeftWidth={400}
|
minLeftWidth={400}
|
||||||
minRightWidth={400}
|
minRightWidth={400}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
@@ -1705,38 +1726,52 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Git Panel */}
|
{/* Git Panel */}
|
||||||
<GitPanelEnhanced
|
{layout.showGitPanel && (
|
||||||
projectPath={projectPath}
|
<ResponsivePanel
|
||||||
isVisible={showGitPanel}
|
isVisible={layout.showGitPanel}
|
||||||
onToggle={() => setShowGitPanel(!showGitPanel)}
|
position="right"
|
||||||
/>
|
width={layout.gitPanelWidth}
|
||||||
</div>
|
isMobile={breakpoints.isMobile}
|
||||||
|
onClose={toggleGitPanel}
|
||||||
{/* Timeline */}
|
resizable={!breakpoints.isMobile}
|
||||||
<AnimatePresence>
|
onResize={(width) => setPanelWidth('gitPanel', width)}
|
||||||
{showTimeline && effectiveSession && (
|
minWidth={200}
|
||||||
<motion.div
|
maxWidth={500}
|
||||||
initial={{ x: "100%" }}
|
>
|
||||||
animate={{ x: 0 }}
|
<GitPanelEnhanced
|
||||||
exit={{ x: "100%" }}
|
projectPath={projectPath}
|
||||||
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
isVisible={true}
|
||||||
className="fixed right-0 top-0 h-full w-full sm:w-96 bg-background border-l border-border shadow-xl z-30 overflow-hidden"
|
onToggle={toggleGitPanel}
|
||||||
|
/>
|
||||||
|
</ResponsivePanel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timeline Panel - Only on desktop */}
|
||||||
|
{layout.showTimeline && effectiveSession && !breakpoints.isMobile && (
|
||||||
|
<ResponsivePanel
|
||||||
|
isVisible={layout.showTimeline}
|
||||||
|
position="right"
|
||||||
|
width={layout.timelineWidth}
|
||||||
|
isMobile={false}
|
||||||
|
onClose={toggleTimeline}
|
||||||
|
resizable={true}
|
||||||
|
onResize={(width) => setPanelWidth('timeline', width)}
|
||||||
|
minWidth={320}
|
||||||
|
maxWidth={600}
|
||||||
|
className="border-l"
|
||||||
>
|
>
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{/* Timeline Header */}
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||||
<h3 className="text-lg font-semibold">{t('app.sessionTimeline')}</h3>
|
<h3 className="text-lg font-semibold">{t('app.sessionTimeline')}</h3>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setShowTimeline(false)}
|
onClick={toggleTimeline}
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<TimelineNavigator
|
<TimelineNavigator
|
||||||
sessionId={effectiveSession.id}
|
sessionId={effectiveSession.id}
|
||||||
@@ -1750,9 +1785,9 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</ResponsivePanel>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</GridLayoutContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fork Dialog */}
|
{/* Fork Dialog */}
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
Save,
|
Save,
|
||||||
@@ -156,11 +157,15 @@ export const FileEditorEnhanced: React.FC<FileEditorEnhancedProps> = ({
|
|||||||
const [minimap, setMinimap] = useState(true);
|
const [minimap, setMinimap] = useState(true);
|
||||||
const [wordWrap, setWordWrap] = useState<'on' | 'off'>('on');
|
const [wordWrap, setWordWrap] = useState<'on' | 'off'>('on');
|
||||||
const [autoSave, setAutoSave] = useState(false);
|
const [autoSave, setAutoSave] = useState(false);
|
||||||
|
const [lastCheckTime, setLastCheckTime] = useState<number>(Date.now());
|
||||||
|
const [fileChanged, setFileChanged] = useState(false);
|
||||||
|
|
||||||
const [cursorPosition, setCursorPosition] = useState({ line: 1, column: 1 });
|
const [cursorPosition, setCursorPosition] = useState({ line: 1, column: 1 });
|
||||||
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||||
const monacoRef = useRef<Monaco | null>(null);
|
const monacoRef = useRef<Monaco | null>(null);
|
||||||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const fileCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const unlistenRef = useRef<UnlistenFn | null>(null);
|
||||||
|
|
||||||
const fileName = filePath.split("/").pop() || filePath;
|
const fileName = filePath.split("/").pop() || filePath;
|
||||||
const language = getLanguageFromPath(filePath);
|
const language = getLanguageFromPath(filePath);
|
||||||
@@ -180,13 +185,15 @@ export const FileEditorEnhanced: React.FC<FileEditorEnhancedProps> = ({
|
|||||||
setContent(fileContent);
|
setContent(fileContent);
|
||||||
setOriginalContent(fileContent);
|
setOriginalContent(fileContent);
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
|
setFileChanged(false);
|
||||||
|
setLastCheckTime(Date.now());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load file:", err);
|
console.error("Failed to load file:", err);
|
||||||
setError(err instanceof Error ? err.message : "Failed to load file");
|
setError(err instanceof Error ? err.message : "Failed to load file");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [filePath]);
|
}, [filePath, hasChanges]);
|
||||||
|
|
||||||
// 保存文件
|
// 保存文件
|
||||||
const saveFile = useCallback(async () => {
|
const saveFile = useCallback(async () => {
|
||||||
@@ -204,6 +211,8 @@ export const FileEditorEnhanced: React.FC<FileEditorEnhancedProps> = ({
|
|||||||
setOriginalContent(content);
|
setOriginalContent(content);
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
|
setLastCheckTime(Date.now());
|
||||||
|
setFileChanged(false);
|
||||||
|
|
||||||
// 显示保存成功提示
|
// 显示保存成功提示
|
||||||
setTimeout(() => setSaved(false), 2000);
|
setTimeout(() => setSaved(false), 2000);
|
||||||
@@ -368,12 +377,128 @@ export const FileEditorEnhanced: React.FC<FileEditorEnhancedProps> = ({
|
|||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [hasChanges, saveFile, isFullscreen]);
|
}, [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<any>('get_file_info', { path: filePath });
|
||||||
|
|
||||||
|
if (fileInfo && fileInfo.modified) {
|
||||||
|
const fileModifiedTime = new Date(fileInfo.modified).getTime();
|
||||||
|
|
||||||
|
if (fileModifiedTime > lastCheckTime && !hasChanges) {
|
||||||
|
const newContent = await invoke<string>('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(() => {
|
useEffect(() => {
|
||||||
if (filePath) {
|
if (filePath) {
|
||||||
loadFile();
|
loadFile();
|
||||||
}
|
}
|
||||||
}, [filePath, loadFile]);
|
}, [filePath]); // 移除 loadFile 依赖,避免循环
|
||||||
|
|
||||||
// 计算诊断统计
|
// 计算诊断统计
|
||||||
const diagnosticStats = {
|
const diagnosticStats = {
|
||||||
@@ -587,6 +712,28 @@ export const FileEditorEnhanced: React.FC<FileEditorEnhancedProps> = ({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* 文件外部修改提示 */}
|
||||||
|
{fileChanged && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={reloadFile}
|
||||||
|
className="flex items-center gap-1 border-yellow-500/50 text-yellow-500 hover:bg-yellow-500/10"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>文件已被外部程序修改,点击重新加载最新内容</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 保存按钮 */}
|
{/* 保存按钮 */}
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<Button
|
<Button
|
||||||
|
218
src/components/ui/grid-layout.tsx
Normal file
218
src/components/ui/grid-layout.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
interface GridLayoutContainerProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
gridTemplateColumns: string;
|
||||||
|
isMobile: boolean;
|
||||||
|
isTablet: boolean;
|
||||||
|
showFileExplorer: boolean;
|
||||||
|
showGitPanel: boolean;
|
||||||
|
showTimeline: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid-based layout container for responsive panel management
|
||||||
|
*/
|
||||||
|
export const GridLayoutContainer: React.FC<GridLayoutContainerProps> = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
gridTemplateColumns,
|
||||||
|
isMobile,
|
||||||
|
isTablet,
|
||||||
|
showFileExplorer,
|
||||||
|
showGitPanel,
|
||||||
|
showTimeline,
|
||||||
|
}) => {
|
||||||
|
// Mobile layout: Stack panels as overlays
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<div className={cn('relative h-full w-full overflow-hidden', className)}>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Mobile overlay panels */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{(showFileExplorer || showGitPanel || showTimeline) && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="absolute inset-0 bg-black/50 z-40"
|
||||||
|
onClick={() => {
|
||||||
|
// This will be handled by parent component
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tablet layout: Adaptive grid with optional sidebar
|
||||||
|
if (isTablet) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full w-full grid transition-all duration-300',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: showTimeline ? '1fr 320px' : '1fr',
|
||||||
|
gap: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid h-full" style={{ gridTemplateColumns: gridTemplateColumns }}>
|
||||||
|
{React.Children.toArray(children).slice(0, -1)}
|
||||||
|
</div>
|
||||||
|
{showTimeline && React.Children.toArray(children).slice(-1)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop/Widescreen layout: Full grid
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full w-full grid transition-all duration-300',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: gridTemplateColumns,
|
||||||
|
gap: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ResponsivePanelProps {
|
||||||
|
children: ReactNode;
|
||||||
|
isVisible: boolean;
|
||||||
|
position: 'left' | 'right' | 'overlay';
|
||||||
|
width?: number;
|
||||||
|
isMobile: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
className?: string;
|
||||||
|
resizable?: boolean;
|
||||||
|
onResize?: (width: number) => void;
|
||||||
|
minWidth?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Responsive panel component with mobile overlay support
|
||||||
|
*/
|
||||||
|
export const ResponsivePanel: React.FC<ResponsivePanelProps> = ({
|
||||||
|
children,
|
||||||
|
isVisible,
|
||||||
|
position,
|
||||||
|
width = 320,
|
||||||
|
isMobile,
|
||||||
|
onClose,
|
||||||
|
className,
|
||||||
|
resizable = false,
|
||||||
|
onResize,
|
||||||
|
minWidth = 200,
|
||||||
|
maxWidth = 600,
|
||||||
|
}) => {
|
||||||
|
const [isResizing, setIsResizing] = React.useState(false);
|
||||||
|
const [currentWidth, setCurrentWidth] = React.useState(width);
|
||||||
|
const panelRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setCurrentWidth(width);
|
||||||
|
}, [width]);
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
if (!resizable) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
setIsResizing(true);
|
||||||
|
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startWidth = currentWidth;
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
const diff = position === 'left' ? e.clientX - startX : startX - e.clientX;
|
||||||
|
const newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth + diff));
|
||||||
|
setCurrentWidth(newWidth);
|
||||||
|
onResize?.(newWidth);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsResizing(false);
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
// Mobile: Full screen overlay
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: position === 'left' ? '-100%' : '100%' }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: position === 'left' ? '-100%' : '100%' }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-y-0 z-50 bg-background shadow-2xl',
|
||||||
|
position === 'left' ? 'left-0' : 'right-0',
|
||||||
|
'w-[85vw] max-w-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={panelRef}
|
||||||
|
>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-4 right-4 p-2 rounded-lg hover:bg-accent z-10"
|
||||||
|
aria-label="Close panel"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: Integrated panel with optional resize
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className={cn(
|
||||||
|
'relative h-full overflow-hidden border-border',
|
||||||
|
position === 'left' && 'border-r',
|
||||||
|
position === 'right' && 'border-l',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ width: currentWidth }}
|
||||||
|
>
|
||||||
|
{resizable && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/20 transition-colors z-10',
|
||||||
|
position === 'left' ? 'right-0' : 'left-0',
|
||||||
|
isResizing && 'bg-primary/30'
|
||||||
|
)}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@@ -25,3 +25,4 @@ export {
|
|||||||
} from './usePerformanceMonitor';
|
} from './usePerformanceMonitor';
|
||||||
export { TAB_SCREEN_NAMES } from './useAnalytics';
|
export { TAB_SCREEN_NAMES } from './useAnalytics';
|
||||||
export { useTranslation, getLanguageDisplayName } from './useTranslation';
|
export { useTranslation, getLanguageDisplayName } from './useTranslation';
|
||||||
|
export { useLayoutManager } from './useLayoutManager';
|
||||||
|
268
src/hooks/useLayoutManager.ts
Normal file
268
src/hooks/useLayoutManager.ts
Normal file
@@ -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<LayoutState>(DEFAULT_LAYOUT);
|
||||||
|
const [breakpoints, setBreakpoints] = useState<LayoutBreakpoints>({
|
||||||
|
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<LayoutState>;
|
||||||
|
setLayout(prev => ({ ...prev, ...savedLayout }));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load layout preferences:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadLayout();
|
||||||
|
}, [projectPath]);
|
||||||
|
|
||||||
|
// Save layout changes
|
||||||
|
const saveLayout = useCallback((newLayout: Partial<LayoutState>) => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
@@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "./styles/grid-layout.css";
|
||||||
|
|
||||||
/* Custom scrollbar hiding */
|
/* Custom scrollbar hiding */
|
||||||
.scrollbar-hide {
|
.scrollbar-hide {
|
||||||
|
181
src/styles/grid-layout.css
Normal file
181
src/styles/grid-layout.css
Normal file
@@ -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);
|
||||||
|
}
|
Reference in New Issue
Block a user