project_panel: Add ability to hide hidden files (#39843)

Smit Barmase , Gaauwe Rombouts , and Gaauwe Rombouts created

Closes #5185

Release Notes:

- Added an option to hide hidden files in the project panel by setting
`hide_hidden` in the project panel settings.

---------

Co-authored-by: Gaauwe Rombouts <gromdroid@gmail.com>
Co-authored-by: Gaauwe Rombouts <mail@grombouts.nl>

Change summary

assets/settings/default.json                                                  |   4 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql                |   1 
crates/collab/migrations/20251008120000_add_is_hidden_to_worktree_entries.sql |   2 
crates/collab/src/db/queries/projects.rs                                      |   3 
crates/collab/src/db/queries/rooms.rs                                         |   1 
crates/collab/src/db/tables/worktree_entry.rs                                 |   1 
crates/project_panel/benches/sorting.rs                                       |   1 
crates/project_panel/src/project_panel.rs                                     |  14 
crates/project_panel/src/project_panel_settings.rs                            |   2 
crates/project_panel/src/project_panel_tests.rs                               | 136 
crates/proto/proto/worktree.proto                                             |   1 
crates/settings/src/settings_content/workspace.rs                             |   4 
crates/settings_ui/src/page_data.rs                                           |  21 
crates/worktree/src/worktree.rs                                               |  31 
docs/src/configuring-zed.md                                                   |   1 
docs/src/visual-customization.md                                              |   4 
16 files changed, 223 insertions(+), 4 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -722,7 +722,9 @@
     // Whether to enable drag-and-drop operations in the project panel.
     "drag_and_drop": true,
     // Whether to hide the root entry when only one folder is open in the window.
-    "hide_root": false
+    "hide_root": false,
+    // Whether to hide the hidden entries in the project panel.
+    "hide_hidden": false
   },
   "outline_panel": {
     // Whether to show the outline panel button in the status bar

crates/collab/src/db/queries/projects.rs 🔗

@@ -282,6 +282,7 @@ impl Database {
                         git_status: ActiveValue::set(None),
                         is_external: ActiveValue::set(entry.is_external),
                         is_deleted: ActiveValue::set(false),
+                        is_hidden: ActiveValue::set(entry.is_hidden),
                         scan_id: ActiveValue::set(update.scan_id as i64),
                         is_fifo: ActiveValue::set(entry.is_fifo),
                     }
@@ -300,6 +301,7 @@ impl Database {
                         worktree_entry::Column::MtimeNanos,
                         worktree_entry::Column::CanonicalPath,
                         worktree_entry::Column::IsIgnored,
+                        worktree_entry::Column::IsHidden,
                         worktree_entry::Column::ScanId,
                     ])
                     .to_owned(),
