From 6ed2348078a331bc2039a313bd7ad9f0bb1a00c2 Mon Sep 17 00:00:00 2001
From: Pascal Kuthe <pascal.kuthe@semimod.de>
Date: Thu, 2 Feb 2023 18:14:02 +0100
Subject: [PATCH] Hide duplicate symlinks from the picker (#5658)

* hide duplicate symlinks from the picker

* Apply suggestions from code review

Co-authored-by: g-re-g <123515925+g-re-g@users.noreply.github.com>

* minor stylistic fix

Co-authored-by: Michael Davis <mcarsondavis@gmail.com>

---------

Co-authored-by: g-re-g <123515925+g-re-g@users.noreply.github.com>
Co-authored-by: Michael Davis <mcarsondavis@gmail.com>
---
 book/src/configuration.md  |  2 ++
 helix-term/src/commands.rs | 13 +++++++++----
 helix-term/src/lib.rs      | 25 +++++++++++++++++++++++++
 helix-term/src/ui/mod.rs   | 18 ++++++++----------
 helix-view/src/editor.rs   |  3 +++
 5 files changed, 47 insertions(+), 14 deletions(-)

diff --git a/book/src/configuration.md b/book/src/configuration.md
index 528fafd0..87585ece 100644
--- a/book/src/configuration.md
+++ b/book/src/configuration.md
@@ -150,6 +150,8 @@ All git related options are only enabled in a git repository.
 | Key | Description | Default |
 |--|--|---------|
 |`hidden` | Enables ignoring hidden files. | true
+|`follow-links` | Follow symlinks instead of ignoring them | true
+|`deduplicate-links` | Ignore symlinks that point at files already shown in the picker | true
 |`parents` | Enables reading ignore files from parent directories. | true
 |`ignore` | Enables reading `.ignore` files. | true
 |`git-ignore` | Enables reading `.gitignore` files. | true
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index 2ee3f6b9..4f27fc80 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -49,6 +49,7 @@ use movement::Movement;
 use crate::{
     args,
     compositor::{self, Component, Compositor},
+    filter_picker_entry,
     job::Callback,
     keymap::ReverseKeymap,
     ui::{self, overlay::overlayed, FilePicker, Picker, Popup, Prompt, PromptEvent},
@@ -2013,6 +2014,11 @@ fn global_search(cx: &mut Context) {
 
                 let search_root = std::env::current_dir()
                     .expect("Global search error: Failed to get current dir");
+                let dedup_symlinks = file_picker_config.deduplicate_links;
+                let absolute_root = search_root
+                    .canonicalize()
+                    .unwrap_or_else(|_| search_root.clone());
+
                 WalkBuilder::new(search_root)
                     .hidden(file_picker_config.hidden)
                     .parents(file_picker_config.parents)
@@ -2022,10 +2028,9 @@ fn global_search(cx: &mut Context) {
                     .git_global(file_picker_config.git_global)
                     .git_exclude(file_picker_config.git_exclude)
                     .max_depth(file_picker_config.max_depth)
-                    // We always want to ignore the .git directory, otherwise if
-                    // `ignore` is turned off above, we end up with a lot of noise
-                    // in our picker.
-                    .filter_entry(|entry| entry.file_name() != ".git")
+                    .filter_entry(move |entry| {
+                        filter_picker_entry(entry, &absolute_root, dedup_symlinks)
+                    })
                     .build_parallel()
                     .run(|| {
                         let mut searcher = searcher.clone();
diff --git a/helix-term/src/lib.rs b/helix-term/src/lib.rs
index a945b20d..f0bc9129 100644
--- a/helix-term/src/lib.rs
+++ b/helix-term/src/lib.rs
@@ -10,6 +10,9 @@ pub mod health;
 pub mod job;
 pub mod keymap;
 pub mod ui;
+use std::path::Path;
+
+use ignore::DirEntry;
 pub use keymap::macros::*;
 
 #[cfg(not(windows))]
@@ -22,3 +25,25 @@ fn true_color() -> bool {
 fn true_color() -> bool {
     true
 }
+
+/// Function used for filtering dir entries in the various file pickers.
+fn filter_picker_entry(entry: &DirEntry, root: &Path, dedup_symlinks: bool) -> bool {
+    // We always want to ignore the .git directory, otherwise if
+    // `ignore` is turned off, we end up with a lot of noise
+    // in our picker.
+    if entry.file_name() == ".git" {
+        return false;
+    }
+
+    // We also ignore symlinks that point inside the current directory
+    // if `dedup_links` is enabled.
+    if dedup_symlinks && entry.path_is_symlink() {
+        return entry
+            .path()
+            .canonicalize()
+            .ok()
+            .map_or(false, |path| !path.starts_with(&root));
+    }
+
+    true
+}
diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs
index 5e7f8c36..d7717f8c 100644
--- a/helix-term/src/ui/mod.rs
+++ b/helix-term/src/ui/mod.rs
@@ -15,6 +15,7 @@ mod statusline;
 mod text;
 
 use crate::compositor::{Component, Compositor};
+use crate::filter_picker_entry;
 use crate::job::{self, Callback};
 pub use completion::Completion;
 pub use editor::EditorView;
@@ -163,6 +164,9 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePi
 
     let now = Instant::now();
 
+    let dedup_symlinks = config.file_picker.deduplicate_links;
+    let absolute_root = root.canonicalize().unwrap_or_else(|_| root.clone());
+
     let mut walk_builder = WalkBuilder::new(&root);
     walk_builder
         .hidden(config.file_picker.hidden)
@@ -173,10 +177,7 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePi
         .git_global(config.file_picker.git_global)
         .git_exclude(config.file_picker.git_exclude)
         .max_depth(config.file_picker.max_depth)
-        // We always want to ignore the .git directory, otherwise if
-        // `ignore` is turned off above, we end up with a lot of noise
-        // in our picker.
-        .filter_entry(|entry| entry.file_name() != ".git");
+        .filter_entry(move |entry| filter_picker_entry(entry, &absolute_root, dedup_symlinks));
 
     // We want to exclude files that the editor can't handle yet
     let mut type_builder = TypesBuilder::new();
@@ -195,14 +196,11 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePi
     // We want files along with their modification date for sorting
     let files = walk_builder.build().filter_map(|entry| {
         let entry = entry.ok()?;
-
         // This is faster than entry.path().is_dir() since it uses cached fs::Metadata fetched by ignore/walkdir
-        let is_dir = entry.file_type().map_or(false, |ft| ft.is_dir());
-        if is_dir {
-            // Will give a false positive if metadata cannot be read (eg. permission error)
-            None
-        } else {
+        if entry.file_type()?.is_file() {
             Some(entry.into_path())
+        } else {
+            None
         }
     });
 
diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs
index 46511c62..aabf9cde 100644
--- a/helix-view/src/editor.rs
+++ b/helix-view/src/editor.rs
@@ -173,6 +173,8 @@ pub struct FilePickerConfig {
     /// Enables following symlinks.
     /// Whether to follow symbolic links in file picker and file or directory completions. Defaults to true.
     pub follow_symlinks: bool,
+    /// Hides symlinks that point into the current directory. Defaults to true.
+    pub deduplicate_links: bool,
     /// Enables reading ignore files from parent directories. Defaults to true.
     pub parents: bool,
     /// Enables reading `.ignore` files.
@@ -197,6 +199,7 @@ impl Default for FilePickerConfig {
         Self {
             hidden: true,
             follow_symlinks: true,
+            deduplicate_links: true,
             parents: true,
             ignore: true,
             git_ignore: true,