diff --git a/assets/settings/default.json b/assets/settings/default.json index 101b53c4a74e50..0526f178837b51 100644 --- a/assets/settings/default.json +++ b/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 diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index d498ecd50a0b88..72d0fa933fdd91 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -97,6 +97,7 @@ CREATE TABLE "worktree_entries" ( "is_external" BOOL NOT NULL, "is_ignored" BOOL NOT NULL, "is_deleted" BOOL NOT NULL, + "is_hidden" BOOL NOT NULL, "git_status" INTEGER, "is_fifo" BOOL NOT NULL, PRIMARY KEY (project_id, worktree_id, id), diff --git a/crates/collab/migrations/20251008120000_add_is_hidden_to_worktree_entries.sql b/crates/collab/migrations/20251008120000_add_is_hidden_to_worktree_entries.sql new file mode 100644 index 00000000000000..5b4207aeea5005 --- /dev/null +++ b/crates/collab/migrations/20251008120000_add_is_hidden_to_worktree_entries.sql @@ -0,0 +1,2 @@ +ALTER TABLE "worktree_entries" +ADD "is_hidden" BOOL NOT NULL DEFAULT FALSE; diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index 8014cd3cab27b4..c1f9043a550ea4 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/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 diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index 175361af351b15..f020b99b5f1030 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/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 diff --git a/crates/collab/src/db/tables/worktree_entry.rs b/crates/collab/src/db/tables/worktree_entry.rs index d148c63a7f6d22..1a28203977d675 100644 --- a/crates/collab/src/db/tables/worktree_entry.rs +++ b/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, diff --git a/crates/project_panel/benches/sorting.rs b/crates/project_panel/benches/sorting.rs index 448ec51270dcf5..73d92ccd4913a0 100644 --- a/crates/project_panel/benches/sorting.rs +++ b/crates/project_panel/benches/sorting.rs @@ -29,6 +29,7 @@ fn load_linux_repo_snapshot() -> Vec { is_always_included: false, is_external: false, is_private: false, + is_hidden: false, char_bag: Default::default(), is_fifo: false, }; diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index c50b491a102ef2..aac3769ac4ed3d 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/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, diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index c8bd287c33c9dd..45d50efcaf36ea 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/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(), } } diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 04f52d3ab14bf2..d7dc63dec892a9 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/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, path: &str, cx: &mut VisualTestContext) { let path = rel_path(path); panel.update_in(cx, |panel, window, cx| { diff --git a/crates/proto/proto/worktree.proto b/crates/proto/proto/worktree.proto index 4f5451f2344092..9ab9e95438d220 100644 --- a/crates/proto/proto/worktree.proto +++ b/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 { diff --git a/crates/settings/src/settings_content/workspace.rs b/crates/settings/src/settings_content/workspace.rs index 511c883a4386c6..65e1bec1c47b39 100644 --- a/crates/settings/src/settings_content/workspace.rs +++ b/crates/settings/src/settings_content/workspace.rs @@ -530,6 +530,10 @@ pub struct ProjectPanelSettingsContent { /// /// Default: false pub hide_root: Option, + /// Whether to hide the hidden entries in the project panel. + /// + /// Default: false + pub hide_hidden: Option, /// Whether to stick parent directories at top of the project panel. /// /// Default: true diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 1db443797d8503..b407e88cd5619c 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -2657,6 +2657,27 @@ pub(crate) fn settings_data() -> Vec { 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", diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index eb0b5f861d2181..0327c345a1ee90 100644 --- a/crates/worktree/src/worktree.rs +++ b/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, @@ -4953,6 +4981,7 @@ struct ScanJob { scan_queue: Sender, ancestor_inodes: TreeSet, 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, diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index e4653ecc09a8bd..876d75c7e8911c 100644 --- a/docs/src/configuring-zed.md +++ b/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 } } diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index 0318105cf77a42..89cb3ec1929e6c 100644 --- a/docs/src/visual-customization.md +++ b/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 } ```