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, image 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),