diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 052619cff..209da5f87 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -1803,6 +1803,7 @@ pub fn run() { delete_action_context, // Timeline timeline::get_branch_timeline, + timeline::refresh_branch_git_state, 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 635208580..085c487c0 100644 --- a/apps/staged/src-tauri/src/timeline.rs +++ b/apps/staged/src-tauri/src/timeline.rs @@ -1,19 +1,12 @@ -//! Timeline — branch timeline construction and related delete commands. +//! Timeline — branch timeline construction and related commands. //! -//! When a fetch is needed (TTL expired), the timeline is built using a -//! **two-stream** approach: +//! `get_branch_timeline` always uses `FetchMode::Never` so it returns +//! instantly from locally-cached refs. Git state rows show stale-but-present +//! data until refreshed. //! -//! - **Fast stream**: local-only git commands (HEAD, branch, status, commits) -//! complete in <1ms (local) or ~2s (one remote round-trip). A partial timeline -//! event is emitted so the frontend can show commits and worktree state -//! immediately. -//! -//! - **Slow stream**: `git fetch` + ref comparisons. Runs concurrently with -//! the fast stream for remote projects. The full `BranchTimeline` returned -//! by the command includes the complete git state from this stream. -//! -//! When the fetch cache is fresh, everything runs as a single fast stream -//! and no partial event is emitted. +//! `refresh_branch_git_state` runs a TTL-gated `git fetch` + ref comparison +//! and emits a `git-state-updated` event that the frontend merges into the +//! existing timeline. use crate::git; use crate::session_runner; @@ -28,15 +21,11 @@ use std::path::Path; use std::sync::{Arc, Mutex}; use tauri::Emitter; -/// Payload for the `timeline-partial` event emitted by the fast stream. -/// Contains commits and a placeholder git state (worktree populated, -/// upstream/base set to loading defaults). The frontend merges this -/// into the existing timeline while waiting for the full result. +/// Payload for the `git-state-updated` event emitted by `refresh_branch_git_state`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -struct TimelinePartialPayload { +struct GitStateUpdatedPayload { branch_id: String, - commits: Vec, git_state: git::BranchGitState, } @@ -131,11 +120,7 @@ fn map_local_commits( .collect() } -fn build_branch_timeline( - store: &Arc, - branch_id: &str, - app: Option<&tauri::AppHandle>, -) -> Result { +fn build_branch_timeline(store: &Arc, branch_id: &str) -> Result { // Get the branch and its workdir for git operations let branch = store .get_branch(branch_id) @@ -161,138 +146,40 @@ fn build_branch_timeline( let resolved_path = resolve_repo_path(ws_name, repo_subpath.as_deref())?; let base_ref = git::origin_ref_for_branch(&branch.base_branch); - if app.is_some() && git::needs_fetch(&cache_key, git::FetchMode::Ttl) { - // Two-stream: run fast + slow scripts concurrently. - // The fast script returns local state + commits in one round-trip. - // The slow script performs fetch + ref comparisons in another. - let slow_git_state = std::thread::scope(|s| { - let slow_handle = s.spawn(|| { - git::compute_branch_git_state_batched( - &cache_key, - |script, args| { - branches::run_workspace_shell(ws_name, script, args) - .map_err(|e| e.to_string()) - }, - &resolved_path, - &branch.branch_name, - &branch.base_branch, - git::FetchMode::Ttl, - ) - }); - - // Fast stream: local state + commits (no fetch) - if let Ok(fast_output) = git::compute_fast_git_state_batched( - &|script, args| { - branches::run_workspace_shell(ws_name, script, args) - .map_err(|e| e.to_string()) - }, - &resolved_path, - &branch.base_branch, - ) { - let (fast, commit_lines) = fast_output.into_fast_git_state(&branch.branch_name); - commits = parse_commit_lines(store, branch_id, &commit_lines); - let partial_state = - fast.into_placeholder_git_state(&branch.branch_name, &branch.base_branch); - if let Some(app) = app { - let _ = app.emit( - "timeline-partial", - TimelinePartialPayload { - branch_id: branch_id.to_string(), - commits: commits.clone(), - git_state: partial_state, - }, - ); - } - } - - slow_handle.join().expect("slow git state thread panicked") - }); - - // If fast stream failed to get commits, fall back to traditional path - if commits.is_empty() { - commits = fetch_remote_commits( - ws_name, - repo_subpath.as_deref(), - store, - branch_id, - &base_ref, - )?; - } - git_state = Some(slow_git_state); - } else { - // Single stream: fetch cache is fresh, everything is fast - git_state = Some(git::compute_branch_git_state_batched( - &cache_key, - |script, args| { - branches::run_workspace_shell(ws_name, script, args).map_err(|e| e.to_string()) - }, - &resolved_path, - &branch.branch_name, - &branch.base_branch, - git::FetchMode::Ttl, - )); - commits = fetch_remote_commits( - ws_name, - repo_subpath.as_deref(), - store, - branch_id, - &base_ref, - )?; - } + git_state = Some(git::compute_branch_git_state_batched( + &cache_key, + |script, args| { + branches::run_workspace_shell(ws_name, script, args).map_err(|e| e.to_string()) + }, + &resolved_path, + &branch.branch_name, + &branch.base_branch, + git::FetchMode::Never, + )); + commits = fetch_remote_commits( + ws_name, + repo_subpath.as_deref(), + store, + branch_id, + &base_ref, + )?; } else if let Some(ref wd) = workdir { // Local branch: fetch commits from the local worktree let worktree_path = Path::new(&wd.path); if worktree_path.exists() { let base_ref = git::origin_ref_for_branch(&branch.base_branch); - let cache_key = git::local_git_state_cache_key( + + git_state = Some(git::compute_local_branch_git_state( worktree_path, &branch.branch_name, &branch.base_branch, - ); - - if app.is_some() && git::needs_fetch(&cache_key, git::FetchMode::Ttl) { - // Two-stream: fast state + commits → emit partial → slow state - let fast = git::compute_fast_local_git_state(worktree_path, &branch.branch_name); - let git_commits = - git::get_commits_since_base(worktree_path, &base_ref).map_err(|e| { - format!("Failed to get commits since base for branch {branch_id}: {e:?}") - })?; - commits = map_local_commits(store, branch_id, &git_commits); - if let Some(app) = app { - let partial_state = fast - .clone() - .into_placeholder_git_state(&branch.branch_name, &branch.base_branch); - let _ = app.emit( - "timeline-partial", - TimelinePartialPayload { - branch_id: branch_id.to_string(), - commits: commits.clone(), - git_state: partial_state, - }, - ); - } - // Slow stream: fetch + ref comparisons - git_state = Some(git::complete_local_git_state( - worktree_path, - &fast, - &branch.branch_name, - &branch.base_branch, - git::FetchMode::Ttl, - )); - } else { - // Single stream: fetch cache is fresh - git_state = Some(git::compute_local_branch_git_state( - worktree_path, - &branch.branch_name, - &branch.base_branch, - git::FetchMode::Ttl, - )); - let git_commits = - git::get_commits_since_base(worktree_path, &base_ref).map_err(|e| { - format!("Failed to get commits since base for branch {branch_id}: {e:?}") - })?; - commits = map_local_commits(store, branch_id, &git_commits); - } + git::FetchMode::Never, + )); + let git_commits = + git::get_commits_since_base(worktree_path, &base_ref).map_err(|e| { + format!("Failed to get commits since base for branch {branch_id}: {e:?}") + })?; + commits = map_local_commits(store, branch_id, &git_commits); } } @@ -446,19 +333,88 @@ fn review_is_visible_in_timeline(review: &Review, visible_shas: &HashSet<&str>) .any(|comment| comment.author == CommentAuthor::User) } -#[tauri::command] +#[tauri::command(rename_all = "camelCase")] pub async fn get_branch_timeline( - app: tauri::AppHandle, store: tauri::State<'_, Mutex>>>, branch_id: String, ) -> Result { let store = crate::get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || build_branch_timeline(&store, &branch_id)) + .await + .map_err(|e| format!("Timeline task failed: {e}"))? +} + +/// Run a TTL-gated `git fetch` + git state recomputation for a branch, +/// then emit a `git-state-updated` event so the frontend can merge the +/// fresh state into the existing timeline. +#[tauri::command(rename_all = "camelCase")] +pub async fn refresh_branch_git_state( + app: tauri::AppHandle, + store: tauri::State<'_, Mutex>>>, + branch_id: String, +) -> Result<(), String> { + let store = crate::get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { - build_branch_timeline(&store, &branch_id, Some(&app)) + let branch = store + .get_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; + + let workdir = store + .get_workdir_for_branch(&branch_id) + .map_err(|e| e.to_string())?; + + let git_state = if let Some(ref ws_name) = branch.workspace_name { + let repo_subpath = branches::resolve_branch_workspace_subpath(&store, &branch)?; + let cache_key = remote_git_state_cache_key( + ws_name, + repo_subpath.as_deref(), + &branch.branch_name, + &branch.base_branch, + ); + let resolved_path = resolve_repo_path(ws_name, repo_subpath.as_deref())?; + + Some(git::compute_branch_git_state_batched( + &cache_key, + |script, args| { + branches::run_workspace_shell(ws_name, script, args).map_err(|e| e.to_string()) + }, + &resolved_path, + &branch.branch_name, + &branch.base_branch, + git::FetchMode::Ttl, + )) + } else if let Some(ref wd) = workdir { + let worktree_path = Path::new(&wd.path); + if worktree_path.exists() { + Some(git::compute_local_branch_git_state( + worktree_path, + &branch.branch_name, + &branch.base_branch, + git::FetchMode::Ttl, + )) + } else { + None + } + } else { + None + }; + + if let Some(state) = git_state { + let _ = app.emit( + "git-state-updated", + GitStateUpdatedPayload { + branch_id: branch_id.to_string(), + git_state: state, + }, + ); + } + Ok(()) }) .await - .map_err(|e| format!("Timeline task failed: {e}"))? + .map_err(|e| format!("Git state refresh task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] @@ -969,7 +925,7 @@ mod tests { store.create_review(&visible_review).unwrap(); store.create_review(&stale_review).unwrap(); - let timeline = build_branch_timeline(&store, &branch.id, None).unwrap(); + let timeline = build_branch_timeline(&store, &branch.id).unwrap(); assert_eq!(timeline.commits.len(), 1); assert_eq!(timeline.commits[0].sha, visible_sha); @@ -988,7 +944,7 @@ mod tests { store.create_review(&stale_review).unwrap(); store.add_comment(&stale_review.id, &agent_comment).unwrap(); - let timeline = build_branch_timeline(&store, &branch.id, None).unwrap(); + let timeline = build_branch_timeline(&store, &branch.id).unwrap(); assert_eq!(timeline.commits.len(), 1); assert!(timeline.reviews.is_empty()); @@ -1004,7 +960,7 @@ mod tests { store.create_review(&stale_review).unwrap(); store.add_comment(&stale_review.id, &user_comment).unwrap(); - let timeline = build_branch_timeline(&store, &branch.id, None).unwrap(); + let timeline = build_branch_timeline(&store, &branch.id).unwrap(); assert_eq!(timeline.commits.len(), 1); assert_eq!(timeline.reviews.len(), 1); @@ -1023,7 +979,7 @@ mod tests { store.add_comment(&stale_review.id, &user_comment).unwrap(); store.delete_comment(&user_comment.id).unwrap(); - let timeline = build_branch_timeline(&store, &branch.id, None).unwrap(); + let timeline = build_branch_timeline(&store, &branch.id).unwrap(); assert_eq!(timeline.commits.len(), 1); assert!(timeline.reviews.is_empty()); diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index c03e6024f..5e57b990c 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -363,7 +363,9 @@ export function getBranchTimeline( } } - const request = invoke('get_branch_timeline', { branchId }) + const request = invoke('get_branch_timeline', { + branchId, + }) .then((timeline) => { if (inFlightTimelines.get(branchId) === request) { timelineCache.set(branchId, { timeline, fetchedAt: Date.now() }); @@ -392,6 +394,10 @@ export function getBranchTimelineWithRevalidation(branchId: string): { }; } +export function refreshBranchGitState(branchId: string): Promise { + return invoke('refresh_branch_git_state', { 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 4a10f5378..a572d0657 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -28,7 +28,6 @@ Branch, BranchGitState, BranchTimeline as BranchTimelineData, - CommitTimelineItem, HashtagItem, ProjectRepo, SessionStatusPayload, @@ -127,8 +126,8 @@ let timeline = $state(null); let loading = $state(true); - /** True from the start of any timeline load until the full (slow) git state arrives. */ - let refreshingGitState = $state(true); + /** True while a background git-state refresh (fetch + ref comparison) is in flight. */ + let refreshingGitState = $state(false); let error = $state(null); let pullingOrigin = $state(false); let discardingWorktreeChanges = $state(false); @@ -481,8 +480,11 @@ const sessionMgr = new BranchCardSessionManager({ getBranch: () => branch, getIsRemote: () => isRemote, - loadTimeline: () => loadTimeline(), + loadTimeline: (opts) => loadTimeline(opts), getTimeline: () => timeline, + setTimeline: (tl) => { + timeline = tl; + }, }); let requestedTimelineKey: string | null = null; @@ -511,7 +513,6 @@ loading = false; prunedSessionIds = sessionMgr.prunePendingSessionItems(cached); if (fresh) { - refreshingGitState = true; const version = ++revalidationVersion; fresh .then((next) => { @@ -529,17 +530,16 @@ return; } error = e instanceof Error ? e.message : String(e); - }) - .finally(() => { - if (version !== revalidationVersion || branchTimelineReadyKey(branch) !== timelineKey) { - return; - } - refreshingGitState = false; }); } else { - refreshingGitState = false; void loadTimelineReviewDetails(cached.reviews); } + + // Kick off a background git-state refresh (TTL-gated fetch). + refreshingGitState = true; + commands.refreshBranchGitState(branch.id).catch(() => { + refreshingGitState = false; + }); } // Synchronously hydrate timeline from cache so isSettingUp is never true @@ -625,6 +625,16 @@ return; } + // Skip reload for the adopted auto-review session completing — + // the timeline was already updated optimistically during adoption. + if (eventSessionId === sessionMgr.adoptedSessionId) { + sessionMgr.adoptedSessionId = null; + return; + } + + // Only reload if this session belongs to our branch + if (eventBranchId && eventBranchId !== branchId) return; + commands.invalidateBranchTimeline(branch.id); loadTimeline(); // Handle PR session completion @@ -663,55 +673,24 @@ }; }); - // Listen for partial timeline events emitted by the fast stream. - // When the backend needs to fetch, it emits commits + fast git state - // (worktree, HEAD, branch identity) before the slow fetch completes. - // This lets the UI show commits and worktree state immediately. - let unlistenPartial: UnlistenFn | null = null; + // Listen for git-state-updated events emitted by refresh_branch_git_state. + // Merges the fresh git state into the existing timeline without a full reload. + let unlistenGitState: UnlistenFn | null = null; $effect(() => { const branchId = branch.id; - listen<{ branchId: string; commits: CommitTimelineItem[]; gitState: BranchGitState }>( - 'timeline-partial', - (event) => { - if (event.payload.branchId !== branchId) return; - const { commits: partialCommits, gitState: partialGitState } = event.payload; - - if (!timeline) { - // No existing timeline — create a minimal one from the partial data - timeline = { - commits: partialCommits, - notes: [], - reviews: [], - images: [], - gitState: partialGitState, - }; - loading = false; - } else { - // Merge: update commits and fast git state fields, preserve - // existing upstream/base data so those rows don't flash away - timeline = { - ...timeline, - commits: partialCommits, - gitState: timeline.gitState - ? { - ...timeline.gitState, - headSha: partialGitState.headSha, - currentBranch: partialGitState.currentBranch, - detachedHead: partialGitState.detachedHead, - expectedBranchMatches: partialGitState.expectedBranchMatches, - worktree: partialGitState.worktree, - } - : partialGitState, - }; - } + listen<{ branchId: string; gitState: BranchGitState }>('git-state-updated', (event) => { + if (event.payload.branchId !== branchId) return; + if (timeline) { + timeline = { ...timeline, gitState: event.payload.gitState }; } - ).then((unlisten) => { - unlistenPartial = unlisten; + refreshingGitState = false; + }).then((unlisten) => { + unlistenGitState = unlisten; }); return () => { - unlistenPartial?.(); + unlistenGitState?.(); }; }); @@ -749,7 +728,6 @@ error = null; // Cancel any in-flight revalidation so it can't overwrite fresher data revalidationVersion++; - refreshingGitState = true; try { if (isInitialLoad) { @@ -783,9 +761,15 @@ } if (isCurrentTimelineLoad(loadVersion, timelineKey)) { loading = false; - refreshingGitState = false; } } + + // Kick off a background git-state refresh (TTL-gated fetch). + // The result arrives via the `git-state-updated` event listener above. + refreshingGitState = true; + commands.refreshBranchGitState(branch.id).catch(() => { + refreshingGitState = false; + }); } function getTimelineReviewDetails(fullReview: TimelineFullReview): TimelineReviewDetails { diff --git a/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts b/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts index 4bc4c017d..c25a9a409 100644 --- a/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts +++ b/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts @@ -38,8 +38,10 @@ export default class BranchCardSessionManager { // Private callback refs — declared first so $derived fields can reference them private getBranch: () => Branch = undefined!; private getIsRemote: () => boolean = undefined!; - private loadTimeline: () => void = undefined!; + private loadTimeline: (opts?: { timelineKey?: string | null; force?: boolean }) => void = + undefined!; private getTimeline: () => BranchTimelineData | null = () => null; + private setTimeline: (tl: BranchTimelineData) => void = undefined!; // New session modal state showNewSession = $state(false); @@ -52,6 +54,9 @@ export default class BranchCardSessionManager { // Auto review state — tracks a background review started after each commit autoReviewSessionId = $state(null); autoReviewId = $state(null); + // Tracks the session ID of an adopted auto-review so its completion event + // can be ignored (it would otherwise trigger a spurious timeline reload). + adoptedSessionId = $state(null); // Session modal (opened after starting a branch session, or from timeline) openSessionId = $state(null); @@ -95,13 +100,15 @@ export default class BranchCardSessionManager { constructor(opts: { getBranch: () => Branch; getIsRemote: () => boolean; - loadTimeline: () => void; + loadTimeline: (opts?: { timelineKey?: string | null; force?: boolean }) => void; getTimeline: () => BranchTimelineData | null; + setTimeline: (tl: BranchTimelineData) => void; }) { this.getBranch = opts.getBranch; this.getIsRemote = opts.getIsRemote; this.loadTimeline = opts.loadTimeline; this.getTimeline = opts.getTimeline; + this.setTimeline = opts.setTimeline; } prunePendingSessionItems(nextTimeline: BranchTimelineData): Set { @@ -242,6 +249,19 @@ export default class BranchCardSessionManager { // Only reveal the review after all fallible operations succeed await commands.setReviewAuto(review.id, false); + // Optimistically update the local timeline so the review is visible + // immediately, before the backend reload completes. + const currentTimeline = this.getTimeline(); + if (currentTimeline) { + this.setTimeline({ + ...currentTimeline, + reviews: currentTimeline.reviews.map((r) => + r.id === review.id ? { ...r, isAuto: false } : r + ), + }); + } + + this.adoptedSessionId = this.autoReviewSessionId; this.autoReviewSessionId = null; this.autoReviewId = null;