Add language icons to the language selector (#21298)

Danilo Leal , Kirill Bulatov , and Piotr Osiewicz created

Closes https://github.com/zed-industries/zed/issues/21290

This is a first attempt to show the language icons to the selector.
Ideally, I wouldn't like to have yet another place mapping extensions to
icons, as we already have the `file_types.json` file doing that, but I'm
not so sure how to pull from it yet. Maybe in a future pass we'll
improve this and make it more solid.

<img width="700" alt="Screenshot 2024-11-28 at 16 10 27"
src="https://github.com/user-attachments/assets/683c3bef-5389-470f-a41e-3d510b927b61">

Release Notes:

- N/A

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>

Change summary

Cargo.lock                                          |  3 
assets/icons/file_icons/diff.svg                    |  5 
assets/icons/file_icons/file_types.json             |  6 +
crates/extension/src/extension_host_proxy.rs        |  4 
crates/extension_host/src/extension_host.rs         |  3 
crates/extension_host/src/extension_store_test.rs   |  2 
crates/extension_host/src/headless_host.rs          |  1 
crates/file_finder/src/file_finder.rs               |  2 
crates/language/src/language.rs                     |  8 +
crates/language/src/language_registry.rs            | 15 +
crates/language_extension/src/language_extension.rs |  3 
crates/language_selector/Cargo.toml                 |  3 
crates/language_selector/src/language_selector.rs   | 76 +++++++++++++-
crates/languages/src/jsdoc/config.toml              |  1 
crates/languages/src/lib.rs                         |  4 
crates/languages/src/regex/config.toml              |  1 
16 files changed, 119 insertions(+), 18 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -6709,11 +6709,14 @@ version = "0.1.0"
 dependencies = [
  "anyhow",
  "editor",
+ "file_finder",
+ "file_icons",
  "fuzzy",
  "gpui",
  "language",
  "picker",
  "project",
+ "settings",
  "ui",
  "util",
  "workspace",

assets/icons/file_icons/diff.svg πŸ”—

@@ -0,0 +1,5 @@
+<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8.5 3L8.5 10" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5 6.5H12" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5 13H12" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/file_icons/file_types.json πŸ”—

@@ -34,6 +34,7 @@
     "dat": "storage",
     "db": "storage",
     "dbf": "storage",
+    "diff": "diff",
     "dll": "storage",
     "doc": "document",
     "docx": "document",
@@ -112,6 +113,7 @@
     "mkv": "video",
     "ml": "ocaml",
     "mli": "ocaml",
+    "mod": "go",
     "mov": "video",
     "mp3": "audio",
     "mp4": "video",
@@ -185,6 +187,7 @@
     "wmv": "video",
     "woff": "font",
     "woff2": "font",
+    "work": "go",
     "wv": "audio",
     "xls": "document",
     "xlsx": "document",
@@ -239,6 +242,9 @@
     "default": {
       "icon": "icons/file_icons/file.svg"
     },
+    "diff": {
+      "icon": "icons/file_icons/diff.svg"
+    },
     "docker": {
       "icon": "icons/file_icons/docker.svg"
     },

crates/extension/src/extension_host_proxy.rs πŸ”—

@@ -159,6 +159,7 @@ pub trait ExtensionLanguageProxy: Send + Sync + 'static {
         language: LanguageName,
         grammar: Option<Arc<str>>,
         matcher: LanguageMatcher,
+        hidden: bool,
         load: Arc<dyn Fn() -> Result<LoadedLanguage> + Send + Sync + 'static>,
     );
 
@@ -175,13 +176,14 @@ impl ExtensionLanguageProxy for ExtensionHostProxy {
         language: LanguageName,
         grammar: Option<Arc<str>>,
         matcher: LanguageMatcher,
+        hidden: bool,
         load: Arc<dyn Fn() -> Result<LoadedLanguage> + Send + Sync + 'static>,
     ) {
         let Some(proxy) = self.language_proxy.read().clone() else {
             return;
         };
 
-        proxy.register_language(language, grammar, matcher, load)
+        proxy.register_language(language, grammar, matcher, hidden, load)
     }
 
     fn remove_languages(

crates/extension_host/src/extension_host.rs πŸ”—

@@ -162,6 +162,7 @@ pub struct ExtensionIndexLanguageEntry {
     pub extension: Arc<str>,
     pub path: PathBuf,
     pub matcher: LanguageMatcher,
+    pub hidden: bool,
     pub grammar: Option<Arc<str>>,
 }
 
@@ -1097,6 +1098,7 @@ impl ExtensionStore {
                 language_name.clone(),
                 language.grammar.clone(),
                 language.matcher.clone(),
+                language.hidden,
                 Arc::new(move || {
                     let config = std::fs::read_to_string(language_path.join("config.toml"))?;
                     let config: LanguageConfig = ::toml::from_str(&config)?;
@@ -1324,6 +1326,7 @@ impl ExtensionStore {
                         extension: extension_id.clone(),
                         path: relative_path,
                         matcher: config.matcher,
+                        hidden: config.hidden,
                         grammar: config.grammar,
                     },
                 );

crates/extension_host/src/extension_store_test.rs πŸ”—

@@ -203,6 +203,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                     extension: "zed-ruby".into(),
                     path: "languages/erb".into(),
                     grammar: Some("embedded_template".into()),
+                    hidden: false,
                     matcher: LanguageMatcher {
                         path_suffixes: vec!["erb".into()],
                         first_line_pattern: None,
@@ -215,6 +216,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                     extension: "zed-ruby".into(),
                     path: "languages/ruby".into(),
                     grammar: Some("ruby".into()),
+                    hidden: false,
                     matcher: LanguageMatcher {
                         path_suffixes: vec!["rb".into()],
                         first_line_pattern: None,

crates/language/src/language.rs πŸ”—

@@ -129,6 +129,10 @@ pub static PLAIN_TEXT: LazyLock<Arc<Language>> = LazyLock::new(|| {
         LanguageConfig {
             name: "Plain Text".into(),
             soft_wrap: Some(SoftWrap::EditorWidth),
+            matcher: LanguageMatcher {
+                path_suffixes: vec!["txt".to_owned()],
+                first_line_pattern: None,
+            },
             ..Default::default()
         },
         None,
@@ -1418,6 +1422,10 @@ impl Language {
     pub fn prettier_parser_name(&self) -> Option<&str> {
         self.config.prettier_parser_name.as_deref()
     }
+
+    pub fn config(&self) -> &LanguageConfig {
+        &self.config
+    }
 }
 
 impl LanguageScope {

crates/language/src/language_registry.rs πŸ”—

@@ -130,6 +130,7 @@ pub struct AvailableLanguage {
     name: LanguageName,
     grammar: Option<Arc<str>>,
     matcher: LanguageMatcher,
+    hidden: bool,
     load: Arc<dyn Fn() -> Result<LoadedLanguage> + 'static + Send + Sync>,
     loaded: bool,
 }
@@ -142,6 +143,9 @@ impl AvailableLanguage {
     pub fn matcher(&self) -> &LanguageMatcher {
         &self.matcher
     }
+    pub fn hidden(&self) -> bool {
+        self.hidden
+    }
 }
 
 enum AvailableGrammar {
@@ -288,6 +292,7 @@ impl LanguageRegistry {
             config.name.clone(),
             config.grammar.clone(),
             config.matcher.clone(),
+            config.hidden,
             Arc::new(move || {
                 Ok(LoadedLanguage {
                     config: config.clone(),
@@ -436,6 +441,7 @@ impl LanguageRegistry {
         name: LanguageName,
         grammar_name: Option<Arc<str>>,
         matcher: LanguageMatcher,
+        hidden: bool,
         load: Arc<dyn Fn() -> Result<LoadedLanguage> + 'static + Send + Sync>,
     ) {
         let state = &mut *self.state.write();
@@ -455,6 +461,7 @@ impl LanguageRegistry {
             grammar: grammar_name,
             matcher,
             load,
+            hidden,
             loaded: false,
         });
         state.version += 1;
@@ -522,6 +529,7 @@ impl LanguageRegistry {
             name: language.name(),
             grammar: language.config.grammar.clone(),
             matcher: language.config.matcher.clone(),
+            hidden: language.config.hidden,
             load: Arc::new(|| Err(anyhow!("already loaded"))),
             loaded: true,
         });
@@ -590,15 +598,12 @@ impl LanguageRegistry {
         async move { rx.await? }
     }
 
-    pub fn available_language_for_name(
-        self: &Arc<Self>,
-        name: &LanguageName,
-    ) -> Option<AvailableLanguage> {
+    pub fn available_language_for_name(self: &Arc<Self>, name: &str) -> Option<AvailableLanguage> {
         let state = self.state.read();
         state
             .available_languages
             .iter()
-            .find(|l| &l.name == name)
+            .find(|l| l.name.0.as_ref() == name)
             .cloned()
     }
 

crates/language_extension/src/language_extension.rs πŸ”—

@@ -34,10 +34,11 @@ impl ExtensionLanguageProxy for LanguageServerRegistryProxy {
         language: LanguageName,
         grammar: Option<Arc<str>>,
         matcher: LanguageMatcher,
+        hidden: bool,
         load: Arc<dyn Fn() -> Result<LoadedLanguage> + Send + Sync + 'static>,
     ) {
         self.language_registry
-            .register_language(language, grammar, matcher, load);
+            .register_language(language, grammar, matcher, hidden, load);
     }
 
     fn remove_languages(

crates/language_selector/Cargo.toml πŸ”—

@@ -15,11 +15,14 @@ doctest = false
 [dependencies]
 anyhow.workspace = true
 editor.workspace = true
+file_finder.workspace = true
+file_icons.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
 language.workspace = true
 picker.workspace = true
 project.workspace = true
+settings.workspace = true
 ui.workspace = true
 util.workspace = true
 workspace.workspace = true

crates/language_selector/src/language_selector.rs πŸ”—

@@ -3,15 +3,18 @@ mod active_buffer_language;
 pub use active_buffer_language::ActiveBufferLanguage;
 use anyhow::anyhow;
 use editor::Editor;
+use file_finder::file_finder_settings::FileFinderSettings;
+use file_icons::FileIcons;
 use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
 use gpui::{
     actions, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model,
     ParentElement, Render, Styled, View, ViewContext, VisualContext, WeakView,
 };
-use language::{Buffer, LanguageRegistry};
+use language::{Buffer, LanguageMatcher, LanguageName, LanguageRegistry};
 use picker::{Picker, PickerDelegate};
 use project::Project;
-use std::sync::Arc;
+use settings::Settings;
+use std::{ops::Not as _, path::Path, sync::Arc};
 use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
 use util::ResultExt;
 use workspace::{ModalView, Workspace};
@@ -102,7 +105,13 @@ impl LanguageSelectorDelegate {
             .language_names()
             .into_iter()
             .enumerate()
-            .map(|(candidate_id, name)| StringMatchCandidate::new(candidate_id, name))
+            .filter_map(|(candidate_id, name)| {
+                language_registry
+                    .available_language_for_name(&name)?
+                    .hidden()
+                    .not()
+                    .then(|| StringMatchCandidate::new(candidate_id, name))
+            })
             .collect::<Vec<_>>();
 
         Self {
@@ -115,13 +124,64 @@ impl LanguageSelectorDelegate {
             selected_index: 0,
         }
     }
+
+    fn language_data_for_match(
+        &self,
+        mat: &StringMatch,
+        cx: &AppContext,
+    ) -> (String, Option<Icon>) {
+        let mut label = mat.string.clone();
+        let buffer_language = self.buffer.read(cx).language();
+        let need_icon = FileFinderSettings::get_global(cx).file_icons;
+        if let Some(buffer_language) = buffer_language {
+            let buffer_language_name = buffer_language.name();
+            if buffer_language_name.0.as_ref() == mat.string.as_str() {
+                label.push_str(" (current)");
+                let icon = need_icon
+                    .then(|| self.language_icon(&buffer_language.config().matcher, cx))
+                    .flatten();
+                return (label, icon);
+            }
+        }
+
+        if need_icon {
+            let language_name = LanguageName::new(mat.string.as_str());
+            match self
+                .language_registry
+                .available_language_for_name(&language_name.0)
+            {
+                Some(available_language) => {
+                    let icon = self.language_icon(available_language.matcher(), cx);
+                    (label, icon)
+                }
+                None => (label, None),
+            }
+        } else {
+            (label, None)
+        }
+    }
+
+    fn language_icon(&self, matcher: &LanguageMatcher, cx: &AppContext) -> Option<Icon> {
+        matcher
+            .path_suffixes
+            .iter()
+            .find_map(|extension| {
+                if extension.contains('.') {
+                    None
+                } else {
+                    FileIcons::get_icon(Path::new(&format!("file.{extension}")), cx)
+                }
+            })
+            .map(Icon::from_path)
+            .map(|icon| icon.color(Color::Muted))
+    }
 }
 
 impl PickerDelegate for LanguageSelectorDelegate {
     type ListItem = ListItem;
 
     fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
-        "Select a language...".into()
+        "Select a language…".into()
     }
 
     fn match_count(&self) -> usize {
@@ -215,17 +275,13 @@ impl PickerDelegate for LanguageSelectorDelegate {
         cx: &mut ViewContext<Picker<Self>>,
     ) -> Option<Self::ListItem> {
         let mat = &self.matches[ix];
-        let buffer_language_name = self.buffer.read(cx).language().map(|l| l.name());
-        let mut label = mat.string.clone();
-        if buffer_language_name.map(|n| n.0).as_deref() == Some(mat.string.as_str()) {
-            label.push_str(" (current)");
-        }
-
+        let (label, language_icon) = self.language_data_for_match(mat, cx);
         Some(
             ListItem::new(ix)
                 .inset(true)
                 .spacing(ListItemSpacing::Sparse)
                 .selected(selected)
+                .start_slot::<Icon>(language_icon)
                 .child(HighlightedLabel::new(label, mat.positions.clone())),
         )
     }

crates/languages/src/lib.rs πŸ”—

@@ -62,6 +62,7 @@ pub fn init(languages: Arc<LanguageRegistry>, node_runtime: NodeRuntime, cx: &mu
                 config.name.clone(),
                 config.grammar.clone(),
                 config.matcher.clone(),
+                config.hidden,
                 Arc::new(move || {
                     Ok(LoadedLanguage {
                         config: config.clone(),
@@ -83,6 +84,7 @@ pub fn init(languages: Arc<LanguageRegistry>, node_runtime: NodeRuntime, cx: &mu
                 config.name.clone(),
                 config.grammar.clone(),
                 config.matcher.clone(),
+                config.hidden,
                 Arc::new(move || {
                     Ok(LoadedLanguage {
                         config: config.clone(),
@@ -104,6 +106,7 @@ pub fn init(languages: Arc<LanguageRegistry>, node_runtime: NodeRuntime, cx: &mu
                 config.name.clone(),
                 config.grammar.clone(),
                 config.matcher.clone(),
+                config.hidden,
                 Arc::new(move || {
                     Ok(LoadedLanguage {
                         config: config.clone(),
@@ -125,6 +128,7 @@ pub fn init(languages: Arc<LanguageRegistry>, node_runtime: NodeRuntime, cx: &mu
                 config.name.clone(),
                 config.grammar.clone(),
                 config.matcher.clone(),
+                config.hidden,
                 Arc::new(move || {
                     Ok(LoadedLanguage {
                         config: config.clone(),