diff --git a/docs/roadmap.md b/docs/roadmap.md index cb9b0db..d95d19b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -484,16 +484,16 @@ **ゴール**: 複数リポジトリの同時操作と自動化で効率を上げる -- [ ] 複数リポジトリのタブ表示 - - [ ] タブで複数リポジトリを同時に開く - - [ ] タブ間のドラッグ&ドロップ -- [ ] 自動 fetch - - [ ] バックグラウンドで定期的に fetch - - [ ] fetch 間隔の設定 +- [x] 複数リポジトリのタブ表示 + - [x] タブで複数リポジトリを同時に開く + - [x] タブ間のドラッグ&ドロップ +- [x] 自動 fetch + - [x] バックグラウンドで定期的に fetch + - [x] fetch 間隔の設定 - [ ] 新着コミット数の通知バッジ -- [ ] 通知機能 - - [ ] 操作完了/エラー時のシステム通知 - - [ ] リモート更新の検知通知 +- [x] 通知機能 + - [x] 操作完了/エラー時のシステム通知 + - [x] リモート更新の検知通知 **完動品としての価値**: 複数プロジェクトを横断的に管理し、リモートの変更を見逃さない diff --git a/package.json b/package.json index 25ea301..9c46371 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "dependencies": { "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2", + "@tauri-apps/plugin-notification": "^2.3.3", "react": "^19.2.4", "react-dom": "^19.2.4", "zustand": "^5.0.11" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d075133..fc8ca9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@tauri-apps/plugin-dialog': specifier: ^2 version: 2.6.0 + '@tauri-apps/plugin-notification': + specifier: ^2.3.3 + version: 2.3.3 react: specifier: ^19.2.4 version: 19.2.4 @@ -567,6 +570,9 @@ packages: '@tauri-apps/plugin-dialog@2.6.0': resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==} + '@tauri-apps/plugin-notification@2.3.3': + resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1295,6 +1301,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-notification@2.3.3': + dependencies: + '@tauri-apps/api': 2.10.1 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b6c0c5d..5fd7ce2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -92,6 +92,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-log", + "tauri-plugin-notification", "tauri-plugin-opener", "tempfile", "thiserror 2.0.18", @@ -2324,6 +2325,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26053f9919b5b032f327ab94d830f2465c4c88138e9df23c8fcd305060a9b28b" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -2509,6 +2522,20 @@ dependencies = [ "tempfile", ] +[[package]] +name = "notify-rust" +version = "4.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "notify-types" version = "1.0.1" @@ -2520,9 +2547,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-traits" @@ -3097,7 +3124,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -3257,6 +3284,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -3312,6 +3348,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -3332,6 +3378,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -3350,6 +3406,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -4496,6 +4561,25 @@ dependencies = [ "time", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.2", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.3" @@ -4619,6 +4703,18 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.25.0" @@ -4685,9 +4781,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", "itoa", @@ -4702,15 +4798,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" dependencies = [ "num-conv", "time-core", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 849d9af..6a1c5ea 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,6 +34,7 @@ which = "7" chrono = { version = "0.4", features = ["serde"] } reqwest = { version = "0.12", features = ["blocking", "json"] } tauri-plugin-dialog = "2" +tauri-plugin-notification = "2" [dev-dependencies] tempfile = "3" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 349a560..86c2f5e 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -12,6 +12,7 @@ "core:window:allow-toggle-maximize", "core:window:allow-start-dragging", "opener:default", - "dialog:default" + "dialog:default", + "notification:default" ] } diff --git a/src-tauri/src/ai/mod.rs b/src-tauri/src/ai/mod.rs index 33bb81a..5b92242 100644 --- a/src-tauri/src/ai/mod.rs +++ b/src-tauri/src/ai/mod.rs @@ -1,7 +1,6 @@ pub mod adapter; pub mod conflict; pub mod detector; -pub mod pr; pub mod prompt; pub mod review; pub mod types; diff --git a/src-tauri/src/ai/pr.rs b/src-tauri/src/ai/pr.rs deleted file mode 100644 index 049acd1..0000000 --- a/src-tauri/src/ai/pr.rs +++ /dev/null @@ -1,75 +0,0 @@ -use crate::ai::types::{AiError, PrDescription}; - -pub fn build_pr_description_prompt(diff: &str, branch_name: &str) -> String { - format!( - "Generate a pull request title and description for the following changes.\n\n\ - Branch: {branch_name}\n\n\ - Output ONLY a JSON object with this exact structure (no markdown fences, no explanation):\n\ - {{\"title\":\"PR title (concise, under 72 chars)\",\"body\":\"PR description in markdown\"}}\n\n\ - Rules:\n\ - - Title should be concise and descriptive\n\ - - Body should include a summary of changes, motivation, and any notable implementation details\n\ - - Use markdown formatting in the body (headers, bullet points, etc.)\n\ - - Focus on the \"what\" and \"why\", not the \"how\"\n\n\ - Diff:\n\ - ```\n\ - {diff}\n\ - ```" - ) -} - -pub fn parse_pr_description_response(raw: &str) -> Result { - let trimmed = raw.trim(); - let json_str = super::strip_code_fences(trimmed); - - serde_json::from_str::(json_str) - .map_err(|e| AiError::ParseFailed(format!("invalid PR description JSON: {e}"))) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn build_prompt_includes_branch_and_diff() { - let prompt = build_pr_description_prompt("diff content", "feat/auth"); - assert!(prompt.contains("feat/auth")); - assert!(prompt.contains("diff content")); - } - - #[test] - fn build_prompt_specifies_json_format() { - let prompt = build_pr_description_prompt("diff", "main"); - assert!(prompt.contains("JSON")); - assert!(prompt.contains("\"title\"")); - assert!(prompt.contains("\"body\"")); - } - - #[test] - fn parse_valid_pr_description() { - let json = "{\"title\":\"Add authentication handler\",\"body\":\"Summary of changes\"}"; - let result = parse_pr_description_response(json).unwrap(); - assert_eq!(result.title, "Add authentication handler"); - assert!(result.body.contains("Summary")); - } - - #[test] - fn parse_response_with_code_fences() { - let raw = "```json\n{\"title\":\"Fix bug\",\"body\":\"Fixed the bug\"}\n```"; - let result = parse_pr_description_response(raw).unwrap(); - assert_eq!(result.title, "Fix bug"); - } - - #[test] - fn parse_invalid_json_returns_error() { - let result = parse_pr_description_response("not json"); - assert!(result.is_err()); - } - - #[test] - fn parse_empty_body_is_valid() { - let json = r#"{"title":"Update deps","body":""}"#; - let result = parse_pr_description_response(json).unwrap(); - assert!(result.body.is_empty()); - } -} diff --git a/src-tauri/src/ai/types.rs b/src-tauri/src/ai/types.rs index f91ef66..41509cd 100644 --- a/src-tauri/src/ai/types.rs +++ b/src-tauri/src/ai/types.rs @@ -94,14 +94,6 @@ pub struct ConflictSuggestion { pub reason: String, } -// === PR description types === - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PrDescription { - pub title: String, - pub body: String, -} - #[derive(Debug, thiserror::Error)] pub enum AiError { #[error("no adapter available")] @@ -247,16 +239,6 @@ exclude_patterns = ["*.key"] assert_eq!(json["resolved_code"], "merged code"); } - #[test] - fn pr_description_serializes_to_json() { - let pr = PrDescription { - title: "Add auth handler".to_string(), - body: "This PR adds authentication.".to_string(), - }; - let json = serde_json::to_value(&pr).unwrap(); - assert_eq!(json["title"], "Add auth handler"); - } - #[test] fn confidence_level_serializes_as_lowercase() { assert_eq!( diff --git a/src-tauri/src/auto_fetch.rs b/src-tauri/src/auto_fetch.rs new file mode 100644 index 0000000..3ad6e03 --- /dev/null +++ b/src-tauri/src/auto_fetch.rs @@ -0,0 +1,151 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use serde::Serialize; +use tauri::{AppHandle, Emitter, Manager}; + +use crate::state::AppState; + +#[derive(Debug, Clone, Serialize)] +pub struct AutoFetchUpdate { + pub tab_id: String, + pub remote_name: String, + pub new_commits_count: u32, +} + +pub struct AutoFetchHandle { + stop_flag: Arc, +} + +impl AutoFetchHandle { + pub fn stop(&self) { + self.stop_flag.store(true, Ordering::Relaxed); + } +} + +impl Drop for AutoFetchHandle { + fn drop(&mut self) { + self.stop(); + } +} + +pub fn start_auto_fetch(app_handle: AppHandle, interval_secs: u64) -> AutoFetchHandle { + let stop_flag = Arc::new(AtomicBool::new(false)); + let stop_flag_clone = Arc::clone(&stop_flag); + + std::thread::spawn(move || { + loop { + for _ in 0..interval_secs { + if stop_flag_clone.load(Ordering::Relaxed) { + return; + } + std::thread::sleep(Duration::from_secs(1)); + } + + if stop_flag_clone.load(Ordering::Relaxed) { + return; + } + + let state: tauri::State<'_, AppState> = match app_handle.try_state::() { + Some(s) => s, + None => continue, + }; + + // Collect tab info while holding the lock briefly + let tab_entries: Vec<(String, Vec)> = { + let tabs = match state.tabs.try_lock() { + Ok(t) => t, + Err(_) => continue, + }; + + tabs.iter() + .filter_map(|(tab_id, ctx)| { + let remotes = ctx.backend.list_remotes().ok()?; + let remote_names: Vec = + remotes.iter().map(|r| r.name.clone()).collect(); + if remote_names.is_empty() { + None + } else { + Some((tab_id.clone(), remote_names)) + } + }) + .collect() + }; + + for (tab_id, remote_names) in &tab_entries { + for remote_name in remote_names { + if stop_flag_clone.load(Ordering::Relaxed) { + return; + } + + // Snapshot branches before fetch and extract repo path (short lock) + let (branches_before, repo_path) = { + let tabs = match state.tabs.try_lock() { + Ok(t) => t, + Err(_) => continue, + }; + match tabs.get(tab_id) { + Some(ctx) => ( + ctx.backend.list_branches().unwrap_or_default(), + ctx.backend.workdir().to_path_buf(), + ), + None => continue, + } + }; + + // Execute fetch outside of lock via git CLI to avoid holding + // MutexGuard during network I/O + { + let output = std::process::Command::new("git") + .args(["fetch", remote_name]) + .current_dir(&repo_path) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + match output { + Ok(status) if status.success() => {} + _ => continue, + } + } + + // Snapshot branches after fetch (short lock) + let branches_after = { + let tabs = match state.tabs.try_lock() { + Ok(t) => t, + Err(_) => continue, + }; + match tabs.get(tab_id) { + Some(ctx) => ctx.backend.list_branches().unwrap_or_default(), + None => continue, + } + }; + + // Compare outside of any lock + let mut new_commits: u32 = 0; + for after_branch in &branches_after { + if let Some(before_branch) = + branches_before.iter().find(|b| b.name == after_branch.name) + { + if after_branch.behind_count > before_branch.behind_count { + new_commits += + after_branch.behind_count - before_branch.behind_count; + } + } + } + + if new_commits > 0 { + let update = AutoFetchUpdate { + tab_id: tab_id.clone(), + remote_name: remote_name.clone(), + new_commits_count: new_commits, + }; + let _ = app_handle.emit("auto-fetch:updated", &update); + } + } + } + } + }); + + AutoFetchHandle { stop_flag } +} diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index e84d755..67fcadf 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -4,8 +4,9 @@ use crate::ai::adapter::LlmAdapter; use crate::ai::detector; use crate::ai::types::{ AiConfig, CliAdapterInfo, CommitMessageStyle, ConflictSuggestion, GenerateRequest, - GenerateResult, Language, PrDescription, ReviewResult, + GenerateResult, Language, ReviewResult, }; +use crate::commands::with_repo; use crate::config; use crate::git::types::FileDiff; use crate::state::AppState; @@ -54,6 +55,7 @@ pub fn detect_cli_adapters() -> Result, String> { #[tauri::command] pub fn generate_commit_message( + tab_id: String, format: String, language: String, state: State<'_, AppState>, @@ -63,21 +65,16 @@ pub fn generate_commit_message( let lang: Language = serde_json::from_value(serde_json::Value::String(language)) .map_err(|e| format!("Invalid language: {e}"))?; - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - - let diff_options = crate::git::types::DiffOptions { - staged: true, - ..Default::default() - }; - let diffs = backend - .diff(None, &diff_options) - .map_err(|e| e.to_string())?; - - let diff_text = format_diff_text(&diffs); + let diff_text = with_repo(&state, &tab_id, |backend| { + let diff_options = crate::git::types::DiffOptions { + staged: true, + ..Default::default() + }; + let diffs = backend + .diff(None, &diff_options) + .map_err(|e| e.to_string())?; + Ok(format_diff_text(&diffs)) + })?; if diff_text.trim().is_empty() { return Err("No staged changes to generate a commit message from".to_string()); @@ -95,28 +92,25 @@ pub fn generate_commit_message( } #[tauri::command] -pub fn review_diff(state: State<'_, AppState>) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - - let staged_options = crate::git::types::DiffOptions { - staged: true, - ..Default::default() - }; - let unstaged_options = crate::git::types::DiffOptions { - staged: false, - ..Default::default() - }; - - let staged_diffs = backend - .diff(None, &staged_options) - .map_err(|e| e.to_string())?; - let unstaged_diffs = backend - .diff(None, &unstaged_options) - .map_err(|e| e.to_string())?; +pub fn review_diff(tab_id: String, state: State<'_, AppState>) -> Result { + let (staged_diffs, unstaged_diffs) = with_repo(&state, &tab_id, |backend| { + let staged_options = crate::git::types::DiffOptions { + staged: true, + ..Default::default() + }; + let unstaged_options = crate::git::types::DiffOptions { + staged: false, + ..Default::default() + }; + + let staged = backend + .diff(None, &staged_options) + .map_err(|e| e.to_string())?; + let unstaged = backend + .diff(None, &unstaged_options) + .map_err(|e| e.to_string())?; + Ok((staged, unstaged)) + })?; let mut all_diffs = staged_diffs; all_diffs.extend(unstaged_diffs); @@ -148,36 +142,6 @@ pub fn ai_resolve_conflict( crate::ai::conflict::parse_conflict_response(&raw).map_err(|e| e.to_string()) } -#[tauri::command] -pub fn generate_pr_description(state: State<'_, AppState>) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - - let branch_name = backend.current_branch().map_err(|e| e.to_string())?; - - let diff_options = crate::git::types::DiffOptions { - staged: true, - ..Default::default() - }; - let diffs = backend - .diff(None, &diff_options) - .map_err(|e| e.to_string())?; - - let diff_text = format_diff_text(&diffs); - - if diff_text.trim().is_empty() { - return Err("No changes to generate a PR description from".to_string()); - } - - let adapter = get_adapter()?; - let prompt = crate::ai::pr::build_pr_description_prompt(&diff_text, &branch_name); - let raw = adapter.execute_prompt(&prompt).map_err(|e| e.to_string())?; - crate::ai::pr::parse_pr_description_response(&raw).map_err(|e| e.to_string()) -} - #[tauri::command] pub fn get_ai_config() -> Result { let cfg = config::load_config().map_err(|e| e.to_string())?; diff --git a/src-tauri/src/commands/branch.rs b/src-tauri/src/commands/branch.rs index 87eb1aa..4b8fb8d 100644 --- a/src-tauri/src/commands/branch.rs +++ b/src-tauri/src/commands/branch.rs @@ -1,92 +1,90 @@ use tauri::State; +use crate::commands::with_repo; use crate::git::types::{BranchInfo, CommitInfo, MergeOption, MergeResult}; use crate::state::AppState; #[tauri::command] -pub fn list_branches(state: State<'_, AppState>) -> Result, String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.list_branches().map_err(|e| e.to_string()) +pub fn list_branches( + tab_id: String, + state: State<'_, AppState>, +) -> Result, String> { + with_repo(&state, &tab_id, |backend| { + backend.list_branches().map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn create_branch(name: String, state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.create_branch(&name).map_err(|e| e.to_string()) +pub fn create_branch( + tab_id: String, + name: String, + state: State<'_, AppState>, +) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.create_branch(&name).map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn checkout_branch(name: String, state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.checkout_branch(&name).map_err(|e| e.to_string()) +pub fn checkout_branch( + tab_id: String, + name: String, + state: State<'_, AppState>, +) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.checkout_branch(&name).map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn delete_branch(name: String, state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.delete_branch(&name).map_err(|e| e.to_string()) +pub fn delete_branch( + tab_id: String, + name: String, + state: State<'_, AppState>, +) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.delete_branch(&name).map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn rename_branch( + tab_id: String, old_name: String, new_name: String, state: State<'_, AppState>, ) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .rename_branch(&old_name, &new_name) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .rename_branch(&old_name, &new_name) + .map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn merge_branch( + tab_id: String, branch_name: String, option: MergeOption, state: State<'_, AppState>, ) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .merge_branch(&branch_name, option) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .merge_branch(&branch_name, option) + .map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn get_branch_commits( + tab_id: String, branch_name: String, limit: usize, state: State<'_, AppState>, ) -> Result, String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .get_branch_commits(&branch_name, limit) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .get_branch_commits(&branch_name, limit) + .map_err(|e| e.to_string()) + }) } diff --git a/src-tauri/src/commands/cherry_pick.rs b/src-tauri/src/commands/cherry_pick.rs index 41fbf4d..cb5ce79 100644 --- a/src-tauri/src/commands/cherry_pick.rs +++ b/src-tauri/src/commands/cherry_pick.rs @@ -1,51 +1,44 @@ use tauri::State; +use crate::commands::with_repo; use crate::git::types::{CherryPickMode, CherryPickResult}; use crate::state::AppState; #[tauri::command] pub fn cherry_pick( + tab_id: String, oids: Vec, mode: CherryPickMode, state: State<'_, AppState>, ) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - let oid_refs: Vec<&str> = oids.iter().map(|s| s.as_str()).collect(); - backend - .cherry_pick(&oid_refs, mode) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + let oid_refs: Vec<&str> = oids.iter().map(|s| s.as_str()).collect(); + backend + .cherry_pick(&oid_refs, mode) + .map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn is_cherry_picking(state: State<'_, AppState>) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.is_cherry_picking().map_err(|e| e.to_string()) +pub fn is_cherry_picking(tab_id: String, state: State<'_, AppState>) -> Result { + with_repo(&state, &tab_id, |backend| { + backend.is_cherry_picking().map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn abort_cherry_pick(state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.abort_cherry_pick().map_err(|e| e.to_string()) +pub fn abort_cherry_pick(tab_id: String, state: State<'_, AppState>) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.abort_cherry_pick().map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn continue_cherry_pick(state: State<'_, AppState>) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.continue_cherry_pick().map_err(|e| e.to_string()) +pub fn continue_cherry_pick( + tab_id: String, + state: State<'_, AppState>, +) -> Result { + with_repo(&state, &tab_id, |backend| { + backend.continue_cherry_pick().map_err(|e| e.to_string()) + }) } diff --git a/src-tauri/src/commands/conflict.rs b/src-tauri/src/commands/conflict.rs index b220fd5..f2b2ec6 100644 --- a/src-tauri/src/commands/conflict.rs +++ b/src-tauri/src/commands/conflict.rs @@ -1,87 +1,80 @@ use tauri::State; +use crate::commands::with_repo; use crate::git::types::{CommitResult, ConflictFile, ConflictResolution}; use crate::state::AppState; #[tauri::command] -pub fn get_conflict_files(state: State<'_, AppState>) -> Result, String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.get_conflict_files().map_err(|e| e.to_string()) +pub fn get_conflict_files( + tab_id: String, + state: State<'_, AppState>, +) -> Result, String> { + with_repo(&state, &tab_id, |backend| { + backend.get_conflict_files().map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn resolve_conflict( + tab_id: String, path: String, resolution: ConflictResolution, state: State<'_, AppState>, ) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .resolve_conflict(&path, resolution) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .resolve_conflict(&path, resolution) + .map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn resolve_conflict_block( + tab_id: String, path: String, block_index: usize, resolution: ConflictResolution, state: State<'_, AppState>, ) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .resolve_conflict_block(&path, block_index, resolution) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .resolve_conflict_block(&path, block_index, resolution) + .map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn mark_resolved(path: String, state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.mark_resolved(&path).map_err(|e| e.to_string()) +pub fn mark_resolved( + tab_id: String, + path: String, + state: State<'_, AppState>, +) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.mark_resolved(&path).map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn abort_merge(state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.abort_merge().map_err(|e| e.to_string()) +pub fn abort_merge(tab_id: String, state: State<'_, AppState>) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.abort_merge().map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn continue_merge(message: String, state: State<'_, AppState>) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.continue_merge(&message).map_err(|e| e.to_string()) +pub fn continue_merge( + tab_id: String, + message: String, + state: State<'_, AppState>, +) -> Result { + with_repo(&state, &tab_id, |backend| { + backend.continue_merge(&message).map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn is_merging(state: State<'_, AppState>) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.is_merging().map_err(|e| e.to_string()) +pub fn is_merging(tab_id: String, state: State<'_, AppState>) -> Result { + with_repo(&state, &tab_id, |backend| { + backend.is_merging().map_err(|e| e.to_string()) + }) } diff --git a/src-tauri/src/commands/git.rs b/src-tauri/src/commands/git.rs index 43f56e8..bbaba0b 100644 --- a/src-tauri/src/commands/git.rs +++ b/src-tauri/src/commands/git.rs @@ -2,211 +2,182 @@ use std::path::Path; use tauri::State; +use crate::commands::with_repo; use crate::git::types::{ CommitResult, DiffOptions, FileDiff, HunkIdentifier, LineRange, RepoStatus, }; use crate::state::AppState; #[tauri::command] -pub fn get_status(state: State<'_, AppState>) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.status().map_err(|e| e.to_string()) +pub fn get_status(tab_id: String, state: State<'_, AppState>) -> Result { + with_repo(&state, &tab_id, |backend| { + backend.status().map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn get_diff( + tab_id: String, path: Option, staged: bool, state: State<'_, AppState>, ) -> Result, String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - let options = DiffOptions { - staged, - ..Default::default() - }; - let path_buf = path.map(std::path::PathBuf::from); - backend - .diff(path_buf.as_deref(), &options) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + let options = DiffOptions { + staged, + ..Default::default() + }; + let path_buf = path.map(std::path::PathBuf::from); + backend + .diff(path_buf.as_deref(), &options) + .map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn stage_file(path: String, state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.stage(Path::new(&path)).map_err(|e| e.to_string()) +pub fn stage_file(tab_id: String, path: String, state: State<'_, AppState>) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.stage(Path::new(&path)).map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn unstage_file(path: String, state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.unstage(Path::new(&path)).map_err(|e| e.to_string()) +pub fn unstage_file( + tab_id: String, + path: String, + state: State<'_, AppState>, +) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.unstage(Path::new(&path)).map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn stage_all(state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.stage_all().map_err(|e| e.to_string()) +pub fn stage_all(tab_id: String, state: State<'_, AppState>) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.stage_all().map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn unstage_all(state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.unstage_all().map_err(|e| e.to_string()) +pub fn unstage_all(tab_id: String, state: State<'_, AppState>) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.unstage_all().map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn commit( + tab_id: String, message: String, amend: bool, sign: bool, state: State<'_, AppState>, ) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .commit(&message, amend, sign) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .commit(&message, amend, sign) + .map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn get_current_branch(state: State<'_, AppState>) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.current_branch().map_err(|e| e.to_string()) +pub fn get_current_branch(tab_id: String, state: State<'_, AppState>) -> Result { + with_repo(&state, &tab_id, |backend| { + backend.current_branch().map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn stage_hunk( + tab_id: String, path: String, hunk: HunkIdentifier, state: State<'_, AppState>, ) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .stage_hunk(Path::new(&path), &hunk) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .stage_hunk(Path::new(&path), &hunk) + .map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn unstage_hunk( + tab_id: String, path: String, hunk: HunkIdentifier, state: State<'_, AppState>, ) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .unstage_hunk(Path::new(&path), &hunk) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .unstage_hunk(Path::new(&path), &hunk) + .map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn discard_hunk( + tab_id: String, path: String, hunk: HunkIdentifier, state: State<'_, AppState>, ) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .discard_hunk(Path::new(&path), &hunk) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .discard_hunk(Path::new(&path), &hunk) + .map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn stage_lines( + tab_id: String, path: String, line_range: LineRange, state: State<'_, AppState>, ) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .stage_lines(Path::new(&path), &line_range) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .stage_lines(Path::new(&path), &line_range) + .map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn unstage_lines( + tab_id: String, path: String, line_range: LineRange, state: State<'_, AppState>, ) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .unstage_lines(Path::new(&path), &line_range) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .unstage_lines(Path::new(&path), &line_range) + .map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn discard_lines( + tab_id: String, path: String, line_range: LineRange, state: State<'_, AppState>, ) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .discard_lines(Path::new(&path), &line_range) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .discard_lines(Path::new(&path), &line_range) + .map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn get_head_commit_message(state: State<'_, AppState>) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.get_head_commit_message().map_err(|e| e.to_string()) +pub fn get_head_commit_message( + tab_id: String, + state: State<'_, AppState>, +) -> Result { + with_repo(&state, &tab_id, |backend| { + backend.get_head_commit_message().map_err(|e| e.to_string()) + }) } diff --git a/src-tauri/src/commands/gitconfig.rs b/src-tauri/src/commands/gitconfig.rs index ff9efdd..2c950a5 100644 --- a/src-tauri/src/commands/gitconfig.rs +++ b/src-tauri/src/commands/gitconfig.rs @@ -1,81 +1,72 @@ use tauri::State; +use crate::commands::with_repo; use crate::git::types::{GitConfigEntry, GitConfigScope}; use crate::state::AppState; #[tauri::command] pub fn get_gitconfig_entries( + tab_id: String, scope: GitConfigScope, state: State<'_, AppState>, ) -> Result, String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .get_gitconfig_entries(scope) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .get_gitconfig_entries(scope) + .map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn get_gitconfig_value( + tab_id: String, scope: GitConfigScope, key: String, state: State<'_, AppState>, ) -> Result, String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .get_gitconfig_value(scope, &key) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .get_gitconfig_value(scope, &key) + .map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn set_gitconfig_value( + tab_id: String, scope: GitConfigScope, key: String, value: String, state: State<'_, AppState>, ) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .set_gitconfig_value(scope, &key, &value) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .set_gitconfig_value(scope, &key, &value) + .map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn unset_gitconfig_value( + tab_id: String, scope: GitConfigScope, key: String, state: State<'_, AppState>, ) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .unset_gitconfig_value(scope, &key) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .unset_gitconfig_value(scope, &key) + .map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn get_gitconfig_path( + tab_id: String, scope: GitConfigScope, state: State<'_, AppState>, ) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.get_gitconfig_path(scope).map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend.get_gitconfig_path(scope).map_err(|e| e.to_string()) + }) } diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs index f4296b9..844f6c1 100644 --- a/src-tauri/src/commands/history.rs +++ b/src-tauri/src/commands/history.rs @@ -1,5 +1,6 @@ use tauri::State; +use crate::commands::with_repo; use crate::git::types::{ BlameResult, CommitDetail, CommitInfo, CommitLogResult, FileDiff, LogFilter, }; @@ -7,76 +8,69 @@ use crate::state::AppState; #[tauri::command] pub fn get_commit_log( + tab_id: String, filter: LogFilter, limit: usize, skip: usize, state: State<'_, AppState>, ) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .get_commit_log(&filter, limit, skip) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .get_commit_log(&filter, limit, skip) + .map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn get_commit_detail(oid: String, state: State<'_, AppState>) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.get_commit_detail(&oid).map_err(|e| e.to_string()) +pub fn get_commit_detail( + tab_id: String, + oid: String, + state: State<'_, AppState>, +) -> Result { + with_repo(&state, &tab_id, |backend| { + backend.get_commit_detail(&oid).map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn get_commit_file_diff( + tab_id: String, oid: String, path: String, state: State<'_, AppState>, ) -> Result, String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .get_commit_file_diff(&oid, &path) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .get_commit_file_diff(&oid, &path) + .map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn get_blame( + tab_id: String, path: String, commit_oid: Option, state: State<'_, AppState>, ) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .get_blame(&path, commit_oid.as_deref()) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .get_blame(&path, commit_oid.as_deref()) + .map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn get_file_history( + tab_id: String, path: String, limit: usize, skip: usize, state: State<'_, AppState>, ) -> Result, String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .get_file_history(&path, limit, skip) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .get_file_history(&path, limit, skip) + .map_err(|e| e.to_string()) + }) } diff --git a/src-tauri/src/commands/hosting.rs b/src-tauri/src/commands/hosting.rs index 635dea6..72cb443 100644 --- a/src-tauri/src/commands/hosting.rs +++ b/src-tauri/src/commands/hosting.rs @@ -1,66 +1,69 @@ use tauri::State; use tauri_plugin_opener::OpenerExt; +use crate::commands::with_repo; use crate::hosting::detector; use crate::hosting::github; use crate::hosting::types::{HostingInfo, Issue, PrDetail, PullRequest}; use crate::state::AppState; -fn get_repo_path(state: &State<'_, AppState>) -> Result { - let guard = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let repo = guard.as_ref().ok_or("No repository opened")?; - Ok(repo.workdir().to_string_lossy().to_string()) +fn get_repo_path(state: &State<'_, AppState>, tab_id: &str) -> Result { + with_repo(state, tab_id, |backend| { + Ok(backend.workdir().to_string_lossy().to_string()) + }) } #[tauri::command] -pub fn detect_hosting_provider(state: State<'_, AppState>) -> Result { - let guard = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let repo = guard.as_ref().ok_or("No repository opened")?; - let remotes = repo.list_remotes().map_err(|e| e.to_string())?; - let remote = remotes.first().ok_or("No remotes found")?; - Ok(detector::detect_from_remote_url(&remote.url)) +pub fn detect_hosting_provider( + tab_id: String, + state: State<'_, AppState>, +) -> Result { + with_repo(&state, &tab_id, |backend| { + let remotes = backend.list_remotes().map_err(|e| e.to_string())?; + let remote = remotes.first().ok_or("No remotes found")?; + Ok(detector::detect_from_remote_url(&remote.url)) + }) } #[tauri::command] -pub fn list_pull_requests(state: State<'_, AppState>) -> Result, String> { - let repo_path = get_repo_path(&state)?; +pub fn list_pull_requests( + tab_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let repo_path = get_repo_path(&state, &tab_id)?; github::list_pull_requests(&repo_path) } #[tauri::command] pub fn get_pull_request_detail( + tab_id: String, state: State<'_, AppState>, number: u64, ) -> Result { - let repo_path = get_repo_path(&state)?; + let repo_path = get_repo_path(&state, &tab_id)?; github::get_pull_request_detail(&repo_path, number) } #[tauri::command] -pub fn list_issues(state: State<'_, AppState>) -> Result, String> { - let repo_path = get_repo_path(&state)?; +pub fn list_issues(tab_id: String, state: State<'_, AppState>) -> Result, String> { + let repo_path = get_repo_path(&state, &tab_id)?; github::list_issues(&repo_path) } #[tauri::command] -pub fn get_default_branch(state: State<'_, AppState>) -> Result { - let repo_path = get_repo_path(&state)?; +pub fn get_default_branch(tab_id: String, state: State<'_, AppState>) -> Result { + let repo_path = get_repo_path(&state, &tab_id)?; github::get_default_branch(&repo_path) } #[tauri::command] pub fn create_pull_request_url( + tab_id: String, state: State<'_, AppState>, head: String, base: String, ) -> Result { - let repo_path = get_repo_path(&state)?; + let repo_path = get_repo_path(&state, &tab_id)?; github::create_pull_request_url(&repo_path, &head, &base) } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 45ea82c..d94ae22 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -16,5 +16,23 @@ pub mod revert; pub mod search; pub mod stash; pub mod submodule; +pub mod tab; pub mod tag; pub mod worktree; + +use crate::git::backend::GitBackend; +use crate::state::AppState; + +pub fn with_repo(state: &AppState, tab_id: &str, f: F) -> Result +where + F: FnOnce(&dyn GitBackend) -> Result, +{ + let tabs = state + .tabs + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + let ctx = tabs + .get(tab_id) + .ok_or("No repository opened for this tab")?; + f(ctx.backend.as_ref()) +} diff --git a/src-tauri/src/commands/rebase.rs b/src-tauri/src/commands/rebase.rs index bfac481..9c912bd 100644 --- a/src-tauri/src/commands/rebase.rs +++ b/src-tauri/src/commands/rebase.rs @@ -1,101 +1,88 @@ use tauri::State; +use crate::commands::with_repo; use crate::git::types::{MergeBaseContent, RebaseResult, RebaseState, RebaseTodoEntry}; use crate::state::AppState; #[tauri::command] -pub fn rebase(onto: String, state: State<'_, AppState>) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.rebase(&onto).map_err(|e| e.to_string()) +pub fn rebase( + tab_id: String, + onto: String, + state: State<'_, AppState>, +) -> Result { + with_repo(&state, &tab_id, |backend| { + backend.rebase(&onto).map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn interactive_rebase( + tab_id: String, onto: String, todo: Vec, state: State<'_, AppState>, ) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .interactive_rebase(&onto, &todo) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .interactive_rebase(&onto, &todo) + .map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn is_rebasing(state: State<'_, AppState>) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.is_rebasing().map_err(|e| e.to_string()) +pub fn is_rebasing(tab_id: String, state: State<'_, AppState>) -> Result { + with_repo(&state, &tab_id, |backend| { + backend.is_rebasing().map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn abort_rebase(state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.abort_rebase().map_err(|e| e.to_string()) +pub fn abort_rebase(tab_id: String, state: State<'_, AppState>) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.abort_rebase().map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn continue_rebase(state: State<'_, AppState>) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.continue_rebase().map_err(|e| e.to_string()) +pub fn continue_rebase(tab_id: String, state: State<'_, AppState>) -> Result { + with_repo(&state, &tab_id, |backend| { + backend.continue_rebase().map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn get_rebase_state(state: State<'_, AppState>) -> Result, String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.get_rebase_state().map_err(|e| e.to_string()) +pub fn get_rebase_state( + tab_id: String, + state: State<'_, AppState>, +) -> Result, String> { + with_repo(&state, &tab_id, |backend| { + backend.get_rebase_state().map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn get_rebase_todo( + tab_id: String, onto: String, limit: usize, state: State<'_, AppState>, ) -> Result, String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .get_rebase_todo(&onto, limit) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .get_rebase_todo(&onto, limit) + .map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn get_merge_base_content( + tab_id: String, path: String, state: State<'_, AppState>, ) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .get_merge_base_content(&path) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .get_merge_base_content(&path) + .map_err(|e| e.to_string()) + }) } diff --git a/src-tauri/src/commands/remote.rs b/src-tauri/src/commands/remote.rs index e6b89fa..d5c4652 100644 --- a/src-tauri/src/commands/remote.rs +++ b/src-tauri/src/commands/remote.rs @@ -1,89 +1,85 @@ use tauri::State; +use crate::commands::with_repo; use crate::git::types::{FetchResult, MergeResult, PullOption, PushResult, RemoteInfo}; use crate::state::AppState; #[tauri::command] pub fn fetch_remote( + tab_id: String, remote_name: String, state: State<'_, AppState>, ) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.fetch(&remote_name).map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend.fetch(&remote_name).map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn pull_remote( + tab_id: String, remote_name: String, option: PullOption, state: State<'_, AppState>, ) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .pull(&remote_name, option) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .pull(&remote_name, option) + .map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn push_remote(remote_name: String, state: State<'_, AppState>) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.push(&remote_name).map_err(|e| e.to_string()) +pub fn push_remote( + tab_id: String, + remote_name: String, + state: State<'_, AppState>, +) -> Result { + with_repo(&state, &tab_id, |backend| { + backend.push(&remote_name).map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn list_remotes(state: State<'_, AppState>) -> Result, String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.list_remotes().map_err(|e| e.to_string()) +pub fn list_remotes(tab_id: String, state: State<'_, AppState>) -> Result, String> { + with_repo(&state, &tab_id, |backend| { + backend.list_remotes().map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn add_remote(name: String, url: String, state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.add_remote(&name, &url).map_err(|e| e.to_string()) +pub fn add_remote( + tab_id: String, + name: String, + url: String, + state: State<'_, AppState>, +) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.add_remote(&name, &url).map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn remove_remote(name: String, state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.remove_remote(&name).map_err(|e| e.to_string()) +pub fn remove_remote( + tab_id: String, + name: String, + state: State<'_, AppState>, +) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.remove_remote(&name).map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn edit_remote( + tab_id: String, name: String, new_url: String, state: State<'_, AppState>, ) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .edit_remote(&name, &new_url) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .edit_remote(&name, &new_url) + .map_err(|e| e.to_string()) + }) } diff --git a/src-tauri/src/commands/repo.rs b/src-tauri/src/commands/repo.rs index 90d7ce2..02634f7 100644 --- a/src-tauri/src/commands/repo.rs +++ b/src-tauri/src/commands/repo.rs @@ -1,57 +1,57 @@ use tauri::{AppHandle, Emitter, State}; use crate::config::{self, RecentRepo}; -use crate::git::backend::GitBackend; use crate::git::dispatcher::GitDispatcher; -use crate::state::AppState; +use crate::state::{self, AppState, RepoContext}; use crate::watcher; -fn repo_name_from_path(path: &str) -> String { - std::path::Path::new(path) - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| path.to_string()) -} - -fn setup_repo_after_open( - backend: Box, +pub(crate) fn setup_repo_after_open( + backend: Box, app_handle: &AppHandle, state: &State<'_, AppState>, path: &str, + tab_id: &str, ) -> Result<(), String> { let watch_path = backend.workdir().to_path_buf(); + let name = state::repo_name_from_path(path); + + let watcher_box = match watcher::start_watcher(app_handle.clone(), &watch_path, tab_id) { + Ok(w) => Some(w), + Err(e) => { + log::error!("Failed to start watcher: {}", e); + None + } + }; + + let mut cfg = config::load_config().map_err(|e| e.to_string())?; + cfg.last_opened_repo = Some(path.to_string()); + config::add_recent_repo(&mut cfg, path, &name); + + let ctx = RepoContext { + backend, + watcher: watcher_box, + path: path.to_string(), + name, + }; { - let mut repo_lock = state - .repo + let mut tabs = state + .tabs .lock() .map_err(|e| format!("Lock poisoned: {e}"))?; - *repo_lock = Some(backend); + tabs.insert(tab_id.to_string(), ctx); } { - let mut watcher_lock = state - .watcher + let mut active = state + .active_tab .lock() .map_err(|e| format!("Lock poisoned: {e}"))?; - *watcher_lock = None; - - match watcher::start_watcher(app_handle.clone(), &watch_path) { - Ok(w) => { - *watcher_lock = Some(w); - } - Err(e) => { - log::error!("Failed to start watcher: {}", e); - } - } + *active = Some(tab_id.to_string()); } - - let mut cfg = config::load_config().map_err(|e| e.to_string())?; - cfg.last_opened_repo = Some(path.to_string()); - config::add_recent_repo(&mut cfg, path, &repo_name_from_path(path)); config::save_config(&cfg).map_err(|e| e.to_string())?; - let _ = app_handle.emit("repo:changed", ()); + let _ = app_handle.emit("repo:changed", tab_id.to_string()); Ok(()) } @@ -59,17 +59,19 @@ fn setup_repo_after_open( #[tauri::command] pub fn open_repository( path: String, + tab_id: String, app_handle: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { let backend = GitDispatcher::open_default(&path).map_err(|e| e.to_string())?; - setup_repo_after_open(backend, &app_handle, &state, &path) + setup_repo_after_open(backend, &app_handle, &state, &path, &tab_id) } #[tauri::command] pub fn init_repository( path: String, gitignore_template: Option, + tab_id: String, app_handle: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { @@ -84,7 +86,7 @@ pub fn init_repository( } } - setup_repo_after_open(backend, &app_handle, &state, &path) + setup_repo_after_open(backend, &app_handle, &state, &path, &tab_id) } #[tauri::command] @@ -105,9 +107,10 @@ pub fn remove_recent_repo(path: String) -> Result<(), String> { pub fn clone_repository( url: String, path: String, + tab_id: String, app_handle: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { let backend = GitDispatcher::clone_repo(&url, &path).map_err(|e| e.to_string())?; - setup_repo_after_open(backend, &app_handle, &state, &path) + setup_repo_after_open(backend, &app_handle, &state, &path, &tab_id) } diff --git a/src-tauri/src/commands/reset.rs b/src-tauri/src/commands/reset.rs index 9732853..fe7c757 100644 --- a/src-tauri/src/commands/reset.rs +++ b/src-tauri/src/commands/reset.rs @@ -1,44 +1,43 @@ use tauri::State; +use crate::commands::with_repo; use crate::git::types::{ReflogEntry, ResetMode, ResetResult}; use crate::state::AppState; #[tauri::command] pub fn reset( + tab_id: String, oid: String, mode: ResetMode, state: State<'_, AppState>, ) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.reset(&oid, mode).map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend.reset(&oid, mode).map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn reset_file(path: String, oid: String, state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.reset_file(&path, &oid).map_err(|e| e.to_string()) +pub fn reset_file( + tab_id: String, + path: String, + oid: String, + state: State<'_, AppState>, +) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.reset_file(&path, &oid).map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn get_reflog( + tab_id: String, ref_name: String, limit: usize, state: State<'_, AppState>, ) -> Result, String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .get_reflog(&ref_name, limit) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .get_reflog(&ref_name, limit) + .map_err(|e| e.to_string()) + }) } diff --git a/src-tauri/src/commands/revert.rs b/src-tauri/src/commands/revert.rs index 96fa76c..7829c64 100644 --- a/src-tauri/src/commands/revert.rs +++ b/src-tauri/src/commands/revert.rs @@ -1,48 +1,38 @@ use tauri::State; +use crate::commands::with_repo; use crate::git::types::{RevertMode, RevertResult}; use crate::state::AppState; #[tauri::command] pub fn revert( + tab_id: String, oid: String, mode: RevertMode, state: State<'_, AppState>, ) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.revert(&oid, mode).map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend.revert(&oid, mode).map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn is_reverting(state: State<'_, AppState>) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.is_reverting().map_err(|e| e.to_string()) +pub fn is_reverting(tab_id: String, state: State<'_, AppState>) -> Result { + with_repo(&state, &tab_id, |backend| { + backend.is_reverting().map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn abort_revert(state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.abort_revert().map_err(|e| e.to_string()) +pub fn abort_revert(tab_id: String, state: State<'_, AppState>) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.abort_revert().map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn continue_revert(state: State<'_, AppState>) -> Result { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.continue_revert().map_err(|e| e.to_string()) +pub fn continue_revert(tab_id: String, state: State<'_, AppState>) -> Result { + with_repo(&state, &tab_id, |backend| { + backend.continue_revert().map_err(|e| e.to_string()) + }) } diff --git a/src-tauri/src/commands/search.rs b/src-tauri/src/commands/search.rs index e7c7866..cead1ef 100644 --- a/src-tauri/src/commands/search.rs +++ b/src-tauri/src/commands/search.rs @@ -1,49 +1,44 @@ use tauri::State; +use crate::commands::with_repo; use crate::git::search::{CodeSearchResult, CommitSearchResult, FilenameSearchResult}; use crate::state::AppState; #[tauri::command] pub fn search_code( + tab_id: String, query: String, is_regex: bool, state: State<'_, AppState>, ) -> Result, String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .search_code(&query, is_regex) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .search_code(&query, is_regex) + .map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn search_commits( + tab_id: String, query: String, search_diff: bool, state: State<'_, AppState>, ) -> Result, String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .search_commits(&query, search_diff) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .search_commits(&query, search_diff) + .map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn search_filenames( + tab_id: String, query: String, state: State<'_, AppState>, ) -> Result, String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.search_filenames(&query).map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend.search_filenames(&query).map_err(|e| e.to_string()) + }) } diff --git a/src-tauri/src/commands/stash.rs b/src-tauri/src/commands/stash.rs index accf424..8a788cf 100644 --- a/src-tauri/src/commands/stash.rs +++ b/src-tauri/src/commands/stash.rs @@ -1,66 +1,57 @@ use tauri::State; +use crate::commands::with_repo; use crate::git::types::{FileDiff, StashEntry}; use crate::state::AppState; #[tauri::command] -pub fn stash_save(message: Option, state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .stash_save(message.as_deref()) - .map_err(|e| e.to_string()) +pub fn stash_save( + tab_id: String, + message: Option, + state: State<'_, AppState>, +) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend + .stash_save(message.as_deref()) + .map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn list_stashes(state: State<'_, AppState>) -> Result, String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.stash_list().map_err(|e| e.to_string()) +pub fn list_stashes(tab_id: String, state: State<'_, AppState>) -> Result, String> { + with_repo(&state, &tab_id, |backend| { + backend.stash_list().map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn apply_stash(index: usize, state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.stash_apply(index).map_err(|e| e.to_string()) +pub fn apply_stash(tab_id: String, index: usize, state: State<'_, AppState>) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.stash_apply(index).map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn pop_stash(index: usize, state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.stash_pop(index).map_err(|e| e.to_string()) +pub fn pop_stash(tab_id: String, index: usize, state: State<'_, AppState>) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.stash_pop(index).map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn drop_stash(index: usize, state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.stash_drop(index).map_err(|e| e.to_string()) +pub fn drop_stash(tab_id: String, index: usize, state: State<'_, AppState>) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.stash_drop(index).map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn get_stash_diff(index: usize, state: State<'_, AppState>) -> Result, String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.stash_diff(index).map_err(|e| e.to_string()) +pub fn get_stash_diff( + tab_id: String, + index: usize, + state: State<'_, AppState>, +) -> Result, String> { + with_repo(&state, &tab_id, |backend| { + backend.stash_diff(index).map_err(|e| e.to_string()) + }) } diff --git a/src-tauri/src/commands/submodule.rs b/src-tauri/src/commands/submodule.rs index ddf26c4..d2c1b8f 100644 --- a/src-tauri/src/commands/submodule.rs +++ b/src-tauri/src/commands/submodule.rs @@ -1,56 +1,58 @@ use tauri::State; +use crate::commands::with_repo; use crate::git::types::SubmoduleInfo; use crate::state::AppState; #[tauri::command] -pub fn list_submodules(state: State<'_, AppState>) -> Result, String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.list_submodules().map_err(|e| e.to_string()) +pub fn list_submodules( + tab_id: String, + state: State<'_, AppState>, +) -> Result, String> { + with_repo(&state, &tab_id, |backend| { + backend.list_submodules().map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn add_submodule(url: String, path: String, state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .add_submodule(&url, &path) - .map_err(|e| e.to_string()) +pub fn add_submodule( + tab_id: String, + url: String, + path: String, + state: State<'_, AppState>, +) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend + .add_submodule(&url, &path) + .map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn update_submodule(path: String, state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.update_submodule(&path).map_err(|e| e.to_string()) +pub fn update_submodule( + tab_id: String, + path: String, + state: State<'_, AppState>, +) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.update_submodule(&path).map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn update_all_submodules(state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.update_all_submodules().map_err(|e| e.to_string()) +pub fn update_all_submodules(tab_id: String, state: State<'_, AppState>) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.update_all_submodules().map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn remove_submodule(path: String, state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.remove_submodule(&path).map_err(|e| e.to_string()) +pub fn remove_submodule( + tab_id: String, + path: String, + state: State<'_, AppState>, +) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.remove_submodule(&path).map_err(|e| e.to_string()) + }) } diff --git a/src-tauri/src/commands/tab.rs b/src-tauri/src/commands/tab.rs new file mode 100644 index 0000000..dd37f5f --- /dev/null +++ b/src-tauri/src/commands/tab.rs @@ -0,0 +1,92 @@ +use serde::Serialize; +use tauri::{AppHandle, State}; + +use crate::git::dispatcher::GitDispatcher; +use crate::state::{AppState, TabId}; + +#[derive(Debug, Serialize)] +pub struct TabInfo { + pub id: String, + pub name: String, + pub path: String, +} + +#[tauri::command] +pub fn open_tab( + path: String, + tab_id: String, + app_handle: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let backend = GitDispatcher::open_default(&path).map_err(|e| e.to_string())?; + super::repo::setup_repo_after_open(backend, &app_handle, &state, &path, &tab_id) +} + +#[tauri::command] +pub fn close_tab(tab_id: String, state: State<'_, AppState>) -> Result<(), String> { + let mut tabs = state + .tabs + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + tabs.remove(&tab_id); + + let mut active = state + .active_tab + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + if active.as_deref() == Some(&tab_id) { + *active = tabs.keys().next().cloned(); + } + + Ok(()) +} + +#[tauri::command] +pub fn set_active_tab(tab_id: String, state: State<'_, AppState>) -> Result<(), String> { + let tabs = state + .tabs + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + if !tabs.contains_key(&tab_id) { + return Err("Tab not found".to_string()); + } + drop(tabs); + + let mut active = state + .active_tab + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + *active = Some(tab_id); + + Ok(()) +} + +#[tauri::command] +pub fn list_tabs(state: State<'_, AppState>) -> Result, String> { + let tabs = state + .tabs + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + let mut result: Vec = tabs + .iter() + .map(|(id, ctx)| TabInfo { + id: id.clone(), + name: ctx.name.clone(), + path: ctx.path.clone(), + }) + .collect(); + + // Sort so active tab context is consistent + result.sort_by(|a, b| a.id.cmp(&b.id)); + + Ok(result) +} + +#[tauri::command] +pub fn get_active_tab(state: State<'_, AppState>) -> Result, String> { + let active = state + .active_tab + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + Ok(active.clone()) +} diff --git a/src-tauri/src/commands/tag.rs b/src-tauri/src/commands/tag.rs index 3393eb3..e93a226 100644 --- a/src-tauri/src/commands/tag.rs +++ b/src-tauri/src/commands/tag.rs @@ -1,50 +1,44 @@ use tauri::State; +use crate::commands::with_repo; use crate::git::types::TagInfo; use crate::state::AppState; #[tauri::command] -pub fn list_tags(state: State<'_, AppState>) -> Result, String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.list_tags().map_err(|e| e.to_string()) +pub fn list_tags(tab_id: String, state: State<'_, AppState>) -> Result, String> { + with_repo(&state, &tab_id, |backend| { + backend.list_tags().map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn create_tag( + tab_id: String, name: String, message: Option, state: State<'_, AppState>, ) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .create_tag(&name, message.as_deref()) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .create_tag(&name, message.as_deref()) + .map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn delete_tag(name: String, state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.delete_tag(&name).map_err(|e| e.to_string()) +pub fn delete_tag(tab_id: String, name: String, state: State<'_, AppState>) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.delete_tag(&name).map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn checkout_tag(name: String, state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.checkout_tag(&name).map_err(|e| e.to_string()) +pub fn checkout_tag( + tab_id: String, + name: String, + state: State<'_, AppState>, +) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.checkout_tag(&name).map_err(|e| e.to_string()) + }) } diff --git a/src-tauri/src/commands/worktree.rs b/src-tauri/src/commands/worktree.rs index db52b04..41aff5b 100644 --- a/src-tauri/src/commands/worktree.rs +++ b/src-tauri/src/commands/worktree.rs @@ -1,40 +1,40 @@ use tauri::State; +use crate::commands::with_repo; use crate::git::types::WorktreeInfo; use crate::state::AppState; #[tauri::command] -pub fn list_worktrees(state: State<'_, AppState>) -> Result, String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.list_worktrees().map_err(|e| e.to_string()) +pub fn list_worktrees( + tab_id: String, + state: State<'_, AppState>, +) -> Result, String> { + with_repo(&state, &tab_id, |backend| { + backend.list_worktrees().map_err(|e| e.to_string()) + }) } #[tauri::command] pub fn add_worktree( + tab_id: String, path: String, branch: String, state: State<'_, AppState>, ) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend - .add_worktree(&path, &branch) - .map_err(|e| e.to_string()) + with_repo(&state, &tab_id, |backend| { + backend + .add_worktree(&path, &branch) + .map_err(|e| e.to_string()) + }) } #[tauri::command] -pub fn remove_worktree(path: String, state: State<'_, AppState>) -> Result<(), String> { - let repo_lock = state - .repo - .lock() - .map_err(|e| format!("Lock poisoned: {e}"))?; - let backend = repo_lock.as_ref().ok_or("No repository opened")?; - backend.remove_worktree(&path).map_err(|e| e.to_string()) +pub fn remove_worktree( + tab_id: String, + path: String, + state: State<'_, AppState>, +) -> Result<(), String> { + with_repo(&state, &tab_id, |backend| { + backend.remove_worktree(&path).map_err(|e| e.to_string()) + }) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ed4a980..bb096a4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ pub mod ai; +pub mod auto_fetch; pub mod commands; pub mod config; pub mod git; @@ -6,19 +7,18 @@ pub mod hosting; pub mod state; pub mod watcher; +use std::collections::HashMap; use std::sync::Mutex; -use state::AppState; +use state::{AppState, RepoContext, DEFAULT_TAB_ID}; use tauri::Manager; /// CLI 引数 → last_opened_repo → カレントディレクトリ の優先順位でリポジトリパスを解決する fn resolve_repo_path() -> Option { - // 1. CLI 引数にパスが指定されている場合 if let Some(arg) = std::env::args().nth(1) { return std::fs::canonicalize(&arg).ok(); } - // 2. last_opened_repo が設定されている場合 if let Ok(cfg) = config::load_config() { if let Some(last) = cfg.last_opened_repo { let path = std::path::PathBuf::from(&last); @@ -28,7 +28,6 @@ fn resolve_repo_path() -> Option { } } - // 3. カレントディレクトリ std::env::current_dir().ok() } @@ -39,27 +38,47 @@ pub fn run() { .as_ref() .and_then(|path| git::dispatcher::GitDispatcher::open_default(path).ok()); - // リポジトリを正常に開けた場合、last_opened_repo に保存 - if let (Some(path), Some(_)) = (&repo_path, &repo) { + let default_tab_id = DEFAULT_TAB_ID.to_string(); + let mut tabs = HashMap::new(); + let mut active_tab = None; + + if let (Some(path), Some(backend)) = (&repo_path, repo) { if let Ok(mut cfg) = config::load_config() { cfg.last_opened_repo = Some(path.to_string_lossy().to_string()); let _ = config::save_config(&cfg); } + + let path_str = path.to_string_lossy().to_string(); + let name = state::repo_name_from_path(&path_str); + let ctx = RepoContext { + backend, + watcher: None, + path: path_str, + name, + }; + tabs.insert(default_tab_id.clone(), ctx); + active_tab = Some(default_tab_id.clone()); } - // git2::Repository::discover で実際の workdir を解決する - let watch_path = repo_path.as_ref().and_then(|path| { - git2::Repository::discover(path) - .ok() - .and_then(|repo| repo.workdir().map(|p| p.to_path_buf())) + let watch_info: Option<(std::path::PathBuf, String)> = repo_path.as_ref().and_then(|path| { + git2::Repository::discover(path).ok().and_then(|repo| { + repo.workdir() + .map(|p| (p.to_path_buf(), default_tab_id.clone())) + }) }); + let auto_fetch_interval = config::load_config() + .map(|c| c.tools.auto_fetch_interval) + .unwrap_or(300); + tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_notification::init()) .manage(AppState { - repo: Mutex::new(repo), - watcher: Mutex::new(None), + tabs: Mutex::new(tabs), + active_tab: Mutex::new(active_tab), + auto_fetch_handle: Mutex::new(None), }) .setup(move |app| { if cfg!(debug_assertions) { @@ -70,11 +89,14 @@ pub fn run() { )?; } - if let Some(path) = watch_path { - match watcher::start_watcher(app.handle().clone(), &path) { + if let Some((watch_path, tab_id)) = watch_info { + match watcher::start_watcher(app.handle().clone(), &watch_path, &tab_id) { Ok(w) => { let state = app.state::(); - *state.watcher.lock().unwrap() = Some(w); + let mut tabs = state.tabs.lock().unwrap(); + if let Some(ctx) = tabs.get_mut(&tab_id) { + ctx.watcher = Some(w); + } } Err(e) => { log::error!("ファイル監視の起動に失敗: {}", e); @@ -82,9 +104,27 @@ pub fn run() { } } + // auto fetch の起動 + let auto_fetch_enabled = config::load_config() + .map(|c| c.tools.auto_fetch_on_open) + .unwrap_or(true); + + if auto_fetch_enabled && auto_fetch_interval > 0 { + let handle = + auto_fetch::start_auto_fetch(app.handle().clone(), auto_fetch_interval as u64); + let state = app.state::(); + let mut auto_fetch_lock = state.auto_fetch_handle.lock().unwrap(); + *auto_fetch_lock = Some(Box::new(handle)); + } + Ok(()) }) .invoke_handler(tauri::generate_handler![ + commands::tab::open_tab, + commands::tab::close_tab, + commands::tab::set_active_tab, + commands::tab::list_tabs, + commands::tab::get_active_tab, commands::git::get_status, commands::git::get_diff, commands::git::stage_file, @@ -161,7 +201,6 @@ pub fn run() { commands::ai::generate_commit_message, commands::ai::review_diff, commands::ai::ai_resolve_conflict, - commands::ai::generate_pr_description, commands::ai::get_ai_config, commands::ai::save_ai_config, commands::hosting::detect_hosting_provider, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index a1f4b95..3ab9062 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,8 +1,29 @@ +use std::collections::HashMap; +use std::path::Path; use std::sync::Mutex; use crate::git::backend::GitBackend; +pub const DEFAULT_TAB_ID: &str = "default"; + +pub type TabId = String; + +pub struct RepoContext { + pub backend: Box, + pub watcher: Option>, + pub path: String, + pub name: String, +} + pub struct AppState { - pub repo: Mutex>>, - pub watcher: Mutex>>, + pub tabs: Mutex>, + pub active_tab: Mutex>, + pub auto_fetch_handle: Mutex>>, +} + +pub fn repo_name_from_path(path: &str) -> String { + Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.to_string()) } diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs index 5a63c5f..eaf154c 100644 --- a/src-tauri/src/watcher.rs +++ b/src-tauri/src/watcher.rs @@ -7,14 +7,16 @@ use tauri::{AppHandle, Emitter}; pub fn start_watcher( app_handle: AppHandle, repo_path: &Path, + tab_id: &str, ) -> Result, Box> { let handle = app_handle.clone(); + let tab_id_owned = tab_id.to_string(); let mut debouncer = new_debouncer( Duration::from_millis(500), move |res: Result, notify::Error>| { if res.is_ok() { - let _ = handle.emit("repo:changed", ()); + let _ = handle.emit("repo:changed", &tab_id_owned); } }, )?; diff --git a/src-tauri/tests/tauri_commands_test.rs b/src-tauri/tests/tauri_commands_test.rs index d6ea993..daf53da 100644 --- a/src-tauri/tests/tauri_commands_test.rs +++ b/src-tauri/tests/tauri_commands_test.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::fs; use std::path::Path; use std::process::Command; @@ -6,7 +7,7 @@ use std::sync::Mutex; use app_lib::commands; use app_lib::git::backend::GitBackend; use app_lib::git::git2_backend::Git2Backend; -use app_lib::state::AppState; +use app_lib::state::{AppState, RepoContext}; fn init_test_repo(dir: &Path) { Command::new("git") @@ -138,14 +139,15 @@ fn make_request(cmd: &str, body: serde_json::Value) -> tauri::webview::InvokeReq fn test_no_repo_returns_error() { // Given: AppState with no repository let state = AppState { - repo: Mutex::new(None), - watcher: Mutex::new(None), + tabs: Mutex::new(HashMap::new()), + active_tab: Mutex::new(None), + auto_fetch_handle: Mutex::new(None), }; let app = build_test_app(state); let webview = build_test_webview(&app); // When: get_status is called - let request = make_request("get_status", serde_json::json!({})); + let request = make_request("get_status", serde_json::json!({ "tabId": "test" })); let response = tauri::test::get_ipc_response(&webview, request); // Then: error containing "No repository opened" @@ -165,15 +167,26 @@ fn test_get_status_empty_repo() { let tmp = tempfile::tempdir().unwrap(); init_test_repo(tmp.path()); let backend = Git2Backend::open(tmp.path()).unwrap(); + let mut tabs = HashMap::new(); + tabs.insert( + "test".to_string(), + RepoContext { + backend: Box::new(backend), + watcher: None, + path: tmp.path().to_string_lossy().to_string(), + name: "test-repo".to_string(), + }, + ); let state = AppState { - repo: Mutex::new(Some(Box::new(backend))), - watcher: Mutex::new(None), + tabs: Mutex::new(tabs), + active_tab: Mutex::new(Some("test".to_string())), + auto_fetch_handle: Mutex::new(None), }; let app = build_test_app(state); let webview = build_test_webview(&app); // When: get_status is called - let request = make_request("get_status", serde_json::json!({})); + let request = make_request("get_status", serde_json::json!({ "tabId": "test" })); let response = tauri::test::get_ipc_response(&webview, request); // Then: empty files list @@ -191,15 +204,26 @@ fn test_get_status_with_files() { init_test_repo(tmp.path()); fs::write(tmp.path().join("hello.txt"), "hello").unwrap(); let backend = Git2Backend::open(tmp.path()).unwrap(); + let mut tabs = HashMap::new(); + tabs.insert( + "test".to_string(), + RepoContext { + backend: Box::new(backend), + watcher: None, + path: tmp.path().to_string_lossy().to_string(), + name: "test-repo".to_string(), + }, + ); let state = AppState { - repo: Mutex::new(Some(Box::new(backend))), - watcher: Mutex::new(None), + tabs: Mutex::new(tabs), + active_tab: Mutex::new(Some("test".to_string())), + auto_fetch_handle: Mutex::new(None), }; let app = build_test_app(state); let webview = build_test_webview(&app); // When: get_status is called - let request = make_request("get_status", serde_json::json!({})); + let request = make_request("get_status", serde_json::json!({ "tabId": "test" })); let response = tauri::test::get_ipc_response(&webview, request); // Then: one untracked file @@ -218,19 +242,33 @@ fn test_stage_and_unstage_file() { init_test_repo(tmp.path()); fs::write(tmp.path().join("a.txt"), "content").unwrap(); let backend = Git2Backend::open(tmp.path()).unwrap(); + let mut tabs = HashMap::new(); + tabs.insert( + "test".to_string(), + RepoContext { + backend: Box::new(backend), + watcher: None, + path: tmp.path().to_string_lossy().to_string(), + name: "test-repo".to_string(), + }, + ); let state = AppState { - repo: Mutex::new(Some(Box::new(backend))), - watcher: Mutex::new(None), + tabs: Mutex::new(tabs), + active_tab: Mutex::new(Some("test".to_string())), + auto_fetch_handle: Mutex::new(None), }; let app = build_test_app(state); let webview = build_test_webview(&app); // When: stage_file is called - let request = make_request("stage_file", serde_json::json!({ "path": "a.txt" })); + let request = make_request( + "stage_file", + serde_json::json!({ "tabId": "test", "path": "a.txt" }), + ); tauri::test::get_ipc_response(&webview, request).expect("stage_file should succeed"); // Then: file is staged - let request = make_request("get_status", serde_json::json!({})); + let request = make_request("get_status", serde_json::json!({ "tabId": "test" })); let body = tauri::test::get_ipc_response(&webview, request).expect("get_status should succeed"); let status = body .deserialize::() @@ -241,11 +279,14 @@ fn test_stage_and_unstage_file() { .any(|f| f.path == "a.txt" && f.staging == app_lib::git::types::StagingState::Staged)); // When: unstage_file is called - let request = make_request("unstage_file", serde_json::json!({ "path": "a.txt" })); + let request = make_request( + "unstage_file", + serde_json::json!({ "tabId": "test", "path": "a.txt" }), + ); tauri::test::get_ipc_response(&webview, request).expect("unstage_file should succeed"); // Then: file is unstaged - let request = make_request("get_status", serde_json::json!({})); + let request = make_request("get_status", serde_json::json!({ "tabId": "test" })); let body = tauri::test::get_ipc_response(&webview, request).expect("get_status should succeed"); let status = body .deserialize::() @@ -264,19 +305,30 @@ fn test_stage_all_and_unstage_all() { fs::write(tmp.path().join("a.txt"), "a").unwrap(); fs::write(tmp.path().join("b.txt"), "b").unwrap(); let backend = Git2Backend::open(tmp.path()).unwrap(); + let mut tabs = HashMap::new(); + tabs.insert( + "test".to_string(), + RepoContext { + backend: Box::new(backend), + watcher: None, + path: tmp.path().to_string_lossy().to_string(), + name: "test-repo".to_string(), + }, + ); let state = AppState { - repo: Mutex::new(Some(Box::new(backend))), - watcher: Mutex::new(None), + tabs: Mutex::new(tabs), + active_tab: Mutex::new(Some("test".to_string())), + auto_fetch_handle: Mutex::new(None), }; let app = build_test_app(state); let webview = build_test_webview(&app); // When: stage_all is called - let request = make_request("stage_all", serde_json::json!({})); + let request = make_request("stage_all", serde_json::json!({ "tabId": "test" })); tauri::test::get_ipc_response(&webview, request).expect("stage_all should succeed"); // Then: all files are staged - let request = make_request("get_status", serde_json::json!({})); + let request = make_request("get_status", serde_json::json!({ "tabId": "test" })); let body = tauri::test::get_ipc_response(&webview, request).expect("get_status should succeed"); let status = body .deserialize::() @@ -287,11 +339,11 @@ fn test_stage_all_and_unstage_all() { .all(|f| f.staging == app_lib::git::types::StagingState::Staged)); // When: unstage_all is called - let request = make_request("unstage_all", serde_json::json!({})); + let request = make_request("unstage_all", serde_json::json!({ "tabId": "test" })); tauri::test::get_ipc_response(&webview, request).expect("unstage_all should succeed"); // Then: all files are unstaged - let request = make_request("get_status", serde_json::json!({})); + let request = make_request("get_status", serde_json::json!({ "tabId": "test" })); let body = tauri::test::get_ipc_response(&webview, request).expect("get_status should succeed"); let status = body .deserialize::() @@ -308,20 +360,34 @@ fn test_commit() { let tmp = tempfile::tempdir().unwrap(); let backend = init_repo_with_commit(tmp.path()); fs::write(tmp.path().join("new.txt"), "new content").unwrap(); + let mut tabs = HashMap::new(); + tabs.insert( + "test".to_string(), + RepoContext { + backend: backend, + watcher: None, + path: tmp.path().to_string_lossy().to_string(), + name: "test-repo".to_string(), + }, + ); let state = AppState { - repo: Mutex::new(Some(backend)), - watcher: Mutex::new(None), + tabs: Mutex::new(tabs), + active_tab: Mutex::new(Some("test".to_string())), + auto_fetch_handle: Mutex::new(None), }; let app = build_test_app(state); let webview = build_test_webview(&app); - let request = make_request("stage_file", serde_json::json!({ "path": "new.txt" })); + let request = make_request( + "stage_file", + serde_json::json!({ "tabId": "test", "path": "new.txt" }), + ); tauri::test::get_ipc_response(&webview, request).expect("stage_file should succeed"); // When: commit is called let request = make_request( "commit", - serde_json::json!({ "message": "test commit", "amend": false, "sign": false }), + serde_json::json!({ "tabId": "test", "message": "test commit", "amend": false, "sign": false }), ); let body = tauri::test::get_ipc_response(&webview, request).expect("commit should succeed"); @@ -337,15 +403,26 @@ fn test_get_current_branch() { // Given: a repository with at least one commit let tmp = tempfile::tempdir().unwrap(); let backend = init_repo_with_commit(tmp.path()); + let mut tabs = HashMap::new(); + tabs.insert( + "test".to_string(), + RepoContext { + backend: backend, + watcher: None, + path: tmp.path().to_string_lossy().to_string(), + name: "test-repo".to_string(), + }, + ); let state = AppState { - repo: Mutex::new(Some(backend)), - watcher: Mutex::new(None), + tabs: Mutex::new(tabs), + active_tab: Mutex::new(Some("test".to_string())), + auto_fetch_handle: Mutex::new(None), }; let app = build_test_app(state); let webview = build_test_webview(&app); // When: get_current_branch is called - let request = make_request("get_current_branch", serde_json::json!({})); + let request = make_request("get_current_branch", serde_json::json!({ "tabId": "test" })); let body = tauri::test::get_ipc_response(&webview, request) .expect("get_current_branch should succeed"); @@ -362,9 +439,20 @@ fn test_get_diff() { let tmp = tempfile::tempdir().unwrap(); let backend = init_repo_with_commit(tmp.path()); fs::write(tmp.path().join("init.txt"), "modified content").unwrap(); + let mut tabs = HashMap::new(); + tabs.insert( + "test".to_string(), + RepoContext { + backend: backend, + watcher: None, + path: tmp.path().to_string_lossy().to_string(), + name: "test-repo".to_string(), + }, + ); let state = AppState { - repo: Mutex::new(Some(backend)), - watcher: Mutex::new(None), + tabs: Mutex::new(tabs), + active_tab: Mutex::new(Some("test".to_string())), + auto_fetch_handle: Mutex::new(None), }; let app = build_test_app(state); let webview = build_test_webview(&app); @@ -372,7 +460,7 @@ fn test_get_diff() { // When: get_diff is called for unstaged changes let request = make_request( "get_diff", - serde_json::json!({ "path": "init.txt", "staged": false }), + serde_json::json!({ "tabId": "test", "path": "init.txt", "staged": false }), ); let body = tauri::test::get_ipc_response(&webview, request).expect("get_diff should succeed"); @@ -389,15 +477,29 @@ fn test_get_head_commit_message() { // Given: a repository with a commit let tmp = tempfile::tempdir().unwrap(); let backend = init_repo_with_commit(tmp.path()); + let mut tabs = HashMap::new(); + tabs.insert( + "test".to_string(), + RepoContext { + backend: backend, + watcher: None, + path: tmp.path().to_string_lossy().to_string(), + name: "test-repo".to_string(), + }, + ); let state = AppState { - repo: Mutex::new(Some(backend)), - watcher: Mutex::new(None), + tabs: Mutex::new(tabs), + active_tab: Mutex::new(Some("test".to_string())), + auto_fetch_handle: Mutex::new(None), }; let app = build_test_app(state); let webview = build_test_webview(&app); // When: get_head_commit_message is called - let request = make_request("get_head_commit_message", serde_json::json!({})); + let request = make_request( + "get_head_commit_message", + serde_json::json!({ "tabId": "test" }), + ); let body = tauri::test::get_ipc_response(&webview, request) .expect("get_head_commit_message should succeed"); @@ -415,15 +517,26 @@ fn test_list_branches() { // Given: a repository with at least one commit let tmp = tempfile::tempdir().unwrap(); let backend = init_repo_with_commit(tmp.path()); + let mut tabs = HashMap::new(); + tabs.insert( + "test".to_string(), + RepoContext { + backend: backend, + watcher: None, + path: tmp.path().to_string_lossy().to_string(), + name: "test-repo".to_string(), + }, + ); let state = AppState { - repo: Mutex::new(Some(backend)), - watcher: Mutex::new(None), + tabs: Mutex::new(tabs), + active_tab: Mutex::new(Some("test".to_string())), + auto_fetch_handle: Mutex::new(None), }; let app = build_test_app(state); let webview = build_test_webview(&app); // When: list_branches is called - let request = make_request("list_branches", serde_json::json!({})); + let request = make_request("list_branches", serde_json::json!({ "tabId": "test" })); let body = tauri::test::get_ipc_response(&webview, request).expect("list_branches should succeed"); @@ -440,23 +553,40 @@ fn test_create_and_checkout_branch() { // Given: a repository with at least one commit let tmp = tempfile::tempdir().unwrap(); let backend = init_repo_with_commit(tmp.path()); + let mut tabs = HashMap::new(); + tabs.insert( + "test".to_string(), + RepoContext { + backend: backend, + watcher: None, + path: tmp.path().to_string_lossy().to_string(), + name: "test-repo".to_string(), + }, + ); let state = AppState { - repo: Mutex::new(Some(backend)), - watcher: Mutex::new(None), + tabs: Mutex::new(tabs), + active_tab: Mutex::new(Some("test".to_string())), + auto_fetch_handle: Mutex::new(None), }; let app = build_test_app(state); let webview = build_test_webview(&app); // When: create_branch is called - let request = make_request("create_branch", serde_json::json!({ "name": "feature" })); + let request = make_request( + "create_branch", + serde_json::json!({ "tabId": "test", "name": "feature" }), + ); tauri::test::get_ipc_response(&webview, request).expect("create_branch should succeed"); // When: checkout_branch is called - let request = make_request("checkout_branch", serde_json::json!({ "name": "feature" })); + let request = make_request( + "checkout_branch", + serde_json::json!({ "tabId": "test", "name": "feature" }), + ); tauri::test::get_ipc_response(&webview, request).expect("checkout_branch should succeed"); // Then: current branch is "feature" - let request = make_request("get_current_branch", serde_json::json!({})); + let request = make_request("get_current_branch", serde_json::json!({ "tabId": "test" })); let body = tauri::test::get_ipc_response(&webview, request) .expect("get_current_branch should succeed"); let branch = body @@ -472,15 +602,26 @@ fn test_list_remotes_empty() { // Given: a repository with no remotes let tmp = tempfile::tempdir().unwrap(); let backend = init_repo_with_commit(tmp.path()); + let mut tabs = HashMap::new(); + tabs.insert( + "test".to_string(), + RepoContext { + backend: backend, + watcher: None, + path: tmp.path().to_string_lossy().to_string(), + name: "test-repo".to_string(), + }, + ); let state = AppState { - repo: Mutex::new(Some(backend)), - watcher: Mutex::new(None), + tabs: Mutex::new(tabs), + active_tab: Mutex::new(Some("test".to_string())), + auto_fetch_handle: Mutex::new(None), }; let app = build_test_app(state); let webview = build_test_webview(&app); // When: list_remotes is called - let request = make_request("list_remotes", serde_json::json!({})); + let request = make_request("list_remotes", serde_json::json!({ "tabId": "test" })); let body = tauri::test::get_ipc_response(&webview, request).expect("list_remotes should succeed"); @@ -496,9 +637,20 @@ fn test_add_and_list_remote() { // Given: a repository with no remotes let tmp = tempfile::tempdir().unwrap(); let backend = init_repo_with_commit(tmp.path()); + let mut tabs = HashMap::new(); + tabs.insert( + "test".to_string(), + RepoContext { + backend: backend, + watcher: None, + path: tmp.path().to_string_lossy().to_string(), + name: "test-repo".to_string(), + }, + ); let state = AppState { - repo: Mutex::new(Some(backend)), - watcher: Mutex::new(None), + tabs: Mutex::new(tabs), + active_tab: Mutex::new(Some("test".to_string())), + auto_fetch_handle: Mutex::new(None), }; let app = build_test_app(state); let webview = build_test_webview(&app); @@ -506,12 +658,12 @@ fn test_add_and_list_remote() { // When: add_remote is called let request = make_request( "add_remote", - serde_json::json!({ "name": "origin", "url": "https://example.com/repo.git" }), + serde_json::json!({ "tabId": "test", "name": "origin", "url": "https://example.com/repo.git" }), ); tauri::test::get_ipc_response(&webview, request).expect("add_remote should succeed"); // Then: list_remotes returns the added remote - let request = make_request("list_remotes", serde_json::json!({})); + let request = make_request("list_remotes", serde_json::json!({ "tabId": "test" })); let body = tauri::test::get_ipc_response(&webview, request).expect("list_remotes should succeed"); let remotes = body @@ -529,15 +681,26 @@ fn test_list_tags_empty() { // Given: a repository with no tags let tmp = tempfile::tempdir().unwrap(); let backend = init_repo_with_commit(tmp.path()); + let mut tabs = HashMap::new(); + tabs.insert( + "test".to_string(), + RepoContext { + backend: backend, + watcher: None, + path: tmp.path().to_string_lossy().to_string(), + name: "test-repo".to_string(), + }, + ); let state = AppState { - repo: Mutex::new(Some(backend)), - watcher: Mutex::new(None), + tabs: Mutex::new(tabs), + active_tab: Mutex::new(Some("test".to_string())), + auto_fetch_handle: Mutex::new(None), }; let app = build_test_app(state); let webview = build_test_webview(&app); // When: list_tags is called - let request = make_request("list_tags", serde_json::json!({})); + let request = make_request("list_tags", serde_json::json!({ "tabId": "test" })); let body = tauri::test::get_ipc_response(&webview, request).expect("list_tags should succeed"); // Then: empty list @@ -552,9 +715,20 @@ fn test_create_and_list_tag() { // Given: a repository with at least one commit let tmp = tempfile::tempdir().unwrap(); let backend = init_repo_with_commit(tmp.path()); + let mut tabs = HashMap::new(); + tabs.insert( + "test".to_string(), + RepoContext { + backend: backend, + watcher: None, + path: tmp.path().to_string_lossy().to_string(), + name: "test-repo".to_string(), + }, + ); let state = AppState { - repo: Mutex::new(Some(backend)), - watcher: Mutex::new(None), + tabs: Mutex::new(tabs), + active_tab: Mutex::new(Some("test".to_string())), + auto_fetch_handle: Mutex::new(None), }; let app = build_test_app(state); let webview = build_test_webview(&app); @@ -562,12 +736,12 @@ fn test_create_and_list_tag() { // When: create_tag is called let request = make_request( "create_tag", - serde_json::json!({ "name": "v0.1.0", "message": null }), + serde_json::json!({ "tabId": "test", "name": "v0.1.0", "message": null }), ); tauri::test::get_ipc_response(&webview, request).expect("create_tag should succeed"); // Then: list_tags returns the created tag - let request = make_request("list_tags", serde_json::json!({})); + let request = make_request("list_tags", serde_json::json!({ "tabId": "test" })); let body = tauri::test::get_ipc_response(&webview, request).expect("list_tags should succeed"); let tags = body .deserialize::>() @@ -583,15 +757,26 @@ fn test_list_stashes_empty() { // Given: a repository with no stashes let tmp = tempfile::tempdir().unwrap(); let backend = init_repo_with_commit(tmp.path()); + let mut tabs = HashMap::new(); + tabs.insert( + "test".to_string(), + RepoContext { + backend: backend, + watcher: None, + path: tmp.path().to_string_lossy().to_string(), + name: "test-repo".to_string(), + }, + ); let state = AppState { - repo: Mutex::new(Some(backend)), - watcher: Mutex::new(None), + tabs: Mutex::new(tabs), + active_tab: Mutex::new(Some("test".to_string())), + auto_fetch_handle: Mutex::new(None), }; let app = build_test_app(state); let webview = build_test_webview(&app); // When: list_stashes is called - let request = make_request("list_stashes", serde_json::json!({})); + let request = make_request("list_stashes", serde_json::json!({ "tabId": "test" })); let body = tauri::test::get_ipc_response(&webview, request).expect("list_stashes should succeed"); @@ -609,9 +794,20 @@ fn test_get_commit_log() { // Given: a repository with commits let tmp = tempfile::tempdir().unwrap(); let backend = init_repo_with_commit(tmp.path()); + let mut tabs = HashMap::new(); + tabs.insert( + "test".to_string(), + RepoContext { + backend: backend, + watcher: None, + path: tmp.path().to_string_lossy().to_string(), + name: "test-repo".to_string(), + }, + ); let state = AppState { - repo: Mutex::new(Some(backend)), - watcher: Mutex::new(None), + tabs: Mutex::new(tabs), + active_tab: Mutex::new(Some("test".to_string())), + auto_fetch_handle: Mutex::new(None), }; let app = build_test_app(state); let webview = build_test_webview(&app); @@ -619,8 +815,7 @@ fn test_get_commit_log() { // When: get_commit_log is called let request = make_request( "get_commit_log", - serde_json::json!({ - "filter": { + serde_json::json!({ "tabId": "test", "filter": { "author": null, "since": null, "until": null, @@ -649,15 +844,26 @@ fn test_is_merging_false() { // Given: a repository in normal state let tmp = tempfile::tempdir().unwrap(); let backend = init_repo_with_commit(tmp.path()); + let mut tabs = HashMap::new(); + tabs.insert( + "test".to_string(), + RepoContext { + backend: backend, + watcher: None, + path: tmp.path().to_string_lossy().to_string(), + name: "test-repo".to_string(), + }, + ); let state = AppState { - repo: Mutex::new(Some(backend)), - watcher: Mutex::new(None), + tabs: Mutex::new(tabs), + active_tab: Mutex::new(Some("test".to_string())), + auto_fetch_handle: Mutex::new(None), }; let app = build_test_app(state); let webview = build_test_webview(&app); // When: is_merging is called - let request = make_request("is_merging", serde_json::json!({})); + let request = make_request("is_merging", serde_json::json!({ "tabId": "test" })); let body = tauri::test::get_ipc_response(&webview, request).expect("is_merging should succeed"); // Then: false @@ -672,15 +878,26 @@ fn test_is_rebasing_false() { // Given: a repository in normal state let tmp = tempfile::tempdir().unwrap(); let backend = init_repo_with_commit(tmp.path()); + let mut tabs = HashMap::new(); + tabs.insert( + "test".to_string(), + RepoContext { + backend: backend, + watcher: None, + path: tmp.path().to_string_lossy().to_string(), + name: "test-repo".to_string(), + }, + ); let state = AppState { - repo: Mutex::new(Some(backend)), - watcher: Mutex::new(None), + tabs: Mutex::new(tabs), + active_tab: Mutex::new(Some("test".to_string())), + auto_fetch_handle: Mutex::new(None), }; let app = build_test_app(state); let webview = build_test_webview(&app); // When: is_rebasing is called - let request = make_request("is_rebasing", serde_json::json!({})); + let request = make_request("is_rebasing", serde_json::json!({ "tabId": "test" })); let body = tauri::test::get_ipc_response(&webview, request).expect("is_rebasing should succeed"); diff --git a/src/App.tsx b/src/App.tsx index 79b480f..b2913c6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { TagsModal } from "./components/organisms/TagsModal"; import { ToastContainer } from "./components/organisms/ToastContainer"; import { AppShell } from "./components/templates/AppShell"; import { useFileWatcher } from "./hooks/useFileWatcher"; +import { useSystemNotification } from "./hooks/useSystemNotification"; import { useTheme } from "./hooks/useTheme"; import { BlamePage } from "./pages/blame"; import { BranchesPage } from "./pages/branches"; @@ -29,6 +30,7 @@ import { WorktreesPage } from "./pages/worktrees"; import type { PullOption } from "./services/git"; import { useConfigStore } from "./stores/configStore"; import { useGitStore } from "./stores/gitStore"; +import { useTabStore } from "./stores/tabStore"; import { useUIStore } from "./stores/uiStore"; export function App() { @@ -54,13 +56,26 @@ export function App() { const activeModal = useUIStore((s) => s.activeModal); const openModal = useUIStore((s) => s.openModal); const closeModal = useUIStore((s) => s.closeModal); + const activeTabId = useTabStore((s) => s.activeTabId); + const fetchTabs = useTabStore((s) => s.fetchTabs); + const fetchActiveTab = useTabStore((s) => s.fetchActiveTab); useTheme(); + useSystemNotification(); + useEffect(() => { + fetchTabs(); + fetchActiveTab(); + }, [fetchTabs, fetchActiveTab]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: activeTabId triggers re-fetch on tab switch useEffect(() => { loadConfig().catch((e: unknown) => { addToast(String(e), "error"); }); + fetchStatus().catch((e: unknown) => { + addToast(String(e), "error"); + }); fetchBranch().catch((e: unknown) => { addToast(String(e), "error"); }); @@ -83,7 +98,9 @@ export function App() { addToast(String(e), "error"); }); }, [ + activeTabId, loadConfig, + fetchStatus, fetchBranch, fetchRemotes, fetchStashes, diff --git a/src/components/organisms/SearchModal.tsx b/src/components/organisms/SearchModal.tsx index ab2d38b..3a75c33 100644 --- a/src/components/organisms/SearchModal.tsx +++ b/src/components/organisms/SearchModal.tsx @@ -9,6 +9,7 @@ import { searchCommits, searchFilenames, } from "../../services/search"; +import { getActiveTabId } from "../../stores/tabStore"; import { Modal } from "./Modal"; type SearchTab = "code" | "commits" | "filenames"; @@ -78,19 +79,19 @@ export function SearchModal({ onClose }: SearchModalProps) { const searchPromise = (() => { switch (tab) { case "code": - return searchCode(q, regex).then((results) => { + return searchCode(getActiveTabId(), q, regex).then((results) => { if (requestIdRef.current === thisRequestId) { setCodeResults(results); } }); case "commits": - return searchCommits(q, diff).then((results) => { + return searchCommits(getActiveTabId(), q, diff).then((results) => { if (requestIdRef.current === thisRequestId) { setCommitResults(results); } }); case "filenames": - return searchFilenames(q).then((results) => { + return searchFilenames(getActiveTabId(), q).then((results) => { if (requestIdRef.current === thisRequestId) { setFilenameResults(results); } diff --git a/src/components/organisms/SettingsGitConfigTab.tsx b/src/components/organisms/SettingsGitConfigTab.tsx index b4c7059..084a90a 100644 --- a/src/components/organisms/SettingsGitConfigTab.tsx +++ b/src/components/organisms/SettingsGitConfigTab.tsx @@ -12,6 +12,7 @@ import { setGitconfigValue, unsetGitconfigValue, } from "../../services/gitconfig"; +import { getActiveTabId } from "../../stores/tabStore"; import { useUIStore } from "../../stores/uiStore"; interface ConfigSection { @@ -155,8 +156,8 @@ export function SettingsGitConfigTab() { async (s: GitConfigScope) => { try { const [allEntries, path] = await Promise.all([ - getGitconfigEntries(s), - getGitconfigPath(s), + getGitconfigEntries(getActiveTabId(), s), + getGitconfigPath(getActiveTabId(), s), ]); const map = new Map(); const aliasList: GitConfigEntry[] = []; @@ -191,9 +192,9 @@ export function SettingsGitConfigTab() { async (key: string, value: string) => { try { if (value === "") { - await unsetGitconfigValue(scope, key); + await unsetGitconfigValue(getActiveTabId(), scope, key); } else { - await setGitconfigValue(scope, key, value); + await setGitconfigValue(getActiveTabId(), scope, key, value); } setEntries((prev) => { const next = new Map(prev); diff --git a/src/components/organisms/Titlebar.tsx b/src/components/organisms/Titlebar.tsx index f558bc7..7f6a317 100644 --- a/src/components/organisms/Titlebar.tsx +++ b/src/components/organisms/Titlebar.tsx @@ -1,4 +1,6 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; +import { type DragEvent, useCallback, useState } from "react"; +import { useTabStore } from "../../stores/tabStore"; import { useUIStore } from "../../stores/uiStore"; const iconColor = "rgba(77,18,10,0.85)"; @@ -58,9 +60,93 @@ function MaximizeIcon() { ); } +function TabCloseIcon() { + return ( + + ); +} + +function PlusIcon() { + return ( + + ); +} + export function Titlebar() { const appWindow = getCurrentWindow(); const openModal = useUIStore((s) => s.openModal); + const setActivePage = useUIStore((s) => s.setActivePage); + const tabs = useTabStore((s) => s.tabs); + const activeTabId = useTabStore((s) => s.activeTabId); + const setActiveTab = useTabStore((s) => s.setActiveTab); + const closeTab = useTabStore((s) => s.closeTab); + const reorderTabs = useTabStore((s) => s.reorderTabs); + + const [dragIndex, setDragIndex] = useState(null); + + const handleDragStart = useCallback( + (e: DragEvent, index: number) => { + setDragIndex(index); + e.dataTransfer.effectAllowed = "move"; + }, + [], + ); + + const handleDragOver = useCallback((e: DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }, []); + + const handleDrop = useCallback( + (e: DragEvent, toIndex: number) => { + e.preventDefault(); + if (dragIndex !== null && dragIndex !== toIndex) { + reorderTabs(dragIndex, toIndex); + } + setDragIndex(null); + }, + [dragIndex, reorderTabs], + ); + + const handleDragEnd = useCallback(() => { + setDragIndex(null); + }, []); + + const handleAddTab = useCallback(() => { + setActivePage("open-repository"); + }, [setActivePage]); + + const handleTabClose = useCallback( + (e: React.MouseEvent, tabId: string) => { + e.stopPropagation(); + closeTab(tabId); + }, + [closeTab], + ); return (
@@ -90,9 +176,47 @@ export function Titlebar() {
- - Rocket - + {tabs.length > 0 ? ( +
+ {tabs.map((tab, index) => ( + + )} + + ))} + +
+ ) : ( + + Rocket + + )}