Add icon support for files without extensions (#8453)

Sai Gokula Krishnan created

Release Notes:

- Added support for showing file icons for files without suffixes.

Before:

<img width="281" alt="image"
src="https://github.com/zed-industries/zed/assets/25414681/ab4c00ed-72c7-458f-8dda-61c68165590f">


After:

<img width="242" alt="Screenshot 2024-02-27 at 1 51 20 AM"
src="https://github.com/zed-industries/zed/assets/25414681/8f3082c4-9424-4bc3-9100-a527b9adc315">


This screenshot is to show if the file has extension, then the extension
takes precedence.

<img width="193" alt="image"
src="https://github.com/zed-industries/zed/assets/25414681/72fcebd1-361f-444b-8890-f59932963083">


<br>

- Added icons for
    - Docker - https://www.svgrepo.com/svg/473589/docker
    - License - https://www.svgrepo.com/svg/477704/license-1
    - Heroku - https://www.svgrepo.com/svg/341904/heroku
 
 - Updated tests

Change summary

assets/icons/file_icons/docker.svg            |  3 +
assets/icons/file_icons/file_types.json       | 14 ++++++++
assets/icons/file_icons/heroku.svg            |  3 +
crates/project_panel/src/file_associations.rs | 11 ++++++
crates/util/src/paths.rs                      | 32 +++++++++++---------
5 files changed, 47 insertions(+), 16 deletions(-)

Detailed changes

assets/icons/file_icons/docker.svg 🔗

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="14px" height="14px" viewBox="0 0 14 14" version="1.1">
+<g id="surface1">

assets/icons/file_icons/file_types.json 🔗

@@ -1,4 +1,9 @@
 {
+  "stems": {
+    "Podfile": "ruby",
+    "Procfile": "heroku",
+    "Dockerfile": "docker"
+  },
   "suffixes": {
     "astro": "astro",
     "Emakefile": "erlang",
@@ -86,6 +91,7 @@
     "mdb": "storage",
     "mdf": "storage",
     "mdx": "document",
+    "metadata": "code",
     "mkv": "video",
     "mjs": "code",
     "mka": "audio",
@@ -189,6 +195,9 @@
     "default": {
       "icon": "icons/file_icons/file.svg"
     },
+    "docker": {
+      "icon": "icons/file_icons/docker.svg"
+    },
     "document": {
       "icon": "icons/file_icons/book.svg"
     },
@@ -216,6 +225,9 @@
     "haskell": {
       "icon": "icons/file_icons/haskell.svg"
     },
+    "heroku": {
+      "icon": "icons/file_icons/heroku.svg"
+    },
     "go": {
       "icon": "icons/file_icons/go.svg"
     },
@@ -228,7 +240,7 @@
     "java": {
       "icon": "icons/file_icons/java.svg"
     },
-    "kotlin":{
+    "kotlin": {
       "icon": "icons/file_icons/kotlin.svg"
     },
     "lock": {

assets/icons/file_icons/heroku.svg 🔗

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="14px" height="14px" viewBox="0 0 14 14" version="1.1">
+<g id="surface1">

crates/project_panel/src/file_associations.rs 🔗

@@ -13,6 +13,7 @@ struct TypeConfig {
 
 #[derive(Deserialize, Debug)]
 pub struct FileAssociations {
+    stems: HashMap<String, String>,
     suffixes: HashMap<String, String>,
     types: HashMap<String, TypeConfig>,
 }
@@ -38,6 +39,7 @@ impl FileAssociations {
                     .map_err(Into::into)
             })
             .unwrap_or_else(|_| FileAssociations {
+                stems: HashMap::default(),
                 suffixes: HashMap::default(),
                 types: HashMap::default(),
             })
@@ -49,7 +51,14 @@ impl FileAssociations {
         // FIXME: Associate a type with the languages and have the file's language
         //        override these associations
         maybe!({
-            let suffix = path.icon_suffix()?;
+            let suffix = path.icon_stem_or_suffix()?;
+
+            if let Some(type_str) = this.stems.get(suffix) {
+                return this
+                    .types
+                    .get(type_str)
+                    .map(|type_config| type_config.icon.clone());
+            }
 
             this.suffixes
                 .get(suffix)

crates/util/src/paths.rs 🔗

@@ -48,7 +48,7 @@ lazy_static::lazy_static! {
 
 pub trait PathExt {
     fn compact(&self) -> PathBuf;
-    fn icon_suffix(&self) -> Option<&str>;
+    fn icon_stem_or_suffix(&self) -> Option<&str>;
     fn extension_or_hidden_file_name(&self) -> Option<&str>;
     fn try_from_bytes<'a>(bytes: &'a [u8]) -> anyhow::Result<Self>
     where
@@ -100,17 +100,17 @@ impl<T: AsRef<Path>> PathExt for T {
         }
     }
 
-    /// Returns a suffix of the path that is used to determine which file icon to use
-    fn icon_suffix(&self) -> Option<&str> {
-        let file_name = self.as_ref().file_name()?.to_str()?;
-
+    /// Returns either the suffix if available, or the file stem otherwise to determine which file icon to use
+    fn icon_stem_or_suffix(&self) -> Option<&str> {
+        let path = self.as_ref();
+        let file_name = path.file_name()?.to_str()?;
         if file_name.starts_with('.') {
             return file_name.strip_prefix('.');
         }
 
-        self.as_ref()
-            .extension()
-            .and_then(|extension| extension.to_str())
+        path.extension()
+            .and_then(|e| e.to_str())
+            .or_else(|| path.file_stem()?.to_str())
     }
 
     /// Returns a file's extension or, if the file is hidden, its name without the leading dot
@@ -403,26 +403,30 @@ mod tests {
     }
 
     #[test]
-    fn test_icon_suffix() {
+    fn test_icon_stem_or_suffix() {
         // No dots in name
         let path = Path::new("/a/b/c/file_name.rs");
-        assert_eq!(path.icon_suffix(), Some("rs"));
+        assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
 
         // Single dot in name
         let path = Path::new("/a/b/c/file.name.rs");
-        assert_eq!(path.icon_suffix(), Some("rs"));
+        assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
+
+        // No suffix
+        let path = Path::new("/a/b/c/file");
+        assert_eq!(path.icon_stem_or_suffix(), Some("file"));
 
         // Multiple dots in name
         let path = Path::new("/a/b/c/long.file.name.rs");
-        assert_eq!(path.icon_suffix(), Some("rs"));
+        assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
 
         // Hidden file, no extension
         let path = Path::new("/a/b/c/.gitignore");
-        assert_eq!(path.icon_suffix(), Some("gitignore"));
+        assert_eq!(path.icon_stem_or_suffix(), Some("gitignore"));
 
         // Hidden file, with extension
         let path = Path::new("/a/b/c/.eslintrc.js");
-        assert_eq!(path.icon_suffix(), Some("eslintrc.js"));
+        assert_eq!(path.icon_stem_or_suffix(), Some("eslintrc.js"));
     }
 
     #[test]