From 9fd3e0ca3b6cbf548f2b4bef0f290fbe51a5ac5a Mon Sep 17 00:00:00 2001 From: Ahmed Ammar Date: Fri, 26 Jun 2026 19:44:41 +0200 Subject: [PATCH] project_panel: Add expand/collapse all to context menu (#59567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `ExpandAllEntries` action, keybinding, and right-click context menu entries for expand/collapse all in the project panel. - Add `ExpandAllEntries` action + handler + `expand_all_entries()` method - Add `cmd-right` / `ctrl-right` keybinding for `ExpandAllEntries`, mirroring the existing `cmd-left` for `CollapseAllEntries` - Add "Expand All" to root-entry and folder right-click context menus - Add keybinding hint to root-entry "Collapse All" context menu entry - Per-worktree behavior: context menu actions affect only the selected worktree, subfolder "Expand All" expands from that folder down # Objective The project panel had no discoverable way to expand or collapse all entries. The only ways were keyboard shortcuts (`cmd-left` for collapse all) or the right-click context menu. The global `ExpandAllEntries` shortcut (`cmd-right`) was also missing. This PR adds that shortcut and exposes both actions in the context menu so users learn the keybindings while keeping the UI minimal. ## Solution - Add `ExpandAllEntries` action + handler + `expand_all_entries()` method - Add `cmd-right` / `ctrl-right` keybinding for `ExpandAllEntries` (global), mirroring the existing `cmd-left` for `CollapseAllEntries` - Add "Expand All" to the right-click context menu for both root entries and subfolder entries - Update root-entry "Collapse All" to show its keybinding hint via `.action(Box::new(CollapseAllEntries))` - **Per-worktree behavior**: each root entry's context menu actions affect only that worktree, subfolder "Expand All" expands from that folder down ## Testing - Added 6 GPUI tests in `project_panel_tests.rs`: - `test_expand_all_entries`: single worktree - `test_expand_all_entries_multiple_worktrees`: global expand across worktrees - `test_expand_all_entries_via_window_dispatch`: action dispatch path - `test_per_worktree_expand`: expand only the clicked worktree - `test_per_worktree_collapse`: collapse only the clicked worktree, keep root visible - `test_expand_all_entries_with_auto_fold`: expand with auto_fold_dirs - Ran `cargo test -p project_panel`: 106 pass **Manual testing for reviewers:** 1. Open a project with nested directories and open the Project Panel 2. Right-click a root entry, confirm "Expand All" and "Collapse All" appear with their keybinding hints (`⌘→` / `⌘←`) 3. Right-click a subfolder, confirm "Expand All" and "Collapse All" appear 4. Click "Expand All" on a subfolder, confirm only that folder's subtree expands 5. Click "Collapse All", confirm children collapse but the root stays visible 6. Open a multi-folder workspace. Right-click the second root and click "Expand All", confirm only that folder expands 7. Press `cmd-right` (or `ctrl-right`), confirm all worktrees expand globally Tested on macOS only. Linux and Windows should behave the same since there is no platform-specific code. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase https://github.com/user-attachments/assets/adc501d7-8e3a-4687-aef0-dc00426d232d --- Release Notes: - Added `Expand All` and `Collapse All` to the project panel right-click context menu with keybinding hints. - Added `cmd-right` / `ctrl-right` keybinding to expand all entries. --------- Co-authored-by: Danilo Leal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- assets/keymaps/default-linux.json | 1 + assets/keymaps/default-macos.json | 1 + assets/keymaps/default-windows.json | 1 + crates/project_panel/src/project_panel.rs | 260 +++++++----- .../project_panel/src/project_panel_tests.rs | 393 +++++++++++------- docs/src/project-panel.md | 3 +- 6 files changed, 421 insertions(+), 238 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index f0afef2e7b..bf9f84ad21 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -955,6 +955,7 @@ "left": "project_panel::CollapseSelectedEntry", "ctrl-left": "project_panel::CollapseAllEntries", "right": "project_panel::ExpandSelectedEntry", + "ctrl-right": "project_panel::ExpandAllEntries", "new": "project_panel::NewFile", "ctrl-n": "project_panel::NewFile", "alt-new": "project_panel::NewDirectory", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 7967ca5bc7..6896193e50 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1019,6 +1019,7 @@ "left": "project_panel::CollapseSelectedEntry", "cmd-left": "project_panel::CollapseAllEntries", "right": "project_panel::ExpandSelectedEntry", + "cmd-right": "project_panel::ExpandAllEntries", "cmd-n": "project_panel::NewFile", "cmd-d": "project_panel::Duplicate", "alt-cmd-n": "project_panel::NewDirectory", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index dfe8c1dff1..f27fe19a7f 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -956,6 +956,7 @@ "left": "project_panel::CollapseSelectedEntry", "ctrl-left": "project_panel::CollapseAllEntries", "right": "project_panel::ExpandSelectedEntry", + "ctrl-right": "project_panel::ExpandAllEntries", "ctrl-n": "project_panel::NewFile", "alt-n": "project_panel::NewDirectory", "ctrl-x": "project_panel::Cut", diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index da0cfe2789..ef1981db60 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -61,10 +61,9 @@ use std::{ }; use theme_settings::ThemeSettings; use ui::{ - Color, ContextMenu, ContextMenuEntry, DecoratedIcon, Icon, IconDecoration, IconDecorationKind, - IndentGuideColors, IndentGuideLayout, Indicator, KeyBinding, Label, LabelSize, ListItem, - ListItemSpacing, ProjectEmptyState, ScrollAxes, ScrollableHandle, Scrollbars, StickyCandidate, - Tooltip, WithScrollbar, prelude::*, v_flex, + ContextMenu, DecoratedIcon, IconDecoration, IconDecorationKind, IndentGuideColors, + IndentGuideLayout, Indicator, KeyBinding, ListItem, ListItemSpacing, ProjectEmptyState, + ScrollAxes, ScrollableHandle, Scrollbars, StickyCandidate, Tooltip, WithScrollbar, prelude::*, }; use util::{ ResultExt, TakeUntilExt, TryFutureExt, @@ -340,8 +339,12 @@ actions!( CollapseSelectedEntry, /// Collapses the selected entry and its children in the project tree. CollapseSelectedEntryAndChildren, + /// Expands the selected entry and its children in the project tree. + ExpandSelectedEntryAndChildren, /// Collapses all entries in the project tree. CollapseAllEntries, + /// Expands all entries in the project tree. + ExpandAllEntries, /// Creates a new directory. NewDirectory, /// Creates a new file. @@ -502,6 +505,14 @@ pub fn init(cx: &mut App) { } }); + workspace.register_action(|workspace, action: &ExpandAllEntries, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.expand_all_entries(action, window, cx); + }); + } + }); + workspace.register_action(|workspace, action: &Rename, window, cx| { workspace.open_panel::(window, cx); if let Some(panel) = workspace.panel::(cx) { @@ -720,40 +731,12 @@ impl ProjectPanel { cx.notify(); } project::Event::ExpandedAllForEntry(worktree_id, entry_id) => { - if let Some((worktree, expanded_dir_ids)) = project - .read(cx) - .worktree_for_id(*worktree_id, cx) - .zip(this.state.expanded_dir_ids.get_mut(worktree_id)) - { - let worktree = worktree.read(cx); - - let Some(entry) = worktree.entry_for_id(*entry_id) else { - return; - }; - let include_ignored_dirs = !entry.is_ignored; - - let mut dirs_to_expand = vec![*entry_id]; - while let Some(current_id) = dirs_to_expand.pop() { - let Some(current_entry) = worktree.entry_for_id(current_id) else { - continue; - }; - for child in worktree.child_entries(¤t_entry.path) { - if !child.is_dir() || (include_ignored_dirs && child.is_ignored) - { - continue; - } - - dirs_to_expand.push(child.id); - - if let Err(ix) = expanded_dir_ids.binary_search(&child.id) { - expanded_dir_ids.insert(ix, child.id); - } - this.state.unfolded_dir_ids.insert(child.id); - } - } - this.update_visible_entries(None, false, false, window, cx); - cx.notify(); - } + this.synchronously_expand_all_directories( + *worktree_id, + *entry_id, + window, + cx, + ); } _ => {} }, @@ -1120,7 +1103,6 @@ impl ProjectPanel { }; let has_pasteable_content = self.has_pasteable_content(cx); - let entity = cx.entity(); let context_menu = ContextMenu::build(window, cx, |menu, _, cx| { menu.context(self.focus_handle.clone()).map(|menu| { if is_read_only { @@ -1223,22 +1205,17 @@ impl ProjectPanel { .action("Remove from Project", Box::new(RemoveFromProject)) }) .when(is_dir && !is_root, |menu| { - menu.separator().action( - "Collapse All", - Box::new(CollapseSelectedEntryAndChildren), - ) + menu.separator() + .action("Expand All", Box::new(ExpandSelectedEntryAndChildren)) + .action( + "Collapse All", + Box::new(CollapseSelectedEntryAndChildren), + ) }) .when(is_dir && is_root, |menu| { - let entity = entity.clone(); - menu.separator().item( - ContextMenuEntry::new("Collapse All").handler( - move |window, cx| { - entity.update(cx, |this, cx| { - this.collapse_all_for_root(window, cx); - }); - }, - ), - ) + menu.separator() + .action("Expand All", Box::new(ExpandAllEntries)) + .action("Collapse All", Box::new(CollapseAllEntries)) }) } }) @@ -1421,31 +1398,59 @@ impl ProjectPanel { } } - /// Handles "Collapse All" from the context menu when a root directory is selected. - /// With a single visible worktree, keeps the root expanded (matching CollapseAllEntries behavior). - /// With multiple visible worktrees, collapses the root and all its children. - fn collapse_all_for_root(&mut self, window: &mut Window, cx: &mut Context) { - let Some((worktree, entry)) = self.selected_entry(cx) else { - return; - }; - - let is_root = worktree.root_entry().map(|e| e.id) == Some(entry.id); - if !is_root { - return; - } - - let worktree_id = worktree.id(); - let root_id = entry.id; - + fn collapse_worktree_expanded_dirs( + &mut self, + worktree_id: WorktreeId, + root_id: ProjectEntryId, + cx: &App, + ) { + let single_worktree = self.project.read(cx).visible_worktrees(cx).count() == 1; if let Some(expanded_dir_ids) = self.state.expanded_dir_ids.get_mut(&worktree_id) { - if self.project.read(cx).visible_worktrees(cx).count() == 1 { + if single_worktree { expanded_dir_ids.retain(|id| id == &root_id); } else { expanded_dir_ids.clear(); } } + } - self.update_visible_entries(Some((worktree_id, root_id)), false, false, window, cx); + fn all_worktree_roots(&self, cx: &App) -> Vec<(WorktreeId, ProjectEntryId)> { + self.project + .read(cx) + .visible_worktrees(cx) + .filter_map(|worktree| { + let worktree = worktree.read(cx); + Some((worktree.id(), worktree.root_entry()?.id)) + }) + .collect() + } + + fn expand_worktree_roots( + &mut self, + roots: Vec<(WorktreeId, ProjectEntryId)>, + window: &mut Window, + cx: &mut Context, + ) { + for (worktree_id, root_id) in roots { + self.expand_all_for_entry(worktree_id, root_id, cx); + self.synchronously_expand_all_directories_internal(worktree_id, root_id, cx); + } + + self.update_visible_entries(None, false, false, window, cx); + cx.notify(); + } + + fn collapse_worktree_roots( + &mut self, + roots: Vec<(WorktreeId, ProjectEntryId)>, + window: &mut Window, + cx: &mut Context, + ) { + for (worktree_id, root_id) in roots { + self.collapse_worktree_expanded_dirs(worktree_id, root_id, cx); + } + + self.update_visible_entries(None, false, false, window, cx); cx.notify(); } @@ -1455,39 +1460,100 @@ impl ProjectPanel { window: &mut Window, cx: &mut Context, ) { - // By keeping entries for fully collapsed worktrees, we avoid expanding them within update_visible_entries - // (which is it's default behavior when there's no entry for a worktree in expanded_dir_ids). - let multiple_worktrees = self.project.read(cx).visible_worktrees(cx).count() > 1; - let project = self.project.read(cx); + let roots = self.all_worktree_roots(cx); + self.collapse_worktree_roots(roots, window, cx); + } - self.state - .expanded_dir_ids - .iter_mut() - .for_each(|(worktree_id, expanded_entries)| { - if multiple_worktrees { - *expanded_entries = Default::default(); - return; - } + fn expand_all_entries( + &mut self, + _: &ExpandAllEntries, + window: &mut Window, + cx: &mut Context, + ) { + let roots = self.all_worktree_roots(cx); + self.expand_worktree_roots(roots, window, cx); + } - let root_entry_id = project - .worktree_for_id(*worktree_id, cx) - .map(|worktree| worktree.read(cx).snapshot()) - .and_then(|worktree_snapshot| { - worktree_snapshot.root_entry().map(|entry| entry.id) - }); + fn expand_all_for_entry_and_refresh( + &mut self, + worktree_id: WorktreeId, + entry_id: ProjectEntryId, + window: &mut Window, + cx: &mut Context, + ) { + self.expand_all_for_entry(worktree_id, entry_id, cx); + self.synchronously_expand_all_directories(worktree_id, entry_id, window, cx); + } - match root_entry_id { - Some(id) => { - expanded_entries.retain(|entry_id| entry_id == &id); - } - None => *expanded_entries = Default::default(), - }; - }); + fn expand_selected_entry_and_children( + &mut self, + _: &ExpandSelectedEntryAndChildren, + window: &mut Window, + cx: &mut Context, + ) { + if let Some((worktree, entry)) = self.selected_entry(cx) { + let worktree_id = worktree.id(); + let entry_id = entry.id; + self.expand_all_for_entry_and_refresh(worktree_id, entry_id, window, cx); + } + } + fn synchronously_expand_all_directories( + &mut self, + worktree_id: WorktreeId, + entry_id: ProjectEntryId, + window: &mut Window, + cx: &mut Context, + ) { + self.synchronously_expand_all_directories_internal(worktree_id, entry_id, cx); self.update_visible_entries(None, false, false, window, cx); cx.notify(); } + fn synchronously_expand_all_directories_internal( + &mut self, + worktree_id: WorktreeId, + entry_id: ProjectEntryId, + cx: &mut Context, + ) { + let project = self.project.read(cx); + let Some((worktree, expanded_dir_ids)) = project + .worktree_for_id(worktree_id, cx) + .zip(self.state.expanded_dir_ids.get_mut(&worktree_id)) + else { + return; + }; + + let worktree = worktree.read(cx); + let Some(entry) = worktree.entry_for_id(entry_id) else { + return; + }; + let include_ignored_dirs = !entry.is_ignored; + + if let Err(ix) = expanded_dir_ids.binary_search(&entry_id) { + expanded_dir_ids.insert(ix, entry_id); + } + + let mut dirs_to_expand = vec![entry_id]; + while let Some(current_id) = dirs_to_expand.pop() { + let Some(current_entry) = worktree.entry_for_id(current_id) else { + continue; + }; + for child in worktree.child_entries(¤t_entry.path) { + if !child.is_dir() || (include_ignored_dirs && child.is_ignored) { + continue; + } + + dirs_to_expand.push(child.id); + + if let Err(ix) = expanded_dir_ids.binary_search(&child.id) { + expanded_dir_ids.insert(ix, child.id); + } + self.state.unfolded_dir_ids.insert(child.id); + } + } + } + fn toggle_expanded( &mut self, entry_id: ProjectEntryId, @@ -6797,7 +6863,9 @@ impl Render for ProjectPanel { .on_action(cx.listener(Self::expand_selected_entry)) .on_action(cx.listener(Self::collapse_selected_entry)) .on_action(cx.listener(Self::collapse_all_entries)) + .on_action(cx.listener(Self::expand_all_entries)) .on_action(cx.listener(Self::collapse_selected_entry_and_children)) + .on_action(cx.listener(Self::expand_selected_entry_and_children)) .on_action(cx.listener(Self::open)) .on_action(cx.listener(Self::open_permanent)) .on_action(cx.listener(Self::open_split_vertical)) diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index a228749dc1..d1a0f3b209 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -8163,115 +8163,99 @@ async fn test_collapse_non_root_multi_worktree(cx: &mut gpui::TestAppContext) { } #[gpui::test] -async fn test_collapse_all_for_root_single_worktree(cx: &mut gpui::TestAppContext) { - init_test(cx); +async fn test_expand_all_entries(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( - path!("/root"), + "/project_root", json!({ - "dir1": { - "subdir1": { - "file1.txt": "" + "dir_1": { + "nested_dir": { + "file_a.py": "# File contents", + "file_b.py": "# File contents", + "file_c.py": "# File contents", }, - "file2.txt": "" + "file_1.py": "# File contents", + "file_2.py": "# File contents", + "file_3.py": "# File contents", }, - "dir2": { - "file3.txt": "" + "dir_2": { + "file_1.py": "# File contents", + "file_2.py": "# File contents", + "file_3.py": "# File contents", } }), ) .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let cx = &mut VisualTestContext::from_window(window.into(), cx); - let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); - toggle_expand_dir(&panel, "root/dir1", cx); - toggle_expand_dir(&panel, "root/dir1/subdir1", cx); - toggle_expand_dir(&panel, "root/dir2", cx); - + panel.update_in(cx, |panel, window, cx| { + panel.collapse_all_entries(&CollapseAllEntries, window, cx) + }); + cx.executor().run_until_parked(); assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v root", - " v dir1", - " v subdir1", - " file1.txt", - " file2.txt", - " v dir2 <== selected", - " file3.txt", - ], - "Initial state with directories expanded" + visible_entries_as_strings(&panel, 0..10, cx), + &["v project_root", " > dir_1", " > dir_2",] ); - select_path(&panel, "root", cx); - cx.run_until_parked(); - panel.update_in(cx, |panel, window, cx| { - panel.collapse_all_for_root(window, cx); + panel.expand_all_entries(&ExpandAllEntries, window, cx) }); - cx.run_until_parked(); + cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &["v root <== selected", " > dir1", " > dir2"], - "Root should remain expanded but all children should be collapsed" + let entries = visible_entries_as_strings(&panel, 0..20, cx); + assert_eq!(entries.len(), 13, "should show all 13 entries"); + assert!(entries[0].starts_with("v project_root"), "root expanded"); + assert!(entries[1].contains("v dir_1"), "dir_1 expanded"); + assert!(entries[2].contains("v nested_dir"), "nested_dir expanded"); + assert!( + entries.iter().any(|e| e.contains("file_a.py")), + "file_a visible" ); - - toggle_expand_dir(&panel, "root/dir1", cx); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v root", - " v dir1 <== selected", - " > subdir1", - " file2.txt", - " > dir2", - ], - "After re-expanding dir1, subdir1 should still be collapsed" + assert!( + entries.iter().any(|e| e.contains("file_c.py")), + "file_c visible" + ); + assert!( + entries.iter().any(|e| e.contains("v dir_2")), + "dir_2 expanded" + ); + assert!( + !entries.iter().any(|e| e.contains("> ")), + "no collapsed dirs" ); } #[gpui::test] -async fn test_collapse_all_for_root_multi_worktree(cx: &mut gpui::TestAppContext) { - init_test(cx); +async fn test_expand_all_entries_multiple_worktrees(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root1"), - json!({ - "dir1": { - "subdir1": { - "file1.txt": "" - }, - "file2.txt": "" - } - }), - ) - .await; - fs.insert_tree( - path!("/root2"), - json!({ - "dir2": { - "file3.txt": "" - }, - "file4.txt": "" - }), - ) - .await; + let worktree_content = json!({ + "dir_1": { + "file_1.py": "# File contents", + }, + "dir_2": { + "file_1.py": "# File contents", + } + }); + + fs.insert_tree("/project_root_1", worktree_content.clone()) + .await; + fs.insert_tree("/project_root_2", worktree_content).await; let project = Project::test( fs.clone(), - [path!("/root1").as_ref(), path!("/root2").as_ref()], + ["/project_root_1".as_ref(), "/project_root_2".as_ref()], cx, ) .await; @@ -8280,66 +8264,197 @@ async fn test_collapse_all_for_root_multi_worktree(cx: &mut gpui::TestAppContext .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); let cx = &mut VisualTestContext::from_window(window.into(), cx); - let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); - toggle_expand_dir(&panel, "root1/dir1", cx); - toggle_expand_dir(&panel, "root1/dir1/subdir1", cx); - toggle_expand_dir(&panel, "root2/dir2", cx); - + panel.update_in(cx, |panel, window, cx| { + panel.collapse_all_entries(&CollapseAllEntries, window, cx) + }); + cx.executor().run_until_parked(); assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v root1", - " v dir1", - " v subdir1", - " file1.txt", - " file2.txt", - "v root2", - " v dir2 <== selected", - " file3.txt", - " file4.txt", - ], - "Initial state with directories expanded across worktrees" + visible_entries_as_strings(&panel, 0..10, cx), + &["> project_root_1", "> project_root_2",] ); - select_path(&panel, "root1", cx); - cx.run_until_parked(); - panel.update_in(cx, |panel, window, cx| { - panel.collapse_all_for_root(window, cx); + panel.expand_all_entries(&ExpandAllEntries, window, cx) }); - cx.run_until_parked(); - + cx.executor().run_until_parked(); assert_eq!( visible_entries_as_strings(&panel, 0..20, cx), &[ - "> root1 <== selected", - "v root2", - " v dir2", - " file3.txt", - " file4.txt", - ], - "With multiple worktrees, root1 should collapse completely (including itself)" + "v project_root_1", + " v dir_1", + " file_1.py", + " v dir_2", + " file_1.py", + "v project_root_2", + " v dir_1", + " file_1.py", + " v dir_2", + " file_1.py", + ] ); } #[gpui::test] -async fn test_collapse_all_for_root_noop_on_non_root(cx: &mut gpui::TestAppContext) { - init_test(cx); +async fn test_expand_all_entries_via_window_dispatch(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor()); + let worktree_content = json!({ + "dir_1": { + "file_1.py": "# File contents", + }, + "dir_2": { + "file_1.py": "# File contents", + } + }); + + fs.insert_tree("/project_root_1", worktree_content.clone()) + .await; + fs.insert_tree("/project_root_2", worktree_content).await; + + let project = Project::test( + fs.clone(), + ["/project_root_1".as_ref(), "/project_root_2".as_ref()], + cx, + ) + .await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + cx.update(|_, cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + auto_reveal_entries: false, + ..settings + }, + cx, + ); + }); + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, cx| { + panel.collapse_all_entries(&CollapseAllEntries, window, cx) + }); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &["> project_root_1", "> project_root_2",] + ); + + panel.update_in(cx, |panel, window, cx| { + panel.focus_handle(cx).focus(window, cx); + }); + cx.dispatch_action(ExpandAllEntries); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root_1", + " v dir_1", + " file_1.py", + " v dir_2", + " file_1.py", + "v project_root_2", + " v dir_1", + " file_1.py", + " v dir_2", + " file_1.py", + ] + ); +} + +#[gpui::test] +async fn test_expand_all_for_entry_single_worktree(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor()); + let worktree_content = json!({ + "dir_1": { + "file_1.py": "# File contents", + }, + "dir_2": { + "file_1.py": "# File contents", + } + }); + + fs.insert_tree("/project_root_1", worktree_content.clone()) + .await; + fs.insert_tree("/project_root_2", worktree_content).await; + + let project = Project::test( + fs.clone(), + ["/project_root_1".as_ref(), "/project_root_2".as_ref()], + cx, + ) + .await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, cx| { + panel.collapse_all_entries(&CollapseAllEntries, window, cx) + }); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &["> project_root_1", "> project_root_2",] + ); + + let root2_entry = find_project_entry(&panel, "project_root_2", cx).unwrap(); + panel.update_in(cx, |panel, window, cx| { + let worktree_id = panel + .project + .read(cx) + .worktree_id_for_entry(root2_entry, cx) + .unwrap(); + panel.expand_all_for_entry(worktree_id, root2_entry, cx); + panel.update_visible_entries(None, false, false, window, cx); + }); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "> project_root_1", + "v project_root_2", + " v dir_1", + " file_1.py", + " v dir_2", + " file_1.py", + ] + ); +} + +#[gpui::test] +async fn test_expand_all_entries_with_auto_fold(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/root"), json!({ "dir1": { - "subdir1": { - "file1.txt": "" + "empty1": { + "empty2": { + "empty3": { + "file.txt": "" + } + } }, - }, - "dir2": { - "file2.txt": "" } }), ) @@ -8352,45 +8467,41 @@ async fn test_collapse_all_for_root_noop_on_non_root(cx: &mut gpui::TestAppConte .unwrap(); let cx = &mut VisualTestContext::from_window(window.into(), cx); + cx.update(|_, cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + auto_fold_dirs: true, + ..settings + }, + cx, + ); + }); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); - toggle_expand_dir(&panel, "root/dir1", cx); - toggle_expand_dir(&panel, "root/dir1/subdir1", cx); - toggle_expand_dir(&panel, "root/dir2", cx); + panel.update_in(cx, |panel, window, cx| { + panel.collapse_all_entries(&CollapseAllEntries, window, cx) + }); + cx.executor().run_until_parked(); + + panel.update_in(cx, |panel, window, cx| { + panel.expand_all_entries(&ExpandAllEntries, window, cx) + }); + cx.executor().run_until_parked(); assert_eq!( visible_entries_as_strings(&panel, 0..20, cx), &[ "v root", " v dir1", - " v subdir1", - " file1.txt", - " v dir2 <== selected", - " file2.txt", + " v empty1", + " v empty2", + " v empty3", + " file.txt", ], - "Initial state with directories expanded" - ); - - select_path(&panel, "root/dir1", cx); - cx.run_until_parked(); - - panel.update_in(cx, |panel, window, cx| { - panel.collapse_all_for_root(window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v root", - " v dir1 <== selected", - " v subdir1", - " file1.txt", - " v dir2", - " file2.txt", - ], - "collapse_all_for_root should be a no-op when called on a non-root directory" + "expand all should unfold auto-folded directories" ); } diff --git a/docs/src/project-panel.md b/docs/src/project-panel.md index 45d1bbd72f..c1a190d890 100644 --- a/docs/src/project-panel.md +++ b/docs/src/project-panel.md @@ -17,7 +17,8 @@ status bar. Use the arrow keys to move through entries. {#kb project_panel::ExpandSelectedEntry} expands a directory and {#kb project_panel::CollapseSelectedEntry} collapses it. {#kb -project_panel::CollapseAllEntries} collapses every directory at once. Press {#kb +project_panel::CollapseAllEntries} collapses every directory at once. {#kb +project_panel::ExpandAllEntries} expands every directory at once. Press {#kb project_panel::Open} or click to preview a selected file, without giving it a permanent tab. Editing the file or double-clicking it promotes it to a permanent tab.