Add support for named folder icons (#36351)

Jacob and Marshall Bowers created

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,

<img width="609" height="1307" alt="image"
src="https://github.com/user-attachments/assets/2d3c120a-b2f0-43fd-889d-641ad4bb9cee"
/>

Release Notes:

- Added support for icon themes to change the folder icon based on the
directory name.

---------

Co-authored-by: Marshall Bowers <git@maxdeviant.com>

Change summary

crates/acp_thread/src/mention.rs                          |  2 
crates/agent_ui/src/context_picker/completion_provider.rs |  7 
crates/agent_ui/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(-)

Detailed changes

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

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()

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

crates/file_icons/src/file_icons.rs 🔗

@@ -93,21 +93,62 @@ impl FileIcons {
         })
     }
 
-    pub fn get_folder_icon(expanded: bool, cx: &App) -> Option<SharedString> {
-        fn get_folder_icon(icon_theme: &Arc<IconTheme>, expanded: bool) -> Option<SharedString> {
+    pub fn get_folder_icon(expanded: bool, path: &Path, cx: &App) -> Option<SharedString> {
+        fn get_folder_icon(
+            icon_theme: &Arc<IconTheme>,
+            path: &Path,
+            expanded: bool,
+        ) -> Option<SharedString> {
+            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<SharedString> {
+        fn get_generic_folder_icon(
+            icon_theme: &Arc<IconTheme>,
+            expanded: bool,
+        ) -> Option<SharedString> {
+            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<SharedString> {
         fn get_chevron_icon(icon_theme: &Arc<IconTheme>, expanded: bool) -> Option<SharedString> {
             if expanded {

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)
             }

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)
                 }

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<String, DirectoryIcons>,
     /// 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<SharedString>,
@@ -392,6 +394,7 @@ static DEFAULT_ICON_THEME: LazyLock<Arc<IconTheme>> = 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()),

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<String, DirectoryIconsContent>,
+    #[serde(default)]
     pub chevron_icons: ChevronIconsContent,
     #[serde(default)]
     pub file_stems: HashMap<String, String>,

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