From f53915c711ab184b690856332d84291c16bbaea5 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 15 Jan 2025 18:33:47 -0500 Subject: [PATCH] Add infrastructure for loading icon themes from extensions (#23203) 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 ", "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 --- 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 +++++++++++++++++++ .../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(-) create mode 100644 crates/theme/src/icon_theme_schema.rs diff --git a/crates/extension/src/extension_builder.rs b/crates/extension/src/extension_builder.rs index a2d7ae573f..a8f9675f27 100644 --- a/crates/extension/src/extension_builder.rs +++ b/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); diff --git a/crates/extension/src/extension_host_proxy.rs b/crates/extension/src/extension_host_proxy.rs index 3fa35597a8..ffa99fc45a 100644 --- a/crates/extension/src/extension_host_proxy.rs +++ b/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) -> Task>; fn reload_current_theme(&self, cx: &mut AppContext); + + fn list_icon_theme_names( + &self, + icon_theme_path: PathBuf, + fs: Arc, + ) -> Task>>; + + fn remove_icon_themes(&self, icon_themes: Vec); + + fn load_icon_theme( + &self, + icon_theme_path: PathBuf, + icons_root_dir: PathBuf, + fs: Arc, + ) -> Task>; } 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, + ) -> Task>> { + 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) { + 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, + ) -> Task> { + 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 { diff --git a/crates/extension/src/extension_manifest.rs b/crates/extension/src/extension_manifest.rs index 367d312d99..4c93eac36c 100644 --- a/crates/extension/src/extension_manifest.rs +++ b/crates/extension/src/extension_manifest.rs @@ -70,6 +70,8 @@ pub struct ExtensionManifest { #[serde(default)] pub themes: Vec, #[serde(default)] + pub icon_themes: Vec, + #[serde(default)] pub languages: Vec, #[serde(default)] pub grammars: BTreeMap, 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::>(); languages.sort(); diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index afe78e49a0..f98ce22f5f 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -142,6 +142,8 @@ impl Global for GlobalExtensionStore {} pub struct ExtensionIndex { pub extensions: BTreeMap, ExtensionIndexEntry>, pub themes: BTreeMap, ExtensionIndexThemeEntry>, + #[serde(default)] + pub icon_themes: BTreeMap, ExtensionIndexIconThemeEntry>, pub languages: BTreeMap, } @@ -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, + pub path: PathBuf, +} + #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)] pub struct ExtensionIndexLanguageEntry { pub extension: Arc, @@ -1022,6 +1030,17 @@ impl ExtensionStore { } }) .collect::>(); + 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::>(); 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::>(); 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 diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs index 724988fadd..cd6a29ac8c 100644 --- a/crates/extension_host/src/extension_store_test.rs +++ b/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(), diff --git a/crates/theme/src/icon_theme.rs b/crates/theme/src/icon_theme.rs index 31e40c3cf3..170b51a1f9 100644 --- a/crates/theme/src/icon_theme.rs +++ b/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()), diff --git a/crates/theme/src/icon_theme_schema.rs b/crates/theme/src/icon_theme_schema.rs new file mode 100644 index 0000000000..6e2888630c --- /dev/null +++ b/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, +} + +#[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, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +pub struct DirectoryIconsContent { + pub collapsed: Option, + pub expanded: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +pub struct ChevronIconsContent { + pub collapsed: Option, + pub expanded: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct IconDefinitionContent { + pub path: SharedString, +} diff --git a/crates/theme/src/registry.rs b/crates/theme/src/registry.rs index 02af91dfb3..17cc69b594 100644 --- a/crates/theme/src/registry.rs +++ b/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> { - 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, + ) -> 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 { diff --git a/crates/theme/src/settings.rs b/crates/theme/src/settings.rs index 618e3f0268..5e1547db01 100644 --- a/crates/theme/src/settings.rs +++ b/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), }; diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 4f395696aa..1398f4efd8 100644 --- a/crates/theme/src/theme.rs +++ b/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) -> Result, +) -> Result { + let reader = fs.open_sync(icon_theme_path).await?; + let icon_theme_family: IconThemeFamilyContent = serde_json_lenient::from_reader(reader)?; + + Ok(icon_theme_family) +} diff --git a/crates/theme_extension/src/theme_extension.rs b/crates/theme_extension/src/theme_extension.rs index 0266db324b..aec634d4f2 100644 --- a/crates/theme_extension/src/theme_extension.rs +++ b/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, + ) -> Task>> { + 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) { + self.theme_registry.remove_icon_themes(&icon_themes); + } + + fn load_icon_theme( + &self, + icon_theme_path: PathBuf, + icons_root_dir: PathBuf, + fs: Arc, + ) -> Task> { + 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 + }) + } }