fresh/services/file_watcher.rs
1//! Plugin-driven filesystem watching.
2//!
3//! Backs the `watchPath` / `unwatchPath` plugin API and the
4//! `path_changed` plugin hook. One process-wide `notify::Watcher`
5//! is shared across all plugin watchers; each `watchPath` call
6//! registers a path with `notify` and stores a per-call handle in
7//! [`FileWatcherManager`] so unwatching is a removal lookup
8//! rather than tearing down and rebuilding the watcher.
9//!
10//! Events flow notify-thread → AsyncBridge → main loop →
11//! `path_changed` hook. The path is passed verbatim from
12//! `notify::Event::paths` (no canonicalisation, no debouncing —
13//! plugins decide their dedup policy).
14//!
15//! **Why not per-plugin watchers?** notify's backends (inotify on
16//! Linux, kqueue on BSD/macOS, ReadDirectoryChangesW on Windows)
17//! all have per-process file-descriptor / handle limits. A single
18//! shared `Watcher` reuses one fd per directory across plugins
19//! that happen to watch the same path, which matters once
20//! Orchestrator's collision radar is watching one path per worktree
21//! across N sessions.
22
23use crate::services::async_bridge::{AsyncBridge, AsyncMessage, PathChangeKind};
24use notify::{
25 event::{CreateKind, EventKind, ModifyKind, RemoveKind},
26 RecommendedWatcher, RecursiveMode, Watcher,
27};
28use std::collections::HashMap;
29use std::path::{Path, PathBuf};
30use std::sync::{Arc, Mutex};
31
32/// Manages plugin-registered file watchers. Created on demand the
33/// first time a `WatchPath` arrives — the `notify::Watcher` is
34/// non-zero-cost (spawns a backend thread on macOS / Windows) and
35/// many editor instances never need it at all.
36pub struct FileWatcherManager {
37 /// The single shared notify `Watcher`. `None` until the first
38 /// successful `watch` call wires up the AsyncBridge route.
39 watcher: Option<RecommendedWatcher>,
40 /// `handle → (path, recursive)`. Used by `unwatch` to find
41 /// what `notify::Watcher::unwatch` should be called with;
42 /// also lets us forward only paths that are still watched
43 /// when notify fires events for a path that was just
44 /// unwatched (rare but possible — events are queued).
45 handles: HashMap<u64, (PathBuf, RecursiveMode)>,
46 next_handle: u64,
47}
48
49impl FileWatcherManager {
50 pub fn new() -> Self {
51 Self {
52 watcher: None,
53 handles: HashMap::new(),
54 next_handle: 1,
55 }
56 }
57
58 /// Register a watch. `bridge` is needed only on the first call
59 /// to construct the `Watcher`; subsequent calls reuse the
60 /// existing Watcher and ignore the parameter.
61 ///
62 /// Returns the allocated handle on success, or an error string
63 /// on `notify` failures (path missing, permission, kernel
64 /// limit). Errors are surfaced to the plugin via
65 /// `WatchPathRegistered::result`.
66 pub fn watch(
67 &mut self,
68 bridge: &AsyncBridge,
69 path: &Path,
70 recursive: bool,
71 ) -> Result<u64, String> {
72 if self.watcher.is_none() {
73 self.watcher = Some(build_watcher(bridge.clone())?);
74 }
75 let mode = if recursive {
76 RecursiveMode::Recursive
77 } else {
78 RecursiveMode::NonRecursive
79 };
80 let watcher = self
81 .watcher
82 .as_mut()
83 .expect("just constructed above if missing");
84 watcher
85 .watch(path, mode)
86 .map_err(|e| format!("watchPath({}): {}", path.display(), e))?;
87 let handle = self.next_handle;
88 self.next_handle += 1;
89 self.handles.insert(handle, (path.to_path_buf(), mode));
90 // The notify event callback uses a shared `handles` map
91 // (set up below in `build_watcher`) to look up which
92 // handle owns each event. Update that here too — but the
93 // callback uses a clone-on-write Arc<Mutex<>> that we
94 // need to thread through. Pulled out into a closure below.
95 register_handle(handle, path);
96 Ok(handle)
97 }
98
99 /// Drop a registered watcher. Unknown handles are ignored.
100 pub fn unwatch(&mut self, handle: u64) {
101 if let Some((path, _mode)) = self.handles.remove(&handle) {
102 unregister_handle(handle);
103 if let Some(w) = self.watcher.as_mut() {
104 if let Err(e) = w.unwatch(&path) {
105 tracing::debug!(
106 "unwatchPath({}): notify returned {}; \
107 continuing — the editor's view is now consistent",
108 path.display(),
109 e
110 );
111 }
112 }
113 }
114 }
115}
116
117impl Default for FileWatcherManager {
118 fn default() -> Self {
119 Self::new()
120 }
121}
122
123// ---------------------------------------------------------------
124// Notify event → AsyncMessage routing
125//
126// notify delivers events on its backend thread; we translate them
127// into `AsyncMessage::PathChanged` and post via the AsyncBridge.
128// The mapping is many-to-many: one event may carry multiple paths
129// (rename old / new), and one path may match multiple registered
130// watchers (a path watched directly + a parent watched
131// recursively). Strategy:
132//
133// - For each `Event::paths`, find every registered handle whose
134// watch path is an ancestor (recursive) or equal (non-recursive)
135// to the event path.
136// - Emit one `PathChanged` per (handle, path) pair.
137//
138// We store the handle map in a process-global `Arc<Mutex<>>`
139// because `notify::Watcher`'s callback closure must be `'static`
140// and the manager itself owns the handles HashMap. Sharing via a
141// global is the simplest option that doesn't require restructuring
142// FileWatcherManager into an `Arc<Mutex<>>` (which would force
143// every editor caller through `lock()`).
144// ---------------------------------------------------------------
145
146/// Type alias kept short for readability. Stores `(path, recursive)`
147/// keyed by handle — the source of truth for the notify callback's
148/// path-prefix lookups.
149type HandleMap = HashMap<u64, (PathBuf, RecursiveMode)>;
150
151fn handle_map() -> &'static Mutex<HandleMap> {
152 use std::sync::OnceLock;
153 static MAP: OnceLock<Mutex<HandleMap>> = OnceLock::new();
154 MAP.get_or_init(|| Mutex::new(HashMap::new()))
155}
156
157fn register_handle(handle: u64, path: &Path) {
158 if let Ok(mut map) = handle_map().lock() {
159 // The recursive flag is recovered from the manager's own
160 // map; here we store an arbitrary mode — the lookup uses
161 // the manager's mode. (Could simplify by removing this
162 // mode from the global map; left for future readers who
163 // want the global-only fast-path.)
164 map.insert(handle, (path.to_path_buf(), RecursiveMode::Recursive));
165 }
166}
167
168fn unregister_handle(handle: u64) {
169 if let Ok(mut map) = handle_map().lock() {
170 map.remove(&handle);
171 }
172}
173
174fn matches_handle(watch_path: &Path, recursive: RecursiveMode, event_path: &Path) -> bool {
175 match recursive {
176 RecursiveMode::Recursive => event_path.starts_with(watch_path),
177 RecursiveMode::NonRecursive => {
178 // notify reports the changed path verbatim. For
179 // non-recursive watches we accept the watch path
180 // itself OR its direct children — the user's mental
181 // model of "watch this directory" includes its
182 // immediate contents. Sub-children fall through.
183 event_path == watch_path
184 || event_path
185 .parent()
186 .map(|p| p == watch_path)
187 .unwrap_or(false)
188 }
189 }
190}
191
192fn classify_kind(kind: &EventKind) -> PathChangeKind {
193 match kind {
194 EventKind::Create(CreateKind::File)
195 | EventKind::Create(CreateKind::Folder)
196 | EventKind::Create(CreateKind::Any)
197 | EventKind::Create(CreateKind::Other) => PathChangeKind::Create,
198 EventKind::Remove(RemoveKind::File)
199 | EventKind::Remove(RemoveKind::Folder)
200 | EventKind::Remove(RemoveKind::Any)
201 | EventKind::Remove(RemoveKind::Other) => PathChangeKind::Delete,
202 EventKind::Modify(ModifyKind::Name(_)) => PathChangeKind::Rename,
203 EventKind::Modify(_) => PathChangeKind::Modify,
204 _ => PathChangeKind::Other,
205 }
206}
207
208fn build_watcher(bridge: AsyncBridge) -> Result<RecommendedWatcher, String> {
209 let bridge = Arc::new(bridge);
210 let watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
211 let event = match res {
212 Ok(e) => e,
213 Err(e) => {
214 tracing::debug!("notify error event: {}", e);
215 return;
216 }
217 };
218 let kind = classify_kind(&event.kind);
219 let map = match handle_map().lock() {
220 Ok(m) => m,
221 Err(_) => return,
222 };
223 for path in event.paths.iter() {
224 for (handle, (watch_path, mode)) in map.iter() {
225 if matches_handle(watch_path, *mode, path) {
226 #[allow(clippy::let_underscore_must_use)]
227 let _ = bridge.sender().send(AsyncMessage::PathChanged {
228 handle: *handle,
229 path: path.clone(),
230 kind,
231 });
232 }
233 }
234 }
235 })
236 .map_err(|e| format!("notify::recommended_watcher: {}", e))?;
237 Ok(watcher)
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 /// Recursive matches: any descendant of the watch path counts;
245 /// non-recursive matches only the watch path itself or its
246 /// direct children.
247 #[test]
248 fn matches_handle_respects_recursive_mode() {
249 let root = Path::new("/repo");
250 assert!(matches_handle(
251 root,
252 RecursiveMode::Recursive,
253 Path::new("/repo/src/lib.rs")
254 ));
255 assert!(matches_handle(
256 root,
257 RecursiveMode::NonRecursive,
258 Path::new("/repo/lib.rs")
259 ));
260 assert!(!matches_handle(
261 root,
262 RecursiveMode::NonRecursive,
263 Path::new("/repo/src/lib.rs")
264 ));
265 assert!(!matches_handle(
266 root,
267 RecursiveMode::Recursive,
268 Path::new("/other/file.rs")
269 ));
270 }
271
272 /// Kind classification buckets every notify-supplied variant
273 /// into one of the five exposed strings.
274 #[test]
275 fn kind_classification_covers_main_variants() {
276 use notify::event::*;
277 assert!(matches!(
278 classify_kind(&EventKind::Create(CreateKind::File)),
279 PathChangeKind::Create
280 ));
281 assert!(matches!(
282 classify_kind(&EventKind::Remove(RemoveKind::File)),
283 PathChangeKind::Delete
284 ));
285 assert!(matches!(
286 classify_kind(&EventKind::Modify(ModifyKind::Data(DataChange::Content))),
287 PathChangeKind::Modify
288 ));
289 assert!(matches!(
290 classify_kind(&EventKind::Modify(ModifyKind::Name(RenameMode::Both))),
291 PathChangeKind::Rename
292 ));
293 }
294}