Skip to main content

oximemo_core/
watcher.rs

1//! Vault file watcher (§5.5).
2//!
3//! Watches the notes and trash trees. Events are coalesced through a debounce
4//! window (default 300 ms) so a burst of writes from an editor's
5//! swap-and-rename produces a single re-index call per path. The caller
6//! supplies the per-path handler (the [`Vault`] re-indexes the file).
7
8use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::sync::mpsc;
13use std::time::{Duration, Instant};
14
15use crate::error::Result;
16
17/// A callback invoked once a path has settled (debounced). The handler owns the
18/// parse + re-index logic, including retries (§5.5).
19pub type OnChange = Arc<dyn Fn(PathBuf) + Send + Sync>;
20
21/// Holds the underlying watcher alive. Dropping stops watching.
22pub struct MemoWatcher {
23    _watcher: RecommendedWatcher,
24}
25
26impl MemoWatcher {
27    /// Begin watching `roots` (recursively). Returns a handle that must be kept
28    /// alive for the lifetime of the watch.
29    pub fn spawn(roots: Vec<PathBuf>, debounce: Duration, on_change: OnChange) -> Result<Self> {
30        let (tx, rx) = mpsc::channel::<PathBuf>();
31        std::thread::spawn(move || debounce_loop(rx, debounce, on_change));
32
33        let mut watcher =
34            notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
35                if let Ok(ev) = res {
36                    // Ignore purely-access (open/close) chatter; only act on
37                    // creation/modification/removal.
38                    let relevant = matches!(
39                        ev.kind,
40                        EventKind::Create(_)
41                            | EventKind::Modify(_)
42                            | EventKind::Remove(_)
43                            | EventKind::Any
44                    );
45                    if !relevant {
46                        return;
47                    }
48                    for p in ev.paths {
49                        if is_markdown(&p) {
50                            let _ = tx.send(p);
51                        }
52                    }
53                }
54            })?;
55
56        for root in &roots {
57            if root.exists() {
58                watcher.watch(root, RecursiveMode::Recursive)?;
59            }
60        }
61        Ok(Self { _watcher: watcher })
62    }
63}
64
65/// Coalesce events: a path fires only after `debounce` of quiet following its
66/// last change. Repeated changes within the window reset its timer.
67fn debounce_loop(rx: mpsc::Receiver<PathBuf>, debounce: Duration, on_change: OnChange) {
68    let mut pending: HashMap<PathBuf, Instant> = HashMap::new();
69
70    loop {
71        let now = Instant::now();
72        // Earliest due time across pending paths.
73        let next_due = pending.values().copied().min();
74
75        let timeout = match next_due {
76            Some(due) if due > now => Some(due - now),
77            _ => Some(Duration::from_millis(10)),
78        };
79
80        match rx.recv_timeout(timeout.unwrap_or(Duration::from_millis(10))) {
81            Ok(path) => {
82                pending.insert(path, Instant::now() + debounce);
83            }
84            Err(mpsc::RecvTimeoutError::Timeout) => {
85                // Fire any path whose debounce has elapsed.
86                let now = Instant::now();
87                let due: Vec<PathBuf> = pending
88                    .iter()
89                    .filter(|entry| *entry.1 <= now)
90                    .map(|(p, _)| p.clone())
91                    .collect();
92                for p in due {
93                    pending.remove(&p);
94                    on_change(p);
95                }
96            }
97            Err(mpsc::RecvTimeoutError::Disconnected) => break,
98        }
99    }
100}
101
102fn is_markdown(p: &Path) -> bool {
103    p.extension().is_some_and(|e| e == "md")
104}