Properly handle ignored files in the file finder (#31542)

Kirill Bulatov created

Follow-up of https://github.com/zed-industries/zed/pull/31457

Add a button and also allows to use `search::ToggleIncludeIgnored`
action in the file finder to toggle whether to show gitignored files or
not.
By default, returns back to the gitignored treatment before the PR
above.


![image](https://github.com/user-attachments/assets/c3117488-9c51-4b34-b630-42098fe14b4d)


Release Notes:

- Improved file finder to include indexed gitignored files in its search
results

Change summary

Cargo.lock                                     |   2 
assets/settings/default.json                   |  12 +
crates/file_finder/Cargo.toml                  |   2 
crates/file_finder/src/file_finder.rs          | 123 +++++++++++++++----
crates/file_finder/src/file_finder_settings.rs |  15 ++
crates/file_finder/src/file_finder_tests.rs    | 106 ++++++++++++++++
6 files changed, 228 insertions(+), 32 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -5381,8 +5381,10 @@ dependencies = [
  "language",
  "menu",
  "picker",
+ "pretty_assertions",
  "project",
  "schemars",
+ "search",
  "serde",
  "serde_derive",
  "serde_json",

assets/settings/default.json πŸ”—

@@ -959,7 +959,17 @@
     //    "skip_focus_for_active_in_search": false
     //
     // Default: true
-    "skip_focus_for_active_in_search": true
+    "skip_focus_for_active_in_search": true,
+    // Whether to show the git status in the file finder.
+    "git_status": true,
+    // Whether to use gitignored files when searching.
+    // Only the file Zed had indexed will be used, not necessary all the gitignored files.
+    //
+    // Can accept 3 values:
+    //   * `true`: Use all gitignored files
+    //   * `false`: Use only the files Zed had indexed
+    //   * `null`: Be smart and search for ignored when called from a gitignored worktree
+    "include_ignored": null
   },
   // Whether or not to remove any trailing whitespace from lines of a buffer
   // before saving it.

crates/file_finder/Cargo.toml πŸ”—

@@ -24,6 +24,7 @@ menu.workspace = true
 picker.workspace = true
 project.workspace = true
 schemars.workspace = true
+search.workspace = true
 settings.workspace = true
 serde.workspace = true
 serde_derive.workspace = true
@@ -40,6 +41,7 @@ editor = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 language = { workspace = true, features = ["test-support"] }
 picker = { workspace = true, features = ["test-support"] }
+pretty_assertions.workspace = true
 serde_json.workspace = true
 theme = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }

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

@@ -24,6 +24,7 @@ use new_path_prompt::NewPathPrompt;
 use open_path_prompt::OpenPathPrompt;
 use picker::{Picker, PickerDelegate};
 use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
+use search::ToggleIncludeIgnored;
 use settings::Settings;
 use std::{
     borrow::Cow,
@@ -37,8 +38,8 @@ use std::{
 };
 use text::Point;
 use ui::{
-    ContextMenu, HighlightedLabel, ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle,
-    prelude::*,
+    ContextMenu, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, PopoverMenu,
+    PopoverMenuHandle, Tooltip, prelude::*,
 };
 use util::{ResultExt, maybe, paths::PathWithPosition, post_inc};
 use workspace::{
@@ -222,6 +223,26 @@ impl FileFinder {
         });
     }
 
+    fn handle_toggle_ignored(
+        &mut self,
+        _: &ToggleIncludeIgnored,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.picker.update(cx, |picker, cx| {
+            picker.delegate.include_ignored = match picker.delegate.include_ignored {
+                Some(true) => match FileFinderSettings::get_global(cx).include_ignored {
+                    Some(_) => Some(false),
+                    None => None,
+                },
+                Some(false) => Some(true),
+                None => Some(true),
+            };
+            picker.delegate.include_ignored_refresh =
+                picker.delegate.update_matches(picker.query(cx), window, cx);
+        });
+    }
+
     fn go_to_file_split_left(
         &mut self,
         _: &pane::SplitLeft,
@@ -325,6 +346,7 @@ impl Render for FileFinder {
             .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
             .on_action(cx.listener(Self::handle_select_prev))
             .on_action(cx.listener(Self::handle_toggle_menu))
+            .on_action(cx.listener(Self::handle_toggle_ignored))
             .on_action(cx.listener(Self::go_to_file_split_left))
             .on_action(cx.listener(Self::go_to_file_split_right))
             .on_action(cx.listener(Self::go_to_file_split_up))
@@ -351,6 +373,8 @@ pub struct FileFinderDelegate {
     first_update: bool,
     popover_menu_handle: PopoverMenuHandle<ContextMenu>,
     focus_handle: FocusHandle,
+    include_ignored: Option<bool>,
+    include_ignored_refresh: Task<()>,
 }
 
 /// Use a custom ordering for file finder: the regular one
@@ -736,6 +760,8 @@ impl FileFinderDelegate {
             first_update: true,
             popover_menu_handle: PopoverMenuHandle::default(),
             focus_handle: cx.focus_handle(),
+            include_ignored: FileFinderSettings::get_global(cx).include_ignored,
+            include_ignored_refresh: Task::ready(()),
         }
     }
 
@@ -779,7 +805,11 @@ impl FileFinderDelegate {
                 let worktree = worktree.read(cx);
                 PathMatchCandidateSet {
                     snapshot: worktree.snapshot(),
-                    include_ignored: true,
+                    include_ignored: self.include_ignored.unwrap_or_else(|| {
+                        worktree
+                            .root_entry()
+                            .map_or(false, |entry| entry.is_ignored)
+                    }),
                     include_root_name,
                     candidates: project::Candidates::Files,
                 }
@@ -1468,38 +1498,75 @@ impl PickerDelegate for FileFinderDelegate {
             h_flex()
                 .w_full()
                 .p_2()
-                .gap_2()
-                .justify_end()
+                .justify_between()
                 .border_t_1()
                 .border_color(cx.theme().colors().border_variant)
                 .child(
-                    Button::new("open-selection", "Open").on_click(|_, window, cx| {
-                        window.dispatch_action(menu::Confirm.boxed_clone(), cx)
-                    }),
+                    IconButton::new("toggle-ignored", IconName::Sliders)
+                        .on_click({
+                            let focus_handle = self.focus_handle.clone();
+                            move |_, window, cx| {
+                                focus_handle.dispatch_action(&ToggleIncludeIgnored, window, cx);
+                            }
+                        })
+                        .style(ButtonStyle::Subtle)
+                        .shape(IconButtonShape::Square)
+                        .toggle_state(self.include_ignored.unwrap_or(false))
+                        .tooltip({
+                            let focus_handle = self.focus_handle.clone();
+                            move |window, cx| {
+                                Tooltip::for_action_in(
+                                    "Use ignored files",
+                                    &ToggleIncludeIgnored,
+                                    &focus_handle,
+                                    window,
+                                    cx,
+                                )
+                            }
+                        }),
                 )
                 .child(
-                    PopoverMenu::new("menu-popover")
-                        .with_handle(self.popover_menu_handle.clone())
-                        .attach(gpui::Corner::TopRight)
-                        .anchor(gpui::Corner::BottomRight)
-                        .trigger(
-                            Button::new("actions-trigger", "Split…")
-                                .selected_label_color(Color::Accent),
+                    h_flex()
+                        .p_2()
+                        .gap_2()
+                        .child(
+                            Button::new("open-selection", "Open").on_click(|_, window, cx| {
+                                window.dispatch_action(menu::Confirm.boxed_clone(), cx)
+                            }),
                         )
-                        .menu({
-                            move |window, cx| {
-                                Some(ContextMenu::build(window, cx, {
-                                    let context = context.clone();
-                                    move |menu, _, _| {
-                                        menu.context(context)
-                                            .action("Split Left", pane::SplitLeft.boxed_clone())
-                                            .action("Split Right", pane::SplitRight.boxed_clone())
-                                            .action("Split Up", pane::SplitUp.boxed_clone())
-                                            .action("Split Down", pane::SplitDown.boxed_clone())
+                        .child(
+                            PopoverMenu::new("menu-popover")
+                                .with_handle(self.popover_menu_handle.clone())
+                                .attach(gpui::Corner::TopRight)
+                                .anchor(gpui::Corner::BottomRight)
+                                .trigger(
+                                    Button::new("actions-trigger", "Split…")
+                                        .selected_label_color(Color::Accent),
+                                )
+                                .menu({
+                                    move |window, cx| {
+                                        Some(ContextMenu::build(window, cx, {
+                                            let context = context.clone();
+                                            move |menu, _, _| {
+                                                menu.context(context)
+                                                    .action(
+                                                        "Split Left",
+                                                        pane::SplitLeft.boxed_clone(),
+                                                    )
+                                                    .action(
+                                                        "Split Right",
+                                                        pane::SplitRight.boxed_clone(),
+                                                    )
+                                                    .action("Split Up", pane::SplitUp.boxed_clone())
+                                                    .action(
+                                                        "Split Down",
+                                                        pane::SplitDown.boxed_clone(),
+                                                    )
+                                            }
+                                        }))
                                     }
-                                }))
-                            }
-                        }),
+                                }),
+                        ),
                 )
                 .into_any(),
         )

crates/file_finder/src/file_finder_settings.rs πŸ”—

@@ -8,6 +8,7 @@ pub struct FileFinderSettings {
     pub file_icons: bool,
     pub modal_max_width: Option<FileFinderWidth>,
     pub skip_focus_for_active_in_search: bool,
+    pub include_ignored: Option<bool>,
 }
 
 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
@@ -24,6 +25,20 @@ pub struct FileFinderSettingsContent {
     ///
     /// Default: true
     pub skip_focus_for_active_in_search: Option<bool>,
+    /// Determines whether to show the git status in the file finder
+    ///
+    /// Default: true
+    pub git_status: Option<bool>,
+    /// Whether to use gitignored files when searching.
+    /// Only the file Zed had indexed will be used, not necessary all the gitignored files.
+    ///
+    /// Can accept 3 values:
+    /// * `Some(true)`: Use all gitignored files
+    /// * `Some(false)`: Use only the files Zed had indexed
+    /// * `None`: Be smart and search for ignored when called from a gitignored worktree
+    ///
+    /// Default: None
+    pub include_ignored: Option<Option<bool>>,
 }
 
 impl Settings for FileFinderSettings {

crates/file_finder/src/file_finder_tests.rs πŸ”—

@@ -1,9 +1,10 @@
-use std::{assert_eq, future::IntoFuture, path::Path, time::Duration};
+use std::{future::IntoFuture, path::Path, time::Duration};
 
 use super::*;
 use editor::Editor;
 use gpui::{Entity, TestAppContext, VisualTestContext};
 use menu::{Confirm, SelectNext, SelectPrevious};
+use pretty_assertions::assert_eq;
 use project::{FS_WATCH_LATENCY, RemoveOptions};
 use serde_json::json;
 use util::path;
@@ -646,6 +647,31 @@ async fn test_ignored_root(cx: &mut TestAppContext) {
     .await;
     let (picker, workspace, cx) = build_find_picker(project, cx);
 
+    picker
+        .update_in(cx, |picker, window, cx| {
+            picker
+                .delegate
+                .spawn_search(test_path_position("hi"), window, cx)
+        })
+        .await;
+    picker.update(cx, |picker, _| {
+        let matches = collect_search_matches(picker);
+        assert_eq!(matches.history.len(), 0);
+        assert_eq!(
+            matches.search,
+            vec![
+                PathBuf::from("ignored-root/hi"),
+                PathBuf::from("tracked-root/hi"),
+                PathBuf::from("ignored-root/hiccup"),
+                PathBuf::from("tracked-root/hiccup"),
+                PathBuf::from("ignored-root/height"),
+                PathBuf::from("ignored-root/happiness"),
+                PathBuf::from("tracked-root/happiness"),
+            ],
+            "All ignored files that were indexed are found for default ignored mode"
+        );
+    });
+    cx.dispatch_action(ToggleIncludeIgnored);
     picker
         .update_in(cx, |picker, window, cx| {
             picker
@@ -668,7 +694,29 @@ async fn test_ignored_root(cx: &mut TestAppContext) {
                 PathBuf::from("ignored-root/happiness"),
                 PathBuf::from("tracked-root/happiness"),
             ],
-            "All ignored files that were indexed are found"
+            "All ignored files should be found, for the toggled on ignored mode"
+        );
+    });
+
+    picker
+        .update_in(cx, |picker, window, cx| {
+            picker.delegate.include_ignored = Some(false);
+            picker
+                .delegate
+                .spawn_search(test_path_position("hi"), window, cx)
+        })
+        .await;
+    picker.update(cx, |picker, _| {
+        let matches = collect_search_matches(picker);
+        assert_eq!(matches.history.len(), 0);
+        assert_eq!(
+            matches.search,
+            vec![
+                PathBuf::from("tracked-root/hi"),
+                PathBuf::from("tracked-root/hiccup"),
+                PathBuf::from("tracked-root/happiness"),
+            ],
+            "Only non-ignored files should be found for the turned off ignored mode"
         );
     });
 
@@ -686,6 +734,7 @@ async fn test_ignored_root(cx: &mut TestAppContext) {
         })
         .await
         .unwrap();
+    cx.run_until_parked();
     workspace
         .update_in(cx, |workspace, window, cx| {
             workspace.active_pane().update(cx, |pane, cx| {
@@ -695,8 +744,37 @@ async fn test_ignored_root(cx: &mut TestAppContext) {
         })
         .await
         .unwrap();
+    cx.run_until_parked();
+
     picker
         .update_in(cx, |picker, window, cx| {
+            picker.delegate.include_ignored = None;
+            picker
+                .delegate
+                .spawn_search(test_path_position("hi"), window, cx)
+        })
+        .await;
+    picker.update(cx, |picker, _| {
+        let matches = collect_search_matches(picker);
+        assert_eq!(matches.history.len(), 0);
+        assert_eq!(
+            matches.search,
+            vec![
+                PathBuf::from("ignored-root/hi"),
+                PathBuf::from("tracked-root/hi"),
+                PathBuf::from("ignored-root/hiccup"),
+                PathBuf::from("tracked-root/hiccup"),
+                PathBuf::from("ignored-root/height"),
+                PathBuf::from("ignored-root/happiness"),
+                PathBuf::from("tracked-root/happiness"),
+            ],
+            "Only for the worktree with the ignored root, all indexed ignored files are found in the auto ignored mode"
+        );
+    });
+
+    picker
+        .update_in(cx, |picker, window, cx| {
+            picker.delegate.include_ignored = Some(true);
             picker
                 .delegate
                 .spawn_search(test_path_position("hi"), window, cx)
@@ -719,7 +797,29 @@ async fn test_ignored_root(cx: &mut TestAppContext) {
                 PathBuf::from("ignored-root/happiness"),
                 PathBuf::from("tracked-root/happiness"),
             ],
-            "All ignored files that were indexed are found"
+            "All ignored files that were indexed are found in the turned on ignored mode"
+        );
+    });
+
+    picker
+        .update_in(cx, |picker, window, cx| {
+            picker.delegate.include_ignored = Some(false);
+            picker
+                .delegate
+                .spawn_search(test_path_position("hi"), window, cx)
+        })
+        .await;
+    picker.update(cx, |picker, _| {
+        let matches = collect_search_matches(picker);
+        assert_eq!(matches.history.len(), 0);
+        assert_eq!(
+            matches.search,
+            vec![
+                PathBuf::from("tracked-root/hi"),
+                PathBuf::from("tracked-root/hiccup"),
+                PathBuf::from("tracked-root/happiness"),
+            ],
+            "Only non-ignored files should be found for the turned off ignored mode"
         );
     });
 }