diff --git a/Cargo.lock b/Cargo.lock index df99a38b..69c3f731 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2500,7 +2500,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ - "roxmltree 0.20.0", + "roxmltree", ] [[package]] @@ -3151,7 +3151,7 @@ dependencies = [ "raw-window-handle", "refineable", "regex", - "resvg 0.45.1", + "resvg", "scheduler", "schemars", "seahash", @@ -3167,7 +3167,7 @@ dependencies = [ "thiserror 2.0.18", "ttf-parser", "url", - "usvg 0.45.1", + "usvg", "util_macros", "uuid", "waker-fn", @@ -3209,7 +3209,7 @@ dependencies = [ "paste", "raw-window-handle", "regex", - "resvg 0.45.1", + "resvg", "ropey", "rust-i18n", "schemars", @@ -4056,12 +4056,6 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" -[[package]] -name = "imagesize" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" - [[package]] name = "imgref" version = "1.12.2" @@ -4494,18 +4488,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "kurbo" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" -dependencies = [ - "arrayvec", - "euclid", - "polycool", - "smallvec", -] - [[package]] name = "kv-log-macro" version = "1.0.7" @@ -6307,15 +6289,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "polycool" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" -dependencies = [ - "arrayvec", -] - [[package]] name = "polyval" version = "0.7.2" @@ -7084,26 +7057,12 @@ dependencies = [ "log", "pico-args", "rgb", - "svgtypes 0.15.3", - "tiny-skia 0.11.4", - "usvg 0.45.1", + "svgtypes", + "tiny-skia", + "usvg", "zune-jpeg 0.4.21", ] -[[package]] -name = "resvg" -version = "0.47.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" -dependencies = [ - "log", - "pico-args", - "rgb", - "svgtypes 0.16.1", - "tiny-skia 0.12.0", - "usvg 0.47.0", -] - [[package]] name = "rfc6979" version = "0.6.0" @@ -7158,15 +7117,6 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" -[[package]] -name = "roxmltree" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" -dependencies = [ - "memchr", -] - [[package]] name = "rsa" version = "0.10.0-rc.18" @@ -8512,17 +8462,7 @@ version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" dependencies = [ - "kurbo 0.11.3", - "siphasher", -] - -[[package]] -name = "svgtypes" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" -dependencies = [ - "kurbo 0.13.1", + "kurbo", "siphasher", ] @@ -8859,22 +8799,7 @@ dependencies = [ "cfg-if", "log", "png 0.17.16", - "tiny-skia-path 0.11.4", -] - -[[package]] -name = "tiny-skia" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" -dependencies = [ - "arrayref", - "arrayvec", - "bytemuck", - "cfg-if", - "log", - "png 0.18.1", - "tiny-skia-path 0.12.0", + "tiny-skia-path", ] [[package]] @@ -8888,17 +8813,6 @@ dependencies = [ "strict-num", ] -[[package]] -name = "tiny-skia-path" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" -dependencies = [ - "arrayref", - "bytemuck", - "strict-num", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -9292,13 +9206,13 @@ dependencies = [ "portable-pty", "regex", "reqwest_client", - "resvg 0.47.0", + "resvg", "russh", "russh-sftp", "serde", "serde_json", "serde_yaml", - "sha2 0.11.0", + "sha2 0.10.9", "smallvec", "smol", "tokio", @@ -9465,45 +9379,23 @@ dependencies = [ "data-url", "flate2", "fontdb", - "imagesize 0.13.0", - "kurbo 0.11.3", + "imagesize", + "kurbo", "log", "pico-args", - "roxmltree 0.20.0", + "roxmltree", "rustybuzz", "simplecss", "siphasher", "strict-num", - "svgtypes 0.15.3", - "tiny-skia-path 0.11.4", + "svgtypes", + "tiny-skia-path", "unicode-bidi", "unicode-script", "unicode-vo", "xmlwriter", ] -[[package]] -name = "usvg" -version = "0.47.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" -dependencies = [ - "base64", - "data-url", - "flate2", - "imagesize 0.14.0", - "kurbo 0.13.1", - "log", - "pico-args", - "roxmltree 0.21.1", - "simplecss", - "siphasher", - "strict-num", - "svgtypes 0.16.1", - "tiny-skia-path 0.12.0", - "xmlwriter", -] - [[package]] name = "utf-8" version = "0.7.6" diff --git a/src/terminal/git_status.rs b/src/terminal/git_status.rs index 8dd92b59..39d8d3ff 100644 --- a/src/terminal/git_status.rs +++ b/src/terminal/git_status.rs @@ -193,34 +193,70 @@ impl GitStatusCache { /// call — rather than by a rare edge like a command ending. /// /// Unlike [`begin_probe`](Self::begin_probe) this declines instead of - /// queueing: a probe already in flight, or one that landed less than - /// `min_interval` ago, drops the trigger entirely (no dirty mark, no + /// queueing: a probe already in flight, or one against a repo probed less + /// than `min_interval` ago, drops the trigger entirely (no dirty mark, no /// rerun). That's the whole point of the two entry points — the rare edges /// must never be missed, while these signals repeat on their own, so a /// count that's a second stale beats a `git` storm across every pane of a /// repo the moment the user alt-tabs back. + /// + /// The throttle counts per *repo*, not per cwd (see + /// [`throttle_key`](Self::throttle_key)), and the claim stamps the clock + /// rather than waiting for the landing: without that, a dozen panes + /// scattered over one repo's subdirectories would all claim in the same + /// instant — each of them passing a throttle no probe had answered yet — + /// and produce a dozen identical full-repo diffs. pub fn begin_probe_throttled(&mut self, cwd: &Path, min_interval: Duration) -> bool { if self.in_flight.contains(cwd) { return false; } + let key = self.throttle_key(cwd).to_path_buf(); if self .last_probe - .get(cwd) + .get(&key) .is_some_and(|at| at.elapsed() < min_interval) { return false; } + self.last_probe.insert(key, Instant::now()); self.in_flight.insert(cwd.to_path_buf()); true } + /// What the opportunistic throttle counts against: the work-tree root once + /// some probe has answered for `cwd`, and `cwd` itself before that. + /// + /// The counts a probe produces are repo-wide — `git diff --numstat HEAD` + /// ignores which subdirectory it ran in — so panes at `repo/`, `repo/src` + /// and `repo/docs` are three ways of asking one question, and want one + /// shared clock rather than one each. In-flight dedup stays keyed by cwd: + /// it brackets a specific spawn, and [`finish_probe`](Self::finish_probe) + /// has to be able to release exactly what was claimed. + /// + /// Before any probe has landed the root is simply unknown, so the first + /// sweep over a repo still costs one probe per distinct cwd; every sweep + /// after that collapses to one. + fn throttle_key<'a>(&'a self, cwd: &'a Path) -> &'a Path { + match self.roots.get(cwd) { + Some(Some(root)) => root, + _ => cwd, + } + } + /// Fold a landed probe for `cwd` into the cache. A failed diff inside a /// live repo keeps the root's previous counts (a transient `git` error is /// not "the tree went clean"). Returns whether the cwd was re-triggered /// while this probe flew — the caller should start one more probe. pub fn finish_probe(&mut self, cwd: &Path, snapshot: Option) -> bool { self.in_flight.remove(cwd); - self.last_probe.insert(cwd.to_path_buf(), Instant::now()); + // Re-stamp on landing so the gap is measured from fresh counts, and + // under the root this probe just resolved — which is how a cwd first + // learns to share its repo's clock (at claim time it had none). + let key = match &snapshot { + Some(snap) => snap.root.clone(), + None => self.throttle_key(cwd).to_path_buf(), + }; + self.last_probe.insert(key, Instant::now()); match snapshot { Some(snap) => { let (added, removed) = snap.counts.unwrap_or_else(|| { @@ -516,4 +552,41 @@ mod tests { assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((1, 0)))))); assert!(cache.begin_probe(cwd)); } + + /// The throttle is per repo, not per cwd: panes sitting in different + /// subdirectories ask one question (the counts are repo-wide), so once the + /// cache knows where they live, a window activation costs one probe for + /// the repo rather than one per pane. + #[test] + fn throttle_collapses_subdirectories_of_one_repo() { + let mut cache = GitStatusCache::default(); + let (top, src, docs) = ( + Path::new("/repo"), + Path::new("/repo/src"), + Path::new("/repo/docs"), + ); + let gap = Duration::from_secs(60); + + // Nothing known yet, so each cwd is its own key and each gets a probe. + for cwd in [top, src, docs] { + assert!(cache.begin_probe_throttled(cwd, gap)); + assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((3, 1)))))); + } + + // Now all three resolve to `/repo`, so the next sweep collapses: the + // first pane to ask spends the probe and the rest ride on it. + assert!(!cache.begin_probe_throttled(top, gap)); + assert!(!cache.begin_probe_throttled(src, gap)); + + // …and the claim itself is what stops the stampede — with the clock + // wound back far enough to let one through, the *others* still decline + // while it is in flight, even though nothing has landed yet. + assert!(cache.begin_probe_throttled(docs, Duration::ZERO)); + assert!(!cache.begin_probe_throttled(top, gap)); + assert!(!cache.begin_probe_throttled(src, gap)); + + // A pane elsewhere is untouched by any of it. + let other = Path::new("/other"); + assert!(cache.begin_probe_throttled(other, gap)); + } } diff --git a/src/ui/app.rs b/src/ui/app.rs index a2d21aa9..14b86051 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -2083,8 +2083,11 @@ impl Tty7App { /// right after switching back. /// /// Panes sharing a cwd fold into one probe in the shared cache, and the - /// throttle there drops anything probed in the last moment, so the cost of - /// a window with many panes is bounded by the number of distinct repos. + /// throttle there counts per repo rather than per cwd, so once the cache + /// knows where each pane lives the cost of a window with many panes is + /// bounded by the number of distinct repos — not by the number of + /// subdirectories they happen to sit in, which would be the same full-repo + /// `git diff` asked several times over. fn refresh_git_status_all(&mut self, cx: &mut Context) { for leaf in self.tabs.iter().flat_map(|tab| tab.pane.leaves()) { leaf.update(cx, |view, cx| view.refresh_git_status_now(cx));