Add icon and hover description for symlinks (#12263)

Patryck created

![image](https://github.com/zed-industries/zed/assets/12102857/c6ed8140-1256-4618-aa46-64e66becdf7e)


![Screenshot_20240524_201346](https://github.com/zed-industries/zed/assets/12102857/2577067c-4f61-486a-8613-14941555f5a8)

Resolves https://github.com/zed-industries/zed/issues/12142


Release Notes:

- Added in project panel an icon and hover description for symlinks ([12142](https://github.com/zed-industries/zed/issues/12142))

Change summary

crates/project_panel/src/project_panel.rs | 21 +++++++++++++++++++--
crates/worktree/src/worktree.rs           | 18 ++++++++++++++++--
2 files changed, 35 insertions(+), 4 deletions(-)

Detailed changes

crates/project_panel/src/project_panel.rs 🔗

@@ -29,7 +29,7 @@ use std::{
     sync::Arc,
 };
 use theme::ThemeSettings;
-use ui::{prelude::*, v_flex, ContextMenu, Icon, KeyBinding, Label, ListItem};
+use ui::{prelude::*, v_flex, ContextMenu, Icon, KeyBinding, Label, ListItem, Tooltip};
 use unicase::UniCase;
 use util::{maybe, NumericPrefixWithSuffix, ResultExt, TryFutureExt};
 use workspace::{
@@ -103,6 +103,7 @@ pub struct EntryDetails {
     is_cut: bool,
     git_status: Option<GitFileStatus>,
     is_private: bool,
+    canonical_path: Option<PathBuf>,
 }
 
 #[derive(PartialEq, Clone, Default, Debug, Deserialize)]
@@ -1444,11 +1445,12 @@ impl ProjectPanel {
                         path: entry.path.join("\0").into(),
                         inode: 0,
                         mtime: entry.mtime,
-                        is_symlink: false,
                         is_ignored: entry.is_ignored,
                         is_external: false,
                         is_private: false,
                         git_status: entry.git_status,
+                        canonical_path: entry.canonical_path.clone(),
+                        is_symlink: entry.is_symlink,
                     });
                 }
                 if expanded_dir_ids.binary_search(&entry.id).is_err()
@@ -1646,6 +1648,7 @@ impl ProjectPanel {
                             .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
                         git_status: status,
                         is_private: entry.is_private,
+                        canonical_path: entry.canonical_path.clone(),
                     };
 
                     if let Some(edit_state) = &self.edit_state {
@@ -1738,6 +1741,12 @@ impl ProjectPanel {
                 icon = FileIcons::get_icon(Path::new(&filename), cx);
             }
         }
+
+        let canonical_path = details
+            .canonical_path
+            .as_ref()
+            .map(|f| f.to_string_lossy().to_string());
+
         let depth = details.depth;
         div()
             .id(entry_id.to_proto() as usize)
@@ -1759,6 +1768,14 @@ impl ProjectPanel {
                     .indent_level(depth)
                     .indent_step_size(px(settings.indent_size))
                     .selected(is_selected)
+                    .when_some(canonical_path, |this, path| {
+                        this.end_slot::<Icon>(
+                            Icon::new(IconName::ArrowUpRight)
+                                .size(IconSize::Indicator)
+                                .color(filename_text_color),
+                        )
+                        .tooltip(move |cx| Tooltip::text(format!("{path} • Symbolic Link"), cx))
+                    })
                     .child(if let Some(icon) = &icon {
                         h_flex().child(Icon::from_path(icon.to_string()).color(filename_text_color))
                     } else {

crates/worktree/src/worktree.rs 🔗

@@ -471,6 +471,7 @@ impl Worktree {
                         &metadata,
                         &next_entry_id,
                         snapshot.root_char_bag,
+                        None
                     ),
                     fs.as_ref(),
                 );
@@ -3060,8 +3061,9 @@ pub struct Entry {
     pub path: Arc<Path>,
     pub inode: u64,
     pub mtime: Option<SystemTime>,
-    pub is_symlink: bool,
 
+    pub canonical_path: Option<PathBuf>,
+    pub is_symlink: bool,
     /// Whether this entry is ignored by Git.
     ///
     /// We only scan ignored entries once the directory is expanded and
@@ -3119,6 +3121,7 @@ impl Entry {
         metadata: &fs::Metadata,
         next_entry_id: &AtomicUsize,
         root_char_bag: CharBag,
+        canonical_path: Option<PathBuf>,
     ) -> Self {
         Self {
             id: ProjectEntryId::new(next_entry_id),
@@ -3130,6 +3133,7 @@ impl Entry {
             path,
             inode: metadata.inode,
             mtime: Some(metadata.mtime),
+            canonical_path,
             is_symlink: metadata.is_symlink,
             is_ignored: false,
             is_external: false,
@@ -3861,6 +3865,7 @@ impl BackgroundScanner {
                 &child_metadata,
                 &next_entry_id,
                 root_char_bag,
+                None,
             );
 
             if job.is_external {
@@ -3894,6 +3899,8 @@ impl BackgroundScanner {
                 if !canonical_path.starts_with(root_canonical_path) {
                     child_entry.is_external = true;
                 }
+
+                child_entry.canonical_path = Some(canonical_path);
             }
 
             if child_entry.is_dir() {
@@ -4049,7 +4056,13 @@ impl BackgroundScanner {
                         metadata,
                         self.next_entry_id.as_ref(),
                         state.snapshot.root_char_bag,
+                        if metadata.is_symlink {
+                            Some(canonical_path.to_path_buf())
+                        } else {
+                            None
+                        },
                     );
+
                     let is_dir = fs_entry.is_dir();
                     fs_entry.is_ignored = ignore_stack.is_abs_path_ignored(&abs_path, is_dir);
                     fs_entry.is_external = !canonical_path.starts_with(&root_canonical_path);
@@ -5048,11 +5061,12 @@ impl<'a> TryFrom<(&'a CharBag, proto::Entry)> for Entry {
             path,
             inode: entry.inode,
             mtime: entry.mtime.map(|time| time.into()),
-            is_symlink: entry.is_symlink,
+            canonical_path: None,
             is_ignored: entry.is_ignored,
             is_external: entry.is_external,
             git_status: git_status_from_proto(entry.git_status),
             is_private: false,
+            is_symlink: entry.is_symlink,
         })
     }
 }