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}