diff --git a/Cargo.lock b/Cargo.lock index 25f7a7cd..d7fc48d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -512,6 +512,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -1650,6 +1659,7 @@ dependencies = [ "indoc", "libc", "log", + "notify", "nucleo", "once_cell", "open", @@ -1810,6 +1820,26 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inotify" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -1895,6 +1925,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "kstring" version = "2.0.2" @@ -2018,6 +2068,33 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags", +] + [[package]] name = "nucleo" version = "0.5.0" @@ -3216,6 +3293,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.2", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index 2f3809da..b3f83c90 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -52,6 +52,7 @@ helix-loader = { path = "../helix-loader" } anyhow = "1" once_cell = "1.21" +notify = "8" tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot"] } tui = { path = "../helix-tui", package = "helix-tui", default-features = false, features = ["termina", "crossterm"] } diff --git a/helix-term/src/ui/filetree.rs b/helix-term/src/ui/filetree.rs index 036d0ad0..8e00adeb 100644 --- a/helix-term/src/ui/filetree.rs +++ b/helix-term/src/ui/filetree.rs @@ -24,6 +24,7 @@ mod context_menu; pub mod vcs; +mod watcher; use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -223,8 +224,43 @@ pub struct Filetree { /// handle becomes visually present and the drag-to-resize affordance is /// discoverable. hovered_handle: bool, + /// Recursive filesystem watcher rooted at the panel root, or `None` + /// when no root is set yet or the OS refused to register a watcher + /// (capacity limits on Linux, etc.). Created/recreated lazily in + /// render whenever the root changes; dropped automatically with the + /// component when the panel is hidden. + fs_watcher: Option, + /// Root that [`watcher::FsWatcher::new`] previously rejected (typically + /// because the OS ran out of inotify watches on a large monorepo). + /// `ensure_watcher` consults this to avoid the retry storm that would + /// otherwise fire — re-attempting watch creation every render walks the + /// entire workspace tree from scratch, allocates partial OS resources, + /// fails partway through, and logs. That loop ran at ~22 attempts per + /// second on a Rust workspace whose `target/` alone exceeded the + /// kqueue fd budget, which alone made every render stall and starved + /// the terminal-event drain for seconds. Cleared whenever the panel + /// root changes so a future `cd` into a smaller workspace recovers. + fs_watcher_failed: Option, + /// Wall-clock instant of the most recent VCS refresh we kicked. Used + /// as a debounce gate: even if the FS watcher's flag is set on every + /// render (cargo writing to `target/` during a long build, `git + /// checkout` blasting through thousands of files), we kick at most + /// one fresh git scan per [`VCS_REFRESH_DEBOUNCE`]. Without this, + /// every mouse-move transition during ongoing FS activity spawns a + /// new spawn_blocking task — they pile up, each does `cache.clear()` + /// + per-file inserts, and they all contend on the cache Mutex with + /// the render path. + last_vcs_refresh: Option, } +/// Minimum interval between successive VCS refreshes kicked from the +/// FS-watcher path. Git scans of even a modest repo take ~50–500ms; piling +/// them up during a `cargo build` (which produces a steady stream of FS +/// events under `target/`) makes every concurrent mouse-move stutter while +/// the bg writers fight for the cache lock. 500ms is well above typical +/// scan duration so successive refreshes don't overlap. +const VCS_REFRESH_DEBOUNCE: Duration = Duration::from_millis(500); + impl Default for Filetree { fn default() -> Self { Self { @@ -239,6 +275,9 @@ impl Default for Filetree { drag_state: DragState::None, hovered_row: None, hovered_handle: false, + fs_watcher: None, + fs_watcher_failed: None, + last_vcs_refresh: None, } } } @@ -271,6 +310,63 @@ impl Filetree { self.dirty = true; } + /// Ensure the filesystem watcher is alive and rooted at the right + /// directory. Called from render every frame, so the decision is + /// factored out into the pure [`watcher_action`] so the four-way + /// decision matrix is testable without spinning real OS resources. + /// This method just dispatches the action. + /// + /// The dispatcher handles its own state updates: [`WatcherAction::Release`] + /// drops both the live watcher and the failed-root marker, and + /// [`WatcherAction::Create`] either records the new watcher (clearing + /// the failed marker) or records the failure (so a subsequent render + /// short-circuits via the failed-root branch in `watcher_action`). + /// + /// The failed-root branch is what stops the retry storm that would + /// otherwise occur when watch creation fails partway through: + /// without it, every render reattempts watch creation, walks the + /// entire workspace tree, partially exhausts OS resources, fails, + /// and logs — at the editor's render cadence. That's what made the + /// panel feel laggy on a Rust workspace whose `target/` alone + /// exceeded the (then-kqueue) fd budget on macOS. + fn ensure_watcher(&mut self, root: Option<&Path>) { + let have = self.fs_watcher.as_ref().map(|w| w.root.as_path()); + let failed = self.fs_watcher_failed.as_deref(); + match watcher::watcher_action(root, have, failed) { + watcher::WatcherAction::NoOp => {} + watcher::WatcherAction::Release => { + self.fs_watcher = None; + self.fs_watcher_failed = None; + } + watcher::WatcherAction::Create => { + // Only the `Some(want)` branch of `watcher_action` returns + // `Create`, so this unwrap is enforced by that function. + let want = root.expect("watcher_action returned Create with no want root"); + match watcher::FsWatcher::new(want.to_path_buf()) { + Ok(w) => { + self.fs_watcher = Some(w); + self.fs_watcher_failed = None; + } + Err(e) => { + // Most likely OS-level watch capacity exhausted + // (Linux inotify limits on monorepos). The + // macOS-on-kqueue version of this footgun moved + // away when the backend switched to FSEvents. + // Non-fatal — manual `R` still refreshes; we + // just miss out on the auto behavior. The + // failed-root marker we set here gates further + // retries via `watcher_action`, so this logs + // once per (root, attempt) rather than every + // render. + log::warn!("filetree fs watcher failed for {}: {e}", want.display()); + self.fs_watcher = None; + self.fs_watcher_failed = Some(want.to_path_buf()); + } + } + } + } + } + /// Expand the directories between the root and `path`, then queue a /// reveal of `path` itself for the next render. Returns whether the /// reveal was set up — `false` means the path isn't under the root and @@ -859,6 +955,41 @@ impl Component for Filetree { } } + // Filesystem watcher. Lazy init / re-init on root change keeps the + // watch alive across renders without rebuilding it every frame; a + // raised flag means at least one FS event arrived since the last + // drain, so we mark entries dirty and (subject to the debounce + // below) kick a VCS refresh so the two surfaces stay in sync. + // + // The debounce is load-bearing: workspaces with a busy `target/` + // (cargo) or `node_modules/` fire FS events at a high rate, and + // without throttling every redraw — including the ones triggered + // by mouse hover transitions — would spawn yet another concurrent + // git scan, each of them stomping on the cache via `cache.clear()` + // and per-file inserts. They contend on the cache Mutex with the + // render path's per-row lookups, and the cumulative lock-acquire + // overhead is what makes mouse motion specifically feel laggy + // (each Moved event triggers a redraw, redraws come fast). + self.ensure_watcher(ctx.editor.filetree.root.as_deref()); + if self.fs_watcher.as_ref().is_some_and(|w| w.drain()) { + self.dirty = true; + let now = Instant::now(); + let should_refresh = match self.last_vcs_refresh { + Some(t) => now.duration_since(t) > VCS_REFRESH_DEBOUNCE, + None => true, + }; + if should_refresh { + if let Some(root) = ctx.editor.filetree.root.clone() { + self.last_vcs_refresh = Some(now); + vcs::refresh( + ctx.editor.diff_providers.clone(), + root, + ctx.editor.filetree.vcs_cache.clone(), + ); + } + } + } + // Rebuild entries before clamping cursor/scroll, so clamps reflect // the actual list length. if self.dirty { @@ -982,6 +1113,13 @@ impl Component for Filetree { .filter_map(|doc| doc.path().map(|p| p.to_path_buf())) .collect(); + // VCS snapshot. Take *once* per render rather than locking the + // cache Mutex per row — that's what kept mouse-move from feeling + // smooth, since each Moved-transition redraw multiplied the lock + // contention with the background VCS scan that fires after FS + // events under busy directories like `target/` or `node_modules/`. + let vcs_snapshot = ctx.editor.filetree.vcs_cache.snapshot(); + let visible_rows = panel_rect.height as usize; let scroll = ctx.editor.filetree.scroll; let cursor_idx = ctx.editor.filetree.cursor_idx; @@ -1026,10 +1164,8 @@ impl Component for Filetree { // behavior — collapsed parents reveal something interesting // is inside). let vcs_status = match entry.kind { - EntryKind::File => ctx.editor.filetree.vcs_cache.lookup(&entry.path), - EntryKind::Directory { .. } => { - ctx.editor.filetree.vcs_cache.rollup_under(&entry.path) - } + EntryKind::File => vcs_snapshot.lookup(&entry.path), + EntryKind::Directory { .. } => vcs_snapshot.rollup_under(&entry.path), }; let vcs_patch = vcs_status.and_then(vcs_style_for); diff --git a/helix-term/src/ui/filetree/watcher.rs b/helix-term/src/ui/filetree/watcher.rs new file mode 100644 index 00000000..a7a0410a --- /dev/null +++ b/helix-term/src/ui/filetree/watcher.rs @@ -0,0 +1,340 @@ +//! Recursive filesystem watcher for the filetree. +//! +//! Each [`FsWatcher`] owns a `notify::RecommendedWatcher` rooted at the +//! panel's workspace root. The callback filters out events under known- +//! noisy paths (`target/`, `node_modules/`, `.git/`, `.jj/`, `.direnv/`), +//! sets a flag, and requests a redraw. The render path drains the flag, +//! re-walks the filesystem if it was set, and kicks a VCS refresh. +//! +//! Path filtering is load-bearing for usability. Without it, a build +//! tool writing into `target/` (cargo's normal operation) fires hundreds +//! of FS events per second. Each event calls `request_redraw`, the +//! editor wakes at its 30 fps cap to render, and even though the panel +//! shows no visible change, the redraw storm leaves no headroom for +//! responsive mouse input. The filter cuts ~99% of the event rate on +//! a typical rust workspace without losing any change the user would +//! ever see in the panel — those paths are gitignored and the panel +//! collapses them visually anyway. +//! +//! Watcher lifetime is tied to the [`super::Filetree`] component. The +//! component holds an `Option` and creates one whenever the +//! panel's root is set (or changes). When the panel is hidden (the +//! component drops), so does the watcher — there's no point watching while +//! the panel isn't on screen, and any changes that happen while hidden +//! get picked up on the next show by the panel's existing first-render +//! `dirty = true`. +//! +//! Failures are non-fatal. If the OS's watch capacity is exhausted (inotify +//! limits on Linux, the macos_kqueue backend on macOS handling small +//! workspaces fine but failing on monorepos) we log and leave the field +//! as `None`. The panel continues to function — the user just has to press +//! `R` to refresh manually. + +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; + +/// Decision the `ensure_watcher` dispatcher should take, based on what's +/// wanted, what's currently held, and what was previously rejected. Lifting +/// the decision out of the impl lets the four-way matrix be unit-tested +/// without spinning a real `notify::Watcher` on the OS. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum WatcherAction { + /// Current state is correct: either we already watch the wanted root + /// or we already tried this root and the OS refused. In both cases + /// the dispatcher does nothing. + NoOp, + /// Release any held watcher and clear the failed-root marker. Used + /// when the panel's root went away (no watcher should exist) or when + /// we're transitioning between distinct roots and want a clean slate. + Release, + /// Attempt to create a watcher for the wanted root. The dispatcher + /// uses `root.expect(...)` here, which is sound because this variant + /// is only returned when `want.is_some()`. + Create, +} + +/// Decide what `ensure_watcher` should do given the three inputs: +/// - `want`: panel's current root (or `None` when the panel has none yet) +/// - `have`: root of the currently-active watcher, if any +/// - `failed`: root that `FsWatcher::new` previously rejected, if any +/// +/// The `(want, have, failed)` truth table is small enough to enumerate +/// fully in the tests and the priorities are simple: +/// +/// 1. If no root is wanted, any held state must be released (idempotent). +/// 2. Otherwise, if we're already watching the wanted root, we're done. +/// 3. Otherwise, if we already tried this exact root and failed, skip — +/// retrying just walks the workspace and fails again, which at render +/// cadence becomes the lag bug the watcher was added to avoid. +/// 4. Otherwise, attempt to create. +/// +/// Stale `failed` markers for previously-wanted roots are harmless: rule +/// 3 only consults them when `want == failed`, and a successful Create +/// or any Release wipes them. +pub(super) fn watcher_action( + want: Option<&Path>, + have: Option<&Path>, + failed: Option<&Path>, +) -> WatcherAction { + match want { + None => { + if have.is_some() || failed.is_some() { + WatcherAction::Release + } else { + WatcherAction::NoOp + } + } + Some(want) => { + if have == Some(want) || failed == Some(want) { + WatcherAction::NoOp + } else { + WatcherAction::Create + } + } + } +} + +/// Directory names whose contents we ignore at the watcher callback level. +/// +/// These are the four directories that, on a typical project, are +/// gitignored / VCS-ignored *and* are written to constantly by tooling +/// (cargo, npm, git itself, jj, direnv). Watching them eats CPU and +/// generates redraw storms; ignoring them costs us nothing the user +/// would see in the panel (which collapses them anyway by default). +/// +/// Defined as a const slice rather than a config knob because every +/// other config knob the panel exposes is about *what to display*, +/// and these never display content the user wants to interact with. +/// If a real use case for "I want to see target/ light up live" comes +/// up, this becomes a config knob; until then it's just constants. +const NOISY_DIR_NAMES: &[&str] = &["target", "node_modules", ".git", ".jj", ".direnv"]; + +/// Whether `path` lies under any of the [`NOISY_DIR_NAMES`] components. +/// +/// Walks components rather than doing string-match because nested matches +/// ("foo/target/bar") and component-boundary matches ("targetlike/foo") +/// have to behave correctly without false positives on substrings. +fn is_noisy_path(path: &Path) -> bool { + path.components().any(|c| match c { + Component::Normal(name) => NOISY_DIR_NAMES + .iter() + .any(|noisy| name.as_encoded_bytes() == noisy.as_bytes()), + _ => false, + }) +} + +/// A live filesystem watcher rooted at `root`. +/// +/// Dropping this stops the watch. The internal `notify::RecommendedWatcher` +/// is held as a field whose only purpose is to keep the underlying OS +/// resources alive — we never call methods on it after construction. +pub struct FsWatcher { + /// The OS-level watcher handle. Field-only: dropping it stops the + /// background watch thread. + _watcher: RecommendedWatcher, + /// Root path under watch. The component uses this to decide whether + /// the existing watcher still applies after a root change (most of + /// the time root doesn't change, so this is just a cheap `==`). + pub root: PathBuf, + /// Set by the watcher's background callback whenever a non-noisy + /// event arrives. Drained (atomically swapped back to `false`) by + /// [`FsWatcher::drain`] from the render path. + /// + /// `Arc` because the callback closure captures it and outlives the + /// `FsWatcher` value during construction; `AtomicBool` because the + /// closure runs on a notify-owned thread and we read it from the + /// main thread without locking. + changed: Arc, +} + +impl FsWatcher { + /// Create a watcher rooted at `root` and start watching recursively. + /// Returns the error from `notify` if the watcher couldn't be created + /// (out of OS-level watch capacity, root doesn't exist, etc.) — the + /// caller treats that as "fine, just no auto-refresh". + pub fn new(root: PathBuf) -> notify::Result { + let changed = Arc::new(AtomicBool::new(false)); + let changed_writer = changed.clone(); + let mut watcher = + notify::recommended_watcher(move |res: notify::Result| match res { + Ok(event) => { + // Drop events that only touch noisy directories. If + // *every* path on the event is under target/ / + // node_modules/ / .git/ / .jj/ / .direnv/, the event + // carries no information the panel can render — skip + // it entirely so we don't wake the render loop. + // + // `all` rather than `any`: a rename moving a file + // *out* of target/ has both the old (noisy) and new + // (interesting) paths in the event, and we want to + // handle that. Empty paths defensively treated as + // noisy too — `all` on an empty iterator is `true`, + // but `any` would be `false`, and we'd rather miss + // an empty-paths event than wake on it. + if !event.paths.is_empty() && event.paths.iter().all(|p| is_noisy_path(p)) { + return; + } + // The kind of event doesn't matter — any FS state + // change means "the entries you cached are stale, + // re-walk on next render". Sub-classification (only + // refresh on Create/Remove, ignore Access, etc.) + // would be premature optimization at this scale. + changed_writer.store(true, Ordering::Relaxed); + helix_event::request_redraw(); + } + Err(e) => { + log::warn!("filetree watcher event error: {e}"); + } + })?; + watcher.watch(&root, RecursiveMode::Recursive)?; + Ok(Self { + _watcher: watcher, + root, + changed, + }) + } + + /// Atomically read and clear the changed flag. Returns `true` if any + /// event was observed since the last drain — the caller treats that + /// as a single "you should re-scan" signal, regardless of how many + /// underlying events fired. + pub fn drain(&self) -> bool { + self.changed.swap(false, Ordering::Relaxed) + } +} + +#[cfg(test)] +mod tests { + use super::{is_noisy_path, watcher_action, WatcherAction}; + use std::path::Path; + + /// [`is_noisy_path`] is the filter the watcher callback uses to drop + /// events. Closed input domain — enumerate the four noisy dir names + /// and the boundary cases (substring matches, multi-segment paths, + /// nested, root-level vs deep, etc.). + #[test] + fn is_noisy_path_table() { + // Each of the noisy names matches when it appears as a component. + assert!(is_noisy_path(Path::new("/repo/target/debug/hx"))); + assert!(is_noisy_path(Path::new("/repo/node_modules/foo/bar.js"))); + assert!(is_noisy_path(Path::new("/repo/.git/HEAD"))); + assert!(is_noisy_path(Path::new("/repo/.jj/op/HEAD"))); + assert!(is_noisy_path(Path::new("/repo/.direnv/flake-profile"))); + // Nested: a noisy dir inside another noisy dir still matches. + assert!(is_noisy_path(Path::new("/repo/target/node_modules"))); + // Root-level: just the noisy name as the only component. + assert!(is_noisy_path(Path::new("target/foo"))); + + // Substring matches on the dir name shouldn't match. "targetlike" + // and "targets" are not the noisy directory. + assert!(!is_noisy_path(Path::new("/repo/targetlike/foo.rs"))); + assert!(!is_noisy_path(Path::new("/repo/targets/foo.rs"))); + assert!(!is_noisy_path(Path::new("/repo/my_node_modules/foo"))); + assert!(!is_noisy_path(Path::new("/repo/.git_something/foo"))); + + // Substring inside a non-component segment shouldn't match. + assert!(!is_noisy_path(Path::new("/repo/src/target.rs"))); + assert!(!is_noisy_path(Path::new("/repo/.gitignore"))); + + // No noisy components at all. + assert!(!is_noisy_path(Path::new("/repo/src/main.rs"))); + assert!(!is_noisy_path(Path::new("/repo/Cargo.toml"))); + } + + /// [`watcher_action`] has a closed 3-input truth table. Enumerate + /// every meaningful combination so a future regression that loops + /// back into the retry storm (the lag bug the watcher was added to + /// avoid) shows up as a specific failed row, not a vague "watcher + /// is slow" report. + #[test] + fn watcher_action_table() { + use WatcherAction::*; + let a = Path::new("/a"); + let b = Path::new("/b"); + + // (want, have, failed, expected, label) + let cases: &[( + Option<&Path>, + Option<&Path>, + Option<&Path>, + WatcherAction, + &str, + )] = &[ + (None, None, None, NoOp, "nothing wanted, nothing held"), + ( + None, + Some(a), + None, + Release, + "wanted nothing, still watching a", + ), + ( + None, + None, + Some(a), + Release, + "wanted nothing, stale failed-marker for a", + ), + ( + None, + Some(a), + Some(a), + Release, + "wanted nothing, watching and failed markers both held", + ), + ( + Some(a), + Some(a), + None, + NoOp, + "already watching the wanted root", + ), + ( + Some(a), + Some(b), + None, + Create, + "watching wrong root, switch to wanted", + ), + ( + Some(a), + None, + None, + Create, + "no watcher, no failure history → first attempt", + ), + ( + Some(a), + None, + Some(a), + NoOp, + "wanted root previously failed — DO NOT retry (regression test)", + ), + ( + Some(a), + None, + Some(b), + Create, + "different root failed; this root is untried", + ), + ( + Some(a), + Some(b), + Some(a), + NoOp, + "wanted root previously failed, even with stale watcher elsewhere", + ), + ]; + + for (want, have, failed, expected, label) in cases { + assert_eq!( + watcher_action(*want, *have, *failed), + *expected, + "{label}: want={want:?} have={have:?} failed={failed:?}", + ); + } + } +} diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index c57e3ed9..636afaaa 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1407,9 +1407,46 @@ impl VcsCache { /// descendant, and including it would have us colored against our own /// row's status, which would be weird). pub fn rollup_under(&self, dir: &Path) -> Option { - let map = self.inner.lock().unwrap(); + self.snapshot().rollup_under(dir) + } + + /// Take a point-in-time clone of the cache. The renderer takes one + /// snapshot at the start of each frame and does all per-row lookups + /// against the clone, so the per-row lookups never contend with the + /// background VCS-refresh writer for the cache Mutex. Without this, + /// mouse-move events (which fire fast) compound the per-row Mutex + /// acquisitions and the bg writer's per-insert Mutex acquisitions + /// into visible lag. + pub fn snapshot(&self) -> VcsSnapshot { + VcsSnapshot(self.inner.lock().unwrap().clone()) + } +} + +/// A point-in-time copy of a [`VcsCache`]'s contents. +/// +/// Taken at the start of render with [`VcsCache::snapshot`]; queried per-row +/// for the rest of the frame. Lookups against this type never touch a +/// Mutex, so a fast stream of renders (e.g. driven by mouse-move events) +/// doesn't pay the cost of repeated lock-acquire/release cycles — +/// especially important when the background VCS scan is concurrently +/// writing into the source [`VcsCache`]. +#[derive(Debug, Clone)] +pub struct VcsSnapshot(HashMap); + +impl VcsSnapshot { + /// Look up a single path against this snapshot. Same semantics as + /// [`VcsCache::lookup`], without the Mutex acquire. + pub fn lookup(&self, path: &Path) -> Option { + self.0.get(path).copied() + } + + /// The most-severe status among descendants of `dir`, against this + /// snapshot. Same semantics as [`VcsCache::rollup_under`], without + /// the Mutex acquire — and so safe to call once per directory row + /// in a render loop without contending with the bg writer. + pub fn rollup_under(&self, dir: &Path) -> Option { let mut highest: Option = None; - for (path, status) in map.iter() { + for (path, status) in self.0.iter() { if path == dir || !path.starts_with(dir) { continue; } diff --git a/runtime/themes/darcula-solid.toml b/runtime/themes/darcula-solid.toml index ae78df9f..c9a7aea4 100644 --- a/runtime/themes/darcula-solid.toml +++ b/runtime/themes/darcula-solid.toml @@ -32,7 +32,6 @@ inherits = "darcula" "ui.filetree.vcs.modified" = { fg = "orange" } "ui.filetree.vcs.untracked" = { fg = "green" } "ui.filetree.vcs.conflict" = { fg = "red" } - [palette] grey00 = "#101010" grey01 = "#1f1f1f"