diff --git a/apps/staged/src-tauri/src/prs.rs b/apps/staged/src-tauri/src/prs.rs index 95c6a307..0dc3665c 100644 --- a/apps/staged/src-tauri/src/prs.rs +++ b/apps/staged/src-tauri/src/prs.rs @@ -390,7 +390,7 @@ pub fn clear_branch_pr_status( /// Check if a branch has commits that haven't been pushed to the remote. #[tauri::command(rename_all = "camelCase")] -pub fn has_unpushed_commits( +pub async fn has_unpushed_commits( store: tauri::State<'_, Mutex>>>, branch_id: String, ) -> Result { @@ -405,28 +405,36 @@ pub fn has_unpushed_commits( let workspace_name = branch .workspace_name .as_deref() - .ok_or_else(|| format!("Branch has no workspace name: {branch_id}"))?; + .ok_or_else(|| format!("Branch has no workspace name: {branch_id}"))? + .to_string(); let repo_subpath = crate::branches::resolve_branch_workspace_subpath(&store, &branch)?; - - let remote_ref = format!("origin/{}", branch.branch_name); - // Check that the remote tracking branch exists - if crate::branches::run_workspace_git( - workspace_name, - repo_subpath.as_deref(), - &["rev-parse", "--verify", &remote_ref], - ) - .is_err() - { - return Ok(false); - } - let rev_range = format!("{remote_ref}..HEAD"); - let output = crate::branches::run_workspace_git( - workspace_name, - repo_subpath.as_deref(), - &["rev-list", &rev_range], - ) - .map_err(|e| e.to_string())?; - return Ok(!output.trim().is_empty()); + let branch_name = branch.branch_name.clone(); + + // Run the blocking SSH calls on a background thread so we don't + // block the Tauri IPC thread and freeze the UI. + return tauri::async_runtime::spawn_blocking(move || { + let remote_ref = format!("origin/{}", branch_name); + // Check that the remote tracking branch exists + if crate::branches::run_workspace_git( + &workspace_name, + repo_subpath.as_deref(), + &["rev-parse", "--verify", &remote_ref], + ) + .is_err() + { + return Ok(false); + } + let rev_range = format!("{remote_ref}..HEAD"); + let output = crate::branches::run_workspace_git( + &workspace_name, + repo_subpath.as_deref(), + &["rev-list", &rev_range], + ) + .map_err(|e| e.to_string())?; + Ok(!output.trim().is_empty()) + }) + .await + .map_err(|e| format!("has_unpushed_commits task failed: {e}"))?; } let workdir = store diff --git a/apps/staged/src/lib/features/branches/BranchCardPrButton.svelte b/apps/staged/src/lib/features/branches/BranchCardPrButton.svelte index 86bf9b1b..ca56e831 100644 --- a/apps/staged/src/lib/features/branches/BranchCardPrButton.svelte +++ b/apps/staged/src/lib/features/branches/BranchCardPrButton.svelte @@ -203,10 +203,11 @@ return () => clearInterval(interval); }); - // Re-check unpushed commits whenever the timeline refreshes and a PR exists + // Re-check unpushed commits whenever the timeline refreshes and a PR exists. + // Skip for remote branches — the SSH round-trip to check takes ~5s and + // blocks the UI. Remote workspace commits are already on the server. $effect(() => { - // Re-run when timeline changes (dependency) and PR exists - if (timeline && branch.prNumber) { + if (timeline && branch.prNumber && isLocal) { commands.hasUnpushedCommits(branch.id).then((v) => (hasUnpushed = v)); } }); diff --git a/apps/staged/src/lib/features/projects/ProjectHome.svelte b/apps/staged/src/lib/features/projects/ProjectHome.svelte index b160569b..2ab87476 100644 --- a/apps/staged/src/lib/features/projects/ProjectHome.svelte +++ b/apps/staged/src/lib/features/projects/ProjectHome.svelte @@ -313,12 +313,15 @@ // Track which projects are safe to delete (for button styling) let safeToDeleteProjects = $state>(new Set()); - // Update safe-to-delete status when branches change + // Update safe-to-delete status when branches change. + // Only check visible projects — calling hasUnpushedCommits for every + // project wastes IPC round-trips (especially expensive for remote branches) + // and the result is only consumed in the visibleProjects render loop. $effect(() => { const updateSafeStatus = async () => { const nextSafe = new Set(); - for (const project of projects) { + for (const project of visibleProjects) { const branches = branchesByProject.get(project.id) || []; const repoCount = repoCountsByProject.get(project.id) || 0; @@ -328,12 +331,16 @@ continue; } - // Check if all branches have merged PRs and no unpushed changes + // Check if all branches have merged PRs and no unpushed changes. + // Skip the expensive hasUnpushedCommits check for remote branches — + // their commits live on the workspace and the SSH round-trip (~5s) + // blocks the UI. Treat merged remote branches as safe. if (branches.length > 0) { const allSafe = await Promise.all( branches.map(async (branch) => { const isMerged = branch.prState === 'MERGED'; if (!isMerged) return false; + if (branch.branchType === 'remote') return true; try { const hasUnpushed = await commands.hasUnpushedCommits(branch.id); @@ -410,11 +417,14 @@ return; } - // Check if all branches have merged PRs and no unpushed changes + // Check if all branches have merged PRs and no unpushed changes. + // Skip the expensive hasUnpushedCommits check for remote branches — + // their commits live on the workspace and the SSH round-trip blocks the UI. const allSafe = await Promise.all( branches.map(async (branch) => { const isMerged = branch.prState === 'MERGED'; if (!isMerged) return false; + if (branch.branchType === 'remote') return true; // Check for unpushed commits try {