@@ -905,6 +907,7 @@ impl Database {
                         canonical_path: db_entry.canonical_path,
                         is_ignored: db_entry.is_ignored,
                         is_external: db_entry.is_external,
+                        is_hidden: db_entry.is_hidden,
                         // This is only used in the summarization backlog, so if it's None,
                         // that just means we won't be able to detect when to resummarize
                         // based on total number of backlogged bytes - instead, we'd go

crates/collab/src/db/queries/rooms.rs 🔗

@@ -671,6 +671,7 @@ impl Database {
                             canonical_path: db_entry.canonical_path,
                             is_ignored: db_entry.is_ignored,
                             is_external: db_entry.is_external,
+                            is_hidden: db_entry.is_hidden,
                             // This is only used in the summarization backlog, so if it's None,
                             // that just means we won't be able to detect when to resummarize
                             // based on total number of backlogged bytes - instead, we'd go

crates/collab/src/db/tables/worktree_entry.rs 🔗

@@ -19,6 +19,7 @@ pub struct Model {
     pub is_ignored: bool,
     pub is_external: bool,
     pub is_deleted: bool,
+    pub is_hidden: bool,
     pub scan_id: i64,
     pub is_fifo: bool,
     pub canonical_path: Option<String>,

crates/project_panel/benches/sorting.rs 🔗

@@ -29,6 +29,7 @@ fn load_linux_repo_snapshot() -> Vec<GitEntry> {
                 is_always_included: false,
                 is_external: false,
                 is_private: false,
+                is_hidden: false,
                 char_bag: Default::default(),
                 is_fifo: false,
             };

crates/project_panel/src/project_panel.rs 🔗

@@ -676,6 +676,9 @@ impl ProjectPanel {
                     if project_panel_settings.hide_root != new_settings.hide_root {
                         this.update_visible_entries(None, false, false, window, cx);
                     }
+                    if project_panel_settings.hide_hidden != new_settings.hide_hidden {
+                        this.update_visible_entries(None, false, false, window, cx);
+                    }
                     if project_panel_settings.sticky_scroll && !new_settings.sticky_scroll {
                         this.sticky_items_count = 0;
                     }
@@ -3172,6 +3175,7 @@ impl ProjectPanel {
                 mtime: parent_entry.mtime,
                 size: parent_entry.size,
                 is_ignored: parent_entry.is_ignored,
+                is_hidden: parent_entry.is_hidden,
                 is_external: false,
                 is_private: false,
                 is_always_included: parent_entry.is_always_included,
@@ -3212,6 +3216,7 @@ impl ProjectPanel {
             .map(|worktree| worktree.read(cx).snapshot())
             .collect();
         let hide_root = settings.hide_root && visible_worktrees.len() == 1;
+        let hide_hidden = settings.hide_hidden;
         self.update_visible_entries_task = cx.spawn_in(window, async move |this, cx| {
             let new_state = cx
                 .background_spawn(async move {
@@ -3303,7 +3308,9 @@ impl ProjectPanel {
                                 }
                             }
                             auto_folded_ancestors.clear();
-                            if !hide_gitignore || !entry.is_ignored {
+                            if (!hide_gitignore || !entry.is_ignored)
+                                && (!hide_hidden || !entry.is_hidden)
+                            {
                                 visible_worktree_entries.push(entry.to_owned());
                             }
                             let precedes_new_entry = if let Some(new_entry_id) = new_entry_parent_id
@@ -3316,7 +3323,10 @@ impl ProjectPanel {
                             } else {
                                 false
                             };
-                            if precedes_new_entry && (!hide_gitignore || !entry.is_ignored) {
+                            if precedes_new_entry
+                                && (!hide_gitignore || !entry.is_ignored)
+                                && (!hide_hidden || !entry.is_hidden)
+                            {
                                 visible_worktree_entries.push(Self::create_new_git_entry(
                                     entry.entry,
                                     entry.git_summary,

crates/project_panel/src/project_panel_settings.rs 🔗

@@ -30,6 +30,7 @@ pub struct ProjectPanelSettings {
     pub scrollbar: ScrollbarSettings,
     pub show_diagnostics: ShowDiagnostics,
     pub hide_root: bool,
+    pub hide_hidden: bool,
     pub drag_and_drop: bool,
 }
 
@@ -79,6 +80,7 @@ impl Settings for ProjectPanelSettings {
             },
             show_diagnostics: project_panel.show_diagnostics.unwrap(),
             hide_root: project_panel.hide_root.unwrap(),
+            hide_hidden: project_panel.hide_hidden.unwrap(),
             drag_and_drop: project_panel.drag_and_drop.unwrap(),
         }
     }

crates/project_panel/src/project_panel_tests.rs 🔗

@@ -6678,6 +6678,142 @@ async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) {
     }
 }
 
+#[gpui::test]
+async fn test_hide_hidden_entries(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/root",
+        json!({
+            ".hidden-file.txt": "hidden file content",
+            "visible-file.txt": "visible file content",
+            ".hidden-parent-dir": {
+                "nested-dir": {
+                    "file.txt": "file content",
+                }
+            },
+            "visible-dir": {
+                "file-in-visible.txt": "file content",
+                "nested": {
+                    ".hidden-nested-dir": {
+                        ".double-hidden-dir": {
+                            "deep-file-1.txt": "deep content 1",
+                            "deep-file-2.txt": "deep content 2"
+                        },
+                        "hidden-nested-file-1.txt": "hidden nested 1",
+                        "hidden-nested-file-2.txt": "hidden nested 2"
+                    },
+                    "visible-nested-file.txt": "visible nested content"
+                }
+            }
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/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_hidden: false,
+                ..settings
+            },
+            cx,
+        );
+    });
+
+    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+    cx.run_until_parked();
+
+    toggle_expand_dir(&panel, "root/.hidden-parent-dir", cx);
+    toggle_expand_dir(&panel, "root/.hidden-parent-dir/nested-dir", cx);
+    toggle_expand_dir(&panel, "root/visible-dir", cx);
+    toggle_expand_dir(&panel, "root/visible-dir/nested", cx);
+    toggle_expand_dir(&panel, "root/visible-dir/nested/.hidden-nested-dir", cx);
+    toggle_expand_dir(
+        &panel,
+        "root/visible-dir/nested/.hidden-nested-dir/.double-hidden-dir",
+        cx,
+    );
+
+    let expanded = [
+        "v root",
+        "    v .hidden-parent-dir",
+        "        v nested-dir",
+        "              file.txt",
+        "    v visible-dir",
+        "        v nested",
+        "            v .hidden-nested-dir",
+        "                v .double-hidden-dir  <== selected",
+        "                      deep-file-1.txt",
+        "                      deep-file-2.txt",
+        "                  hidden-nested-file-1.txt",
+        "                  hidden-nested-file-2.txt",
+        "              visible-nested-file.txt",
+        "          file-in-visible.txt",
+        "      .hidden-file.txt",
+        "      visible-file.txt",
+    ];
+
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..30, cx),
+        &expanded,
+        "With hide_hidden=false, contents of hidden nested directory should be visible"
+    );
+
+    cx.update(|_, cx| {
+        let settings = *ProjectPanelSettings::get_global(cx);
+        ProjectPanelSettings::override_global(
+            ProjectPanelSettings {
+                hide_hidden: true,
+                ..settings
+            },
+            cx,
+        );
+    });
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.update_visible_entries(None, false, false, window, cx);
+    });
+    cx.run_until_parked();
+
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..30, cx),
+        &[
+            "v root",
+            "    v visible-dir",
+            "        v nested",
+            "              visible-nested-file.txt",
+            "          file-in-visible.txt",
+            "      visible-file.txt",
+        ],
+        "With hide_hidden=false, contents of hidden nested directory should be visible"
+    );
+
+    panel.update_in(cx, |panel, window, cx| {
+        let settings = *ProjectPanelSettings::get_global(cx);
+        ProjectPanelSettings::override_global(
+            ProjectPanelSettings {
+                hide_hidden: false,
+                ..settings
+            },
+            cx,
+        );
+        panel.update_visible_entries(None, false, false, window, cx);
+    });
+    cx.run_until_parked();
+
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..30, cx),
+        &expanded,
+        "With hide_hidden=false, deeply nested hidden directories and their contents should be visible"
+    );
+}
+
 fn select_path(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
     let path = rel_path(path);
     panel.update_in(cx, |panel, window, cx| {

crates/proto/proto/worktree.proto 🔗

@@ -27,6 +27,7 @@ message Entry {
     bool is_fifo = 10;
     optional uint64 size = 11;
     optional string canonical_path = 12;
+    bool is_hidden = 13;
 }
 
 message AddWorktree {

crates/settings/src/settings_content/workspace.rs 🔗

@@ -530,6 +530,10 @@ pub struct ProjectPanelSettingsContent {
     ///
     /// Default: false
     pub hide_root: Option<bool>,
+    /// Whether to hide the hidden entries in the project panel.
+    ///
+    /// Default: false
+    pub hide_hidden: Option<bool>,
     /// Whether to stick parent directories at top of the project panel.
     ///
     /// Default: true

crates/settings_ui/src/page_data.rs 🔗

@@ -2657,6 +2657,27 @@ pub(crate) fn settings_data() -> Vec<SettingsPage> {
                     metadata: None,
                     files: USER,
                 }),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Hide Hidden",
+                    description: "Whether to hide the hidden entries in the project panel",
+                    field: Box::new(SettingField {
+                        pick: |settings_content| {
+                            if let Some(project_panel) = &settings_content.project_panel {
+                                &project_panel.hide_hidden
+                            } else {
+                                &None
+                            }
+                        },
+                        pick_mut: |settings_content| {
+                            &mut settings_content
+                                .project_panel
+                                .get_or_insert_default()
+                                .hide_hidden
+                        },
+                    }),
+                    metadata: None,
+                    files: USER,
+                }),
                 SettingsPageItem::SectionHeader("Terminal Panel"),
                 SettingsPageItem::SettingItem(SettingItem {
                     title: "Terminal Dock",

crates/worktree/src/worktree.rs 🔗

@@ -439,6 +439,10 @@ impl Worktree {
                         entry.is_private = !share_private_files && settings.is_path_private(path);
                     }
                 }
+                entry.is_hidden = abs_path
+                    .file_name()
+                    .and_then(|name| name.to_str())
+                    .map_or(false, |name| is_path_hidden(name));
                 snapshot.insert_entry(entry, fs.as_ref());
             }
 
@@ -2668,6 +2672,7 @@ impl BackgroundScannerState {
                     scan_queue: scan_job_tx.clone(),
                     ancestor_inodes,
                     is_external: entry.is_external,
+                    is_hidden: entry.is_hidden,
                 })
                 .unwrap();
         }
@@ -3177,6 +3182,11 @@ pub struct Entry {
     /// exclude them from searches.
     pub is_ignored: bool,
 
+    /// Whether this entry is hidden or inside hidden directory.
+    ///
+    /// We only scan hidden entries once the directory is expanded.
+    pub is_hidden: bool,
+
     /// Whether this entry is always included in searches.
     ///
     /// This is used for entries that are always included in searches, even
@@ -3351,6 +3361,7 @@ impl Entry {
             size: metadata.len,
             canonical_path,
             is_ignored: false,
+            is_hidden: false,
             is_always_included: false,
             is_external: false,
             is_private: false,
@@ -4219,6 +4230,11 @@ impl BackgroundScanner {
                 child_entry.canonical_path = Some(canonical_path.into());
             }
 
+            child_entry.is_hidden = job.is_hidden
+                || child_name
+                    .to_str()
+                    .map_or(false, |name| is_path_hidden(name));
+
             if child_entry.is_dir() {
                 child_entry.is_ignored = ignore_stack.is_abs_path_ignored(&child_abs_path, true);
                 child_entry.is_always_included = self.settings.is_path_always_included(&child_path);
@@ -4234,6 +4250,7 @@ impl BackgroundScanner {
                         abs_path: child_abs_path.clone(),
                         path: child_path,
                         is_external: child_entry.is_external,
+                        is_hidden: child_entry.is_hidden,
                         ignore_stack: if child_entry.is_ignored {
                             IgnoreStack::all()
                         } else {
@@ -4384,6 +4401,13 @@ impl BackgroundScanner {
                     fs_entry.is_private = self.is_path_private(path);
                     fs_entry.is_always_included = self.settings.is_path_always_included(path);
 
+                    let parent_is_hidden = path
+                        .parent()
+                        .and_then(|parent| state.snapshot.entry_for_path(parent))
+                        .map_or(false, |parent_entry| parent_entry.is_hidden);
+                    fs_entry.is_hidden = parent_is_hidden
+                        || path.file_name().map_or(false, |name| is_path_hidden(name));
+
                     if let (Some(scan_queue_tx), true) = (&scan_queue_tx, is_dir) {
                         if state.should_scan_directory(&fs_entry)
                             || (fs_entry.path.is_empty()
@@ -4945,6 +4969,10 @@ fn char_bag_for_path(root_char_bag: CharBag, path: &RelPath) -> CharBag {
     result
 }
 
+fn is_path_hidden(name: &str) -> bool {
+    name.starts_with('.')
+}
+
 #[derive(Debug)]
 struct ScanJob {
     abs_path: Arc<Path>,
@@ -4953,6 +4981,7 @@ struct ScanJob {
     scan_queue: Sender<ScanJob>,
     ancestor_inodes: TreeSet<u64>,
     is_external: bool,
+    is_hidden: bool,
 }
 
 struct UpdateIgnoreStatusJob {
@@ -5374,6 +5403,7 @@ impl<'a> From<&'a Entry> for proto::Entry {
             inode: entry.inode,
             mtime: entry.mtime.map(|time| time.into()),
             is_ignored: entry.is_ignored,
+            is_hidden: entry.is_hidden,
             is_external: entry.is_external,
             is_fifo: entry.is_fifo,
             size: Some(entry.size),
@@ -5412,6 +5442,7 @@ impl TryFrom<(&CharBag, &PathMatcher, proto::Entry)> for Entry {
                 .canonical_path
                 .map(|path_string| Arc::from(PathBuf::from(path_string))),
             is_ignored: entry.is_ignored,
+            is_hidden: entry.is_hidden,
             is_always_included,
             is_external: entry.is_external,
             is_private: false,

docs/src/configuring-zed.md 🔗

@@ -4154,6 +4154,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a
       "show": "always"
     },
     "hide_root": false,
+    "hide_hidden": false,
     "starts_open": true
   }
 }

docs/src/visual-customization.md 🔗

@@ -443,7 +443,9 @@ Project panel can be shown/hidden with {#action project_panel::ToggleFocus} ({#k
       "show": "always"
     },
     // Whether to hide the root entry when only one folder is open in the window.
-    "hide_root": false
+    "hide_root": false,
+    // Whether to hide the hidden entries in the project panel.
+    "hide_hidden": false
   }
 ```