From 263b8f32654588bc0ca229361f7b7be30d72aed2 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Mon, 30 Mar 2026 00:30:24 +0300 Subject: [PATCH] feat: show upstream status in workspace sidebar --- src/app/mod.rs | 20 +++++++++++++++++-- src/persist.rs | 1 + src/ui.rs | 32 ++++++++++++++++++++++++++++--- src/workspace.rs | 50 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 5 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index c7408e47..f352f97c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -11,6 +11,8 @@ pub mod state; use std::io; use std::time::{Duration, Instant}; +const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); + use crossterm::event::{self, Event, KeyEventKind}; use ratatui::layout::Rect; use ratatui::DefaultTerminal; @@ -34,6 +36,7 @@ pub struct App { no_session: bool, config_diagnostic_deadline: Option, toast_deadline: Option, + last_git_remote_status_refresh: Instant, } /// Resolve the palette from config: base theme + optional custom overrides. @@ -105,7 +108,7 @@ impl App { state::Mode::Navigate }; - let state = AppState { + let mut state = AppState { workspaces, active, selected, @@ -154,6 +157,10 @@ impl App { }, }; + for ws in &mut state.workspaces { + ws.refresh_git_ahead_behind(); + } + // Background auto-update (skipped in --no-session / test mode) if !no_session { let update_tx = event_tx.clone(); @@ -176,6 +183,7 @@ impl App { state, event_tx, event_rx, + last_git_remote_status_refresh: Instant::now(), api_rx, event_hub, last_focus, @@ -203,6 +211,13 @@ impl App { self.state.spinner_tick = self.state.spinner_tick.wrapping_add(1); + if self.last_git_remote_status_refresh.elapsed() >= GIT_REMOTE_STATUS_REFRESH_INTERVAL { + for ws in &mut self.state.workspaces { + ws.refresh_git_ahead_behind(); + } + self.last_git_remote_status_refresh = Instant::now(); + } + terminal.draw(|frame| { crate::ui::compute_view(&mut self.state, frame.area()); crate::ui::render(&self.state, frame); @@ -976,7 +991,8 @@ impl App { focus: bool, ) -> std::io::Result { let (rows, cols) = self.state.estimate_pane_size(); - let ws = Workspace::new(initial_cwd, rows, cols, self.event_tx.clone())?; + let mut ws = Workspace::new(initial_cwd, rows, cols, self.event_tx.clone())?; + ws.refresh_git_ahead_behind(); self.state.workspaces.push(ws); let idx = self.state.workspaces.len() - 1; if focus || self.state.active.is_none() { diff --git a/src/persist.rs b/src/persist.rs index 4f6de57b..9cc6205e 100644 --- a/src/persist.rs +++ b/src/persist.rs @@ -198,6 +198,7 @@ fn restore_workspace( custom_name: snap.custom_name.clone(), root_pane, layout, + cached_git_ahead_behind: None, public_pane_numbers, next_public_pane_number: panes.len() + 1, panes, diff --git a/src/ui.rs b/src/ui.rs index 291a2e98..2b5b8a4a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -410,7 +410,23 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig ); row_y += 1; } else if let Some(branch) = ws.branch() { - let max_branch_len = (area.width as usize).saturating_sub(5); + let upstream_label = ws.git_ahead_behind().and_then(|(ahead, behind)| { + let mut parts = Vec::new(); + if ahead > 0 { + parts.push((format!("↑{}", ahead), p.green)); + } + if behind > 0 { + parts.push((format!("↓{}", behind), p.red)); + } + (!parts.is_empty()).then_some(parts) + }); + let reserved = upstream_label + .as_ref() + .map(|parts| { + parts.iter().map(|(label, _)| label.len()).sum::() + parts.len() + }) + .unwrap_or(0); + let max_branch_len = (area.width as usize).saturating_sub(5 + reserved); let branch_display = if branch.len() > max_branch_len { format!("{}…", &branch[..max_branch_len.saturating_sub(1)]) } else { @@ -421,10 +437,20 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig } else { p.overlay0 }; - let line2 = Line::from(vec![ + let mut spans = vec![ Span::styled(" ", Style::default()), Span::styled(branch_display, Style::default().fg(branch_color)), - ]); + ]; + if let Some(parts) = upstream_label { + spans.push(Span::styled(" ", Style::default())); + for (idx, (label, color)) in parts.into_iter().enumerate() { + if idx > 0 { + spans.push(Span::styled(" ", Style::default())); + } + spans.push(Span::styled(label, Style::default().fg(color))); + } + } + let line2 = Line::from(spans); frame.render_widget( Paragraph::new(line2), Rect::new(area.x, row_y, area.width, 1), diff --git a/src/workspace.rs b/src/workspace.rs index 51fd0250..7bda8f23 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -17,6 +17,8 @@ pub struct Workspace { /// Identity source for this workspace. pub root_pane: PaneId, pub layout: TileLayout, + /// Cached ahead/behind counts for the root repo's current branch upstream. + pub(crate) cached_git_ahead_behind: Option<(usize, usize)>, /// Stable-ish public pane numbers within this workspace. /// New panes append at the end; closing a pane compacts higher numbers down. pub public_pane_numbers: HashMap, @@ -51,6 +53,7 @@ impl Workspace { custom_name: None, root_pane: root_id, layout, + cached_git_ahead_behind: None, public_pane_numbers, next_public_pane_number: 2, panes, @@ -239,6 +242,16 @@ impl Workspace { pub fn branch(&self) -> Option { self.root_cwd().and_then(|cwd| git_branch(&cwd)) } + + /// Cached ahead/behind counts for this workspace's current branch upstream. + pub fn git_ahead_behind(&self) -> Option<(usize, usize)> { + self.cached_git_ahead_behind + } + + /// Refresh cached ahead/behind counts from the workspace's current cwd. + pub fn refresh_git_ahead_behind(&mut self) { + self.cached_git_ahead_behind = self.root_cwd().and_then(|cwd| git_ahead_behind(&cwd)); + } } /// Detail info for a single pane, used by the agent detail panel. @@ -330,6 +343,32 @@ fn git_repo_root(start: &Path) -> Option { } } +/// Read ahead/behind counts relative to the current branch upstream. +fn git_ahead_behind(cwd: &Path) -> Option<(usize, usize)> { + git_repo_root(cwd)?; + + let output = std::process::Command::new("git") + .arg("-C") + .arg(cwd) + .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8(output.stdout).ok()?; + parse_git_ahead_behind_output(&stdout) +} + +fn parse_git_ahead_behind_output(stdout: &str) -> Option<(usize, usize)> { + let mut parts = stdout.split_whitespace(); + let ahead = parts.next()?.parse().ok()?; + let behind = parts.next()?.parse().ok()?; + Some((ahead, behind)) +} + // --------------------------------------------------------------------------- // Test helpers — construct workspaces without PTYs // --------------------------------------------------------------------------- @@ -348,6 +387,7 @@ impl Workspace { custom_name: Some(name.to_string()), root_pane: root_id, layout, + cached_git_ahead_behind: None, public_pane_numbers, next_public_pane_number: 2, panes, @@ -448,4 +488,14 @@ mod tests { ws.remove_pane(root); assert_eq!(ws.root_pane, other); } + + #[test] + fn parse_git_ahead_behind_output_maps_first_field_to_ahead() { + assert_eq!(parse_git_ahead_behind_output("7\t0\n"), Some((7, 0))); + } + + #[test] + fn parse_git_ahead_behind_output_maps_second_field_to_behind() { + assert_eq!(parse_git_ahead_behind_output("0 3\n"), Some((0, 3))); + } }