aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-07-25 04:26:55 +0300
committerGitHub <noreply@github.com>2026-07-25 04:26:55 +0300
commitd4e0dd3d903c50d2edb8c3cec71952a83989b310 (patch)
treec90ec43a08fab1fc79e1d6db865c723877a051af /src/app
parentb33e5f973fc89381aa4ac72b3a31fa087ec66e23 (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.rs65
-rw-r--r--src/app/api.rs58
-rw-r--r--src/app/git_refresh.rs480
-rw-r--r--src/app/mod.rs50
-rw-r--r--src/app/runtime.rs301
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.