mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-26 08:01:15 +00:00
feat: show upstream status in workspace sidebar
This commit is contained in:
+18
-2
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user