aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-09 21:57:26 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-09 21:57:26 +0300
commit819a71ea7a939e45234648c6ee09aa3bcaa6ff77 (patch)
tree49013b4736d9569a4e7c4e3ae2fa3c95cdad0299
parentd984ab2a3cb4b24788e24b3aaba24adea38b510f (diff)
fix: refresh git status off the main loop
fixes #85
-rw-r--r--src/app/actions.rs92
-rw-r--r--src/app/api.rs11
-rw-r--r--src/app/creation.rs3
-rw-r--r--src/app/mod.rs50
-rw-r--r--src/app/runtime.rs49
-rw-r--r--src/events.rs3
-rw-r--r--src/server/headless.rs12
-rw-r--r--src/workspace.rs26
8 files changed, 219 insertions, 27 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 506930ca..b8777cdc 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -7,6 +7,7 @@ use crate::detect::{Agent, AgentState};
use crate::events::AppEvent;
use crate::layout::{find_in_direction, NavDirection, PaneId};
use crate::pane::EffectiveStateChange;
+use crate::workspace::WorkspaceGitStatus;
use super::state::{AppState, Mode, ToastKind, ToastNotification, ToastTarget, ViewLayout};
@@ -534,6 +535,33 @@ impl AppState {
// ---------------------------------------------------------------------------
impl AppState {
+ pub fn apply_workspace_git_statuses(&mut self, results: Vec<WorkspaceGitStatus>) -> bool {
+ let mut changed = false;
+ for result in results {
+ let Some(ws) = self
+ .workspaces
+ .iter_mut()
+ .find(|ws| ws.id == result.workspace_id)
+ else {
+ continue;
+ };
+
+ if ws.resolved_identity_cwd().as_ref() != Some(&result.resolved_identity_cwd) {
+ continue;
+ }
+
+ if ws.cached_git_branch != result.branch {
+ ws.cached_git_branch = result.branch;
+ changed = true;
+ }
+ if ws.cached_git_ahead_behind != result.ahead_behind {
+ ws.cached_git_ahead_behind = result.ahead_behind;
+ changed = true;
+ }
+ }
+ changed
+ }
+
pub fn handle_app_event(&mut self, event: AppEvent) -> Vec<PaneStateUpdate> {
match event {
AppEvent::PaneDied { pane_id } => {
@@ -593,6 +621,10 @@ impl AppState {
// Intercepted in App::handle_internal_event before reaching this
// dispatch; never touches AppState.
AppEvent::ClipboardWrite { .. } => Vec::new(),
+ AppEvent::GitStatusRefreshed { results } => {
+ self.apply_workspace_git_statuses(results);
+ Vec::new()
+ }
}
}
@@ -753,6 +785,66 @@ mod tests {
}
#[test]
+ fn apply_workspace_git_statuses_updates_matching_workspace() {
+ let mut state = app_with_workspaces(&["one", "two"]);
+ let first_id = state.workspaces[0].id.clone();
+ let first_cwd = state.workspaces[0].resolved_identity_cwd().unwrap();
+ let second_id = state.workspaces[1].id.clone();
+
+ let changed = state.apply_workspace_git_statuses(vec![WorkspaceGitStatus {
+ workspace_id: first_id,
+ resolved_identity_cwd: first_cwd,
+ branch: Some("main".into()),
+ ahead_behind: Some((2, 1)),
+ }]);
+
+ assert!(changed);
+ assert_eq!(state.workspaces[0].branch().as_deref(), Some("main"));
+ assert_eq!(state.workspaces[0].git_ahead_behind(), Some((2, 1)));
+ assert_eq!(state.workspaces[1].id, second_id);
+ assert_eq!(state.workspaces[1].git_ahead_behind(), None);
+ }
+
+ #[test]
+ fn apply_workspace_git_statuses_ignores_stale_cwd() {
+ let mut state = app_with_workspaces(&["one"]);
+ let workspace_id = state.workspaces[0].id.clone();
+ state.workspaces[0].cached_git_branch = Some("old".into());
+ state.workspaces[0].cached_git_ahead_behind = Some((1, 0));
+
+ let changed = state.apply_workspace_git_statuses(vec![WorkspaceGitStatus {
+ workspace_id,
+ resolved_identity_cwd: std::path::PathBuf::from("/definitely/not/current"),
+ branch: Some("main".into()),
+ ahead_behind: Some((0, 1)),
+ }]);
+
+ assert!(!changed);
+ assert_eq!(state.workspaces[0].branch().as_deref(), Some("old"));
+ assert_eq!(state.workspaces[0].git_ahead_behind(), Some((1, 0)));
+ }
+
+ #[test]
+ fn apply_workspace_git_statuses_clears_missing_git_status() {
+ let mut state = app_with_workspaces(&["one"]);
+ let workspace_id = state.workspaces[0].id.clone();
+ let cwd = state.workspaces[0].resolved_identity_cwd().unwrap();
+ state.workspaces[0].cached_git_branch = Some("main".into());
+ state.workspaces[0].cached_git_ahead_behind = Some((1, 2));
+
+ let changed = state.apply_workspace_git_statuses(vec![WorkspaceGitStatus {
+ workspace_id,
+ resolved_identity_cwd: cwd,
+ branch: None,
+ ahead_behind: None,
+ }]);
+
+ assert!(changed);
+ assert_eq!(state.workspaces[0].branch(), None);
+ assert_eq!(state.workspaces[0].git_ahead_behind(), None);
+ }
+
+ #[test]
fn update_ready_sets_explicit_upgrade_toast() {
let mut state = AppState::test_new();
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
diff --git a/src/app/api.rs b/src/app/api.rs
index ab8058cf..bda7fc16 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -1,3 +1,4 @@
+use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
use super::{
@@ -16,6 +17,16 @@ impl App {
return;
}
+ if let AppEvent::GitStatusRefreshed { results } = ev {
+ self.git_refresh_in_flight = false;
+ self.last_git_remote_status_refresh = Instant::now();
+ if self.state.apply_workspace_git_statuses(results) {
+ self.render_dirty.store(true, Ordering::Release);
+ self.render_notify.notify_one();
+ }
+ return;
+ }
+
let overlay_state = if let AppEvent::PaneDied { pane_id } = &ev {
self.overlay_panes.remove(pane_id)
} else {
diff --git a/src/app/creation.rs b/src/app/creation.rs
index 508bf023..4e38084c 100644
--- a/src/app/creation.rs
+++ b/src/app/creation.rs
@@ -105,7 +105,7 @@ impl App {
focus: bool,
) -> std::io::Result<usize> {
let (rows, cols) = self.state.estimate_pane_size();
- let mut ws = Workspace::new(
+ let ws = Workspace::new(
initial_cwd,
rows,
cols,
@@ -115,7 +115,6 @@ impl App {
self.render_notify.clone(),
self.render_dirty.clone(),
)?;
- ws.refresh_git_ahead_behind();
self.state.workspaces.push(ws);
let idx = self.state.workspaces.len() - 1;
let workspace_id = self.state.workspaces[idx].id.clone();
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 1d09230a..1ea315cf 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -66,6 +66,7 @@ pub struct App {
pub(crate) config_diagnostic_deadline: Option<Instant>,
pub(crate) toast_deadline: Option<Instant>,
pub(crate) last_git_remote_status_refresh: Instant,
+ pub(crate) git_refresh_in_flight: bool,
pub(crate) last_sidebar_divider_click: Option<Instant>,
pub(crate) next_resize_poll: Instant,
pub(crate) next_animation_tick: Option<Instant>,
@@ -368,7 +369,7 @@ impl App {
};
for ws in &mut state.workspaces {
- ws.refresh_git_ahead_behind();
+ ws.refresh_git_branch();
}
// Background auto-update is disabled in monolithic no-session mode
@@ -395,7 +396,8 @@ impl App {
state,
event_tx,
event_rx,
- last_git_remote_status_refresh: Instant::now(),
+ last_git_remote_status_refresh: Instant::now() - GIT_REMOTE_STATUS_REFRESH_INTERVAL,
+ git_refresh_in_flight: false,
last_sidebar_divider_click: None,
next_resize_poll: Instant::now() + RESIZE_POLL_INTERVAL,
next_animation_tick: None,
@@ -909,6 +911,50 @@ mod tests {
}
#[test]
+ fn git_refresh_deadline_is_suppressed_while_in_flight() {
+ let mut app = test_app();
+ app.state.workspaces.push(Workspace::test_new("one"));
+ app.git_refresh_in_flight = true;
+
+ assert_eq!(app.git_refresh_deadline(), None);
+ }
+
+ #[test]
+ fn git_status_event_clears_in_flight_refresh() {
+ let mut app = test_app();
+ app.git_refresh_in_flight = true;
+ let previous_refresh = Instant::now() - Duration::from_secs(10);
+ app.last_git_remote_status_refresh = previous_refresh;
+
+ app.handle_internal_event(AppEvent::GitStatusRefreshed {
+ results: Vec::new(),
+ });
+
+ assert!(!app.git_refresh_in_flight);
+ assert!(app.last_git_remote_status_refresh > previous_refresh);
+ }
+
+ #[test]
+ fn git_status_event_marks_render_dirty_when_status_changes() {
+ let mut app = test_app();
+ app.state.workspaces.push(Workspace::test_new("one"));
+ app.render_dirty.store(false, Ordering::Release);
+ let workspace_id = app.state.workspaces[0].id.clone();
+ let resolved_identity_cwd = app.state.workspaces[0].resolved_identity_cwd().unwrap();
+
+ app.handle_internal_event(AppEvent::GitStatusRefreshed {
+ results: vec![crate::workspace::WorkspaceGitStatus {
+ workspace_id,
+ resolved_identity_cwd,
+ branch: Some("render-dirty-test".into()),
+ ahead_behind: Some((1, 0)),
+ }],
+ });
+
+ assert!(app.render_dirty.load(Ordering::Acquire));
+ }
+
+ #[test]
fn startup_uses_configured_agent_panel_scope() {
let mut config = Config::default();
config.ui.agent_panel_scope = crate::config::AgentPanelScopeConfig::Current;
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index 27c061bd..a4e93d3b 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -7,7 +7,8 @@ use super::{
AUTO_UPDATE_CHECK_INTERVAL, GIT_REMOTE_STATUS_REFRESH_INTERVAL, MIN_RENDER_INTERVAL,
RESIZE_POLL_INTERVAL,
};
-use crate::workspace::Workspace;
+use crate::events::AppEvent;
+use crate::workspace::{Workspace, WorkspaceGitStatus};
impl App {
pub(crate) fn drain_api_requests(&mut self) -> bool {
@@ -149,16 +150,7 @@ impl App {
changed = true;
}
- if self
- .git_refresh_deadline()
- .is_some_and(|deadline| now >= deadline)
- {
- for ws in &mut self.state.workspaces {
- ws.refresh_git_ahead_behind();
- }
- self.last_git_remote_status_refresh = now;
- changed = true;
- }
+ self.start_git_status_refresh_if_due(now);
if self
.next_auto_update_check
@@ -236,8 +228,41 @@ impl App {
std::thread::spawn(move || crate::update::auto_update(update_tx));
}
+ pub(crate) fn start_git_status_refresh_if_due(&mut self, now: Instant) {
+ if !self
+ .git_refresh_deadline()
+ .is_some_and(|deadline| now >= deadline)
+ {
+ return;
+ }
+
+ let workspaces: Vec<_> = self
+ .state
+ .workspaces
+ .iter()
+ .filter_map(|ws| ws.resolved_identity_cwd().map(|cwd| (ws.id.clone(), cwd)))
+ .collect();
+
+ if workspaces.is_empty() {
+ self.last_git_remote_status_refresh = now;
+ return;
+ }
+
+ self.git_refresh_in_flight = true;
+ let event_tx = self.event_tx.clone();
+ std::thread::spawn(move || {
+ let results = workspaces
+ .into_iter()
+ .map(|(workspace_id, resolved_identity_cwd)| {
+ Workspace::git_status_for_cwd(workspace_id, resolved_identity_cwd)
+ })
+ .collect::<Vec<WorkspaceGitStatus>>();
+ let _ = event_tx.blocking_send(AppEvent::GitStatusRefreshed { results });
+ });
+ }
+
pub(crate) fn git_refresh_deadline(&self) -> Option<Instant> {
- (!self.state.workspaces.is_empty())
+ (!self.git_refresh_in_flight && !self.state.workspaces.is_empty())
.then_some(self.last_git_remote_status_refresh + GIT_REMOTE_STATUS_REFRESH_INTERVAL)
}
diff --git a/src/events.rs b/src/events.rs
index 1d90ac32..74045d4c 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -5,6 +5,7 @@
use crate::detect::{Agent, AgentState};
use crate::layout::PaneId;
+use crate::workspace::WorkspaceGitStatus;
/// An event from a background task to the main loop.
#[derive(Debug)]
@@ -42,4 +43,6 @@ pub enum AppEvent {
/// A pane child emitted a valid OSC 52 clipboard write. The main loop
/// re-emits it through herdr's own clipboard writer.
ClipboardWrite { content: Vec<u8> },
+ /// Background git status refresh completed for workspaces.
+ GitStatusRefreshed { results: Vec<WorkspaceGitStatus> },
}
diff --git a/src/server/headless.rs b/src/server/headless.rs
index f9ceed84..ffcb6bbb 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -1563,17 +1563,7 @@ impl HeadlessServer {
changed = true;
}
- if self
- .app
- .git_refresh_deadline()
- .is_some_and(|deadline| now >= deadline)
- {
- for ws in &mut self.app.state.workspaces {
- ws.refresh_git_ahead_behind();
- }
- self.app.last_git_remote_status_refresh = now;
- changed = true;
- }
+ self.app.start_git_status_refresh_if_due(now);
if self
.app
diff --git a/src/workspace.rs b/src/workspace.rs
index f9fb0493..e0513f65 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -24,6 +24,14 @@ pub use self::{
tab::Tab,
};
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WorkspaceGitStatus {
+ pub workspace_id: String,
+ pub resolved_identity_cwd: PathBuf,
+ pub branch: Option<String>,
+ pub ahead_behind: Option<(usize, usize)>,
+}
+
static NEXT_WORKSPACE_ID: AtomicU64 = AtomicU64::new(1);
pub(crate) fn generate_workspace_id() -> String {
@@ -362,12 +370,30 @@ impl Workspace {
self.cached_git_ahead_behind
}
+ pub fn refresh_git_branch(&mut self) {
+ let cwd = self.resolved_identity_cwd();
+ self.cached_git_branch = cwd.as_deref().and_then(git_branch);
+ }
+
+ #[cfg(test)]
pub fn refresh_git_ahead_behind(&mut self) {
let cwd = self.resolved_identity_cwd();
self.cached_git_branch = cwd.as_deref().and_then(git_branch);
self.cached_git_ahead_behind = cwd.as_deref().and_then(git_ahead_behind);
}
+ pub fn git_status_for_cwd(
+ workspace_id: String,
+ resolved_identity_cwd: PathBuf,
+ ) -> WorkspaceGitStatus {
+ WorkspaceGitStatus {
+ branch: git_branch(&resolved_identity_cwd),
+ ahead_behind: git_ahead_behind(&resolved_identity_cwd),
+ workspace_id,
+ resolved_identity_cwd,
+ }
+ }
+
pub fn focused_runtime(&self) -> Option<&PaneRuntime> {
self.active_tab().and_then(Tab::focused_runtime)
}