Watch the themes directory for changes (#7173)

Marshall Bowers created

This PR makes Zed watch the themes directory for changes.

When theme files are added or modified, we reload the theme and apply
any changes to Zed.

Release Notes:

- Added live reloading for the themes directory.

Change summary

crates/theme/src/registry.rs |  20 ++++--
crates/zed/src/main.rs       | 107 +++++++++++++++++++++++++++----------
2 files changed, 90 insertions(+), 37 deletions(-)

Detailed changes

crates/theme/src/registry.rs 🔗

@@ -255,16 +255,20 @@ impl ThemeRegistry {
                 continue;
             };
 
-            let Some(reader) = fs.open_sync(&theme_path).await.log_err() else {
-                continue;
-            };
+            self.load_user_theme(&theme_path, fs.clone())
+                .await
+                .log_err();
+        }
 
-            let Some(theme) = serde_json_lenient::from_reader(reader).log_err() else {
-                continue;
-            };
+        Ok(())
+    }
 
-            self.insert_user_theme_families([theme]);
-        }
+    /// Loads the user theme from the specified path and adds it to the registry.
+    pub async fn load_user_theme(&self, theme_path: &Path, fs: Arc<dyn Fs>) -> Result<()> {
+        let reader = fs.open_sync(&theme_path).await?;
+        let theme = serde_json_lenient::from_reader(reader)?;
+
+        self.insert_user_theme_families([theme]);
 
         Ok(())
     }

crates/zed/src/main.rs 🔗

@@ -11,6 +11,7 @@ use db::kvp::KEY_VALUE_STORE;
 use editor::Editor;
 use env_logger::Builder;
 use fs::RealFs;
+use fsevent::StreamFlags;
 use futures::StreamExt;
 use gpui::{App, AppContext, AsyncAppContext, Context, SemanticVersion, Task};
 use isahc::{prelude::Configurable, Request};
@@ -171,35 +172,8 @@ fn main() {
         );
         assistant::init(cx);
 
-        // TODO: Should we be loading the themes in a different spot?
-        cx.spawn({
-            let fs = fs.clone();
-            |cx| async move {
-                if let Some(theme_registry) =
-                    cx.update(|cx| ThemeRegistry::global(cx).clone()).log_err()
-                {
-                    if let Some(()) = theme_registry
-                        .load_user_themes(&paths::THEMES_DIR.clone(), fs)
-                        .await
-                        .log_err()
-                    {
-                        cx.update(|cx| {
-                            let mut theme_settings = ThemeSettings::get_global(cx).clone();
-
-                            if let Some(requested_theme) = theme_settings.requested_theme.clone() {
-                                if let Some(_theme) =
-                                    theme_settings.switch_theme(&requested_theme, cx)
-                                {
-                                    ThemeSettings::override_global(theme_settings, cx);
-                                }
-                            }
-                        })
-                        .log_err();
-                    }
-                }
-            }
-        })
-        .detach();
+        load_user_themes_in_background(fs.clone(), cx);
+        watch_themes(fs.clone(), cx);
 
         cx.spawn(|_| watch_languages(fs.clone(), languages.clone()))
             .detach();
@@ -903,6 +877,81 @@ fn load_embedded_fonts(cx: &AppContext) {
         .unwrap();
 }
 
+/// Spawns a background task to load the user themes from the themes directory.
+fn load_user_themes_in_background(fs: Arc<dyn fs::Fs>, cx: &mut AppContext) {
+    cx.spawn({
+        let fs = fs.clone();
+        |cx| async move {
+            if let Some(theme_registry) =
+                cx.update(|cx| ThemeRegistry::global(cx).clone()).log_err()
+            {
+                if let Some(()) = theme_registry
+                    .load_user_themes(&paths::THEMES_DIR.clone(), fs)
+                    .await
+                    .log_err()
+                {
+                    cx.update(|cx| {
+                        let mut theme_settings = ThemeSettings::get_global(cx).clone();
+
+                        if let Some(requested_theme) = theme_settings.requested_theme.clone() {
+                            if let Some(_theme) = theme_settings.switch_theme(&requested_theme, cx)
+                            {
+                                ThemeSettings::override_global(theme_settings, cx);
+                            }
+                        }
+                    })
+                    .log_err();
+                }
+            }
+        }
+    })
+    .detach();
+}
+
+/// Spawns a background task to watch the themes directory for changes.
+fn watch_themes(fs: Arc<dyn fs::Fs>, cx: &mut AppContext) {
+    cx.spawn(|cx| async move {
+        let mut events = fs
+            .watch(&paths::THEMES_DIR.clone(), Duration::from_millis(100))
+            .await;
+
+        while let Some(events) = events.next().await {
+            for event in events {
+                if event.flags.contains(StreamFlags::ITEM_REMOVED) {
+                    // Theme was removed, don't need to reload.
+                    // We may want to remove the theme from the registry, in this case.
+                } else {
+                    if let Some(theme_registry) =
+                        cx.update(|cx| ThemeRegistry::global(cx).clone()).log_err()
+                    {
+                        if let Some(()) = theme_registry
+                            .load_user_theme(&event.path, fs.clone())
+                            .await
+                            .log_err()
+                        {
+                            cx.update(|cx| {
+                                let mut theme_settings = ThemeSettings::get_global(cx).clone();
+
+                                if let Some(requested_theme) =
+                                    theme_settings.requested_theme.clone()
+                                {
+                                    if let Some(_theme) =
+                                        theme_settings.switch_theme(&requested_theme, cx)
+                                    {
+                                        ThemeSettings::override_global(theme_settings, cx);
+                                    }
+                                }
+                            })
+                            .log_err();
+                        }
+                    }
+                }
+            }
+        }
+    })
+    .detach()
+}
+
 async fn watch_languages(fs: Arc<dyn fs::Fs>, languages: Arc<LanguageRegistry>) {
     let reload_debounce = Duration::from_millis(250);