1use 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
42pub const THEME_WATCH_INTERVAL: Duration = Duration::from_millis(500);
44
45#[derive(Debug, Default)]
48#[non_exhaustive]
49pub struct ThemeReload {
50 pub reloaded: Vec<SharedString>,
52 pub errors: Vec<ThemeLoadError>,
56}
57
58#[must_use = "dropping a ThemeWatcher stops watching the directory"]
60pub struct ThemeWatcher {
61 dir: PathBuf,
62 _task: Task<()>,
63}
64
65impl ThemeWatcher {
66 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
80type OnReload = Box<dyn FnMut(&ThemeReload, &mut App)>;
82
83type 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
96pub 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 seen: HashMap<PathBuf, String>,
140 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 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 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 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 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 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 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}