|
| 1 | +use std::path::{Path, PathBuf}; |
| 2 | +use std::sync::{Arc, Mutex}; |
| 3 | +use std::time::{Duration, Instant}; |
| 4 | + |
| 5 | +use flume::Sender; |
| 6 | +use gpui::{App, Global}; |
| 7 | +use notify::event::{EventKind, ModifyKind}; |
| 8 | +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; |
| 9 | + |
| 10 | +const OP_DEBOUNCE: Duration = Duration::from_millis(1000); |
| 11 | +const WC_DEBOUNCE: Duration = Duration::from_millis(2000); |
| 12 | + |
| 13 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 14 | +pub enum FsEvent { |
| 15 | + OpHeads, |
| 16 | + WorkingCopy, |
| 17 | +} |
| 18 | + |
| 19 | +/// Returns `true` if any path is unignored. Mirrors `hasUnignoredWorkingCopyPaths` in SwiftUI. |
| 20 | +pub type IsRelevantWcChange = Arc<dyn Fn(&[PathBuf]) -> bool + Send + Sync>; |
| 21 | + |
| 22 | +/// When set, the watcher is armed but the real `notify` OS thread isn't spawned — tests |
| 23 | +/// install this so the FSEvents loop can't trip the GPUI scheduler's nondeterminism guard. |
| 24 | +#[derive(Default)] |
| 25 | +struct WatcherSuppressed(bool); |
| 26 | + |
| 27 | +impl Global for WatcherSuppressed {} |
| 28 | + |
| 29 | +/// True when the real OS watcher must not be spawned (test scheduler is active). |
| 30 | +pub fn is_watcher_suppressed(cx: &App) -> bool { |
| 31 | + cx.try_global::<WatcherSuppressed>().is_some_and(|s| s.0) |
| 32 | +} |
| 33 | + |
| 34 | +/// Suppress real OS-thread watchers for the rest of this process. Tests call this. |
| 35 | +pub fn suppress_for_tests(cx: &mut App) { |
| 36 | + cx.set_global(WatcherSuppressed(true)); |
| 37 | +} |
| 38 | + |
| 39 | +/// What an FS event means before debounce, derived purely from its kind and paths. |
| 40 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 41 | +enum EventClass { |
| 42 | + /// Metadata-only / uninteresting, or a `.jj/` internal we already cover via op_heads. |
| 43 | + Ignore, |
| 44 | + /// A jj operation landed (op_heads changed) — refresh the graph. |
| 45 | + OpHeads, |
| 46 | + /// A working-copy path changed; relevance + debounce still gate the send. |
| 47 | + WorkingCopy, |
| 48 | +} |
| 49 | + |
| 50 | +/// Static per-watcher config used to classify each raw event. |
| 51 | +struct PathClassifier { |
| 52 | + op_heads_dir: PathBuf, |
| 53 | + repo_root: PathBuf, |
| 54 | +} |
| 55 | + |
| 56 | +impl PathClassifier { |
| 57 | + fn classify(&self, event: ¬ify::Event) -> EventClass { |
| 58 | + // Metadata-only events — Spotlight / Time Machine spam them on macOS. |
| 59 | + let interesting = matches!( |
| 60 | + &event.kind, |
| 61 | + EventKind::Create(_) |
| 62 | + | EventKind::Remove(_) |
| 63 | + | EventKind::Modify(ModifyKind::Data(_) | ModifyKind::Name(_)) |
| 64 | + ); |
| 65 | + if !interesting { |
| 66 | + return EventClass::Ignore; |
| 67 | + } |
| 68 | + let touches_op_heads = event |
| 69 | + .paths |
| 70 | + .iter() |
| 71 | + .any(|p| p == &self.op_heads_dir || p.starts_with(&self.op_heads_dir)); |
| 72 | + if touches_op_heads { |
| 73 | + return EventClass::OpHeads; |
| 74 | + } |
| 75 | + // Other `.jj/` internals are already handled via op_heads above. |
| 76 | + let in_jj_dir = event.paths.iter().all(|p| { |
| 77 | + p.strip_prefix(&self.repo_root) |
| 78 | + .is_ok_and(starts_with_dot_jj) |
| 79 | + }); |
| 80 | + if in_jj_dir { |
| 81 | + return EventClass::Ignore; |
| 82 | + } |
| 83 | + EventClass::WorkingCopy |
| 84 | + } |
| 85 | +} |
| 86 | + |
| 87 | +/// Leading-edge debounce timestamps for the two event streams. |
| 88 | +struct Debounce { |
| 89 | + last_op: Instant, |
| 90 | + last_wc: Instant, |
| 91 | +} |
| 92 | + |
| 93 | +impl Debounce { |
| 94 | + fn fresh() -> Self { |
| 95 | + Self { |
| 96 | + last_op: Instant::now() - OP_DEBOUNCE, |
| 97 | + last_wc: Instant::now() - WC_DEBOUNCE, |
| 98 | + } |
| 99 | + } |
| 100 | + |
| 101 | + /// Whether enough time has elapsed to emit another op-heads refresh. |
| 102 | + fn op_ready(&self, now: Instant) -> bool { |
| 103 | + now.duration_since(self.last_op) >= OP_DEBOUNCE |
| 104 | + } |
| 105 | + |
| 106 | + /// Whether enough time has elapsed to emit another working-copy refresh. |
| 107 | + fn wc_ready(&self, now: Instant) -> bool { |
| 108 | + now.duration_since(self.last_wc) >= WC_DEBOUNCE |
| 109 | + } |
| 110 | +} |
| 111 | + |
| 112 | +pub struct RepoFsWatcher { |
| 113 | + _watcher: RecommendedWatcher, |
| 114 | +} |
| 115 | + |
| 116 | +impl RepoFsWatcher { |
| 117 | + pub fn new( |
| 118 | + repo_path: &Path, |
| 119 | + tx: Sender<FsEvent>, |
| 120 | + is_relevant_wc_change: IsRelevantWcChange, |
| 121 | + ) -> Option<Self> { |
| 122 | + let classifier = PathClassifier { |
| 123 | + // Match by prefix: jj writes one file per head under this dir. |
| 124 | + op_heads_dir: repo_path.join(".jj/repo/op_heads/heads"), |
| 125 | + repo_root: repo_path.to_path_buf(), |
| 126 | + }; |
| 127 | + let debounce = Mutex::new(Debounce::fresh()); |
| 128 | + |
| 129 | + let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| { |
| 130 | + let Ok(event) = res else { |
| 131 | + return; |
| 132 | + }; |
| 133 | + if let Some(out) = next_event( |
| 134 | + &classifier, |
| 135 | + &debounce, |
| 136 | + &event, |
| 137 | + Instant::now(), |
| 138 | + is_relevant_wc_change.as_ref(), |
| 139 | + ) { |
| 140 | + let _ = tx.send(out); |
| 141 | + } |
| 142 | + }) |
| 143 | + .ok()?; |
| 144 | + |
| 145 | + watcher.watch(repo_path, RecursiveMode::Recursive).ok()?; |
| 146 | + Some(Self { _watcher: watcher }) |
| 147 | + } |
| 148 | +} |
| 149 | + |
| 150 | +/// Decide whether a raw event should emit, stamping the debounce on a send. The window is |
| 151 | +/// checked before the relevance filter so a build storm is dropped without running the |
| 152 | +/// gitignore matcher more than once per `WC_DEBOUNCE`. |
| 153 | +fn next_event( |
| 154 | + classifier: &PathClassifier, |
| 155 | + debounce: &Mutex<Debounce>, |
| 156 | + event: ¬ify::Event, |
| 157 | + now: Instant, |
| 158 | + is_relevant_wc_change: &dyn Fn(&[PathBuf]) -> bool, |
| 159 | +) -> Option<FsEvent> { |
| 160 | + match classifier.classify(event) { |
| 161 | + EventClass::Ignore => None, |
| 162 | + EventClass::OpHeads => { |
| 163 | + let mut guard = debounce.lock().expect("op debounce lock"); |
| 164 | + guard.op_ready(now).then(|| { |
| 165 | + guard.last_op = now; |
| 166 | + FsEvent::OpHeads |
| 167 | + }) |
| 168 | + } |
| 169 | + EventClass::WorkingCopy => { |
| 170 | + // Read-only window check first; bail before touching the gitignore matcher. |
| 171 | + { |
| 172 | + let guard = debounce.lock().expect("wc debounce lock"); |
| 173 | + if !guard.wc_ready(now) { |
| 174 | + return None; |
| 175 | + } |
| 176 | + } |
| 177 | + if !is_relevant_wc_change(&event.paths) { |
| 178 | + return None; |
| 179 | + } |
| 180 | + let mut guard = debounce.lock().expect("wc debounce lock"); |
| 181 | + // Re-check under the lock: a concurrent event may have stamped it meanwhile. |
| 182 | + guard.wc_ready(now).then(|| { |
| 183 | + guard.last_wc = now; |
| 184 | + FsEvent::WorkingCopy |
| 185 | + }) |
| 186 | + } |
| 187 | + } |
| 188 | +} |
| 189 | + |
| 190 | +fn starts_with_dot_jj(rel: &Path) -> bool { |
| 191 | + rel.components() |
| 192 | + .next() |
| 193 | + .map(|c| c.as_os_str() == ".jj") |
| 194 | + .unwrap_or(false) |
| 195 | +} |
| 196 | + |
| 197 | +#[cfg(test)] |
| 198 | +mod tests; |
0 commit comments