1use std::collections::{HashMap, HashSet};
5use std::path::{Path, PathBuf};
6use std::sync::mpsc;
7use std::thread::{self, JoinHandle};
8
9use notify::Watcher as _;
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum FileChangeKind {
14 Created,
16 Changed,
18 Deleted,
20}
21
22#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct WatchEvent {
25 pub path: PathBuf,
27 pub kind: FileChangeKind,
29}
30
31pub struct FileWatcher {
33 tx: mpsc::Sender<WorkerMessage>,
34
35 worker: Option<JoinHandle<()>>,
44}
45
46impl FileWatcher {
47 pub fn start(
51 on_event: impl FnMut(WatchEvent) + Send + 'static,
52 on_error: impl FnMut(notify::Error) + Send + 'static,
53 ) -> notify::Result<Self> {
54 let (tx, rx) = mpsc::channel();
55 let (startup_tx, startup_rx) = mpsc::sync_channel(1);
56 let worker_tx = tx.clone();
57 let worker = thread::spawn(move || {
58 worker_loop(rx, worker_tx, startup_tx, on_event, on_error);
59 });
60
61 match startup_rx.recv() {
62 Ok(Ok(())) => Ok(Self { tx, worker: Some(worker) }),
63 Ok(Err(err)) => {
64 let _ = worker.join();
65 Err(err)
66 }
67 Err(_) => {
68 let _ = worker.join();
69 Err(worker_stopped_error())
70 }
71 }
72 }
73
74 pub fn update_watched_paths<I>(&mut self, paths: I) -> notify::Result<()>
76 where
77 I: IntoIterator<Item = PathBuf>,
78 {
79 let watched_files = paths
80 .into_iter()
81 .map(|path| i_slint_compiler::pathutils::clean_path(&path))
82 .collect::<HashSet<_>>();
83
84 let (response_tx, response_rx) = mpsc::sync_channel(1);
85 self.tx
86 .send(WorkerMessage::UpdateWatchedPaths { watched_files, response: response_tx })
87 .map_err(|_| worker_stopped_error())?;
88 response_rx.recv().map_err(|_| worker_stopped_error())?
89 }
90}
91
92impl Drop for FileWatcher {
93 fn drop(&mut self) {
94 let _ = self.tx.send(WorkerMessage::Shutdown);
95 if let Some(worker) = self.worker.take() {
96 let _ = worker.join();
97 }
98 }
99}
100
101fn classify_event(event: notify::Event) -> Vec<(PathBuf, FileChangeKind)> {
102 use notify::EventKind;
103 use notify::event::{ModifyKind, RenameMode};
104
105 fn map_event(event: notify::Event, kind: FileChangeKind) -> Vec<(PathBuf, FileChangeKind)> {
106 event
107 .paths
108 .into_iter()
109 .map(|path| (i_slint_compiler::pathutils::clean_path(&path), kind))
110 .collect()
111 }
112
113 match event.kind {
114 EventKind::Create(_) => map_event(event, FileChangeKind::Created),
115 EventKind::Remove(_) => map_event(event, FileChangeKind::Deleted),
116 EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {
117 map_event(event, FileChangeKind::Deleted)
118 }
119 EventKind::Modify(ModifyKind::Name(RenameMode::To)) => {
120 map_event(event, FileChangeKind::Created)
121 }
122 EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => {
123 let mut paths = event.paths.into_iter();
124 [
125 paths.next().map(|path| {
126 (i_slint_compiler::pathutils::clean_path(&path), FileChangeKind::Deleted)
127 }),
128 paths.next().map(|path| {
129 (i_slint_compiler::pathutils::clean_path(&path), FileChangeKind::Created)
130 }),
131 ]
132 .into_iter()
133 .flatten()
134 .collect()
135 }
136 EventKind::Modify(_) => map_event(event, FileChangeKind::Changed),
137 _ => Vec::new(),
138 }
139}
140
141enum WorkerMessage {
142 UpdateWatchedPaths {
143 watched_files: HashSet<PathBuf>,
144 response: mpsc::SyncSender<notify::Result<()>>,
145 },
146 RawEvent(notify::Result<notify::Event>),
147 Shutdown,
148}
149
150#[derive(Clone, Debug, Eq, PartialEq)]
151enum TargetState {
152 Existing { probe_dir: Option<PathBuf> },
153 Missing { probe_dir: Option<PathBuf> },
154}
155
156impl TargetState {
157 fn exists(&self) -> bool {
158 matches!(self, Self::Existing { .. })
159 }
160
161 fn probe_dir(&self) -> Option<&PathBuf> {
162 match self {
163 Self::Existing { probe_dir } | Self::Missing { probe_dir } => probe_dir.as_ref(),
164 }
165 }
166}
167
168#[derive(Default, Debug)]
169struct WorkerState {
170 watched_files: HashSet<PathBuf>,
172 target_states: HashMap<PathBuf, TargetState>,
173 registered_watches: HashSet<PathBuf>,
175}
176
177impl WorkerState {
178 fn update_watched_paths(
179 &mut self,
180 watcher: &mut notify::RecommendedWatcher,
181 watched_files: HashSet<PathBuf>,
182 on_event: &mut impl FnMut(WatchEvent),
183 ) -> notify::Result<()> {
184 let previous_states = watched_files
185 .iter()
186 .map(|path| {
187 let state = self
188 .target_states
189 .get(path)
190 .cloned()
191 .unwrap_or_else(|| scan_target_state(path));
192 (path.clone(), state)
193 })
194 .collect::<HashMap<_, _>>();
195
196 self.watched_files = watched_files;
197 self.target_states = previous_states.clone();
198 self.reconcile(watcher, previous_states, HashSet::new(), on_event)
199 }
200
201 fn handle_raw_event(
202 &mut self,
203 watcher: &mut notify::RecommendedWatcher,
204 event: notify::Event,
205 on_event: &mut impl FnMut(WatchEvent),
206 ) -> notify::Result<()> {
207 if self.watched_files.is_empty() {
208 return Ok(());
209 }
210
211 let previous_states = self.target_states.clone();
212 let changed_paths = classify_event(event)
213 .into_iter()
214 .filter_map(|(path, kind)| {
215 (kind == FileChangeKind::Changed && self.watched_files.contains(&path))
216 .then_some(path)
217 })
218 .collect::<HashSet<_>>();
219
220 self.reconcile(watcher, previous_states, changed_paths, on_event)
221 }
222
223 fn reconcile(
224 &mut self,
225 watcher: &mut notify::RecommendedWatcher,
226 previous_states: HashMap<PathBuf, TargetState>,
227 changed_paths: HashSet<PathBuf>,
228 on_event: &mut impl FnMut(WatchEvent),
229 ) -> notify::Result<()> {
230 const MAX_RECONCILE_PASSES: usize = 8;
231
232 let mut target_states = scan_target_states(&self.watched_files);
233
234 for _ in 0..MAX_RECONCILE_PASSES {
235 let desired_watches = desired_watches_for_states(&target_states);
236 if desired_watches == self.registered_watches {
237 break;
238 }
239
240 self.apply_watch_plan(watcher, &desired_watches)?;
241 target_states = scan_target_states(&self.watched_files);
242 }
243
244 self.target_states = target_states;
245
246 let mut transitioned_paths = HashSet::new();
247 for path in &self.watched_files {
248 let previous = previous_states.get(path).map(TargetState::exists).unwrap_or(false);
249 let current = self.target_states.get(path).map(TargetState::exists).unwrap_or(false);
250
251 match (previous, current) {
252 (false, true) => {
253 transitioned_paths.insert(path.clone());
254 on_event(WatchEvent { path: path.clone(), kind: FileChangeKind::Created });
255 }
256 (true, false) => {
257 transitioned_paths.insert(path.clone());
258 on_event(WatchEvent { path: path.clone(), kind: FileChangeKind::Deleted });
259 }
260 _ => {}
261 }
262 }
263
264 for path in changed_paths {
265 if transitioned_paths.contains(&path) {
266 continue;
267 }
268
269 if self.target_states.get(&path).map(TargetState::exists).unwrap_or(false) {
270 on_event(WatchEvent { path, kind: FileChangeKind::Changed });
271 }
272 }
273
274 Ok(())
275 }
276
277 fn apply_watch_plan(
278 &mut self,
279 watcher: &mut notify::RecommendedWatcher,
280 desired_registrations: &HashSet<PathBuf>,
281 ) -> notify::Result<()> {
282 let current_watches = self.registered_watches.clone();
283
284 for registration in desired_registrations.difference(¤t_watches) {
285 match watcher.watch(registration, notify::RecursiveMode::NonRecursive) {
286 Ok(()) => {
287 self.registered_watches.insert(registration.clone());
288 }
289 Err(err) if is_transient_watch_error(&err) => {}
290 Err(err) => return Err(err),
291 }
292 }
293
294 for registration in current_watches.difference(desired_registrations) {
295 match watcher.unwatch(registration) {
296 Ok(()) => {}
297 Err(err) if is_transient_watch_error(&err) => {}
298 Err(err) => return Err(err),
299 }
300 self.registered_watches.remove(registration);
301 }
302
303 Ok(())
304 }
305}
306
307fn worker_loop(
308 rx: mpsc::Receiver<WorkerMessage>,
309 tx: mpsc::Sender<WorkerMessage>,
310 startup_tx: mpsc::SyncSender<notify::Result<()>>,
311 mut on_event: impl FnMut(WatchEvent) + Send + 'static,
312 mut on_error: impl FnMut(notify::Error) + Send + 'static,
313) {
314 let watcher = notify::recommended_watcher(move |event| {
315 let _ = tx.send(WorkerMessage::RawEvent(event));
320 });
321
322 let mut watcher = match watcher {
323 Ok(watcher) => {
324 let _ = startup_tx.send(Ok(()));
325 watcher
326 }
327 Err(err) => {
328 let _ = startup_tx.send(Err(err));
329 return;
330 }
331 };
332
333 let mut state = WorkerState::default();
334
335 while let Ok(message) = rx.recv() {
336 match message {
337 WorkerMessage::UpdateWatchedPaths { watched_files, response } => {
338 let _ = response.send(state.update_watched_paths(
339 &mut watcher,
340 watched_files,
341 &mut on_event,
342 ));
343 }
344 WorkerMessage::RawEvent(Ok(event)) => {
345 if let Err(err) = state.handle_raw_event(&mut watcher, event, &mut on_event) {
346 on_error(err);
347 }
348 }
349 WorkerMessage::RawEvent(Err(err)) => {
350 if !is_transient_watch_error(&err) {
351 on_error(err);
352 }
353 }
354 WorkerMessage::Shutdown => break,
355 }
356 }
357}
358
359fn scan_target_states(watched_files: &HashSet<PathBuf>) -> HashMap<PathBuf, TargetState> {
360 watched_files.iter().map(|path| (path.clone(), scan_target_state(path))).collect()
361}
362
363fn scan_target_state(path: &Path) -> TargetState {
364 let probe_dir = probe_dir_for_path(path);
365 if path.exists() {
366 TargetState::Existing { probe_dir }
367 } else {
368 TargetState::Missing { probe_dir }
369 }
370}
371
372fn desired_watches_for_states(target_states: &HashMap<PathBuf, TargetState>) -> HashSet<PathBuf> {
373 let mut watches = target_states
374 .values()
375 .filter_map(|state| state.probe_dir().cloned())
376 .collect::<HashSet<_>>();
377
378 if needs_direct_file_watches() {
379 watches.extend(
380 target_states
381 .iter()
382 .filter(|(_path, state)| state.exists())
383 .map(|(path, _state)| path.clone()),
384 );
385 }
386
387 watches
388}
389
390fn probe_dir_for_path(path: &Path) -> Option<PathBuf> {
391 if path.exists() {
392 let parent = path.parent()?;
393 parent.is_dir().then(|| i_slint_compiler::pathutils::clean_path(parent))
394 } else {
395 nearest_existing_ancestor(path)
396 }
397}
398
399fn nearest_existing_ancestor(path: &Path) -> Option<PathBuf> {
400 let mut current = path.parent()?;
401 while !current.is_dir() {
402 current = current.parent()?;
403 }
404
405 Some(i_slint_compiler::pathutils::clean_path(current))
406}
407
408fn is_transient_watch_error(err: ¬ify::Error) -> bool {
409 match &err.kind {
410 notify::ErrorKind::PathNotFound
411 | notify::ErrorKind::WatchNotFound
412 | notify::ErrorKind::Generic(_) => true,
413 notify::ErrorKind::Io(e) => e.kind() == std::io::ErrorKind::NotFound,
414 _ => false,
415 }
416}
417
418fn worker_stopped_error() -> notify::Error {
419 notify::Error::generic("file watcher worker thread stopped")
420}
421
422fn needs_direct_file_watches() -> bool {
423 cfg!(target_os = "macos")
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 use std::fs;
433 use std::sync::atomic::{AtomicUsize, Ordering};
434 use std::sync::mpsc::{self, Receiver};
435 use std::time::{Duration, SystemTime, UNIX_EPOCH};
436
437 const WATCHER_SETTLE_DELAY: Duration = Duration::from_millis(50);
438 const EVENT_TIMEOUT: Duration = Duration::from_millis(100);
439 const QUIET_TIMEOUT: Duration = Duration::from_millis(50);
440
441 struct TestContext {
442 root: PathBuf,
443 watcher: FileWatcher,
444 events: Receiver<WatchEvent>,
445 errors: Receiver<notify::Error>,
446 }
447
448 impl TestContext {
449 fn new() -> Self {
450 static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
451
452 let unique_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
453 let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
454 let root = std::env::temp_dir()
455 .join(format!("slint-file-watcher-{timestamp}-{unique_id}-{}", std::process::id()));
456 fs::create_dir_all(&root).unwrap();
457 let (event_tx, events) = mpsc::channel();
458 let (error_tx, errors) = mpsc::channel();
459
460 let watcher = FileWatcher::start(
461 move |event| {
462 event_tx.send(event).unwrap();
463 },
464 move |error| {
465 error_tx.send(error).unwrap();
466 },
467 )
468 .unwrap();
469
470 Self { root, watcher, events, errors }
471 }
472
473 fn path(&self, relative: impl AsRef<Path>) -> PathBuf {
474 self.root.join(relative)
475 }
476
477 fn create_dir_all(&self, relative: impl AsRef<Path>) -> PathBuf {
478 let path = self.path(relative);
479 fs::create_dir_all(&path).unwrap();
480 path
481 }
482
483 fn write(&self, relative: impl AsRef<Path>, contents: &str) -> PathBuf {
484 let path = self.path(relative);
485 if let Some(parent) = path.parent() {
486 fs::create_dir_all(parent).unwrap();
487 }
488 fs::write(&path, contents).unwrap();
489 path
490 }
491
492 fn remove_file(&self, relative: impl AsRef<Path>) {
493 fs::remove_file(self.path(relative)).unwrap();
494 }
495
496 fn remove_dir_all(&self, relative: impl AsRef<Path>) {
497 fs::remove_dir_all(self.path(relative)).unwrap();
498 }
499
500 fn rename(&self, from: impl AsRef<Path>, to: impl AsRef<Path>) {
501 let from = self.path(from);
502 let to = self.path(to);
503 if let Some(parent) = to.parent() {
504 fs::create_dir_all(parent).unwrap();
505 }
506 fs::rename(from, to).unwrap();
507 }
508
509 fn watch(&mut self, relative_paths: &[&str]) {
510 let paths = relative_paths.iter().map(|path| self.path(*path)).collect::<Vec<_>>();
511 self.watcher.update_watched_paths(paths).unwrap();
512 self.settle();
513 self.drain_events();
514 self.assert_no_errors();
515 }
516
517 fn settle(&self) {
518 std::thread::sleep(WATCHER_SETTLE_DELAY);
519 }
520
521 fn drain_events(&self) -> Vec<WatchEvent> {
522 let mut events = Vec::new();
523 while let Ok(event) = self.events.try_recv() {
524 events.push(event);
525 }
526 events
527 }
528
529 fn drain_errors(&self) -> Vec<notify::Error> {
530 let mut errors = Vec::new();
531 while let Ok(error) = self.errors.try_recv() {
532 errors.push(error);
533 }
534 errors
535 }
536
537 fn assert_no_errors(&self) {
538 let errors = self.drain_errors();
539 assert!(errors.is_empty(), "unexpected watcher errors: {errors:?}");
540 }
541
542 fn expect_event(&self, path: &Path, kind: FileChangeKind) {
543 let expected = WatchEvent { path: path.to_path_buf(), kind };
544 let mut seen = Vec::new();
545
546 loop {
547 self.assert_no_errors();
548
549 match self.events.recv_timeout(EVENT_TIMEOUT) {
550 Ok(event) if event == expected => return,
551 Ok(event) => seen.push(event),
552 Err(mpsc::RecvTimeoutError::Timeout) => {
553 panic!("timed out waiting for {expected:?}; saw {seen:?}")
554 }
555 Err(mpsc::RecvTimeoutError::Disconnected) => {
556 panic!("watcher event channel disconnected while waiting for {expected:?}")
557 }
558 }
559 }
560 }
561
562 fn expect_quiet(&self) {
563 match self.events.recv_timeout(QUIET_TIMEOUT) {
564 Ok(event) => panic!("unexpected event during quiet period: {event:?}"),
565 Err(mpsc::RecvTimeoutError::Timeout) => {}
566 Err(mpsc::RecvTimeoutError::Disconnected) => {
567 panic!("watcher event channel disconnected while waiting for quiet period")
568 }
569 }
570
571 self.assert_no_errors();
572 }
573 }
574
575 impl Drop for TestContext {
576 fn drop(&mut self) {
577 let _ = fs::remove_dir_all(&self.root);
578 }
579 }
580
581 #[test]
582 fn reports_changed_for_existing_watched_file() {
583 let mut ctx = TestContext::new();
584 let watched = ctx.write("ui/main.slint", "first");
585
586 ctx.watch(&["ui/main.slint"]);
587 ctx.write("ui/main.slint", "second");
588
589 ctx.expect_event(&watched, FileChangeKind::Changed);
590 }
591
592 #[test]
593 fn reports_deleted_and_created_for_existing_watched_file() {
594 let mut ctx = TestContext::new();
595 let watched = ctx.write("ui/main.slint", "first");
596
597 ctx.watch(&["ui/main.slint"]);
598 ctx.remove_file("ui/main.slint");
599 ctx.expect_event(&watched, FileChangeKind::Deleted);
600
601 ctx.write("ui/main.slint", "second");
602 ctx.expect_event(&watched, FileChangeKind::Created);
603 }
604
605 #[test]
606 fn reports_deleted_when_watched_file_is_renamed_away() {
607 let mut ctx = TestContext::new();
608 let watched = ctx.write("ui/main.slint", "first");
609
610 ctx.watch(&["ui/main.slint"]);
611 ctx.rename("ui/main.slint", "ui/renamed.slint");
612
613 ctx.expect_event(&watched, FileChangeKind::Deleted);
614 }
615
616 #[test]
617 fn reports_created_when_file_is_renamed_into_watched_path() {
618 let mut ctx = TestContext::new();
619 let watched = ctx.path("ui/main.slint");
620
621 ctx.create_dir_all("ui");
622 ctx.write("ui/temp.slint", "temporary");
623 ctx.watch(&["ui/main.slint"]);
624 ctx.drain_events();
625
626 ctx.rename("ui/temp.slint", "ui/main.slint");
627
628 ctx.expect_event(&watched, FileChangeKind::Created);
629 }
630
631 #[test]
632 fn ignores_changes_to_unwatched_sibling_files() {
633 let mut ctx = TestContext::new();
634 ctx.write("ui/main.slint", "main");
635 ctx.write("ui/sibling.slint", "sibling");
636
637 ctx.watch(&["ui/main.slint"]);
638 ctx.write("ui/sibling.slint", "sibling changed");
639
640 ctx.expect_quiet();
641 }
642
643 #[test]
644 fn reports_created_for_missing_file_when_parent_directory_exists() {
645 let mut ctx = TestContext::new();
646 let watched = ctx.path("ui/missing.slint");
647
648 ctx.create_dir_all("ui");
649 ctx.watch(&["ui/missing.slint"]);
650 ctx.write("ui/missing.slint", "created later");
651
652 ctx.expect_event(&watched, FileChangeKind::Created);
653 }
654
655 #[test]
656 fn reports_created_for_missing_file_when_intermediate_directory_is_created_later() {
657 let mut ctx = TestContext::new();
658 let watched = ctx.path("ui/generated/missing.slint");
659
660 ctx.create_dir_all("ui");
661 ctx.watch(&["ui/generated/missing.slint"]);
662 ctx.write("ui/generated/missing.slint", "created with parent later");
663
664 ctx.expect_event(&watched, FileChangeKind::Created);
665 }
666
667 #[test]
668 fn reports_created_for_missing_file_when_directory_chain_is_created_later() {
669 let mut ctx = TestContext::new();
670 let watched = ctx.path("ui/generated/deep/missing.slint");
671
672 ctx.watch(&["ui/generated/deep/missing.slint"]);
673 ctx.write("ui/generated/deep/missing.slint", "created with full chain later");
674
675 ctx.expect_event(&watched, FileChangeKind::Created);
676 }
677
678 #[test]
679 fn refreshing_watch_set_stops_forwarding_old_paths() {
680 let mut ctx = TestContext::new();
681 let first = ctx.write("ui/first.slint", "first");
682 let second = ctx.write("ui/second.slint", "first");
683
684 ctx.watch(&["ui/first.slint"]);
685 ctx.write("ui/first.slint", "first updated");
686 ctx.expect_event(&first, FileChangeKind::Changed);
687 ctx.drain_events();
688
689 ctx.watch(&["ui/second.slint"]);
690 ctx.write("ui/first.slint", "should now be ignored");
691 ctx.expect_quiet();
692
693 ctx.write("ui/second.slint", "second updated");
694 ctx.expect_event(&second, FileChangeKind::Changed);
695 }
696
697 #[test]
698 fn refreshing_after_probe_directory_is_removed_recovers_cleanly() {
699 let mut ctx = TestContext::new();
700 ctx.write("test.slint", "export component Test { }");
701 let watched_nested = ctx.write("thing/thing.slint", "export component Thing { }");
702
703 ctx.watch(&["test.slint", "thing/thing.slint"]);
704 ctx.remove_dir_all("thing");
705 ctx.settle();
706 ctx.expect_event(&watched_nested, FileChangeKind::Deleted);
707 ctx.drain_events();
708 ctx.assert_no_errors();
709
710 ctx.watch(&["test.slint", "thing/thing.slint"]);
711
712 ctx.write("thing/thing.slint", "export component Thing { in property<string> x; }");
713 ctx.expect_event(&watched_nested, FileChangeKind::Created);
714 }
715
716 #[test]
717 fn removing_watched_directory_does_not_report_spurious_errors() {
718 let mut ctx = TestContext::new();
719 let watched = ctx.write("project/src/main.slint", "export component App { }");
720
721 ctx.watch(&["project/src/main.slint"]);
722 ctx.remove_dir_all("project");
723 ctx.expect_event(&watched, FileChangeKind::Deleted);
724 ctx.settle();
725 ctx.assert_no_errors();
726 }
727}