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

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)
This commit is contained in:
Alvaro Parker 2025-03-26 12:27:09 -03:00 committed by GitHub
parent 9eacac62a9
commit 82536f5243
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 196 additions and 8 deletions

View file

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

View file

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

View file

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

View file

@ -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] #[gpui::test]
async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) { async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx); init_test_with_editor(cx);