Skip to main content

herogpui_theme/
theme_watch.rs

1//! Theme hot reload: re-register a directory's theme files when they change
2//! (HeroGPUI extension; `watch` feature).
3//!
4//! [`watch_themes_dir`] polls the directory [`load_themes_dir`] read, on
5//! GPUI's executors, every [`THEME_WATCH_INTERVAL`]. A file whose contents
6//! changed (or a new file) is parsed and registered again under its id, which
7//! swaps the tokens of the active theme in place when the file defines it and
8//! repaints every window, so an edit to the theme in use shows up live.
9//!
10//! Polling rather than an OS file-notification API keeps the feature free of
11//! new dependencies and identical on every platform; the cost is reading a
12//! handful of small JSON files twice a second on a background thread, and
13//! only while a [`ThemeWatcher`] is alive.
14//!
15//! ```
16//! # fn startup(cx: &mut gpui::App) -> Result<(), herogpui_theme::ThemeLoadError> {
17//! use herogpui_theme::{load_themes_dir, watch_themes_dir};
18//!
19//! load_themes_dir("themes", cx)?;
20//! let watcher = watch_themes_dir("themes", |reload, _cx| {
21//!     for error in &reload.errors {
22//!         eprintln!("theme reload: {error}");
23//!     }
24//! }, cx)?;
25//! // Keep `watcher` alive (e.g. in the root view); dropping it stops watching.
26//! # drop(watcher);
27//! # Ok(())
28//! # }
29//! ```
30//!
31//! [`load_themes_dir`]: crate::load_themes_dir
32
33use std::collections::HashMap;
34use std::path::{Path, PathBuf};
35use std::time::Duration;
36
37use gpui::{App, AppContext as _, AsyncApp, SharedString, Task};
38
39use crate::theme_registry::{register_theme_file, theme_files};
40use crate::ThemeLoadError;
41
42/// How often [`watch_themes_dir`] looks for changed theme files.
43pub const THEME_WATCH_INTERVAL: Duration = Duration::from_millis(500);
44
45/// What one poll of [`watch_themes_dir`] changed. The callback is called only
46/// for a poll that found something: a reloaded file or a new error.
47#[derive(Debug, Default)]
48#[non_exhaustive]
49pub struct ThemeReload {
50    /// The ids registered again, in file-name order.
51    pub reloaded: Vec<SharedString>,
52    /// Files (or the directory) that could not be read or parsed. A file that
53    /// fails keeps its previously registered theme, and is reported again only
54    /// after its contents change.
55    pub errors: Vec<ThemeLoadError>,
56}
57
58/// A running [`watch_themes_dir`]. Dropping it stops the watch.
59#[must_use = "dropping a ThemeWatcher stops watching the directory"]
60pub struct ThemeWatcher {
61    dir: PathBuf,
62    _task: Task<()>,
63}
64
65impl ThemeWatcher {
66    /// The directory being watched.
67    pub fn dir(&self) -> &Path {
68        &self.dir
69    }
70}
71
72impl std::fmt::Debug for ThemeWatcher {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.debug_struct("ThemeWatcher")
75            .field("dir", &self.dir)
76            .finish_non_exhaustive()
77    }
78}
79
80/// The callback [`watch_themes_dir`] reports each changing poll to.
81type OnReload = Box<dyn FnMut(&ThemeReload, &mut App)>;
82
83/// One file as read by a poll: its path and its contents (or why not).
84type Scan = std::io::Result<Vec<(PathBuf, std::io::Result<String>)>>;
85
86fn scan(dir: &Path) -> Scan {
87    Ok(theme_files(dir)?
88        .into_iter()
89        .map(|path| {
90            let contents = std::fs::read_to_string(&path);
91            (path, contents)
92        })
93        .collect())
94}
95
96/// Watches `dir` for changed theme files and registers each again, calling
97/// `on_reload` after every poll that changed something.
98///
99/// The files present now are taken as already loaded (call
100/// [`load_themes_dir`](crate::load_themes_dir) first to register them); only
101/// later edits and new files are reloaded. A deleted file's theme stays
102/// registered. Returns an error only when `dir` cannot be read now.
103///
104/// Reloading only registers themes, exactly as loading does: it never
105/// switches the active theme, but an edit to the active theme's own file
106/// applies immediately.
107pub fn watch_themes_dir(
108    dir: impl AsRef<Path>,
109    on_reload: impl FnMut(&ThemeReload, &mut App) + 'static,
110    cx: &mut App,
111) -> Result<ThemeWatcher, ThemeLoadError> {
112    let dir = dir.as_ref().to_path_buf();
113    let seen: HashMap<PathBuf, String> = scan(&dir)
114        .map_err(|source| ThemeLoadError::Io {
115            path: dir.clone(),
116            source,
117        })?
118        .into_iter()
119        .filter_map(|(path, contents)| contents.ok().map(|c| (path, c)))
120        .collect();
121    let mut state = WatchState {
122        dir: dir.clone(),
123        seen,
124        dir_unreadable: false,
125        on_reload: Box::new(on_reload),
126    };
127    let task = cx.spawn(async move |cx: &mut AsyncApp| loop {
128        cx.background_executor().timer(THEME_WATCH_INTERVAL).await;
129        let scan_dir = state.dir.clone();
130        let result = cx.background_spawn(async move { scan(&scan_dir) }).await;
131        cx.update(|cx| state.apply(result, cx));
132    });
133    Ok(ThemeWatcher { dir, _task: task })
134}
135
136struct WatchState {
137    dir: PathBuf,
138    /// The last contents seen per file, whether or not they parsed.
139    seen: HashMap<PathBuf, String>,
140    /// Set after an unreadable-directory error was reported, so a directory
141    /// that stays missing is reported once rather than on every poll.
142    dir_unreadable: bool,
143    on_reload: OnReload,
144}
145
146impl WatchState {
147    fn apply(&mut self, scan: Scan, cx: &mut App) {
148        let mut reload = ThemeReload::default();
149        match scan {
150            Err(source) => {
151                if !self.dir_unreadable {
152                    self.dir_unreadable = true;
153                    reload.errors.push(ThemeLoadError::Io {
154                        path: self.dir.clone(),
155                        source,
156                    });
157                }
158            }
159            Ok(files) => {
160                self.dir_unreadable = false;
161                for (path, contents) in files {
162                    // An unreadable file is typically one caught mid-write or
163                    // deleted between listing and reading; the next poll sees
164                    // it settled.
165                    let Ok(json) = contents else { continue };
166                    if self.seen.get(&path) == Some(&json) {
167                        continue;
168                    }
169                    match register_theme_file(&path, &json, cx) {
170                        Ok(id) => reload.reloaded.push(id),
171                        Err(err) => reload.errors.push(err),
172                    }
173                    self.seen.insert(path, json);
174                }
175            }
176        }
177        if !reload.reloaded.is_empty() || !reload.errors.is_empty() {
178            (self.on_reload)(&reload, cx);
179        }
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use std::cell::RefCell;
186    use std::rc::Rc;
187
188    use super::*;
189    use crate::{load_themes_dir, use_theme, ActiveTheme, ThemeProvider};
190    use gpui::TestAppContext;
191
192    fn temp_dir(name: &str) -> PathBuf {
193        let dir =
194            std::env::temp_dir().join(format!("herogpui-watch-{name}-{}", std::process::id()));
195        let _ = std::fs::remove_dir_all(&dir);
196        std::fs::create_dir_all(&dir).unwrap();
197        dir
198    }
199
200    fn poll(cx: &mut TestAppContext) {
201        cx.executor().advance_clock(THEME_WATCH_INTERVAL);
202        cx.run_until_parked();
203    }
204
205    #[gpui::test]
206    fn an_edited_active_theme_reapplies_and_new_files_register(cx: &mut TestAppContext) {
207        let dir = temp_dir("edit");
208        let file = dir.join("brand.json");
209        std::fs::write(
210            &file,
211            r##"{"id":"brand","base":"light","accent":"#ff0000"}"##,
212        )
213        .unwrap();
214        // (reloaded ids, error count) per callback.
215        type Log = Rc<RefCell<Vec<(Vec<SharedString>, usize)>>>;
216        let reloads: Log = Rc::default();
217        let log = reloads.clone();
218        let watcher = cx.update(|cx| {
219            ThemeProvider::init(cx);
220            load_themes_dir(&dir, cx).unwrap();
221            use_theme("brand", cx).unwrap();
222            watch_themes_dir(
223                &dir,
224                move |reload, _| {
225                    log.borrow_mut()
226                        .push((reload.reloaded.clone(), reload.errors.len()));
227                },
228                cx,
229            )
230            .unwrap()
231        });
232        assert_eq!(watcher.dir(), dir.as_path());
233        let red = cx.read(|cx| cx.colors().accent.color);
234
235        // Nothing changed: no reload, no callback.
236        poll(cx);
237        assert!(reloads.borrow().is_empty());
238
239        std::fs::write(
240            &file,
241            r##"{"id":"brand","base":"light","accent":"#00ff00"}"##,
242        )
243        .unwrap();
244        poll(cx);
245        let green = cx.read(|cx| cx.colors().accent.color);
246        assert_ne!(red, green, "the active theme did not pick up the edit");
247        assert_eq!(
248            cx.read(|cx| ThemeProvider::get(cx).active_id().clone()),
249            "brand"
250        );
251        assert_eq!(*reloads.borrow(), [(vec![SharedString::from("brand")], 0)]);
252
253        // A broken edit keeps the last good theme and is reported once.
254        std::fs::write(&file, r#"{"id":"brand","base":"light","nope":1}"#).unwrap();
255        poll(cx);
256        poll(cx);
257        assert_eq!(cx.read(|cx| cx.colors().accent.color), green);
258        assert_eq!(reloads.borrow().len(), 2);
259        assert_eq!(reloads.borrow()[1], (vec![], 1));
260
261        // A new file registers without activating.
262        std::fs::write(dir.join("extra.json"), r#"{"id":"extra","base":"dark"}"#).unwrap();
263        poll(cx);
264        assert!(cx.read(|cx| ThemeProvider::get(cx).contains("extra")));
265        assert_eq!(
266            cx.read(|cx| ThemeProvider::get(cx).active_id().clone()),
267            "brand"
268        );
269
270        // Dropping the watcher stops it.
271        drop(watcher);
272        std::fs::write(
273            &file,
274            r##"{"id":"brand","base":"light","accent":"#0000ff"}"##,
275        )
276        .unwrap();
277        poll(cx);
278        assert_eq!(cx.read(|cx| cx.colors().accent.color), green);
279        assert_eq!(reloads.borrow().len(), 3);
280
281        std::fs::remove_dir_all(&dir).unwrap();
282    }
283
284    #[gpui::test]
285    fn an_unreadable_directory_is_an_error(cx: &mut TestAppContext) {
286        let dir = temp_dir("missing");
287        std::fs::remove_dir_all(&dir).unwrap();
288        cx.update(|cx| {
289            ThemeProvider::init(cx);
290            let err = watch_themes_dir(&dir, |_, _| {}, cx).unwrap_err();
291            assert!(matches!(err, ThemeLoadError::Io { .. }), "{err:?}");
292        });
293    }
294}