From 9092b77c17ab645a125def9a6425f8fe100a1fae Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 28 May 2026 16:26:11 +1000 Subject: [PATCH 1/5] feat(staged): hover parent-branch capsule to preview upstream commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `list_parent_branch_commits` Tauri command returning the merge-base(HEAD, origin/{base})..origin/{base} commit range — the same range powering the `+N` badge — and a `ParentBranchCommitsHover` component that lazy-loads and renders it as a popover when the parent capsule is hovered. The cache invalidates on `git-state-updated`. Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/lib.rs | 1 + apps/staged/src-tauri/src/timeline.rs | 109 ++++++ apps/staged/src/lib/commands.ts | 13 + .../lib/features/branches/BranchCard.svelte | 2 + .../branches/BranchCardHeaderInfo.svelte | 29 +- .../branches/ParentBranchCommitsHover.svelte | 348 ++++++++++++++++++ 6 files changed, 494 insertions(+), 8 deletions(-) create mode 100644 apps/staged/src/lib/features/branches/ParentBranchCommitsHover.svelte diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 21274756..f277605e 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -2193,6 +2193,7 @@ pub fn run() { // Timeline timeline::get_branch_timeline, timeline::refresh_branch_git_state, + timeline::list_parent_branch_commits, timeline::pull_branch_ff_only, // Notes note_commands::create_note, diff --git a/apps/staged/src-tauri/src/timeline.rs b/apps/staged/src-tauri/src/timeline.rs index b18f6fc3..c960923a 100644 --- a/apps/staged/src-tauri/src/timeline.rs +++ b/apps/staged/src-tauri/src/timeline.rs @@ -144,6 +144,38 @@ struct GitStateUpdatedPayload { git_state: git::BranchGitState, } +/// A single commit on the parent branch that hasn't yet been merged into the +/// current branch — returned by `list_parent_branch_commits` for the hover +/// popover on the parent-branch capsule. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ParentBranchCommit { + pub sha: String, + pub short_sha: String, + pub subject: String, + pub author: String, + pub author_email: String, + pub timestamp: i64, +} + +fn parse_parent_commit_lines(lines: &[String]) -> Vec { + let mut commits = Vec::new(); + for line in lines { + let parts: Vec<&str> = line.splitn(6, '|').collect(); + if parts.len() >= 6 { + commits.push(ParentBranchCommit { + sha: parts[0].to_string(), + short_sha: parts[1].to_string(), + subject: parts[2].to_string(), + author: parts[3].to_string(), + author_email: parts[4].to_string(), + timestamp: parts[5].parse().unwrap_or(0), + }); + } + } + commits +} + /// Parse `%H|%h|%s|%an|%ae|%ct` formatted commit lines into timeline items, /// looking up DB metadata for session linkage. fn parse_commit_lines( @@ -622,6 +654,83 @@ pub async fn refresh_branch_git_state( .map_err(|e| format!("Git state refresh task failed: {e}"))? } +/// Return the commits on the parent branch that aren't yet in this branch's +/// ancestry — i.e. the same `merge-base(HEAD, origin/{base})..origin/{base}` +/// range that `commitsSinceFork` counts. Read-only against locally cached +/// refs; the count's own fetch round-trip is what keeps `origin/{base}` +/// current. +#[tauri::command(rename_all = "camelCase")] +pub async fn list_parent_branch_commits( + store: tauri::State<'_, Mutex>>>, + branch_id: String, +) -> Result, String> { + let store = crate::get_store(&store)?; + + tauri::async_runtime::spawn_blocking(move || { + let branch = store + .get_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; + + let base_ref = git::origin_ref_for_branch(&branch.base_branch); + let format_arg = "--format=%H|%h|%s|%an|%ae|%ct"; + + if let Some(ref ws_name) = branch.workspace_name { + let repo_subpath = branches::resolve_branch_workspace_subpath(&store, &branch)?; + let range = match branches::run_workspace_git( + ws_name, + repo_subpath.as_deref(), + &["merge-base", &base_ref, "HEAD"], + ) { + Ok(mb_output) => format!("{}..{base_ref}", mb_output.trim()), + Err(_) => base_ref.clone(), + }; + let output = match branches::run_workspace_git( + ws_name, + repo_subpath.as_deref(), + &["log", format_arg, &range], + ) { + Ok(o) => o, + Err(_) => return Ok(Vec::new()), + }; + let lines: Vec = output + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect(); + return Ok(parse_parent_commit_lines(&lines)); + } + + let workdir = store + .get_workdir_for_branch(&branch_id) + .map_err(|e| e.to_string())?; + let Some(wd) = workdir else { + return Ok(Vec::new()); + }; + let worktree_path = Path::new(&wd.path); + if !worktree_path.exists() { + return Ok(Vec::new()); + } + + let range = match git::cli_run_smart(worktree_path, &["merge-base", &base_ref, "HEAD"]) { + Ok(mb_output) => format!("{}..{base_ref}", mb_output.trim()), + Err(_) => base_ref.clone(), + }; + let output = match git::cli_run_smart(worktree_path, &["log", format_arg, &range]) { + Ok(o) => o, + Err(_) => return Ok(Vec::new()), + }; + let lines: Vec = output + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect(); + Ok(parse_parent_commit_lines(&lines)) + }) + .await + .map_err(|e| format!("List parent branch commits task failed: {e}"))? +} + #[tauri::command(rename_all = "camelCase")] pub async fn pull_branch_ff_only( store: tauri::State<'_, Mutex>>>, diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 2069577e..274a4a44 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -417,6 +417,19 @@ export function refreshBranchGitState( }); } +export interface ParentBranchCommit { + sha: string; + shortSha: string; + subject: string; + author: string; + authorEmail: string; + timestamp: number; +} + +export function listParentBranchCommits(branchId: string): Promise { + return invokeCommand('list_parent_branch_commits', { branchId }); +} + export function invalidateProjectBranchTimelines(branchIds: string[]): void { for (const id of branchIds) { timelineCache.delete(id); diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index ebc757fb..2a0bcaeb 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -1308,6 +1308,7 @@ {:else if isLocal && !branch.worktreePath && worktreeError}
{/if} +{#snippet capsule()} + + {baseBranch}{#if parentAheadCount > 0} + +{parentAheadCount}{/if} + +{/snippet} + {#snippet parentPill()} {#if baseBranch} - - {baseBranch}{#if parentAheadCount > 0} - +{parentAheadCount}{/if} - + {#if parentAheadCount > 0 && branchId} + + {@render capsule()} + + {:else} + {@render capsule()} + {/if} {#if parentAheadCount > 0 && onRebase}