Add infrastructure for loading icon themes from extensions (#23203)

Marshall Bowers created

This PR adds the supporting infrastructure to support loading icon
themes defined by extensions.

Here's an example icon theme:

```json
{
  "name": "My Icon Theme",
  "author": "Me <me@example.com>",
  "themes": [
    {
      "name": "My Icon Theme",
      "appearance": "dark",
      "file_icons": {
        "gleam": { "path": "./icons/file_type_gleam.svg" },
        "toml": { "path": "./icons/file_type_toml.svg" }
      }
    }
  ]
}
```

The icon paths are resolved relative to the root of the extension
directory.

Release Notes:

- N/A

Change summary

crates/extension/src/extension_builder.rs         | 15 +++
crates/extension/src/extension_host_proxy.rs      | 48 +++++++++++
crates/extension/src/extension_manifest.rs        |  3 
crates/extension_host/src/extension_host.rs       | 71 +++++++++++++++++
crates/extension_host/src/extension_store_test.rs |  4 
crates/theme/src/icon_theme.rs                    |  8 
crates/theme/src/icon_theme_schema.rs             | 44 ++++++++++
crates/theme/src/registry.rs                      | 70 +++++++++++++++
crates/theme/src/settings.rs                      |  4 
crates/theme/src/theme.rs                         | 13 +++
crates/theme_extension/src/theme_extension.rs     | 33 +++++++
11 files changed, 303 insertions(+), 10 deletions(-)

Detailed changes

crates/extension/src/extension_builder.rs 🔗

@@ -560,6 +560,21 @@ fn populate_defaults(manifest: &mut ExtensionManifest, extension_path: &Path) ->
         }
     }
 
+    let icon_themes_dir = extension_path.join("icon_themes");
+    if icon_themes_dir.exists() {
+        for entry in fs::read_dir(&icon_themes_dir).context("failed to list icon themes dir")? {
+            let entry = entry?;
+            let icon_theme_path = entry.path();
+            if icon_theme_path.extension() == Some("json".as_ref()) {
+                let relative_icon_theme_path =
+                    icon_theme_path.strip_prefix(extension_path)?.to_path_buf();
+                if !manifest.icon_themes.contains(&relative_icon_theme_path) {
+                    manifest.icon_themes.push(relative_icon_theme_path);
+                }
+            }
+        }
+    }
+
     let snippets_json_path = extension_path.join("snippets.json");
     if snippets_json_path.exists() {
         manifest.snippets = Some(snippets_json_path);

crates/extension/src/extension_host_proxy.rs 🔗

@@ -103,6 +103,21 @@ pub trait ExtensionThemeProxy: Send + Sync + 'static {
     fn load_user_theme(&self, theme_path: PathBuf, fs: Arc<dyn Fs>) -> Task<Result<()>>;
 
     fn reload_current_theme(&self, cx: &mut AppContext);
+
+    fn list_icon_theme_names(
+        &self,
+        icon_theme_path: PathBuf,
+        fs: Arc<dyn Fs>,
+    ) -> Task<Result<Vec<String>>>;
+
+    fn remove_icon_themes(&self, icon_themes: Vec<SharedString>);
+
+    fn load_icon_theme(
+        &self,
+        icon_theme_path: PathBuf,
+        icons_root_dir: PathBuf,
+        fs: Arc<dyn Fs>,
+    ) -> Task<Result<()>>;
 }
 
 impl ExtensionThemeProxy for ExtensionHostProxy {
@@ -137,6 +152,39 @@ impl ExtensionThemeProxy for ExtensionHostProxy {
 
         proxy.reload_current_theme(cx)
     }
+
+    fn list_icon_theme_names(
+        &self,
+        icon_theme_path: PathBuf,
+        fs: Arc<dyn Fs>,
+    ) -> Task<Result<Vec<String>>> {
+        let Some(proxy) = self.theme_proxy.read().clone() else {
+            return Task::ready(Ok(Vec::new()));
+        };
+
+        proxy.list_icon_theme_names(icon_theme_path, fs)
+    }
+
+    fn remove_icon_themes(&self, icon_themes: Vec<SharedString>) {
+        let Some(proxy) = self.theme_proxy.read().clone() else {
+            return;
+        };
+
+        proxy.remove_icon_themes(icon_themes)
+    }
+
+    fn load_icon_theme(
+        &self,
+        icon_theme_path: PathBuf,
+        icons_root_dir: PathBuf,
+        fs: Arc<dyn Fs>,
+    ) -> Task<Result<()>> {
+        let Some(proxy) = self.theme_proxy.read().clone() else {
+            return Task::ready(Ok(()));
+        };
+
+        proxy.load_icon_theme(icon_theme_path, icons_root_dir, fs)
+    }
 }
 
 pub trait ExtensionGrammarProxy: Send + Sync + 'static {

