aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/mod.rs20
-rw-r--r--src/persist.rs1
-rw-r--r--src/ui.rs32
-rw-r--r--src/workspace.rs50
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<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() {
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::<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),
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<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)));
+ }
}