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:
xintaofei
2026-04-14 22:26:36 +08:00
parent 90e8bb645a
commit b5e8fd8acb
15 changed files with 2856 additions and 1419 deletions

View File

@@ -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);

View File

@@ -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;

View 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
}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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;

View 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))
}

View File

@@ -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() {

File diff suppressed because it is too large Load Diff