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.
This commit is contained in:
@@ -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<std::thread::JoinHandle<()>>,
|
||||
ref_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct FileTreeChangedEvent {
|
||||
root_path: String,
|
||||
changed_paths: Vec<String>,
|
||||
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<GitPushResult, AppCommandError> {
|
||||
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<Vec<GitStashEntry>, AppComma
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
||||
pub async fn git_stash_apply(
|
||||
path: String,
|
||||
stash_ref: String,
|
||||
) -> Result<String, AppCommandError> {
|
||||
pub async fn git_stash_apply(path: String, stash_ref: String) -> Result<String, AppCommandError> {
|
||||
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<String, AppCommandError> {
|
||||
pub async fn git_stash_drop(path: String, stash_ref: String) -> Result<String, AppCommandError> {
|
||||
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<GitCommitResult, AppCommandError> {
|
||||
// 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<String> =
|
||||
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<String> = 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<Semaphore> =
|
||||
LazyLock::new(|| Semaphore::new(FILE_IO_MAX_CONCURRENT_OPS));
|
||||
static FILE_WATCHERS: LazyLock<Mutex<HashMap<String, FileWatchEntry>>> =
|
||||
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<HashSet<String>, 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<String> = 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<String>,
|
||||
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::<Vec<_>>();
|
||||
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<notify::Event>,
|
||||
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<Instant> = 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<PathBuf, AppCommandError> {
|
||||
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::<notify::Event>();
|
||||
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<notify::Event, notify::Error>| 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<i64> {
|
||||
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<String, AppCommand
|
||||
if metadata.len() > 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<Vec<DirectoryEntry>, AppCommandError> {
|
||||
pub async fn list_directory_entries(path: String) -> Result<Vec<DirectoryEntry>, 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<DirectoryEntry> = 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
46
src-tauri/src/commands/workspace_state.rs
Normal file
46
src-tauri/src/commands/workspace_state.rs
Normal file
@@ -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<WorkspaceSnapshotResponse, AppCommandError> {
|
||||
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<u64>,
|
||||
) -> Result<WorkspaceSnapshotResponse, AppCommandError> {
|
||||
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<WorkspaceSnapshotResponse, AppCommandError> {
|
||||
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<u64>,
|
||||
) -> Result<WorkspaceSnapshotResponse, AppCommandError> {
|
||||
get_workspace_snapshot_core(root_path, since_seq).await
|
||||
}
|
||||
@@ -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::<ChatChannelManager>();
|
||||
let broadcaster = app
|
||||
.state::<std::sync::Arc<web::event_bridge::WebEventBroadcaster>>();
|
||||
let broadcaster =
|
||||
app.state::<std::sync::Arc<web::event_bridge::WebEventBroadcaster>>();
|
||||
let db_conn = app.state::<db::AppDatabase>().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::<ConnectionManager>() {
|
||||
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,
|
||||
|
||||
@@ -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<Arc<AppState>>,
|
||||
Json(params): Json<RootPathParams>,
|
||||
) -> Result<Json<()>, 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<RootPathParams>,
|
||||
) -> Result<Json<()>, AppCommandError> {
|
||||
folder_commands::stop_file_tree_watch(params.root_path).await?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenSettingsWindowParams {
|
||||
|
||||
@@ -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;
|
||||
|
||||
50
src-tauri/src/web/handlers/workspace_state.rs
Normal file
50
src-tauri/src/web/handlers/workspace_state.rs
Normal file
@@ -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<u64>,
|
||||
}
|
||||
|
||||
pub async fn start_workspace_state_stream(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
Json(params): Json<WorkspaceRootPathParams>,
|
||||
) -> Result<Json<WorkspaceSnapshotResponse>, 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<WorkspaceRootPathParams>,
|
||||
) -> Result<Json<()>, AppCommandError> {
|
||||
workspace_state_commands::stop_workspace_state_stream_core(params.root_path).await?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn get_workspace_snapshot(
|
||||
Json(params): Json<WorkspaceSnapshotParams>,
|
||||
) -> Result<Json<WorkspaceSnapshotResponse>, AppCommandError> {
|
||||
let result =
|
||||
workspace_state_commands::get_workspace_snapshot_core(params.root_path, params.since_seq)
|
||||
.await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
@@ -25,57 +25,160 @@ pub fn build_router(state: Arc<AppState>, 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<AppState>, 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<AppState>, 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<AppState>, 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<AppState>, 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() {
|
||||
|
||||
1090
src-tauri/src/workspace_state/mod.rs
Normal file
1090
src-tauri/src/workspace_state/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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<string, unknown>
|
||||
) {
|
||||
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<string, unknown>
|
||||
) {
|
||||
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<string, FileTreeNode[]>
|
||||
): 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<string>) {
|
||||
}
|
||||
}
|
||||
|
||||
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<FileTreeNode[]>([])
|
||||
const [gitStatusByPath, setGitStatusByPath] = useState<Map<string, string>>(
|
||||
new Map()
|
||||
@@ -841,25 +899,21 @@ export function FileTreeTab() {
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(
|
||||
() => new Set([FILE_TREE_ROOT_PATH])
|
||||
)
|
||||
const [loadedTreeDepth, setLoadedTreeDepth] = useState(1)
|
||||
const [gitignoreIgnoredPaths, setGitignoreIgnoredPaths] = useState<
|
||||
Set<string>
|
||||
>(new Set())
|
||||
const isFileTreeTabActive = activeTab === "file_tree"
|
||||
const activeFileTabRef = useRef(activeFileTab)
|
||||
const filePathSetRef = useRef<Set<string>>(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<Map<string, string>>(
|
||||
const previousWorkspaceSeqRef = useRef(0)
|
||||
const previousExpandedPathsRef = useRef<Set<string>>(
|
||||
new Set([FILE_TREE_ROOT_PATH])
|
||||
)
|
||||
const lazyLoadedChildrenByPathRef = useRef<Map<string, FileTreeNode[]>>(
|
||||
new Map()
|
||||
)
|
||||
const treeRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const statusRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null
|
||||
const lazyLoadingDirPathsRef = useRef<Set<string>>(new Set())
|
||||
const externalConflictSignatureByPathRef = useRef<Map<string, string>>(
|
||||
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<string, string>()
|
||||
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<string, string>()
|
||||
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<string>()
|
||||
@@ -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<ActiveFileChangeDecision> => {
|
||||
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<ActiveFileChangeDecision> => {
|
||||
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<FileTreeChangedEvent>(
|
||||
"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 (
|
||||
|
||||
@@ -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<string, unknown>
|
||||
) {
|
||||
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<string, { additions: number; deletions: number }> {
|
||||
const stats = new Map<string, { additions: number; deletions: number }>()
|
||||
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<ChangeTreeNode>((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<WorkingTreeChange[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const workspaceState = useWorkspaceStateStore(folder?.path ?? null)
|
||||
|
||||
const [expandedTrackedPaths, setExpandedTrackedPaths] = useState<Set<string>>(
|
||||
new Set()
|
||||
@@ -492,9 +420,6 @@ export function GitChangesTab() {
|
||||
|
||||
const hasHydratedTrackedPaths = useRef(false)
|
||||
const hasHydratedUntrackedPaths = useRef(false)
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | 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<WorkingTreeChange[]>(() => {
|
||||
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<FileTreeChangedEvent>(
|
||||
"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(() => {
|
||||
|
||||
609
src/hooks/use-workspace-state-store.ts
Normal file
609
src/hooks/use-workspace-state-store.ts
Normal file
@@ -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<void>
|
||||
}
|
||||
|
||||
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<string, unknown>) {
|
||||
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<void> | null = null
|
||||
private stopping: Promise<void> | null = null
|
||||
private unlisten: (() => void) | null = null
|
||||
private resyncInFlight: Promise<void> | null = null
|
||||
private lifecycleId = 0
|
||||
private evictionTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private shutdownTimer: ReturnType<typeof setTimeout> | 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<WorkspaceStateEvent>(
|
||||
"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<string, WorkspaceStateStore>()
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
return getTransport().call("start_file_tree_watch", { rootPath })
|
||||
export async function startWorkspaceStateStream(
|
||||
rootPath: string
|
||||
): Promise<WorkspaceSnapshotResponse> {
|
||||
return getTransport().call("start_workspace_state_stream", { rootPath })
|
||||
}
|
||||
|
||||
export async function stopFileTreeWatch(rootPath: string): Promise<void> {
|
||||
return getTransport().call("stop_file_tree_watch", { rootPath })
|
||||
export async function stopWorkspaceStateStream(
|
||||
rootPath: string
|
||||
): Promise<void> {
|
||||
return getTransport().call("stop_workspace_state_stream", { rootPath })
|
||||
}
|
||||
|
||||
export async function getWorkspaceSnapshot(
|
||||
rootPath: string,
|
||||
sinceSeq?: number
|
||||
): Promise<WorkspaceSnapshotResponse> {
|
||||
return getTransport().call("get_workspace_snapshot", {
|
||||
rootPath,
|
||||
sinceSeq: sinceSeq ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function readFileBase64(
|
||||
|
||||
@@ -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<void> {
|
||||
return invoke("start_file_tree_watch", { rootPath })
|
||||
export async function startWorkspaceStateStream(
|
||||
rootPath: string
|
||||
): Promise<WorkspaceSnapshotResponse> {
|
||||
return invoke("start_workspace_state_stream", { rootPath })
|
||||
}
|
||||
|
||||
export async function stopFileTreeWatch(rootPath: string): Promise<void> {
|
||||
return invoke("stop_file_tree_watch", { rootPath })
|
||||
export async function stopWorkspaceStateStream(
|
||||
rootPath: string
|
||||
): Promise<void> {
|
||||
return invoke("stop_workspace_state_stream", { rootPath })
|
||||
}
|
||||
|
||||
export async function getWorkspaceSnapshot(
|
||||
rootPath: string,
|
||||
sinceSeq?: number
|
||||
): Promise<WorkspaceSnapshotResponse> {
|
||||
return invoke("get_workspace_snapshot", {
|
||||
rootPath,
|
||||
sinceSeq: sinceSeq ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function readFileBase64(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user