diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-30 23:58:15 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-30 23:58:34 +0300 |
| commit | fc4e1bbf6b0fa2ec37445a5b72356514c7c6be6a (patch) | |
| tree | 93af04af44a4dcc062a7886c51d70641c1903144 /src/app | |
| parent | d477341e6af816ff4396042b084a0378ef437b4a (diff) | |
perf: reduce sidebar git polling overhead
refs #353
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 6 | ||||
| -rw-r--r-- | src/app/api.rs | 19 | ||||
| -rw-r--r-- | src/app/mod.rs | 13 | ||||
| -rw-r--r-- | src/app/runtime.rs | 277 |
4 files changed, 289 insertions, 26 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 40c8e74c..981212b1 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -2044,8 +2044,12 @@ impl AppState { // Intercepted in App::handle_internal_event before reaching this // dispatch; never touches AppState. AppEvent::ClipboardWrite { .. } => Vec::new(), - AppEvent::GitStatusRefreshed { results } => { + AppEvent::GitStatusRefreshed { + results, + cache_updates, + } => { let _ = results; + let _ = cache_updates; Vec::new() } AppEvent::WorktreeAddFinished(_) => Vec::new(), diff --git a/src/app/api.rs b/src/app/api.rs index a26b2856..d8224506 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -21,14 +21,29 @@ enum RuntimeExitAction { impl App { pub(crate) fn handle_internal_event(&mut self, ev: AppEvent) { if let AppEvent::ClipboardWrite { content } = ev { + #[cfg(not(test))] crate::selection::write_osc52_bytes(&content); + #[cfg(test)] + let _ = content; self.show_clipboard_feedback(); return; } - if let AppEvent::GitStatusRefreshed { results } = ev { + if let AppEvent::GitStatusRefreshed { + results, + cache_updates, + } = ev + { self.git_refresh_in_flight = false; - self.last_git_remote_status_refresh = Instant::now(); + for (key, entry) in cache_updates { + self.git_status_cache.insert(key, entry); + } + if self.git_refresh_due_after_in_flight { + self.mark_git_status_refresh_due(Instant::now()); + self.git_refresh_due_after_in_flight = false; + } else { + self.last_git_remote_status_refresh = Instant::now(); + } if self .state .apply_workspace_git_statuses(&self.terminal_runtimes, results) diff --git a/src/app/mod.rs b/src/app/mod.rs index d8071c4d..273016db 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -97,6 +97,8 @@ pub struct App { pub(crate) copy_feedback_deadline: Option<Instant>, pub(crate) last_git_remote_status_refresh: Instant, pub(crate) git_refresh_in_flight: bool, + pub(crate) git_refresh_due_after_in_flight: bool, + pub(crate) git_status_cache: HashMap<std::path::PathBuf, crate::workspace::GitStatusCacheEntry>, pub(crate) last_sidebar_divider_click: Option<Instant>, pub(crate) last_pane_click: Option<PaneClickState>, pub(crate) next_resize_poll: Instant, @@ -563,6 +565,8 @@ impl App { event_rx, last_git_remote_status_refresh: Instant::now() - GIT_REMOTE_STATUS_REFRESH_INTERVAL, git_refresh_in_flight: false, + git_refresh_due_after_in_flight: false, + git_status_cache: HashMap::new(), last_sidebar_divider_click: None, last_pane_click: None, next_resize_poll: Instant::now() + RESIZE_POLL_INTERVAL, @@ -1459,6 +1463,7 @@ mod tests { app.handle_internal_event(AppEvent::GitStatusRefreshed { results: Vec::new(), + cache_updates: Vec::new(), }); assert!(!app.git_refresh_in_flight); @@ -1481,6 +1486,7 @@ mod tests { ahead_behind: Some((1, 0)), space: None, }], + cache_updates: Vec::new(), }); assert!(app.render_dirty.load(Ordering::Acquire)); @@ -2915,7 +2921,7 @@ mod tests { app.next_auto_update_check = Some(now + Duration::from_secs(6)); assert_eq!( - app.next_headless_loop_deadline(now, false), + app.next_headless_loop_deadline_with_git_refresh(now, false, true), app.session_save_deadline ); } @@ -2932,7 +2938,10 @@ mod tests { app.session_save_deadline = None; app.state.workspaces.clear(); - assert_eq!(app.next_headless_loop_deadline(now, false), None); + assert_eq!( + app.next_headless_loop_deadline_with_git_refresh(now, false, true), + None + ); } #[test] diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 62b803f3..ad90a267 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -8,7 +8,35 @@ use super::{ RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL, }; use crate::events::AppEvent; -use crate::workspace::{Workspace, WorkspaceGitStatus}; +use crate::workspace::{GitStatusCacheEntry, Workspace, WorkspaceGitStatus}; +use std::collections::HashMap; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct WorkspaceGitRefreshItem { + pub(crate) workspace_id: String, + pub(crate) resolved_identity_cwd: std::path::PathBuf, + pub(crate) cache_key: std::path::PathBuf, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct WorkspaceGitRefreshTarget { + pub(crate) workspace_id: String, + pub(crate) resolved_identity_cwd: std::path::PathBuf, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct WorkspaceGitRefreshJob { + pub(crate) cache_key: std::path::PathBuf, + pub(crate) status_cwd: std::path::PathBuf, + pub(crate) cached: Option<GitStatusCacheEntry>, + pub(crate) targets: Vec<WorkspaceGitRefreshTarget>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct WorkspaceGitRefreshOutput { + pub(crate) results: Vec<WorkspaceGitStatus>, + pub(crate) cache_updates: Vec<(std::path::PathBuf, GitStatusCacheEntry)>, +} impl App { pub(crate) fn shutdown_detached_terminal_runtimes(&mut self) { @@ -394,15 +422,7 @@ impl App { return; } - let workspaces: Vec<_> = self - .state - .workspaces - .iter() - .filter_map(|ws| { - ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) - .map(|cwd| (ws.id.clone(), cwd)) - }) - .collect(); + let workspaces = self.workspace_git_refresh_items(); if workspaces.is_empty() { self.last_git_remote_status_refresh = now; @@ -411,32 +431,43 @@ impl App { self.git_refresh_in_flight = true; let event_tx = self.event_tx.clone(); + let cache = self.git_status_cache.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 }); + let output = refresh_workspace_git_statuses_with_cache(workspaces, &cache); + let _ = event_tx.blocking_send(AppEvent::GitStatusRefreshed { + results: output.results, + cache_updates: output.cache_updates, + }); }); } + pub(crate) fn mark_git_status_refresh_due(&mut self, now: Instant) { + if self.git_refresh_in_flight { + self.git_refresh_due_after_in_flight = true; + return; + } + self.last_git_remote_status_refresh = now + .checked_sub(GIT_REMOTE_STATUS_REFRESH_INTERVAL) + .unwrap_or(now); + self.git_refresh_due_after_in_flight = false; + } + pub(crate) fn git_refresh_deadline(&self) -> Option<Instant> { (!self.git_refresh_in_flight && !self.state.workspaces.is_empty()) .then_some(self.last_git_remote_status_refresh + GIT_REMOTE_STATUS_REFRESH_INTERVAL) } pub(crate) fn next_loop_deadline(&self, now: Instant, needs_render: bool) -> Option<Instant> { - self.next_loop_deadline_with_resize_poll(now, needs_render, true) + self.next_loop_deadline_with_resize_poll(now, needs_render, true, true) } - pub(crate) fn next_headless_loop_deadline( + pub(crate) fn next_headless_loop_deadline_with_git_refresh( &self, now: Instant, needs_render: bool, + include_git_refresh: bool, ) -> Option<Instant> { - self.next_loop_deadline_with_resize_poll(now, needs_render, false) + self.next_loop_deadline_with_resize_poll(now, needs_render, false, include_git_refresh) } fn next_loop_deadline_with_resize_poll( @@ -444,6 +475,7 @@ impl App { now: Instant, needs_render: bool, include_resize_poll: bool, + include_git_refresh: bool, ) -> Option<Instant> { let render_deadline = if needs_render { self.last_render_at @@ -459,7 +491,9 @@ impl App { self.toast_deadline, self.copy_feedback_deadline, self.next_animation_tick, - self.git_refresh_deadline(), + include_git_refresh + .then(|| self.git_refresh_deadline()) + .flatten(), self.next_auto_update_check, self.agent_metadata_deadline, self.session_save_deadline, @@ -472,6 +506,24 @@ impl App { .min() } + fn workspace_git_refresh_items(&self) -> Vec<WorkspaceGitRefreshItem> { + self.state + .workspaces + .iter() + .filter_map(|ws| { + let cwd = + ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)?; + let git_key = crate::workspace::git_status_cache_key(&cwd); + let cache_key = git_key.unwrap_or_else(|| cwd.clone()); + Some(WorkspaceGitRefreshItem { + workspace_id: ws.id.clone(), + resolved_identity_cwd: cwd, + cache_key, + }) + }) + .collect() + } + pub(crate) fn drain_internal_events(&mut self) -> bool { self.drain_internal_events_up_to(super::APP_EVENT_DRAIN_LIMIT) } @@ -497,11 +549,69 @@ impl App { } } +pub(crate) fn deduplicate_git_refresh_items( + items: Vec<WorkspaceGitRefreshItem>, + cache: &HashMap<std::path::PathBuf, GitStatusCacheEntry>, +) -> Vec<WorkspaceGitRefreshJob> { + let mut indexes = HashMap::<std::path::PathBuf, usize>::new(); + let mut jobs = Vec::<WorkspaceGitRefreshJob>::new(); + + for item in items { + let target = WorkspaceGitRefreshTarget { + workspace_id: item.workspace_id, + resolved_identity_cwd: item.resolved_identity_cwd.clone(), + }; + if let Some(&index) = indexes.get(&item.cache_key) { + jobs[index].targets.push(target); + continue; + } + + let status_cwd = item.cache_key.clone(); + let cached = cache.get(&item.cache_key).cloned(); + indexes.insert(item.cache_key, jobs.len()); + jobs.push(WorkspaceGitRefreshJob { + cache_key: status_cwd.clone(), + status_cwd, + cached, + targets: vec![target], + }); + } + + jobs +} + +pub(crate) fn refresh_workspace_git_statuses_with_cache( + items: Vec<WorkspaceGitRefreshItem>, + cache: &HashMap<std::path::PathBuf, GitStatusCacheEntry>, +) -> WorkspaceGitRefreshOutput { + let mut results = Vec::new(); + let mut cache_updates = Vec::new(); + + for job in deduplicate_git_refresh_items(items, cache) { + let (snapshot, cache_entry) = + Workspace::git_status_snapshot_for_cwd_with_cache(&job.status_cwd, job.cached.as_ref()); + if let Some(cache_entry) = cache_entry { + cache_updates.push((job.cache_key.clone(), cache_entry)); + } + results.extend(job.targets.into_iter().map(move |target| { + snapshot + .clone() + .into_workspace_status(target.workspace_id, target.resolved_identity_cwd) + })); + } + + WorkspaceGitRefreshOutput { + results, + cache_updates, + } +} + #[cfg(test)] mod tests { use super::*; use crate::app::state; use crate::workspace::Workspace; + use std::path::PathBuf; fn test_app_with_pane() -> (super::super::App, crate::layout::PaneId) { let mut app = super::super::App::new( @@ -526,6 +636,131 @@ mod tests { } #[test] + fn git_refresh_deduplicates_workspaces_with_same_cache_key() { + let repo = + std::env::temp_dir().join(format!("herdr-git-refresh-dedupe-{}", std::process::id())); + let nested = repo.join("nested"); + let other = repo.join("other"); + std::fs::create_dir_all(&nested).expect("create nested dir"); + std::fs::create_dir_all(&other).expect("create other dir"); + std::process::Command::new("git") + .arg("-C") + .arg(&repo) + .arg("init") + .output() + .expect("run git init"); + + let output = refresh_workspace_git_statuses_with_cache( + vec![ + WorkspaceGitRefreshItem { + workspace_id: "one".into(), + resolved_identity_cwd: nested.clone(), + cache_key: repo.clone(), + }, + WorkspaceGitRefreshItem { + workspace_id: "two".into(), + resolved_identity_cwd: other.clone(), + cache_key: repo.clone(), + }, + ], + &HashMap::new(), + ); + + assert_eq!(output.cache_updates.len(), 1); + assert_eq!(output.cache_updates[0].0, repo); + assert_eq!(output.results.len(), 2); + assert_eq!(output.results[0].workspace_id, "one"); + assert_eq!( + output.results[0].resolved_identity_cwd, + PathBuf::from(&nested) + ); + assert_eq!(output.results[1].workspace_id, "two"); + assert_eq!( + output.results[1].resolved_identity_cwd, + PathBuf::from(&other) + ); + + let _ = std::fs::remove_dir_all(repo); + } + + #[test] + fn git_refresh_items_use_cwd_cache_key_for_non_git_cwd() { + let mut app = super::super::App::new( + &crate::config::Config::default(), + true, + None, + tokio::sync::mpsc::unbounded_channel().1, + crate::api::EventHub::default(), + ); + let cwd = std::env::temp_dir().join(format!("herdr-non-git-cwd-{}", std::process::id())); + std::fs::create_dir_all(&cwd).expect("create temp cwd"); + let mut ws = Workspace::test_new("test"); + ws.identity_cwd = cwd.clone(); + ws.tabs.clear(); + app.state.workspaces.push(ws); + + let items = app.workspace_git_refresh_items(); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].cache_key, cwd); + let _ = std::fs::remove_dir_all(&cwd); + } + + #[test] + fn headless_deadline_can_suppress_git_refresh_timer() { + let mut app = super::super::App::new( + &crate::config::Config::default(), + true, + None, + tokio::sync::mpsc::unbounded_channel().1, + crate::api::EventHub::default(), + ); + app.state.workspaces.push(Workspace::test_new("test")); + let now = Instant::now(); + app.last_git_remote_status_refresh = now - super::super::GIT_REMOTE_STATUS_REFRESH_INTERVAL; + + assert_eq!( + app.next_headless_loop_deadline_with_git_refresh(now, false, false), + None + ); + assert_eq!( + app.next_headless_loop_deadline_with_git_refresh(now, false, true), + Some(now) + ); + } + + #[test] + fn git_refresh_due_request_survives_in_flight_refresh() { + let mut app = super::super::App::new( + &crate::config::Config::default(), + true, + None, + tokio::sync::mpsc::unbounded_channel().1, + crate::api::EventHub::default(), + ); + let now = Instant::now(); + app.git_refresh_in_flight = true; + + app.mark_git_status_refresh_due(now); + assert!(app.git_refresh_due_after_in_flight); + + app.handle_internal_event(crate::events::AppEvent::GitStatusRefreshed { + results: Vec::new(), + cache_updates: Vec::new(), + }); + + assert!(!app.git_refresh_in_flight); + assert!(!app.git_refresh_due_after_in_flight); + assert_eq!(app.git_refresh_deadline(), None); + + app.state.workspaces.push(Workspace::test_new("test")); + let deadline = app + .git_refresh_deadline() + .expect("refresh should be due once a workspace exists"); + assert!(deadline <= Instant::now()); + } + + #[test] fn tick_selection_autoscroll_stops_when_metrics_unavailable() { // Without a runtime, pane_scroll_metrics returns None. // Fail-closed: stop autoscroll instead of rescheduling forever. |
