From b5e8fd8acb13f6df01576c597e8b98da551e4211 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 14 Apr 2026 22:26:36 +0800 Subject: [PATCH] feat(folder): unify workspace state streaming for tree and git panels Introduce a shared workspace-state backend stream with snapshot/delta APIs for file tree and git changes. Migrate both aux panels to a common frontend workspace store with lifecycle-safe stream handling. Apply batched watch throttling, path-aware git refresh gating, no-op delta suppression, and bounded history compaction to improve runtime stability. --- src-tauri/src/commands/folders.rs | 750 ++---------- src-tauri/src/commands/mod.rs | 5 +- src-tauri/src/commands/workspace_state.rs | 46 + src-tauri/src/lib.rs | 20 +- src-tauri/src/web/handlers/folders.rs | 21 - src-tauri/src/web/handlers/mod.rs | 3 +- src-tauri/src/web/handlers/workspace_state.rs | 50 + src-tauri/src/web/router.rs | 659 ++++++++-- src-tauri/src/workspace_state/mod.rs | 1090 +++++++++++++++++ .../layout/aux-panel-file-tree-tab.tsx | 656 ++++------ .../layout/aux-panel-git-changes-tab.tsx | 280 +---- src/hooks/use-workspace-state-store.ts | 609 +++++++++ src/lib/api.ts | 23 +- src/lib/tauri.ts | 23 +- src/lib/types.ts | 40 +- 15 files changed, 2856 insertions(+), 1419 deletions(-) create mode 100644 src-tauri/src/commands/workspace_state.rs create mode 100644 src-tauri/src/web/handlers/workspace_state.rs create mode 100644 src-tauri/src/workspace_state/mod.rs create mode 100644 src/hooks/use-workspace-state-store.ts diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index 386282a..06fa8cf 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -4,11 +4,10 @@ use std::hash::{Hash, Hasher}; use std::io::Write; use std::path::{Component, Path, PathBuf}; use std::process::Stdio; -use std::sync::{mpsc, LazyLock, Mutex}; -use std::time::{Duration, Instant, UNIX_EPOCH}; +use std::sync::LazyLock; +use std::time::UNIX_EPOCH; use base64::Engine as _; -use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use serde::Serialize; use tokio::sync::Semaphore; @@ -51,8 +50,7 @@ async fn prepare_remote_git_cmd_with_remote( db: &AppDatabase, data_dir: &std::path::Path, ) { - cmd.env("GIT_TERMINAL_PROMPT", "0") - .stdin(Stdio::null()); + cmd.env("GIT_TERMINAL_PROMPT", "0").stdin(Stdio::null()); if let Some(creds) = credentials { // Explicit credentials provided (e.g. from credential dialog) @@ -67,7 +65,11 @@ async fn prepare_remote_git_cmd_with_remote( } else { // Fall back to stored accounts, matching against the specified remote crate::git_credential::try_inject_for_repo_remote( - cmd, repo_path, remote_name, &db.conn, data_dir, + cmd, + repo_path, + remote_name, + &db.conn, + data_dir, ) .await; } @@ -81,8 +83,7 @@ async fn prepare_remote_git_cmd_for_url( db: &AppDatabase, data_dir: &std::path::Path, ) { - cmd.env("GIT_TERMINAL_PROMPT", "0") - .stdin(Stdio::null()); + cmd.env("GIT_TERMINAL_PROMPT", "0").stdin(Stdio::null()); if let Some(creds) = credentials { if let Ok(askpass) = crate::git_credential::ensure_askpass_script(data_dir) { @@ -225,24 +226,7 @@ struct GitPushSucceededEvent { upstream_set: bool, } -struct FileWatchEntry { - root_canonical: PathBuf, - root_display: String, - watcher: RecommendedWatcher, - worker: Option>, - ref_count: usize, -} - -#[derive(Debug, Clone, Serialize)] -struct FileTreeChangedEvent { - root_path: String, - changed_paths: Vec, - kind: String, - full_reload: bool, - refresh_git_status: bool, -} - -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum FileTreeNode { File { @@ -570,19 +554,14 @@ pub(crate) async fn clone_repository_core( cmd.args(["clone", url, target_dir]); prepare_remote_git_cmd_for_url(&mut cmd, url, credentials, db, data_dir).await; - let output = cmd - .output() - .await - .map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - AppCommandError::dependency_missing( - "Git is not installed. Please install Git first.", - ) + let output = cmd.output().await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + AppCommandError::dependency_missing("Git is not installed. Please install Git first.") .with_detail("https://git-scm.com") - } else { - AppCommandError::external_command("Failed to run git clone", e.to_string()) - } - })?; + } else { + AppCommandError::external_command("Failed to run git clone", e.to_string()) + } + })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -718,10 +697,7 @@ pub(crate) async fn git_pull_core( fetch_cmd.args(["fetch"]).current_dir(path); prepare_remote_git_cmd(&mut fetch_cmd, path, credentials, db, data_dir).await; - let fetch_output = fetch_cmd - .output() - .await - .map_err(AppCommandError::io)?; + let fetch_output = fetch_cmd.output().await.map_err(AppCommandError::io)?; if !fetch_output.status.success() { return Err(classify_remote_git_error("fetch", &fetch_output.stderr)); @@ -759,8 +735,12 @@ pub(crate) async fn git_pull_core( .await .map_err(AppCommandError::io)?; - let base_hash = String::from_utf8_lossy(&merge_base.stdout).trim().to_string(); - let current_head = String::from_utf8_lossy(&head_hash.stdout).trim().to_string(); + let base_hash = String::from_utf8_lossy(&merge_base.stdout) + .trim() + .to_string(); + let current_head = String::from_utf8_lossy(&head_hash.stdout) + .trim() + .to_string(); if base_hash == current_head { let ff_output = crate::process::tokio_command("git") @@ -898,10 +878,7 @@ pub(crate) async fn git_fetch_core( cmd.args(["fetch", "--all"]).current_dir(path); prepare_remote_git_cmd(&mut cmd, path, credentials, db, data_dir).await; - let output = cmd - .output() - .await - .map_err(AppCommandError::io)?; + let output = cmd.output().await.map_err(AppCommandError::io)?; if !output.status.success() { return Err(classify_remote_git_error("fetch --all", &output.stderr)); @@ -970,9 +947,7 @@ pub(crate) async fn git_push_core( ) -> Result { let pushed_commits = estimate_push_commit_count(path).await; - let target_remote = remote - .filter(|s| !s.is_empty()) - .unwrap_or("origin"); + let target_remote = remote.filter(|s| !s.is_empty()).unwrap_or("origin"); let branch_output = crate::process::tokio_command("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) @@ -1010,13 +985,28 @@ pub(crate) async fn git_push_core( let mut cmd = crate::process::tokio_command("git"); cmd.args(["push", "--set-upstream", target_remote, &branch]) .current_dir(path); - prepare_remote_git_cmd_with_remote(&mut cmd, path, Some(target_remote), credentials, db, data_dir).await; + prepare_remote_git_cmd_with_remote( + &mut cmd, + path, + Some(target_remote), + credentials, + db, + data_dir, + ) + .await; cmd.output().await.map_err(AppCommandError::io)? } else { let mut cmd = crate::process::tokio_command("git"); - cmd.args(["push", target_remote, &branch]) - .current_dir(path); - prepare_remote_git_cmd_with_remote(&mut cmd, path, Some(target_remote), credentials, db, data_dir).await; + cmd.args(["push", target_remote, &branch]).current_dir(path); + prepare_remote_git_cmd_with_remote( + &mut cmd, + path, + Some(target_remote), + credentials, + db, + data_dir, + ) + .await; cmd.output().await.map_err(AppCommandError::io)? }; @@ -1065,7 +1055,16 @@ pub async fn git_push( AppCommandError::external_command("Failed to resolve app data dir", e.to_string()) })?; let emitter = EventEmitter::Tauri(app.clone()); - git_push_core(&data_dir, &emitter, folder_id, &path, remote.as_deref(), credentials.as_ref(), &db).await + git_push_core( + &data_dir, + &emitter, + folder_id, + &path, + remote.as_deref(), + credentials.as_ref(), + &db, + ) + .await } #[cfg_attr(feature = "tauri-runtime", tauri::command)] @@ -1291,10 +1290,7 @@ pub async fn git_stash_list(path: String) -> Result, AppComma } #[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn git_stash_apply( - path: String, - stash_ref: String, -) -> Result { +pub async fn git_stash_apply(path: String, stash_ref: String) -> Result { let output = crate::process::tokio_command("git") .args(["stash", "apply", &stash_ref]) .current_dir(&path) @@ -1309,10 +1305,7 @@ pub async fn git_stash_apply( } #[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn git_stash_drop( - path: String, - stash_ref: String, -) -> Result { +pub async fn git_stash_drop(path: String, stash_ref: String) -> Result { let output = crate::process::tokio_command("git") .args(["stash", "drop", &stash_ref]) .current_dir(&path) @@ -1571,21 +1564,20 @@ pub(crate) async fn git_commit_core( ) -> Result { // Find files already staged for deletion — git add would fail on these // because they no longer exist in either the working tree or the index. - let staged_deletions: std::collections::HashSet = - crate::process::tokio_command("git") - .args(["diff", "--cached", "--name-only", "--diff-filter=D", "-z"]) - .current_dir(path) - .output() - .await - .ok() - .map(|o| { - String::from_utf8_lossy(&o.stdout) - .split('\0') - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect() - }) - .unwrap_or_default(); + let staged_deletions: std::collections::HashSet = crate::process::tokio_command("git") + .args(["diff", "--cached", "--name-only", "--diff-filter=D", "-z"]) + .current_dir(path) + .output() + .await + .ok() + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .split('\0') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect() + }) + .unwrap_or_default(); // Stage only files that aren't already staged deletions let files_to_add: Vec<_> = files @@ -1595,7 +1587,11 @@ pub(crate) async fn git_commit_core( if !files_to_add.is_empty() { let mut add_args = vec!["add".to_string(), "--".to_string()]; - add_args.extend(files_to_add.iter().map(|file| to_git_literal_pathspec(file))); + add_args.extend( + files_to_add + .iter() + .map(|file| to_git_literal_pathspec(file)), + ); let add_output = crate::process::tokio_command("git") .args(&add_args) @@ -1610,8 +1606,7 @@ pub(crate) async fn git_commit_core( } // Resolve commit author from matching account (e.g. GitHub username) - let author_override = - crate::git_credential::resolve_commit_author(path, conn).await; + let author_override = crate::git_credential::resolve_commit_author(path, conn).await; // Commit let mut commit_cmd = crate::process::tokio_command("git"); @@ -1625,10 +1620,7 @@ pub(crate) async fn git_commit_core( } commit_cmd.args(["commit", "-m", message]).current_dir(path); - let commit_output = commit_cmd - .output() - .await - .map_err(AppCommandError::io)?; + let commit_output = commit_cmd.output().await.map_err(AppCommandError::io)?; if !commit_output.status.success() { return Err(git_command_error("commit", &commit_output.stderr)); @@ -1879,10 +1871,7 @@ pub(crate) async fn git_fetch_remote_core( cmd.args(["fetch", name]).current_dir(path); prepare_remote_git_cmd_with_remote(&mut cmd, path, Some(name), credentials, db, data_dir).await; - let output = cmd - .output() - .await - .map_err(AppCommandError::io)?; + let output = cmd.output().await.map_err(AppCommandError::io)?; if !output.status.success() { return Err(classify_remote_git_error("fetch", &output.stderr)); @@ -2096,8 +2085,15 @@ pub async fn git_delete_remote_branch( let data_dir = app_handle.path().app_data_dir().map_err(|e| { AppCommandError::external_command("Failed to resolve app data dir", e.to_string()) })?; - git_delete_remote_branch_core(&path, &remote, &branch, credentials.as_ref(), &db, &data_dir) - .await + git_delete_remote_branch_core( + &path, + &remote, + &branch, + credentials.as_ref(), + &db, + &data_dir, + ) + .await } #[cfg_attr(feature = "tauri-runtime", tauri::command)] @@ -2157,9 +2153,8 @@ pub async fn git_resolve_conflict( let file_path = Path::new(&path).join(&file); // Write resolved content - std::fs::write(&file_path, content).map_err(|e| { - AppCommandError::io_error(format!("Failed to write resolved file: {}", e)) - })?; + std::fs::write(&file_path, content) + .map_err(|e| AppCommandError::io_error(format!("Failed to write resolved file: {}", e)))?; // Stage the resolved file let output = crate::process::tokio_command("git") @@ -2177,10 +2172,7 @@ pub async fn git_resolve_conflict( } #[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn git_abort_operation( - path: String, - operation: String, -) -> Result<(), AppCommandError> { +pub async fn git_abort_operation(path: String, operation: String) -> Result<(), AppCommandError> { let args = match operation.as_str() { "merge" | "pull" => vec!["merge", "--abort"], "rebase" => vec!["rebase", "--abort"], @@ -2239,7 +2231,6 @@ pub async fn git_continue_operation( Ok(()) } -const WATCH_IGNORED_DIRS: &[&str] = &["__pycache__"]; const FILE_TREE_IGNORED_DIRS: &[&str] = &[".git", "__pycache__"]; /// Hard limit: refuse to open files larger than 50 MB in the text editor. @@ -2252,11 +2243,6 @@ const FILE_IO_MAX_CONCURRENT_OPS: usize = 8; static FILE_IO_SEMAPHORE: LazyLock = LazyLock::new(|| Semaphore::new(FILE_IO_MAX_CONCURRENT_OPS)); -static FILE_WATCHERS: LazyLock>> = - LazyLock::new(|| Mutex::new(HashMap::new())); -const FILE_WATCH_DEBOUNCE_MS: u64 = 2_000; -const FILE_WATCH_MAX_BATCH_WINDOW_MS: u64 = 5_000; -const FILE_WATCH_MAX_CHANGED_PATHS: usize = 2_000; fn to_git_literal_pathspec(path: &str) -> String { format!(":(literal){path}") @@ -2276,345 +2262,6 @@ fn unquote_git_path(path: &str) -> String { } } -fn normalize_slash_path(path: &Path) -> String { - path.to_string_lossy().replace('\\', "/") -} - -fn is_git_metadata_rel_path(path: &str) -> bool { - path == ".git" || path.starts_with(".git/") -} - -fn is_gitignore_rel_path(path: &str) -> bool { - Path::new(path) - .file_name() - .map(|name| name.to_string_lossy() == ".gitignore") - .unwrap_or(false) -} - -fn is_codeg_edit_temp_path(path: &Path) -> bool { - path.file_name() - .map(|name| { - let name = name.to_string_lossy(); - name.starts_with(".codeg-edit-") && name.ends_with(".tmp") - }) - .unwrap_or(false) -} - -fn git_check_ignored_paths( - repo_path: &str, - paths: &[String], -) -> Result, AppCommandError> { - if paths.is_empty() { - return Ok(HashSet::new()); - } - - let mut child = crate::process::std_command("git") - .args(["check-ignore", "--stdin", "-z"]) - .current_dir(repo_path) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(AppCommandError::io)?; - - if let Some(mut stdin) = child.stdin.take() { - for path in paths { - stdin - .write_all(path.as_bytes()) - .map_err(AppCommandError::io)?; - stdin.write_all(&[0]).map_err(AppCommandError::io)?; - } - } - - let output = child.wait_with_output().map_err(AppCommandError::io)?; - - // Exit code 1 means "no matches", which is expected. - if !output.status.success() && output.status.code() != Some(1) { - return Err(git_command_error("check-ignore", &output.stderr)); - } - - let mut ignored = HashSet::new(); - for raw in output.stdout.split(|byte| *byte == 0) { - if raw.is_empty() { - continue; - } - ignored.insert(String::from_utf8_lossy(raw).to_string()); - } - Ok(ignored) -} - -fn should_refresh_git_status_for_paths(root_display: &str, changed_paths: &[String]) -> bool { - if changed_paths.is_empty() { - return true; - } - - let mut candidates: Vec = Vec::new(); - for path in changed_paths { - if is_git_metadata_rel_path(path) || is_gitignore_rel_path(path) { - return true; - } - candidates.push(path.clone()); - } - - if candidates.is_empty() { - return false; - } - - let ignored = match git_check_ignored_paths(root_display, &candidates) { - Ok(ignored) => ignored, - // Fail safe: if detection fails, keep current behavior and refresh status. - Err(_) => return true, - }; - - candidates - .iter() - .any(|path| !ignored.contains(path.as_str())) -} - -fn canonicalize_watch_root(root: &Path) -> Result<(PathBuf, String), AppCommandError> { - let canonical = std::fs::canonicalize(root).map_err(|e| { - AppCommandError::not_found("Unable to resolve workspace root").with_detail(e.to_string()) - })?; - let key = normalize_slash_path(&canonical); - Ok((canonical, key)) -} - -fn is_allowed_git_watch_path(relative: &Path) -> bool { - let mut components = relative.components(); - - let Some(Component::Normal(first)) = components.next() else { - return false; - }; - if first.to_string_lossy() != ".git" { - return false; - } - - let Some(Component::Normal(second)) = components.next() else { - // Allow top-level .git events. - return true; - }; - - let second_name = second.to_string_lossy(); - match second_name.as_ref() { - "HEAD" | "index" | "packed-refs" | "FETCH_HEAD" | "ORIG_HEAD" | "MERGE_HEAD" - | "CHERRY_PICK_HEAD" | "REVERT_HEAD" => true, - "refs" => { - let Some(Component::Normal(scope)) = components.next() else { - return true; - }; - matches!( - scope.to_string_lossy().as_ref(), - "heads" | "remotes" | "stash" - ) - } - "rebase-merge" | "rebase-apply" => true, - _ => false, - } -} - -fn is_ignored_watch_path(path: &Path, root: &Path) -> bool { - let Ok(relative) = path.strip_prefix(root) else { - return false; - }; - - if is_codeg_edit_temp_path(relative) { - return true; - } - - let mut components = relative.components(); - if let Some(Component::Normal(first)) = components.next() { - if first.to_string_lossy() == ".git" { - return !is_allowed_git_watch_path(relative); - } - } - - relative.components().any(|component| { - let Component::Normal(name) = component else { - return false; - }; - let component_name = name.to_string_lossy(); - WATCH_IGNORED_DIRS - .iter() - .any(|ignored| *ignored == component_name.as_ref()) - }) -} - -fn should_emit_watch_event(kind: &EventKind) -> bool { - matches!( - kind, - EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) - ) -} - -#[derive(Default)] -struct WatchEventBatch { - changed_paths: HashSet, - has_create: bool, - has_remove: bool, - overflowed: bool, -} - -impl WatchEventBatch { - fn clear(&mut self) { - self.changed_paths.clear(); - self.has_create = false; - self.has_remove = false; - self.overflowed = false; - } - - fn is_empty(&self) -> bool { - !self.overflowed && self.changed_paths.is_empty() - } - - fn ingest_event(&mut self, root_canonical: &Path, event: notify::Event) { - if !should_emit_watch_event(&event.kind) { - return; - } - - if self.overflowed { - return; - } - - let mut has_relevant_path = false; - for path in event.paths { - if is_ignored_watch_path(&path, root_canonical) { - continue; - } - - let relative = if let Ok(relative) = path.strip_prefix(root_canonical) { - normalize_slash_path(relative) - } else { - normalize_slash_path(&path) - }; - - if relative.is_empty() { - continue; - } - - self.changed_paths.insert(relative); - has_relevant_path = true; - if self.changed_paths.len() > FILE_WATCH_MAX_CHANGED_PATHS { - self.overflowed = true; - self.changed_paths.clear(); - break; - } - } - - if !has_relevant_path { - return; - } - - match event.kind { - EventKind::Create(_) => self.has_create = true, - EventKind::Remove(_) => self.has_remove = true, - _ => {} - } - } - - fn emit(&self, emitter: &EventEmitter, root_display: &str, root_canonical: &Path) { - if self.is_empty() { - return; - } - - let changed_paths = if self.overflowed { - Vec::new() - } else { - let mut paths = self.changed_paths.iter().cloned().collect::>(); - paths.sort(); - paths - }; - - // On macOS, Finder trash (move-to-trash) may be reported as a rename - // (`Modify(Name)`) instead of `Remove`, so `has_remove` is never set. - // Detect this by checking whether any changed path no longer exists on - // disk and promote the event kind to "remove" accordingly. - let has_missing_path = !self.has_remove - && !self.overflowed - && self - .changed_paths - .iter() - .any(|p| !root_canonical.join(p).exists()); - let kind = if self.has_remove || has_missing_path { - "remove" - } else if self.has_create { - "create" - } else { - "modify" - }; - - let payload = FileTreeChangedEvent { - root_path: root_display.to_string(), - refresh_git_status: if self.overflowed { - true - } else { - should_refresh_git_status_for_paths(root_display, &changed_paths) - }, - changed_paths, - kind: kind.to_string(), - full_reload: self.overflowed, - }; - - crate::web::event_bridge::emit_event(emitter, "folder://file-tree-changed", payload); - } -} - -fn run_file_watch_event_loop( - event_rx: mpsc::Receiver, - emitter: EventEmitter, - root_display: String, - root_canonical: PathBuf, -) { - let debounce = Duration::from_millis(FILE_WATCH_DEBOUNCE_MS); - let max_batch_window = Duration::from_millis(FILE_WATCH_MAX_BATCH_WINDOW_MS); - let mut batch = WatchEventBatch::default(); - let mut batch_started_at: Option = None; - - loop { - match event_rx.recv_timeout(debounce) { - Ok(event) => { - batch.ingest_event(&root_canonical, event); - if !batch.is_empty() && batch_started_at.is_none() { - batch_started_at = Some(Instant::now()); - } - - while let Ok(next_event) = event_rx.try_recv() { - batch.ingest_event(&root_canonical, next_event); - if !batch.is_empty() && batch_started_at.is_none() { - batch_started_at = Some(Instant::now()); - } - } - - let should_flush = if batch.overflowed { - true - } else { - batch_started_at - .map(|started| started.elapsed() >= max_batch_window) - .unwrap_or(false) - }; - - if should_flush { - batch.emit(&emitter, &root_display, &root_canonical); - batch.clear(); - batch_started_at = None; - } - } - Err(mpsc::RecvTimeoutError::Timeout) => { - if !batch.is_empty() { - batch.emit(&emitter, &root_display, &root_canonical); - batch.clear(); - batch_started_at = None; - } - } - Err(mpsc::RecvTimeoutError::Disconnected) => { - if !batch.is_empty() { - batch.emit(&emitter, &root_display, &root_canonical); - } - break; - } - } - } -} - fn resolve_tree_path(root: &Path, rel_path: &str) -> Result { let rel = Path::new(rel_path); if rel.is_absolute() { @@ -2652,164 +2299,6 @@ fn validate_new_name(new_name: &str) -> Result<&str, AppCommandError> { Ok(trimmed) } -pub(crate) async fn start_file_tree_watch_core( - emitter: EventEmitter, - root_path: String, -) -> Result<(), AppCommandError> { - let root = PathBuf::from(&root_path); - if !root.exists() || !root.is_dir() { - return Err(AppCommandError::not_found("Folder does not exist")); - } - - let (root_canonical, key) = canonicalize_watch_root(&root)?; - - { - let mut watchers = FILE_WATCHERS.lock().map_err(|_| { - AppCommandError::task_execution_failed("Failed to lock file watcher registry") - })?; - if let Some(entry) = watchers.get_mut(&key) { - entry.ref_count += 1; - return Ok(()); - } - } - - let root_display_for_worker = root_path.clone(); - let root_display_for_error = root_path.clone(); - let root_canonical_for_worker = root_canonical.clone(); - let emitter_for_worker = emitter; - let (event_tx, event_rx) = mpsc::channel::(); - let mut worker = Some(std::thread::spawn(move || { - run_file_watch_event_loop( - event_rx, - emitter_for_worker, - root_display_for_worker, - root_canonical_for_worker, - ) - })); - - let mut watcher = Some( - notify::recommended_watcher( - move |result: Result| match result { - Ok(event) => { - let _ = event_tx.send(event); - } - Err(err) => { - eprintln!( - "[file-watch] failed event for {}: {}", - root_display_for_error, err - ); - } - }, - ) - .map_err(|e| { - AppCommandError::io_error("Failed to create file watcher").with_detail(e.to_string()) - })?, - ); - - watcher - .as_mut() - .ok_or_else(|| AppCommandError::task_execution_failed("Failed to create file watcher"))? - .watch(&root_canonical, RecursiveMode::Recursive) - .map_err(|e| { - AppCommandError::io_error("Failed to start file watcher").with_detail(e.to_string()) - })?; - - let should_cleanup_new_watcher = { - let mut watchers = FILE_WATCHERS.lock().map_err(|_| { - AppCommandError::task_execution_failed("Failed to lock file watcher registry") - })?; - if let Some(entry) = watchers.get_mut(&key) { - entry.ref_count += 1; - true - } else { - watchers.insert( - key, - FileWatchEntry { - root_canonical, - root_display: root_path, - watcher: watcher.take().ok_or_else(|| { - AppCommandError::task_execution_failed( - "Failed to initialize file watcher state", - ) - })?, - worker: worker.take(), - ref_count: 1, - }, - ); - false - } - }; - - if !should_cleanup_new_watcher { - return Ok(()); - } - - drop(watcher.take()); - if let Some(handle) = worker.take() { - let _ = handle.join(); - } - - Ok(()) -} - -#[cfg(feature = "tauri-runtime")] -#[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn start_file_tree_watch( - app: tauri::AppHandle, - root_path: String, -) -> Result<(), AppCommandError> { - let emitter = EventEmitter::Tauri(app); - start_file_tree_watch_core(emitter, root_path).await -} - -#[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn stop_file_tree_watch(root_path: String) -> Result<(), AppCommandError> { - let root = PathBuf::from(&root_path); - let key = canonicalize_watch_root(&root) - .map(|(_, key)| key) - .unwrap_or_else(|_| normalize_slash_path(&root)); - - let mut watchers = FILE_WATCHERS.lock().map_err(|_| { - AppCommandError::task_execution_failed("Failed to lock file watcher registry") - })?; - - let target_key = if watchers.contains_key(&key) { - Some(key) - } else { - watchers.iter().find_map(|(candidate_key, entry)| { - if entry.root_display == root_path { - Some(candidate_key.clone()) - } else { - None - } - }) - }; - - let Some(target_key) = target_key else { - return Ok(()); - }; - - if let Some(entry) = watchers.get_mut(&target_key) { - if entry.ref_count > 1 { - entry.ref_count -= 1; - return Ok(()); - } - } - - let mut removed_entry = watchers.remove(&target_key); - drop(watchers); - - if let Some(mut entry) = removed_entry.take() { - let _ = entry.watcher.unwatch(&entry.root_canonical); - drop(entry.watcher); - if let Some(worker) = entry.worker.take() { - let _ = worker.join(); - } - } - - Ok(()) -} - fn file_mtime_ms(metadata: &std::fs::Metadata) -> Option { let modified = metadata.modified().ok()?; let elapsed = modified.duration_since(UNIX_EPOCH).ok()?; @@ -2875,11 +2364,7 @@ fn read_text_full(target: &Path, hard_limit: usize) -> Result hard_limit as u64 { return Err( AppCommandError::invalid_input("File is too large to open in editor") - .with_detail(format!( - "size={}, limit={}", - metadata.len(), - hard_limit - )), + .with_detail(format!("size={}, limit={}", metadata.len(), hard_limit)), ); } @@ -3030,13 +2515,10 @@ pub struct DirectoryEntry { } #[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn list_directory_entries( - path: String, -) -> Result, AppCommandError> { +pub async fn list_directory_entries(path: String) -> Result, AppCommandError> { let root = PathBuf::from(&path); if !root.is_dir() { - return Err(AppCommandError::io_error("Path is not a directory") - .with_detail(path)); + return Err(AppCommandError::io_error("Path is not a directory").with_detail(path)); } let mut entries: Vec = Vec::new(); @@ -3071,23 +2553,21 @@ pub async fn list_directory_entries( // Peek into subdirectory to check if it has child directories let has_children = match std::fs::read_dir(entry.path()) { - Ok(sub) => sub - .filter_map(|e| e.ok()) - .any(|e| { - let ft = e.file_type().ok(); - let is_sub_dir = ft.is_some_and(|ft| { - if ft.is_symlink() { - e.path().is_dir() - } else { - ft.is_dir() - } - }); - if !is_sub_dir { - return false; + Ok(sub) => sub.filter_map(|e| e.ok()).any(|e| { + let ft = e.file_type().ok(); + let is_sub_dir = ft.is_some_and(|ft| { + if ft.is_symlink() { + e.path().is_dir() + } else { + ft.is_dir() } - let sub_name = e.file_name().to_string_lossy().to_string(); - !sub_name.starts_with('.') - }), + }); + if !is_sub_dir { + return false; + } + let sub_name = e.file_name().to_string_lossy().to_string(); + !sub_name.starts_with('.') + }), Err(_) => false, }; @@ -3644,10 +3124,9 @@ pub async fn create_file_tree_entry( return Err(AppCommandError::not_found("Parent path does not exist")); } if resolved.is_file() { - resolved - .parent() - .map(|p| p.to_path_buf()) - .ok_or_else(|| AppCommandError::invalid_input("Cannot determine parent directory"))? + resolved.parent().map(|p| p.to_path_buf()).ok_or_else(|| { + AppCommandError::invalid_input("Cannot determine parent directory") + })? } else { resolved } @@ -3699,9 +3178,7 @@ pub async fn git_log( let mut args = vec![ "log".to_string(), limit_str, - format!( - "--format=__COMMIT__%x00%h%x00%H%x00%an%x00%aI%n%B%n{MESSAGE_END_MARKER}" - ), + format!("--format=__COMMIT__%x00%h%x00%H%x00%an%x00%aI%n%B%n{MESSAGE_END_MARKER}"), "--raw".to_string(), "--numstat".to_string(), "--no-renames".to_string(), @@ -4057,8 +3534,7 @@ async fn get_unpushed_hashes( .current_dir(path) .output() .await; - let remote_branch_exists = verify_output - .is_ok_and(|o| o.status.success()); + let remote_branch_exists = verify_output.is_ok_and(|o| o.status.success()); if remote_branch_exists { let range = format!("{}/{}..{}", remote, branch_name, local_ref); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 7edb3c8..d60e6bd 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,11 +6,12 @@ pub mod folder_commands; pub mod folders; pub mod mcp; pub mod model_provider; +#[cfg(feature = "tauri-runtime")] +pub mod notification; pub mod project_boot; pub mod system_settings; pub mod terminal; pub mod version_control; #[cfg(feature = "tauri-runtime")] -pub mod notification; -#[cfg(feature = "tauri-runtime")] pub mod windows; +pub mod workspace_state; diff --git a/src-tauri/src/commands/workspace_state.rs b/src-tauri/src/commands/workspace_state.rs new file mode 100644 index 0000000..8cb96fd --- /dev/null +++ b/src-tauri/src/commands/workspace_state.rs @@ -0,0 +1,46 @@ +use crate::app_error::AppCommandError; +use crate::web::event_bridge::EventEmitter; +use crate::workspace_state::WorkspaceSnapshotResponse; + +pub(crate) async fn start_workspace_state_stream_core( + emitter: EventEmitter, + root_path: String, +) -> Result { + crate::workspace_state::start_workspace_state_stream_core(emitter, root_path).await +} + +pub(crate) async fn stop_workspace_state_stream_core( + root_path: String, +) -> Result<(), AppCommandError> { + crate::workspace_state::stop_workspace_state_stream_core(root_path).await +} + +pub(crate) async fn get_workspace_snapshot_core( + root_path: String, + since_seq: Option, +) -> Result { + crate::workspace_state::get_workspace_snapshot_core(root_path, since_seq).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn start_workspace_state_stream( + app: tauri::AppHandle, + root_path: String, +) -> Result { + let emitter = EventEmitter::Tauri(app); + start_workspace_state_stream_core(emitter, root_path).await +} + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn stop_workspace_state_stream(root_path: String) -> Result<(), AppCommandError> { + stop_workspace_state_stream_core(root_path).await +} + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn get_workspace_snapshot( + root_path: String, + since_seq: Option, +) -> Result { + get_workspace_snapshot_core(root_path, since_seq).await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b8db55b..13fb3d4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ mod parsers; pub mod process; mod terminal; pub mod web; +pub mod workspace_state; #[cfg(feature = "tauri-runtime")] mod tauri_app { @@ -24,6 +25,7 @@ mod tauri_app { experts as experts_commands, folder_commands, folders, mcp as mcp_commands, model_provider as model_provider_commands, notification, project_boot, system_settings, terminal as terminal_commands, version_control, windows, + workspace_state as workspace_state_commands, }; use crate::terminal::manager::TerminalManager; use crate::{db, network, process, web}; @@ -61,7 +63,9 @@ mod tauri_app { .manage(windows::CommitWindowState::new()) .manage(windows::MergeWindowState::new()) .manage(web::WebServerState::new()) - .manage(std::sync::Arc::new(web::event_bridge::WebEventBroadcaster::new())) + .manage(std::sync::Arc::new( + web::event_bridge::WebEventBroadcaster::new(), + )) .setup(|app| { let app_data_dir = app.path().app_data_dir()?; let app_version = env!("CARGO_PKG_VERSION"); @@ -111,8 +115,8 @@ mod tauri_app { // Start chat channel background tasks { let ccm = app.state::(); - let broadcaster = app - .state::>(); + let broadcaster = + app.state::>(); let db_conn = app.state::().conn.clone(); let ccm_ref = ccm.clone_ref(); let br = broadcaster.inner().clone(); @@ -214,8 +218,9 @@ mod tauri_app { if label.starts_with("folder-") { let app = window.app_handle(); if let Some(cm) = app.try_state::() { - let disconnected = - tauri::async_runtime::block_on(cm.disconnect_by_owner_window(&label)); + let disconnected = tauri::async_runtime::block_on( + cm.disconnect_by_owner_window(&label), + ); eprintln!( "[ACP] folder window closing label={} disconnected_connections={}", label, disconnected @@ -316,8 +321,9 @@ mod tauri_app { folders::git_abort_operation, folders::git_continue_operation, folders::save_folder_opened_conversations, - folders::start_file_tree_watch, - folders::stop_file_tree_watch, + workspace_state_commands::start_workspace_state_stream, + workspace_state_commands::stop_workspace_state_stream, + workspace_state_commands::get_workspace_snapshot, folders::get_home_directory, folders::list_directory_entries, folders::get_file_tree, diff --git a/src-tauri/src/web/handlers/folders.rs b/src-tauri/src/web/handlers/folders.rs index b2f5a23..36613f9 100644 --- a/src-tauri/src/web/handlers/folders.rs +++ b/src-tauri/src/web/handlers/folders.rs @@ -142,27 +142,6 @@ pub async fn get_file_tree( Ok(Json(result)) } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RootPathParams { - pub root_path: String, -} - -pub async fn start_file_tree_watch( - Extension(state): Extension>, - Json(params): Json, -) -> Result, AppCommandError> { - folder_commands::start_file_tree_watch_core(state.emitter.clone(), params.root_path).await?; - Ok(Json(())) -} - -pub async fn stop_file_tree_watch( - Json(params): Json, -) -> Result, AppCommandError> { - folder_commands::stop_file_tree_watch(params.root_path).await?; - Ok(Json(())) -} - #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct OpenSettingsWindowParams { diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index 615d793..cc7d7de 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -1,7 +1,7 @@ -mod error; pub mod acp; pub mod chat_channel; pub mod conversations; +mod error; pub mod experts; pub mod files; pub mod folder_commands; @@ -14,3 +14,4 @@ pub mod system_settings; pub mod terminal; pub mod version_control; pub mod web_server; +pub mod workspace_state; diff --git a/src-tauri/src/web/handlers/workspace_state.rs b/src-tauri/src/web/handlers/workspace_state.rs new file mode 100644 index 0000000..d168ae9 --- /dev/null +++ b/src-tauri/src/web/handlers/workspace_state.rs @@ -0,0 +1,50 @@ +use std::sync::Arc; + +use axum::{extract::Extension, Json}; +use serde::Deserialize; + +use crate::app_error::AppCommandError; +use crate::app_state::AppState; +use crate::commands::workspace_state as workspace_state_commands; +use crate::workspace_state::WorkspaceSnapshotResponse; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceRootPathParams { + pub root_path: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSnapshotParams { + pub root_path: String, + pub since_seq: Option, +} + +pub async fn start_workspace_state_stream( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let result = workspace_state_commands::start_workspace_state_stream_core( + state.emitter.clone(), + params.root_path, + ) + .await?; + Ok(Json(result)) +} + +pub async fn stop_workspace_state_stream( + Json(params): Json, +) -> Result, AppCommandError> { + workspace_state_commands::stop_workspace_state_stream_core(params.root_path).await?; + Ok(Json(())) +} + +pub async fn get_workspace_snapshot( + Json(params): Json, +) -> Result, AppCommandError> { + let result = + workspace_state_commands::get_workspace_snapshot_core(params.root_path, params.since_seq) + .await?; + Ok(Json(result)) +} diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index a07552f..22f9910 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -25,57 +25,160 @@ pub fn build_router(state: Arc, token: String, static_dir: std::path:: let api = Router::new() .route("/health", post(health_check)) // ─── Conversations ─── - .route("/list_conversations", post(handlers::conversations::list_conversations)) - .route("/get_conversation", post(handlers::conversations::get_conversation)) - .route("/list_folder_conversations", post(handlers::conversations::list_folder_conversations)) - .route("/get_folder_conversation", post(handlers::conversations::get_folder_conversation)) - .route("/import_local_conversations", post(handlers::conversations::import_local_conversations)) + .route( + "/list_conversations", + post(handlers::conversations::list_conversations), + ) + .route( + "/get_conversation", + post(handlers::conversations::get_conversation), + ) + .route( + "/list_folder_conversations", + post(handlers::conversations::list_folder_conversations), + ) + .route( + "/get_folder_conversation", + post(handlers::conversations::get_folder_conversation), + ) + .route( + "/import_local_conversations", + post(handlers::conversations::import_local_conversations), + ) .route("/list_folders", post(handlers::conversations::list_folders)) .route("/get_stats", post(handlers::conversations::get_stats)) - .route("/get_sidebar_data", post(handlers::conversations::get_sidebar_data)) - .route("/create_conversation", post(handlers::conversations::create_conversation)) - .route("/update_conversation_status", post(handlers::conversations::update_conversation_status)) - .route("/update_conversation_title", post(handlers::conversations::update_conversation_title)) - .route("/delete_conversation", post(handlers::conversations::delete_conversation)) - .route("/update_conversation_external_id", post(handlers::conversations::update_conversation_external_id)) + .route( + "/get_sidebar_data", + post(handlers::conversations::get_sidebar_data), + ) + .route( + "/create_conversation", + post(handlers::conversations::create_conversation), + ) + .route( + "/update_conversation_status", + post(handlers::conversations::update_conversation_status), + ) + .route( + "/update_conversation_title", + post(handlers::conversations::update_conversation_title), + ) + .route( + "/delete_conversation", + post(handlers::conversations::delete_conversation), + ) + .route( + "/update_conversation_external_id", + post(handlers::conversations::update_conversation_external_id), + ) // ─── Folders ─── - .route("/load_folder_history", post(handlers::folders::load_folder_history)) - .route("/list_open_folders", post(handlers::folders::list_open_folders)) - .route("/close_folder_window", post(handlers::folders::close_folder_window)) + .route( + "/load_folder_history", + post(handlers::folders::load_folder_history), + ) + .route( + "/list_open_folders", + post(handlers::folders::list_open_folders), + ) + .route( + "/close_folder_window", + post(handlers::folders::close_folder_window), + ) .route("/get_folder", post(handlers::folders::get_folder)) - .route("/open_folder_window", post(handlers::folders::open_folder_window)) - .route("/add_folder_to_history", post(handlers::folders::add_folder_to_history)) - .route("/set_folder_parent_branch", post(handlers::folders::set_folder_parent_branch)) - .route("/remove_folder_from_history", post(handlers::folders::remove_folder_from_history)) - .route("/create_folder_directory", post(handlers::folders::create_folder_directory)) - .route("/save_folder_opened_conversations", post(handlers::folders::save_folder_opened_conversations)) + .route( + "/open_folder_window", + post(handlers::folders::open_folder_window), + ) + .route( + "/add_folder_to_history", + post(handlers::folders::add_folder_to_history), + ) + .route( + "/set_folder_parent_branch", + post(handlers::folders::set_folder_parent_branch), + ) + .route( + "/remove_folder_from_history", + post(handlers::folders::remove_folder_from_history), + ) + .route( + "/create_folder_directory", + post(handlers::folders::create_folder_directory), + ) + .route( + "/save_folder_opened_conversations", + post(handlers::folders::save_folder_opened_conversations), + ) .route("/get_git_branch", post(handlers::folders::get_git_branch)) - .route("/get_home_directory", post(handlers::folders::get_home_directory)) - .route("/list_directory_entries", post(handlers::folders::list_directory_entries)) + .route( + "/get_home_directory", + post(handlers::folders::get_home_directory), + ) + .route( + "/list_directory_entries", + post(handlers::folders::list_directory_entries), + ) .route("/get_file_tree", post(handlers::folders::get_file_tree)) - .route("/start_file_tree_watch", post(handlers::folders::start_file_tree_watch)) - .route("/stop_file_tree_watch", post(handlers::folders::stop_file_tree_watch)) + .route( + "/start_workspace_state_stream", + post(handlers::workspace_state::start_workspace_state_stream), + ) + .route( + "/stop_workspace_state_stream", + post(handlers::workspace_state::stop_workspace_state_stream), + ) + .route( + "/get_workspace_snapshot", + post(handlers::workspace_state::get_workspace_snapshot), + ) // ─── Window navigation ─── - .route("/open_settings_window", post(handlers::folders::open_settings_window)) - .route("/open_commit_window", post(handlers::folders::open_commit_window)) - .route("/open_merge_window", post(handlers::folders::open_merge_window)) - .route("/open_stash_window", post(handlers::folders::open_stash_window)) - .route("/open_push_window", post(handlers::folders::open_push_window)) + .route( + "/open_settings_window", + post(handlers::folders::open_settings_window), + ) + .route( + "/open_commit_window", + post(handlers::folders::open_commit_window), + ) + .route( + "/open_merge_window", + post(handlers::folders::open_merge_window), + ) + .route( + "/open_stash_window", + post(handlers::folders::open_stash_window), + ) + .route( + "/open_push_window", + post(handlers::folders::open_push_window), + ) // ─── Git (pure) ─── .route("/git_status", post(handlers::git::git_status)) .route("/git_init", post(handlers::git::git_init)) .route("/git_log", post(handlers::git::git_log)) - .route("/git_list_all_branches", post(handlers::git::git_list_all_branches)) + .route( + "/git_list_all_branches", + post(handlers::git::git_list_all_branches), + ) .route("/git_list_branches", post(handlers::git::git_list_branches)) - .route("/git_commit_branches", post(handlers::git::git_commit_branches)) + .route( + "/git_commit_branches", + post(handlers::git::git_commit_branches), + ) .route("/git_show_file", post(handlers::git::git_show_file)) .route("/git_diff", post(handlers::git::git_diff)) - .route("/git_diff_with_branch", post(handlers::git::git_diff_with_branch)) + .route( + "/git_diff_with_branch", + post(handlers::git::git_diff_with_branch), + ) .route("/git_show_diff", post(handlers::git::git_show_diff)) .route("/git_list_remotes", post(handlers::git::git_list_remotes)) .route("/git_add_remote", post(handlers::git::git_add_remote)) .route("/git_remove_remote", post(handlers::git::git_remove_remote)) - .route("/git_set_remote_url", post(handlers::git::git_set_remote_url)) + .route( + "/git_set_remote_url", + post(handlers::git::git_set_remote_url), + ) .route("/git_new_branch", post(handlers::git::git_new_branch)) .route("/git_checkout", post(handlers::git::git_checkout)) .route("/git_delete_branch", post(handlers::git::git_delete_branch)) @@ -83,16 +186,37 @@ pub fn build_router(state: Arc, token: String, static_dir: std::path:: .route("/git_rebase", post(handlers::git::git_rebase)) .route("/git_worktree_add", post(handlers::git::git_worktree_add)) .route("/git_push_info", post(handlers::git::git_push_info)) - .route("/git_start_pull_merge", post(handlers::git::git_start_pull_merge)) - .route("/git_has_merge_head", post(handlers::git::git_has_merge_head)) + .route( + "/git_start_pull_merge", + post(handlers::git::git_start_pull_merge), + ) + .route( + "/git_has_merge_head", + post(handlers::git::git_has_merge_head), + ) .route("/git_is_tracked", post(handlers::git::git_is_tracked)) .route("/git_rollback_file", post(handlers::git::git_rollback_file)) .route("/git_add_files", post(handlers::git::git_add_files)) - .route("/git_list_conflicts", post(handlers::git::git_list_conflicts)) - .route("/git_conflict_file_versions", post(handlers::git::git_conflict_file_versions)) - .route("/git_resolve_conflict", post(handlers::git::git_resolve_conflict)) - .route("/git_abort_operation", post(handlers::git::git_abort_operation)) - .route("/git_continue_operation", post(handlers::git::git_continue_operation)) + .route( + "/git_list_conflicts", + post(handlers::git::git_list_conflicts), + ) + .route( + "/git_conflict_file_versions", + post(handlers::git::git_conflict_file_versions), + ) + .route( + "/git_resolve_conflict", + post(handlers::git::git_resolve_conflict), + ) + .route( + "/git_abort_operation", + post(handlers::git::git_abort_operation), + ) + .route( + "/git_continue_operation", + post(handlers::git::git_continue_operation), + ) .route("/git_stash_push", post(handlers::git::git_stash_push)) .route("/git_stash_pop", post(handlers::git::git_stash_pop)) .route("/git_stash_list", post(handlers::git::git_stash_list)) @@ -106,124 +230,391 @@ pub fn build_router(state: Arc, token: String, static_dir: std::path:: .route("/git_fetch", post(handlers::git::git_fetch)) .route("/git_commit", post(handlers::git::git_commit)) .route("/git_fetch_remote", post(handlers::git::git_fetch_remote)) - .route("/git_delete_remote_branch", post(handlers::git::git_delete_remote_branch)) + .route( + "/git_delete_remote_branch", + post(handlers::git::git_delete_remote_branch), + ) .route("/clone_repository", post(handlers::git::clone_repository)) // ─── Files ─── - .route("/read_file_preview", post(handlers::files::read_file_preview)) + .route( + "/read_file_preview", + post(handlers::files::read_file_preview), + ) .route("/read_file_base64", post(handlers::files::read_file_base64)) - .route("/read_file_for_edit", post(handlers::files::read_file_for_edit)) - .route("/save_file_content", post(handlers::files::save_file_content)) + .route( + "/read_file_for_edit", + post(handlers::files::read_file_for_edit), + ) + .route( + "/save_file_content", + post(handlers::files::save_file_content), + ) .route("/save_file_copy", post(handlers::files::save_file_copy)) - .route("/rename_file_tree_entry", post(handlers::files::rename_file_tree_entry)) - .route("/delete_file_tree_entry", post(handlers::files::delete_file_tree_entry)) - .route("/create_file_tree_entry", post(handlers::files::create_file_tree_entry)) + .route( + "/rename_file_tree_entry", + post(handlers::files::rename_file_tree_entry), + ) + .route( + "/delete_file_tree_entry", + post(handlers::files::delete_file_tree_entry), + ) + .route( + "/create_file_tree_entry", + post(handlers::files::create_file_tree_entry), + ) // ─── Folder commands ─── - .route("/list_folder_commands", post(handlers::folder_commands::list_folder_commands)) - .route("/create_folder_command", post(handlers::folder_commands::create_folder_command)) - .route("/update_folder_command", post(handlers::folder_commands::update_folder_command)) - .route("/delete_folder_command", post(handlers::folder_commands::delete_folder_command)) - .route("/reorder_folder_commands", post(handlers::folder_commands::reorder_folder_commands)) - .route("/bootstrap_folder_commands_from_package_json", post(handlers::folder_commands::bootstrap_folder_commands_from_package_json)) + .route( + "/list_folder_commands", + post(handlers::folder_commands::list_folder_commands), + ) + .route( + "/create_folder_command", + post(handlers::folder_commands::create_folder_command), + ) + .route( + "/update_folder_command", + post(handlers::folder_commands::update_folder_command), + ) + .route( + "/delete_folder_command", + post(handlers::folder_commands::delete_folder_command), + ) + .route( + "/reorder_folder_commands", + post(handlers::folder_commands::reorder_folder_commands), + ) + .route( + "/bootstrap_folder_commands_from_package_json", + post(handlers::folder_commands::bootstrap_folder_commands_from_package_json), + ) // ─── MCP ─── .route("/mcp_scan_local", post(handlers::mcp::mcp_scan_local)) - .route("/mcp_list_marketplaces", post(handlers::mcp::mcp_list_marketplaces)) - .route("/mcp_search_marketplace", post(handlers::mcp::mcp_search_marketplace)) - .route("/mcp_get_marketplace_server_detail", post(handlers::mcp::mcp_get_marketplace_server_detail)) - .route("/mcp_install_from_marketplace", post(handlers::mcp::mcp_install_from_marketplace)) - .route("/mcp_upsert_local_server", post(handlers::mcp::mcp_upsert_local_server)) - .route("/mcp_set_server_apps", post(handlers::mcp::mcp_set_server_apps)) + .route( + "/mcp_list_marketplaces", + post(handlers::mcp::mcp_list_marketplaces), + ) + .route( + "/mcp_search_marketplace", + post(handlers::mcp::mcp_search_marketplace), + ) + .route( + "/mcp_get_marketplace_server_detail", + post(handlers::mcp::mcp_get_marketplace_server_detail), + ) + .route( + "/mcp_install_from_marketplace", + post(handlers::mcp::mcp_install_from_marketplace), + ) + .route( + "/mcp_upsert_local_server", + post(handlers::mcp::mcp_upsert_local_server), + ) + .route( + "/mcp_set_server_apps", + post(handlers::mcp::mcp_set_server_apps), + ) .route("/mcp_remove_server", post(handlers::mcp::mcp_remove_server)) // ─── Version control settings ─── .route("/detect_git", post(handlers::version_control::detect_git)) - .route("/test_git_path", post(handlers::version_control::test_git_path)) - .route("/get_git_settings", post(handlers::version_control::get_git_settings)) - .route("/update_git_settings", post(handlers::version_control::update_git_settings)) - .route("/get_github_accounts", post(handlers::version_control::get_github_accounts)) - .route("/update_github_accounts", post(handlers::version_control::update_github_accounts)) - .route("/validate_github_token", post(handlers::version_control::validate_github_token)) - .route("/save_account_token", post(handlers::version_control::save_account_token)) - .route("/get_account_token", post(handlers::version_control::get_account_token)) - .route("/delete_account_token", post(handlers::version_control::delete_account_token)) + .route( + "/test_git_path", + post(handlers::version_control::test_git_path), + ) + .route( + "/get_git_settings", + post(handlers::version_control::get_git_settings), + ) + .route( + "/update_git_settings", + post(handlers::version_control::update_git_settings), + ) + .route( + "/get_github_accounts", + post(handlers::version_control::get_github_accounts), + ) + .route( + "/update_github_accounts", + post(handlers::version_control::update_github_accounts), + ) + .route( + "/validate_github_token", + post(handlers::version_control::validate_github_token), + ) + .route( + "/save_account_token", + post(handlers::version_control::save_account_token), + ) + .route( + "/get_account_token", + post(handlers::version_control::get_account_token), + ) + .route( + "/delete_account_token", + post(handlers::version_control::delete_account_token), + ) // ─── System settings ─── - .route("/get_system_proxy_settings", post(handlers::system_settings::get_system_proxy_settings)) - .route("/get_system_language_settings", post(handlers::system_settings::get_system_language_settings)) - .route("/update_system_proxy_settings", post(handlers::system_settings::update_system_proxy_settings)) - .route("/update_system_language_settings", post(handlers::system_settings::update_system_language_settings)) + .route( + "/get_system_proxy_settings", + post(handlers::system_settings::get_system_proxy_settings), + ) + .route( + "/get_system_language_settings", + post(handlers::system_settings::get_system_language_settings), + ) + .route( + "/update_system_proxy_settings", + post(handlers::system_settings::update_system_proxy_settings), + ) + .route( + "/update_system_language_settings", + post(handlers::system_settings::update_system_language_settings), + ) // ─── ACP ─── - .route("/acp_get_agent_status", post(handlers::acp::acp_get_agent_status)) + .route( + "/acp_get_agent_status", + post(handlers::acp::acp_get_agent_status), + ) .route("/acp_list_agents", post(handlers::acp::acp_list_agents)) .route("/acp_connect", post(handlers::acp::acp_connect)) .route("/acp_disconnect", post(handlers::acp::acp_disconnect)) .route("/acp_prompt", post(handlers::acp::acp_prompt)) .route("/acp_preflight", post(handlers::acp::acp_preflight)) .route("/acp_set_mode", post(handlers::acp::acp_set_mode)) - .route("/acp_set_config_option", post(handlers::acp::acp_set_config_option)) + .route( + "/acp_set_config_option", + post(handlers::acp::acp_set_config_option), + ) .route("/acp_cancel", post(handlers::acp::acp_cancel)) .route("/acp_fork", post(handlers::acp::acp_fork)) - .route("/acp_respond_permission", post(handlers::acp::acp_respond_permission)) - .route("/acp_list_connections", post(handlers::acp::acp_list_connections)) - .route("/acp_clear_binary_cache", post(handlers::acp::acp_clear_binary_cache)) - .route("/acp_update_agent_preferences", post(handlers::acp::acp_update_agent_preferences)) - .route("/acp_update_agent_env", post(handlers::acp::acp_update_agent_env)) - .route("/acp_update_agent_config", post(handlers::acp::acp_update_agent_config)) - .route("/acp_download_agent_binary", post(handlers::acp::acp_download_agent_binary)) - .route("/acp_detect_agent_local_version", post(handlers::acp::acp_detect_agent_local_version)) - .route("/acp_prepare_npx_agent", post(handlers::acp::acp_prepare_npx_agent)) - .route("/acp_uninstall_agent", post(handlers::acp::acp_uninstall_agent)) - .route("/acp_reorder_agents", post(handlers::acp::acp_reorder_agents)) - .route("/acp_list_agent_skills", post(handlers::acp::acp_list_agent_skills)) - .route("/acp_read_agent_skill", post(handlers::acp::acp_read_agent_skill)) - .route("/acp_save_agent_skill", post(handlers::acp::acp_save_agent_skill)) - .route("/acp_delete_agent_skill", post(handlers::acp::acp_delete_agent_skill)) - .route("/opencode_list_plugins", post(handlers::acp::opencode_list_plugins)) - .route("/opencode_install_plugins", post(handlers::acp::opencode_install_plugins)) - .route("/opencode_uninstall_plugin", post(handlers::acp::opencode_uninstall_plugin)) + .route( + "/acp_respond_permission", + post(handlers::acp::acp_respond_permission), + ) + .route( + "/acp_list_connections", + post(handlers::acp::acp_list_connections), + ) + .route( + "/acp_clear_binary_cache", + post(handlers::acp::acp_clear_binary_cache), + ) + .route( + "/acp_update_agent_preferences", + post(handlers::acp::acp_update_agent_preferences), + ) + .route( + "/acp_update_agent_env", + post(handlers::acp::acp_update_agent_env), + ) + .route( + "/acp_update_agent_config", + post(handlers::acp::acp_update_agent_config), + ) + .route( + "/acp_download_agent_binary", + post(handlers::acp::acp_download_agent_binary), + ) + .route( + "/acp_detect_agent_local_version", + post(handlers::acp::acp_detect_agent_local_version), + ) + .route( + "/acp_prepare_npx_agent", + post(handlers::acp::acp_prepare_npx_agent), + ) + .route( + "/acp_uninstall_agent", + post(handlers::acp::acp_uninstall_agent), + ) + .route( + "/acp_reorder_agents", + post(handlers::acp::acp_reorder_agents), + ) + .route( + "/acp_list_agent_skills", + post(handlers::acp::acp_list_agent_skills), + ) + .route( + "/acp_read_agent_skill", + post(handlers::acp::acp_read_agent_skill), + ) + .route( + "/acp_save_agent_skill", + post(handlers::acp::acp_save_agent_skill), + ) + .route( + "/acp_delete_agent_skill", + post(handlers::acp::acp_delete_agent_skill), + ) + .route( + "/opencode_list_plugins", + post(handlers::acp::opencode_list_plugins), + ) + .route( + "/opencode_install_plugins", + post(handlers::acp::opencode_install_plugins), + ) + .route( + "/opencode_uninstall_plugin", + post(handlers::acp::opencode_uninstall_plugin), + ) // ─── Experts ─── .route("/experts_list", post(handlers::experts::experts_list)) - .route("/experts_list_for_agent", post(handlers::experts::experts_list_for_agent)) - .route("/experts_get_install_status", post(handlers::experts::experts_get_install_status)) - .route("/experts_link_to_agent", post(handlers::experts::experts_link_to_agent)) - .route("/experts_unlink_from_agent", post(handlers::experts::experts_unlink_from_agent)) - .route("/experts_read_content", post(handlers::experts::experts_read_content)) - .route("/experts_open_central_dir", post(handlers::experts::experts_open_central_dir)) + .route( + "/experts_list_for_agent", + post(handlers::experts::experts_list_for_agent), + ) + .route( + "/experts_get_install_status", + post(handlers::experts::experts_get_install_status), + ) + .route( + "/experts_link_to_agent", + post(handlers::experts::experts_link_to_agent), + ) + .route( + "/experts_unlink_from_agent", + post(handlers::experts::experts_unlink_from_agent), + ) + .route( + "/experts_read_content", + post(handlers::experts::experts_read_content), + ) + .route( + "/experts_open_central_dir", + post(handlers::experts::experts_open_central_dir), + ) // ─── Project boot ─── - .route("/detect_package_manager", post(handlers::project_boot::detect_package_manager)) - .route("/create_shadcn_project", post(handlers::project_boot::create_shadcn_project)) + .route( + "/detect_package_manager", + post(handlers::project_boot::detect_package_manager), + ) + .route( + "/create_shadcn_project", + post(handlers::project_boot::create_shadcn_project), + ) // ─── Web Server ─── - .route("/get_web_server_status", post(handlers::web_server::get_web_server_status)) - .route("/start_web_server", post(handlers::web_server::start_web_server)) - .route("/stop_web_server", post(handlers::web_server::stop_web_server)) - .route("/check_app_update", post(handlers::web_server::check_app_update)) + .route( + "/get_web_server_status", + post(handlers::web_server::get_web_server_status), + ) + .route( + "/start_web_server", + post(handlers::web_server::start_web_server), + ) + .route( + "/stop_web_server", + post(handlers::web_server::stop_web_server), + ) + .route( + "/check_app_update", + post(handlers::web_server::check_app_update), + ) // ─── Chat Channels ─── - .route("/list_chat_channels", post(handlers::chat_channel::list_chat_channels)) - .route("/create_chat_channel", post(handlers::chat_channel::create_chat_channel)) - .route("/update_chat_channel", post(handlers::chat_channel::update_chat_channel)) - .route("/delete_chat_channel", post(handlers::chat_channel::delete_chat_channel)) - .route("/save_chat_channel_token", post(handlers::chat_channel::save_chat_channel_token)) - .route("/get_chat_channel_has_token", post(handlers::chat_channel::get_chat_channel_has_token)) - .route("/delete_chat_channel_token", post(handlers::chat_channel::delete_chat_channel_token)) - .route("/connect_chat_channel", post(handlers::chat_channel::connect_chat_channel)) - .route("/disconnect_chat_channel", post(handlers::chat_channel::disconnect_chat_channel)) - .route("/test_chat_channel", post(handlers::chat_channel::test_chat_channel)) - .route("/get_chat_channel_status", post(handlers::chat_channel::get_chat_channel_status)) - .route("/list_chat_channel_messages", post(handlers::chat_channel::list_chat_channel_messages)) - .route("/get_chat_command_prefix", post(handlers::chat_channel::get_chat_command_prefix)) - .route("/set_chat_command_prefix", post(handlers::chat_channel::set_chat_command_prefix)) - .route("/get_chat_event_filter", post(handlers::chat_channel::get_chat_event_filter)) - .route("/set_chat_event_filter", post(handlers::chat_channel::set_chat_event_filter)) - .route("/get_chat_message_language", post(handlers::chat_channel::get_chat_message_language)) - .route("/set_chat_message_language", post(handlers::chat_channel::set_chat_message_language)) - .route("/weixin_get_qrcode", post(handlers::chat_channel::weixin_get_qrcode)) - .route("/weixin_check_qrcode", post(handlers::chat_channel::weixin_check_qrcode)) + .route( + "/list_chat_channels", + post(handlers::chat_channel::list_chat_channels), + ) + .route( + "/create_chat_channel", + post(handlers::chat_channel::create_chat_channel), + ) + .route( + "/update_chat_channel", + post(handlers::chat_channel::update_chat_channel), + ) + .route( + "/delete_chat_channel", + post(handlers::chat_channel::delete_chat_channel), + ) + .route( + "/save_chat_channel_token", + post(handlers::chat_channel::save_chat_channel_token), + ) + .route( + "/get_chat_channel_has_token", + post(handlers::chat_channel::get_chat_channel_has_token), + ) + .route( + "/delete_chat_channel_token", + post(handlers::chat_channel::delete_chat_channel_token), + ) + .route( + "/connect_chat_channel", + post(handlers::chat_channel::connect_chat_channel), + ) + .route( + "/disconnect_chat_channel", + post(handlers::chat_channel::disconnect_chat_channel), + ) + .route( + "/test_chat_channel", + post(handlers::chat_channel::test_chat_channel), + ) + .route( + "/get_chat_channel_status", + post(handlers::chat_channel::get_chat_channel_status), + ) + .route( + "/list_chat_channel_messages", + post(handlers::chat_channel::list_chat_channel_messages), + ) + .route( + "/get_chat_command_prefix", + post(handlers::chat_channel::get_chat_command_prefix), + ) + .route( + "/set_chat_command_prefix", + post(handlers::chat_channel::set_chat_command_prefix), + ) + .route( + "/get_chat_event_filter", + post(handlers::chat_channel::get_chat_event_filter), + ) + .route( + "/set_chat_event_filter", + post(handlers::chat_channel::set_chat_event_filter), + ) + .route( + "/get_chat_message_language", + post(handlers::chat_channel::get_chat_message_language), + ) + .route( + "/set_chat_message_language", + post(handlers::chat_channel::set_chat_message_language), + ) + .route( + "/weixin_get_qrcode", + post(handlers::chat_channel::weixin_get_qrcode), + ) + .route( + "/weixin_check_qrcode", + post(handlers::chat_channel::weixin_check_qrcode), + ) // ─── Model Providers ─── - .route("/list_model_providers", post(handlers::model_provider::list_model_providers)) - .route("/create_model_provider", post(handlers::model_provider::create_model_provider)) - .route("/update_model_provider", post(handlers::model_provider::update_model_provider)) - .route("/delete_model_provider", post(handlers::model_provider::delete_model_provider)) + .route( + "/list_model_providers", + post(handlers::model_provider::list_model_providers), + ) + .route( + "/create_model_provider", + post(handlers::model_provider::create_model_provider), + ) + .route( + "/update_model_provider", + post(handlers::model_provider::update_model_provider), + ) + .route( + "/delete_model_provider", + post(handlers::model_provider::delete_model_provider), + ) // ─── Terminal ─── .route("/terminal_spawn", post(handlers::terminal::terminal_spawn)) .route("/terminal_write", post(handlers::terminal::terminal_write)) - .route("/terminal_resize", post(handlers::terminal::terminal_resize)) + .route( + "/terminal_resize", + post(handlers::terminal::terminal_resize), + ) .route("/terminal_kill", post(handlers::terminal::terminal_kill)) .route("/terminal_list", post(handlers::terminal::terminal_list)) // Catch-all @@ -242,8 +633,8 @@ pub fn build_router(state: Arc, token: String, static_dir: std::path:: // Static file serving. // Next.js static export produces "folder.html" for "/folder" route. // We use a middleware to rewrite "/folder" → "/folder.html" before ServeDir. - let fallback = ServeDir::new(&static_dir) - .fallback(ServeFile::new(static_dir.join("index.html"))); + let fallback = + ServeDir::new(&static_dir).fallback(ServeFile::new(static_dir.join("index.html"))); let static_dir_for_mw = static_dir.clone(); let html_rewrite = middleware::from_fn(move |req: axum::extract::Request, next: Next| { @@ -251,7 +642,11 @@ pub fn build_router(state: Arc, token: String, static_dir: std::path:: async move { let path = req.uri().path(); // If path has no extension (not a file) and a .html version exists, rewrite - if path != "/" && !path.contains('.') && !path.starts_with("/api") && !path.starts_with("/ws") { + if path != "/" + && !path.contains('.') + && !path.starts_with("/api") + && !path.starts_with("/ws") + { let html_path = format!("{}.html", path.trim_end_matches('/')); let html_file = dir.join(html_path.trim_start_matches('/')); if html_file.exists() { diff --git a/src-tauri/src/workspace_state/mod.rs b/src-tauri/src/workspace_state/mod.rs new file mode 100644 index 0000000..18743d4 --- /dev/null +++ b/src-tauri/src/workspace_state/mod.rs @@ -0,0 +1,1090 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::io::Write; +use std::path::{Component, Path, PathBuf}; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::{Duration, Instant}; + +use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use serde::Serialize; +use tokio::sync::mpsc; +use tokio::sync::mpsc::error::TrySendError; + +use crate::app_error::AppCommandError; +use crate::commands::folders::{self, FileTreeNode}; +use crate::web::event_bridge::{emit_event, EventEmitter}; + +pub const WORKSPACE_STATE_PROTOCOL_VERSION: u16 = 1; + +const WATCH_IGNORED_DIRS: &[&str] = &["__pycache__"]; +const WATCH_DEBOUNCE_MS: u64 = 2_000; +const WATCH_MAX_BATCH_WINDOW_MS: u64 = 5_000; +const WATCH_MAX_CHANGED_PATHS: usize = 2_000; +const WATCH_EVENT_CHANNEL_CAPACITY: usize = 2_048; +const RECENT_EVENT_CAPACITY: usize = 24; +const WORKSPACE_TREE_MAX_DEPTH: usize = 2; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WorkspaceGitEntry { + pub path: String, + pub status: String, + pub additions: i32, + pub deletions: i32, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum WorkspaceDelta { + TreeReplace { nodes: Vec }, + GitReplace { entries: Vec }, + Meta { reason: String }, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceDeltaEnvelope { + pub seq: u64, + pub kind: String, + pub payload: Vec, + pub requires_resync: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceStateEvent { + pub root_path: String, + pub seq: u64, + pub version: u16, + pub kind: String, + pub payload: Vec, + pub requires_resync: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceSnapshotResponse { + pub root_path: String, + pub seq: u64, + pub version: u16, + pub full: bool, + pub tree_snapshot: Option>, + pub git_snapshot: Option>, + pub deltas: Vec, +} + +struct WorkspaceStateCore { + root_path: String, + seq: u64, + tree_snapshot: Vec, + git_snapshot: Vec, + recent_events: VecDeque, + recent_capacity: usize, +} + +impl WorkspaceStateCore { + fn new( + root_path: String, + tree_snapshot: Vec, + git_snapshot: Vec, + ) -> Self { + Self { + root_path, + seq: 0, + tree_snapshot, + git_snapshot, + recent_events: VecDeque::new(), + recent_capacity: RECENT_EVENT_CAPACITY, + } + } + + fn append_event( + &mut self, + kind: String, + payload: Vec, + requires_resync: bool, + ) -> WorkspaceStateEvent { + self.seq += 1; + + if !requires_resync { + self.apply_payload(&payload); + } + + let envelope = WorkspaceDeltaEnvelope { + seq: self.seq, + kind: kind.clone(), + payload: payload.clone(), + requires_resync, + }; + self.push_recent_event(envelope); + + WorkspaceStateEvent { + root_path: self.root_path.clone(), + seq: self.seq, + version: WORKSPACE_STATE_PROTOCOL_VERSION, + kind, + payload, + requires_resync, + } + } + + fn snapshot(&self, since_seq: Option) -> WorkspaceSnapshotResponse { + if let Some(since) = since_seq { + if self.can_replay_from(since) { + let deltas = self + .recent_events + .iter() + .filter(|event| event.seq > since) + .cloned() + .collect::>(); + + return WorkspaceSnapshotResponse { + root_path: self.root_path.clone(), + seq: self.seq, + version: WORKSPACE_STATE_PROTOCOL_VERSION, + full: false, + tree_snapshot: None, + git_snapshot: None, + deltas, + }; + } + } + + WorkspaceSnapshotResponse { + root_path: self.root_path.clone(), + seq: self.seq, + version: WORKSPACE_STATE_PROTOCOL_VERSION, + full: true, + tree_snapshot: Some(self.tree_snapshot.clone()), + git_snapshot: Some(self.git_snapshot.clone()), + deltas: Vec::new(), + } + } + + fn apply_payload(&mut self, payload: &[WorkspaceDelta]) { + for delta in payload { + match delta { + WorkspaceDelta::TreeReplace { nodes } => { + self.tree_snapshot = nodes.clone(); + } + WorkspaceDelta::GitReplace { entries } => { + self.git_snapshot = entries.clone(); + } + WorkspaceDelta::Meta { .. } => {} + } + } + } + + fn push_recent_event(&mut self, event: WorkspaceDeltaEnvelope) { + // Tree replace events carry large payloads. Keeping a long history of + // them can cause unnecessary memory growth on large workspaces. + let has_tree_replace = event + .payload + .iter() + .any(|delta| matches!(delta, WorkspaceDelta::TreeReplace { .. })); + if has_tree_replace { + self.recent_events.clear(); + self.recent_events.push_back(event); + return; + } + + self.recent_events.push_back(event); + while self.recent_events.len() > self.recent_capacity { + let _ = self.recent_events.pop_front(); + } + } + + fn can_replay_from(&self, since_seq: u64) -> bool { + if since_seq == self.seq { + return true; + } + + if since_seq > self.seq { + return false; + } + + let Some(first) = self.recent_events.front() else { + return false; + }; + + let min_since = first.seq.saturating_sub(1); + since_seq >= min_since + } +} + +struct WorkspaceStreamEntry { + root_canonical: PathBuf, + root_display: String, + watcher: RecommendedWatcher, + task: tokio::task::JoinHandle<()>, + ref_count: usize, + state: Arc>, +} + +static WORKSPACE_STREAMS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +#[derive(Default)] +struct WatchEventBatch { + changed_paths: HashSet, + has_create: bool, + has_remove: bool, + overflowed: bool, +} + +impl WatchEventBatch { + fn clear(&mut self) { + self.changed_paths.clear(); + self.has_create = false; + self.has_remove = false; + self.overflowed = false; + } + + fn is_empty(&self) -> bool { + !self.overflowed && self.changed_paths.is_empty() + } + + fn ingest_event(&mut self, root_canonical: &Path, event: notify::Event) { + if !should_emit_watch_event(&event.kind) { + return; + } + + if self.overflowed { + return; + } + + let mut has_relevant_path = false; + for path in event.paths { + if is_ignored_watch_path(&path, root_canonical) { + continue; + } + + let relative = if let Ok(relative) = path.strip_prefix(root_canonical) { + normalize_slash_path(relative) + } else { + normalize_slash_path(&path) + }; + + if relative.is_empty() { + continue; + } + + self.changed_paths.insert(relative); + has_relevant_path = true; + if self.changed_paths.len() > WATCH_MAX_CHANGED_PATHS { + self.overflowed = true; + self.changed_paths.clear(); + break; + } + } + + if !has_relevant_path { + return; + } + + match event.kind { + EventKind::Create(_) => self.has_create = true, + EventKind::Remove(_) => self.has_remove = true, + _ => {} + } + } + + fn kind(&self, root_canonical: &Path) -> String { + let has_missing_path = !self.has_remove + && !self.overflowed + && self + .changed_paths + .iter() + .any(|p| !root_canonical.join(p).exists()); + + if self.has_remove || has_missing_path { + "remove".to_string() + } else if self.has_create { + "create".to_string() + } else { + "modify".to_string() + } + } + +} + +fn normalize_slash_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn is_git_metadata_rel_path(path: &str) -> bool { + path == ".git" || path.starts_with(".git/") +} + +fn is_gitignore_rel_path(path: &str) -> bool { + Path::new(path) + .file_name() + .map(|name| name.to_string_lossy() == ".gitignore") + .unwrap_or(false) +} + +fn canonicalize_watch_root(root: &Path) -> Result<(PathBuf, String), AppCommandError> { + let canonical = std::fs::canonicalize(root).map_err(|e| { + AppCommandError::not_found("Unable to resolve workspace root").with_detail(e.to_string()) + })?; + let key = normalize_slash_path(&canonical); + Ok((canonical, key)) +} + +fn is_codeg_edit_temp_path(path: &Path) -> bool { + path.file_name() + .map(|name| { + let name = name.to_string_lossy(); + name.starts_with(".codeg-edit-") && name.ends_with(".tmp") + }) + .unwrap_or(false) +} + +fn git_check_ignored_paths( + repo_path: &str, + paths: &[String], +) -> Result, AppCommandError> { + if paths.is_empty() { + return Ok(HashSet::new()); + } + + let mut child = crate::process::std_command("git") + .args(["check-ignore", "--stdin", "-z"]) + .current_dir(repo_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(AppCommandError::io)?; + + if let Some(mut stdin) = child.stdin.take() { + for path in paths { + stdin.write_all(path.as_bytes()).map_err(AppCommandError::io)?; + stdin.write_all(&[0]).map_err(AppCommandError::io)?; + } + } + + let output = child.wait_with_output().map_err(AppCommandError::io)?; + + // Exit code 1 means "no matches", which is expected. + if !output.status.success() && output.status.code() != Some(1) { + return Err(AppCommandError::external_command( + "git check-ignore failed", + String::from_utf8_lossy(&output.stderr).to_string(), + )); + } + + let mut ignored = HashSet::new(); + for raw in output.stdout.split(|byte| *byte == 0) { + if raw.is_empty() { + continue; + } + ignored.insert(String::from_utf8_lossy(raw).to_string()); + } + Ok(ignored) +} + +async fn should_refresh_git_status_for_paths(root_display: &str, changed_paths: &[String]) -> bool { + if changed_paths.is_empty() { + return true; + } + + let mut candidates: Vec = Vec::new(); + for path in changed_paths { + if is_git_metadata_rel_path(path) || is_gitignore_rel_path(path) { + return true; + } + candidates.push(path.clone()); + } + + if candidates.is_empty() { + return false; + } + + let repo_path = root_display.to_string(); + let candidates_for_check = candidates.clone(); + let ignored = match tokio::task::spawn_blocking(move || { + git_check_ignored_paths(&repo_path, &candidates_for_check) + }) + .await + { + Ok(Ok(ignored)) => ignored, + // Fail safe: if detection fails, keep current behavior and refresh status. + _ => return true, + }; + + candidates + .iter() + .any(|path| !ignored.contains(path.as_str())) +} + +fn is_allowed_git_watch_path(relative: &Path) -> bool { + let mut components = relative.components(); + + let Some(Component::Normal(first)) = components.next() else { + return false; + }; + if first.to_string_lossy() != ".git" { + return false; + } + + let Some(Component::Normal(second)) = components.next() else { + return true; + }; + + let second_name = second.to_string_lossy(); + match second_name.as_ref() { + "HEAD" | "index" | "packed-refs" | "FETCH_HEAD" | "ORIG_HEAD" | "MERGE_HEAD" + | "CHERRY_PICK_HEAD" | "REVERT_HEAD" => true, + "refs" => { + let Some(Component::Normal(scope)) = components.next() else { + return true; + }; + matches!( + scope.to_string_lossy().as_ref(), + "heads" | "remotes" | "stash" + ) + } + "rebase-merge" | "rebase-apply" => true, + _ => false, + } +} + +fn is_ignored_watch_path(path: &Path, root: &Path) -> bool { + let Ok(relative) = path.strip_prefix(root) else { + return false; + }; + + if is_codeg_edit_temp_path(relative) { + return true; + } + + let mut components = relative.components(); + if let Some(Component::Normal(first)) = components.next() { + if first.to_string_lossy() == ".git" { + return !is_allowed_git_watch_path(relative); + } + } + + relative.components().any(|component| { + let Component::Normal(name) = component else { + return false; + }; + let component_name = name.to_string_lossy(); + WATCH_IGNORED_DIRS + .iter() + .any(|ignored| *ignored == component_name.as_ref()) + }) +} + +fn should_emit_watch_event(kind: &EventKind) -> bool { + matches!( + kind, + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) + ) +} + +fn normalize_git_status_path(path: &str) -> String { + let normalized = path.trim().replace('\\', "/"); + if let Some(index) = normalized.rfind(" -> ") { + return normalized[index + 4..] + .trim() + .trim_end_matches('/') + .to_string(); + } + normalized.trim_end_matches('/').to_string() +} + +fn normalize_numstat_path(path: &str) -> String { + let trimmed = path.trim().replace('\\', "/"); + + if let (Some(open), Some(close)) = (trimmed.find('{'), trimmed.rfind('}')) { + if open < close { + let prefix = &trimmed[..open]; + let suffix = &trimmed[close + 1..]; + let inner = &trimmed[open + 1..close]; + if let Some(idx) = inner.find(" => ") { + let right = &inner[idx + 4..]; + return format!("{prefix}{right}{suffix}"); + } + } + } + + if let Some(index) = trimmed.rfind(" => ") { + return trimmed[index + 4..].to_string(); + } + + trimmed +} + +fn parse_numstat_value(raw: &str) -> i32 { + raw.trim().parse::().unwrap_or(0) +} + +async fn git_numstat_map(path: &str) -> HashMap { + async fn run_numstat(path: &str, args: &[&str]) -> Option> { + let output = crate::process::tokio_command("git") + .args(args) + .current_dir(path) + .output() + .await + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut map = HashMap::new(); + for line in stdout.lines() { + if line.trim().is_empty() { + continue; + } + let mut parts = line.splitn(3, '\t'); + let Some(add_raw) = parts.next() else { + continue; + }; + let Some(del_raw) = parts.next() else { + continue; + }; + let Some(path_raw) = parts.next() else { + continue; + }; + let parsed_path = normalize_numstat_path(path_raw); + if parsed_path.is_empty() { + continue; + } + map.insert( + parsed_path, + (parse_numstat_value(add_raw), parse_numstat_value(del_raw)), + ); + } + + Some(map) + } + + if let Some(map) = run_numstat(path, &["diff", "--numstat", "HEAD"]).await { + return map; + } + + run_numstat(path, &["diff", "--numstat", "--cached"]) + .await + .unwrap_or_default() +} + +async fn collect_git_snapshot(path: &str) -> Result, AppCommandError> { + let status_entries = folders::git_status(path.to_string(), Some(true)).await?; + + let stats = git_numstat_map(path).await; + + let mut result = status_entries + .into_iter() + .filter_map(|entry| { + let normalized_path = normalize_git_status_path(&entry.file); + if normalized_path.is_empty() { + return None; + } + let (additions, deletions) = stats.get(&normalized_path).cloned().unwrap_or((0, 0)); + Some(WorkspaceGitEntry { + path: normalized_path, + status: entry.status, + additions, + deletions, + }) + }) + .collect::>(); + + result.sort_by(|a, b| { + a.path + .to_lowercase() + .cmp(&b.path.to_lowercase()) + .then(a.path.cmp(&b.path)) + }); + + Ok(result) +} + +async fn flush_watch_batch( + state: &Arc>, + emitter: &EventEmitter, + root_display: &str, + root_canonical: &Path, + batch: &WatchEventBatch, +) { + if batch.is_empty() { + return; + } + + let event_kind_hint = batch.kind(root_canonical); + let changed_paths = if batch.overflowed { + Vec::new() + } else { + let mut paths = batch.changed_paths.iter().cloned().collect::>(); + paths.sort(); + paths + }; + + let should_refresh_tree = batch.overflowed || event_kind_hint != "modify"; + let should_refresh_git = if batch.overflowed { + true + } else { + should_refresh_git_status_for_paths(root_display, &changed_paths).await + }; + + let mut payload = Vec::new(); + let mut requires_resync = false; + let mut refreshed_tree: Option> = None; + let mut refreshed_git: Option> = None; + + if should_refresh_tree { + match folders::get_file_tree(root_display.to_string(), Some(WORKSPACE_TREE_MAX_DEPTH)).await + { + Ok(tree) => refreshed_tree = Some(tree), + Err(_) => requires_resync = true, + } + } + + if should_refresh_git { + match collect_git_snapshot(root_display).await { + Ok(git_snapshot) => refreshed_git = Some(git_snapshot), + Err(_) => requires_resync = true, + } + } + + if requires_resync { + payload = vec![WorkspaceDelta::Meta { + reason: format!("watch_refresh_failed:{event_kind_hint}"), + }]; + } + + let event = { + let mut guard = match state.lock() { + Ok(guard) => guard, + Err(_) => return, + }; + + if !requires_resync { + if let Some(tree) = refreshed_tree { + if tree != guard.tree_snapshot { + payload.push(WorkspaceDelta::TreeReplace { nodes: tree }); + } + } + if let Some(git_snapshot) = refreshed_git { + if git_snapshot != guard.git_snapshot { + payload.push(WorkspaceDelta::GitReplace { + entries: git_snapshot, + }); + } + } + + if payload.is_empty() { + return; + } + } + + let kind = if requires_resync { + "resync_hint".to_string() + } else if payload + .iter() + .any(|delta| matches!(delta, WorkspaceDelta::TreeReplace { .. })) + { + "fs_delta".to_string() + } else if payload + .iter() + .any(|delta| matches!(delta, WorkspaceDelta::GitReplace { .. })) + { + "git_delta".to_string() + } else { + "meta".to_string() + }; + + guard.append_event(kind, payload, requires_resync) + }; + + emit_event(emitter, "folder://workspace-state-event", event); +} + +async fn run_workspace_watch_event_loop( + mut event_rx: mpsc::Receiver, + dropped_events: Arc, + state: Arc>, + emitter: EventEmitter, + root_display: String, + root_canonical: PathBuf, +) { + let debounce = Duration::from_millis(WATCH_DEBOUNCE_MS); + let max_batch_window = Duration::from_millis(WATCH_MAX_BATCH_WINDOW_MS); + let mut batch = WatchEventBatch::default(); + let mut batch_started_at: Option = None; + + loop { + if dropped_events.swap(false, Ordering::AcqRel) { + batch.overflowed = true; + if batch_started_at.is_none() { + batch_started_at = Some(Instant::now()); + } + } + + if batch.is_empty() { + match event_rx.recv().await { + Some(event) => { + batch.ingest_event(&root_canonical, event); + if !batch.is_empty() { + batch_started_at = Some(Instant::now()); + } + } + None => break, + } + } else { + match tokio::time::timeout(debounce, event_rx.recv()).await { + Ok(Some(event)) => { + batch.ingest_event(&root_canonical, event); + } + Ok(None) => { + flush_watch_batch(&state, &emitter, &root_display, &root_canonical, &batch) + .await; + break; + } + Err(_) => { + flush_watch_batch(&state, &emitter, &root_display, &root_canonical, &batch) + .await; + batch.clear(); + batch_started_at = None; + continue; + } + } + } + + while let Ok(next_event) = event_rx.try_recv() { + batch.ingest_event(&root_canonical, next_event); + } + + if dropped_events.swap(false, Ordering::AcqRel) { + batch.overflowed = true; + if batch_started_at.is_none() { + batch_started_at = Some(Instant::now()); + } + } + + let should_flush = batch_started_at + .map(|started| started.elapsed() >= max_batch_window) + .unwrap_or(false); + + if should_flush { + flush_watch_batch(&state, &emitter, &root_display, &root_canonical, &batch).await; + batch.clear(); + batch_started_at = None; + } + } + + if !batch.is_empty() { + flush_watch_batch(&state, &emitter, &root_display, &root_canonical, &batch).await; + } +} + +pub async fn start_workspace_state_stream_core( + emitter: EventEmitter, + root_path: String, +) -> Result { + let root = PathBuf::from(&root_path); + if !root.exists() || !root.is_dir() { + return Err(AppCommandError::not_found("Folder does not exist")); + } + + let (root_canonical, key) = canonicalize_watch_root(&root)?; + + { + let mut streams = WORKSPACE_STREAMS.lock().map_err(|_| { + AppCommandError::task_execution_failed("Failed to lock workspace stream registry") + })?; + if let Some(entry) = streams.get_mut(&key) { + entry.ref_count += 1; + let snapshot = entry.state.lock().map_err(|_| { + AppCommandError::task_execution_failed("Failed to lock workspace state snapshot") + })?; + return Ok(snapshot.snapshot(None)); + } + } + + let initial_tree = folders::get_file_tree(root_path.clone(), Some(WORKSPACE_TREE_MAX_DEPTH)) + .await + .unwrap_or_default(); + let initial_git = collect_git_snapshot(&root_path).await.unwrap_or_default(); + + let state = Arc::new(Mutex::new(WorkspaceStateCore::new( + root_path.clone(), + initial_tree, + initial_git, + ))); + + let (event_tx, event_rx) = mpsc::channel::(WATCH_EVENT_CHANNEL_CAPACITY); + let dropped_events = Arc::new(AtomicBool::new(false)); + + let state_for_task = Arc::clone(&state); + let emitter_for_task = emitter.clone(); + let root_display_for_task = root_path.clone(); + let root_canonical_for_task = root_canonical.clone(); + let dropped_events_for_task = Arc::clone(&dropped_events); + let mut task = Some(tokio::spawn(async move { + run_workspace_watch_event_loop( + event_rx, + dropped_events_for_task, + state_for_task, + emitter_for_task, + root_display_for_task, + root_canonical_for_task, + ) + .await; + })); + + let root_display_for_error = root_path.clone(); + let dropped_events_for_callback = Arc::clone(&dropped_events); + let mut watcher = Some( + notify::recommended_watcher( + move |result: Result| match result { + Ok(event) => { + match event_tx.try_send(event) { + Ok(()) => {} + Err(TrySendError::Full(_)) => { + dropped_events_for_callback.store(true, Ordering::Release); + } + Err(TrySendError::Closed(_)) => {} + } + } + Err(err) => { + eprintln!( + "[workspace-state-watch] failed event for {}: {}", + root_display_for_error, err + ); + } + }, + ) + .map_err(|e| { + AppCommandError::io_error("Failed to create workspace state watcher") + .with_detail(e.to_string()) + })?, + ); + + watcher + .as_mut() + .ok_or_else(|| AppCommandError::task_execution_failed("Failed to create watcher"))? + .watch(&root_canonical, RecursiveMode::Recursive) + .map_err(|e| { + AppCommandError::io_error("Failed to start workspace state watcher") + .with_detail(e.to_string()) + })?; + + let (should_cleanup_new_stream, start_snapshot) = { + let mut streams = WORKSPACE_STREAMS.lock().map_err(|_| { + AppCommandError::task_execution_failed("Failed to lock workspace stream registry") + })?; + + if let Some(entry) = streams.get_mut(&key) { + entry.ref_count += 1; + let snapshot = entry.state.lock().map_err(|_| { + AppCommandError::task_execution_failed("Failed to lock workspace state snapshot") + })?; + (true, snapshot.snapshot(None)) + } else { + let snapshot = state + .lock() + .map_err(|_| { + AppCommandError::task_execution_failed( + "Failed to lock workspace state snapshot", + ) + })? + .snapshot(None); + streams.insert( + key, + WorkspaceStreamEntry { + root_canonical: root_canonical.clone(), + root_display: root_path, + watcher: watcher.take().ok_or_else(|| { + AppCommandError::task_execution_failed( + "Failed to initialize workspace state watcher", + ) + })?, + task: task.take().ok_or_else(|| { + AppCommandError::task_execution_failed( + "Failed to initialize workspace state task", + ) + })?, + ref_count: 1, + state: Arc::clone(&state), + }, + ); + (false, snapshot) + } + }; + + if should_cleanup_new_stream { + if let Some(mut created_watcher) = watcher.take() { + let _ = created_watcher.unwatch(&root_canonical); + } + if let Some(created_task) = task.take() { + created_task.abort(); + } + } + + Ok(start_snapshot) +} + +pub async fn stop_workspace_state_stream_core(root_path: String) -> Result<(), AppCommandError> { + let root = PathBuf::from(&root_path); + let key = canonicalize_watch_root(&root) + .map(|(_, key)| key) + .unwrap_or_else(|_| normalize_slash_path(&root)); + + let mut streams = WORKSPACE_STREAMS.lock().map_err(|_| { + AppCommandError::task_execution_failed("Failed to lock workspace stream registry") + })?; + + let target_key = if streams.contains_key(&key) { + Some(key) + } else { + streams.iter().find_map(|(candidate_key, entry)| { + if entry.root_display == root_path { + Some(candidate_key.clone()) + } else { + None + } + }) + }; + + let Some(target_key) = target_key else { + return Ok(()); + }; + + if let Some(entry) = streams.get_mut(&target_key) { + if entry.ref_count > 1 { + entry.ref_count -= 1; + return Ok(()); + } + } + + let mut removed_entry = streams.remove(&target_key); + drop(streams); + + if let Some(mut entry) = removed_entry.take() { + let _ = entry.watcher.unwatch(&entry.root_canonical); + drop(entry.watcher); + entry.task.abort(); + } + + Ok(()) +} + +pub async fn get_workspace_snapshot_core( + root_path: String, + since_seq: Option, +) -> Result { + let root = PathBuf::from(&root_path); + let key = canonicalize_watch_root(&root) + .map(|(_, key)| key) + .unwrap_or_else(|_| normalize_slash_path(&root)); + + let state = { + let streams = WORKSPACE_STREAMS.lock().map_err(|_| { + AppCommandError::task_execution_failed("Failed to lock workspace stream registry") + })?; + + let by_key = streams.get(&key).map(|entry| Arc::clone(&entry.state)); + if let Some(found) = by_key { + found + } else if let Some(found) = streams + .values() + .find(|entry| entry.root_display == root_path) + .map(|entry| Arc::clone(&entry.state)) + { + found + } else { + return Err(AppCommandError::not_found( + "Workspace stream is not running for this root", + )); + } + }; + + let guard = state.lock().map_err(|_| { + AppCommandError::task_execution_failed("Failed to lock workspace state snapshot") + })?; + + Ok(guard.snapshot(since_seq)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspace_state_core_seq_is_monotonic() { + let mut core = WorkspaceStateCore::new("/tmp/repo".to_string(), Vec::new(), Vec::new()); + + let e1 = core.append_event( + "meta".to_string(), + vec![WorkspaceDelta::Meta { + reason: "boot".to_string(), + }], + false, + ); + + let e2 = core.append_event( + "meta".to_string(), + vec![WorkspaceDelta::Meta { + reason: "tick".to_string(), + }], + false, + ); + + assert!(e2.seq > e1.seq); + } + + #[test] + fn workspace_state_core_snapshot_incremental_when_since_available() { + let mut core = WorkspaceStateCore::new("/tmp/repo".to_string(), Vec::new(), Vec::new()); + + let e1 = core.append_event( + "meta".to_string(), + vec![WorkspaceDelta::Meta { + reason: "a".to_string(), + }], + false, + ); + + core.append_event( + "meta".to_string(), + vec![WorkspaceDelta::Meta { + reason: "b".to_string(), + }], + false, + ); + + let snapshot = core.snapshot(Some(e1.seq)); + assert!(!snapshot.full); + assert_eq!(snapshot.deltas.len(), 1); + assert!(snapshot.tree_snapshot.is_none()); + assert!(snapshot.git_snapshot.is_none()); + } + + #[test] + fn workspace_state_core_snapshot_full_when_since_too_old() { + let mut core = WorkspaceStateCore::new("/tmp/repo".to_string(), Vec::new(), Vec::new()); + core.recent_capacity = 1; + + core.append_event( + "meta".to_string(), + vec![WorkspaceDelta::Meta { + reason: "a".to_string(), + }], + false, + ); + core.append_event( + "meta".to_string(), + vec![WorkspaceDelta::Meta { + reason: "b".to_string(), + }], + false, + ); + + let snapshot = core.snapshot(Some(0)); + assert!(snapshot.full); + assert!(snapshot.tree_snapshot.is_some()); + assert!(snapshot.git_snapshot.is_some()); + } +} diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index 4f410ff..b6dd391 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -8,23 +8,23 @@ import { useState, type ReactNode, } from "react" -import { revealItemInDir, subscribe } from "@/lib/platform" +import { revealItemInDir } from "@/lib/platform" import ignore from "ignore" import { Check, ChevronRight } from "lucide-react" import { useTranslations } from "next-intl" -import { toErrorMessage } from "@/lib/app-error" import { toast } from "sonner" import { useFolderContext } from "@/contexts/folder-context" import { useAuxPanelContext } from "@/contexts/aux-panel-context" import { useTabContext } from "@/contexts/tab-context" import { useTerminalContext } from "@/contexts/terminal-context" import { useWorkspaceContext } from "@/contexts/workspace-context" +import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store" import { createFileTreeEntry, deleteFileTreeEntry, gitAddFiles, - getGitBranch, getFileTree, + getGitBranch, gitListAllBranches, gitRollbackFile, gitStatus, @@ -33,17 +33,10 @@ import { openCommitWindow, renameFileTreeEntry, saveFileCopy, - startFileTreeWatch, - stopFileTreeWatch, } from "@/lib/api" import { emitAttachFileToSession } from "@/lib/session-attachment-events" import { ScrollArea } from "@/components/ui/scroll-area" -import type { - FileTreeChangedEvent, - FileTreeNode, - GitBranchList, - GitStatusEntry, -} from "@/lib/types" +import type { FileTreeNode, GitBranchList, GitStatusEntry } from "@/lib/types" import { FileTree, FileTreeFolder, @@ -101,6 +94,7 @@ function baseName(path: string): string { const FILE_TREE_ROOT_PATH = "__workspace_root__" const GITIGNORE_MUTED_CLASS = "text-muted-foreground/55" +const FILE_TREE_LAZY_DEBUG_LOG = process.env.NODE_ENV === "development" interface FileActionTarget { kind: "file" | "dir" @@ -135,9 +129,78 @@ function normalizeComparePath(path: string): string { return path.replace(/\\/g, "/").replace(/\/+$/, "") } -function isGitMetadataPath(path: string): boolean { - const normalized = normalizeComparePath(path) - return normalized === ".git" || normalized.startsWith(".git/") +function logFileTreeLazyDebug( + message: string, + payload?: Record +) { + if (!FILE_TREE_LAZY_DEBUG_LOG) return + if (payload) { + console.info(`[FileTreeTab/lazy] ${message}`, payload) + return + } + console.info(`[FileTreeTab/lazy] ${message}`) +} + +function logFileTreeWorkspaceDebug( + message: string, + payload?: Record +) { + if (!FILE_TREE_LAZY_DEBUG_LOG) return + if (payload) { + console.info(`[FileTreeTab/workspace] ${message}`, payload) + return + } + console.info(`[FileTreeTab/workspace] ${message}`) +} + +function prefixFileTreeNodePaths( + nodes: FileTreeNode[], + prefix: string +): FileTreeNode[] { + return nodes.map((node) => { + const nextPath = prefix ? `${prefix}/${node.path}` : node.path + if (node.kind === "file") { + return { + ...node, + path: nextPath, + } + } + return { + ...node, + path: nextPath, + children: prefixFileTreeNodePaths(node.children, nextPath), + } + }) +} + +function applyLazyTreeOverrides( + nodes: FileTreeNode[], + overrides: ReadonlyMap +): FileTreeNode[] { + return nodes.map((node) => { + if (node.kind === "file") return node + const overrideChildren = overrides.get(node.path) + const baseChildren = overrideChildren ?? node.children + return { + ...node, + children: applyLazyTreeOverrides(baseChildren, overrides), + } + }) +} + +function findDirectoryChildren( + nodes: FileTreeNode[], + targetPath: string +): FileTreeNode[] | null { + for (const node of nodes) { + if (node.kind !== "dir") continue + if (normalizeComparePath(node.path) === targetPath) { + return node.children + } + const nested = findDirectoryChildren(node.children, targetPath) + if (nested) return nested + } + return null } function classifyGitFileState(status: string): GitFileState | null { @@ -180,11 +243,6 @@ function hasIgnoredAncestor(path: string, ignoredPaths: ReadonlySet) { } } -function getRelativePathDepth(path: string): number { - if (!path) return 0 - return path.split("/").filter(Boolean).length -} - type DirectoryGitAction = "add" | "rollback" interface DirectoryGitCandidateEntry { @@ -762,8 +820,7 @@ function RenderNode({ export function FileTreeTab() { const t = useTranslations("Folder.fileTreeTab") const tCommon = useTranslations("Folder.common") - const { activeTab, pendingRevealPath, consumePendingRevealPath } = - useAuxPanelContext() + const { pendingRevealPath, consumePendingRevealPath } = useAuxPanelContext() const { folder } = useFolderContext() const { tabs, activeTabId } = useTabContext() const { createTerminalInDirectory } = useTerminalContext() @@ -775,6 +832,7 @@ export function FileTreeTab() { openFilePreview, openWorkingTreeDiff, } = useWorkspaceContext() + const workspaceState = useWorkspaceStateStore(folder?.path ?? null) const [nodes, setNodes] = useState([]) const [gitStatusByPath, setGitStatusByPath] = useState>( new Map() @@ -841,25 +899,21 @@ export function FileTreeTab() { const [expandedPaths, setExpandedPaths] = useState>( () => new Set([FILE_TREE_ROOT_PATH]) ) - const [loadedTreeDepth, setLoadedTreeDepth] = useState(1) const [gitignoreIgnoredPaths, setGitignoreIgnoredPaths] = useState< Set >(new Set()) - const isFileTreeTabActive = activeTab === "file_tree" const activeFileTabRef = useRef(activeFileTab) const filePathSetRef = useRef>(new Set()) - const loadedTreeDepthRef = useRef(1) - const isFileTreeTabActiveRef = useRef(isFileTreeTabActive) - const pendingTreeRefreshRef = useRef(false) - const pendingTreeRefreshNeedsStatusRef = useRef(false) - const pendingStatusRefreshRef = useRef(false) - const treeRefreshNeedsStatusRef = useRef(false) - const externalConflictSignatureByPathRef = useRef>( + const previousWorkspaceSeqRef = useRef(0) + const previousExpandedPathsRef = useRef>( + new Set([FILE_TREE_ROOT_PATH]) + ) + const lazyLoadedChildrenByPathRef = useRef>( new Map() ) - const treeRefreshTimerRef = useRef | null>(null) - const statusRefreshTimerRef = useRef | null>( - null + const lazyLoadingDirPathsRef = useRef>(new Set()) + const externalConflictSignatureByPathRef = useRef>( + new Map() ) useEffect(() => { @@ -868,12 +922,14 @@ export function FileTreeTab() { useEffect(() => { setExpandedPaths(new Set([FILE_TREE_ROOT_PATH])) - loadedTreeDepthRef.current = 1 - setLoadedTreeDepth(1) + previousExpandedPathsRef.current = new Set([FILE_TREE_ROOT_PATH]) setGitignoreIgnoredPaths(new Set()) setExternalConflictPrompt(null) setSavingExternalConflictCopy(false) + lazyLoadedChildrenByPathRef.current.clear() + lazyLoadingDirPathsRef.current.clear() externalConflictSignatureByPathRef.current.clear() + previousWorkspaceSeqRef.current = 0 }, [folder?.path]) // Handle pending reveal path: expand all ancestor directories once tree is loaded @@ -909,10 +965,6 @@ export function FileTreeTab() { ) }, [activeFileTab]) - useEffect(() => { - loadedTreeDepthRef.current = loadedTreeDepth - }, [loadedTreeDepth]) - const activeSessionTabId = useMemo(() => { const activeTab = tabs.find((tab) => tab.id === activeTabId) if (!activeTab) return null @@ -922,39 +974,6 @@ export function FileTreeTab() { return activeTab.id }, [tabs, activeTabId]) - const applyGitStatusResult = useCallback( - (entries: { file: string; status: string }[]) => { - const nextStatusByPath = new Map() - for (const entry of entries) { - const raw = normalizeGitStatusPath(entry.file) - if (!raw) continue - // Strip trailing slash (directory entries from -unormal) - const normalizedPath = raw.replace(/\/+$/, "") - if (!normalizedPath) continue - nextStatusByPath.set(normalizedPath, entry.status) - } - setGitEnabled(true) - setGitStatusByPath(nextStatusByPath) - }, - [] - ) - - const refreshGitStatus = useCallback(async () => { - if (!folder?.path) { - setGitStatusByPath(new Map()) - setGitEnabled(false) - return - } - - try { - const result = await gitStatus(folder.path) - applyGitStatusResult(result) - } catch { - setGitEnabled(false) - setGitStatusByPath(new Map()) - } - }, [applyGitStatusResult, folder?.path]) - const fetchTree = useCallback( async (options?: { skipTree?: boolean @@ -962,103 +981,140 @@ export function FileTreeTab() { silent?: boolean maxDepth?: number }) => { + void options if (!folder?.path) { setNodes([]) - loadedTreeDepthRef.current = 1 - setLoadedTreeDepth(1) setGitStatusByPath(new Map()) setGitEnabled(false) setLoading(false) + setError(null) return } - const skipTree = options?.skipTree ?? false - const skipStatus = options?.skipStatus ?? false - const silent = options?.silent ?? false - const maxDepth = options?.maxDepth ?? loadedTreeDepthRef.current - - if (!silent) setLoading(true) - setError(null) - let loadingReleased = false - - try { - if (skipTree) { - if (!skipStatus) { - await refreshGitStatus() - } - return - } - - if (skipStatus) { - const treeResult = await getFileTree(folder.path, maxDepth) - setNodes(treeResult) - setLoadedTreeDepth((prev) => { - const next = Math.max(prev, maxDepth) - loadedTreeDepthRef.current = next - return next - }) - return - } - - const treePromise = getFileTree(folder.path, maxDepth) - const gitStatusPromise = gitStatus(folder.path) - const treeResult = await treePromise - setNodes(treeResult) - setLoadedTreeDepth((prev) => { - const next = Math.max(prev, maxDepth) - loadedTreeDepthRef.current = next - return next - }) - - // Show file tree as soon as it's ready; git status can follow. - if (!silent) { - setLoading(false) - loadingReleased = true - } - - try { - const gitStatusResult = await gitStatusPromise - applyGitStatusResult(gitStatusResult) - } catch { - setGitEnabled(false) - setGitStatusByPath(new Map()) - } - } catch (e) { - setError(toErrorMessage(e)) - } finally { - if (!silent && !loadingReleased) setLoading(false) - } + await workspaceState.requestResync("manual_refresh") }, - [applyGitStatusResult, folder?.path, refreshGitStatus] + [folder?.path, workspaceState] ) useEffect(() => { - isFileTreeTabActiveRef.current = isFileTreeTabActive - if (!isFileTreeTabActive) return - - if (pendingTreeRefreshRef.current) { - const needsStatus = - pendingTreeRefreshNeedsStatusRef.current || - pendingStatusRefreshRef.current - pendingTreeRefreshRef.current = false - pendingTreeRefreshNeedsStatusRef.current = false - pendingStatusRefreshRef.current = false - void fetchTree({ silent: true, skipStatus: !needsStatus }) - return + setNodes( + applyLazyTreeOverrides( + workspaceState.tree, + lazyLoadedChildrenByPathRef.current + ) + ) + const nextStatusByPath = new Map() + for (const entry of workspaceState.git) { + nextStatusByPath.set(entry.path, entry.status) } + setGitStatusByPath(nextStatusByPath) + setGitEnabled(true) + setLoading( + workspaceState.health === "resyncing" && workspaceState.seq === 0 + ) + setError(workspaceState.health === "degraded" ? workspaceState.error : null) - if (pendingStatusRefreshRef.current) { - pendingStatusRefreshRef.current = false - void fetchTree({ skipTree: true, silent: true }) - } - }, [fetchTree, isFileTreeTabActive]) + logFileTreeWorkspaceDebug("workspace state consumed", { + rootPath: folder?.path ?? "", + seq: workspaceState.seq, + health: workspaceState.health, + treeRoots: workspaceState.tree.length, + gitEntries: workspaceState.git.length, + lazyOverrideDirs: lazyLoadedChildrenByPathRef.current.size, + }) + }, [ + folder?.path, + workspaceState.error, + workspaceState.git, + workspaceState.health, + workspaceState.seq, + workspaceState.tree, + ]) + + const loadDirectoryChildren = useCallback( + async (dirPath: string) => { + const rootPath = folder?.path + if (!rootPath) return + const normalizedDirPath = normalizeComparePath(dirPath) + if (!normalizedDirPath) return + if (lazyLoadedChildrenByPathRef.current.has(normalizedDirPath)) { + logFileTreeLazyDebug("skip cached", { + rootPath, + dirPath: normalizedDirPath, + }) + return + } + if (lazyLoadingDirPathsRef.current.has(normalizedDirPath)) { + logFileTreeLazyDebug("skip in-flight", { + rootPath, + dirPath: normalizedDirPath, + }) + return + } + + const existingChildren = findDirectoryChildren(nodes, normalizedDirPath) + if (existingChildren && existingChildren.length > 0) { + lazyLoadedChildrenByPathRef.current.set( + normalizedDirPath, + existingChildren + ) + logFileTreeLazyDebug("skip use existing children", { + rootPath, + dirPath: normalizedDirPath, + childrenCount: existingChildren.length, + }) + return + } + + lazyLoadingDirPathsRef.current.add(normalizedDirPath) + const startedAt = performance.now() + logFileTreeLazyDebug("request start", { + rootPath, + dirPath: normalizedDirPath, + }) + try { + const subtree = await getFileTree( + joinFsPath(rootPath, normalizedDirPath), + 1 + ) + const prefixed = prefixFileTreeNodePaths(subtree, normalizedDirPath) + lazyLoadedChildrenByPathRef.current.set(normalizedDirPath, prefixed) + setNodes((prev) => + applyLazyTreeOverrides(prev, lazyLoadedChildrenByPathRef.current) + ) + logFileTreeLazyDebug("request success", { + rootPath, + dirPath: normalizedDirPath, + childrenCount: prefixed.length, + durationMs: Math.round(performance.now() - startedAt), + }) + } catch { + // Ignore lazy load failures and keep current collapsed/empty state. + logFileTreeLazyDebug("request failed", { + rootPath, + dirPath: normalizedDirPath, + durationMs: Math.round(performance.now() - startedAt), + }) + } finally { + lazyLoadingDirPathsRef.current.delete(normalizedDirPath) + } + }, + [folder?.path, nodes] + ) useEffect(() => { - pendingTreeRefreshRef.current = false - pendingTreeRefreshNeedsStatusRef.current = false - pendingStatusRefreshRef.current = false - treeRefreshNeedsStatusRef.current = false - }, [folder?.path]) + const previousExpanded = previousExpandedPathsRef.current + for (const path of expandedPaths) { + if (path === FILE_TREE_ROOT_PATH) continue + if (previousExpanded.has(path)) continue + logFileTreeLazyDebug("expanded path detected", { + rootPath: folder?.path ?? "", + dirPath: path, + }) + void loadDirectoryChildren(path) + } + previousExpandedPathsRef.current = new Set(expandedPaths) + }, [expandedPaths, folder?.path, loadDirectoryChildren]) const filePathSet = useMemo(() => { const paths = new Set() @@ -1100,15 +1156,6 @@ export function FileTreeTab() { return Array.from(dirs) }, [expandedPaths]) - const desiredTreeDepth = useMemo(() => { - let nextDepth = 1 - for (const path of expandedPaths) { - if (path === FILE_TREE_ROOT_PATH) continue - nextDepth = Math.max(nextDepth, getRelativePathDepth(path) + 1) - } - return nextDepth - }, [expandedPaths]) - useEffect(() => { filePathSetRef.current = filePathSet }, [filePathSet]) @@ -1873,117 +1920,22 @@ export function FileTreeTab() { [rootNodeName] ) - useEffect(() => { - if (!isFileTreeTabActive) return - void fetchTree() - }, [fetchTree, isFileTreeTabActive]) - - useEffect(() => { - if (!isFileTreeTabActive || !folder?.path) return - if (desiredTreeDepth <= loadedTreeDepth) return - void fetchTree({ silent: true, maxDepth: desiredTreeDepth }) - }, [ - desiredTreeDepth, - fetchTree, - folder?.path, - isFileTreeTabActive, - loadedTreeDepth, - ]) - - useEffect(() => { - const rootPath = folder?.path - if (!rootPath) return - - let unlisten: (() => void) | null = null - let disposed = false - let watchStarted = false - let watchReleased = false - const normalizedRootPath = normalizeComparePath(rootPath) - - const releaseWatch = () => { - if (watchReleased) return - watchReleased = true - if (unlisten) { - unlisten() - unlisten = null + type ActiveFileChangeDecision = + | { kind: "none" } + | { kind: "reload"; path: string } + | { + kind: "conflict" + path: string + diskContent: string + unsavedContent: string + signature: string } - if (watchStarted) { - void stopFileTreeWatch(rootPath) - } - } - const scheduleTreeRefresh = (refreshGitStatus: boolean) => { - if (!isFileTreeTabActiveRef.current) { - pendingTreeRefreshRef.current = true - pendingTreeRefreshNeedsStatusRef.current = - pendingTreeRefreshNeedsStatusRef.current || refreshGitStatus - if (refreshGitStatus) { - pendingStatusRefreshRef.current = false - } - return - } - treeRefreshNeedsStatusRef.current = - treeRefreshNeedsStatusRef.current || refreshGitStatus - if (treeRefreshTimerRef.current) { - clearTimeout(treeRefreshTimerRef.current) - } - treeRefreshTimerRef.current = setTimeout(() => { - const needsStatus = treeRefreshNeedsStatusRef.current - treeRefreshNeedsStatusRef.current = false - void fetchTree({ silent: true, skipStatus: !needsStatus }) - }, 180) - } + const resolveActiveFileChangeDecision = useCallback( + async (path: string): Promise => { + const rootPath = folder?.path + if (!rootPath) return { kind: "none" } - const scheduleStatusRefresh = () => { - if (!isFileTreeTabActiveRef.current) { - if (pendingTreeRefreshRef.current) { - pendingTreeRefreshNeedsStatusRef.current = true - } else { - pendingStatusRefreshRef.current = true - } - return - } - if (statusRefreshTimerRef.current) { - clearTimeout(statusRefreshTimerRef.current) - } - statusRefreshTimerRef.current = setTimeout(() => { - void fetchTree({ skipTree: true, silent: true }) - }, 120) - } - - const getActiveChangedFilePath = ( - changedPaths: string[], - fullReload: boolean - ) => { - if (fullReload) return null - const currentTab = activeFileTabRef.current - if (!currentTab || currentTab.kind !== "file") return null - if (!currentTab.path || currentTab.loading) return null - - const normalizedActivePath = normalizeComparePath(currentTab.path) - const activePathChanged = changedPaths.some( - (changedPath) => - normalizeComparePath(changedPath) === normalizedActivePath - ) - if (!activePathChanged) return null - - return currentTab.path - } - - type ActiveFileChangeDecision = - | { kind: "none" } - | { kind: "reload"; path: string } - | { - kind: "conflict" - path: string - diskContent: string - unsavedContent: string - signature: string - } - - const resolveActiveFileChangeDecision = async ( - path: string - ): Promise => { const currentTab = activeFileTabRef.current if (!currentTab || currentTab.kind !== "file") return { kind: "none" } if ( @@ -2034,131 +1986,59 @@ export function FileTreeTab() { if (latestTab.loading) return { kind: "none" } if (latestTab.isDirty) return { kind: "none" } if (!knownTabEtag) return { kind: "reload", path } - // Fallback: if probe fails but tab is clean, reload to reflect latest disk state. return { kind: "reload", path } } - } + }, + [folder?.path] + ) - const setup = async () => { - try { - await startFileTreeWatch(rootPath) - watchStarted = true - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - toast.error(t("toasts.watchStartFailed"), { description: message }) - } - if (disposed) { - releaseWatch() + useEffect(() => { + const rootPath = folder?.path + if (!rootPath) return + + const nextSeq = workspaceState.seq + if (nextSeq <= previousWorkspaceSeqRef.current) return + previousWorkspaceSeqRef.current = nextSeq + + const currentTab = activeFileTabRef.current + if (!currentTab || currentTab.kind !== "file") return + if (!currentTab.path || currentTab.loading) return + + const activePath = currentTab.path + void (async () => { + const decision = await resolveActiveFileChangeDecision(activePath) + if (decision.kind === "none") return + + if (decision.kind === "reload") { + externalConflictSignatureByPathRef.current.delete(decision.path) + void openFilePreview(decision.path) return } - try { - const subscribedUnlisten = await subscribe( - "folder://file-tree-changed", - (payload) => { - if ( - normalizeComparePath(payload.root_path) !== normalizedRootPath - ) { - return - } - - const changedPaths = payload.changed_paths.map(normalizeComparePath) - const shouldRefreshGitStatus = payload.refresh_git_status ?? true - const nonGitChangedPaths = changedPaths.filter( - (path) => !isGitMetadataPath(path) - ) - const onlyGitMetadataChanges = - changedPaths.length > 0 && nonGitChangedPaths.length === 0 - const hasUnknownPath = nonGitChangedPaths.some( - (path) => !filePathSetRef.current.has(path) - ) - const needsTreeRefresh = - payload.full_reload || - (!onlyGitMetadataChanges && - (payload.kind !== "modify" || - nonGitChangedPaths.length === 0 || - hasUnknownPath)) - - if (onlyGitMetadataChanges && !payload.full_reload) { - if (shouldRefreshGitStatus) { - scheduleStatusRefresh() - } - } else if (needsTreeRefresh) { - scheduleTreeRefresh(shouldRefreshGitStatus) - } else if (shouldRefreshGitStatus) { - scheduleStatusRefresh() - } - - if (onlyGitMetadataChanges && !payload.full_reload) { - return - } - - const changedActivePath = getActiveChangedFilePath( - nonGitChangedPaths, - payload.full_reload - ) - if (!changedActivePath) return - - void (async () => { - const decision = - await resolveActiveFileChangeDecision(changedActivePath) - if (decision.kind === "none") return - - if (decision.kind === "reload") { - externalConflictSignatureByPathRef.current.delete(decision.path) - void openFilePreview(decision.path) - return - } - - const shownSignature = - externalConflictSignatureByPathRef.current.get(decision.path) - if (shownSignature === decision.signature) return - externalConflictSignatureByPathRef.current.set( - decision.path, - decision.signature - ) - setExternalConflictPrompt((current) => { - if (current?.signature === decision.signature) return current - return { - path: decision.path, - diskContent: decision.diskContent, - unsavedContent: decision.unsavedContent, - signature: decision.signature, - } - }) - })() - } - ) - if (disposed) { - subscribedUnlisten() - releaseWatch() - return + const shownSignature = externalConflictSignatureByPathRef.current.get( + decision.path + ) + if (shownSignature === decision.signature) return + externalConflictSignatureByPathRef.current.set( + decision.path, + decision.signature + ) + setExternalConflictPrompt((current) => { + if (current?.signature === decision.signature) return current + return { + path: decision.path, + diskContent: decision.diskContent, + unsavedContent: decision.unsavedContent, + signature: decision.signature, } - unlisten = subscribedUnlisten - } catch (error) { - console.error("[FileTreeTab] failed to listen file watch event:", error) - } - } - - void setup() - - return () => { - disposed = true - if (treeRefreshTimerRef.current) { - clearTimeout(treeRefreshTimerRef.current) - treeRefreshTimerRef.current = null - } - treeRefreshNeedsStatusRef.current = false - if (statusRefreshTimerRef.current) { - clearTimeout(statusRefreshTimerRef.current) - statusRefreshTimerRef.current = null - } - pendingTreeRefreshRef.current = false - pendingTreeRefreshNeedsStatusRef.current = false - pendingStatusRefreshRef.current = false - releaseWatch() - } - }, [fetchTree, folder?.path, openFilePreview, t]) + }) + })() + }, [ + folder?.path, + openFilePreview, + resolveActiveFileChangeDecision, + workspaceState.seq, + ]) if (loading && nodes.length === 0) { return ( diff --git a/src/components/layout/aux-panel-git-changes-tab.tsx b/src/components/layout/aux-panel-git-changes-tab.tsx index bd82dd2..40db0a8 100644 --- a/src/components/layout/aux-panel-git-changes-tab.tsx +++ b/src/components/layout/aux-panel-git-changes-tab.tsx @@ -8,7 +8,6 @@ import { useRef, useState, } from "react" -import { subscribe } from "@/lib/platform" import { ChevronsDownUp, ChevronsUpDown } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" @@ -35,20 +34,17 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu" import { Skeleton } from "@/components/ui/skeleton" -import { useAuxPanelContext } from "@/contexts/aux-panel-context" import { useFolderContext } from "@/contexts/folder-context" import { useWorkspaceContext } from "@/contexts/workspace-context" +import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store" import { deleteFileTreeEntry, - gitDiff, gitAddFiles, gitRollbackFile, gitStatus, openCommitWindow, - startFileTreeWatch, - stopFileTreeWatch, } from "@/lib/api" -import type { FileTreeChangedEvent, GitStatusEntry } from "@/lib/types" +import type { GitStatusEntry } from "@/lib/types" import { AlertDialog, AlertDialogAction, @@ -119,6 +115,19 @@ interface MutableChangeTreeDirNode { const TRACKED_ROOT_PATH = "__working_tree_tracked_root__" const UNTRACKED_ROOT_PATH = "__working_tree_untracked_root__" const UNTRACKED_STATUS = "??" +const GIT_CHANGES_DEBUG_LOG = process.env.NODE_ENV === "development" + +function logGitChangesDebug( + message: string, + payload?: Record +) { + if (!GIT_CHANGES_DEBUG_LOG) return + if (payload) { + console.info(`[GitChangesTab/workspace] ${message}`, payload) + return + } + console.info(`[GitChangesTab/workspace] ${message}`) +} type GitFileState = | "untracked" @@ -228,52 +237,6 @@ function filterDirectoryGitCandidates( }) } -function normalizeDiffPath(rawPath: string): string | null { - const trimmed = rawPath.trim().replace(/^"|"$/g, "") - if (!trimmed || trimmed === "/dev/null") return null - if (trimmed.startsWith("a/") || trimmed.startsWith("b/")) { - return trimmed.slice(2).replace(/\\/g, "/") - } - return trimmed.replace(/\\/g, "/") -} - -function parsePathFromDiffGitLine(line: string): string | null { - if (!line.startsWith("diff --git ")) return null - const match = line.match(/^diff --git\s+(.+?)\s+(.+)$/) - if (!match) return null - return normalizeDiffPath(match[2]) ?? normalizeDiffPath(match[1]) -} - -function parseDiffStatsMap( - diffText: string -): Map { - const stats = new Map() - let currentPath: string | null = null - - for (const line of diffText.split("\n")) { - const nextPath = parsePathFromDiffGitLine(line) - if (nextPath) { - currentPath = nextPath - if (!stats.has(currentPath)) { - stats.set(currentPath, { additions: 0, deletions: 0 }) - } - continue - } - - if (!currentPath) continue - const current = stats.get(currentPath) - if (!current) continue - - if (line.startsWith("+") && !line.startsWith("+++")) { - current.additions += 1 - } else if (line.startsWith("-") && !line.startsWith("---")) { - current.deletions += 1 - } - } - - return stats -} - function toSortedTreeNodes(dir: MutableChangeTreeDirNode): ChangeTreeNode[] { return Array.from(dir.children.values()) .map((node) => { @@ -420,47 +383,12 @@ function canOpenFile(status: string): boolean { return !status.trim().toUpperCase().includes("D") } -function shouldRefreshFromEvent(event: FileTreeChangedEvent): boolean { - const shouldRefreshGitStatus = event.refresh_git_status ?? true - if (!shouldRefreshGitStatus) return false - if (event.kind === "access") return false - return true -} - -function toWorkingTreeChanges( - entries: GitStatusEntry[], - diffText: string -): WorkingTreeChange[] { - const stats = parseDiffStatsMap(diffText) - - return entries - .map((entry) => { - const path = normalizeGitStatusPath(entry.file) - if (!path) return null - const diffStat = stats.get(path) - return { - path, - status: entry.status.trim() || "M", - additions: diffStat?.additions ?? 0, - deletions: diffStat?.deletions ?? 0, - } - }) - .filter((change): change is WorkingTreeChange => change !== null) - .sort((left, right) => - left.path.localeCompare(right.path, undefined, { sensitivity: "base" }) - ) -} - export function GitChangesTab() { const t = useTranslations("Folder.gitChangesTab") const tCommon = useTranslations("Folder.common") const { folder } = useFolderContext() - const { activeTab } = useAuxPanelContext() const { openFilePreview, openWorkingTreeDiff } = useWorkspaceContext() - - const [changes, setChanges] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const workspaceState = useWorkspaceStateStore(folder?.path ?? null) const [expandedTrackedPaths, setExpandedTrackedPaths] = useState>( new Set() @@ -492,9 +420,6 @@ export function GitChangesTab() { const hasHydratedTrackedPaths = useRef(false) const hasHydratedUntrackedPaths = useRef(false) - const refreshTimerRef = useRef | null>(null) - - const isChangesTabActive = activeTab === "changes" const folderName = useMemo(() => { const path = folder?.path ?? "" @@ -502,6 +427,26 @@ export function GitChangesTab() { return (parts[parts.length - 1] ?? path) || t("workspace") }, [folder?.path, t]) + const changes = useMemo(() => { + return [...workspaceState.git] + .map((entry) => ({ + path: entry.path, + status: entry.status, + additions: entry.additions, + deletions: entry.deletions, + })) + .sort((left, right) => + left.path.localeCompare(right.path, undefined, { sensitivity: "base" }) + ) + }, [workspaceState.git]) + + const loading = useMemo( + () => workspaceState.health === "resyncing" && workspaceState.seq === 0, + [workspaceState.health, workspaceState.seq] + ) + const error = + workspaceState.health === "degraded" ? workspaceState.error : null + const trackedChanges = useMemo( () => changes.filter((change) => !isUntrackedStatus(change.status)), [changes] @@ -570,125 +515,6 @@ export function GitChangesTab() { }) }, [allUntrackedDirectoryPaths, untrackedChanges.length]) - const fetchChanges = useCallback( - async (options?: { inline?: boolean }) => { - if (!folder?.path) { - setLoading(false) - setError(null) - setChanges([]) - return - } - - const inline = options?.inline ?? false - if (!inline) { - setLoading(true) - } - setError(null) - - try { - const statusEntries = await gitStatus(folder.path, true) - const hasTrackedEntries = statusEntries.some( - (entry) => !isUntrackedStatus(entry.status) - ) - const diffText = hasTrackedEntries - ? await gitDiff(folder.path).catch(() => "") - : "" - setChanges(toWorkingTreeChanges(statusEntries, diffText)) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - if (!inline) { - setLoading(false) - } - } - }, - [folder?.path] - ) - - useEffect(() => { - if (!isChangesTabActive) return - void fetchChanges() - }, [fetchChanges, isChangesTabActive]) - - useEffect(() => { - const rootPath = folder?.path - if (!rootPath || !isChangesTabActive) return - - let unlisten: (() => void) | null = null - let disposed = false - let watchStarted = false - let watchReleased = false - const normalizedRootPath = normalizeComparePath(rootPath) - - const releaseWatch = () => { - if (watchReleased) return - watchReleased = true - if (unlisten) { - unlisten() - unlisten = null - } - if (watchStarted) { - void stopFileTreeWatch(rootPath) - } - } - - const scheduleRefresh = () => { - if (refreshTimerRef.current) { - clearTimeout(refreshTimerRef.current) - } - refreshTimerRef.current = setTimeout(() => { - void fetchChanges({ inline: true }) - }, 220) - } - - const setup = async () => { - try { - await startFileTreeWatch(rootPath) - watchStarted = true - } catch { - // ignore watch startup errors - } - if (disposed) { - releaseWatch() - return - } - - try { - const subscribedUnlisten = await subscribe( - "folder://file-tree-changed", - (payload) => { - if ( - normalizeComparePath(payload.root_path) !== normalizedRootPath - ) { - return - } - if (!shouldRefreshFromEvent(payload)) return - scheduleRefresh() - } - ) - if (disposed) { - subscribedUnlisten() - releaseWatch() - return - } - unlisten = subscribedUnlisten - } catch { - // ignore listen errors - } - } - - void setup() - - return () => { - disposed = true - if (refreshTimerRef.current) { - clearTimeout(refreshTimerRef.current) - refreshTimerRef.current = null - } - releaseWatch() - } - }, [fetchChanges, folder?.path, isChangesTabActive]) - const trackedCanExpand = useMemo(() => { if (trackedTreeNodes.length === 0) return false for (const path of allTrackedDirectoryPaths) { @@ -719,6 +545,24 @@ export function GitChangesTab() { [expandedUntrackedPaths.size, untrackedTreeNodes.length] ) + useEffect(() => { + logGitChangesDebug("workspace state consumed", { + rootPath: folder?.path ?? "", + seq: workspaceState.seq, + health: workspaceState.health, + gitEntries: workspaceState.git.length, + trackedChanges: trackedChanges.length, + untrackedChanges: untrackedChanges.length, + }) + }, [ + folder?.path, + trackedChanges.length, + untrackedChanges.length, + workspaceState.git.length, + workspaceState.health, + workspaceState.seq, + ]) + const toggleTrackedExpanded = useCallback(() => { if (trackedCanExpand) { setExpandedTrackedPaths(new Set(allTrackedDirectoryPaths)) @@ -822,13 +666,13 @@ export function GitChangesTab() { try { await gitAddFiles(folder.path, [target.path]) toast.success(t("toasts.addedToVcs", { name: target.name })) - await fetchChanges({ inline: true }) + await workspaceState.requestResync("git_action:add") } catch (error) { const message = error instanceof Error ? error.message : String(error) toast.error(t("toasts.addToVcsFailed"), { description: message }) } }, - [fetchChanges, folder?.path, openDirectoryGitActionDialog, t] + [folder?.path, openDirectoryGitActionDialog, t, workspaceState] ) const handleRollbackConfirm = useCallback(async () => { @@ -839,14 +683,14 @@ export function GitChangesTab() { await gitRollbackFile(folder.path, rollbackTarget.path) toast.success(t("toasts.rolledBack", { name: rollbackTarget.name })) setRollbackTarget(null) - await fetchChanges({ inline: true }) + await workspaceState.requestResync("git_action:rollback") } catch (error) { const message = error instanceof Error ? error.message : String(error) toast.error(t("toasts.rollbackFailed"), { description: message }) } finally { setRollingBack(false) } - }, [fetchChanges, folder?.path, rollbackTarget, t]) + }, [folder?.path, rollbackTarget, t, workspaceState]) const handleRequestDelete = useCallback( (target: GitActionTarget, scope: "tracked" | "untracked") => { @@ -870,14 +714,14 @@ export function GitChangesTab() { await deleteFileTreeEntry(folder.path, deleteTarget.path) toast.success(t("toasts.deleted", { name: deleteTarget.name })) setDeleteTarget(null) - await fetchChanges({ inline: true }) + await workspaceState.requestResync("git_action:delete") } catch (error) { const message = error instanceof Error ? error.message : String(error) toast.error(t("toasts.deleteFailed"), { description: message }) } finally { setDeleting(false) } - }, [deleteTarget, fetchChanges, folder?.path, t]) + }, [deleteTarget, folder?.path, t, workspaceState]) const directoryGitAllFilePaths = useMemo( () => directoryGitCandidates.map((entry) => entry.path), @@ -955,7 +799,7 @@ export function GitChangesTab() { } resetDirectoryGitActionDialog() - await fetchChanges({ inline: true }) + await workspaceState.requestResync("git_action:batch") } catch (error) { const message = error instanceof Error ? error.message : String(error) setDirectoryGitError(message) @@ -975,10 +819,10 @@ export function GitChangesTab() { }, [ directoryGitActionType, directoryGitSelectedPaths, - fetchChanges, folder?.path, resetDirectoryGitActionDialog, t, + workspaceState, ]) useEffect(() => { diff --git a/src/hooks/use-workspace-state-store.ts b/src/hooks/use-workspace-state-store.ts new file mode 100644 index 0000000..739f9d4 --- /dev/null +++ b/src/hooks/use-workspace-state-store.ts @@ -0,0 +1,609 @@ +"use client" + +import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react" +import { + getWorkspaceSnapshot, + startWorkspaceStateStream, + stopWorkspaceStateStream, +} from "@/lib/api" +import { subscribe } from "@/lib/platform" +import type { + FileTreeNode, + WorkspaceDelta, + WorkspaceDeltaEnvelope, + WorkspaceGitEntry, + WorkspaceSnapshotResponse, + WorkspaceStateEvent, +} from "@/lib/types" + +type WorkspaceHealth = "healthy" | "resyncing" | "degraded" + +export interface WorkspaceStateView { + rootPath: string + seq: number + version: number + health: WorkspaceHealth + tree: FileTreeNode[] + git: WorkspaceGitEntry[] + error: string | null +} + +export interface WorkspaceStateResult extends WorkspaceStateView { + requestResync: (reason?: string) => Promise +} + +const WORKSPACE_PROTOCOL_VERSION = 1 +const STORE_EVICT_DELAY_MS = 120_000 +const STORE_SHUTDOWN_GRACE_MS = 600 +const WORKSPACE_DEBUG_LOG = process.env.NODE_ENV === "development" + +const EMPTY_STATE: WorkspaceStateView = { + rootPath: "", + seq: 0, + version: WORKSPACE_PROTOCOL_VERSION, + health: "healthy", + tree: [], + git: [], + error: null, +} + +function normalizeComparePath(path: string): string { + return path.replace(/\\/g, "/").replace(/\/+$/, "") +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message + return String(error) +} + +function logWorkspaceDebug(message: string, payload?: Record) { + if (!WORKSPACE_DEBUG_LOG) return + if (payload) { + console.info(`[WorkspaceStateStore] ${message}`, payload) + return + } + console.info(`[WorkspaceStateStore] ${message}`) +} + +function summarizeSnapshot(snapshot: WorkspaceSnapshotResponse) { + return { + rootPath: snapshot.root_path, + seq: snapshot.seq, + full: snapshot.full, + deltas: snapshot.deltas.length, + treeRoots: snapshot.tree_snapshot?.length ?? 0, + gitEntries: snapshot.git_snapshot?.length ?? 0, + } +} + +function summarizeEvent(event: WorkspaceStateEvent, localSeq: number) { + return { + rootPath: event.root_path, + kind: event.kind, + eventSeq: event.seq, + localSeq, + requiresResync: event.requires_resync, + payloadKinds: event.payload.map((delta) => delta.kind), + payloadCount: event.payload.length, + } +} + +function applyDeltaToState( + state: WorkspaceStateView, + delta: WorkspaceDelta +): WorkspaceStateView { + switch (delta.kind) { + case "tree_replace": + return { ...state, tree: delta.nodes } + case "git_replace": + return { ...state, git: delta.entries } + case "meta": + return state + } +} + +function applyDeltaEnvelope( + state: WorkspaceStateView, + envelope: WorkspaceDeltaEnvelope +): WorkspaceStateView { + let next = state + for (const delta of envelope.payload) { + next = applyDeltaToState(next, delta) + } + return { + ...next, + seq: envelope.seq, + version: WORKSPACE_PROTOCOL_VERSION, + health: envelope.requires_resync ? "resyncing" : "healthy", + error: envelope.requires_resync ? "resync requested" : null, + } +} + +function applySnapshot( + state: WorkspaceStateView, + snapshot: WorkspaceSnapshotResponse +): WorkspaceStateView { + if (snapshot.full) { + if (snapshot.seq < state.seq) { + return state + } + return { + rootPath: snapshot.root_path, + seq: snapshot.seq, + version: snapshot.version, + health: "healthy", + tree: snapshot.tree_snapshot ?? [], + git: snapshot.git_snapshot ?? [], + error: null, + } + } + + let next = state + const ordered = [...snapshot.deltas].sort( + (left, right) => left.seq - right.seq + ) + + for (const envelope of ordered) { + if (envelope.seq <= next.seq) continue + if (envelope.seq !== next.seq + 1) { + throw new Error("workspace state delta gap") + } + next = applyDeltaEnvelope(next, envelope) + } + + return { + ...next, + seq: Math.max(next.seq, snapshot.seq), + version: snapshot.version, + health: "healthy", + error: null, + } +} + +class WorkspaceStateStore { + private readonly rootPath: string + private readonly normalizedRootPath: string + private listeners = new Set<() => void>() + private state: WorkspaceStateView + private refCount = 0 + private started = false + private starting: Promise | null = null + private stopping: Promise | null = null + private unlisten: (() => void) | null = null + private resyncInFlight: Promise | null = null + private lifecycleId = 0 + private evictionTimer: ReturnType | null = null + private shutdownTimer: ReturnType | null = null + private hasBaselineSnapshot = false + + constructor(rootPath: string) { + this.rootPath = rootPath + this.normalizedRootPath = normalizeComparePath(rootPath) + this.state = { + ...EMPTY_STATE, + rootPath, + } + } + + getSnapshot = (): WorkspaceStateView => this.state + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + acquire = () => { + this.cancelPendingShutdown() + this.cancelEviction() + this.refCount += 1 + logWorkspaceDebug("acquire", { + rootPath: this.rootPath, + refCount: this.refCount, + started: this.started, + }) + if (this.refCount === 1) { + const canReuseLifecycle = + this.lifecycleId > 0 && + (this.started || this.starting !== null || this.stopping !== null) + if (!canReuseLifecycle) { + this.lifecycleId += 1 + } + const lifecycleId = this.lifecycleId + void this.ensureStarted(lifecycleId) + } + } + + release = () => { + if (this.refCount === 0) return + this.refCount -= 1 + logWorkspaceDebug("release", { + rootPath: this.rootPath, + refCount: this.refCount, + started: this.started, + }) + if (this.refCount === 0) { + const lifecycleId = this.lifecycleId + this.scheduleShutdown(lifecycleId) + } + } + + requestResync = async (reason?: string) => { + void reason + if (this.resyncInFlight) { + logWorkspaceDebug("requestResync skip in-flight", { + rootPath: this.rootPath, + reason: reason ?? "unknown", + }) + return this.resyncInFlight + } + + const run = async () => { + const startedAt = performance.now() + this.patchState((prev) => ({ + ...prev, + health: "resyncing", + })) + + try { + const sinceSeq = this.hasBaselineSnapshot ? this.state.seq : undefined + logWorkspaceDebug("requestResync start", { + rootPath: this.rootPath, + reason: reason ?? "unknown", + sinceSeq: sinceSeq ?? null, + }) + const snapshot = await getWorkspaceSnapshot(this.rootPath, sinceSeq) + this.patchState((prev) => applySnapshot(prev, snapshot)) + if (snapshot.full) { + this.hasBaselineSnapshot = true + } + logWorkspaceDebug("requestResync success", { + ...summarizeSnapshot(snapshot), + reason: reason ?? "unknown", + durationMs: Math.round(performance.now() - startedAt), + }) + } catch (error) { + this.patchState((prev) => ({ + ...prev, + health: "degraded", + error: toErrorMessage(error), + })) + logWorkspaceDebug("requestResync failed", { + rootPath: this.rootPath, + reason: reason ?? "unknown", + durationMs: Math.round(performance.now() - startedAt), + error: toErrorMessage(error), + }) + } + } + + this.resyncInFlight = run().finally(() => { + this.resyncInFlight = null + }) + + return this.resyncInFlight + } + + private ensureStarted = async (lifecycleId: number) => { + if (this.started) return + if (this.starting) { + await this.starting + if (!this.isLifecycleActive(lifecycleId) || this.started) { + return + } + await this.ensureStarted(lifecycleId) + return + } + + const start = async () => { + if (this.stopping) { + await this.stopping + } + if (!this.isLifecycleActive(lifecycleId)) { + return + } + + try { + const streamStartedAt = performance.now() + logWorkspaceDebug("ensureStarted start stream", { + rootPath: this.rootPath, + lifecycleId, + }) + const initialSnapshot = await startWorkspaceStateStream(this.rootPath) + if (!this.isLifecycleActive(lifecycleId)) { + await stopWorkspaceStateStream(this.rootPath).catch(() => {}) + logWorkspaceDebug("ensureStarted aborted after initial snapshot", { + rootPath: this.rootPath, + lifecycleId, + }) + return + } + this.patchState((prev) => applySnapshot(prev, initialSnapshot)) + this.hasBaselineSnapshot = true + logWorkspaceDebug("ensureStarted initial snapshot", { + ...summarizeSnapshot(initialSnapshot), + lifecycleId, + durationMs: Math.round(performance.now() - streamStartedAt), + }) + + const unlisten = await subscribe( + "folder://workspace-state-event", + (event) => { + if ( + normalizeComparePath(event.root_path) !== this.normalizedRootPath + ) { + return + } + this.handleEvent(event) + } + ) + logWorkspaceDebug("ensureStarted subscribe ready", { + rootPath: this.rootPath, + lifecycleId, + }) + + if (!this.isLifecycleActive(lifecycleId)) { + unlisten() + await stopWorkspaceStateStream(this.rootPath).catch(() => {}) + return + } + + this.unlisten = unlisten + this.started = true + const catchUpStartedAt = performance.now() + const catchUpSnapshot = await getWorkspaceSnapshot( + this.rootPath, + this.state.seq + ) + if (!this.isLifecycleActive(lifecycleId)) { + logWorkspaceDebug("ensureStarted aborted after catch-up snapshot", { + rootPath: this.rootPath, + lifecycleId, + }) + return + } + this.patchState((prev) => applySnapshot(prev, catchUpSnapshot)) + logWorkspaceDebug("ensureStarted catch-up snapshot", { + ...summarizeSnapshot(catchUpSnapshot), + lifecycleId, + durationMs: Math.round(performance.now() - catchUpStartedAt), + }) + } catch (error) { + this.patchState((prev) => ({ + ...prev, + health: "degraded", + error: toErrorMessage(error), + })) + logWorkspaceDebug("ensureStarted failed", { + rootPath: this.rootPath, + lifecycleId, + error: toErrorMessage(error), + }) + } + } + + this.starting = start().finally(() => { + this.starting = null + }) + + await this.starting + } + + private shutdown = async (lifecycleId: number) => { + void lifecycleId + this.started = false + logWorkspaceDebug("shutdown", { + rootPath: this.rootPath, + lifecycleId, + }) + const unlisten = this.unlisten + this.unlisten = null + if (unlisten) { + unlisten() + } + await stopWorkspaceStateStream(this.rootPath).catch(() => {}) + } + + private cancelPendingShutdown = () => { + if (!this.shutdownTimer) return + clearTimeout(this.shutdownTimer) + this.shutdownTimer = null + } + + private scheduleShutdown = (lifecycleId: number) => { + this.cancelPendingShutdown() + this.shutdownTimer = setTimeout(() => { + this.shutdownTimer = null + if (this.refCount !== 0) { + logWorkspaceDebug("shutdown grace canceled by new acquire", { + rootPath: this.rootPath, + lifecycleId, + refCount: this.refCount, + }) + return + } + const dispose = async () => { + await this.shutdown(lifecycleId) + } + const stopping = dispose().finally(() => { + if (this.stopping === stopping) { + this.stopping = null + } + if (this.refCount === 0) { + this.scheduleEviction() + } + }) + this.stopping = stopping + void stopping + }, STORE_SHUTDOWN_GRACE_MS) + } + + private cancelEviction = () => { + if (!this.evictionTimer) return + clearTimeout(this.evictionTimer) + this.evictionTimer = null + } + + private scheduleEviction = () => { + this.cancelEviction() + this.evictionTimer = setTimeout(() => { + this.evictionTimer = null + if (this.refCount !== 0) return + if (this.started || this.starting || this.stopping || this.unlisten) + return + deleteStore(this.normalizedRootPath, this) + }, STORE_EVICT_DELAY_MS) + } + + private isLifecycleCurrent = (lifecycleId: number) => { + return this.lifecycleId === lifecycleId + } + + private isLifecycleActive = (lifecycleId: number) => { + return this.isLifecycleCurrent(lifecycleId) && this.refCount > 0 + } + + private handleEvent = (event: WorkspaceStateEvent) => { + logWorkspaceDebug("event received", summarizeEvent(event, this.state.seq)) + + if (event.version !== WORKSPACE_PROTOCOL_VERSION) { + logWorkspaceDebug("event version mismatch", { + rootPath: this.rootPath, + eventVersion: event.version, + expectedVersion: WORKSPACE_PROTOCOL_VERSION, + }) + void this.requestResync("version_mismatch") + return + } + + if (event.requires_resync || event.seq !== this.state.seq + 1) { + logWorkspaceDebug("event requires resync", { + rootPath: this.rootPath, + kind: event.kind, + eventSeq: event.seq, + localSeq: this.state.seq, + requiresResync: event.requires_resync, + }) + void this.requestResync("seq_gap_or_hint") + return + } + + let next = this.state + for (const delta of event.payload) { + next = applyDeltaToState(next, delta) + } + + this.patchState(() => ({ + ...next, + rootPath: event.root_path, + seq: event.seq, + version: event.version, + health: "healthy", + error: null, + })) + + logWorkspaceDebug("event applied", { + rootPath: event.root_path, + seq: event.seq, + treeRoots: next.tree.length, + gitEntries: next.git.length, + }) + } + + private patchState = ( + updater: + | WorkspaceStateView + | ((prev: WorkspaceStateView) => WorkspaceStateView) + ) => { + this.state = + typeof updater === "function" + ? (updater as (prev: WorkspaceStateView) => WorkspaceStateView)( + this.state + ) + : updater + this.emit() + } + + private emit = () => { + for (const listener of this.listeners) { + listener() + } + } +} + +const stores = new Map() + +function deleteStore(normalizedRootPath: string, store: WorkspaceStateStore) { + const current = stores.get(normalizedRootPath) + if (current === store) { + stores.delete(normalizedRootPath) + } +} + +function getStore(rootPath: string): WorkspaceStateStore { + const normalized = normalizeComparePath(rootPath) + const existing = stores.get(normalized) + if (existing) return existing + + const created = new WorkspaceStateStore(rootPath) + stores.set(normalized, created) + return created +} + +export function useWorkspaceStateStore( + rootPath: string | null +): WorkspaceStateResult { + const store = useMemo(() => { + if (!rootPath) return null + return getStore(rootPath) + }, [rootPath]) + + useEffect(() => { + if (!store || !rootPath) return + store.acquire() + + return () => { + store.release() + } + }, [rootPath, store]) + + const subscribeToStore = useCallback( + (onStoreChange: () => void) => { + if (!store) return () => {} + return store.subscribe(onStoreChange) + }, + [store] + ) + + const getSnapshot = useCallback(() => { + if (!store) return EMPTY_STATE + return store.getSnapshot() + }, [store]) + + const snapshot = useSyncExternalStore( + subscribeToStore, + getSnapshot, + getSnapshot + ) + + const requestResync = useCallback( + async (reason?: string) => { + if (!store) return + await store.requestResync(reason) + }, + [store] + ) + + if (!rootPath) { + return { + ...EMPTY_STATE, + requestResync, + } + } + + return { + ...snapshot, + requestResync, + } +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 685812f..a8e8b42 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -42,6 +42,7 @@ import type { FilePreviewContent, FileEditContent, FileSaveResult, + WorkspaceSnapshotResponse, GitLogResult, SystemLanguageSettings, SystemProxySettings, @@ -1267,12 +1268,26 @@ export async function getFileTree( }) } -export async function startFileTreeWatch(rootPath: string): Promise { - return getTransport().call("start_file_tree_watch", { rootPath }) +export async function startWorkspaceStateStream( + rootPath: string +): Promise { + return getTransport().call("start_workspace_state_stream", { rootPath }) } -export async function stopFileTreeWatch(rootPath: string): Promise { - return getTransport().call("stop_file_tree_watch", { rootPath }) +export async function stopWorkspaceStateStream( + rootPath: string +): Promise { + return getTransport().call("stop_workspace_state_stream", { rootPath }) +} + +export async function getWorkspaceSnapshot( + rootPath: string, + sinceSeq?: number +): Promise { + return getTransport().call("get_workspace_snapshot", { + rootPath, + sinceSeq: sinceSeq ?? null, + }) } export async function readFileBase64( diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index b61b6f6..11cfb5b 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -40,6 +40,7 @@ import type { FilePreviewContent, FileEditContent, FileSaveResult, + WorkspaceSnapshotResponse, GitLogResult, SystemLanguageSettings, SystemProxySettings, @@ -1010,12 +1011,26 @@ export async function getFileTree( return invoke("get_file_tree", { path, maxDepth: maxDepth ?? null }) } -export async function startFileTreeWatch(rootPath: string): Promise { - return invoke("start_file_tree_watch", { rootPath }) +export async function startWorkspaceStateStream( + rootPath: string +): Promise { + return invoke("start_workspace_state_stream", { rootPath }) } -export async function stopFileTreeWatch(rootPath: string): Promise { - return invoke("stop_file_tree_watch", { rootPath }) +export async function stopWorkspaceStateStream( + rootPath: string +): Promise { + return invoke("stop_workspace_state_stream", { rootPath }) +} + +export async function getWorkspaceSnapshot( + rootPath: string, + sinceSeq?: number +): Promise { + return invoke("get_workspace_snapshot", { + rootPath, + sinceSeq: sinceSeq ?? null, + }) } export async function readFileBase64( diff --git a/src/lib/types.ts b/src/lib/types.ts index 9e4d171..2a0254d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -831,12 +831,42 @@ export interface FileSaveResult { line_ending: "lf" | "crlf" | "mixed" | "none" } -export interface FileTreeChangedEvent { +export interface WorkspaceGitEntry { + path: string + status: string + additions: number + deletions: number +} + +export type WorkspaceDelta = + | { kind: "tree_replace"; nodes: FileTreeNode[] } + | { kind: "git_replace"; entries: WorkspaceGitEntry[] } + | { kind: "meta"; reason: string } + +export interface WorkspaceDeltaEnvelope { + seq: number + kind: "fs_delta" | "git_delta" | "meta" | "resync_hint" | string + payload: WorkspaceDelta[] + requires_resync: boolean +} + +export interface WorkspaceStateEvent { root_path: string - changed_paths: string[] - kind: "create" | "modify" | "remove" | "access" | "any" | "other" - full_reload: boolean - refresh_git_status: boolean + seq: number + version: number + kind: "fs_delta" | "git_delta" | "meta" | "resync_hint" | string + payload: WorkspaceDelta[] + requires_resync: boolean +} + +export interface WorkspaceSnapshotResponse { + root_path: string + seq: number + version: number + full: boolean + tree_snapshot: FileTreeNode[] | null + git_snapshot: WorkspaceGitEntry[] | null + deltas: WorkspaceDeltaEnvelope[] } export interface GitLogResult {