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
4use 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/// A normalized file-system change emitted by [`FileWatcher`].
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum FileChangeKind {
14    /// A watched file appeared on disk.
15    Created,
16    /// A watched file changed on disk.
17    Changed,
18    /// A watched file disappeared from disk.
19    Deleted,
20}
21
22/// A file-system event for one watched path.
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct WatchEvent {
25    /// The affected watched path.
26    pub path: PathBuf,
27    /// The normalized change kind for this path.
28    pub kind: FileChangeKind,
29}
30
31/// A file watcher for a set of source or resource paths.
32pub struct FileWatcher {
33    tx: mpsc::Sender<WorkerMessage>,
34
35    /// Use a worker thread for processing file events and updating watches.
36    ///
37    /// `notify` already invokes callbacks from backend-managed threads/event loops, but
38    /// reconcile performs `watch()` / `unwatch()` calls as it updates probe directories.
39    /// Backends such as inotify and kqueue route those operations through the same backend
40    /// loop and wait synchronously for an acknowledgement, so running reconcile directly in
41    /// the callback can deadlock. The dedicated worker thread keeps that work off the
42    /// backend callback thread while still serializing all watcher state transitions.
43    worker: Option<JoinHandle<()>>,
44}
45
46impl FileWatcher {
47    /// Creates a watcher and invokes `on_event` for matching watched-path changes.
48    ///
49    /// Runtime watcher errors are forwarded to `on_error`.
50    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    /// Replaces the watched path set with `paths`.
75    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    /// The set of paths to watch
171    watched_files: HashSet<PathBuf>,
172    target_states: HashMap<PathBuf, TargetState>,
173    /// The set of actually registered watch paths, which may include probe directories and/or directly watched files.
174    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(&current_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        // Keep the backend callback lightweight and forward the real work to the worker.
316        //
317        // This is especially needed on inotify backends, where calling watch/unwatch within
318        // the callback can cause a deadlock.
319        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: &notify::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    // On macOS, notify does not report file changed events, if we only watch the parent
424    // directory, so we need to add a direct file watch as well.
425    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}