diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index edbf398..dabfad4 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -382,13 +382,13 @@ fn check_search_cancelled(cancellation: Option<&SearchCancellation>) -> Result<( Ok(()) } -#[tauri::command] +#[tauri::command(async)] pub fn open_repository(path: String) -> Result { let repo = resolve_repo(&path)?; status_for_repo(&repo) } -#[tauri::command] +#[tauri::command(async)] pub fn init_repository(path: String, initial_branch: Option) -> Result { let path = PathBuf::from(path.trim()); if path.as_os_str().is_empty() { @@ -416,13 +416,13 @@ pub fn init_repository(path: String, initial_branch: Option) -> Result Result<(), String> { let repo = resolve_repo(&path)?; open_path_in_file_manager(&repo) } -#[tauri::command] +#[tauri::command(async)] pub fn open_repository_file(path: String, file: String) -> Result<(), String> { let repo = resolve_repo(&path)?; validate_files(std::slice::from_ref(&file))?; @@ -448,6 +448,56 @@ pub struct RepositoryBundle { pub files: Vec, } +async fn run_git_task(context: &'static str, task: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + tauri::async_runtime::spawn_blocking(task) + .await + .map_err(|error| format!("{context}: {error}"))? +} + +fn join_git_worker(name: &str, result: thread::Result>) -> Result { + result.map_err(|_| format!("The {name} Git worker stopped unexpectedly."))? +} + +fn repository_bundle_for_repo( + repo: &Path, + commit_limit: Option, +) -> Result { + // Status is needed by the file tree. Once it is available, all remaining + // reads are independent and can run concurrently. Each worker only starts + // read-only Git processes, so this is safe while cutting the former + // branches -> tags -> stashes -> commits -> files waterfall down to the + // duration of its slowest member. + let status = status_for_repo(repo)?; + let (branches, tags, stashes, commits, files) = thread::scope(|scope| -> Result<_, String> { + let branches = scope.spawn(|| branches_for_repo(repo)); + let tags = scope.spawn(|| tags_for_repo(repo)); + let stashes = scope.spawn(|| stashes_for_repo(repo)); + let commits = scope.spawn(|| commits_for_repo(repo, commit_limit)); + let files = scope.spawn(|| repository_files_with_status(repo, &status)); + + Ok(( + join_git_worker("branch", branches.join())?, + join_git_worker("tag", tags.join())?, + join_git_worker("stash", stashes.join())?, + join_git_worker("history", commits.join())?, + join_git_worker("file tree", files.join())?, + )) + })?; + + Ok(RepositoryBundle { + status, + branches, + tags, + stashes, + commits, + files, + }) +} + #[tauri::command] pub async fn clone_repository( remote_url: String, @@ -481,40 +531,32 @@ pub async fn open_repository_bundle( path: String, commit_limit: Option, ) -> Result { - tauri::async_runtime::spawn_blocking(move || -> Result { + run_git_task("Could not load repository", move || { let repo = resolve_repo(&path)?; - let status = status_for_repo(&repo)?; - let branches = branches_for_repo(&repo)?; - let tags = tags_for_repo(&repo)?; - let stashes = stashes_for_repo(&repo)?; - let commits = commits_for_repo(&repo, commit_limit)?; - let files = repository_files_with_status(&repo, &status)?; - Ok(RepositoryBundle { - status, - branches, - tags, - stashes, - commits, - files, - }) + repository_bundle_for_repo(&repo, commit_limit) }) .await - .map_err(|err| format!("Could not load repository: {err}"))? } #[tauri::command] -pub fn get_status(path: String) -> Result { - let repo = resolve_repo(&path)?; - status_for_repo(&repo) +pub async fn get_status(path: String) -> Result { + run_git_task("Could not refresh repository status", move || { + let repo = resolve_repo(&path)?; + status_for_repo(&repo) + }) + .await } #[tauri::command] -pub fn list_branches(path: String) -> Result, String> { - let repo = resolve_repo(&path)?; - branches_for_repo(&repo) +pub async fn list_branches(path: String) -> Result, String> { + run_git_task("Could not load branches", move || { + let repo = resolve_repo(&path)?; + branches_for_repo(&repo) + }) + .await } -#[tauri::command] +#[tauri::command(async)] pub fn list_remotes(path: String) -> Result, String> { log::info!(target: "gitty::remote", "list_remotes invoked: path={path:?}"); let repo = resolve_repo(&path)?; @@ -535,7 +577,7 @@ pub fn list_remotes(path: String) -> Result, String> { .collect()) } -#[tauri::command] +#[tauri::command(async)] pub fn add_remote(path: String, name: String, url: String) -> Result, String> { let repo = resolve_repo(&path)?; let name = validate_remote_name(&repo, &name, false)?; @@ -544,7 +586,7 @@ pub fn add_remote(path: String, name: String, url: String) -> Result Result, String> { let repo = resolve_repo(&path)?; let name = validate_remote_name(&repo, &name, true)?; @@ -553,7 +595,7 @@ pub fn update_remote(path: String, name: String, url: String) -> Result Result, String> { log::info!(target: "gitty::remote", "remove_remote invoked: path={path:?}, name={name:?}"); let result = (|| { @@ -574,7 +616,7 @@ pub fn remove_remote(path: String, name: String) -> Result, Strin result } -#[tauri::command] +#[tauri::command(async)] pub fn set_branch_upstream( path: String, branch: String, @@ -607,7 +649,7 @@ pub fn set_branch_upstream( status_for_repo(&repo) } -#[tauri::command] +#[tauri::command(async)] pub fn delete_remote_branch( path: String, remote: String, @@ -634,15 +676,21 @@ pub fn delete_remote_branch( } #[tauri::command] -pub fn list_stashes(path: String) -> Result, String> { - let repo = resolve_repo(&path)?; - stashes_for_repo(&repo) +pub async fn list_stashes(path: String) -> Result, String> { + run_git_task("Could not load stashes", move || { + let repo = resolve_repo(&path)?; + stashes_for_repo(&repo) + }) + .await } #[tauri::command] -pub fn list_tags(path: String) -> Result, String> { - let repo = resolve_repo(&path)?; - tags_for_repo(&repo) +pub async fn list_tags(path: String) -> Result, String> { + run_git_task("Could not load tags", move || { + let repo = resolve_repo(&path)?; + tags_for_repo(&repo) + }) + .await } fn tags_for_repo(repo: &Path) -> Result, String> { @@ -790,39 +838,51 @@ fn parse_stash_subject(subject: &str) -> (Option, String) { } #[tauri::command] -pub fn stash_push( +pub async fn stash_push( path: String, message: Option, include_untracked: bool, ) -> Result { - let repo = resolve_repo(&path)?; - let trimmed_message = message.unwrap_or_default().trim().to_string(); - let mut args: Vec = vec![OsString::from("stash"), OsString::from("push")]; - if include_untracked { - args.push(OsString::from("--include-untracked")); - } - if !trimmed_message.is_empty() { - args.push(OsString::from("-m")); - args.push(OsString::from(trimmed_message)); - } + run_git_task("Could not stash changes", move || { + let repo = resolve_repo(&path)?; + let trimmed_message = message.unwrap_or_default().trim().to_string(); + let mut args: Vec = vec![OsString::from("stash"), OsString::from("push")]; + if include_untracked { + args.push(OsString::from("--include-untracked")); + } + if !trimmed_message.is_empty() { + args.push(OsString::from("-m")); + args.push(OsString::from(trimmed_message)); + } - run_git(&repo, args)?; - status_for_repo(&repo) + run_git(&repo, args)?; + status_for_repo(&repo) + }) + .await } #[tauri::command] -pub fn stash_apply(path: String, selector: String) -> Result { - run_stash_update(path, "apply", selector) +pub async fn stash_apply(path: String, selector: String) -> Result { + run_git_task("Could not apply stash", move || { + run_stash_update(path, "apply", selector) + }) + .await } #[tauri::command] -pub fn stash_pop(path: String, selector: String) -> Result { - run_stash_update(path, "pop", selector) +pub async fn stash_pop(path: String, selector: String) -> Result { + run_git_task("Could not pop stash", move || { + run_stash_update(path, "pop", selector) + }) + .await } #[tauri::command] -pub fn stash_drop(path: String, selector: String) -> Result { - run_stash_update(path, "drop", selector) +pub async fn stash_drop(path: String, selector: String) -> Result { + run_git_task("Could not drop stash", move || { + run_stash_update(path, "drop", selector) + }) + .await } fn run_stash_update(path: String, action: &str, selector: String) -> Result { @@ -859,7 +919,14 @@ fn validate_stash_selector(selector: &str) -> Result { } #[tauri::command] -pub fn checkout_branch(path: String, branch: String) -> Result { +pub async fn checkout_branch(path: String, branch: String) -> Result { + run_git_task("Could not check out branch", move || { + checkout_branch_core(path, branch) + }) + .await +} + +fn checkout_branch_core(path: String, branch: String) -> Result { let repo = resolve_repo(&path)?; let branch = branch.trim().to_string(); if branch.is_empty() { @@ -885,7 +952,18 @@ pub fn checkout_branch(path: String, branch: String) -> Result, +) -> Result { + run_git_task("Could not create branch", move || { + create_branch_core(path, branch, start_point) + }) + .await +} + +fn create_branch_core( path: String, branch: String, start_point: Option, @@ -906,7 +984,18 @@ pub fn create_branch( } #[tauri::command] -pub fn rename_branch( +pub async fn rename_branch( + path: String, + old_branch: String, + new_branch: String, +) -> Result { + run_git_task("Could not rename branch", move || { + rename_branch_core(path, old_branch, new_branch) + }) + .await +} + +fn rename_branch_core( path: String, old_branch: String, new_branch: String, @@ -929,7 +1018,18 @@ pub fn rename_branch( } #[tauri::command] -pub fn delete_branch( +pub async fn delete_branch( + path: String, + branch: String, + force: Option, +) -> Result { + run_git_task("Could not delete branch", move || { + delete_branch_core(path, branch, force) + }) + .await +} + +fn delete_branch_core( path: String, branch: String, force: Option, @@ -1070,13 +1170,43 @@ fn worktrees_for_repo(repo: &Path) -> Result, String> { } #[tauri::command] -pub fn list_worktrees(path: String) -> Result, String> { +pub async fn list_worktrees(path: String) -> Result, String> { + run_git_task("Could not load worktrees", move || { + list_worktrees_core(path) + }) + .await +} + +fn list_worktrees_core(path: String) -> Result, String> { let repo = resolve_repo(&path)?; worktrees_for_repo(&repo) } #[tauri::command] -pub fn add_worktree( +pub async fn add_worktree( + path: String, + worktree_path: String, + branch: Option, + new_branch: Option, + start_point: Option, + detached: Option, + lock: Option, +) -> Result, String> { + run_git_task("Could not add worktree", move || { + add_worktree_core( + path, + worktree_path, + branch, + new_branch, + start_point, + detached, + lock, + ) + }) + .await +} + +fn add_worktree_core( path: String, worktree_path: String, branch: Option, @@ -1136,7 +1266,18 @@ pub fn add_worktree( } #[tauri::command] -pub fn remove_worktree( +pub async fn remove_worktree( + path: String, + worktree_path: String, + force: Option, +) -> Result, String> { + run_git_task("Could not remove worktree", move || { + remove_worktree_core(path, worktree_path, force) + }) + .await +} + +fn remove_worktree_core( path: String, worktree_path: String, force: Option, @@ -1168,7 +1309,18 @@ pub fn remove_worktree( } #[tauri::command] -pub fn move_worktree( +pub async fn move_worktree( + path: String, + worktree_path: String, + destination: String, +) -> Result, String> { + run_git_task("Could not move worktree", move || { + move_worktree_core(path, worktree_path, destination) + }) + .await +} + +fn move_worktree_core( path: String, worktree_path: String, destination: String, @@ -1191,7 +1343,18 @@ pub fn move_worktree( } #[tauri::command] -pub fn lock_worktree( +pub async fn lock_worktree( + path: String, + worktree_path: String, + reason: Option, +) -> Result, String> { + run_git_task("Could not lock worktree", move || { + lock_worktree_core(path, worktree_path, reason) + }) + .await +} + +fn lock_worktree_core( path: String, worktree_path: String, reason: Option, @@ -1211,28 +1374,59 @@ pub fn lock_worktree( } #[tauri::command] -pub fn unlock_worktree(path: String, worktree_path: String) -> Result, String> { +pub async fn unlock_worktree( + path: String, + worktree_path: String, +) -> Result, String> { + run_git_task("Could not unlock worktree", move || { + unlock_worktree_core(path, worktree_path) + }) + .await +} + +fn unlock_worktree_core(path: String, worktree_path: String) -> Result, String> { let repo = resolve_repo(&path)?; run_git(&repo, ["worktree", "unlock", "--", worktree_path.as_str()])?; worktrees_for_repo(&repo) } #[tauri::command] -pub fn prune_worktrees(path: String) -> Result, String> { - let repo = resolve_repo(&path)?; - run_git(&repo, ["worktree", "prune"])?; - worktrees_for_repo(&repo) +pub async fn prune_worktrees(path: String) -> Result, String> { + run_git_task("Could not prune worktrees", move || { + let repo = resolve_repo(&path)?; + run_git(&repo, ["worktree", "prune"])?; + worktrees_for_repo(&repo) + }) + .await } #[tauri::command] -pub fn repair_worktree(path: String, worktree_path: String) -> Result, String> { - let repo = resolve_repo(&path)?; - run_git(&repo, ["worktree", "repair", "--", worktree_path.as_str()])?; - worktrees_for_repo(&repo) +pub async fn repair_worktree( + path: String, + worktree_path: String, +) -> Result, String> { + run_git_task("Could not repair worktree", move || { + let repo = resolve_repo(&path)?; + run_git(&repo, ["worktree", "repair", "--", worktree_path.as_str()])?; + worktrees_for_repo(&repo) + }) + .await } #[tauri::command] -pub fn create_tag( +pub async fn create_tag( + path: String, + name: String, + target: Option, + message: Option, +) -> Result, String> { + run_git_task("Could not create tag", move || { + create_tag_core(path, name, target, message) + }) + .await +} + +fn create_tag_core( path: String, name: String, target: Option, @@ -1271,36 +1465,42 @@ pub fn create_tag( } #[tauri::command] -pub fn delete_tag(path: String, name: String) -> Result, String> { - let repo = resolve_repo(&path)?; - let name = validate_existing_tag_name(&repo, &name)?; - run_git(&repo, ["tag", "-d", name.as_str()])?; - tags_for_repo(&repo) +pub async fn delete_tag(path: String, name: String) -> Result, String> { + run_git_task("Could not delete tag", move || { + let repo = resolve_repo(&path)?; + let name = validate_existing_tag_name(&repo, &name)?; + run_git(&repo, ["tag", "-d", name.as_str()])?; + tags_for_repo(&repo) + }) + .await } #[tauri::command] -pub fn push_tag( +pub async fn push_tag( path: String, name: String, username: Option, password: Option, ) -> Result<(), String> { - let repo = resolve_repo(&path)?; - let name = validate_existing_tag_name(&repo, &name)?; - let remote = initial_push_remote_name(&repo)?; - let tag_ref = format!("refs/tags/{name}"); - let push_args = ["push", remote.as_str(), tag_ref.as_str()]; + run_git_task("Could not push tag", move || { + let repo = resolve_repo(&path)?; + let name = validate_existing_tag_name(&repo, &name)?; + let remote = initial_push_remote_name(&repo)?; + let tag_ref = format!("refs/tags/{name}"); + let push_args = ["push", remote.as_str(), tag_ref.as_str()]; - match (username.as_deref(), password.as_deref()) { - (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { - run_git_authenticated(&repo, push_args, u, p)?; + match (username.as_deref(), password.as_deref()) { + (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { + run_git_authenticated(&repo, push_args, u, p)?; + } + _ => { + run_git(&repo, push_args)?; + } } - _ => { - run_git(&repo, push_args)?; - } - } - Ok(()) + Ok(()) + }) + .await } fn validate_new_tag_name(repo: &Path, name: &str) -> Result { @@ -1435,7 +1635,7 @@ pub async fn restore_files( .map_err(|err| format!("Could not restore files: {err}"))? } -#[tauri::command] +#[tauri::command(async)] pub fn get_file_patch(path: String, file: String, staged: bool) -> Result { let repo = resolve_repo(&path)?; validate_files(std::slice::from_ref(&file))?; @@ -1866,7 +2066,7 @@ pub async fn commit_ai_review( parse_ai_review(&raw) } -#[tauri::command] +#[tauri::command(async)] pub fn apply_file_patch( path: String, file: String, @@ -1951,7 +2151,7 @@ pub async fn amend_commit(path: String, message: Option) -> Result Result, String> { let repo = resolve_repo(&path)?; if verify_commit(&repo, "HEAD").is_err() { @@ -1967,7 +2167,7 @@ pub fn last_commit_message(path: String) -> Result, String> { }) } -#[tauri::command] +#[tauri::command(async)] pub fn undo_last_commit(path: String) -> Result { let repo = resolve_repo(&path)?; if verify_commit(&repo, "HEAD").is_err() { @@ -2146,7 +2346,7 @@ fn cred_entry(key: &str) -> Result { /// Returns the remote URL used for auth key derivation (upstream remote of the /// current branch, falling back to `origin`, then the first configured remote). -#[tauri::command] +#[tauri::command(async)] pub fn get_remote_url(path: String) -> Result, String> { let repo = resolve_repo(&path)?; let remote = upstream_remote_name(&repo).unwrap_or_else(|| "origin".to_string()); @@ -2301,7 +2501,7 @@ fn push_args_for_repo_to( ]) } -#[tauri::command] +#[tauri::command(async)] pub fn cred_load(key: String) -> Result, String> { let entry = cred_entry(&key)?; match entry.get_password() { @@ -2315,7 +2515,7 @@ pub fn cred_load(key: String) -> Result, String> { } } -#[tauri::command] +#[tauri::command(async)] pub fn cred_save( key: String, username: String, @@ -2336,7 +2536,7 @@ pub fn cred_save( .map_err(|err| format!("Saving to keychain failed: {err}")) } -#[tauri::command] +#[tauri::command(async)] pub fn cred_delete(key: String) -> Result<(), String> { let entry = cred_entry(&key)?; match entry.delete_credential() { @@ -2402,7 +2602,7 @@ pub async fn merge_branch( .map_err(|err| format!("Could not merge: {err}"))? } -#[tauri::command] +#[tauri::command(async)] pub fn merge_continue(path: String) -> Result { let repo = resolve_repo(&path)?; if !merge_in_progress(&repo) { @@ -2415,7 +2615,7 @@ pub fn merge_continue(path: String) -> Result { status_for_repo(&repo) } -#[tauri::command] +#[tauri::command(async)] pub fn merge_abort(path: String) -> Result { let repo = resolve_repo(&path)?; if !merge_in_progress(&repo) { @@ -2425,7 +2625,7 @@ pub fn merge_abort(path: String) -> Result { status_for_repo(&repo) } -#[tauri::command] +#[tauri::command(async)] pub fn revert_commit(path: String, commit: String) -> Result { let repo = resolve_repo(&path)?; let commit = verify_commit(&repo, &commit)?; @@ -2470,7 +2670,7 @@ pub async fn rebase_branch(path: String, branch: String) -> Result Result { let repo = resolve_repo(&path)?; if !rebase_in_progress(&repo) { @@ -2580,7 +2780,7 @@ pub fn rebase_continue(path: String) -> Result { result } -#[tauri::command] +#[tauri::command(async)] pub fn rebase_abort(path: String) -> Result { let repo = resolve_repo(&path)?; if !rebase_in_progress(&repo) { @@ -2592,7 +2792,7 @@ pub fn rebase_abort(path: String) -> Result { status_for_repo(&repo) } -#[tauri::command] +#[tauri::command(async)] pub fn list_reflog(path: String, limit: Option) -> Result, String> { let repo = resolve_repo(&path)?; let limit = limit.unwrap_or(250).clamp(1, 1_000).to_string(); @@ -2610,7 +2810,7 @@ pub fn list_reflog(path: String, limit: Option) -> Result, parse_reflog(&output) } -#[tauri::command] +#[tauri::command(async)] pub fn restore_reflog_entry( path: String, commit: String, @@ -2870,7 +3070,7 @@ pub async fn cherry_pick_commit(path: String, commit: String) -> Result Result { let repo = resolve_repo(&path)?; if !cherry_pick_in_progress(&repo) { @@ -2888,7 +3088,7 @@ pub fn cherry_pick_continue(path: String) -> Result { cherry_pick_status_or_error(&repo, output, "Cherry-pick continue failed") } -#[tauri::command] +#[tauri::command(async)] pub fn cherry_pick_abort(path: String) -> Result { let repo = resolve_repo(&path)?; if !cherry_pick_in_progress(&repo) { @@ -2917,13 +3117,16 @@ fn cherry_pick_status_or_error( } #[tauri::command] -pub fn list_commits( +pub async fn list_commits( path: String, limit: Option, skip: Option, ) -> Result, String> { - let repo = resolve_repo(&path)?; - commit_page_for_repo(&repo, limit, skip) + run_git_task("Could not load commit history", move || { + let repo = resolve_repo(&path)?; + commit_page_for_repo(&repo, limit, skip) + }) + .await } fn commits_for_repo(repo: &Path, limit: Option) -> Result, String> { @@ -2968,9 +3171,12 @@ fn commit_page_for_repo( } #[tauri::command] -pub fn list_repository_files(path: String) -> Result, String> { - let repo = resolve_repo(&path)?; - repository_files(&repo) +pub async fn list_repository_files(path: String) -> Result, String> { + run_git_task("Could not load repository files", move || { + let repo = resolve_repo(&path)?; + repository_files(&repo) + }) + .await } #[tauri::command] @@ -3020,7 +3226,7 @@ pub fn cancel_file_history( const UNCOMMITTED_BLAME_HASH: &str = "0000000000000000000000000000000000000000"; -#[tauri::command] +#[tauri::command(async)] pub fn get_file_blame(path: String, file: String) -> Result { let repo = resolve_repo(&path)?; validate_files(std::slice::from_ref(&file))?; @@ -3318,7 +3524,7 @@ fn search_code_introductions_core( Ok(hits) } -#[tauri::command] +#[tauri::command(async)] pub fn restore_to_commit(path: String, commit: String) -> Result { let repo = resolve_repo(&path)?; let commit_hash = verify_commit(&repo, &commit)?; @@ -3341,7 +3547,7 @@ pub fn restore_to_commit(path: String, commit: String) -> Result Result { let repo = resolve_repo(&path)?; validate_files(std::slice::from_ref(&file))?; @@ -3635,7 +3841,7 @@ pub fn read_conflict(path: String, file: String) -> Result }) } -#[tauri::command] +#[tauri::command(async)] pub fn resolve_conflict_side( path: String, file: String, @@ -3677,7 +3883,7 @@ fn index_stage_size(repo: &Path, stage: u8, file: &str) -> Option { } } -#[tauri::command] +#[tauri::command(async)] pub fn resolve_conflict(path: String, file: String, content: String) -> Result { let repo = resolve_repo(&path)?; validate_files(std::slice::from_ref(&file))?; @@ -4073,21 +4279,7 @@ fn clone_repository_core( run_git_clone(remote_url.trim(), &target, username, password)?; let repo = resolve_repo(&target.to_string_lossy())?; - let status = status_for_repo(&repo)?; - let branches = branches_for_repo(&repo)?; - let tags = tags_for_repo(&repo)?; - let stashes = stashes_for_repo(&repo)?; - let commits = commits_for_repo(&repo, commit_limit)?; - let files = repository_files_with_status(&repo, &status)?; - - Ok(RepositoryBundle { - status, - branches, - tags, - stashes, - commits, - files, - }) + repository_bundle_for_repo(&repo, commit_limit) } fn clone_target_path( @@ -6747,7 +6939,7 @@ mod tests { let repo = init_temp_repo("create_branch"); commit_initial_file(&repo.path); - let status = create_branch( + let status = create_branch_core( repo.path.to_string_lossy().to_string(), "feature/new-panel".to_string(), None, @@ -6760,7 +6952,7 @@ mod tests { "new branch should exist" ); - let err = create_branch( + let err = create_branch_core( repo.path.to_string_lossy().to_string(), "feature/new-panel".to_string(), None, @@ -6776,7 +6968,7 @@ mod tests { let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); run_git_test(&repo.path, ["branch", "feature/old-panel"]); - let status = rename_branch( + let status = rename_branch_core( repo.path.to_string_lossy().to_string(), "feature/old-panel".to_string(), "feature/new-panel".to_string(), @@ -6801,7 +6993,7 @@ mod tests { let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); run_git_test(&repo.path, ["branch", "stale"]); - let status = delete_branch( + let status = delete_branch_core( repo.path.to_string_lossy().to_string(), "stale".to_string(), None, @@ -6815,7 +7007,7 @@ mod tests { ); let err = - delete_branch(repo.path.to_string_lossy().to_string(), current, None).unwrap_err(); + delete_branch_core(repo.path.to_string_lossy().to_string(), current, None).unwrap_err(); assert!(err.contains("current branch")); } @@ -6831,7 +7023,7 @@ mod tests { run_git_test(&repo.path, ["commit", "-q", "-m", "feature"]); run_git_test(&repo.path, ["checkout", "-q", current.as_str()]); - let err = delete_branch( + let err = delete_branch_core( repo.path.to_string_lossy().to_string(), "feature/unmerged".to_string(), Some(false), @@ -6839,7 +7031,7 @@ mod tests { .unwrap_err(); assert!(err.contains("not fully merged")); - delete_branch( + delete_branch_core( repo.path.to_string_lossy().to_string(), "feature/unmerged".to_string(), Some(true), @@ -7216,7 +7408,7 @@ mod tests { commit_initial_file(&repo.path); run_git_test(&repo.path, ["branch", "feature"]); - let rows = add_worktree( + let rows = add_worktree_core( repo.path.to_string_lossy().to_string(), destination.path.to_string_lossy().to_string(), Some("feature".to_string()), @@ -7234,7 +7426,7 @@ mod tests { assert!(!linked.is_main); assert!(linked.clean); - let rows = lock_worktree( + let rows = lock_worktree_core( repo.path.to_string_lossy().to_string(), destination.path.to_string_lossy().to_string(), Some("test lock".to_string()), @@ -7247,13 +7439,13 @@ mod tests { assert!(linked.locked); assert_eq!(linked.lock_reason.as_deref(), Some("test lock")); - unlock_worktree( + unlock_worktree_core( repo.path.to_string_lossy().to_string(), destination.path.to_string_lossy().to_string(), ) .expect("worktree should unlock"); - let rows = remove_worktree( + let rows = remove_worktree_core( repo.path.to_string_lossy().to_string(), destination.path.to_string_lossy().to_string(), Some(false), diff --git a/src/App.svelte b/src/App.svelte index f91302b..e8f4dc5 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -153,6 +153,7 @@ RebaseCommit, RebasePlanItem, ReflogEntry, + RepositoryBundle, StoredCredential, } from "./lib/types"; @@ -204,6 +205,15 @@ clearIfCurrent: (message: string) => void; } + interface RepositoryRefreshOptions { + branches?: boolean; + tags?: boolean; + stashes?: boolean; + commits?: boolean; + files?: boolean; + fileHistory?: boolean; + } + const OPEN_REPOS_KEY = "gitlite.openRepos.v1"; const RECENT_REPOS_KEY = "gitlite.recentRepos.v1"; const FAVORITE_REPOS_KEY = "gitlite.favoriteRepos.v1"; @@ -372,7 +382,6 @@ const BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS = 30_000; const BACKGROUND_FETCH_OTHER_BATCH_SIZE = 2; const STARTUP_SPLASH_MIN_VISIBLE_MS = 850; - const STARTUP_FETCH_MAX_WAIT_MS = 20_000; let backgroundFetchTimer: ReturnType | undefined; let backgroundRepoStatusTimer: ReturnType | undefined; let backgroundFetchInFlight = false; @@ -482,12 +491,16 @@ void getVersion().then((version) => { appVersion = version; }).catch(() => { appVersion = ""; }); window.addEventListener("beforeunload", handleAppShutdown); window.addEventListener("pagehide", handleAppShutdown); - void getCurrentWindow().onCloseRequested(() => handleAppShutdown()).then((unlisten) => { - if (appShuttingDown) unlisten(); - else unlistenCloseRequested = unlisten; - }).catch(() => { - // Browser preview has no Tauri window; DOM lifecycle events still cover it. - }); + try { + void getCurrentWindow().onCloseRequested(() => handleAppShutdown()).then((unlisten) => { + if (appShuttingDown) unlisten(); + else unlistenCloseRequested = unlisten; + }).catch(() => { + // Browser preview has no Tauri window; DOM lifecycle events still cover it. + }); + } catch { + // getCurrentWindow itself throws synchronously in a plain browser preview. + } }); onDestroy(() => { @@ -568,16 +581,15 @@ try { await waitForStartupPaint(); - await Promise.race([ - fetchOpenRepositoriesDuringStartup(), - wait(STARTUP_FETCH_MAX_WAIT_MS), - ]); } finally { const remainingSplashTime = STARTUP_SPLASH_MIN_VISIBLE_MS - (performance.now() - startupStartedAt); if (remainingSplashTime > 0) await wait(remainingSplashTime); await closeStartupSplashscreen(); startBackgroundTimers(); + // Remote access can take seconds (offline networks, SSH negotiation, + // credential helpers). It must never hold the startup screen hostage. + void fetchOpenRepositoriesDuringStartup(); void backgroundRepoStatusTick(false); } } @@ -765,23 +777,14 @@ // now-active repo's name and data with this one's. if (!sameRepoPath(path, activeRepoPath)) return; if (statusFingerprint(nextStatus) === lastStatusFingerprint) return; - applyStatus(nextStatus); // Something changed — reload branches, commits and files in one bundled call. const bundle = await openRepositoryBundle(path, Math.max(COMMIT_HISTORY_PAGE_SIZE, commits.length) + 1); if (!sameRepoPath(path, activeRepoPath)) return; - const previousHeadHash = lastFileHistoryHeadHash; - await refreshBranchList(path, bundle.branches); - await refreshTags(path, bundle.tags); - await refreshStashes(path, bundle.stashes); - await refreshCommitHistory(path, bundle.commits); - await refreshExplorerFiles(path, bundle.files); + await applyRepositoryBundle(path, bundle); // File history reflects `git log`, which only changes when HEAD actually moves // (new commit, checkout, merge, ...) — skip the reload otherwise so a plain // working-tree/status change (staging, edits) doesn't keep re-fetching and // flickering the currently viewed file's history. - if (lastFileHistoryHeadHash !== previousHeadHash && sameRepoPath(path, activeRepoPath)) { - await refreshFileHistory(path); - } } catch { /* ignore transient errors */ } finally { autoRefreshInFlight = false; } @@ -1114,10 +1117,7 @@ aiCommitSplitOpen = false; aiCommitPlan = null; commitMessage = ""; - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); trackEvent("ai_commit_split_applied", { groups: plan.groups.length, files: allFiles.length }); }); commitAiSplitting = false; @@ -1895,15 +1895,21 @@ // ── Refresh helpers ──────────────────────────────────────────────────────── async function refreshBranchList(path = activeRepoPath, prefetched?: GitBranchInfo[]) { - branches = prefetched ?? (await listBranches(path)); + const nextBranches = prefetched ?? (await listBranches(path)); + if (!sameRepoPath(path, activeRepoPath)) return; + branches = nextBranches; } async function refreshTags(path = activeRepoPath, prefetched?: GitTag[]) { - tags = prefetched ?? (await listTags(path)); + const nextTags = prefetched ?? (await listTags(path)); + if (!sameRepoPath(path, activeRepoPath)) return; + tags = nextTags; } async function refreshStashes(path = activeRepoPath, prefetched?: GitStash[]) { - stashes = prefetched ?? (await listStashes(path)); + const nextStashes = prefetched ?? (await listStashes(path)); + if (!sameRepoPath(path, activeRepoPath)) return; + stashes = nextStashes; } async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) { @@ -1960,7 +1966,9 @@ } async function refreshExplorerFiles(path = activeRepoPath, prefetched?: GitRepositoryFile[]) { - repoFiles = prefetched ?? (await listRepositoryFiles(path)); + const nextFiles = prefetched ?? (await listRepositoryFiles(path)); + if (!sameRepoPath(path, activeRepoPath)) return; + repoFiles = nextFiles; const folderPaths = allExplorerFolderPaths(repoFiles); expandedExplorerPaths = new Set([...expandedExplorerPaths].filter((folder) => folderPaths.has(folder))); if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) { @@ -1970,11 +1978,63 @@ } } + async function refreshRepositoryViews( + path = activeRepoPath, + options: RepositoryRefreshOptions = {}, + ) { + const tasks: Promise[] = []; + if (options.branches ?? true) tasks.push(refreshBranchList(path)); + if (options.tags ?? false) tasks.push(refreshTags(path)); + if (options.stashes ?? false) tasks.push(refreshStashes(path)); + if (options.commits ?? true) tasks.push(refreshCommitHistory(path)); + if (options.files ?? true) tasks.push(refreshExplorerFiles(path)); + + // These reads do not depend on each other. Starting them together removes + // several IPC/Git-process waterfalls after every user operation. + await Promise.all(tasks); + + // Explorer refresh may invalidate the selected path, so file history runs + // after the parallel group rather than racing a disappearing selection. + if (options.fileHistory ?? true) await refreshFileHistory(path); + } + + async function applyRepositoryBundle( + path: string, + bundle: RepositoryBundle, + forceFileHistory = false, + ) { + const previousHeadHash = lastFileHistoryHeadHash; + applyStatus(bundle.status); + const resolvedPath = activeRepoPath || path; + await Promise.all([ + refreshBranchList(resolvedPath, bundle.branches), + refreshTags(resolvedPath, bundle.tags), + refreshStashes(resolvedPath, bundle.stashes), + refreshCommitHistory(resolvedPath, bundle.commits), + refreshExplorerFiles(resolvedPath, bundle.files), + ]); + + if (forceFileHistory || lastFileHistoryHeadHash !== previousHeadHash) { + await refreshFileHistory(resolvedPath); + } + } + + async function refreshRepositorySnapshot(path = activeRepoPath, forceFileHistory = false) { + const bundle = await openRepositoryBundle( + path, + Math.max(COMMIT_HISTORY_PAGE_SIZE, commits.length) + 1, + ); + if (!sameRepoPath(path, activeRepoPath)) return; + await applyRepositoryBundle(path, bundle, forceFileHistory); + } + async function refreshRefsAndCommitGraph(path = activeRepoPath) { const previousHeadHash = lastFileHistoryHeadHash; - await refreshBranchList(path); - await refreshTags(path); - await refreshCommitHistory(path); + await Promise.all([ + refreshBranchList(path), + refreshTags(path), + refreshCommitHistory(path), + ]); if (lastFileHistoryHeadHash !== previousHeadHash && fileHistoryDialogOpen) { await refreshFileHistory(path); } @@ -2050,14 +2110,9 @@ const bundle = await openRepositoryBundle(path, COMMIT_HISTORY_PAGE_SIZE + 1); if (requestId !== repoOpenRequestId) return; resetRepositoryState(false); - applyStatus(bundle.status); + await applyRepositoryBundle(path, bundle); if (globalSearchBusy) void cancelGlobalSearch(); activeView = "repository"; - await refreshBranchList(activeRepoPath, bundle.branches); - await refreshTags(activeRepoPath, bundle.tags); - await refreshStashes(activeRepoPath, bundle.stashes); - await refreshCommitHistory(activeRepoPath, bundle.commits); - await refreshExplorerFiles(activeRepoPath, bundle.files); lastRepoSwitchAt = Date.now(); trackEvent("repository_opened", { changed_files: bundle.status.files.length, @@ -2124,14 +2179,9 @@ COMMIT_HISTORY_PAGE_SIZE + 1, ); resetRepositoryState(false); - applyStatus(bundle.status); + await applyRepositoryBundle(bundle.status.repo_path, bundle); if (globalSearchBusy) void cancelGlobalSearch(); activeView = "repository"; - await refreshBranchList(activeRepoPath, bundle.branches); - await refreshTags(activeRepoPath, bundle.tags); - await refreshStashes(activeRepoPath, bundle.stashes); - await refreshCommitHistory(activeRepoPath, bundle.commits); - await refreshExplorerFiles(activeRepoPath, bundle.files); cloneDialogOpen = false; pendingClone = null; if (credDialogAction === "clone") { @@ -2350,12 +2400,7 @@ async function refreshRepo() { if (!activeRepoPath) { await openRepo(); return; } await runOperation("Refreshing", async () => { - applyStatus(await getStatus(activeRepoPath)); - await refreshBranchList(activeRepoPath); - await refreshStashes(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositorySnapshot(activeRepoPath, true); trackEvent("repository_refreshed", { changed_files: status?.files.length ?? 0, }); @@ -2366,10 +2411,7 @@ if (!activeRepoPath || branch.current) return; await runOperation(`Checking out ${branch.name}`, async () => { applyStatus(await checkoutBranch(activeRepoPath, branch.name)); - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); trackEvent("branch_checked_out", { remote: branch.remote ? 1 : 0, }); @@ -2381,10 +2423,7 @@ if (!activeRepoPath || !name) return; await runOperation(`Creating ${name}`, async () => { applyStatus(await createBranch(activeRepoPath, name)); - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); trackEvent("branch_created"); }); } @@ -2403,10 +2442,7 @@ await runOperation(`Renaming ${branch.name}`, async () => { applyStatus(await renameBranch(activeRepoPath, branch.name, name)); renameBranchTarget = null; - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); trackEvent("branch_renamed"); }); } @@ -2454,10 +2490,7 @@ applyStatus(await deleteBranch(activeRepoPath, branch.name, deleteBranchForce)); deleteBranchTarget = null; deleteBranchForce = false; - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); trackEvent("branch_deleted", { force: forceDelete ? 1 : 0, }); @@ -2633,10 +2666,7 @@ await runOperation(`Creating ${name}`, async () => { applyStatus(await createBranch(activeRepoPath, name, target.hash)); newBranchCommit = null; - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); trackEvent("branch_created_from_commit"); }); } @@ -2648,10 +2678,7 @@ if (!["default", "squash", "ff-only", "no-ff"].includes(strategy)) { errorMessage = "Unknown merge strategy."; return; } await runOperation(`Merging ${branch.name}`, async () => { applyStatus(await mergeBranch(activeRepoPath, branch.name, strategy as import("./lib/types").MergeStrategy)); - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); trackEvent("branch_merged", { remote: branch.remote ? 1 : 0, }); @@ -2662,10 +2689,7 @@ if (!activeRepoPath || branch.current || rebaseInProgress || cherryPickInProgress) return; await runOperation(`Rebasing onto ${branch.name}`, async () => { applyStatus(await rebaseBranch(activeRepoPath, branch.name)); - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); trackEvent("branch_rebased", { remote: branch.remote ? 1 : 0, }); @@ -2676,10 +2700,7 @@ if (!activeRepoPath || !rebaseInProgress || hasConflicts) return; await runOperation("Continuing rebase", async () => { applyStatus(await rebaseContinue(activeRepoPath)); - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); trackEvent("rebase_continued"); }); } @@ -2695,10 +2716,7 @@ resolveDialogOpen = false; conflict = null; conflictTarget = ""; - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); trackEvent("rebase_aborted"); }); } @@ -2742,10 +2760,7 @@ await runOperation("Starting interactive rebase", async () => { applyStatus(await startInteractiveRebase(activeRepoPath, interactiveRebaseBase, plan)); interactiveRebaseOpen = false; - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); trackEvent("interactive_rebase_started", { commits: plan.length }); }); if (interactiveRebaseOpen && errorMessage) interactiveRebaseError = errorMessage; @@ -2787,10 +2802,7 @@ await runOperation("Restoring reflog entry", async () => { applyStatus(await restoreReflogEntry(activeRepoPath, entry.hash, branch.trim())); reflogOpen = false; - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); trackEvent("reflog_recovered"); }); if (reflogOpen && errorMessage) reflogError = errorMessage; @@ -2843,10 +2855,7 @@ if (!activeRepoPath || rebaseInProgress || cherryPickInProgress) return; await runOperation(`Cherry-picking ${commit.short_hash}`, async () => { applyStatus(await cherryPickCommit(activeRepoPath, commit.hash)); - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); trackEvent("commit_cherry_picked"); }); } @@ -2855,10 +2864,7 @@ if (!activeRepoPath || !cherryPickInProgress || hasConflicts) return; await runOperation("Continuing cherry-pick", async () => { applyStatus(await cherryPickContinue(activeRepoPath)); - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); trackEvent("cherry_pick_continued"); }); } @@ -2874,10 +2880,7 @@ resolveDialogOpen = false; conflict = null; conflictTarget = ""; - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); trackEvent("cherry_pick_aborted"); }); } @@ -2953,10 +2956,7 @@ errorMessage = ""; await runOperation("Pulling", async () => { applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined)); - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); trackEvent("repository_pulled", { from_stored_credential: fromStore ? 1 : 0, changed_files: status?.files.length ?? 0, @@ -2995,9 +2995,7 @@ await runOperation("Pushing", async () => { applyStatus(await push(activeRepoPath, username, password, remoteActionForceWithLease, selectedRemote || undefined)); remoteActionForceWithLease = false; - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath, { files: false }); trackEvent("repository_pushed", { from_stored_credential: fromStore ? 1 : 0, changed_files: status?.files.length ?? 0, @@ -3021,10 +3019,7 @@ await runOperation("Pulling before push", async () => { applyStatus(await pull(activeRepoPath, username, password)); - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); }); if (errorMessage) { @@ -3041,9 +3036,7 @@ await runOperation("Pushing after pull", async () => { applyStatus(await push(activeRepoPath, username, password)); - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath, { files: false }); trackEvent("repository_pushed_after_pull", { from_stored_credential: fromStore ? 1 : 0, changed_files: status?.files.length ?? 0, @@ -3137,18 +3130,24 @@ if (!activeRepoPath || !window.confirm(`Revert commit ${commit.short_hash} (${commit.summary}) with a new commit?`)) return; await runOperation(`Reverting ${commit.short_hash}`, async () => { applyStatus(await revertCommit(activeRepoPath, commit.hash)); - await refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath, { branches: false }); }); } async function continueMerge() { if (!activeRepoPath) return; - await runOperation("Continuing merge", async () => { applyStatus(await mergeContinue(activeRepoPath)); await refreshCommitHistory(activeRepoPath); }); + await runOperation("Continuing merge", async () => { + applyStatus(await mergeContinue(activeRepoPath)); + await refreshRepositoryViews(activeRepoPath); + }); } async function abortMerge() { if (!activeRepoPath || !window.confirm("Abort the current merge and restore the pre-merge state?")) return; - await runOperation("Aborting merge", async () => { applyStatus(await mergeAbort(activeRepoPath)); await refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); }); + await runOperation("Aborting merge", async () => { + applyStatus(await mergeAbort(activeRepoPath)); + await refreshRepositoryViews(activeRepoPath); + }); } async function fetchPruneRepo() { @@ -3205,9 +3204,11 @@ const stashedFiles = changedFiles.length; await runOperation("Stashing changes", async () => { applyStatus(await stashPush(activeRepoPath, message, includeUntracked)); - await refreshStashes(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath, { + branches: false, + stashes: true, + commits: false, + }); trackEvent("stash_saved", { include_untracked: includeUntracked ? 1 : 0, changed_files: stashedFiles, @@ -3219,9 +3220,11 @@ if (!activeRepoPath) return; await runOperation(`Applying ${stash.selector}`, async () => { applyStatus(await stashApply(activeRepoPath, stash.selector)); - await refreshStashes(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath, { + branches: false, + stashes: true, + commits: false, + }); trackEvent("stash_applied"); }); } @@ -3230,9 +3233,11 @@ if (!activeRepoPath) return; await runOperation(`Popping ${stash.selector}`, async () => { applyStatus(await stashPop(activeRepoPath, stash.selector)); - await refreshStashes(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath, { + branches: false, + stashes: true, + commits: false, + }); trackEvent("stash_popped"); }); } @@ -3303,8 +3308,7 @@ const paths = files.map((file) => file.path); await runOperation(files.length === 1 ? `Discarding ${baseName(files[0].path)}` : `Discarding ${files.length} files`, async () => { applyStatus(await restoreFiles(activeRepoPath, paths, staged)); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false }); trackEvent("file_discarded", { files: files.length, staged: staged ? 1 : 0, @@ -3323,8 +3327,7 @@ if (stagedPaths.length > 0) nextStatus = await restoreFiles(activeRepoPath, stagedPaths, true); if (unstagedPaths.length > 0) nextStatus = await restoreFiles(activeRepoPath, unstagedPaths, false); if (nextStatus) applyStatus(nextStatus); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false }); trackEvent("file_discarded", { files: files.length, staged: 2, @@ -3425,8 +3428,7 @@ try { applyStatus(await applyFilePatch(activeRepoPath, file.path, patch, action)); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false }); const updatedPatch = await getFilePatch(activeRepoPath, file.path, staged); if (updatedPatch.trim()) { @@ -3532,10 +3534,7 @@ amendMode = false; preAmendDraftMessage = ""; lastLocalAiGeneratedMessage = ""; - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); trackEvent("commit_created", { amend: 1 }); }); return; @@ -3546,10 +3545,7 @@ applyStatus(await commit(activeRepoPath, message)); commitMessage = ""; lastLocalAiGeneratedMessage = ""; - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); trackEvent("commit_created", { amend: 0, staged_files: trackedStagedCount }); }); } @@ -3591,10 +3587,7 @@ commitMessage = preAmendDraftMessage; preAmendDraftMessage = ""; } - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); trackEvent("commit_undone"); }); } @@ -3607,10 +3600,7 @@ if (!confirmed) return; await runOperation(`Restoring ${target.short_hash}`, async () => { applyStatus(await restoreToCommit(activeRepoPath, target.hash)); - await refreshBranchList(activeRepoPath); - await refreshCommitHistory(activeRepoPath); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath); trackEvent("commit_restored"); }); } @@ -3621,8 +3611,7 @@ if (!confirmed) return false; await runOperation(`Restoring ${file.path}`, async () => { applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, file.path)); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false }); trackEvent("commit_file_restored"); }); return !errorMessage; @@ -3767,8 +3756,7 @@ if (!confirmed) return; await runOperation(`Restoring ${selectedExplorerPath}`, async () => { applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, selectedExplorerPath)); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false }); trackEvent("selected_file_restored_from_commit", { kind, }); @@ -3960,8 +3948,7 @@ } preparedResolutions = {}; if (nextStatus) applyStatus(nextStatus); - await refreshExplorerFiles(activeRepoPath); - await refreshFileHistory(activeRepoPath); + await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false }); const remaining = (nextStatus?.files ?? status?.files ?? []).filter( (f) => f.staged === "conflicted" || f.unstaged === "conflicted",