crates/extension/src/extension_manifest.rs 🔗

@@ -70,6 +70,8 @@ pub struct ExtensionManifest {
     #[serde(default)]
     pub themes: Vec<PathBuf>,
     #[serde(default)]
+    pub icon_themes: Vec<PathBuf>,
+    #[serde(default)]
     pub languages: Vec<PathBuf>,
     #[serde(default)]
     pub grammars: BTreeMap<Arc<str>, GrammarManifestEntry>,
@@ -199,6 +201,7 @@ fn manifest_from_old_manifest(
             themes.dedup();
             themes
         },
+        icon_themes: Vec::new(),
         languages: {
             let mut languages = manifest_json.languages.into_values().collect::<Vec<_>>();
             languages.sort();

crates/extension_host/src/extension_host.rs 🔗

@@ -142,6 +142,8 @@ impl Global for GlobalExtensionStore {}
 pub struct ExtensionIndex {
     pub extensions: BTreeMap<Arc<str>, ExtensionIndexEntry>,
     pub themes: BTreeMap<Arc<str>, ExtensionIndexThemeEntry>,
+    #[serde(default)]
+    pub icon_themes: BTreeMap<Arc<str>, ExtensionIndexIconThemeEntry>,
     pub languages: BTreeMap<LanguageName, ExtensionIndexLanguageEntry>,
 }
 
@@ -157,6 +159,12 @@ pub struct ExtensionIndexThemeEntry {
     pub path: PathBuf,
 }
 
+#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)]
+pub struct ExtensionIndexIconThemeEntry {
+    pub extension: Arc<str>,
+    pub path: PathBuf,
+}
+
 #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)]
 pub struct ExtensionIndexLanguageEntry {
     pub extension: Arc<str>,
@@ -1022,6 +1030,17 @@ impl ExtensionStore {
                 }
             })
             .collect::<Vec<_>>();
+        let icon_themes_to_remove = old_index
+            .icon_themes
+            .iter()
+            .filter_map(|(name, entry)| {
+                if extensions_to_unload.contains(&entry.extension) {
+                    Some(name.clone().into())
+                } else {
+                    None
+                }
+            })
+            .collect::<Vec<_>>();
         let languages_to_remove = old_index
             .languages
             .iter()
@@ -1050,6 +1069,7 @@ impl ExtensionStore {
         self.wasm_extensions
             .retain(|(extension, _)| !extensions_to_unload.contains(&extension.id));
         self.proxy.remove_user_themes(themes_to_remove);
+        self.proxy.remove_icon_themes(icon_themes_to_remove);
         self.proxy
             .remove_languages(&languages_to_remove, &grammars_to_remove);
 
@@ -1060,6 +1080,7 @@ impl ExtensionStore {
             .collect::<Vec<_>>();
         let mut grammars_to_add = Vec::new();
         let mut themes_to_add = Vec::new();
+        let mut icon_themes_to_add = Vec::new();
         let mut snippets_to_add = Vec::new();
         for extension_id in &extensions_to_load {
             let Some(extension) = new_index.extensions.get(extension_id) else {
@@ -1078,6 +1099,17 @@ impl ExtensionStore {
                 path.extend([Path::new(extension_id.as_ref()), theme_path.as_path()]);
                 path
             }));
+            icon_themes_to_add.extend(extension.manifest.icon_themes.iter().map(
+                |icon_theme_path| {
+                    let mut path = self.installed_dir.clone();
+                    path.extend([Path::new(extension_id.as_ref()), icon_theme_path.as_path()]);
+
+                    let mut icons_root_path = self.installed_dir.clone();
+                    icons_root_path.extend([Path::new(extension_id.as_ref())]);
+
+                    (path, icons_root_path)
+                },
+            ));
             snippets_to_add.extend(extension.manifest.snippets.iter().map(|snippets_path| {
                 let mut path = self.installed_dir.clone();
                 path.extend([Path::new(extension_id.as_ref()), snippets_path.as_path()]);
@@ -1146,6 +1178,13 @@ impl ExtensionStore {
                                 .log_err();
                         }
 
+                        for (icon_theme_path, icons_root_path) in icon_themes_to_add.into_iter() {
+                            proxy
+                                .load_icon_theme(icon_theme_path, icons_root_path, fs.clone())
+                                .await
+                                .log_err();
+                        }
+
                         for snippets_path in &snippets_to_add {
                             if let Some(snippets_contents) = fs.load(snippets_path).await.log_err()
                             {
@@ -1364,6 +1403,38 @@ impl ExtensionStore {
             }
         }
 
+        if let Ok(mut icon_theme_paths) = fs.read_dir(&extension_dir.join("icon_themes")).await {
+            while let Some(icon_theme_path) = icon_theme_paths.next().await {
+                let icon_theme_path = icon_theme_path?;
+                let Ok(relative_path) = icon_theme_path.strip_prefix(&extension_dir) else {
+                    continue;
+                };
+
+                let Some(icon_theme_families) = proxy
+                    .list_icon_theme_names(icon_theme_path.clone(), fs.clone())
+                    .await
+                    .log_err()
+                else {
+                    continue;
+                };
+
+                let relative_path = relative_path.to_path_buf();
+                if !extension_manifest.icon_themes.contains(&relative_path) {
+                    extension_manifest.icon_themes.push(relative_path.clone());
+                }
+
+                for icon_theme_name in icon_theme_families {
+                    index.icon_themes.insert(
+                        icon_theme_name.into(),
+                        ExtensionIndexIconThemeEntry {
+                            extension: extension_id.clone(),
+                            path: relative_path.clone(),
+                        },
+                    );
+                }
+            }
+        }
+
         let extension_wasm_path = extension_dir.join("extension.wasm");
         if fs.is_file(&extension_wasm_path).await {
             extension_manifest

crates/extension_host/src/extension_store_test.rs 🔗

@@ -149,6 +149,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                         authors: Vec::new(),
                         repository: None,
                         themes: Default::default(),
+                        icon_themes: Vec::new(),
                         lib: Default::default(),
                         languages: vec!["languages/erb".into(), "languages/ruby".into()],
                         grammars: [
@@ -181,6 +182,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                             "themes/monokai-pro.json".into(),
                             "themes/monokai.json".into(),
                         ],
+                        icon_themes: Vec::new(),
                         lib: Default::default(),
                         languages: Default::default(),
                         grammars: BTreeMap::default(),
@@ -258,6 +260,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
         ]
         .into_iter()
         .collect(),
+        icon_themes: BTreeMap::default(),
     };
 
     let proxy = Arc::new(ExtensionHostProxy::new());
@@ -344,6 +347,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                 authors: vec![],
                 repository: None,
                 themes: vec!["themes/gruvbox.json".into()],
+                icon_themes: Vec::new(),
                 lib: Default::default(),
                 languages: Default::default(),
                 grammars: BTreeMap::default(),

crates/theme/src/icon_theme.rs 🔗

@@ -124,14 +124,14 @@ const FILE_ICONS: &[(&str, &str)] = &[
     ("zig", "icons/file_icons/zig.svg"),
 ];
 
-/// The ID of the default icon theme.
-pub(crate) const DEFAULT_ICON_THEME_ID: &str = "zed";
+/// The name of the default icon theme.
+pub(crate) const DEFAULT_ICON_THEME_NAME: &str = "Zed (Default)";
 
 /// Returns the default icon theme.
 pub fn default_icon_theme() -> IconTheme {
     IconTheme {
-        id: DEFAULT_ICON_THEME_ID.into(),
-        name: "Zed (Default)".into(),
+        id: "zed".into(),
+        name: DEFAULT_ICON_THEME_NAME.into(),
         appearance: Appearance::Dark,
         directory_icons: DirectoryIcons {
             collapsed: Some("icons/file_icons/folder.svg".into()),

crates/theme/src/icon_theme_schema.rs 🔗

@@ -0,0 +1,44 @@
+#![allow(missing_docs)]
+
+use gpui::SharedString;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+
+use crate::AppearanceContent;
+
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct IconThemeFamilyContent {
+    pub name: String,
+    pub author: String,
+    pub themes: Vec<IconThemeContent>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct IconThemeContent {
+    pub name: String,
+    pub appearance: AppearanceContent,
+    #[serde(default)]
+    pub directory_icons: DirectoryIconsContent,
+    #[serde(default)]
+    pub chevron_icons: ChevronIconsContent,
+    #[serde(default)]
+    pub file_icons: HashMap<String, IconDefinitionContent>,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
+pub struct DirectoryIconsContent {
+    pub collapsed: Option<SharedString>,
+    pub expanded: Option<SharedString>,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
+pub struct ChevronIconsContent {
+    pub collapsed: Option<SharedString>,
+    pub expanded: Option<SharedString>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct IconDefinitionContent {
+    pub path: SharedString,
+}

crates/theme/src/registry.rs 🔗

@@ -11,8 +11,9 @@ use parking_lot::RwLock;
 use util::ResultExt;
 
 use crate::{
-    read_user_theme, refine_theme_family, Appearance, IconTheme, Theme, ThemeFamily,
-    ThemeFamilyContent, DEFAULT_ICON_THEME_ID,
+    read_icon_theme, read_user_theme, refine_theme_family, Appearance, AppearanceContent,
+    ChevronIcons, DirectoryIcons, IconDefinition, IconTheme, Theme, ThemeFamily,
+    ThemeFamilyContent, DEFAULT_ICON_THEME_NAME,
 };
 
 /// The metadata for a theme.
@@ -80,7 +81,7 @@ impl ThemeRegistry {
 
         let default_icon_theme = crate::default_icon_theme();
         registry.state.write().icon_themes.insert(
-            default_icon_theme.id.clone().into(),
+            default_icon_theme.name.clone(),
             Arc::new(default_icon_theme),
         );
 
@@ -208,7 +209,7 @@ impl ThemeRegistry {
 
     /// Returns the default icon theme.
     pub fn default_icon_theme(&self) -> Result<Arc<IconTheme>> {
-        self.get_icon_theme(DEFAULT_ICON_THEME_ID)
+        self.get_icon_theme(DEFAULT_ICON_THEME_NAME)
     }
 
     /// Returns the icon theme with the specified name.
@@ -220,6 +221,67 @@ impl ThemeRegistry {
             .ok_or_else(|| anyhow!("icon theme not found: {name}"))
             .cloned()
     }
+
+    /// Removes the icon themes with the given names from the registry.
+    pub fn remove_icon_themes(&self, icon_themes_to_remove: &[SharedString]) {
+        self.state
+            .write()
+            .icon_themes
+            .retain(|name, _| !icon_themes_to_remove.contains(name))
+    }
+
+    /// Loads the icon theme from the specified path and adds it to the registry.
+    ///
+    /// The `icons_root_dir` parameter indicates the root directory from which
+    /// the relative paths to icons in the theme should be resolved against.
+    pub async fn load_icon_theme(
+        &self,
+        icon_theme_path: &Path,
+        icons_root_dir: &Path,
+        fs: Arc<dyn Fs>,
+    ) -> Result<()> {
+        let icon_theme_family = read_icon_theme(icon_theme_path, fs).await?;
+
+        let mut state = self.state.write();
+        for icon_theme in icon_theme_family.themes {
+            let icon_theme = IconTheme {
+                id: uuid::Uuid::new_v4().to_string(),
+                name: icon_theme.name.into(),
+                appearance: match icon_theme.appearance {
+                    AppearanceContent::Light => Appearance::Light,
+                    AppearanceContent::Dark => Appearance::Dark,
+                },
+                directory_icons: DirectoryIcons {
+                    collapsed: icon_theme.directory_icons.collapsed,
+                    expanded: icon_theme.directory_icons.expanded,
+                },
+                chevron_icons: ChevronIcons {
+                    collapsed: icon_theme.chevron_icons.collapsed,
+                    expanded: icon_theme.chevron_icons.expanded,
+                },
+                file_icons: icon_theme
+                    .file_icons
+                    .into_iter()
+                    .map(|(key, icon)| {
+                        let path = icons_root_dir.join(icon.path.as_ref());
+
+                        (
+                            key,
+                            IconDefinition {
+                                path: path.to_string_lossy().to_string().into(),
+                            },
+                        )
+                    })
+                    .collect(),
+            };
+
+            state
+                .icon_themes
+                .insert(icon_theme.name.clone(), Arc::new(icon_theme));
+        }
+
+        Ok(())
+    }
 }
 
 impl Default for ThemeRegistry {

crates/theme/src/settings.rs 🔗

@@ -1,7 +1,7 @@
 use crate::fallback_themes::zed_default_dark;
 use crate::{
     Appearance, IconTheme, SyntaxTheme, Theme, ThemeRegistry, ThemeStyleContent,
-    DEFAULT_ICON_THEME_ID,
+    DEFAULT_ICON_THEME_NAME,
 };
 use anyhow::Result;
 use derive_more::{Deref, DerefMut};
@@ -647,7 +647,7 @@ impl settings::Settings for ThemeSettings {
                 .icon_theme
                 .as_ref()
                 .and_then(|name| themes.get_icon_theme(name).ok())
-                .unwrap_or_else(|| themes.get_icon_theme(DEFAULT_ICON_THEME_ID).unwrap()),
+                .unwrap_or_else(|| themes.get_icon_theme(DEFAULT_ICON_THEME_NAME).unwrap()),
             ui_density: defaults.ui_density.unwrap_or(UiDensity::Default),
             unnecessary_code_fade: defaults.unnecessary_code_fade.unwrap_or(0.0),
         };

crates/theme/src/theme.rs 🔗

@@ -12,6 +12,7 @@ mod default_colors;
 mod fallback_themes;
 mod font_family_cache;
 mod icon_theme;
+mod icon_theme_schema;
 mod registry;
 mod scale;
 mod schema;
@@ -34,6 +35,7 @@ use uuid::Uuid;
 pub use crate::default_colors::*;
 pub use crate::font_family_cache::*;
 pub use crate::icon_theme::*;
+pub use crate::icon_theme_schema::*;
 pub use crate::registry::*;
 pub use crate::scale::*;
 pub use crate::schema::*;
@@ -364,3 +366,14 @@ pub async fn read_user_theme(theme_path: &Path, fs: Arc<dyn Fs>) -> Result<Theme
 
     Ok(theme_family)
 }
+
+/// Asynchronously reads the icon theme from the specified path.
+pub async fn read_icon_theme(
+    icon_theme_path: &Path,
+    fs: Arc<dyn Fs>,
+) -> Result<IconThemeFamilyContent> {
+    let reader = fs.open_sync(icon_theme_path).await?;
+    let icon_theme_family: IconThemeFamilyContent = serde_json_lenient::from_reader(reader)?;
+
+    Ok(icon_theme_family)
+}

crates/theme_extension/src/theme_extension.rs 🔗

@@ -44,4 +44,37 @@ impl ExtensionThemeProxy for ThemeRegistryProxy {
     fn reload_current_theme(&self, cx: &mut AppContext) {
         ThemeSettings::reload_current_theme(cx)
     }
+
+    fn list_icon_theme_names(
+        &self,
+        icon_theme_path: PathBuf,
+        fs: Arc<dyn Fs>,
+    ) -> Task<Result<Vec<String>>> {
+        self.executor.spawn(async move {
+            let icon_theme_family = theme::read_icon_theme(&icon_theme_path, fs).await?;
+            Ok(icon_theme_family
+                .themes
+                .into_iter()
+                .map(|theme| theme.name)
+                .collect())
+        })
+    }
+
+    fn remove_icon_themes(&self, icon_themes: Vec<SharedString>) {
+        self.theme_registry.remove_icon_themes(&icon_themes);
+    }
+
+    fn load_icon_theme(
+        &self,
+        icon_theme_path: PathBuf,
+        icons_root_dir: PathBuf,
+        fs: Arc<dyn Fs>,
+    ) -> Task<Result<()>> {
+        let theme_registry = self.theme_registry.clone();
+        self.executor.spawn(async move {
+            theme_registry
+                .load_icon_theme(&icon_theme_path, &icons_root_dir, fs)
+                .await
+        })
+    }
 }