1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
//! File system watcher for live reload functionality.
//!
//! Watches the currently open file for changes and notifies the TUI
//! to reload when modifications are detected.
use notify::{
Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher,
event::{AccessKind, AccessMode, ModifyKind, RenameMode},
};
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::time::{Duration, Instant};
/// Manages file watching for live reload.
pub struct FileWatcher {
watcher: RecommendedWatcher,
receiver: Receiver<Result<Event, notify::Error>>,
current_path: Option<PathBuf>,
/// The directory path actually being watched (parent of current_path)
watched_dir: Option<PathBuf>,
/// Timestamp of the first relevant event in the current debounce window
debounce_start: Option<Instant>,
debounce_duration: Duration,
}
impl FileWatcher {
/// Create a new file watcher.
pub fn new() -> Result<Self, notify::Error> {
let (tx, rx) = mpsc::channel();
let watcher = notify::recommended_watcher(tx)?;
Ok(Self {
watcher,
receiver: rx,
current_path: None,
watched_dir: None,
debounce_start: None,
debounce_duration: Duration::from_millis(100),
})
}
/// Start watching a file. Stops watching any previously watched file.
/// Watches the parent directory to support atomic saves (e.g. Helix).
pub fn watch(&mut self, path: &std::path::Path) -> Result<(), notify::Error> {
// Unwatch previous directory if any
if let Some(ref old_dir) = self.watched_dir {
let _ = self.watcher.unwatch(old_dir);
}
// Watch the parent directory of the file for atomic save support
let dir_path = path.parent().ok_or_else(|| {
notify::Error::generic("Cannot watch a file with no parent directory")
})?;
self.watcher.watch(dir_path, RecursiveMode::NonRecursive)?;
self.current_path = Some(path.to_path_buf());
self.watched_dir = Some(dir_path.to_path_buf());
self.debounce_start = None;
Ok(())
}
/// Stop watching the current file.
#[allow(dead_code)]
pub fn unwatch(&mut self) {
if let Some(ref dir) = self.watched_dir {
let _ = self.watcher.unwatch(dir);
}
self.current_path = None;
self.watched_dir = None;
self.debounce_start = None;
}
/// Check if the watched file has been modified.
/// Returns true if a reload should be triggered.
///
/// Uses non-blocking drain with time-based debouncing: the first relevant
/// event starts a debounce window, and we only signal a reload once the
/// window has elapsed on a subsequent poll. This avoids blocking the event loop.
pub fn check_for_changes(&mut self) -> bool {
// Drain all pending events (non-blocking)
let mut saw_relevant = false;
loop {
match self.receiver.try_recv() {
Ok(Ok(event)) => {
if self.is_relevant_event(&event) {
saw_relevant = true;
}
}
Ok(Err(_)) => {
// Watch error, ignore
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break,
}
}
if saw_relevant {
// Start debounce window if not already running
self.debounce_start.get_or_insert_with(Instant::now);
}
// Check if debounce window has elapsed
if let Some(start) = self.debounce_start
&& start.elapsed() >= self.debounce_duration
{
self.debounce_start = None;
return true;
}
false
}
/// Check if an event is relevant for triggering a reload.
fn is_relevant_event(&self, event: &Event) -> bool {
let Some(ref watched_path) = self.current_path else {
return false;
};
// Check if event path matches our watched file
// Use multiple strategies to handle platform differences
let matches_path =
event.paths.iter().any(|event_path| {
// Strategy 1: Exact path match
if event_path == watched_path {
return true;
}
// Strategy 2: Canonicalized path match (handles symlinks, case differences)
if let (Ok(event_canonical), Ok(watched_canonical)) =
(event_path.canonicalize(), watched_path.canonicalize())
&& event_canonical == watched_canonical
{
return true;
}
// Strategy 3: File name match (fallback for FSEvents quirks)
// Only match if event is in same directory
if let (
Some(event_name),
Some(watched_name),
Some(event_parent),
Some(watched_parent),
) = (
event_path.file_name(),
watched_path.file_name(),
event_path.parent(),
watched_path.parent(),
) && event_name == watched_name
{
// Verify same directory (canonicalize to handle . and ..)
if let (Ok(ep), Ok(wp)) =
(event_parent.canonicalize(), watched_parent.canonicalize())
{
return ep == wp;
}
}
false
});
if !matches_path {
return false;
}
// Check event kind - be permissive to catch various save patterns
matches!(
event.kind,
// Direct data modifications
EventKind::Modify(ModifyKind::Data(_))
| EventKind::Modify(ModifyKind::Any)
// File closed after write
| EventKind::Access(AccessKind::Close(AccessMode::Write))
// File created (new file or recreated)
| EventKind::Create(_)
// Atomic saves: write to temp then rename (or variant)
| EventKind::Modify(ModifyKind::Name(RenameMode::To))
| EventKind::Modify(ModifyKind::Name(RenameMode::Any))
)
}
/// Get the currently watched path.
#[allow(dead_code)]
pub fn current_path(&self) -> Option<&PathBuf> {
self.current_path.as_ref()
}
}
impl Default for FileWatcher {
fn default() -> Self {
Self::new().expect("Failed to create file watcher")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_watcher_creation() {
let watcher = FileWatcher::new();
assert!(watcher.is_ok());
}
}