Skip to main content

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}