From 5f20b905a56f8589f4af9d955be9447f719df012 Mon Sep 17 00:00:00 2001
From: Jacob <33708767+jacobtread@users.noreply.github.com>
Date: Sat, 13 Sep 2025 06:55:25 +1200
Subject: [PATCH] Add support for named folder icons (#36351)
Adds a `named_directory_icons` field to the icon theme that can be used
to specify a collection of icons for collapsed and expanded folders
based on the folder name.
The `named_directory_icons` is a map from the folder name to a
`DirectoryIcons` object containing the paths to the expanded and
collapsed icons for that folder:
```json
{
"named_directory_icons": {
".angular": {
"collapsed": "./icons/folder_angular.svg",
"expanded": "./icons/folder_angular_open.svg"
}
}
}
```
Closes #20295
Also referenced
https://github.com/zed-industries/zed/pull/23987#issuecomment-2638869213
Example using https://github.com/jacobtread/zed-vscode-icons/ which I've
ported over from a VSCode theme,
Release Notes:
- Added support for icon themes to change the folder icon based on the
directory name.
---------
Co-authored-by: Marshall Bowers
---
crates/acp_thread/src/mention.rs | 2 +-
.../src/context_picker/completion_provider.rs | 7 +--
.../src/context_picker/file_context_picker.rs | 2 +-
crates/file_finder/src/open_path_prompt.rs | 5 +-
crates/file_icons/src/file_icons.rs | 53 ++++++++++++++++---
crates/outline_panel/src/outline_panel.rs | 4 +-
crates/project_panel/src/project_panel.rs | 2 +-
crates/theme/src/icon_theme.rs | 5 +-
crates/theme/src/icon_theme_schema.rs | 2 +
crates/theme/src/registry.rs | 14 +++++
10 files changed, 79 insertions(+), 17 deletions(-)
diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs
index 6fa0887e2278467dae9887516d882da90a78d0df..122cd9a5cabb9676b0a7afdf76c0de5862f979cb 100644
--- a/crates/acp_thread/src/mention.rs
+++ b/crates/acp_thread/src/mention.rs
@@ -162,7 +162,7 @@ impl MentionUri {
FileIcons::get_icon(abs_path, cx).unwrap_or_else(|| IconName::File.path().into())
}
MentionUri::PastedImage => IconName::Image.path().into(),
- MentionUri::Directory { .. } => FileIcons::get_folder_icon(false, cx)
+ MentionUri::Directory { abs_path } => FileIcons::get_folder_icon(false, abs_path, cx)
.unwrap_or_else(|| IconName::Folder.path().into()),
MentionUri::Symbol { .. } => IconName::Code.path().into(),
MentionUri::Thread { .. } => IconName::Thread.path().into(),
diff --git a/crates/agent_ui/src/context_picker/completion_provider.rs b/crates/agent_ui/src/context_picker/completion_provider.rs
index b67b463e3bfa654baefece2c97fc505460830f2d..c9cd69bf8e49b2e4f20148640cd029caea51264f 100644
--- a/crates/agent_ui/src/context_picker/completion_provider.rs
+++ b/crates/agent_ui/src/context_picker/completion_provider.rs
@@ -596,11 +596,12 @@ impl ContextPickerCompletionProvider {
file_name.to_string()
};
+ let path = Path::new(&full_path);
let crease_icon_path = if is_directory {
- FileIcons::get_folder_icon(false, cx).unwrap_or_else(|| IconName::Folder.path().into())
+ FileIcons::get_folder_icon(false, path, cx)
+ .unwrap_or_else(|| IconName::Folder.path().into())
} else {
- FileIcons::get_icon(Path::new(&full_path), cx)
- .unwrap_or_else(|| IconName::File.path().into())
+ FileIcons::get_icon(path, cx).unwrap_or_else(|| IconName::File.path().into())
};
let completion_icon_path = if is_recent {
IconName::HistoryRerun.path().into()
diff --git a/crates/agent_ui/src/context_picker/file_context_picker.rs b/crates/agent_ui/src/context_picker/file_context_picker.rs
index 43b1fa5e92fcd792ee1e8567ac558652e933bbfa..d64de23f4e42b8a79dc9bdcbc1c2fa9677c09372 100644
--- a/crates/agent_ui/src/context_picker/file_context_picker.rs
+++ b/crates/agent_ui/src/context_picker/file_context_picker.rs
@@ -330,7 +330,7 @@ pub fn render_file_context_entry(
});
let file_icon = if is_directory {
- FileIcons::get_folder_icon(false, cx)
+ FileIcons::get_folder_icon(false, path, cx)
} else {
FileIcons::get_icon(path, cx)
}
diff --git a/crates/file_finder/src/open_path_prompt.rs b/crates/file_finder/src/open_path_prompt.rs
index ab00f943b811a941f7339853a79f81dfea5275eb..63f2f37ab18cbd1ab210685eeda01ac535a8118f 100644
--- a/crates/file_finder/src/open_path_prompt.rs
+++ b/crates/file_finder/src/open_path_prompt.rs
@@ -695,14 +695,15 @@ impl PickerDelegate for OpenPathDelegate {
if !settings.file_icons {
return None;
}
+
+ let path = path::Path::new(&candidate.path.string);
let icon = if candidate.is_dir {
if is_current_dir_candidate {
return Some(Icon::new(IconName::ReplyArrowRight).color(Color::Muted));
} else {
- FileIcons::get_folder_icon(false, cx)?
+ FileIcons::get_folder_icon(false, path, cx)?
}
} else {
- let path = path::Path::new(&candidate.path.string);
FileIcons::get_icon(path, cx)?
};
Some(Icon::from_path(icon).color(Color::Muted))
diff --git a/crates/file_icons/src/file_icons.rs b/crates/file_icons/src/file_icons.rs
index 42c00fb12d5e9f0fbb1662eb0941ed70d94382b5..327b1451523748aa91356bdb5854e2ef165a5085 100644
--- a/crates/file_icons/src/file_icons.rs
+++ b/crates/file_icons/src/file_icons.rs
@@ -93,21 +93,62 @@ impl FileIcons {
})
}
- pub fn get_folder_icon(expanded: bool, cx: &App) -> Option {
- fn get_folder_icon(icon_theme: &Arc, expanded: bool) -> Option {
+ pub fn get_folder_icon(expanded: bool, path: &Path, cx: &App) -> Option {
+ fn get_folder_icon(
+ icon_theme: &Arc,
+ path: &Path,
+ expanded: bool,
+ ) -> Option {
+ let name = path.file_name()?.to_str()?.trim();
+ if name.is_empty() {
+ return None;
+ }
+
+ let directory_icons = icon_theme.named_directory_icons.get(name)?;
+
if expanded {
- icon_theme.directory_icons.expanded.clone()
+ directory_icons.expanded.clone()
} else {
- icon_theme.directory_icons.collapsed.clone()
+ directory_icons.collapsed.clone()
}
}
- get_folder_icon(&ThemeSettings::get_global(cx).active_icon_theme, expanded).or_else(|| {
+ get_folder_icon(
+ &ThemeSettings::get_global(cx).active_icon_theme,
+ path,
+ expanded,
+ )
+ .or_else(|| {
Self::default_icon_theme(cx)
- .and_then(|icon_theme| get_folder_icon(&icon_theme, expanded))
+ .and_then(|icon_theme| get_folder_icon(&icon_theme, path, expanded))
+ })
+ .or_else(|| {
+ // If we can't find a specific folder icon for the folder at the given path, fall back to the generic folder
+ // icon.
+ Self::get_generic_folder_icon(expanded, cx)
})
}
+ fn get_generic_folder_icon(expanded: bool, cx: &App) -> Option {
+ fn get_generic_folder_icon(
+ icon_theme: &Arc,
+ expanded: bool,
+ ) -> Option {
+ if expanded {
+ icon_theme.directory_icons.expanded.clone()
+ } else {
+ icon_theme.directory_icons.collapsed.clone()
+ }
+ }
+
+ get_generic_folder_icon(&ThemeSettings::get_global(cx).active_icon_theme, expanded).or_else(
+ || {
+ Self::default_icon_theme(cx)
+ .and_then(|icon_theme| get_generic_folder_icon(&icon_theme, expanded))
+ },
+ )
+ }
+
pub fn get_chevron_icon(expanded: bool, cx: &App) -> Option {
fn get_chevron_icon(icon_theme: &Arc, expanded: bool) -> Option {
if expanded {
diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs
index a8485248dbe2e544c80d59b3ad549c54c49e6e51..2f34d47667d10e07c468ec53e4d39083a89bd2fb 100644
--- a/crates/outline_panel/src/outline_panel.rs
+++ b/crates/outline_panel/src/outline_panel.rs
@@ -2319,7 +2319,7 @@ impl OutlinePanel {
is_active,
);
let icon = if settings.folder_icons {
- FileIcons::get_folder_icon(is_expanded, cx)
+ FileIcons::get_folder_icon(is_expanded, &directory.entry.path, cx)
} else {
FileIcons::get_chevron_icon(is_expanded, cx)
}
@@ -2416,7 +2416,7 @@ impl OutlinePanel {
.unwrap_or_default();
let color = entry_git_aware_label_color(git_status, is_ignored, is_active);
let icon = if settings.folder_icons {
- FileIcons::get_folder_icon(is_expanded, cx)
+ FileIcons::get_folder_icon(is_expanded, &Path::new(&name), cx)
} else {
FileIcons::get_chevron_icon(is_expanded, cx)
}
diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs
index 97d7c6fcbb6bf16d73977c1451d3481edbf89715..34471e817db56e9ca9cd693230c06db1fca25a0a 100644
--- a/crates/project_panel/src/project_panel.rs
+++ b/crates/project_panel/src/project_panel.rs
@@ -4778,7 +4778,7 @@ impl ProjectPanel {
}
_ => {
if show_folder_icons {
- FileIcons::get_folder_icon(is_expanded, cx)
+ FileIcons::get_folder_icon(is_expanded, &entry.path, cx)
} else {
FileIcons::get_chevron_icon(is_expanded, cx)
}
diff --git a/crates/theme/src/icon_theme.rs b/crates/theme/src/icon_theme.rs
index c21709559a62f712fa190021803a479cf189f061..513dedfe428e68ff708302e5a23ff64ad6d66d0a 100644
--- a/crates/theme/src/icon_theme.rs
+++ b/crates/theme/src/icon_theme.rs
@@ -28,6 +28,8 @@ pub struct IconTheme {
pub appearance: Appearance,
/// The icons used for directories.
pub directory_icons: DirectoryIcons,
+ /// The icons used for named directories.
+ pub named_directory_icons: HashMap,
/// The icons used for chevrons.
pub chevron_icons: ChevronIcons,
/// The mapping of file stems to their associated icon keys.
@@ -39,7 +41,7 @@ pub struct IconTheme {
}
/// The icons used for directories.
-#[derive(Debug, PartialEq)]
+#[derive(Debug, PartialEq, Clone)]
pub struct DirectoryIcons {
/// The path to the icon to use for a collapsed directory.
pub collapsed: Option,
@@ -392,6 +394,7 @@ static DEFAULT_ICON_THEME: LazyLock> = LazyLock::new(|| {
collapsed: Some("icons/file_icons/folder.svg".into()),
expanded: Some("icons/file_icons/folder_open.svg".into()),
},
+ named_directory_icons: HashMap::default(),
chevron_icons: ChevronIcons {
collapsed: Some("icons/file_icons/chevron_right.svg".into()),
expanded: Some("icons/file_icons/chevron_down.svg".into()),
diff --git a/crates/theme/src/icon_theme_schema.rs b/crates/theme/src/icon_theme_schema.rs
index f73938bc5c8f15cf78117b961cf0219df2c8e21b..45ac985ae99eda69dc313ac419f64dac1fd8e749 100644
--- a/crates/theme/src/icon_theme_schema.rs
+++ b/crates/theme/src/icon_theme_schema.rs
@@ -21,6 +21,8 @@ pub struct IconThemeContent {
#[serde(default)]
pub directory_icons: DirectoryIconsContent,
#[serde(default)]
+ pub named_directory_icons: HashMap,
+ #[serde(default)]
pub chevron_icons: ChevronIconsContent,
#[serde(default)]
pub file_stems: HashMap,
diff --git a/crates/theme/src/registry.rs b/crates/theme/src/registry.rs
index f0f0776582e3b0174a3743ad6c1b20c0fd90b0d8..8bf8481c84c9c48cbbd03c472b785a776aa07dac 100644
--- a/crates/theme/src/registry.rs
+++ b/crates/theme/src/registry.rs
@@ -298,6 +298,19 @@ impl ThemeRegistry {
let mut file_suffixes = default_icon_theme.file_suffixes.clone();
file_suffixes.extend(icon_theme.file_suffixes);
+ let mut named_directory_icons = default_icon_theme.named_directory_icons.clone();
+ named_directory_icons.extend(icon_theme.named_directory_icons.into_iter().map(
+ |(key, value)| {
+ (
+ key,
+ DirectoryIcons {
+ collapsed: value.collapsed.map(resolve_icon_path),
+ expanded: value.expanded.map(resolve_icon_path),
+ },
+ )
+ },
+ ));
+
let icon_theme = IconTheme {
id: uuid::Uuid::new_v4().to_string(),
name: icon_theme.name.into(),
@@ -309,6 +322,7 @@ impl ThemeRegistry {
collapsed: icon_theme.directory_icons.collapsed.map(resolve_icon_path),
expanded: icon_theme.directory_icons.expanded.map(resolve_icon_path),
},
+ named_directory_icons,
chevron_icons: ChevronIcons {
collapsed: icon_theme.chevron_icons.collapsed.map(resolve_icon_path),
expanded: icon_theme.chevron_icons.expanded.map(resolve_icon_path),