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)) => on_error(err),
350 WorkerMessage::Shutdown => break,
351 }
352 }
353}
354
355fn scan_target_states(watched_files: &HashSet<PathBuf>) -> HashMap<PathBuf, TargetState> {
356 watched_files.iter().map(|path| (path.clone(), scan_target_state(path))).collect()
357}
358
359fn scan_target_state(path: &Path) -> TargetState {
360 let probe_dir = probe_dir_for_path(path);
361 if path.exists() {
362 TargetState::Existing { probe_dir }
363 } else {
364 TargetState::Missing { probe_dir }
365 }
366}
367
368fn desired_watches_for_states(target_states: &HashMap<PathBuf, TargetState>) -> HashSet<PathBuf> {
369 let mut watches = target_states
370 .values()
371 .filter_map(|state| state.probe_dir().cloned())
372 .collect::<HashSet<_>>();
373
374 if needs_direct_file_watches() {
375 watches.extend(
376 target_states
377 .iter()
378 .filter(|(_path, state)| state.exists())
379 .map(|(path, _state)| path.clone()),
380 );
381 }
382
383 watches
384}
385
386fn probe_dir_for_path(path: &Path) -> Option<PathBuf> {
387 if path.exists() {
388 let parent = path.parent()?;
389 parent.is_dir().then(|| i_slint_compiler::pathutils::clean_path(parent))
390 } else {
391 nearest_existing_ancestor(path)
392 }
393}
394
395fn nearest_existing_ancestor(path: &Path) -> Option<PathBuf> {
396 let mut current = path.parent()?;
397 while !current.is_dir() {
398 current = current.parent()?;
399 }
400
401 Some(i_slint_compiler::pathutils::clean_path(current))
402}
403
404fn is_transient_watch_error(err: ¬ify::Error) -> bool {
405 matches!(
406 err.kind,
407 notify::ErrorKind::PathNotFound
408 | notify::ErrorKind::WatchNotFound
409 | notify::ErrorKind::Generic(_)
410 )
411}
412
413fn worker_stopped_error() -> notify::Error {
414 notify::Error::generic("file watcher worker thread stopped")
415}
416
417fn needs_direct_file_watches() -> bool {
418 cfg!(target_os = "macos")
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 use std::fs;
428 use std::sync::atomic::{AtomicUsize, Ordering};
429 use std::sync::mpsc::{self, Receiver};
430 use std::time::{Duration, SystemTime, UNIX_EPOCH};
431
432 const WATCHER_SETTLE_DELAY: Duration = Duration::from_millis(50);
433 const EVENT_TIMEOUT: Duration = Duration::from_millis(100);
434 const QUIET_TIMEOUT: Duration = Duration::from_millis(50);
435
436 struct TestContext {
437 root: PathBuf,
438 watcher: FileWatcher,
439 events: Receiver<WatchEvent>,
440 errors: Receiver<notify::Error>,
441 }
442
443 impl TestContext {
444 fn new() -> Self {
445 static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
446
447 let unique_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
448 let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
449 let root = std::env::temp_dir()
450 .join(format!("slint-file-watcher-{timestamp}-{unique_id}-{}", std::process::id()));
451 fs::create_dir_all(&root).unwrap();
452 let (event_tx, events) = mpsc::channel();
453 let (error_tx, errors) = mpsc::channel();
454
455 let watcher = FileWatcher::start(
456 move |event| {
457 event_tx.send(event).unwrap();
458 },
459 move |error| {
460 error_tx.send(error).unwrap();
461 },
462 )
463 .unwrap();
464
465 Self { root, watcher, events, errors }
466 }
467
468 fn path(&self, relative: impl AsRef<Path>) -> PathBuf {
469 self.root.join(relative)
470 }
471
472 fn create_dir_all(&self, relative: impl AsRef<Path>) -> PathBuf {
473 let path = self.path(relative);
474 fs::create_dir_all(&path).unwrap();
475 path
476 }
477
478 fn write(&self, relative: impl AsRef<Path>, contents: &str) -> PathBuf {
479 let path = self.path(relative);
480 if let Some(parent) = path.parent() {
481 fs::create_dir_all(parent).unwrap();
482 }
483 fs::write(&path, contents).unwrap();
484 path
485 }
486
487 fn remove_file(&self, relative: impl AsRef<Path>) {
488 fs::remove_file(self.path(relative)).unwrap();
489 }
490
491 fn remove_dir_all(&self, relative: impl AsRef<Path>) {
492 fs::remove_dir_all(self.path(relative)).unwrap();
493 }
494
495 fn rename(&self, from: impl AsRef<Path>, to: impl AsRef<Path>) {
496 let from = self.path(from);
497 let to = self.path(to);
498 if let Some(parent) = to.parent() {
499 fs::create_dir_all(parent).unwrap();
500 }
501 fs::rename(from, to).unwrap();
502 }
503
504 fn watch(&mut self, relative_paths: &[&str]) {
505 let paths = relative_paths.iter().map(|path| self.path(*path)).collect::<Vec<_>>();
506 self.watcher.update_watched_paths(paths).unwrap();
507 self.settle();
508 self.drain_events();
509 self.assert_no_errors();
510 }
511
512 fn settle(&self) {
513 std::thread::sleep(WATCHER_SETTLE_DELAY);
514 }
515
516 fn drain_events(&self) -> Vec<WatchEvent> {
517 let mut events = Vec::new();
518 while let Ok(event) = self.events.try_recv() {
519 events.push(event);
520 }
521 events
522 }
523
524 fn drain_errors(&self) -> Vec<notify::Error> {
525 let mut errors = Vec::new();
526 while let Ok(error) = self.errors.try_recv() {
527 errors.push(error);
528 }
529 errors
530 }
531
532 fn assert_no_errors(&self) {
533 let errors = self.drain_errors();
534 assert!(errors.is_empty(), "unexpected watcher errors: {errors:?}");
535 }
536
537 fn expect_event(&self, path: &Path, kind: FileChangeKind) {
538 let expected = WatchEvent { path: path.to_path_buf(), kind };
539 let mut seen = Vec::new();
540
541 loop {
542 self.assert_no_errors();
543
544 match self.events.recv_timeout(EVENT_TIMEOUT) {
545 Ok(event) if event == expected => return,
546 Ok(event) => seen.push(event),
547 Err(mpsc::RecvTimeoutError::Timeout) => {
548 panic!("timed out waiting for {expected:?}; saw {seen:?}")
549 }
550 Err(mpsc::RecvTimeoutError::Disconnected) => {
551 panic!("watcher event channel disconnected while waiting for {expected:?}")
552 }
553 }
554 }
555 }
556
557 fn expect_quiet(&self) {
558 match self.events.recv_timeout(QUIET_TIMEOUT) {
559 Ok(event) => panic!("unexpected event during quiet period: {event:?}"),
560 Err(mpsc::RecvTimeoutError::Timeout) => {}
561 Err(mpsc::RecvTimeoutError::Disconnected) => {
562 panic!("watcher event channel disconnected while waiting for quiet period")
563 }
564 }
565
566 self.assert_no_errors();
567 }
568 }
569
570 impl Drop for TestContext {
571 fn drop(&mut self) {
572 let _ = fs::remove_dir_all(&self.root);
573 }
574 }
575
576 #[test]
577 fn reports_changed_for_existing_watched_file() {
578 let mut ctx = TestContext::new();
579 let watched = ctx.write("ui/main.slint", "first");
580
581 ctx.watch(&["ui/main.slint"]);
582 ctx.write("ui/main.slint", "second");
583
584 ctx.expect_event(&watched, FileChangeKind::Changed);
585 }
586
587 #[test]
588 fn reports_deleted_and_created_for_existing_watched_file() {
589 let mut ctx = TestContext::new();
590 let watched = ctx.write("ui/main.slint", "first");
591
592 ctx.watch(&["ui/main.slint"]);
593 ctx.remove_file("ui/main.slint");
594 ctx.expect_event(&watched, FileChangeKind::Deleted);
595
596 ctx.write("ui/main.slint", "second");
597 ctx.expect_event(&watched, FileChangeKind::Created);
598 }
599
600 #[test]
601 fn reports_deleted_when_watched_file_is_renamed_away() {
602 let mut ctx = TestContext::new();
603 let watched = ctx.write("ui/main.slint", "first");
604
605 ctx.watch(&["ui/main.slint"]);
606 ctx.rename("ui/main.slint", "ui/renamed.slint");
607
608 ctx.expect_event(&watched, FileChangeKind::Deleted);
609 }
610
611 #[test]
612 fn reports_created_when_file_is_renamed_into_watched_path() {
613 let mut ctx = TestContext::new();
614 let watched = ctx.path("ui/main.slint");
615
616 ctx.create_dir_all("ui");
617 ctx.write("ui/temp.slint", "temporary");
618 ctx.watch(&["ui/main.slint"]);
619 ctx.drain_events();
620
621 ctx.rename("ui/temp.slint", "ui/main.slint");
622
623 ctx.expect_event(&watched, FileChangeKind::Created);
624 }
625
626 #[test]
627 fn ignores_changes_to_unwatched_sibling_files() {
628 let mut ctx = TestContext::new();
629 ctx.write("ui/main.slint", "main");
630 ctx.write("ui/sibling.slint", "sibling");
631
632 ctx.watch(&["ui/main.slint"]);
633 ctx.write("ui/sibling.slint", "sibling changed");
634
635 ctx.expect_quiet();
636 }
637
638 #[test]
639 fn reports_created_for_missing_file_when_parent_directory_exists() {
640 let mut ctx = TestContext::new();
641 let watched = ctx.path("ui/missing.slint");
642
643 ctx.create_dir_all("ui");
644 ctx.watch(&["ui/missing.slint"]);
645 ctx.write("ui/missing.slint", "created later");
646
647 ctx.expect_event(&watched, FileChangeKind::Created);
648 }
649
650 #[test]
651 fn reports_created_for_missing_file_when_intermediate_directory_is_created_later() {
652 let mut ctx = TestContext::new();
653 let watched = ctx.path("ui/generated/missing.slint");
654
655 ctx.create_dir_all("ui");
656 ctx.watch(&["ui/generated/missing.slint"]);
657 ctx.write("ui/generated/missing.slint", "created with parent later");
658
659 ctx.expect_event(&watched, FileChangeKind::Created);
660 }
661
662 #[test]
663 fn reports_created_for_missing_file_when_directory_chain_is_created_later() {
664 let mut ctx = TestContext::new();
665 let watched = ctx.path("ui/generated/deep/missing.slint");
666
667 ctx.watch(&["ui/generated/deep/missing.slint"]);
668 ctx.write("ui/generated/deep/missing.slint", "created with full chain later");
669
670 ctx.expect_event(&watched, FileChangeKind::Created);
671 }
672
673 #[test]
674 fn refreshing_watch_set_stops_forwarding_old_paths() {
675 let mut ctx = TestContext::new();
676 let first = ctx.write("ui/first.slint", "first");
677 let second = ctx.write("ui/second.slint", "first");
678
679 ctx.watch(&["ui/first.slint"]);
680 ctx.write("ui/first.slint", "first updated");
681 ctx.expect_event(&first, FileChangeKind::Changed);
682 ctx.drain_events();
683
684 ctx.watch(&["ui/second.slint"]);
685 ctx.write("ui/first.slint", "should now be ignored");
686 ctx.expect_quiet();
687
688 ctx.write("ui/second.slint", "second updated");
689 ctx.expect_event(&second, FileChangeKind::Changed);
690 }
691
692 #[test]
693 fn refreshing_after_probe_directory_is_removed_recovers_cleanly() {
694 let mut ctx = TestContext::new();
695 ctx.write("test.slint", "export component Test { }");
696 let watched_nested = ctx.write("thing/thing.slint", "export component Thing { }");
697
698 ctx.watch(&["test.slint", "thing/thing.slint"]);
699 ctx.remove_dir_all("thing");
700 ctx.settle();
701 ctx.expect_event(&watched_nested, FileChangeKind::Deleted);
702 ctx.drain_events();
703 ctx.assert_no_errors();
704
705 ctx.watch(&["test.slint", "thing/thing.slint"]);
706
707 ctx.write("thing/thing.slint", "export component Thing { in property<string> x; }");
708 ctx.expect_event(&watched_nested, FileChangeKind::Created);
709 }
710}