Skip to main content

trail/workers/
fswatch.rs

1//! Filesystem watcher.
2//!
3//! Wraps `notify`, watching `state.cwd` only. Re-subscribes when the
4//! directory changes. Debounces bursts of events (e.g. a `git checkout`) into
5//! a single `WorkerMsg::FsChanged` after a quiet window.
6//!
7//! # Debounce
8//!
9//! Default quiet window: 200 ms (`DEFAULT_DEBOUNCE_MS`). Phase 7 exposes the
10//! runtime value as `general.fs_watch_debounce_ms`.
11//!
12//! # Re-subscription
13//!
14//! Watching is directory-scoped: when the user navigates to a new directory,
15//! the caller drops the `FsWatchHandle` to cancel the current watch and calls
16//! `spawn_fswatch` again with the new path.
17
18use std::path::PathBuf;
19use std::time::Duration;
20
21use notify::event::{EventKind, ModifyKind};
22use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
23use tokio::sync::mpsc::Sender;
24use tokio::task::JoinHandle;
25use tokio::time;
26
27use crate::workers::WorkerMsg;
28
29// ── Constants ─────────────────────────────────────────────────────────────────
30
31// ── FsWatchHandle ─────────────────────────────────────────────────────────────
32
33/// An opaque handle to a running filesystem watch task.
34///
35/// Dropping this value cancels the underlying Tokio task, which in turn drops
36/// the `notify` watcher. The UI thread should drop its current `FsWatchHandle`
37/// before calling `spawn_fswatch` with a new path.
38pub struct FsWatchHandle {
39    /// The Tokio task running the watch loop.
40    task: JoinHandle<()>,
41}
42
43impl FsWatchHandle {
44    /// Cancels the watch task.
45    #[allow(dead_code)]
46    pub fn cancel(self) {
47        self.task.abort();
48    }
49}
50
51impl Drop for FsWatchHandle {
52    fn drop(&mut self) {
53        self.task.abort();
54    }
55}
56
57// ── spawn_fswatch ─────────────────────────────────────────────────────────────
58
59/// Spawns a Tokio task that watches `path` for filesystem changes and sends
60/// `WorkerMsg::FsChanged` over `tx` after the debounce window elapses.
61///
62/// Returns an `FsWatchHandle` that the caller should drop when navigating to a
63/// new directory. The task exits automatically when the handle is dropped.
64///
65/// If the watcher cannot be created (e.g. too many open file descriptors), the
66/// error is logged at `debug` level and `None` is returned — the UI remains
67/// functional, just without automatic refresh.
68pub fn spawn_fswatch(
69    path: PathBuf,
70    tx: Sender<WorkerMsg>,
71    debounce_ms: u64,
72) -> Option<FsWatchHandle> {
73    // Bridge: notify uses a synchronous callback; we bridge to Tokio via an
74    // unbounded tokio mpsc so the callback is non-blocking (it just sends to
75    // an in-memory queue that the async task drains).
76    let (bridge_tx, mut bridge_rx) =
77        tokio::sync::mpsc::unbounded_channel::<notify::Result<Event>>();
78
79    let mut watcher = match RecommendedWatcher::new(
80        move |res: notify::Result<Event>| {
81            // Ignore send error: the task may have been cancelled.
82            let _ = bridge_tx.send(res);
83        },
84        notify::Config::default(),
85    ) {
86        Ok(w) => w,
87        Err(e) => {
88            tracing::debug!(?path, "failed to create fs watcher: {e}");
89            return None;
90        }
91    };
92
93    // Watch only the current directory (non-recursive).
94    if let Err(e) = watcher.watch(&path, RecursiveMode::NonRecursive) {
95        tracing::debug!(?path, "failed to watch directory: {e}");
96        return None;
97    }
98
99    // Also watch .git/HEAD (if it exists) so that external git operations like
100    // `git checkout` trigger a git status refresh. We walk up from `path` to
101    // find the repo root's .git directory.
102    let git_head_path: Option<PathBuf> = {
103        let mut search = path.clone();
104        loop {
105            let candidate = search.join(".git").join("HEAD");
106            if candidate.exists() {
107                break Some(candidate);
108            }
109            match search.parent() {
110                Some(p) => search = p.to_owned(),
111                None => break None,
112            }
113        }
114    };
115    if let Some(ref head) = git_head_path {
116        // Watch only the HEAD file itself (non-recursive on its parent dir would
117        // generate too many events; watching a single file is fine with notify).
118        if let Err(e) = watcher.watch(head, RecursiveMode::NonRecursive) {
119            // Non-fatal: git status just won't auto-refresh on branch change.
120            tracing::debug!(?head, "failed to watch .git/HEAD: {e}");
121        } else {
122            tracing::debug!(?head, "watching .git/HEAD for branch changes");
123        }
124    }
125
126    let debounce = Duration::from_millis(debounce_ms);
127    let watch_path = path.clone();
128
129    let task = tokio::spawn(async move {
130        // Keep the watcher alive for the duration of this task.
131        // Dropping it here when the task is aborted removes the OS watch.
132        let _watcher = watcher;
133
134        loop {
135            // Wait for the first event.
136            let first = bridge_rx.recv().await;
137            match first {
138                None => break, // bridge_tx dropped (watcher task ended)
139                Some(Ok(event)) if !is_relevant_event(&event) => {
140                    continue; // skip access-only events
141                }
142                Some(Err(e)) => {
143                    tracing::debug!(?watch_path, "fs watch error: {e}");
144                    continue;
145                }
146                Some(Ok(_)) => {
147                    // Relevant event received — start the debounce window.
148                    // Drain any further events that arrive within the window.
149                    let deadline = time::Instant::now() + debounce;
150                    loop {
151                        match time::timeout_at(deadline, bridge_rx.recv()).await {
152                            Ok(Some(_)) => {
153                                // More events within the window — reset is handled
154                                // by just draining; the outer sleep covers the window.
155                            }
156                            Ok(None) => {
157                                // bridge_tx dropped.
158                                return;
159                            }
160                            Err(_) => {
161                                // Debounce window elapsed with no more events.
162                                break;
163                            }
164                        }
165                    }
166
167                    let msg = WorkerMsg::FsChanged {
168                        path: watch_path.clone(),
169                    };
170                    if tx.send(msg).await.is_err() {
171                        // Receiver dropped (UI thread exited) — stop watching.
172                        break;
173                    }
174                    tracing::debug!(?watch_path, "FsChanged sent after debounce");
175                }
176            }
177        }
178    });
179
180    Some(FsWatchHandle { task })
181}
182
183// ── Helpers ───────────────────────────────────────────────────────────────────
184
185/// Returns `true` for filesystem events that should trigger a directory refresh.
186///
187/// Access-only events (reads) are excluded to avoid churning the UI when a
188/// file is merely being read (e.g. during a syntax-highlight preview).
189fn is_relevant_event(event: &Event) -> bool {
190    match &event.kind {
191        EventKind::Create(_)
192        | EventKind::Remove(_)
193        | EventKind::Modify(ModifyKind::Name(_))
194        | EventKind::Modify(ModifyKind::Data(_))
195        | EventKind::Modify(ModifyKind::Metadata(_)) => true,
196        // Access events, Other, and Any are not relevant.
197        _ => false,
198    }
199}