Skip to main content

slint_interpreter/
file_watcher.rs

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