feat: show upstream status in workspace sidebar

This commit is contained in:
Ogulcan Celik
2026-03-30 00:30:24 +03:00
parent 00e5da8704
commit 263b8f3265
4 changed files with 98 additions and 5 deletions
+18 -2
View File
@@ -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<Instant>,
toast_deadline: Option<Instant>,
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<usize> {
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() {
+1
View File
@@ -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,
+29 -3
View File
@@ -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::<usize>() + 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),
+50
View File
@@ -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<PaneId, usize>,
@@ -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<String> {
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<PathBuf> {
}
}
/// 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)));
}
}