Add `PathExt` trait (#2823)

Joseph T. Lyons created

This PR adds a `PathExt` trait. It pulls in our existing `compact()`
function, as a method, and then adds a method, and testing, for
`icon_suffix()`. A test was added to fix:

- https://github.com/zed-industries/community/issues/1877

Release Notes:

- Fixed a bug where file icons would not be registered for files with
with `.` characters in their name
([#1877](https://github.com/zed-industries/community/issues/1877)).

Change summary

crates/editor/src/items.rs                                   |   9 
crates/project_panel/src/file_associations.rs                |  11 
crates/recent_projects/src/highlighted_workspace_location.rs |   3 
crates/recent_projects/src/recent_projects.rs                |   3 
crates/util/src/paths.rs                                     | 118 +++--
5 files changed, 88 insertions(+), 56 deletions(-)

Detailed changes

crates/editor/src/items.rs 🔗

@@ -28,7 +28,10 @@ use std::{
     path::{Path, PathBuf},
 };
 use text::Selection;
-use util::{paths::FILE_ROW_COLUMN_DELIMITER, ResultExt, TryFutureExt};
+use util::{
+    paths::{PathExt, FILE_ROW_COLUMN_DELIMITER},
+    ResultExt, TryFutureExt,
+};
 use workspace::item::{BreadcrumbText, FollowableItemHandle};
 use workspace::{
     item::{FollowableItem, Item, ItemEvent, ItemHandle, ProjectItem},
@@ -546,9 +549,7 @@ impl Item for Editor {
             .and_then(|f| f.as_local())?
             .abs_path(cx);
 
-        let file_path = util::paths::compact(&file_path)
-            .to_string_lossy()
-            .to_string();
+        let file_path = file_path.compact().to_string_lossy().to_string();
 
         Some(file_path.into())
     }

crates/project_panel/src/file_associations.rs 🔗

@@ -4,7 +4,7 @@ use collections::HashMap;
 
 use gpui::{AppContext, AssetSource};
 use serde_derive::Deserialize;
-use util::iife;
+use util::{iife, paths::PathExt};
 
 #[derive(Deserialize, Debug)]
 struct TypeConfig {
@@ -48,14 +48,7 @@ impl FileAssociations {
             // FIXME: Associate a type with the languages and have the file's langauge
             //        override these associations
             iife!({
-                let suffix = path
-                    .file_name()
-                    .and_then(|os_str| os_str.to_str())
-                    .and_then(|file_name| {
-                        file_name
-                            .find('.')
-                            .and_then(|dot_index| file_name.get(dot_index + 1..))
-                    })?;
+                let suffix = path.icon_suffix()?;
 
                 this.suffixes
                     .get(suffix)

crates/recent_projects/src/highlighted_workspace_location.rs 🔗

@@ -5,6 +5,7 @@ use gpui::{
     elements::{Label, LabelStyle},
     AnyElement, Element, View,
 };
+use util::paths::PathExt;
 use workspace::WorkspaceLocation;
 
 pub struct HighlightedText {
@@ -61,7 +62,7 @@ impl HighlightedWorkspaceLocation {
             .paths()
             .iter()
             .map(|path| {
-                let path = util::paths::compact(&path);
+                let path = path.compact();
                 let highlighted_text = Self::highlights_for_path(
                     path.as_ref(),
                     &string_match.positions,

crates/recent_projects/src/recent_projects.rs 🔗

@@ -11,6 +11,7 @@ use highlighted_workspace_location::HighlightedWorkspaceLocation;
 use ordered_float::OrderedFloat;
 use picker::{Picker, PickerDelegate, PickerEvent};
 use std::sync::Arc;
+use util::paths::PathExt;
 use workspace::{
     notifications::simple_message_notification::MessageNotification, Workspace, WorkspaceLocation,
     WORKSPACE_DB,
@@ -134,7 +135,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                 let combined_string = location
                     .paths()
                     .iter()
-                    .map(|path| util::paths::compact(&path).to_string_lossy().into_owned())
+                    .map(|path| path.compact().to_string_lossy().into_owned())
                     .collect::<Vec<_>>()
                     .join("");
                 StringMatchCandidate::new(id, combined_string)

crates/util/src/paths.rs 🔗

@@ -30,49 +30,47 @@ pub mod legacy {
     }
 }
 
-/// Compacts a given file path by replacing the user's home directory
-/// prefix with a tilde (`~`).
-///
-/// # Arguments
-///
-/// * `path` - A reference to a `Path` representing the file path to compact.
-///
-/// # Examples
-///
-/// ```
-/// use std::path::{Path, PathBuf};
-/// use util::paths::compact;
-/// let path: PathBuf = [
-///     util::paths::HOME.to_string_lossy().to_string(),
-///     "some_file.txt".to_string(),
-///  ]
-///  .iter()
-///  .collect();
-/// if cfg!(target_os = "linux") || cfg!(target_os = "macos") {
-///     assert_eq!(compact(&path).to_str(), Some("~/some_file.txt"));
-/// } else {
-///     assert_eq!(compact(&path).to_str(), path.to_str());
-/// }
-/// ```
-///
-/// # Returns
-///
-/// * A `PathBuf` containing the compacted file path. If the input path
-///   does not have the user's home directory prefix, or if we are not on
-///   Linux or macOS, the original path is returned unchanged.
-pub fn compact(path: &Path) -> PathBuf {
-    if cfg!(target_os = "linux") || cfg!(target_os = "macos") {
-        match path.strip_prefix(HOME.as_path()) {
-            Ok(relative_path) => {
-                let mut shortened_path = PathBuf::new();
-                shortened_path.push("~");
-                shortened_path.push(relative_path);
-                shortened_path
+pub trait PathExt {
+    fn compact(&self) -> PathBuf;
+    fn icon_suffix(&self) -> Option<&str>;
+}
+
+impl<T: AsRef<Path>> PathExt for T {
+    /// Compacts a given file path by replacing the user's home directory
+    /// prefix with a tilde (`~`).
+    ///
+    /// # Returns
+    ///
+    /// * A `PathBuf` containing the compacted file path. If the input path
+    ///   does not have the user's home directory prefix, or if we are not on
+    ///   Linux or macOS, the original path is returned unchanged.
+    fn compact(&self) -> PathBuf {
+        if cfg!(target_os = "linux") || cfg!(target_os = "macos") {
+            match self.as_ref().strip_prefix(HOME.as_path()) {
+                Ok(relative_path) => {
+                    let mut shortened_path = PathBuf::new();
+                    shortened_path.push("~");
+                    shortened_path.push(relative_path);
+                    shortened_path
+                }
+                Err(_) => self.as_ref().to_path_buf(),
             }
-            Err(_) => path.to_path_buf(),
+        } else {
+            self.as_ref().to_path_buf()
         }
-    } else {
-        path.to_path_buf()
+    }
+
+    fn icon_suffix(&self) -> Option<&str> {
+        let file_name = self.as_ref().file_name()?.to_str()?;
+
+        if file_name.starts_with(".") {
+            return file_name.strip_prefix(".");
+        }
+
+        self.as_ref()
+            .extension()
+            .map(|extension| extension.to_str())
+            .flatten()
     }
 }
 
@@ -279,4 +277,42 @@ mod tests {
             );
         }
     }
+
+    #[test]
+    fn test_path_compact() {
+        let path: PathBuf = [
+            HOME.to_string_lossy().to_string(),
+            "some_file.txt".to_string(),
+        ]
+        .iter()
+        .collect();
+        if cfg!(target_os = "linux") || cfg!(target_os = "macos") {
+            assert_eq!(path.compact().to_str(), Some("~/some_file.txt"));
+        } else {
+            assert_eq!(path.compact().to_str(), path.to_str());
+        }
+    }
+
+    #[test]
+    fn test_path_suffix() {
+        // No dots in name
+        let path = Path::new("/a/b/c/file_name.rs");
+        assert_eq!(path.icon_suffix(), Some("rs"));
+
+        // Single dot in name
+        let path = Path::new("/a/b/c/file.name.rs");
+        assert_eq!(path.icon_suffix(), Some("rs"));
+
+        // Multiple dots in name
+        let path = Path::new("/a/b/c/long.file.name.rs");
+        assert_eq!(path.icon_suffix(), Some("rs"));
+
+        // Hidden file, no extension
+        let path = Path::new("/a/b/c/.gitignore");
+        assert_eq!(path.icon_suffix(), Some("gitignore"));
+
+        // Hidden file, with extension
+        let path = Path::new("/a/b/c/.eslintrc.js");
+        assert_eq!(path.icon_suffix(), Some("eslintrc.js"));
+    }
 }