From a63f55814c1bc9fa9803f331e3376bd553761887 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 17 Mar 2026 16:20:17 +1100 Subject: [PATCH 1/4] feat: add info-level timing logs to all Tauri invoke calls Add a traced invoke wrapper (src/lib/invoke.ts) that logs every IPC call to the Rust backend at info level with the command name and wall-clock duration. Calls exceeding 100ms are tagged [SLOW] to make it easy to spot UI-blocking operations when debugging remote project freezes. All four files that previously imported invoke directly from @tauri-apps/api/core now use the wrapper: - src/lib/commands.ts (main command wrappers) - src/lib/features/actions/actions.ts (action commands) - src/lib/features/branches/branch.ts (branch opener commands) - src/lib/shared/persistentStore.ts (preferences store path) --- apps/staged/src/lib/commands.ts | 2 +- .../src/lib/features/actions/actions.ts | 2 +- .../src/lib/features/branches/branch.ts | 2 +- apps/staged/src/lib/invoke.ts | 34 +++++++++++++++++++ apps/staged/src/lib/shared/persistentStore.ts | 2 +- 5 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 apps/staged/src/lib/invoke.ts diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 464fb650..f382a65c 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -4,7 +4,7 @@ * One function per command. Each returns a typed promise. */ -import { invoke } from '@tauri-apps/api/core'; +import { invoke } from './invoke'; import type { Project, ProjectRepo, diff --git a/apps/staged/src/lib/features/actions/actions.ts b/apps/staged/src/lib/features/actions/actions.ts index 3c0b0f74..7e431201 100644 --- a/apps/staged/src/lib/features/actions/actions.ts +++ b/apps/staged/src/lib/features/actions/actions.ts @@ -6,7 +6,7 @@ * that can be run in branch worktrees with real-time output streaming. */ -import { invoke } from '@tauri-apps/api/core'; +import { invoke } from '../../invoke'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; /** Action types available for project actions. */ diff --git a/apps/staged/src/lib/features/branches/branch.ts b/apps/staged/src/lib/features/branches/branch.ts index a82680ca..133d0e04 100644 --- a/apps/staged/src/lib/features/branches/branch.ts +++ b/apps/staged/src/lib/features/branches/branch.ts @@ -1,4 +1,4 @@ -import { invoke } from '@tauri-apps/api/core'; +import { invoke } from '../../invoke'; import { writeText } from '@tauri-apps/plugin-clipboard-manager'; /** An application that can open a directory */ diff --git a/apps/staged/src/lib/invoke.ts b/apps/staged/src/lib/invoke.ts new file mode 100644 index 00000000..dd96f423 --- /dev/null +++ b/apps/staged/src/lib/invoke.ts @@ -0,0 +1,34 @@ +/** + * Traced invoke wrapper for Tauri commands. + * + * Every IPC call to the Rust backend is logged at `info` level with the + * command name and wall-clock duration so we can identify calls that block + * the UI thread for too long. + */ + +import { invoke as tauriInvoke, type InvokeArgs } from '@tauri-apps/api/core'; +import { info } from '@tauri-apps/plugin-log'; + +const SLOW_THRESHOLD_MS = 100; + +/** + * Drop-in replacement for `@tauri-apps/api/core.invoke` that logs timing. + * + * - Every call logs `[invoke] completed in ms`. + * - Calls exceeding {@link SLOW_THRESHOLD_MS} are tagged `[SLOW]`. + * - Failures log the elapsed time together with the error. + */ +export async function invoke(cmd: string, args?: InvokeArgs): Promise { + const start = performance.now(); + try { + const result = await tauriInvoke(cmd, args); + const elapsed = performance.now() - start; + const tag = elapsed >= SLOW_THRESHOLD_MS ? ' [SLOW]' : ''; + info(`[invoke]${tag} ${cmd} completed in ${elapsed.toFixed(1)}ms`); + return result; + } catch (error) { + const elapsed = performance.now() - start; + info(`[invoke] ${cmd} failed after ${elapsed.toFixed(1)}ms: ${error}`); + throw error; + } +} diff --git a/apps/staged/src/lib/shared/persistentStore.ts b/apps/staged/src/lib/shared/persistentStore.ts index 88a357f7..d794344d 100644 --- a/apps/staged/src/lib/shared/persistentStore.ts +++ b/apps/staged/src/lib/shared/persistentStore.ts @@ -8,7 +8,7 @@ * The store is saved to `~/.staged/preferences.json`. */ -import { invoke } from '@tauri-apps/api/core'; +import { invoke } from '../invoke'; import { load, type Store } from '@tauri-apps/plugin-store'; // Singleton store instance From 7433ee4ce4e6721083fa0312ca6c37c9c0fdb73d Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 17 Mar 2026 16:32:57 +1100 Subject: [PATCH 2/4] fix: stop has_unpushed_commits from freezing the UI for remote branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The has_unpushed_commits command was the primary cause of UI freezes for remote projects. It was called from three frontend sites and each remote call did 2 synchronous SSH round-trips (~5s each) via blox ws_exec. Call frequency: 10 calls in the first 25 seconds of app usage — fired for every branch whenever branchesByProject changed (12 reassignment sites in ProjectHome). Local branches completed in 39–202ms, but remote branches blocked for 4.5–4.8s each. Backend (prs.rs): - Convert has_unpushed_commits from sync pub fn to async, wrapping the remote-branch SSH path in spawn_blocking so it no longer blocks the Tauri IPC thread. Frontend — skip the check entirely for remote branches: - ProjectHome.svelte safeToDeleteProjects $effect: treat merged remote branches as safe without the SSH check. - ProjectHome.svelte handleDeleteProjectRequest: same skip. - BranchCardPrButton.svelte: skip the per-timeline-refresh unpushed check for remote branches (isLocal guard). --- apps/staged/src-tauri/src/prs.rs | 52 +++++++++++-------- .../branches/BranchCardPrButton.svelte | 7 +-- .../lib/features/projects/ProjectHome.svelte | 11 +++- 3 files changed, 43 insertions(+), 27 deletions(-) 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..6d5114a7 100644 --- a/apps/staged/src/lib/features/projects/ProjectHome.svelte +++ b/apps/staged/src/lib/features/projects/ProjectHome.svelte @@ -328,12 +328,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 +414,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 { From ddee15bd5fdb74d462c468ee72265a48a41814fa Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 17 Mar 2026 16:35:31 +1100 Subject: [PATCH 3/4] fix: scope safeToDeleteProjects check to visible projects only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The $effect that computes safeToDeleteProjects was iterating over all projects, calling hasUnpushedCommits for every branch across every project — even ones not currently displayed. Since the result is only consumed in the {#each visibleProjects} render loop, this was wasted work (and especially costly for remote branches with ~5s SSH round-trips). Narrow the loop from `projects` to `visibleProjects` so the check only runs for the project the user is currently viewing. --- apps/staged/src/lib/features/projects/ProjectHome.svelte | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/staged/src/lib/features/projects/ProjectHome.svelte b/apps/staged/src/lib/features/projects/ProjectHome.svelte index 6d5114a7..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; From 1fd75b8d4d11e58ce8b1119c8a60c565837b5e1d Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 17 Mar 2026 16:43:25 +1100 Subject: [PATCH 4/4] refactor: remove invoke instrumentation wrapper Delete src/lib/invoke.ts and revert all four import sites back to importing invoke directly from @tauri-apps/api/core. The timing/logging wrapper was added to diagnose the remote branch UI freeze and is no longer needed now that the fix is in place. --- apps/staged/src/lib/commands.ts | 2 +- .../src/lib/features/actions/actions.ts | 2 +- .../src/lib/features/branches/branch.ts | 2 +- apps/staged/src/lib/invoke.ts | 34 ------------------- apps/staged/src/lib/shared/persistentStore.ts | 2 +- 5 files changed, 4 insertions(+), 38 deletions(-) delete mode 100644 apps/staged/src/lib/invoke.ts diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index f382a65c..464fb650 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -4,7 +4,7 @@ * One function per command. Each returns a typed promise. */ -import { invoke } from './invoke'; +import { invoke } from '@tauri-apps/api/core'; import type { Project, ProjectRepo, diff --git a/apps/staged/src/lib/features/actions/actions.ts b/apps/staged/src/lib/features/actions/actions.ts index 7e431201..3c0b0f74 100644 --- a/apps/staged/src/lib/features/actions/actions.ts +++ b/apps/staged/src/lib/features/actions/actions.ts @@ -6,7 +6,7 @@ * that can be run in branch worktrees with real-time output streaming. */ -import { invoke } from '../../invoke'; +import { invoke } from '@tauri-apps/api/core'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; /** Action types available for project actions. */ diff --git a/apps/staged/src/lib/features/branches/branch.ts b/apps/staged/src/lib/features/branches/branch.ts index 133d0e04..a82680ca 100644 --- a/apps/staged/src/lib/features/branches/branch.ts +++ b/apps/staged/src/lib/features/branches/branch.ts @@ -1,4 +1,4 @@ -import { invoke } from '../../invoke'; +import { invoke } from '@tauri-apps/api/core'; import { writeText } from '@tauri-apps/plugin-clipboard-manager'; /** An application that can open a directory */ diff --git a/apps/staged/src/lib/invoke.ts b/apps/staged/src/lib/invoke.ts deleted file mode 100644 index dd96f423..00000000 --- a/apps/staged/src/lib/invoke.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Traced invoke wrapper for Tauri commands. - * - * Every IPC call to the Rust backend is logged at `info` level with the - * command name and wall-clock duration so we can identify calls that block - * the UI thread for too long. - */ - -import { invoke as tauriInvoke, type InvokeArgs } from '@tauri-apps/api/core'; -import { info } from '@tauri-apps/plugin-log'; - -const SLOW_THRESHOLD_MS = 100; - -/** - * Drop-in replacement for `@tauri-apps/api/core.invoke` that logs timing. - * - * - Every call logs `[invoke] completed in ms`. - * - Calls exceeding {@link SLOW_THRESHOLD_MS} are tagged `[SLOW]`. - * - Failures log the elapsed time together with the error. - */ -export async function invoke(cmd: string, args?: InvokeArgs): Promise { - const start = performance.now(); - try { - const result = await tauriInvoke(cmd, args); - const elapsed = performance.now() - start; - const tag = elapsed >= SLOW_THRESHOLD_MS ? ' [SLOW]' : ''; - info(`[invoke]${tag} ${cmd} completed in ${elapsed.toFixed(1)}ms`); - return result; - } catch (error) { - const elapsed = performance.now() - start; - info(`[invoke] ${cmd} failed after ${elapsed.toFixed(1)}ms: ${error}`); - throw error; - } -} diff --git a/apps/staged/src/lib/shared/persistentStore.ts b/apps/staged/src/lib/shared/persistentStore.ts index d794344d..88a357f7 100644 --- a/apps/staged/src/lib/shared/persistentStore.ts +++ b/apps/staged/src/lib/shared/persistentStore.ts @@ -8,7 +8,7 @@ * The store is saved to `~/.staged/preferences.json`. */ -import { invoke } from '../invoke'; +import { invoke } from '@tauri-apps/api/core'; import { load, type Store } from '@tauri-apps/plugin-store'; // Singleton store instance