project_panel: Add hidden files glob patterns and action toggle hidden files visibility (#41532)

B. Collier Jones and Smit Barmase created

This PR adds the ability to configure which files are considered
"hidden" in the project panel and toggle their visibility with a
keyboard shortcut. Previously, the editor hardcoded dotfiles as hidden -
now users can customize the pattern and quickly show/hide them.

### Release Notes

- Added `project_panel::ToggleHideHidden` action with keyboard shortcuts
to toggle visibility of hidden files
- Added configurable `hidden_files` setting to customize which files are
marked as hidden (defaults to `**/.*` for dotfiles)

### Motivation

This change allows users to:
1. Quickly toggle hidden file visibility with a keyboard shortcut
2. Customize which files are considered "hidden" beyond just dotfiles
3. Better organize their project panel by hiding build artifacts, logs,
or other generated files

### Usage

**Toggle hidden files:**
- **macOS:** `cmd-alt-.`
- **Linux:** `ctrl-alt-.`
- **Windows:** `ctrl-alt-.`

**Customize patterns in settings:**
```json
{
  "hidden_files": ["**/.*", "**/*.tmp", "**/build/**"]
}
```

### Changes

**Core Implementation:**
- Added `hidden_files` setting (defaults to `**/.*` to match current
dotfile behavior)
- Replaced hardcoded `name.starts_with('.')` logic with configurable
pattern matching using `PathMatcher`
- Hidden status propagates through directory hierarchies (if a directory
is hidden, all children inherit that status)

**User-Facing:**
- Added `ToggleHideHidden` action in the project panel
- Added keyboard shortcuts for all platforms
- Added settings UI entry for configuring `hidden_files` patterns

**Testing:**
- Added comprehensive test coverage validating default behavior, custom
patterns, propagation, and settings changes

### Implementation Notes

- Uses `PathMatcher` for efficient glob matching
- Settings changes automatically trigger worktree re-indexing
- No breaking changes - defaults maintain current behavior (hiding
dotfiles)

---

**Disclaimer:** This was implemented with a fair amount of copy/paste
(particularly the gitignore handling), trial and error, and a healthy
dose of Claude.

### Screenshots

**Project Panel with hidden files visible:**
<img width="1368" height="935" alt="Screenshot 2025-10-30 at 3 15 53 AM"
src="https://github.com/user-attachments/assets/1cbe90ce-504c-4f9b-bca8-bef02ab961be"
/>

**Project Panel with hidden files hidden:**
<img width="1363" height="917" alt="Screenshot 2025-10-30 at 3 16 07 AM"
src="https://github.com/user-attachments/assets/9297f43e-98c7-4b19-be8f-3934589d6451"
/>

**Toggle action in command palette:**
<img width="565" height="161" alt="Screenshot 2025-10-30 at 3 17 26 AM"
src="https://github.com/user-attachments/assets/4dc9e7b6-9c29-4972-b886-88d8018905da"
/>

Release Notes:

- Added the ability to configure glob patterns for files treated as
hidden in the project panel using the `hidden_files` setting.
- Added an action `project panel: toggle hidden files` to quickly show
or hide hidden files in the project panel.

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>

Change summary

assets/keymaps/default-linux.json               |  3 
assets/keymaps/default-macos.json               |  3 
assets/keymaps/default-windows.json             |  3 
assets/settings/default.json                    |  3 
crates/project_panel/src/project_panel.rs       | 15 +++
crates/settings/src/settings_content/project.rs |  4 
crates/settings/src/vscode_import.rs            |  1 
crates/settings_ui/src/page_data.rs             | 18 +++
crates/worktree/src/worktree.rs                 | 29 +-----
crates/worktree/src/worktree_settings.rs        | 10 ++
crates/worktree/src/worktree_tests.rs           | 86 +++++++++++++++++++
11 files changed, 149 insertions(+), 26 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -43,7 +43,8 @@
       "f11": "zed::ToggleFullScreen",
       "ctrl-alt-z": "edit_prediction::RateCompletions",
       "ctrl-alt-shift-i": "edit_prediction::ToggleMenu",
-      "ctrl-alt-l": "lsp_tool::ToggleMenu"
+      "ctrl-alt-l": "lsp_tool::ToggleMenu",
+      "ctrl-alt-.": "project_panel::ToggleHideHidden"
     }
   },
   {

assets/keymaps/default-macos.json 🔗

@@ -49,7 +49,8 @@
       "ctrl-cmd-f": "zed::ToggleFullScreen",
       "ctrl-cmd-z": "edit_prediction::RateCompletions",
       "ctrl-cmd-i": "edit_prediction::ToggleMenu",
-      "ctrl-cmd-l": "lsp_tool::ToggleMenu"
+      "ctrl-cmd-l": "lsp_tool::ToggleMenu",
+      "cmd-alt-.": "project_panel::ToggleHideHidden"
     }
   },
   {

assets/keymaps/default-windows.json 🔗

@@ -41,7 +41,8 @@
       "shift-f11": "debugger::StepOut",
       "f11": "zed::ToggleFullScreen",
       "ctrl-shift-i": "edit_prediction::ToggleMenu",
-      "shift-alt-l": "lsp_tool::ToggleMenu"
+      "shift-alt-l": "lsp_tool::ToggleMenu",
+      "ctrl-alt-.": "project_panel::ToggleHideHidden"
     }
   },
   {

assets/settings/default.json 🔗

@@ -1247,6 +1247,9 @@
   // that are overly broad can slow down Zed's file scanning. `file_scan_exclusions` takes
   // precedence over these inclusions.
   "file_scan_inclusions": [".env*"],
+  // Globs to match files that will be considered "hidden". These files can be hidden from the
+  // project panel by toggling the "hide_hidden" setting.
+  "hidden_files": ["**/.*"],
   // Git gutter behavior configuration.
   "git": {
     // Control whether the git gutter is shown. May take 2 values:

crates/project_panel/src/project_panel.rs 🔗

@@ -294,6 +294,8 @@ actions!(
         ToggleFocus,
         /// Toggles visibility of git-ignored files.
         ToggleHideGitIgnore,
+        /// Toggles visibility of hidden files.
+        ToggleHideHidden,
         /// Starts a new search in the selected directory.
         NewSearchInDirectory,
         /// Unfolds the selected directory.
@@ -383,6 +385,19 @@ pub fn init(cx: &mut App) {
             })
         });
 
+        workspace.register_action(|workspace, _: &ToggleHideHidden, _, cx| {
+            let fs = workspace.app_state().fs.clone();
+            update_settings_file(fs, cx, move |setting, _| {
+                setting.project_panel.get_or_insert_default().hide_hidden = Some(
+                    !setting
+                        .project_panel
+                        .get_or_insert_default()
+                        .hide_hidden
+                        .unwrap_or(false),
+                );
+            })
+        });
+
         workspace.register_action(|workspace, action: &CollapseAllEntries, window, cx| {
             if let Some(panel) = workspace.panel::<ProjectPanel>(cx) {
                 panel.update(cx, |panel, cx| {

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

@@ -97,6 +97,10 @@ pub struct WorktreeSettingsContent {
     /// Treat the files matching these globs as `.env` files.
     /// Default: ["**/.env*", "**/*.pem", "**/*.key", "**/*.cert", "**/*.crt", "**/secrets.yml"]
     pub private_files: Option<ExtendingVec<String>>,
+
+    /// Treat the files matching these globs as hidden files. You can hide hidden files in the project panel.
+    /// Default: ["**/.*"]
+    pub hidden_files: Option<Vec<String>>,
 }
 
 #[skip_serializing_none]

crates/settings_ui/src/page_data.rs 🔗

@@ -3743,6 +3743,24 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
                     metadata: None,
                     files: USER,
                 }),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Hidden Files",
+                    description: "Globs to match files that will be considered \"hidden\" and can be hidden from the project panel.",
+                    field: Box::new(
+                        SettingField {
+                            json_path: Some("worktree.hidden_files"),
+                            pick: |settings_content| {
+                                settings_content.project.worktree.hidden_files.as_ref()
+                            },
+                            write: |settings_content, value| {
+                                settings_content.project.worktree.hidden_files = value;
+                            },
+                        }
+                        .unimplemented(),
+                    ),
+                    metadata: None,
+                    files: USER,
+                }),
                 SettingsPageItem::SettingItem(SettingItem {
                     title: "Open File on Paste",
                     description: "Whether to automatically open files when pasting them in the project panel.",

crates/worktree/src/worktree.rs 🔗

@@ -438,12 +438,9 @@ impl Worktree {
                         && let Ok(path) = RelPath::unix(file_name)
                     {
                         entry.is_private = !share_private_files && settings.is_path_private(path);
+                        entry.is_hidden = settings.is_path_hidden(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());
             }
 
@@ -2685,7 +2682,6 @@ impl BackgroundScannerState {
                     scan_queue: scan_job_tx.clone(),
                     ancestor_inodes,
                     is_external: entry.is_external,
-                    is_hidden: entry.is_hidden,
                 })
                 .unwrap();
         }
@@ -4285,11 +4281,6 @@ 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 =
@@ -4306,7 +4297,6 @@ 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 {
@@ -4330,6 +4320,10 @@ impl BackgroundScanner {
                     log::debug!("detected private file: {relative_path:?}");
                     child_entry.is_private = true;
                 }
+                if self.settings.is_path_hidden(&relative_path) {
+                    log::debug!("detected hidden file: {relative_path:?}");
+                    child_entry.is_hidden = true;
+                }
             }
 
             new_entries.push(child_entry);
@@ -4456,13 +4450,7 @@ impl BackgroundScanner {
                     fs_entry.is_private = self.is_path_private(path);
                     fs_entry.is_always_included =
                         self.settings.is_path_always_included(path, is_dir);
-
-                    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));
+                    fs_entry.is_hidden = self.settings.is_path_hidden(path);
 
                     if let (Some(scan_queue_tx), true) = (&scan_queue_tx, is_dir) {
                         if state.should_scan_directory(&fs_entry)
@@ -5035,10 +5023,6 @@ 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>,
@@ -5047,7 +5031,6 @@ struct ScanJob {
     scan_queue: Sender<ScanJob>,
     ancestor_inodes: TreeSet<u64>,
     is_external: bool,
-    is_hidden: bool,
 }
 
 struct UpdateIgnoreStatusJob {

crates/worktree/src/worktree_settings.rs 🔗

@@ -19,6 +19,7 @@ pub struct WorktreeSettings {
     /// determine whether to terminate worktree scanning for a given dir.
     pub parent_dir_scan_inclusions: PathMatcher,
     pub private_files: PathMatcher,
+    pub hidden_files: PathMatcher,
 }
 
 impl WorktreeSettings {
@@ -39,6 +40,11 @@ impl WorktreeSettings {
             self.file_scan_inclusions.is_match(path.as_std_path())
         }
     }
+
+    pub fn is_path_hidden(&self, path: &RelPath) -> bool {
+        path.ancestors()
+            .any(|ancestor| self.hidden_files.is_match(ancestor.as_std_path()))
+    }
 }
 
 impl Settings for WorktreeSettings {
@@ -47,6 +53,7 @@ impl Settings for WorktreeSettings {
         let file_scan_exclusions = worktree.file_scan_exclusions.unwrap();
         let file_scan_inclusions = worktree.file_scan_inclusions.unwrap();
         let private_files = worktree.private_files.unwrap().0;
+        let hidden_files = worktree.hidden_files.unwrap();
         let parsed_file_scan_inclusions: Vec<String> = file_scan_inclusions
             .iter()
             .flat_map(|glob| {
@@ -74,6 +81,9 @@ impl Settings for WorktreeSettings {
             private_files: path_matchers(private_files, "private_files")
                 .log_err()
                 .unwrap_or_default(),
+            hidden_files: path_matchers(hidden_files, "hidden_files")
+                .log_err()
+                .unwrap_or_default(),
         }
     }
 }

crates/worktree/src/worktree_tests.rs 🔗

@@ -1057,6 +1057,92 @@ async fn test_file_scan_exclusions(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_hidden_files(cx: &mut TestAppContext) {
+    init_test(cx);
+    cx.executor().allow_parking();
+    let dir = TempTree::new(json!({
+        ".gitignore": "**/target\n",
+        ".hidden_file": "content",
+        ".hidden_dir": {
+            "nested.rs": "code",
+        },
+        "src": {
+            "visible.rs": "code",
+        },
+        "logs": {
+            "app.log": "logs",
+            "debug.log": "logs",
+        },
+        "visible.txt": "content",
+    }));
+
+    let tree = Worktree::local(
+        dir.path(),
+        true,
+        Arc::new(RealFs::new(None, cx.executor())),
+        Default::default(),
+        &mut cx.to_async(),
+    )
+    .await
+    .unwrap();
+    cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+        .await;
+    tree.flush_fs_events(cx).await;
+
+    tree.read_with(cx, |tree, _| {
+        assert_eq!(
+            tree.entries(true, 0)
+                .map(|entry| (entry.path.as_ref(), entry.is_hidden))
+                .collect::<Vec<_>>(),
+            vec![
+                (rel_path(""), false),
+                (rel_path(".gitignore"), true),
+                (rel_path(".hidden_dir"), true),
+                (rel_path(".hidden_dir/nested.rs"), true),
+                (rel_path(".hidden_file"), true),
+                (rel_path("logs"), false),
+                (rel_path("logs/app.log"), false),
+                (rel_path("logs/debug.log"), false),
+                (rel_path("src"), false),
+                (rel_path("src/visible.rs"), false),
+                (rel_path("visible.txt"), false),
+            ]
+        );
+    });
+
+    cx.update(|cx| {
+        cx.update_global::<SettingsStore, _>(|store, cx| {
+            store.update_user_settings(cx, |settings| {
+                settings.project.worktree.hidden_files = Some(vec!["**/*.log".to_string()]);
+            });
+        });
+    });
+    tree.flush_fs_events(cx).await;
+    cx.executor().run_until_parked();
+
+    tree.read_with(cx, |tree, _| {
+        assert_eq!(
+            tree.entries(true, 0)
+                .map(|entry| (entry.path.as_ref(), entry.is_hidden))
+                .collect::<Vec<_>>(),
+            vec![
+                (rel_path(""), false),
+                (rel_path(".gitignore"), false),
+                (rel_path(".hidden_dir"), false),
+                (rel_path(".hidden_dir/nested.rs"), false),
+                (rel_path(".hidden_file"), false),
+                (rel_path("logs"), false),
+                (rel_path("logs/app.log"), true),
+                (rel_path("logs/debug.log"), true),
+                (rel_path("src"), false),
+                (rel_path("src/visible.rs"), false),
+                (rel_path("visible.txt"), false),
+            ]
+        );
+    });
+}
+
 #[gpui::test]
 async fn test_fs_events_in_exclusions(cx: &mut TestAppContext) {
     init_test(cx);