Add support for excluding files based on `.gitignore` (#26636)

Alvaro Parker created

Closes: #17543

Release Notes:

- **New Feature:** Introduced the ability to automatically remove files
and directories from the Zed project panel that are specified in
`.gitignore`.
- **Configuration Option:** This behavior can be controlled via the new
`project_panel.hide_gitignore` setting. By setting it to `true`, files
listed in `.gitignore` will be excluded from the project panel.
- **Toggle:** Ability to toggle this setting using the action
`ProjectPanel::ToggleHideGitIgnore`

```json
  "project_panel": {
    "hide_gitignore": true
  },

```

This results in a cleaner and easier to browse project panel for
projects that generate a lot of object files like `xv6-riscv` or `linux`
without needing to tweak `file_scan_exclusions` on `settings.json`

**Preview:**
- With `"project_panel.hide_gitignore": false` (default, this is how zed
currently looks)

![Screenshot From 2025-03-23
12-50-17](https://github.com/user-attachments/assets/15607e73-a474-4188-982a-eed4e0551061)

- With `"project_panel.hide_gitignore": true` 

![Screenshot From 2025-03-23
12-50-27](https://github.com/user-attachments/assets/3e281f92-294c-4133-b5e3-25e17f15bd4d)

- Action `ProjectPanel::ToggleHideGitIgnore`

![Screenshot From 2025-03-23
12-50-55](https://github.com/user-attachments/assets/4d03db33-75ad-471c-814c-098698a8cb38)

Change summary

assets/settings/default.json                       |   2 
crates/project_panel/src/project_panel.rs          |  31 ++
crates/project_panel/src/project_panel_settings.rs |   5 
crates/project_panel/src/project_panel_tests.rs    | 166 ++++++++++++++++
4 files changed, 196 insertions(+), 8 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -427,6 +427,8 @@
   "project_panel": {
     // Whether to show the project panel button in the status bar
     "button": true,
+    // Whether to hide the gitignore entries in the project panel.
+    "hide_gitignore": false,
     // Default width of the project panel.
     "default_width": 240,
     // Where to dock the project panel. Can be 'left' or 'right'.

crates/project_panel/src/project_panel.rs 🔗

@@ -36,7 +36,7 @@ use project_panel_settings::{
 };
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsStore};
+use settings::{update_settings_file, Settings, SettingsStore};
 use smallvec::SmallVec;
 use std::any::TypeId;
 use std::{
@@ -197,6 +197,7 @@ actions!(
         Open,
         OpenPermanent,
         ToggleFocus,
+        ToggleHideGitIgnore,
         NewSearchInDirectory,
         UnfoldDirectory,
         FoldDirectory,
@@ -233,6 +234,13 @@ pub fn init(cx: &mut App) {
         workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
             workspace.toggle_panel_focus::<ProjectPanel>(window, cx);
         });
+
+        workspace.register_action(|workspace, _: &ToggleHideGitIgnore, _, cx| {
+            let fs = workspace.app_state().fs.clone();
+            update_settings_file::<ProjectPanelSettings>(fs, cx, move |setting, _| {
+                setting.hide_gitignore = Some(!setting.hide_gitignore.unwrap_or(false));
+            })
+        });
     })
     .detach();
 }
@@ -414,6 +422,9 @@ impl ProjectPanel {
             cx.observe_global::<SettingsStore>(move |this, cx| {
                 let new_settings = *ProjectPanelSettings::get_global(cx);
                 if project_panel_settings != new_settings {
+                    if project_panel_settings.hide_gitignore != new_settings.hide_gitignore {
+                        this.update_visible_entries(None, cx);
+                    }
                     project_panel_settings = new_settings;
                     this.update_diagnostics(cx);
                     cx.notify();
@@ -1536,7 +1547,6 @@ impl ProjectPanel {
         if sanitized_entries.is_empty() {
             return None;
         }
-
         let project = self.project.read(cx);
         let (worktree_id, worktree) = sanitized_entries
             .iter()
@@ -1568,13 +1578,14 @@ impl ProjectPanel {
 
         // Remove all siblings that are being deleted except the last marked entry
         let snapshot = worktree.snapshot();
+        let hide_gitignore = ProjectPanelSettings::get_global(cx).hide_gitignore;
         let mut siblings: Vec<_> = ChildEntriesGitIter::new(&snapshot, parent_path)
             .filter(|sibling| {
-                sibling.id == latest_entry.id
-                    || !marked_entries_in_worktree.contains(&&SelectedEntry {
+                (sibling.id == latest_entry.id)
+                    || (!marked_entries_in_worktree.contains(&&SelectedEntry {
                         worktree_id,
                         entry_id: sibling.id,
-                    })
+                    }) && (!hide_gitignore || !sibling.is_ignored))
             })
             .map(|entry| entry.to_owned())
             .collect();
@@ -2590,7 +2601,9 @@ impl ProjectPanel {
         new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
         cx: &mut Context<Self>,
     ) {
-        let auto_collapse_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
+        let settings = ProjectPanelSettings::get_global(cx);
+        let auto_collapse_dirs = settings.auto_fold_dirs;
+        let hide_gitignore = settings.hide_gitignore;
         let project = self.project.read(cx);
         self.last_worktree_root_id = project
             .visible_worktrees(cx)
@@ -2675,7 +2688,9 @@ impl ProjectPanel {
                     }
                 }
                 auto_folded_ancestors.clear();
-                visible_worktree_entries.push(entry.to_owned());
+                if !hide_gitignore || !entry.is_ignored {
+                    visible_worktree_entries.push(entry.to_owned());
+                }
                 let precedes_new_entry = if let Some(new_entry_id) = new_entry_parent_id {
                     entry.id == new_entry_id || {
                         self.ancestors.get(&entry.id).map_or(false, |entries| {
@@ -2688,7 +2703,7 @@ impl ProjectPanel {
                 } else {
                     false
                 };
-                if precedes_new_entry {
+                if precedes_new_entry && (!hide_gitignore || !entry.is_ignored) {
                     visible_worktree_entries.push(GitEntry {
                         entry: Entry {
                             id: NEW_ENTRY_ID,

crates/project_panel/src/project_panel_settings.rs 🔗

@@ -31,6 +31,7 @@ pub enum EntrySpacing {
 #[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
 pub struct ProjectPanelSettings {
     pub button: bool,
+    pub hide_gitignore: bool,
     pub default_width: Pixels,
     pub dock: ProjectPanelDockPosition,
     pub entry_spacing: EntrySpacing,
@@ -93,6 +94,10 @@ pub struct ProjectPanelSettingsContent {
     ///
     /// Default: true
     pub button: Option<bool>,
+    /// Whether to hide gitignore files in the project panel.
+    ///
+    /// Default: false
+    pub hide_gitignore: Option<bool>,
     /// Customize default width (in pixels) taken by project panel
     ///
     /// Default: 240

crates/project_panel/src/project_panel_tests.rs 🔗

@@ -3735,6 +3735,172 @@ async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
     );
 }
 
+#[gpui::test]
+async fn test_deletion_gitignored(cx: &mut gpui::TestAppContext) {
+    init_test_with_editor(cx);
+
+    let fs = FakeFs::new(cx.executor().clone());
+    fs.insert_tree(
+        path!("/root"),
+        json!({
+            "aa": "// Testing 1",
+            "bb": "// Testing 2",
+            "cc": "// Testing 3",
+            "dd": "// Testing 4",
+            "ee": "// Testing 5",
+            "ff": "// Testing 6",
+            "gg": "// Testing 7",
+            "hh": "// Testing 8",
+            "ii": "// Testing 8",
+            ".gitignore": "bb\ndd\nee\nff\nii\n'",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+    // Test 1: Auto selection with one gitignored file next to the deleted file
+    cx.update(|_, cx| {
+        let settings = *ProjectPanelSettings::get_global(cx);
+        ProjectPanelSettings::override_global(
+            ProjectPanelSettings {
+                hide_gitignore: true,
+                ..settings
+            },
+            cx,
+        );
+    });
+
+    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+
+    select_path(&panel, "root/aa", cx);
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..10, cx),
+        &[
+            "v root",
+            "      .gitignore",
+            "      aa  <== selected",
+            "      cc",
+            "      gg",
+            "      hh"
+        ],
+        "Initial state should hide files on .gitignore"
+    );
+
+    submit_deletion(&panel, cx);
+
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..10, cx),
+        &[
+            "v root",
+            "      .gitignore",
+            "      cc  <== selected",
+            "      gg",
+            "      hh"
+        ],
+        "Should select next entry not on .gitignore"
+    );
+
+    // Test 2: Auto selection with many gitignored files next to the deleted file
+    submit_deletion(&panel, cx);
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..10, cx),
+        &[
+            "v root",
+            "      .gitignore",
+            "      gg  <== selected",
+            "      hh"
+        ],
+        "Should select next entry not on .gitignore"
+    );
+
+    // Test 3: Auto selection of entry before deleted file
+    select_path(&panel, "root/hh", cx);
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..10, cx),
+        &[
+            "v root",
+            "      .gitignore",
+            "      gg",
+            "      hh  <== selected"
+        ],
+        "Should select next entry not on .gitignore"
+    );
+    submit_deletion(&panel, cx);
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..10, cx),
+        &["v root", "      .gitignore", "      gg  <== selected"],
+        "Should select next entry not on .gitignore"
+    );
+}
+
+#[gpui::test]
+async fn test_nested_deletion_gitignore(cx: &mut gpui::TestAppContext) {
+    init_test_with_editor(cx);
+
+    let fs = FakeFs::new(cx.executor().clone());
+    fs.insert_tree(
+        path!("/root"),
+        json!({
+            "dir1": {
+                "file1": "// Testing",
+                "file2": "// Testing",
+                "file3": "// Testing"
+            },
+            "aa": "// Testing",
+            ".gitignore": "file1\nfile3\n",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+    cx.update(|_, cx| {
+        let settings = *ProjectPanelSettings::get_global(cx);
+        ProjectPanelSettings::override_global(
+            ProjectPanelSettings {
+                hide_gitignore: true,
+                ..settings
+            },
+            cx,
+        );
+    });
+
+    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+
+    // Test 1: Visible items should exclude files on gitignore
+    toggle_expand_dir(&panel, "root/dir1", cx);
+    select_path(&panel, "root/dir1/file2", cx);
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..10, cx),
+        &[
+            "v root",
+            "    v dir1",
+            "          file2  <== selected",
+            "      .gitignore",
+            "      aa"
+        ],
+        "Initial state should hide files on .gitignore"
+    );
+    submit_deletion(&panel, cx);
+
+    // Test 2: Auto selection should go to the parent
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..10, cx),
+        &[
+            "v root",
+            "    v dir1  <== selected",
+            "      .gitignore",
+            "      aa"
+        ],
+        "Initial state should hide files on .gitignore"
+    );
+}
+
 #[gpui::test]
 async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
     init_test_with_editor(cx);