From e622ebdccf0f1cac371528c849725ea43d4fbb16 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Tue, 13 Jan 2026 14:34:17 +0100 Subject: [PATCH 1/2] refactor: split tauri backend modules and add tests --- src-tauri/src/codex.rs | 543 +++++++++++ src-tauri/src/git.rs | 370 ++++++++ src-tauri/src/lib.rs | 1690 +---------------------------------- src-tauri/src/settings.rs | 22 + src-tauri/src/state.rs | 37 + src-tauri/src/storage.rs | 40 + src-tauri/src/types.rs | 145 +++ src-tauri/src/utils.rs | 13 + src-tauri/src/workspaces.rs | 579 ++++++++++++ 9 files changed, 1789 insertions(+), 1650 deletions(-) create mode 100644 src-tauri/src/codex.rs create mode 100644 src-tauri/src/git.rs create mode 100644 src-tauri/src/settings.rs create mode 100644 src-tauri/src/state.rs create mode 100644 src-tauri/src/storage.rs create mode 100644 src-tauri/src/types.rs create mode 100644 src-tauri/src/utils.rs create mode 100644 src-tauri/src/workspaces.rs diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs new file mode 100644 index 000000000..b568a5cd1 --- /dev/null +++ b/src-tauri/src/codex.rs @@ -0,0 +1,543 @@ +use serde::Serialize; +use serde_json::{json, Map, Value}; +use std::collections::HashMap; +use std::env; +use std::io::ErrorKind; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use tauri::{AppHandle, Emitter, State}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, Command}; +use tokio::sync::{oneshot, Mutex}; +use tokio::time::timeout; + +use crate::state::AppState; +use crate::types::WorkspaceEntry; + +#[derive(Serialize, Clone)] +struct AppServerEvent { + workspace_id: String, + message: Value, +} + +pub(crate) struct WorkspaceSession { + pub(crate) entry: WorkspaceEntry, + pub(crate) child: Mutex, + pub(crate) stdin: Mutex, + pub(crate) pending: Mutex>>, + pub(crate) next_id: AtomicU64, +} + +impl WorkspaceSession { + async fn write_message(&self, value: Value) -> Result<(), String> { + let mut stdin = self.stdin.lock().await; + let mut line = serde_json::to_string(&value).map_err(|e| e.to_string())?; + line.push('\n'); + stdin + .write_all(line.as_bytes()) + .await + .map_err(|e| e.to_string()) + } + + async fn send_request(&self, method: &str, params: Value) -> Result { + let id = self.next_id.fetch_add(1, Ordering::SeqCst); + let (tx, rx) = oneshot::channel(); + self.pending.lock().await.insert(id, tx); + self.write_message(json!({ "id": id, "method": method, "params": params })) + .await?; + rx.await.map_err(|_| "request canceled".to_string()) + } + + async fn send_notification(&self, method: &str, params: Option) -> Result<(), String> { + let value = if let Some(params) = params { + json!({ "method": method, "params": params }) + } else { + json!({ "method": method }) + }; + self.write_message(value).await + } + + async fn send_response(&self, id: u64, result: Value) -> Result<(), String> { + self.write_message(json!({ "id": id, "result": result })) + .await + } +} + +fn build_codex_command_with_bin(codex_bin: Option) -> Command { + let default_bin = codex_bin + .as_ref() + .map(|value| value.trim().is_empty()) + .unwrap_or(true); + let bin = codex_bin + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "codex".into()); + let mut command = Command::new(bin); + if default_bin { + let mut paths: Vec = env::var("PATH") + .unwrap_or_default() + .split(':') + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) + .collect(); + let mut extras = vec![ + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ] + .into_iter() + .map(|value| value.to_string()) + .collect::>(); + if let Ok(home) = env::var("HOME") { + extras.push(format!("{home}/.local/bin")); + extras.push(format!("{home}/.cargo/bin")); + } + for extra in extras { + if !paths.contains(&extra) { + paths.push(extra); + } + } + if !paths.is_empty() { + command.env("PATH", paths.join(":")); + } + } + command +} + +async fn check_codex_installation(codex_bin: Option) -> Result, String> { + let mut command = build_codex_command_with_bin(codex_bin); + command.arg("--version"); + command.stdout(std::process::Stdio::piped()); + command.stderr(std::process::Stdio::piped()); + + let output = match timeout(Duration::from_secs(5), command.output()).await { + Ok(result) => result.map_err(|e| { + if e.kind() == ErrorKind::NotFound { + "Codex CLI not found. Install Codex and ensure `codex` is on your PATH." + .to_string() + } else { + e.to_string() + } + })?, + Err(_) => { + return Err( + "Timed out while checking Codex CLI. Make sure `codex --version` runs in Terminal." + .to_string(), + ); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + if detail.is_empty() { + return Err( + "Codex CLI failed to start. Try running `codex --version` in Terminal." + .to_string(), + ); + } + return Err(format!( + "Codex CLI failed to start: {detail}. Try running `codex --version` in Terminal." + )); + } + + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Ok(if version.is_empty() { None } else { Some(version) }) +} + +pub(crate) async fn spawn_workspace_session( + entry: WorkspaceEntry, + default_codex_bin: Option, + app_handle: AppHandle, +) -> Result, String> { + let codex_bin = entry + .codex_bin + .clone() + .filter(|value| !value.trim().is_empty()) + .or(default_codex_bin); + let _ = check_codex_installation(codex_bin.clone()).await?; + + let mut command = build_codex_command_with_bin(codex_bin); + command.arg("app-server"); + command.stdin(std::process::Stdio::piped()); + command.stdout(std::process::Stdio::piped()); + command.stderr(std::process::Stdio::piped()); + + let mut child = command.spawn().map_err(|e| e.to_string())?; + let stdin = child.stdin.take().ok_or("missing stdin")?; + let stdout = child.stdout.take().ok_or("missing stdout")?; + let stderr = child.stderr.take().ok_or("missing stderr")?; + + let session = Arc::new(WorkspaceSession { + entry: entry.clone(), + child: Mutex::new(child), + stdin: Mutex::new(stdin), + pending: Mutex::new(HashMap::new()), + next_id: AtomicU64::new(1), + }); + + let session_clone = Arc::clone(&session); + let workspace_id = entry.id.clone(); + let app_handle_clone = app_handle.clone(); + tauri::async_runtime::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.trim().is_empty() { + continue; + } + let value: Value = match serde_json::from_str(&line) { + Ok(value) => value, + Err(err) => { + let payload = AppServerEvent { + workspace_id: workspace_id.clone(), + message: json!({ + "method": "codex/parseError", + "params": { "error": err.to_string(), "raw": line }, + }), + }; + let _ = app_handle_clone.emit("app-server-event", payload); + continue; + } + }; + + let maybe_id = value.get("id").and_then(|id| id.as_u64()); + let has_method = value.get("method").is_some(); + let has_result_or_error = + value.get("result").is_some() || value.get("error").is_some(); + if let Some(id) = maybe_id { + if has_result_or_error { + if let Some(tx) = session_clone.pending.lock().await.remove(&id) { + let _ = tx.send(value); + } + } else if has_method { + let payload = AppServerEvent { + workspace_id: workspace_id.clone(), + message: value, + }; + let _ = app_handle_clone.emit("app-server-event", payload); + } else if let Some(tx) = session_clone.pending.lock().await.remove(&id) { + let _ = tx.send(value); + } + } else if has_method { + let payload = AppServerEvent { + workspace_id: workspace_id.clone(), + message: value, + }; + let _ = app_handle_clone.emit("app-server-event", payload); + } + } + }); + + let workspace_id = entry.id.clone(); + let app_handle_clone = app_handle.clone(); + tauri::async_runtime::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.trim().is_empty() { + continue; + } + let payload = AppServerEvent { + workspace_id: workspace_id.clone(), + message: json!({ + "method": "codex/stderr", + "params": { "message": line }, + }), + }; + let _ = app_handle_clone.emit("app-server-event", payload); + } + }); + + let init_params = json!({ + "clientInfo": { + "name": "codex_monitor", + "title": "CodexMonitor", + "version": "0.1.0" + } + }); + let init_result = timeout( + Duration::from_secs(15), + session.send_request("initialize", init_params), + ) + .await; + let init_response = match init_result { + Ok(response) => response, + Err(_) => { + let mut child = session.child.lock().await; + let _ = child.kill().await; + return Err( + "Codex app-server did not respond to initialize. Check that `codex app-server` works in Terminal." + .to_string(), + ); + } + }; + init_response?; + session.send_notification("initialized", None).await?; + + let payload = AppServerEvent { + workspace_id: entry.id.clone(), + message: json!({ + "method": "codex/connected", + "params": { "workspaceId": entry.id.clone() } + }), + }; + let _ = app_handle.emit("app-server-event", payload); + + Ok(session) +} + +#[tauri::command] +pub(crate) async fn codex_doctor( + codex_bin: Option, + state: State<'_, AppState>, +) -> Result { + let default_bin = { + let settings = state.app_settings.lock().await; + settings.codex_bin.clone() + }; + let resolved = codex_bin + .clone() + .filter(|value| !value.trim().is_empty()) + .or(default_bin); + let version = check_codex_installation(resolved.clone()).await?; + let mut command = build_codex_command_with_bin(resolved.clone()); + command.arg("app-server"); + command.arg("--help"); + command.stdout(std::process::Stdio::piped()); + command.stderr(std::process::Stdio::piped()); + let app_server_ok = match timeout(Duration::from_secs(5), command.output()).await { + Ok(result) => result.map(|output| output.status.success()).unwrap_or(false), + Err(_) => false, + }; + let details = if app_server_ok { + None + } else { + Some("Failed to run `codex app-server --help`.".to_string()) + }; + Ok(json!({ + "ok": version.is_some() && app_server_ok, + "codexBin": resolved, + "version": version, + "appServerOk": app_server_ok, + "details": details, + })) +} + +#[tauri::command] +pub(crate) async fn start_thread( + workspace_id: String, + state: State<'_, AppState>, +) -> Result { + let sessions = state.sessions.lock().await; + let session = sessions + .get(&workspace_id) + .ok_or("workspace not connected")?; + let params = json!({ + "cwd": session.entry.path, + "approvalPolicy": "on-request" + }); + session.send_request("thread/start", params).await +} + +#[tauri::command] +pub(crate) async fn resume_thread( + workspace_id: String, + thread_id: String, + state: State<'_, AppState>, +) -> Result { + let sessions = state.sessions.lock().await; + let session = sessions + .get(&workspace_id) + .ok_or("workspace not connected")?; + let params = json!({ + "threadId": thread_id + }); + session.send_request("thread/resume", params).await +} + +#[tauri::command] +pub(crate) async fn list_threads( + workspace_id: String, + cursor: Option, + limit: Option, + state: State<'_, AppState>, +) -> Result { + let sessions = state.sessions.lock().await; + let session = sessions + .get(&workspace_id) + .ok_or("workspace not connected")?; + let params = json!({ + "cursor": cursor, + "limit": limit, + }); + session.send_request("thread/list", params).await +} + +#[tauri::command] +pub(crate) async fn archive_thread( + workspace_id: String, + thread_id: String, + state: State<'_, AppState>, +) -> Result { + let sessions = state.sessions.lock().await; + let session = sessions + .get(&workspace_id) + .ok_or("workspace not connected")?; + let params = json!({ + "threadId": thread_id + }); + session.send_request("thread/archive", params).await +} + +#[tauri::command] +pub(crate) async fn send_user_message( + workspace_id: String, + thread_id: String, + text: String, + model: Option, + effort: Option, + access_mode: Option, + state: State<'_, AppState>, +) -> Result { + let sessions = state.sessions.lock().await; + let session = sessions + .get(&workspace_id) + .ok_or("workspace not connected")?; + let access_mode = access_mode.unwrap_or_else(|| "current".to_string()); + let sandbox_policy = match access_mode.as_str() { + "full-access" => json!({ + "type": "dangerFullAccess" + }), + "read-only" => json!({ + "type": "readOnly" + }), + _ => json!({ + "type": "workspaceWrite", + "writableRoots": [session.entry.path], + "networkAccess": true + }), + }; + + let approval_policy = if access_mode == "full-access" { + "never" + } else { + "on-request" + }; + + let params = json!({ + "threadId": thread_id, + "input": [{ "type": "text", "text": text }], + "cwd": session.entry.path, + "approvalPolicy": approval_policy, + "sandboxPolicy": sandbox_policy, + "model": model, + "effort": effort, + }); + session.send_request("turn/start", params).await +} + +#[tauri::command] +pub(crate) async fn turn_interrupt( + workspace_id: String, + thread_id: String, + turn_id: String, + state: State<'_, AppState>, +) -> Result { + let sessions = state.sessions.lock().await; + let session = sessions + .get(&workspace_id) + .ok_or("workspace not connected")?; + let params = json!({ + "threadId": thread_id, + "turnId": turn_id, + }); + session.send_request("turn/interrupt", params).await +} + +#[tauri::command] +pub(crate) async fn start_review( + workspace_id: String, + thread_id: String, + target: Value, + delivery: Option, + state: State<'_, AppState>, +) -> Result { + let sessions = state.sessions.lock().await; + let session = sessions + .get(&workspace_id) + .ok_or("workspace not connected")?; + let mut params = Map::new(); + params.insert("threadId".to_string(), json!(thread_id)); + params.insert("target".to_string(), target); + if let Some(delivery) = delivery { + params.insert("delivery".to_string(), json!(delivery)); + } + session + .send_request("review/start", Value::Object(params)) + .await +} + +#[tauri::command] +pub(crate) async fn model_list( + workspace_id: String, + state: State<'_, AppState>, +) -> Result { + let sessions = state.sessions.lock().await; + let session = sessions + .get(&workspace_id) + .ok_or("workspace not connected")?; + let params = json!({}); + session.send_request("model/list", params).await +} + +#[tauri::command] +pub(crate) async fn account_rate_limits( + workspace_id: String, + state: State<'_, AppState>, +) -> Result { + let sessions = state.sessions.lock().await; + let session = sessions + .get(&workspace_id) + .ok_or("workspace not connected")?; + session + .send_request("account/rateLimits/read", Value::Null) + .await +} + +#[tauri::command] +pub(crate) async fn skills_list( + workspace_id: String, + state: State<'_, AppState>, +) -> Result { + let sessions = state.sessions.lock().await; + let session = sessions + .get(&workspace_id) + .ok_or("workspace not connected")?; + let params = json!({ + "cwd": session.entry.path + }); + session.send_request("skills/list", params).await +} + +#[tauri::command] +pub(crate) async fn respond_to_server_request( + workspace_id: String, + request_id: u64, + result: Value, + state: State<'_, AppState>, +) -> Result<(), String> { + let sessions = state.sessions.lock().await; + let session = sessions + .get(&workspace_id) + .ok_or("workspace not connected")?; + session.send_response(request_id, result).await +} diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs new file mode 100644 index 000000000..41a540d0d --- /dev/null +++ b/src-tauri/src/git.rs @@ -0,0 +1,370 @@ +use git2::{BranchType, DiffOptions, Repository, Sort, Status, StatusOptions, Tree}; +use serde_json::json; +use tauri::State; + +use crate::state::AppState; +use crate::types::{ + BranchInfo, GitFileDiff, GitFileStatus, GitLogEntry, GitLogResponse, +}; +use crate::utils::normalize_git_path; + +fn checkout_branch(repo: &Repository, name: &str) -> Result<(), git2::Error> { + let refname = format!("refs/heads/{name}"); + repo.set_head(&refname)?; + let mut options = git2::build::CheckoutBuilder::new(); + options.safe(); + repo.checkout_head(Some(&mut options))?; + Ok(()) +} + +fn diff_stats_for_path( + repo: &Repository, + head_tree: Option<&Tree>, + path: &str, + include_index: bool, + include_workdir: bool, +) -> Result<(i64, i64), git2::Error> { + let mut additions = 0i64; + let mut deletions = 0i64; + + if include_index { + let mut options = DiffOptions::new(); + options.pathspec(path).include_untracked(true); + let diff = repo.diff_tree_to_index(head_tree, None, Some(&mut options))?; + let stats = diff.stats()?; + additions += stats.insertions() as i64; + deletions += stats.deletions() as i64; + } + + if include_workdir { + let mut options = DiffOptions::new(); + options + .pathspec(path) + .include_untracked(true) + .recurse_untracked_dirs(true) + .show_untracked_content(true); + let diff = repo.diff_index_to_workdir(None, Some(&mut options))?; + let stats = diff.stats()?; + additions += stats.insertions() as i64; + deletions += stats.deletions() as i64; + } + + Ok((additions, deletions)) +} + +fn diff_patch_to_string(patch: &mut git2::Patch) -> Result { + let buf = patch.to_buf()?; + Ok(buf + .as_str() + .map(|value| value.to_string()) + .unwrap_or_else(|| String::from_utf8_lossy(&buf).to_string())) +} + +#[tauri::command] +pub(crate) async fn get_git_status( + workspace_id: String, + state: State<'_, AppState>, +) -> Result { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + + let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + + let branch_name = repo + .head() + .ok() + .and_then(|head| head.shorthand().map(|s| s.to_string())) + .unwrap_or_else(|| "unknown".to_string()); + + let mut status_options = StatusOptions::new(); + status_options + .include_untracked(true) + .recurse_untracked_dirs(true) + .renames_head_to_index(true) + .renames_index_to_workdir(true) + .include_ignored(false); + + let statuses = repo + .statuses(Some(&mut status_options)) + .map_err(|e| e.to_string())?; + + let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok()); + + let mut files = Vec::new(); + let mut total_additions = 0i64; + let mut total_deletions = 0i64; + for entry in statuses.iter() { + let path = entry.path().unwrap_or(""); + if path.is_empty() { + continue; + } + let status = entry.status(); + let status_str = if status.contains(Status::WT_NEW) || status.contains(Status::INDEX_NEW) { + "A" + } else if status.contains(Status::WT_MODIFIED) || status.contains(Status::INDEX_MODIFIED) { + "M" + } else if status.contains(Status::WT_DELETED) || status.contains(Status::INDEX_DELETED) { + "D" + } else if status.contains(Status::WT_RENAMED) || status.contains(Status::INDEX_RENAMED) { + "R" + } else if status.contains(Status::WT_TYPECHANGE) + || status.contains(Status::INDEX_TYPECHANGE) + { + "T" + } else { + "--" + }; + let normalized_path = normalize_git_path(path); + let include_index = status.intersects( + Status::INDEX_NEW + | Status::INDEX_MODIFIED + | Status::INDEX_DELETED + | Status::INDEX_RENAMED + | Status::INDEX_TYPECHANGE, + ); + let include_workdir = status.intersects( + Status::WT_NEW + | Status::WT_MODIFIED + | Status::WT_DELETED + | Status::WT_RENAMED + | Status::WT_TYPECHANGE, + ); + let (additions, deletions) = diff_stats_for_path( + &repo, + head_tree.as_ref(), + path, + include_index, + include_workdir, + ) + .map_err(|e| e.to_string())?; + total_additions += additions; + total_deletions += deletions; + files.push(GitFileStatus { + path: normalized_path, + status: status_str.to_string(), + additions, + deletions, + }); + } + + Ok(json!({ + "branchName": branch_name, + "files": files, + "totalAdditions": total_additions, + "totalDeletions": total_deletions, + })) +} + +#[tauri::command] +pub(crate) async fn get_git_diffs( + workspace_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + + let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + let head_tree = repo + .head() + .ok() + .and_then(|head| head.peel_to_tree().ok()); + + let mut options = DiffOptions::new(); + options + .include_untracked(true) + .recurse_untracked_dirs(true) + .show_untracked_content(true); + + let diff = match head_tree.as_ref() { + Some(tree) => repo + .diff_tree_to_workdir_with_index(Some(tree), Some(&mut options)) + .map_err(|e| e.to_string())?, + None => repo + .diff_tree_to_workdir_with_index(None, Some(&mut options)) + .map_err(|e| e.to_string())?, + }; + + let mut results = Vec::new(); + for (index, delta) in diff.deltas().enumerate() { + let path = delta + .new_file() + .path() + .or_else(|| delta.old_file().path()); + let Some(path) = path else { + continue; + }; + let patch = match git2::Patch::from_diff(&diff, index) { + Ok(patch) => patch, + Err(_) => continue, + }; + let Some(mut patch) = patch else { + continue; + }; + let content = match diff_patch_to_string(&mut patch) { + Ok(content) => content, + Err(_) => continue, + }; + if content.trim().is_empty() { + continue; + } + results.push(GitFileDiff { + path: normalize_git_path(path.to_string_lossy().as_ref()), + diff: content, + }); + } + + Ok(results) +} + +#[tauri::command] +pub(crate) async fn get_git_log( + workspace_id: String, + limit: Option, + state: State<'_, AppState>, +) -> Result { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + + let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + let max_items = limit.unwrap_or(40); + let mut revwalk = repo.revwalk().map_err(|e| e.to_string())?; + revwalk.push_head().map_err(|e| e.to_string())?; + revwalk + .set_sorting(Sort::TIME) + .map_err(|e| e.to_string())?; + + let mut total = 0usize; + for oid_result in revwalk { + oid_result.map_err(|e| e.to_string())?; + total += 1; + } + + let mut revwalk = repo.revwalk().map_err(|e| e.to_string())?; + revwalk.push_head().map_err(|e| e.to_string())?; + revwalk + .set_sorting(Sort::TIME) + .map_err(|e| e.to_string())?; + + let mut entries = Vec::new(); + for oid_result in revwalk.take(max_items) { + let oid = oid_result.map_err(|e| e.to_string())?; + let commit = repo.find_commit(oid).map_err(|e| e.to_string())?; + let summary = commit.summary().unwrap_or("").to_string(); + let author = commit.author().name().unwrap_or("").to_string(); + let timestamp = commit.time().seconds(); + entries.push(GitLogEntry { + sha: commit.id().to_string(), + summary, + author, + timestamp, + }); + } + + Ok(GitLogResponse { total, entries }) +} + +#[tauri::command] +pub(crate) async fn get_git_remote( + workspace_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + + let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + let remotes = repo.remotes().map_err(|e| e.to_string())?; + let name = if remotes.iter().any(|remote| remote == Some("origin")) { + "origin".to_string() + } else { + remotes + .iter() + .flatten() + .next() + .unwrap_or("") + .to_string() + }; + if name.is_empty() { + return Ok(None); + } + let remote = repo.find_remote(&name).map_err(|e| e.to_string())?; + Ok(remote.url().map(|url| url.to_string())) +} + +#[tauri::command] +pub(crate) async fn list_git_branches( + workspace_id: String, + state: State<'_, AppState>, +) -> Result { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + let mut branches = Vec::new(); + let refs = repo + .branches(Some(BranchType::Local)) + .map_err(|e| e.to_string())?; + for branch_result in refs { + let (branch, _) = branch_result.map_err(|e| e.to_string())?; + let name = branch.name().ok().flatten().unwrap_or("").to_string(); + if name.is_empty() { + continue; + } + let last_commit = branch + .get() + .target() + .and_then(|oid| repo.find_commit(oid).ok()) + .map(|commit| commit.time().seconds()) + .unwrap_or(0); + branches.push(BranchInfo { name, last_commit }); + } + branches.sort_by(|a, b| b.last_commit.cmp(&a.last_commit)); + Ok(json!({ "branches": branches })) +} + +#[tauri::command] +pub(crate) async fn checkout_git_branch( + workspace_id: String, + name: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + checkout_branch(&repo, &name).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub(crate) async fn create_git_branch( + workspace_id: String, + name: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + let head = repo.head().map_err(|e| e.to_string())?; + let target = head.peel_to_commit().map_err(|e| e.to_string())?; + repo.branch(&name, &target, false) + .map_err(|e| e.to_string())?; + checkout_branch(&repo, &name).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e74e9a7e2..970826db0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,1624 +1,14 @@ -use serde::{Deserialize, Serialize}; -use serde_json::{json, Map, Value}; -use std::collections::HashMap; -use std::env; -use std::io::{ErrorKind, Write}; -use std::path::PathBuf; -use std::time::Duration; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; -use git2::{BranchType, DiffOptions, Repository, Sort, Status, StatusOptions, Tree}; -use git2::build::CheckoutBuilder; -use tauri::{AppHandle, Emitter, Manager, State}; use tauri::menu::{Menu, MenuItemBuilder, PredefinedMenuItem, Submenu}; -use tauri::{WebviewUrl, WebviewWindowBuilder}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::process::{Child, ChildStdin, Command}; -use tokio::sync::{Mutex, oneshot}; -use tokio::time::timeout; -use uuid::Uuid; +use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; -#[derive(Debug, Serialize, Deserialize, Clone)] -struct GitFileStatus { - path: String, - status: String, - additions: i64, - deletions: i64, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct GitFileDiff { - path: String, - diff: String, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct GitLogEntry { - sha: String, - summary: String, - author: String, - timestamp: i64, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct GitLogResponse { - total: usize, - entries: Vec, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct BranchInfo { - name: String, - last_commit: i64, -} - -fn normalize_git_path(path: &str) -> String { - path.replace('\\', "/") -} - -fn sanitize_worktree_name(branch: &str) -> String { - let mut result = String::new(); - for ch in branch.chars() { - if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { - result.push(ch); - } else { - result.push('-'); - } - } - let trimmed = result.trim_matches('-').to_string(); - if trimmed.is_empty() { - "worktree".to_string() - } else { - trimmed - } -} - -fn should_skip_dir(name: &str) -> bool { - matches!( - name, - ".git" | "node_modules" | "dist" | "target" | "release-artifacts" - ) -} - -fn list_workspace_files_inner(root: &PathBuf, max_files: usize) -> Vec { - let mut results = Vec::new(); - let mut stack = vec![root.clone()]; - - while let Some(dir) = stack.pop() { - let entries = match std::fs::read_dir(&dir) { - Ok(entries) => entries, - Err(_) => continue, - }; - for entry in entries.flatten() { - let path = entry.path(); - let file_name = entry - .file_name() - .to_string_lossy() - .to_string(); - if path.is_dir() { - if should_skip_dir(&file_name) { - continue; - } - stack.push(path); - continue; - } - if path.is_file() { - if let Ok(rel_path) = path.strip_prefix(root) { - let normalized = normalize_git_path(&rel_path.to_string_lossy()); - if !normalized.is_empty() { - results.push(normalized); - } - } - } - if results.len() >= max_files { - return results; - } - } - } - - results.sort(); - results -} - -async fn run_git_command(repo_path: &PathBuf, args: &[&str]) -> Result { - let output = Command::new("git") - .args(args) - .current_dir(repo_path) - .output() - .await - .map_err(|e| format!("Failed to run git: {e}"))?; - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - let detail = if stderr.trim().is_empty() { - stdout.trim() - } else { - stderr.trim() - }; - if detail.is_empty() { - Err("Git command failed.".to_string()) - } else { - Err(detail.to_string()) - } - } -} - -async fn git_branch_exists(repo_path: &PathBuf, branch: &str) -> Result { - let status = Command::new("git") - .args(["show-ref", "--verify", &format!("refs/heads/{branch}")]) - .current_dir(repo_path) - .status() - .await - .map_err(|e| format!("Failed to run git: {e}"))?; - Ok(status.success()) -} - -fn unique_worktree_path(base_dir: &PathBuf, name: &str) -> PathBuf { - let mut candidate = base_dir.join(name); - if !candidate.exists() { - return candidate; - } - for index in 2..1000 { - let next = base_dir.join(format!("{name}-{index}")); - if !next.exists() { - candidate = next; - break; - } - } - candidate -} - -fn ensure_worktree_ignored(repo_path: &PathBuf) -> Result<(), String> { - let ignore_path = repo_path.join(".gitignore"); - let entry = ".codex-worktrees/"; - let existing = std::fs::read_to_string(&ignore_path).unwrap_or_default(); - if existing.lines().any(|line| line.trim() == entry) { - return Ok(()); - } - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&ignore_path) - .map_err(|e| format!("Failed to update .gitignore: {e}"))?; - if !existing.ends_with('\n') && !existing.is_empty() { - file.write_all(b"\n") - .map_err(|e| format!("Failed to update .gitignore: {e}"))?; - } - file.write_all(format!("{entry}\n").as_bytes()) - .map_err(|e| format!("Failed to update .gitignore: {e}"))?; - Ok(()) -} - -fn checkout_branch(repo: &Repository, name: &str) -> Result<(), git2::Error> { - let refname = format!("refs/heads/{name}"); - repo.set_head(&refname)?; - let mut options = CheckoutBuilder::new(); - options.safe(); - repo.checkout_head(Some(&mut options))?; - Ok(()) -} - -fn diff_stats_for_path( - repo: &Repository, - head_tree: Option<&Tree>, - path: &str, - include_index: bool, - include_workdir: bool, -) -> Result<(i64, i64), git2::Error> { - let mut additions = 0i64; - let mut deletions = 0i64; - - if include_index { - let mut options = DiffOptions::new(); - options.pathspec(path).include_untracked(true); - let diff = repo.diff_tree_to_index(head_tree, None, Some(&mut options))?; - let stats = diff.stats()?; - additions += stats.insertions() as i64; - deletions += stats.deletions() as i64; - } - - if include_workdir { - let mut options = DiffOptions::new(); - options - .pathspec(path) - .include_untracked(true) - .recurse_untracked_dirs(true) - .show_untracked_content(true); - let diff = repo.diff_index_to_workdir(None, Some(&mut options))?; - let stats = diff.stats()?; - additions += stats.insertions() as i64; - deletions += stats.deletions() as i64; - } - - Ok((additions, deletions)) -} - -fn diff_patch_to_string(patch: &mut git2::Patch) -> Result { - let buf = patch.to_buf()?; - Ok(buf - .as_str() - .map(|value| value.to_string()) - .unwrap_or_else(|| String::from_utf8_lossy(&buf).to_string())) -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct WorkspaceEntry { - id: String, - name: String, - path: String, - codex_bin: Option, - #[serde(default)] - kind: WorkspaceKind, - #[serde(default, rename = "parentId")] - parent_id: Option, - #[serde(default)] - worktree: Option, - #[serde(default)] - settings: WorkspaceSettings, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct WorkspaceInfo { - id: String, - name: String, - path: String, - connected: bool, - codex_bin: Option, - #[serde(default)] - kind: WorkspaceKind, - #[serde(default, rename = "parentId")] - parent_id: Option, - #[serde(default)] - worktree: Option, - #[serde(default)] - settings: WorkspaceSettings, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "lowercase")] -enum WorkspaceKind { - Main, - Worktree, -} - -impl Default for WorkspaceKind { - fn default() -> Self { - WorkspaceKind::Main - } -} - -impl WorkspaceKind { - fn is_worktree(&self) -> bool { - matches!(self, WorkspaceKind::Worktree) - } -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct WorktreeInfo { - branch: String, -} - -#[derive(Debug, Serialize, Deserialize, Clone, Default)] -struct WorkspaceSettings { - #[serde(default, rename = "sidebarCollapsed")] - sidebar_collapsed: bool, - #[serde(default, rename = "sortOrder")] - sort_order: Option, -} - -#[derive(Serialize, Clone)] -struct AppServerEvent { - workspace_id: String, - message: Value, -} - -struct WorkspaceSession { - entry: WorkspaceEntry, - child: Mutex, - stdin: Mutex, - pending: Mutex>>, - next_id: AtomicU64, -} - -impl WorkspaceSession { - async fn write_message(&self, value: Value) -> Result<(), String> { - let mut stdin = self.stdin.lock().await; - let mut line = serde_json::to_string(&value).map_err(|e| e.to_string())?; - line.push('\n'); - stdin - .write_all(line.as_bytes()) - .await - .map_err(|e| e.to_string()) - } - - async fn send_request(&self, method: &str, params: Value) -> Result { - let id = self.next_id.fetch_add(1, Ordering::SeqCst); - let (tx, rx) = oneshot::channel(); - self.pending.lock().await.insert(id, tx); - self.write_message(json!({ "id": id, "method": method, "params": params })) - .await?; - rx.await.map_err(|_| "request canceled".to_string()) - } - - async fn send_notification(&self, method: &str, params: Option) -> Result<(), String> { - let value = if let Some(params) = params { - json!({ "method": method, "params": params }) - } else { - json!({ "method": method }) - }; - self.write_message(value).await - } - - async fn send_response(&self, id: u64, result: Value) -> Result<(), String> { - self.write_message(json!({ "id": id, "result": result })) - .await - } -} - -struct AppState { - workspaces: Mutex>, - sessions: Mutex>>, - storage_path: PathBuf, - settings_path: PathBuf, - app_settings: Mutex, -} - -impl AppState { - fn load(app: &AppHandle) -> Self { - let data_dir = app - .path() - .app_data_dir() - .unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| ".".into())); - let storage_path = data_dir.join("workspaces.json"); - let settings_path = data_dir.join("settings.json"); - let workspaces = read_workspaces(&storage_path).unwrap_or_default(); - let app_settings = read_settings(&settings_path).unwrap_or_default(); - Self { - workspaces: Mutex::new(workspaces), - sessions: Mutex::new(HashMap::new()), - storage_path, - settings_path, - app_settings: Mutex::new(app_settings), - } - } -} - -fn read_workspaces(path: &PathBuf) -> Result, String> { - if !path.exists() { - return Ok(HashMap::new()); - } - let data = std::fs::read_to_string(path).map_err(|e| e.to_string())?; - let list: Vec = serde_json::from_str(&data).map_err(|e| e.to_string())?; - Ok(list.into_iter().map(|entry| (entry.id.clone(), entry)).collect()) -} - -fn write_workspaces(path: &PathBuf, entries: &[WorkspaceEntry]) -> Result<(), String> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - let data = serde_json::to_string_pretty(entries).map_err(|e| e.to_string())?; - std::fs::write(path, data).map_err(|e| e.to_string()) -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct AppSettings { - #[serde(default, rename = "codexBin")] - codex_bin: Option, - #[serde(default, rename = "defaultAccessMode")] - default_access_mode: String, -} - -impl Default for AppSettings { - fn default() -> Self { - Self { - codex_bin: None, - default_access_mode: "current".to_string(), - } - } -} - -fn read_settings(path: &PathBuf) -> Result { - if !path.exists() { - return Ok(AppSettings::default()); - } - let data = std::fs::read_to_string(path).map_err(|e| e.to_string())?; - serde_json::from_str(&data).map_err(|e| e.to_string()) -} - -fn write_settings(path: &PathBuf, settings: &AppSettings) -> Result<(), String> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - let data = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?; - std::fs::write(path, data).map_err(|e| e.to_string()) -} - -fn build_codex_command_with_bin(codex_bin: Option) -> Command { - let default_bin = codex_bin - .as_ref() - .map(|value| value.trim().is_empty()) - .unwrap_or(true); - let bin = codex_bin - .clone() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "codex".into()); - let mut command = Command::new(bin); - if default_bin { - let mut paths: Vec = env::var("PATH") - .unwrap_or_default() - .split(':') - .filter(|value| !value.is_empty()) - .map(|value| value.to_string()) - .collect(); - let mut extras = vec![ - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - "/bin", - "/usr/sbin", - "/sbin", - ] - .into_iter() - .map(|value| value.to_string()) - .collect::>(); - if let Ok(home) = env::var("HOME") { - extras.push(format!("{home}/.local/bin")); - extras.push(format!("{home}/.cargo/bin")); - } - for extra in extras { - if !paths.contains(&extra) { - paths.push(extra); - } - } - if !paths.is_empty() { - command.env("PATH", paths.join(":")); - } - } - command -} - -async fn check_codex_installation(codex_bin: Option) -> Result, String> { - let mut command = build_codex_command_with_bin(codex_bin); - command.arg("--version"); - command.stdout(std::process::Stdio::piped()); - command.stderr(std::process::Stdio::piped()); - - let output = match timeout(Duration::from_secs(5), command.output()).await { - Ok(result) => result.map_err(|e| { - if e.kind() == ErrorKind::NotFound { - "Codex CLI not found. Install Codex and ensure `codex` is on your PATH." - .to_string() - } else { - e.to_string() - } - })?, - Err(_) => { - return Err( - "Timed out while checking Codex CLI. Make sure `codex --version` runs in Terminal." - .to_string(), - ); - } - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - let detail = if stderr.trim().is_empty() { - stdout.trim() - } else { - stderr.trim() - }; - if detail.is_empty() { - return Err( - "Codex CLI failed to start. Try running `codex --version` in Terminal." - .to_string(), - ); - } - return Err(format!( - "Codex CLI failed to start: {detail}. Try running `codex --version` in Terminal." - )); - } - - let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Ok(if version.is_empty() { None } else { Some(version) }) -} - -async fn spawn_workspace_session( - entry: WorkspaceEntry, - default_codex_bin: Option, - app_handle: AppHandle, -) -> Result, String> { - let codex_bin = entry - .codex_bin - .clone() - .filter(|value| !value.trim().is_empty()) - .or(default_codex_bin); - let _ = check_codex_installation(codex_bin.clone()).await?; - - let mut command = build_codex_command_with_bin(codex_bin); - command.arg("app-server"); - command.stdin(std::process::Stdio::piped()); - command.stdout(std::process::Stdio::piped()); - command.stderr(std::process::Stdio::piped()); - - let mut child = command.spawn().map_err(|e| e.to_string())?; - let stdin = child.stdin.take().ok_or("missing stdin")?; - let stdout = child.stdout.take().ok_or("missing stdout")?; - let stderr = child.stderr.take().ok_or("missing stderr")?; - - let session = Arc::new(WorkspaceSession { - entry: entry.clone(), - child: Mutex::new(child), - stdin: Mutex::new(stdin), - pending: Mutex::new(HashMap::new()), - next_id: AtomicU64::new(1), - }); - - let session_clone = Arc::clone(&session); - let workspace_id = entry.id.clone(); - let app_handle_clone = app_handle.clone(); - tauri::async_runtime::spawn(async move { - let mut lines = BufReader::new(stdout).lines(); - while let Ok(Some(line)) = lines.next_line().await { - if line.trim().is_empty() { - continue; - } - let value: Value = match serde_json::from_str(&line) { - Ok(value) => value, - Err(err) => { - let payload = AppServerEvent { - workspace_id: workspace_id.clone(), - message: json!({ - "method": "codex/parseError", - "params": { "error": err.to_string(), "raw": line }, - }), - }; - let _ = app_handle_clone.emit("app-server-event", payload); - continue; - } - }; - - let maybe_id = value.get("id").and_then(|id| id.as_u64()); - let has_method = value.get("method").is_some(); - let has_result_or_error = - value.get("result").is_some() || value.get("error").is_some(); - if let Some(id) = maybe_id { - if has_result_or_error { - if let Some(tx) = session_clone.pending.lock().await.remove(&id) { - let _ = tx.send(value); - } - } else if has_method { - let payload = AppServerEvent { - workspace_id: workspace_id.clone(), - message: value, - }; - let _ = app_handle_clone.emit("app-server-event", payload); - } else if let Some(tx) = session_clone.pending.lock().await.remove(&id) { - let _ = tx.send(value); - } - } else if has_method { - let payload = AppServerEvent { - workspace_id: workspace_id.clone(), - message: value, - }; - let _ = app_handle_clone.emit("app-server-event", payload); - } - } - }); - - let workspace_id = entry.id.clone(); - let app_handle_clone = app_handle.clone(); - tauri::async_runtime::spawn(async move { - let mut lines = BufReader::new(stderr).lines(); - while let Ok(Some(line)) = lines.next_line().await { - if line.trim().is_empty() { - continue; - } - let payload = AppServerEvent { - workspace_id: workspace_id.clone(), - message: json!({ - "method": "codex/stderr", - "params": { "message": line }, - }), - }; - let _ = app_handle_clone.emit("app-server-event", payload); - } - }); - - let init_params = json!({ - "clientInfo": { - "name": "codex_monitor", - "title": "CodexMonitor", - "version": "0.1.0" - } - }); - let init_result = timeout( - Duration::from_secs(15), - session.send_request("initialize", init_params), - ) - .await; - let init_response = match init_result { - Ok(response) => response, - Err(_) => { - let mut child = session.child.lock().await; - let _ = child.kill().await; - return Err( - "Codex app-server did not respond to initialize. Check that `codex app-server` works in Terminal." - .to_string(), - ); - } - }; - init_response?; - session.send_notification("initialized", None).await?; - - let payload = AppServerEvent { - workspace_id: entry.id.clone(), - message: json!({ - "method": "codex/connected", - "params": { "workspaceId": entry.id.clone() } - }), - }; - let _ = app_handle.emit("app-server-event", payload); - - Ok(session) -} - -#[tauri::command] -async fn list_workspaces(state: State<'_, AppState>) -> Result, String> { - let workspaces = state.workspaces.lock().await; - let sessions = state.sessions.lock().await; - let mut result = Vec::new(); - for entry in workspaces.values() { - result.push(WorkspaceInfo { - id: entry.id.clone(), - name: entry.name.clone(), - path: entry.path.clone(), - codex_bin: entry.codex_bin.clone(), - connected: sessions.contains_key(&entry.id), - kind: entry.kind.clone(), - parent_id: entry.parent_id.clone(), - worktree: entry.worktree.clone(), - settings: entry.settings.clone(), - }); - } - result.sort_by(|a, b| { - let a_order = a.settings.sort_order.unwrap_or(u32::MAX); - let b_order = b.settings.sort_order.unwrap_or(u32::MAX); - a_order.cmp(&b_order).then_with(|| a.name.cmp(&b.name)) - }); - Ok(result) -} - -#[tauri::command] -async fn get_app_settings(state: State<'_, AppState>) -> Result { - let settings = state.app_settings.lock().await; - Ok(settings.clone()) -} - -#[tauri::command] -async fn update_app_settings( - settings: AppSettings, - state: State<'_, AppState>, -) -> Result { - write_settings(&state.settings_path, &settings)?; - let mut current = state.app_settings.lock().await; - *current = settings.clone(); - Ok(settings) -} - -#[tauri::command] -async fn codex_doctor( - codex_bin: Option, - state: State<'_, AppState>, -) -> Result { - let default_bin = { - let settings = state.app_settings.lock().await; - settings.codex_bin.clone() - }; - let resolved = codex_bin - .clone() - .filter(|value| !value.trim().is_empty()) - .or(default_bin); - let version = check_codex_installation(resolved.clone()).await?; - let mut command = build_codex_command_with_bin(resolved.clone()); - command.arg("app-server"); - command.arg("--help"); - command.stdout(std::process::Stdio::piped()); - command.stderr(std::process::Stdio::piped()); - let app_server_ok = match timeout(Duration::from_secs(5), command.output()).await { - Ok(result) => result.map(|output| output.status.success()).unwrap_or(false), - Err(_) => false, - }; - let details = if app_server_ok { - None - } else { - Some("Failed to run `codex app-server --help`.".to_string()) - }; - Ok(json!({ - "ok": version.is_some() && app_server_ok, - "codexBin": resolved, - "version": version, - "appServerOk": app_server_ok, - "details": details, - })) -} - -#[tauri::command] -async fn add_workspace( - path: String, - codex_bin: Option, - state: State<'_, AppState>, - app: AppHandle, -) -> Result { - let name = PathBuf::from(&path) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("Workspace") - .to_string(); - let entry = WorkspaceEntry { - id: Uuid::new_v4().to_string(), - name: name.clone(), - path: path.clone(), - codex_bin, - kind: WorkspaceKind::Main, - parent_id: None, - worktree: None, - settings: WorkspaceSettings::default(), - }; - - let default_bin = { - let settings = state.app_settings.lock().await; - settings.codex_bin.clone() - }; - let session = spawn_workspace_session(entry.clone(), default_bin, app).await?; - { - let mut workspaces = state.workspaces.lock().await; - workspaces.insert(entry.id.clone(), entry.clone()); - let list: Vec<_> = workspaces.values().cloned().collect(); - write_workspaces(&state.storage_path, &list)?; - } - state - .sessions - .lock() - .await - .insert(entry.id.clone(), session); - - Ok(WorkspaceInfo { - id: entry.id, - name: entry.name, - path: entry.path, - codex_bin: entry.codex_bin, - connected: true, - kind: entry.kind, - parent_id: entry.parent_id, - worktree: entry.worktree, - settings: entry.settings, - }) -} - -#[tauri::command] -async fn add_worktree( - parent_id: String, - branch: String, - state: State<'_, AppState>, - app: AppHandle, -) -> Result { - let branch = branch.trim(); - if branch.is_empty() { - return Err("Branch name is required.".to_string()); - } - - let parent_entry = { - let workspaces = state.workspaces.lock().await; - workspaces - .get(&parent_id) - .cloned() - .ok_or("parent workspace not found")? - }; - - if parent_entry.kind.is_worktree() { - return Err("Cannot create a worktree from another worktree.".to_string()); - } - - let worktree_root = PathBuf::from(&parent_entry.path).join(".codex-worktrees"); - std::fs::create_dir_all(&worktree_root) - .map_err(|e| format!("Failed to create worktree directory: {e}"))?; - ensure_worktree_ignored(&PathBuf::from(&parent_entry.path))?; - - let safe_name = sanitize_worktree_name(branch); - let worktree_path = unique_worktree_path(&worktree_root, &safe_name); - let worktree_path_string = worktree_path.to_string_lossy().to_string(); - - let branch_exists = git_branch_exists(&PathBuf::from(&parent_entry.path), branch).await?; - if branch_exists { - run_git_command( - &PathBuf::from(&parent_entry.path), - &["worktree", "add", &worktree_path_string, branch], - ) - .await?; - } else { - run_git_command( - &PathBuf::from(&parent_entry.path), - &["worktree", "add", "-b", branch, &worktree_path_string], - ) - .await?; - } - - let entry = WorkspaceEntry { - id: Uuid::new_v4().to_string(), - name: branch.to_string(), - path: worktree_path_string, - codex_bin: parent_entry.codex_bin.clone(), - kind: WorkspaceKind::Worktree, - parent_id: Some(parent_entry.id.clone()), - worktree: Some(WorktreeInfo { - branch: branch.to_string(), - }), - settings: WorkspaceSettings::default(), - }; - - let default_bin = { - let settings = state.app_settings.lock().await; - settings.codex_bin.clone() - }; - let session = spawn_workspace_session(entry.clone(), default_bin, app).await?; - { - let mut workspaces = state.workspaces.lock().await; - workspaces.insert(entry.id.clone(), entry.clone()); - let list: Vec<_> = workspaces.values().cloned().collect(); - write_workspaces(&state.storage_path, &list)?; - } - state - .sessions - .lock() - .await - .insert(entry.id.clone(), session); - - Ok(WorkspaceInfo { - id: entry.id, - name: entry.name, - path: entry.path, - codex_bin: entry.codex_bin, - connected: true, - kind: entry.kind, - parent_id: entry.parent_id, - worktree: entry.worktree, - settings: entry.settings, - }) -} - -#[tauri::command] -async fn remove_workspace( - id: String, - state: State<'_, AppState>, -) -> Result<(), String> { - let (entry, child_worktrees) = { - let workspaces = state.workspaces.lock().await; - let entry = workspaces - .get(&id) - .cloned() - .ok_or("workspace not found")?; - if entry.kind.is_worktree() { - return Err("Use remove_worktree for worktree agents.".to_string()); - } - let children = workspaces - .values() - .filter(|workspace| workspace.parent_id.as_deref() == Some(&id)) - .cloned() - .collect::>(); - (entry, children) - }; - - let parent_path = PathBuf::from(&entry.path); - for child in &child_worktrees { - if let Some(session) = state.sessions.lock().await.remove(&child.id) { - let mut child_process = session.child.lock().await; - let _ = child_process.kill().await; - } - let child_path = PathBuf::from(&child.path); - if child_path.exists() { - run_git_command( - &parent_path, - &["worktree", "remove", "--force", &child.path], - ) - .await?; - } - } - let _ = run_git_command(&parent_path, &["worktree", "prune", "--expire", "now"]).await; - - if let Some(session) = state.sessions.lock().await.remove(&id) { - let mut child = session.child.lock().await; - let _ = child.kill().await; - } - - { - let mut workspaces = state.workspaces.lock().await; - workspaces.remove(&id); - for child in child_worktrees { - workspaces.remove(&child.id); - } - let list: Vec<_> = workspaces.values().cloned().collect(); - write_workspaces(&state.storage_path, &list)?; - } - - Ok(()) -} - -#[tauri::command] -async fn remove_worktree( - id: String, - state: State<'_, AppState>, -) -> Result<(), String> { - let (entry, parent) = { - let workspaces = state.workspaces.lock().await; - let entry = workspaces - .get(&id) - .cloned() - .ok_or("workspace not found")?; - if !entry.kind.is_worktree() { - return Err("Not a worktree workspace.".to_string()); - } - let parent_id = entry - .parent_id - .clone() - .ok_or("worktree parent not found")?; - let parent = workspaces - .get(&parent_id) - .cloned() - .ok_or("worktree parent not found")?; - (entry, parent) - }; - - if let Some(session) = state.sessions.lock().await.remove(&entry.id) { - let mut child = session.child.lock().await; - let _ = child.kill().await; - } - - let parent_path = PathBuf::from(&parent.path); - let entry_path = PathBuf::from(&entry.path); - if entry_path.exists() { - run_git_command( - &parent_path, - &["worktree", "remove", "--force", &entry.path], - ) - .await?; - } - let _ = run_git_command(&parent_path, &["worktree", "prune", "--expire", "now"]).await; - - { - let mut workspaces = state.workspaces.lock().await; - workspaces.remove(&entry.id); - let list: Vec<_> = workspaces.values().cloned().collect(); - write_workspaces(&state.storage_path, &list)?; - } - - Ok(()) -} - -#[tauri::command] -async fn update_workspace_settings( - id: String, - settings: WorkspaceSettings, - state: State<'_, AppState>, -) -> Result { - let (entry_snapshot, list) = { - let mut workspaces = state.workspaces.lock().await; - let entry_snapshot = match workspaces.get_mut(&id) { - Some(entry) => { - entry.settings = settings.clone(); - entry.clone() - } - None => return Err("workspace not found".to_string()), - }; - let list: Vec<_> = workspaces.values().cloned().collect(); - (entry_snapshot, list) - }; - write_workspaces(&state.storage_path, &list)?; - - let connected = state.sessions.lock().await.contains_key(&id); - Ok(WorkspaceInfo { - id: entry_snapshot.id, - name: entry_snapshot.name, - path: entry_snapshot.path, - codex_bin: entry_snapshot.codex_bin, - connected, - kind: entry_snapshot.kind, - parent_id: entry_snapshot.parent_id, - worktree: entry_snapshot.worktree, - settings: entry_snapshot.settings, - }) -} - -#[tauri::command] -async fn update_workspace_codex_bin( - id: String, - codex_bin: Option, - state: State<'_, AppState>, -) -> Result { - let (entry_snapshot, list) = { - let mut workspaces = state.workspaces.lock().await; - let entry_snapshot = match workspaces.get_mut(&id) { - Some(entry) => { - entry.codex_bin = codex_bin.clone(); - entry.clone() - } - None => return Err("workspace not found".to_string()), - }; - let list: Vec<_> = workspaces.values().cloned().collect(); - (entry_snapshot, list) - }; - write_workspaces(&state.storage_path, &list)?; - - let connected = state.sessions.lock().await.contains_key(&id); - Ok(WorkspaceInfo { - id: entry_snapshot.id, - name: entry_snapshot.name, - path: entry_snapshot.path, - codex_bin: entry_snapshot.codex_bin, - connected, - kind: entry_snapshot.kind, - parent_id: entry_snapshot.parent_id, - worktree: entry_snapshot.worktree, - settings: entry_snapshot.settings, - }) -} - -#[tauri::command] -async fn start_thread( - workspace_id: String, - state: State<'_, AppState>, -) -> Result { - let sessions = state.sessions.lock().await; - let session = sessions - .get(&workspace_id) - .ok_or("workspace not connected")?; - let params = json!({ - "cwd": session.entry.path, - "approvalPolicy": "on-request" - }); - session.send_request("thread/start", params).await -} - -#[tauri::command] -async fn resume_thread( - workspace_id: String, - thread_id: String, - state: State<'_, AppState>, -) -> Result { - let sessions = state.sessions.lock().await; - let session = sessions - .get(&workspace_id) - .ok_or("workspace not connected")?; - let params = json!({ - "threadId": thread_id - }); - session.send_request("thread/resume", params).await -} - -#[tauri::command] -async fn list_threads( - workspace_id: String, - cursor: Option, - limit: Option, - state: State<'_, AppState>, -) -> Result { - let sessions = state.sessions.lock().await; - let session = sessions - .get(&workspace_id) - .ok_or("workspace not connected")?; - let params = json!({ - "cursor": cursor, - "limit": limit, - }); - session.send_request("thread/list", params).await -} - -#[tauri::command] -async fn archive_thread( - workspace_id: String, - thread_id: String, - state: State<'_, AppState>, -) -> Result { - let sessions = state.sessions.lock().await; - let session = sessions - .get(&workspace_id) - .ok_or("workspace not connected")?; - let params = json!({ - "threadId": thread_id - }); - session.send_request("thread/archive", params).await -} - -#[tauri::command] -async fn send_user_message( - workspace_id: String, - thread_id: String, - text: String, - model: Option, - effort: Option, - access_mode: Option, - state: State<'_, AppState>, -) -> Result { - let sessions = state.sessions.lock().await; - let session = sessions - .get(&workspace_id) - .ok_or("workspace not connected")?; - let access_mode = access_mode.unwrap_or_else(|| "current".to_string()); - let sandbox_policy = match access_mode.as_str() { - "full-access" => json!({ - "type": "dangerFullAccess" - }), - "read-only" => json!({ - "type": "readOnly" - }), - _ => json!({ - "type": "workspaceWrite", - "writableRoots": [session.entry.path], - "networkAccess": true - }), - }; - - let approval_policy = if access_mode == "full-access" { - "never" - } else { - "on-request" - }; - - let params = json!({ - "threadId": thread_id, - "input": [{ "type": "text", "text": text }], - "cwd": session.entry.path, - "approvalPolicy": approval_policy, - "sandboxPolicy": sandbox_policy, - "model": model, - "effort": effort, - }); - session.send_request("turn/start", params).await -} - -#[tauri::command] -async fn turn_interrupt( - workspace_id: String, - thread_id: String, - turn_id: String, - state: State<'_, AppState>, -) -> Result { - let sessions = state.sessions.lock().await; - let session = sessions - .get(&workspace_id) - .ok_or("workspace not connected")?; - let params = json!({ - "threadId": thread_id, - "turnId": turn_id, - }); - session.send_request("turn/interrupt", params).await -} - -#[tauri::command] -async fn start_review( - workspace_id: String, - thread_id: String, - target: Value, - delivery: Option, - state: State<'_, AppState>, -) -> Result { - let sessions = state.sessions.lock().await; - let session = sessions - .get(&workspace_id) - .ok_or("workspace not connected")?; - let mut params = Map::new(); - params.insert("threadId".to_string(), json!(thread_id)); - params.insert("target".to_string(), target); - if let Some(delivery) = delivery { - params.insert("delivery".to_string(), json!(delivery)); - } - session - .send_request("review/start", Value::Object(params)) - .await -} -#[tauri::command] -async fn model_list( - workspace_id: String, - state: State<'_, AppState>, -) -> Result { - let sessions = state.sessions.lock().await; - let session = sessions - .get(&workspace_id) - .ok_or("workspace not connected")?; - let params = json!({}); - session.send_request("model/list", params).await -} - -#[tauri::command] -async fn account_rate_limits( - workspace_id: String, - state: State<'_, AppState>, -) -> Result { - let sessions = state.sessions.lock().await; - let session = sessions - .get(&workspace_id) - .ok_or("workspace not connected")?; - session - .send_request("account/rateLimits/read", Value::Null) - .await -} - -#[tauri::command] -async fn skills_list( - workspace_id: String, - state: State<'_, AppState>, -) -> Result { - let sessions = state.sessions.lock().await; - let session = sessions - .get(&workspace_id) - .ok_or("workspace not connected")?; - let params = json!({ - "cwd": session.entry.path - }); - session.send_request("skills/list", params).await -} - -#[tauri::command] -async fn respond_to_server_request( - workspace_id: String, - request_id: u64, - result: Value, - state: State<'_, AppState>, -) -> Result<(), String> { - let sessions = state.sessions.lock().await; - let session = sessions - .get(&workspace_id) - .ok_or("workspace not connected")?; - session.send_response(request_id, result).await -} - -#[tauri::command] -async fn connect_workspace( - id: String, - state: State<'_, AppState>, - app: AppHandle, -) -> Result<(), String> { - let entry = { - let workspaces = state.workspaces.lock().await; - workspaces - .get(&id) - .cloned() - .ok_or("workspace not found")? - }; - - let default_bin = { - let settings = state.app_settings.lock().await; - settings.codex_bin.clone() - }; - let session = spawn_workspace_session(entry.clone(), default_bin, app).await?; - state.sessions.lock().await.insert(entry.id, session); - Ok(()) -} - -#[tauri::command] -async fn get_git_status( - workspace_id: String, - state: State<'_, AppState>, -) -> Result { - let workspaces = state.workspaces.lock().await; - let entry = workspaces - .get(&workspace_id) - .ok_or("workspace not found")? - .clone(); - - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; - - let branch_name = repo - .head() - .ok() - .and_then(|head| head.shorthand().map(|s| s.to_string())) - .unwrap_or_else(|| "unknown".to_string()); - - let mut status_options = StatusOptions::new(); - status_options - .include_untracked(true) - .recurse_untracked_dirs(true) - .renames_head_to_index(true) - .renames_index_to_workdir(true) - .include_ignored(false); - - let statuses = repo - .statuses(Some(&mut status_options)) - .map_err(|e| e.to_string())?; - - let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok()); - - let mut files = Vec::new(); - let mut total_additions = 0i64; - let mut total_deletions = 0i64; - for entry in statuses.iter() { - let path = entry.path().unwrap_or(""); - if path.is_empty() { - continue; - } - let status = entry.status(); - let status_str = if status.contains(Status::WT_NEW) || status.contains(Status::INDEX_NEW) { - "A" - } else if status.contains(Status::WT_MODIFIED) || status.contains(Status::INDEX_MODIFIED) { - "M" - } else if status.contains(Status::WT_DELETED) || status.contains(Status::INDEX_DELETED) { - "D" - } else if status.contains(Status::WT_RENAMED) || status.contains(Status::INDEX_RENAMED) { - "R" - } else if status.contains(Status::WT_TYPECHANGE) || status.contains(Status::INDEX_TYPECHANGE) { - "T" - } else { - "--" - }; - let normalized_path = normalize_git_path(path); - let include_index = status.intersects( - Status::INDEX_NEW - | Status::INDEX_MODIFIED - | Status::INDEX_DELETED - | Status::INDEX_RENAMED - | Status::INDEX_TYPECHANGE, - ); - let include_workdir = status.intersects( - Status::WT_NEW - | Status::WT_MODIFIED - | Status::WT_DELETED - | Status::WT_RENAMED - | Status::WT_TYPECHANGE, - ); - let (additions, deletions) = diff_stats_for_path( - &repo, - head_tree.as_ref(), - path, - include_index, - include_workdir, - ) - .map_err(|e| e.to_string())?; - total_additions += additions; - total_deletions += deletions; - files.push(GitFileStatus { - path: normalized_path, - status: status_str.to_string(), - additions, - deletions, - }); - } - - Ok(json!({ - "branchName": branch_name, - "files": files, - "totalAdditions": total_additions, - "totalDeletions": total_deletions, - })) -} - -#[tauri::command] -async fn get_git_diffs( - workspace_id: String, - state: State<'_, AppState>, -) -> Result, String> { - let workspaces = state.workspaces.lock().await; - let entry = workspaces - .get(&workspace_id) - .ok_or("workspace not found")? - .clone(); - - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; - let head_tree = repo - .head() - .ok() - .and_then(|head| head.peel_to_tree().ok()); - - let mut options = DiffOptions::new(); - options - .include_untracked(true) - .recurse_untracked_dirs(true) - .show_untracked_content(true); - - let diff = match head_tree.as_ref() { - Some(tree) => repo - .diff_tree_to_workdir_with_index(Some(tree), Some(&mut options)) - .map_err(|e| e.to_string())?, - None => repo - .diff_tree_to_workdir_with_index(None, Some(&mut options)) - .map_err(|e| e.to_string())?, - }; - - let mut results = Vec::new(); - for (index, delta) in diff.deltas().enumerate() { - let path = delta - .new_file() - .path() - .or_else(|| delta.old_file().path()); - let Some(path) = path else { - continue; - }; - let patch = match git2::Patch::from_diff(&diff, index) { - Ok(patch) => patch, - Err(_) => continue, - }; - let Some(mut patch) = patch else { - continue; - }; - let content = match diff_patch_to_string(&mut patch) { - Ok(content) => content, - Err(_) => continue, - }; - if content.trim().is_empty() { - continue; - } - results.push(GitFileDiff { - path: normalize_git_path(path.to_string_lossy().as_ref()), - diff: content, - }); - } - - Ok(results) -} - -#[tauri::command] -async fn get_git_log( - workspace_id: String, - limit: Option, - state: State<'_, AppState>, -) -> Result { - let workspaces = state.workspaces.lock().await; - let entry = workspaces - .get(&workspace_id) - .ok_or("workspace not found")? - .clone(); - - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; - let max_items = limit.unwrap_or(40); - let mut revwalk = repo.revwalk().map_err(|e| e.to_string())?; - revwalk.push_head().map_err(|e| e.to_string())?; - revwalk - .set_sorting(Sort::TIME) - .map_err(|e| e.to_string())?; - - let mut total = 0usize; - for oid_result in revwalk { - oid_result.map_err(|e| e.to_string())?; - total += 1; - } - - let mut revwalk = repo.revwalk().map_err(|e| e.to_string())?; - revwalk.push_head().map_err(|e| e.to_string())?; - revwalk - .set_sorting(Sort::TIME) - .map_err(|e| e.to_string())?; - - let mut entries = Vec::new(); - for oid_result in revwalk.take(max_items) { - let oid = oid_result.map_err(|e| e.to_string())?; - let commit = repo.find_commit(oid).map_err(|e| e.to_string())?; - let summary = commit.summary().unwrap_or("").to_string(); - let author = commit.author().name().unwrap_or("").to_string(); - let timestamp = commit.time().seconds(); - entries.push(GitLogEntry { - sha: commit.id().to_string(), - summary, - author, - timestamp, - }); - } - - Ok(GitLogResponse { total, entries }) -} - -#[tauri::command] -async fn get_git_remote( - workspace_id: String, - state: State<'_, AppState>, -) -> Result, String> { - let workspaces = state.workspaces.lock().await; - let entry = workspaces - .get(&workspace_id) - .ok_or("workspace not found")? - .clone(); - - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; - let remotes = repo.remotes().map_err(|e| e.to_string())?; - let name = if remotes.iter().any(|remote| remote == Some("origin")) { - "origin".to_string() - } else { - remotes - .iter() - .flatten() - .next() - .unwrap_or("") - .to_string() - }; - if name.is_empty() { - return Ok(None); - } - let remote = repo.find_remote(&name).map_err(|e| e.to_string())?; - Ok(remote.url().map(|url| url.to_string())) -} - -#[tauri::command] -async fn list_workspace_files( - workspace_id: String, - state: State<'_, AppState>, -) -> Result, String> { - let workspaces = state.workspaces.lock().await; - let entry = workspaces - .get(&workspace_id) - .ok_or("workspace not found")?; - let root = PathBuf::from(&entry.path); - Ok(list_workspace_files_inner(&root, 20000)) -} - -#[tauri::command] -async fn list_git_branches( - workspace_id: String, - state: State<'_, AppState>, -) -> Result { - let workspaces = state.workspaces.lock().await; - let entry = workspaces - .get(&workspace_id) - .ok_or("workspace not found")? - .clone(); - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; - let mut branches = Vec::new(); - let refs = repo.branches(Some(BranchType::Local)).map_err(|e| e.to_string())?; - for branch_result in refs { - let (branch, _) = branch_result.map_err(|e| e.to_string())?; - let name = branch - .name() - .ok() - .flatten() - .unwrap_or("") - .to_string(); - if name.is_empty() { - continue; - } - let last_commit = branch - .get() - .target() - .and_then(|oid| repo.find_commit(oid).ok()) - .map(|commit| commit.time().seconds()) - .unwrap_or(0); - branches.push(BranchInfo { name, last_commit }); - } - branches.sort_by(|a, b| b.last_commit.cmp(&a.last_commit)); - Ok(json!({ "branches": branches })) -} - -#[tauri::command] -async fn checkout_git_branch( - workspace_id: String, - name: String, - state: State<'_, AppState>, -) -> Result<(), String> { - let workspaces = state.workspaces.lock().await; - let entry = workspaces - .get(&workspace_id) - .ok_or("workspace not found")? - .clone(); - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; - checkout_branch(&repo, &name).map_err(|e| e.to_string()) -} - -#[tauri::command] -async fn create_git_branch( - workspace_id: String, - name: String, - state: State<'_, AppState>, -) -> Result<(), String> { - let workspaces = state.workspaces.lock().await; - let entry = workspaces - .get(&workspace_id) - .ok_or("workspace not found")? - .clone(); - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; - let head = repo.head().map_err(|e| e.to_string())?; - let target = head.peel_to_commit().map_err(|e| e.to_string())?; - repo.branch(&name, &target, false) - .map_err(|e| e.to_string())?; - checkout_branch(&repo, &name).map_err(|e| e.to_string()) -} +mod codex; +mod git; +mod settings; +mod state; +mod storage; +mod types; +mod utils; +mod workspaces; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -1723,43 +113,43 @@ pub fn run() { } }) .setup(|app| { - let state = AppState::load(&app.handle()); + let state = state::AppState::load(&app.handle()); app.manage(state); Ok(()) }) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![ - get_app_settings, - update_app_settings, - codex_doctor, - list_workspaces, - add_workspace, - add_worktree, - remove_workspace, - remove_worktree, - update_workspace_settings, - update_workspace_codex_bin, - start_thread, - send_user_message, - turn_interrupt, - start_review, - respond_to_server_request, - resume_thread, - list_threads, - archive_thread, - connect_workspace, - get_git_status, - get_git_diffs, - get_git_log, - get_git_remote, - list_workspace_files, - list_git_branches, - checkout_git_branch, - create_git_branch, - model_list, - account_rate_limits, - skills_list + settings::get_app_settings, + settings::update_app_settings, + codex::codex_doctor, + workspaces::list_workspaces, + workspaces::add_workspace, + workspaces::add_worktree, + workspaces::remove_workspace, + workspaces::remove_worktree, + workspaces::update_workspace_settings, + workspaces::update_workspace_codex_bin, + codex::start_thread, + codex::send_user_message, + codex::turn_interrupt, + codex::start_review, + codex::respond_to_server_request, + codex::resume_thread, + codex::list_threads, + codex::archive_thread, + workspaces::connect_workspace, + git::get_git_status, + git::get_git_diffs, + git::get_git_log, + git::get_git_remote, + workspaces::list_workspace_files, + git::list_git_branches, + git::checkout_git_branch, + git::create_git_branch, + codex::model_list, + codex::account_rate_limits, + codex::skills_list ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs new file mode 100644 index 000000000..f7dd45427 --- /dev/null +++ b/src-tauri/src/settings.rs @@ -0,0 +1,22 @@ +use tauri::State; + +use crate::state::AppState; +use crate::storage::write_settings; +use crate::types::AppSettings; + +#[tauri::command] +pub(crate) async fn get_app_settings(state: State<'_, AppState>) -> Result { + let settings = state.app_settings.lock().await; + Ok(settings.clone()) +} + +#[tauri::command] +pub(crate) async fn update_app_settings( + settings: AppSettings, + state: State<'_, AppState>, +) -> Result { + write_settings(&state.settings_path, &settings)?; + let mut current = state.app_settings.lock().await; + *current = settings.clone(); + Ok(settings) +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs new file mode 100644 index 000000000..b89b1fe69 --- /dev/null +++ b/src-tauri/src/state.rs @@ -0,0 +1,37 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use tauri::{AppHandle, Manager}; +use tokio::sync::Mutex; + +use crate::storage::{read_settings, read_workspaces}; +use crate::types::{AppSettings, WorkspaceEntry}; + +pub(crate) struct AppState { + pub(crate) workspaces: Mutex>, + pub(crate) sessions: Mutex>>, + pub(crate) storage_path: PathBuf, + pub(crate) settings_path: PathBuf, + pub(crate) app_settings: Mutex, +} + +impl AppState { + pub(crate) fn load(app: &AppHandle) -> Self { + let data_dir = app + .path() + .app_data_dir() + .unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| ".".into())); + let storage_path = data_dir.join("workspaces.json"); + let settings_path = data_dir.join("settings.json"); + let workspaces = read_workspaces(&storage_path).unwrap_or_default(); + let app_settings = read_settings(&settings_path).unwrap_or_default(); + Self { + workspaces: Mutex::new(workspaces), + sessions: Mutex::new(HashMap::new()), + storage_path, + settings_path, + app_settings: Mutex::new(app_settings), + } + } +} diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs new file mode 100644 index 000000000..9e1399624 --- /dev/null +++ b/src-tauri/src/storage.rs @@ -0,0 +1,40 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use crate::types::{AppSettings, WorkspaceEntry}; + +pub(crate) fn read_workspaces(path: &PathBuf) -> Result, String> { + if !path.exists() { + return Ok(HashMap::new()); + } + let data = std::fs::read_to_string(path).map_err(|e| e.to_string())?; + let list: Vec = serde_json::from_str(&data).map_err(|e| e.to_string())?; + Ok(list + .into_iter() + .map(|entry| (entry.id.clone(), entry)) + .collect()) +} + +pub(crate) fn write_workspaces(path: &PathBuf, entries: &[WorkspaceEntry]) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let data = serde_json::to_string_pretty(entries).map_err(|e| e.to_string())?; + std::fs::write(path, data).map_err(|e| e.to_string()) +} + +pub(crate) fn read_settings(path: &PathBuf) -> Result { + if !path.exists() { + return Ok(AppSettings::default()); + } + let data = std::fs::read_to_string(path).map_err(|e| e.to_string())?; + serde_json::from_str(&data).map_err(|e| e.to_string()) +} + +pub(crate) fn write_settings(path: &PathBuf, settings: &AppSettings) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let data = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?; + std::fs::write(path, data).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs new file mode 100644 index 000000000..ad6088795 --- /dev/null +++ b/src-tauri/src/types.rs @@ -0,0 +1,145 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub(crate) struct GitFileStatus { + pub(crate) path: String, + pub(crate) status: String, + pub(crate) additions: i64, + pub(crate) deletions: i64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub(crate) struct GitFileDiff { + pub(crate) path: String, + pub(crate) diff: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub(crate) struct GitLogEntry { + pub(crate) sha: String, + pub(crate) summary: String, + pub(crate) author: String, + pub(crate) timestamp: i64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub(crate) struct GitLogResponse { + pub(crate) total: usize, + pub(crate) entries: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub(crate) struct BranchInfo { + pub(crate) name: String, + pub(crate) last_commit: i64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub(crate) struct WorkspaceEntry { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) path: String, + pub(crate) codex_bin: Option, + #[serde(default)] + pub(crate) kind: WorkspaceKind, + #[serde(default, rename = "parentId")] + pub(crate) parent_id: Option, + #[serde(default)] + pub(crate) worktree: Option, + #[serde(default)] + pub(crate) settings: WorkspaceSettings, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub(crate) struct WorkspaceInfo { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) path: String, + pub(crate) connected: bool, + pub(crate) codex_bin: Option, + #[serde(default)] + pub(crate) kind: WorkspaceKind, + #[serde(default, rename = "parentId")] + pub(crate) parent_id: Option, + #[serde(default)] + pub(crate) worktree: Option, + #[serde(default)] + pub(crate) settings: WorkspaceSettings, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "lowercase")] +pub(crate) enum WorkspaceKind { + Main, + Worktree, +} + +impl Default for WorkspaceKind { + fn default() -> Self { + WorkspaceKind::Main + } +} + +impl WorkspaceKind { + pub(crate) fn is_worktree(&self) -> bool { + matches!(self, WorkspaceKind::Worktree) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub(crate) struct WorktreeInfo { + pub(crate) branch: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub(crate) struct WorkspaceSettings { + #[serde(default, rename = "sidebarCollapsed")] + pub(crate) sidebar_collapsed: bool, + #[serde(default, rename = "sortOrder")] + pub(crate) sort_order: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub(crate) struct AppSettings { + #[serde(default, rename = "codexBin")] + pub(crate) codex_bin: Option, + #[serde(default = "default_access_mode", rename = "defaultAccessMode")] + pub(crate) default_access_mode: String, +} + +fn default_access_mode() -> String { + "current".to_string() +} + +impl Default for AppSettings { + fn default() -> Self { + Self { + codex_bin: None, + default_access_mode: "current".to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::{AppSettings, WorkspaceEntry, WorkspaceKind}; + + #[test] + fn app_settings_defaults_from_empty_json() { + let settings: AppSettings = serde_json::from_str("{}").expect("settings deserialize"); + assert!(settings.codex_bin.is_none()); + assert_eq!(settings.default_access_mode, "current"); + } + + #[test] + fn workspace_entry_defaults_from_minimal_json() { + let entry: WorkspaceEntry = serde_json::from_str( + r#"{"id":"1","name":"Test","path":"/tmp","codexBin":null}"#, + ) + .expect("workspace deserialize"); + assert!(matches!(entry.kind, WorkspaceKind::Main)); + assert!(entry.parent_id.is_none()); + assert!(entry.worktree.is_none()); + assert!(entry.settings.sort_order.is_none()); + } +} diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs new file mode 100644 index 000000000..e718106cc --- /dev/null +++ b/src-tauri/src/utils.rs @@ -0,0 +1,13 @@ +pub(crate) fn normalize_git_path(path: &str) -> String { + path.replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use super::normalize_git_path; + + #[test] + fn normalize_git_path_replaces_backslashes() { + assert_eq!(normalize_git_path("foo\\bar\\baz"), "foo/bar/baz"); + } +} diff --git a/src-tauri/src/workspaces.rs b/src-tauri/src/workspaces.rs new file mode 100644 index 000000000..f871d7a8d --- /dev/null +++ b/src-tauri/src/workspaces.rs @@ -0,0 +1,579 @@ +use std::io::Write; +use std::path::PathBuf; + +use tauri::{AppHandle, State}; +use tokio::process::Command; +use uuid::Uuid; + +use crate::codex::spawn_workspace_session; +use crate::state::AppState; +use crate::storage::write_workspaces; +use crate::types::{ + WorkspaceEntry, WorkspaceInfo, WorkspaceKind, WorkspaceSettings, WorktreeInfo, +}; +use crate::utils::normalize_git_path; + +fn sanitize_worktree_name(branch: &str) -> String { + let mut result = String::new(); + for ch in branch.chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + result.push(ch); + } else { + result.push('-'); + } + } + let trimmed = result.trim_matches('-').to_string(); + if trimmed.is_empty() { + "worktree".to_string() + } else { + trimmed + } +} + +fn should_skip_dir(name: &str) -> bool { + matches!( + name, + ".git" | "node_modules" | "dist" | "target" | "release-artifacts" + ) +} + +fn list_workspace_files_inner(root: &PathBuf, max_files: usize) -> Vec { + let mut results = Vec::new(); + let mut stack = vec![root.clone()]; + + while let Some(dir) = stack.pop() { + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(_) => continue, + }; + for entry in entries.flatten() { + let path = entry.path(); + let file_name = entry.file_name().to_string_lossy().to_string(); + if path.is_dir() { + if should_skip_dir(&file_name) { + continue; + } + stack.push(path); + continue; + } + if path.is_file() { + if let Ok(rel_path) = path.strip_prefix(root) { + let normalized = normalize_git_path(&rel_path.to_string_lossy()); + if !normalized.is_empty() { + results.push(normalized); + } + } + } + if results.len() >= max_files { + return results; + } + } + } + + results.sort(); + results +} + +fn sort_workspaces(list: &mut Vec) { + list.sort_by(|a, b| { + let a_order = a.settings.sort_order.unwrap_or(u32::MAX); + let b_order = b.settings.sort_order.unwrap_or(u32::MAX); + a_order.cmp(&b_order).then_with(|| a.name.cmp(&b.name)) + }); +} + +async fn run_git_command(repo_path: &PathBuf, args: &[&str]) -> Result { + let output = Command::new("git") + .args(args) + .current_dir(repo_path) + .output() + .await + .map_err(|e| format!("Failed to run git: {e}"))?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + if detail.is_empty() { + Err("Git command failed.".to_string()) + } else { + Err(detail.to_string()) + } + } +} + +async fn git_branch_exists(repo_path: &PathBuf, branch: &str) -> Result { + let status = Command::new("git") + .args(["show-ref", "--verify", &format!("refs/heads/{branch}")]) + .current_dir(repo_path) + .status() + .await + .map_err(|e| format!("Failed to run git: {e}"))?; + Ok(status.success()) +} + +fn unique_worktree_path(base_dir: &PathBuf, name: &str) -> PathBuf { + let mut candidate = base_dir.join(name); + if !candidate.exists() { + return candidate; + } + for index in 2..1000 { + let next = base_dir.join(format!("{name}-{index}")); + if !next.exists() { + candidate = next; + break; + } + } + candidate +} + +fn ensure_worktree_ignored(repo_path: &PathBuf) -> Result<(), String> { + let ignore_path = repo_path.join(".gitignore"); + let entry = ".codex-worktrees/"; + let existing = std::fs::read_to_string(&ignore_path).unwrap_or_default(); + if existing.lines().any(|line| line.trim() == entry) { + return Ok(()); + } + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&ignore_path) + .map_err(|e| format!("Failed to update .gitignore: {e}"))?; + if !existing.ends_with('\n') && !existing.is_empty() { + file.write_all(b"\n") + .map_err(|e| format!("Failed to update .gitignore: {e}"))?; + } + file.write_all(format!("{entry}\n").as_bytes()) + .map_err(|e| format!("Failed to update .gitignore: {e}"))?; + Ok(()) +} + +#[tauri::command] +pub(crate) async fn list_workspaces( + state: State<'_, AppState>, +) -> Result, String> { + let workspaces = state.workspaces.lock().await; + let sessions = state.sessions.lock().await; + let mut result = Vec::new(); + for entry in workspaces.values() { + result.push(WorkspaceInfo { + id: entry.id.clone(), + name: entry.name.clone(), + path: entry.path.clone(), + codex_bin: entry.codex_bin.clone(), + connected: sessions.contains_key(&entry.id), + kind: entry.kind.clone(), + parent_id: entry.parent_id.clone(), + worktree: entry.worktree.clone(), + settings: entry.settings.clone(), + }); + } + sort_workspaces(&mut result); + Ok(result) +} + +#[tauri::command] +pub(crate) async fn add_workspace( + path: String, + codex_bin: Option, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + let name = PathBuf::from(&path) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("Workspace") + .to_string(); + let entry = WorkspaceEntry { + id: Uuid::new_v4().to_string(), + name: name.clone(), + path: path.clone(), + codex_bin, + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings::default(), + }; + + let default_bin = { + let settings = state.app_settings.lock().await; + settings.codex_bin.clone() + }; + let session = spawn_workspace_session(entry.clone(), default_bin, app).await?; + { + let mut workspaces = state.workspaces.lock().await; + workspaces.insert(entry.id.clone(), entry.clone()); + let list: Vec<_> = workspaces.values().cloned().collect(); + write_workspaces(&state.storage_path, &list)?; + } + state + .sessions + .lock() + .await + .insert(entry.id.clone(), session); + + Ok(WorkspaceInfo { + id: entry.id, + name: entry.name, + path: entry.path, + codex_bin: entry.codex_bin, + connected: true, + kind: entry.kind, + parent_id: entry.parent_id, + worktree: entry.worktree, + settings: entry.settings, + }) +} + +#[tauri::command] +pub(crate) async fn add_worktree( + parent_id: String, + branch: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + let branch = branch.trim(); + if branch.is_empty() { + return Err("Branch name is required.".to_string()); + } + + let parent_entry = { + let workspaces = state.workspaces.lock().await; + workspaces + .get(&parent_id) + .cloned() + .ok_or("parent workspace not found")? + }; + + if parent_entry.kind.is_worktree() { + return Err("Cannot create a worktree from another worktree.".to_string()); + } + + let worktree_root = PathBuf::from(&parent_entry.path).join(".codex-worktrees"); + std::fs::create_dir_all(&worktree_root) + .map_err(|e| format!("Failed to create worktree directory: {e}"))?; + ensure_worktree_ignored(&PathBuf::from(&parent_entry.path))?; + + let safe_name = sanitize_worktree_name(branch); + let worktree_path = unique_worktree_path(&worktree_root, &safe_name); + let worktree_path_string = worktree_path.to_string_lossy().to_string(); + + let branch_exists = git_branch_exists(&PathBuf::from(&parent_entry.path), branch).await?; + if branch_exists { + run_git_command( + &PathBuf::from(&parent_entry.path), + &["worktree", "add", &worktree_path_string, branch], + ) + .await?; + } else { + run_git_command( + &PathBuf::from(&parent_entry.path), + &["worktree", "add", "-b", branch, &worktree_path_string], + ) + .await?; + } + + let entry = WorkspaceEntry { + id: Uuid::new_v4().to_string(), + name: branch.to_string(), + path: worktree_path_string, + codex_bin: parent_entry.codex_bin.clone(), + kind: WorkspaceKind::Worktree, + parent_id: Some(parent_entry.id.clone()), + worktree: Some(WorktreeInfo { + branch: branch.to_string(), + }), + settings: WorkspaceSettings::default(), + }; + + let default_bin = { + let settings = state.app_settings.lock().await; + settings.codex_bin.clone() + }; + let session = spawn_workspace_session(entry.clone(), default_bin, app).await?; + { + let mut workspaces = state.workspaces.lock().await; + workspaces.insert(entry.id.clone(), entry.clone()); + let list: Vec<_> = workspaces.values().cloned().collect(); + write_workspaces(&state.storage_path, &list)?; + } + state + .sessions + .lock() + .await + .insert(entry.id.clone(), session); + + Ok(WorkspaceInfo { + id: entry.id, + name: entry.name, + path: entry.path, + codex_bin: entry.codex_bin, + connected: true, + kind: entry.kind, + parent_id: entry.parent_id, + worktree: entry.worktree, + settings: entry.settings, + }) +} + +#[tauri::command] +pub(crate) async fn remove_workspace( + id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let (entry, child_worktrees) = { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&id) + .cloned() + .ok_or("workspace not found")?; + if entry.kind.is_worktree() { + return Err("Use remove_worktree for worktree agents.".to_string()); + } + let children = workspaces + .values() + .filter(|workspace| workspace.parent_id.as_deref() == Some(&id)) + .cloned() + .collect::>(); + (entry, children) + }; + + let parent_path = PathBuf::from(&entry.path); + for child in &child_worktrees { + if let Some(session) = state.sessions.lock().await.remove(&child.id) { + let mut child_process = session.child.lock().await; + let _ = child_process.kill().await; + } + let child_path = PathBuf::from(&child.path); + if child_path.exists() { + run_git_command( + &parent_path, + &["worktree", "remove", "--force", &child.path], + ) + .await?; + } + } + let _ = run_git_command(&parent_path, &["worktree", "prune", "--expire", "now"]).await; + + if let Some(session) = state.sessions.lock().await.remove(&id) { + let mut child = session.child.lock().await; + let _ = child.kill().await; + } + + { + let mut workspaces = state.workspaces.lock().await; + workspaces.remove(&id); + for child in child_worktrees { + workspaces.remove(&child.id); + } + let list: Vec<_> = workspaces.values().cloned().collect(); + write_workspaces(&state.storage_path, &list)?; + } + + Ok(()) +} + +#[tauri::command] +pub(crate) async fn remove_worktree( + id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let (entry, parent) = { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&id) + .cloned() + .ok_or("workspace not found")?; + if !entry.kind.is_worktree() { + return Err("Not a worktree workspace.".to_string()); + } + let parent_id = entry + .parent_id + .clone() + .ok_or("worktree parent not found")?; + let parent = workspaces + .get(&parent_id) + .cloned() + .ok_or("worktree parent not found")?; + (entry, parent) + }; + + if let Some(session) = state.sessions.lock().await.remove(&entry.id) { + let mut child = session.child.lock().await; + let _ = child.kill().await; + } + + let parent_path = PathBuf::from(&parent.path); + let entry_path = PathBuf::from(&entry.path); + if entry_path.exists() { + run_git_command( + &parent_path, + &["worktree", "remove", "--force", &entry.path], + ) + .await?; + } + let _ = run_git_command(&parent_path, &["worktree", "prune", "--expire", "now"]).await; + + { + let mut workspaces = state.workspaces.lock().await; + workspaces.remove(&entry.id); + let list: Vec<_> = workspaces.values().cloned().collect(); + write_workspaces(&state.storage_path, &list)?; + } + + Ok(()) +} + +#[tauri::command] +pub(crate) async fn update_workspace_settings( + id: String, + settings: WorkspaceSettings, + state: State<'_, AppState>, +) -> Result { + let (entry_snapshot, list) = { + let mut workspaces = state.workspaces.lock().await; + let entry_snapshot = match workspaces.get_mut(&id) { + Some(entry) => { + entry.settings = settings.clone(); + entry.clone() + } + None => return Err("workspace not found".to_string()), + }; + let list: Vec<_> = workspaces.values().cloned().collect(); + (entry_snapshot, list) + }; + write_workspaces(&state.storage_path, &list)?; + + let connected = state.sessions.lock().await.contains_key(&id); + Ok(WorkspaceInfo { + id: entry_snapshot.id, + name: entry_snapshot.name, + path: entry_snapshot.path, + codex_bin: entry_snapshot.codex_bin, + connected, + kind: entry_snapshot.kind, + parent_id: entry_snapshot.parent_id, + worktree: entry_snapshot.worktree, + settings: entry_snapshot.settings, + }) +} + +#[tauri::command] +pub(crate) async fn update_workspace_codex_bin( + id: String, + codex_bin: Option, + state: State<'_, AppState>, +) -> Result { + let (entry_snapshot, list) = { + let mut workspaces = state.workspaces.lock().await; + let entry_snapshot = match workspaces.get_mut(&id) { + Some(entry) => { + entry.codex_bin = codex_bin.clone(); + entry.clone() + } + None => return Err("workspace not found".to_string()), + }; + let list: Vec<_> = workspaces.values().cloned().collect(); + (entry_snapshot, list) + }; + write_workspaces(&state.storage_path, &list)?; + + let connected = state.sessions.lock().await.contains_key(&id); + Ok(WorkspaceInfo { + id: entry_snapshot.id, + name: entry_snapshot.name, + path: entry_snapshot.path, + codex_bin: entry_snapshot.codex_bin, + connected, + kind: entry_snapshot.kind, + parent_id: entry_snapshot.parent_id, + worktree: entry_snapshot.worktree, + settings: entry_snapshot.settings, + }) +} + +#[tauri::command] +pub(crate) async fn connect_workspace( + id: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result<(), String> { + let entry = { + let workspaces = state.workspaces.lock().await; + workspaces + .get(&id) + .cloned() + .ok_or("workspace not found")? + }; + + let default_bin = { + let settings = state.app_settings.lock().await; + settings.codex_bin.clone() + }; + let session = spawn_workspace_session(entry.clone(), default_bin, app).await?; + state.sessions.lock().await.insert(entry.id, session); + Ok(()) +} + +#[tauri::command] +pub(crate) async fn list_workspace_files( + workspace_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")?; + let root = PathBuf::from(&entry.path); + Ok(list_workspace_files_inner(&root, 20000)) +} + +#[cfg(test)] +mod tests { + use super::{sanitize_worktree_name, sort_workspaces}; + use crate::types::{WorkspaceInfo, WorkspaceKind, WorkspaceSettings}; + + fn workspace(name: &str, sort_order: Option) -> WorkspaceInfo { + WorkspaceInfo { + id: name.to_string(), + name: name.to_string(), + path: "/tmp".to_string(), + connected: false, + codex_bin: None, + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings { + sidebar_collapsed: false, + sort_order, + }, + } + } + + #[test] + fn sanitize_worktree_name_rewrites_specials() { + assert_eq!(sanitize_worktree_name("feature/new-thing"), "feature-new-thing"); + assert_eq!(sanitize_worktree_name("///"), "worktree"); + assert_eq!(sanitize_worktree_name("--branch--"), "branch"); + } + + #[test] + fn sort_workspaces_orders_by_sort_then_name() { + let mut items = vec![ + workspace("beta", None), + workspace("alpha", None), + workspace("delta", Some(2)), + workspace("gamma", Some(1)), + ]; + + sort_workspaces(&mut items); + + let names: Vec<_> = items.into_iter().map(|item| item.name).collect(); + assert_eq!(names, vec!["gamma", "delta", "alpha", "beta"]); + } +} From 8ffb218ab47623233798a95402e28caa2a02f9f4 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Tue, 13 Jan 2026 14:37:02 +0100 Subject: [PATCH 2/2] ci: add rust test job --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3bbe8f3a..83d68cf2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,14 @@ on: branches: ["main"] jobs: + test-tauri: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Rust tests + run: cargo test + working-directory: src-tauri build-macos: runs-on: macos-latest steps: