diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-07-25 04:26:55 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-25 04:26:55 +0300 |
| commit | d4e0dd3d903c50d2edb8c3cec71952a83989b310 (patch) | |
| tree | c90ec43a08fab1fc79e1d6db865c723877a051af /src/app | |
| parent | b33e5f973fc89381aa4ac72b3a31fa087ec66e23 (diff) | |
perf: eliminate repeated workspace git discovery (#1842)
* perf: eliminate repeated workspace git discovery
refs #1838
* fix: keep workspace labels current without extra redraws
refs #1838
* fix: retain discovered workspace git metadata
refs #1838
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 65 | ||||
| -rw-r--r-- | src/app/api.rs | 58 | ||||
| -rw-r--r-- | src/app/git_refresh.rs | 480 | ||||
| -rw-r--r-- | src/app/mod.rs | 50 | ||||
| -rw-r--r-- | src/app/runtime.rs | 301 |
5 files changed, 640 insertions, 314 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index d3b85c07..0709ed4c 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -2607,11 +2607,21 @@ impl AppState { } let ws = &mut self.workspaces[ws_idx]; - if ws.cached_git_branch != result.branch { + if ws.cached_identity_cwd != result.resolved_identity_cwd { + ws.cached_identity_cwd = result.resolved_identity_cwd; + } + if ws.cached_auto_label != result.auto_label { + ws.cached_auto_label = result.auto_label; + changed |= ws.custom_name.is_none(); + } + if ws.cached_git_status_key != result.status_cache_key { + ws.cached_git_status_key = result.status_cache_key; + } + if result.demand.branch && ws.cached_git_branch != result.branch { ws.cached_git_branch = result.branch; changed = true; } - if ws.cached_git_ahead_behind != result.ahead_behind { + if result.demand.ahead_behind && ws.cached_git_ahead_behind != result.ahead_behind { ws.cached_git_ahead_behind = result.ahead_behind; changed = true; } @@ -3103,7 +3113,8 @@ impl AppState { let sound = sound_for_toast_kind(kind, suppress_active_tab_notifications) .filter(|_| self.sound.allows(known_agent)); let build_toast = || { - let workspace_label = self.workspaces[ws_idx].display_name(); + let workspace_label = + self.workspaces[ws_idx].display_name_from_terminals(&self.terminals); let context = notification_context(&self.workspaces[ws_idx], &workspace_label, ws_idx, pane_id); ToastNotification { @@ -3904,7 +3915,10 @@ mod tests { &terminal_runtimes, vec![WorkspaceGitStatus { workspace_id: first_id, - resolved_identity_cwd: first_cwd, + resolved_identity_cwd: first_cwd.clone(), + status_cache_key: first_cwd, + demand: crate::workspace::GitStatusRefreshDemand::ALL, + auto_label: "one".into(), branch: Some("main".into()), ahead_behind: Some((2, 1)), space: None, @@ -3931,6 +3945,9 @@ mod tests { vec![WorkspaceGitStatus { workspace_id, resolved_identity_cwd: std::path::PathBuf::from("/definitely/not/current"), + status_cache_key: std::path::PathBuf::from("/definitely/not/current"), + demand: crate::workspace::GitStatusRefreshDemand::ALL, + auto_label: "stale".into(), branch: Some("main".into()), ahead_behind: Some((0, 1)), space: None, @@ -3943,6 +3960,36 @@ mod tests { } #[test] + fn apply_workspace_git_statuses_ignores_unrequested_branch_changes() { + 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_auto_label = "one".into(); + state.workspaces[0].cached_git_branch = Some("old".into()); + + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + let changed = state.apply_workspace_git_statuses( + &terminal_runtimes, + vec![WorkspaceGitStatus { + workspace_id, + resolved_identity_cwd: cwd.clone(), + status_cache_key: cwd, + demand: crate::workspace::GitStatusRefreshDemand { + branch: false, + ahead_behind: true, + }, + auto_label: "one".into(), + branch: Some("new".into()), + ahead_behind: None, + space: None, + }], + ); + + assert!(!changed); + assert_eq!(state.workspaces[0].branch().as_deref(), Some("old")); + } + + #[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(); @@ -3955,7 +4002,10 @@ mod tests { &terminal_runtimes, vec![WorkspaceGitStatus { workspace_id, - resolved_identity_cwd: cwd, + resolved_identity_cwd: cwd.clone(), + status_cache_key: cwd, + demand: crate::workspace::GitStatusRefreshDemand::ALL, + auto_label: "one".into(), branch: None, ahead_behind: None, space: None, @@ -3980,7 +4030,10 @@ mod tests { &terminal_runtimes, vec![WorkspaceGitStatus { workspace_id, - resolved_identity_cwd: cwd, + resolved_identity_cwd: cwd.clone(), + status_cache_key: cwd, + demand: crate::workspace::GitStatusRefreshDemand::ALL, + auto_label: "other".into(), branch: Some("scratch".into()), ahead_behind: None, space: Some(crate::workspace::GitSpaceMetadata { diff --git a/src/app/api.rs b/src/app/api.rs index 02a61067..37b1f455 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -59,6 +59,44 @@ impl App { response_rx.try_recv().ok() } + pub(crate) fn handle_internal_event_with_render_impact(&mut self, ev: AppEvent) -> bool { + match ev { + AppEvent::GitStatusRefreshed { + results, + cache_updates, + } => self.handle_git_status_refreshed(results, cache_updates), + ev => { + self.handle_internal_event(ev); + true + } + } + } + + fn handle_git_status_refreshed( + &mut self, + results: Vec<crate::workspace::WorkspaceGitStatus>, + cache_updates: Vec<(std::path::PathBuf, crate::workspace::GitStatusCacheEntry)>, + ) -> bool { + self.git_refresh_in_flight = false; + 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(); + } + let changed = self + .state + .apply_workspace_git_statuses(&self.terminal_runtimes, results); + if changed { + self.render_dirty.store(true, Ordering::Release); + self.render_notify.notify_one(); + } + changed + } + pub(crate) fn handle_internal_event(&mut self, ev: AppEvent) { if let AppEvent::ClipboardWrite { content } = ev { #[cfg(not(test))] @@ -90,23 +128,7 @@ impl App { cache_updates, } = ev { - self.git_refresh_in_flight = false; - 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) - { - self.render_dirty.store(true, Ordering::Release); - self.render_notify.notify_one(); - } + self.handle_git_status_refreshed(results, cache_updates); return; } @@ -270,7 +292,7 @@ impl App { } self.sync_full_lifecycle_authority_detection_pauses(); if terminal_cwd_reported { - self.mark_git_status_refresh_due(Instant::now()); + self.request_git_identity_refresh(Instant::now()); self.render_dirty.store(true, Ordering::Release); self.render_notify.notify_one(); } diff --git a/src/app/git_refresh.rs b/src/app/git_refresh.rs new file mode 100644 index 00000000..a868a0ed --- /dev/null +++ b/src/app/git_refresh.rs @@ -0,0 +1,480 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Instant; + +use super::{App, GIT_REMOTE_STATUS_REFRESH_INTERVAL, GIT_REPO_DISCOVERY_REFRESH_INTERVAL}; +use crate::events::AppEvent; +use crate::workspace::{GitStatusCacheEntry, GitStatusRefreshDemand, WorkspaceGitStatus}; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct WorkspaceGitRefreshItem { + workspace_id: String, + resolved_identity_cwd: PathBuf, + cache_key_hint: Option<PathBuf>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct WorkspaceGitRefreshTarget { + workspace_id: String, + resolved_identity_cwd: PathBuf, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct WorkspaceGitRefreshJob { + cache_key: PathBuf, + cached: Option<GitStatusCacheEntry>, + targets: Vec<WorkspaceGitRefreshTarget>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct WorkspaceGitRefreshOutput { + results: Vec<WorkspaceGitStatus>, + cache_updates: Vec<(PathBuf, GitStatusCacheEntry)>, +} + +impl App { + pub(crate) fn start_git_status_refresh_if_due(&mut self, now: Instant) { + let Some(deadline) = self.git_refresh_deadline() else { + return; + }; + + if now < deadline { + return; + } + + let refresh_repo_discovery = self.git_identity_refresh_requested + || now.saturating_duration_since(self.last_git_repo_discovery_refresh) + >= GIT_REPO_DISCOVERY_REFRESH_INTERVAL; + let workspaces = self.workspace_git_refresh_items(refresh_repo_discovery); + + if workspaces.is_empty() { + self.last_git_remote_status_refresh = now; + self.git_identity_refresh_requested = false; + return; + } + + self.git_refresh_in_flight = true; + let event_tx = self.event_tx.clone(); + let cache = self.git_status_cache.clone(); + let mut demand = self.git_refresh_demand(); + if self.git_identity_refresh_requested { + demand.branch = true; + } + self.git_identity_refresh_requested = false; + if refresh_repo_discovery { + self.last_git_repo_discovery_refresh = now; + } + std::thread::spawn(move || { + let output = + refresh_workspace_git_statuses_with_cache_and_demand(workspaces, &cache, demand); + let _ = event_tx.blocking_send(AppEvent::GitStatusRefreshed { + results: output.results, + cache_updates: output.cache_updates, + }); + }); + } + + pub(crate) fn request_git_identity_refresh(&mut self, now: Instant) { + self.git_identity_refresh_requested = true; + self.mark_git_status_refresh_due(now); + } + + pub(crate) fn mark_git_status_refresh_due(&mut self, now: Instant) { + self.git_status_cache + .retain(|_, entry| entry.fingerprint.is_some()); + 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() + && (self.git_identity_refresh_requested || !self.git_refresh_demand().is_empty())) + .then_some(self.last_git_remote_status_refresh + GIT_REMOTE_STATUS_REFRESH_INTERVAL) + } + + fn git_refresh_demand(&self) -> GitStatusRefreshDemand { + let mut demand = GitStatusRefreshDemand::default(); + for token in self.state.sidebar_spaces.rows.iter().flatten() { + match token.parts().0 { + crate::config::SpaceSidebarToken::Branch => demand.branch = true, + crate::config::SpaceSidebarToken::GitStatus => demand.ahead_behind = true, + _ => {} + } + } + demand + } + + fn workspace_git_refresh_items( + &self, + refresh_repo_discovery: bool, + ) -> Vec<WorkspaceGitRefreshItem> { + self.state + .workspaces + .iter() + .filter_map(|ws| { + let cwd = + ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)?; + let cache_key_hint = (!refresh_repo_discovery && ws.cached_identity_cwd == cwd) + .then(|| ws.cached_git_status_key.clone()); + Some(WorkspaceGitRefreshItem { + workspace_id: ws.id.clone(), + resolved_identity_cwd: cwd, + cache_key_hint, + }) + }) + .collect() + } +} + +fn deduplicate_git_refresh_items( + items: Vec<WorkspaceGitRefreshItem>, + cache: &HashMap<PathBuf, GitStatusCacheEntry>, +) -> Vec<WorkspaceGitRefreshJob> { + let mut indexes = HashMap::<PathBuf, usize>::new(); + let mut jobs = Vec::<WorkspaceGitRefreshJob>::new(); + + for item in items { + let cache_key = item.cache_key_hint.unwrap_or_else(|| { + crate::workspace::git_status_cache_key(&item.resolved_identity_cwd) + .unwrap_or_else(|| item.resolved_identity_cwd.clone()) + }); + let target = WorkspaceGitRefreshTarget { + workspace_id: item.workspace_id, + resolved_identity_cwd: item.resolved_identity_cwd, + }; + if let Some(&index) = indexes.get(&cache_key) { + jobs[index].targets.push(target); + continue; + } + + let cached = cache.get(&cache_key).cloned(); + indexes.insert(cache_key.clone(), jobs.len()); + jobs.push(WorkspaceGitRefreshJob { + cache_key, + cached, + targets: vec![target], + }); + } + + jobs +} + +fn refresh_workspace_git_statuses_with_cache_and_demand( + items: Vec<WorkspaceGitRefreshItem>, + cache: &HashMap<PathBuf, GitStatusCacheEntry>, + demand: GitStatusRefreshDemand, +) -> 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) = crate::workspace::git_status_snapshot_for_cwd_with_demand( + &job.cache_key, + job.cached.as_ref(), + demand, + ); + 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, + job.cache_key.clone(), + demand, + ) + })); + } + + WorkspaceGitRefreshOutput { + results, + cache_updates, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::workspace::Workspace; + + #[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_and_demand( + vec![ + WorkspaceGitRefreshItem { + workspace_id: "one".into(), + resolved_identity_cwd: nested.clone(), + cache_key_hint: None, + }, + WorkspaceGitRefreshItem { + workspace_id: "two".into(), + resolved_identity_cwd: other.clone(), + cache_key_hint: None, + }, + ], + &HashMap::new(), + GitStatusRefreshDemand::ALL, + ); + + assert_eq!(output.cache_updates.len(), 1); + assert_eq!( + output.cache_updates[0].0, + std::fs::canonicalize(&repo).expect("canonical repo path") + ); + assert_eq!(output.results.len(), 2); + assert_eq!(output.results[0].workspace_id, "one"); + assert_eq!(output.results[0].resolved_identity_cwd, nested); + assert_eq!(output.results[1].workspace_id, "two"); + assert_eq!(output.results[1].resolved_identity_cwd, other); + + let _ = std::fs::remove_dir_all(repo); + } + + #[test] + fn git_refresh_item_collection_does_not_discover_uncached_cwd() { + let mut app = test_app(&crate::config::Config::default()); + let cwd = std::env::temp_dir().join(format!("herdr-uncached-cwd-{}", std::process::id())); + 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(false); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].resolved_identity_cwd, cwd); + assert_eq!(items[0].cache_key_hint, None); + } + + #[test] + fn git_refresh_item_collection_reuses_matching_cached_key() { + let mut app = test_app(&crate::config::Config::default()); + let cwd = PathBuf::from("/repo/deep/nested"); + let cache_key = PathBuf::from("/repo"); + let mut ws = Workspace::test_new("test"); + ws.identity_cwd = cwd.clone(); + ws.cached_identity_cwd = cwd; + ws.cached_git_status_key = cache_key.clone(); + ws.tabs.clear(); + app.state.workspaces.push(ws); + + let items = app.workspace_git_refresh_items(false); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].cache_key_hint, Some(cache_key)); + } + + #[test] + fn periodic_repo_discovery_ignores_cached_key_hints() { + let mut app = test_app(&crate::config::Config::default()); + let cwd = PathBuf::from("/repo/deep/nested"); + let mut ws = Workspace::test_new("test"); + ws.identity_cwd = cwd.clone(); + ws.cached_identity_cwd = cwd; + ws.cached_git_status_key = PathBuf::from("/repo"); + ws.tabs.clear(); + app.state.workspaces.push(ws); + + let items = app.workspace_git_refresh_items(true); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].cache_key_hint, None); + } + + #[test] + fn cwd_identity_refresh_runs_once_without_sidebar_git_tokens() { + let mut config = crate::config::Config::default(); + config.ui.sidebar.spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; + let mut app = test_app(&config); + app.state.workspaces.push(Workspace::test_new("test")); + let now = Instant::now(); + + app.request_git_identity_refresh(now); + + assert!(app.git_refresh_deadline().is_some()); + app.start_git_status_refresh_if_due(now); + assert!(app.git_refresh_in_flight); + assert!(!app.git_identity_refresh_requested); + } + + #[test] + fn due_git_refresh_does_not_start_without_sidebar_consumer() { + let mut config = crate::config::Config::default(); + config.ui.sidebar.spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; + let mut app = test_app(&config); + app.state.workspaces.push(Workspace::test_new("test")); + let now = Instant::now(); + app.last_git_remote_status_refresh = now - GIT_REMOTE_STATUS_REFRESH_INTERVAL; + + app.start_git_status_refresh_if_due(now); + + assert!(!app.git_refresh_in_flight); + assert!(app.event_rx.try_recv().is_err()); + } + + #[test] + fn git_refresh_demand_matches_sidebar_rows() { + let cases = [ + ( + crate::config::SpaceSidebarToken::Workspace, + GitStatusRefreshDemand::default(), + ), + ( + crate::config::SpaceSidebarToken::Branch, + GitStatusRefreshDemand { + branch: true, + ahead_behind: false, + }, + ), + ( + crate::config::SpaceSidebarToken::GitStatus, + GitStatusRefreshDemand { + branch: false, + ahead_behind: true, + }, + ), + ]; + + for (token, expected) in cases { + let mut config = crate::config::Config::default(); + config.ui.sidebar.spaces.rows = vec![vec![token.clone()]]; + let mut app = test_app(&config); + app.state.workspaces.push(Workspace::test_new("test")); + + assert_eq!(app.git_refresh_demand(), expected, "token: {token:?}"); + assert_eq!( + app.git_refresh_deadline().is_some(), + !expected.is_empty(), + "token: {token:?}" + ); + } + } + + #[test] + fn unnamed_linked_worktree_does_not_force_periodic_branch_refresh() { + let mut config = crate::config::Config::default(); + config.ui.sidebar.spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; + let mut app = test_app(&config); + let mut child = Workspace::test_new("test"); + child.custom_name = None; + child.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo".into(), + label: "repo".into(), + repo_root: "/repo".into(), + checkout_path: "/repo-worktree".into(), + is_linked_worktree: true, + }); + app.state.workspaces.push(child); + + assert_eq!(app.git_refresh_deadline(), None); + } + + #[test] + fn custom_named_linked_worktree_does_not_require_branch_refresh() { + let mut config = crate::config::Config::default(); + config.ui.sidebar.spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; + let mut app = test_app(&config); + let mut child = Workspace::test_new("custom"); + child.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo".into(), + label: "repo".into(), + repo_root: "/repo".into(), + checkout_path: "/repo-worktree".into(), + is_linked_worktree: true, + }); + app.state.workspaces.push(child); + + assert_eq!(app.git_refresh_deadline(), None); + } + + #[test] + fn headless_deadline_can_suppress_git_refresh_timer() { + let mut app = test_app(&crate::config::Config::default()); + app.state.workspaces.push(Workspace::test_new("test")); + let now = Instant::now(); + app.last_git_remote_status_refresh = now - 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 explicit_git_refresh_invalidates_cached_non_git_results() { + let mut app = test_app(&crate::config::Config::default()); + let cwd = std::env::temp_dir().join(format!("herdr-git-miss-{}", std::process::id())); + std::fs::create_dir_all(&cwd).unwrap(); + let (_, entry) = crate::workspace::git_status_snapshot_for_cwd_with_demand( + &cwd, + None, + GitStatusRefreshDemand::ALL, + ); + app.git_status_cache + .insert(cwd.clone(), entry.expect("non-Git cache entry")); + + app.mark_git_status_refresh_due(Instant::now()); + + assert!(app.git_status_cache.is_empty()); + std::fs::remove_dir_all(cwd).unwrap(); + } + + #[test] + fn git_refresh_due_request_survives_in_flight_refresh() { + let mut app = test_app(&crate::config::Config::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(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()); + } + + fn test_app(config: &crate::config::Config) -> super::super::App { + super::super::App::new( + config, + true, + None, + tokio::sync::mpsc::unbounded_channel().1, + crate::api::EventHub::default(), + ) + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 84f6d917..c2d479b3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -12,6 +12,7 @@ mod api; mod api_helpers; mod config_io; mod creation; +mod git_refresh; mod ids; mod input; mod popup; @@ -38,6 +39,7 @@ pub(crate) const HEADLESS_ANIMATION_TICK_STEP: u32 = 8; pub(crate) const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(30); const RESIZE_POLL_INTERVAL: Duration = Duration::from_millis(100); const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); +const GIT_REPO_DISCOVERY_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60); const PENDING_AGENT_RESUME_THEME_WAIT: Duration = Duration::from_millis(750); const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_secs(5); @@ -110,8 +112,10 @@ pub struct App { pub(crate) copy_feedback_deadline: Option<Instant>, pub(crate) last_api_notification_at: Option<Instant>, pub(crate) last_git_remote_status_refresh: Instant, + pub(crate) last_git_repo_discovery_refresh: Instant, pub(crate) git_refresh_in_flight: bool, pub(crate) git_refresh_due_after_in_flight: bool, + pub(crate) git_identity_refresh_requested: bool, pub(crate) git_status_cache: HashMap<std::path::PathBuf, crate::workspace::GitStatusCacheEntry>, pub(crate) pending_api_worktree_creates: HashMap<std::path::PathBuf, u64>, pub(crate) pending_api_worktree_removes: HashMap<String, u64>, @@ -736,8 +740,10 @@ impl App { event_tx, event_rx, last_git_remote_status_refresh: Instant::now() - GIT_REMOTE_STATUS_REFRESH_INTERVAL, + last_git_repo_discovery_refresh: Instant::now(), git_refresh_in_flight: false, git_refresh_due_after_in_flight: false, + git_identity_refresh_requested: false, git_status_cache: HashMap::new(), pending_api_worktree_creates: HashMap::new(), pending_api_worktree_removes: HashMap::new(), @@ -892,10 +898,11 @@ impl App { pub(crate) fn handle_internal_event_with_prefix_sync( &mut self, event: crate::events::AppEvent, - ) { + ) -> bool { let previous_mode = self.state.mode; - self.handle_internal_event(event); + let changed = self.handle_internal_event_with_render_impact(event); self.sync_prefix_input_source(previous_mode); + changed } #[cfg(test)] @@ -1126,8 +1133,9 @@ impl App { match event { LoopEvent::Timer => {} LoopEvent::Internal(ev) => { - self.handle_internal_event_with_prefix_sync(ev); - needs_render = true; + if self.handle_internal_event_with_prefix_sync(ev) { + needs_render = true; + } } LoopEvent::Api(msg) => { if self.handle_api_request_message(*msg) { @@ -2131,6 +2139,20 @@ mod tests { } #[test] + fn unchanged_git_status_event_has_no_render_impact() { + let mut app = test_app(); + app.git_refresh_in_flight = true; + + let changed = app.handle_internal_event_with_prefix_sync(AppEvent::GitStatusRefreshed { + results: Vec::new(), + cache_updates: Vec::new(), + }); + + assert!(!changed); + assert!(!app.git_refresh_in_flight); + } + + #[test] fn git_status_event_clears_in_flight_refresh() { let mut app = test_app(); app.git_refresh_in_flight = true; @@ -2157,7 +2179,10 @@ mod tests { app.handle_internal_event(AppEvent::GitStatusRefreshed { results: vec![crate::workspace::WorkspaceGitStatus { workspace_id, - resolved_identity_cwd, + resolved_identity_cwd: resolved_identity_cwd.clone(), + status_cache_key: resolved_identity_cwd, + demand: crate::workspace::GitStatusRefreshDemand::ALL, + auto_label: "one".into(), branch: Some("render-dirty-test".into()), ahead_behind: Some((1, 0)), space: None, @@ -2388,6 +2413,21 @@ mod tests { } #[test] + fn unchanged_git_status_drain_has_no_render_impact() { + let mut app = test_app(); + app.git_refresh_in_flight = true; + app.event_tx + .try_send(AppEvent::GitStatusRefreshed { + results: Vec::new(), + cache_updates: Vec::new(), + }) + .unwrap(); + + assert!(!app.drain_internal_events()); + assert!(!app.git_refresh_in_flight); + } + + #[test] fn internal_event_drain_limits_work_per_tick() { let mut app = test_app(); for i in 0..=APP_EVENT_DRAIN_LIMIT { diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 428cd35b..180c3cef 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -4,40 +4,9 @@ use crossterm::terminal; use super::{ background_update_check_enabled, pressed_key_identity, App, ANIMATION_INTERVAL, - AUTO_UPDATE_CHECK_INTERVAL, GIT_REMOTE_STATUS_REFRESH_INTERVAL, MIN_RENDER_INTERVAL, - RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL, + AUTO_UPDATE_CHECK_INTERVAL, MIN_RENDER_INTERVAL, RESIZE_POLL_INTERVAL, + SELECTION_AUTOSCROLL_INTERVAL, }; -use crate::events::AppEvent; -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)>, -} - fn retain_custom_command_after_wait( pid: u32, result: std::io::Result<Option<std::process::ExitStatus>>, @@ -562,50 +531,6 @@ impl App { std::thread::spawn(move || crate::detect::manifest_update::auto_update(manifest_update_tx)); } - pub(crate) fn start_git_status_refresh_if_due(&mut self, now: Instant) { - let Some(deadline) = self.git_refresh_deadline() else { - return; - }; - - if now < deadline { - return; - } - - let workspaces = self.workspace_git_refresh_items(); - - 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(); - let cache = self.git_status_cache.clone(); - std::thread::spawn(move || { - 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, true) } @@ -659,103 +584,35 @@ 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) + .1 } pub(crate) fn drain_all_internal_events(&mut self) -> bool { - let mut had_event = false; - while self.drain_internal_events_up_to(super::APP_EVENT_DRAIN_LIMIT) { - had_event = true; + let mut changed = false; + loop { + let (had_event, batch_changed) = + self.drain_internal_events_up_to(super::APP_EVENT_DRAIN_LIMIT); + changed |= batch_changed; + if !had_event { + break; + } } - had_event + changed } - fn drain_internal_events_up_to(&mut self, limit: usize) -> bool { + fn drain_internal_events_up_to(&mut self, limit: usize) -> (bool, bool) { let mut had_event = false; + let mut changed = false; for _ in 0..limit { let Ok(ev) = self.event_rx.try_recv() else { break; }; had_event = true; - self.handle_internal_event_with_prefix_sync(ev); + changed |= self.handle_internal_event_with_prefix_sync(ev); } - had_event - } -} - -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, + (had_event, changed) } } @@ -764,7 +621,6 @@ mod tests { use super::*; use crate::app::state; use crate::workspace::Workspace; - use std::path::PathBuf; #[test] fn interrupted_custom_command_wait_keeps_child_for_retry() { @@ -797,131 +653,6 @@ 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. |
