Skip to main content

rig_harness/
watch.rs

1//! Watching files. An entity with a [`FileWatch`] on a file or a
2//! directory gets a [`FileChanges`] message naming the files there when it
3//! was armed, then one for each batch of files created, changed or removed
4//! under it, as the system reports them, and the loop is woken for each;
5//! despawning it, or removing the `FileWatch`, stops the watch. A plugin
6//! that reloads templates or skills from a directory watches it, and reads
7//! them on its first `FileChanges`, so no file made in between is missed.
8//!
9//! The watch is a [`Running::stream`] on its entity, so put a `FileWatch`
10//! on an entity of its own. It watches through the project's workspace
11//! ([`ProjectWorkspace`], this machine when there is none): on this
12//! machine the system tells it of changes; another workspace may look
13//! every second ([`Workspace::watch`]). It reports the files
14//! [`Workspace::files`] lists with the watch's [`Listing`] (by default
15//! leaving out ignored and hidden files and `target`). The plugin guide's
16//! [files section](crate::plugin_guide#the-workspace-files-and-child-processes)
17//! has an example.
18
19use std::path::PathBuf;
20use std::sync::Arc;
21
22use bevy_app::prelude::*;
23use bevy_ecs::lifecycle::HookContext;
24use bevy_ecs::prelude::*;
25use bevy_ecs::world::DeferredWorld;
26use bevy_log::warn;
27use bevy_tasks::futures_lite::{Stream, StreamExt as _, stream};
28use bevy_tasks::{IoTaskPool, TaskPool};
29use rig_ecs::tasks::{Done, Running, Wake};
30use rig_tools::workspace::{Listing, Watch, Workspace};
31
32use crate::workspace::ProjectWorkspace;
33
34/// Watches `path`: the file, or the directory's files (with `recursive`,
35/// the files of every directory under it). Inserting another arms it
36/// again.
37#[derive(Component, Clone, Debug, PartialEq, Eq)]
38#[component(immutable, on_insert = arm, on_discard = disarm)]
39pub struct FileWatch {
40    /// The file or directory.
41    pub path: PathBuf,
42    /// Whether directories under it are watched too.
43    pub recursive: bool,
44    /// Which files are reported, at first and as they change.
45    pub listing: Listing,
46}
47
48impl FileWatch {
49    /// A watch of `path` alone, or of the files right in it.
50    pub fn new(path: impl Into<PathBuf>) -> Self {
51        Self {
52            path: path.into(),
53            recursive: false,
54            listing: Listing::Visible,
55        }
56    }
57
58    /// A watch of every file under `path`.
59    pub fn tree(path: impl Into<PathBuf>) -> Self {
60        Self {
61            path: path.into(),
62            recursive: true,
63            listing: Listing::Visible,
64        }
65    }
66
67    /// The same watch, reporting the files `listing` keeps.
68    pub fn listing(mut self, listing: Listing) -> Self {
69        self.listing = listing;
70        self
71    }
72}
73
74/// What changed under a [`FileWatch`]: first the files there when it was
75/// armed, then what changed since it last looked.
76#[derive(Message, Clone, Debug, PartialEq, Eq)]
77pub struct FileChanges {
78    /// The entity with the watch.
79    pub watch: Entity,
80    /// The files there at first, then the files created, changed or
81    /// removed, in path order.
82    pub paths: Vec<PathBuf>,
83}
84
85/// Passes on what the watches see.
86pub struct WatchPlugin;
87
88impl Plugin for WatchPlugin {
89    fn build(&self, app: &mut App) {
90        app.add_message::<FileChanges>().add_observer(changed);
91    }
92}
93
94/// What a watch saw: the files there, then a batch of changes.
95struct Seen(Vec<PathBuf>);
96
97/// Starts the watch through the project's workspace.
98fn arm(mut world: DeferredWorld, context: HookContext) {
99    let Some(watch) = world.get::<FileWatch>(context.entity).cloned() else {
100        return;
101    };
102    let workspace = world.get_resource::<ProjectWorkspace>().cloned();
103    let workspace = workspace.unwrap_or_default().0;
104    let wake = world.get_resource::<Wake>().cloned().unwrap_or_default();
105    let pool = IoTaskPool::get_or_init(TaskPool::default);
106    let running = Running::stream(pool, &wake, watch_stream(workspace, watch));
107    world.commands().entity(context.entity).insert(running);
108}
109
110/// Stops the watch.
111fn disarm(mut world: DeferredWorld, context: HookContext) {
112    world
113        .commands()
114        .entity(context.entity)
115        .try_remove::<(Running, Done<Seen>)>();
116}
117
118/// Writes what a watch saw.
119fn changed(
120    inserted: On<Insert<Done<Seen>>>,
121    seen: Query<&Done<Seen>, With<FileWatch>>,
122    mut changes: MessageWriter<FileChanges>,
123) {
124    if let Ok(Done(Seen(paths))) = seen.get(inserted.entity) {
125        changes.write(FileChanges {
126            watch: inserted.entity,
127            paths: paths.clone(),
128        });
129    }
130}
131
132/// Where a watch is.
133enum WatchState {
134    /// Not armed yet.
135    Unarmed(Arc<dyn Workspace>, FileWatch),
136    /// Armed: the batches to come.
137    Armed(Watch),
138}
139
140/// The files `watch` covers at first, then each batch of changes the
141/// workspace reports.
142fn watch_stream(workspace: Arc<dyn Workspace>, watch: FileWatch) -> impl Stream<Item = Seen> {
143    stream::unfold(
144        Some(WatchState::Unarmed(workspace, watch)),
145        |state| async move {
146            match state? {
147                WatchState::Unarmed(workspace, watch) => {
148                    // Armed before listing, so nothing made in between is missed.
149                    let armed =
150                        workspace
151                            .clone()
152                            .watch(&watch.path, watch.recursive, watch.listing);
153                    let files = files_now(&*workspace, &watch);
154                    let next = match armed {
155                        Ok(armed) => Some(WatchState::Armed(armed)),
156                        Err(error) => {
157                            warn!("cannot watch {}: {error}", watch.path.display());
158                            None
159                        }
160                    };
161                    Some((Seen(files), next))
162                }
163                WatchState::Armed(mut armed) => {
164                    let paths = armed.next().await?;
165                    Some((Seen(paths), Some(WatchState::Armed(armed))))
166                }
167            }
168        },
169    )
170}
171
172/// The files `watch` covers now, in path order.
173fn files_now(workspace: &dyn Workspace, watch: &FileWatch) -> Vec<PathBuf> {
174    let root = watch.path.as_path();
175    let mut files = workspace
176        .files(root, None, watch.listing)
177        .unwrap_or_default();
178    files.retain(|path| watch.recursive || path == root || path.parent() == Some(root));
179    files.sort();
180    files
181}
182
183#[cfg(test)]
184mod tests;