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 <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
This commit is contained in:
Marshall Bowers 2025-01-15 18:33:47 -05:00 committed by GitHub
parent 3b8a5c9647
commit f53915c711
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 303 additions and 10 deletions

View file

@ -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"); let snippets_json_path = extension_path.join("snippets.json");
if snippets_json_path.exists() { if snippets_json_path.exists() {
manifest.snippets = Some(snippets_json_path); manifest.snippets = Some(snippets_json_path);

View file

@ -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 load_user_theme(&self, theme_path: PathBuf, fs: Arc<dyn Fs>) -> Task<Result<()>>;
fn reload_current_theme(&self, cx: &mut AppContext); 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 { impl ExtensionThemeProxy for ExtensionHostProxy {
@ -137,6 +152,39 @@ impl ExtensionThemeProxy for ExtensionHostProxy {
proxy.reload_current_theme(cx) 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 { pub trait ExtensionGrammarProxy: Send + Sync + 'static {

View file

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

View file

@ -142,6 +142,8 @@ impl Global for GlobalExtensionStore {}
pub struct ExtensionIndex { pub struct ExtensionIndex {
pub extensions: BTreeMap<Arc<str>, ExtensionIndexEntry>, pub extensions: BTreeMap<Arc<str>, ExtensionIndexEntry>,
pub themes: BTreeMap<Arc<str>, ExtensionIndexThemeEntry>, pub themes: BTreeMap<Arc<str>, ExtensionIndexThemeEntry>,
#[serde(default)]
pub icon_themes: BTreeMap<Arc<str>, ExtensionIndexIconThemeEntry>,
pub languages: BTreeMap<LanguageName, ExtensionIndexLanguageEntry>, pub languages: BTreeMap<LanguageName, ExtensionIndexLanguageEntry>,
} }
@ -157,6 +159,12 @@ pub struct ExtensionIndexThemeEntry {
pub path: PathBuf, 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)] #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)]
pub struct ExtensionIndexLanguageEntry { pub struct ExtensionIndexLanguageEntry {
pub extension: Arc<str>, pub extension: Arc<str>,
@ -1022,6 +1030,17 @@ impl ExtensionStore {
} }
}) })
.collect::<Vec<_>>(); .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 let languages_to_remove = old_index
.languages .languages
.iter() .iter()
@ -1050,6 +1069,7 @@ impl ExtensionStore {
self.wasm_extensions self.wasm_extensions
.retain(|(extension, _)| !extensions_to_unload.contains(&extension.id)); .retain(|(extension, _)| !extensions_to_unload.contains(&extension.id));
self.proxy.remove_user_themes(themes_to_remove); self.proxy.remove_user_themes(themes_to_remove);
self.proxy.remove_icon_themes(icon_themes_to_remove);
self.proxy self.proxy
.remove_languages(&languages_to_remove, &grammars_to_remove); .remove_languages(&languages_to_remove, &grammars_to_remove);
@ -1060,6 +1080,7 @@ impl ExtensionStore {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut grammars_to_add = Vec::new(); let mut grammars_to_add = Vec::new();
let mut themes_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(); let mut snippets_to_add = Vec::new();
for extension_id in &extensions_to_load { for extension_id in &extensions_to_load {
let Some(extension) = new_index.extensions.get(extension_id) else { 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.extend([Path::new(extension_id.as_ref()), theme_path.as_path()]);
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| { snippets_to_add.extend(extension.manifest.snippets.iter().map(|snippets_path| {
let mut path = self.installed_dir.clone(); let mut path = self.installed_dir.clone();
path.extend([Path::new(extension_id.as_ref()), snippets_path.as_path()]); path.extend([Path::new(extension_id.as_ref()), snippets_path.as_path()]);
@ -1146,6 +1178,13 @@ impl ExtensionStore {
.log_err(); .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 { for snippets_path in &snippets_to_add {
if let Some(snippets_contents) = fs.load(snippets_path).await.log_err() 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"); let extension_wasm_path = extension_dir.join("extension.wasm");
if fs.is_file(&extension_wasm_path).await { if fs.is_file(&extension_wasm_path).await {
extension_manifest extension_manifest

View file

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

View file

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

View file

@ -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,
}

View file

@ -11,8 +11,9 @@ use parking_lot::RwLock;
use util::ResultExt; use util::ResultExt;
use crate::{ use crate::{
read_user_theme, refine_theme_family, Appearance, IconTheme, Theme, ThemeFamily, read_icon_theme, read_user_theme, refine_theme_family, Appearance, AppearanceContent,
ThemeFamilyContent, DEFAULT_ICON_THEME_ID, ChevronIcons, DirectoryIcons, IconDefinition, IconTheme, Theme, ThemeFamily,
ThemeFamilyContent, DEFAULT_ICON_THEME_NAME,
}; };
/// The metadata for a theme. /// The metadata for a theme.
@ -80,7 +81,7 @@ impl ThemeRegistry {
let default_icon_theme = crate::default_icon_theme(); let default_icon_theme = crate::default_icon_theme();
registry.state.write().icon_themes.insert( registry.state.write().icon_themes.insert(
default_icon_theme.id.clone().into(), default_icon_theme.name.clone(),
Arc::new(default_icon_theme), Arc::new(default_icon_theme),
); );
@ -208,7 +209,7 @@ impl ThemeRegistry {
/// Returns the default icon theme. /// Returns the default icon theme.
pub fn default_icon_theme(&self) -> Result<Arc<IconTheme>> { 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. /// Returns the icon theme with the specified name.
@ -220,6 +221,67 @@ impl ThemeRegistry {
.ok_or_else(|| anyhow!("icon theme not found: {name}")) .ok_or_else(|| anyhow!("icon theme not found: {name}"))
.cloned() .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 { impl Default for ThemeRegistry {

View file

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

View file

@ -12,6 +12,7 @@ mod default_colors;
mod fallback_themes; mod fallback_themes;
mod font_family_cache; mod font_family_cache;
mod icon_theme; mod icon_theme;
mod icon_theme_schema;
mod registry; mod registry;
mod scale; mod scale;
mod schema; mod schema;
@ -34,6 +35,7 @@ use uuid::Uuid;
pub use crate::default_colors::*; pub use crate::default_colors::*;
pub use crate::font_family_cache::*; pub use crate::font_family_cache::*;
pub use crate::icon_theme::*; pub use crate::icon_theme::*;
pub use crate::icon_theme_schema::*;
pub use crate::registry::*; pub use crate::registry::*;
pub use crate::scale::*; pub use crate::scale::*;
pub use crate::schema::*; 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) 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)
}

View file

@ -44,4 +44,37 @@ impl ExtensionThemeProxy for ThemeRegistryProxy {
fn reload_current_theme(&self, cx: &mut AppContext) { fn reload_current_theme(&self, cx: &mut AppContext) {
ThemeSettings::reload_current_theme(cx) 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
})
}
} }