From 86f307ef9ab46f4301b3edeb1e4eff337bc59a64 Mon Sep 17 00:00:00 2001 From: Austin Cummings Date: Thu, 5 Feb 2026 00:44:05 -0700 Subject: [PATCH] project panel: Collapse All improvements (#47328) Thanks to @jackTabsCode for the original commit that I based my work off of. See: #27703 Release Notes: - Fixed clicking "Collapse All" from context menu on a project root collapsing all project roots instead of that one. - Added new `project panel: collapse selected entry and children` action that collapses the selected directory and all its subdirectories. - Added "Collapse All" option to the context menu for all directories, not just project roots. --------- Co-authored-by: Jack T Co-authored-by: Smit Barmase --- crates/project_panel/src/project_panel.rs | 79 ++- .../project_panel/src/project_panel_tests.rs | 602 ++++++++++++++++++ 2 files changed, 673 insertions(+), 8 deletions(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 9657f13a62491ace717880660338b77db527f540..0c3e39fa5c5aa29720e64a0fa65ec2b5ca1d87c9 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -58,10 +58,10 @@ use std::{ }; use theme::ThemeSettings; use ui::{ - Color, ContextMenu, DecoratedIcon, Divider, Icon, IconDecoration, IconDecorationKind, - IndentGuideColors, IndentGuideLayout, KeyBinding, Label, LabelSize, ListItem, ListItemSpacing, - ScrollAxes, ScrollableHandle, Scrollbars, StickyCandidate, Tooltip, WithScrollbar, prelude::*, - v_flex, + Color, ContextMenu, ContextMenuEntry, DecoratedIcon, Divider, Icon, IconDecoration, + IconDecorationKind, IndentGuideColors, IndentGuideLayout, KeyBinding, Label, LabelSize, + ListItem, ListItemSpacing, ScrollAxes, ScrollableHandle, Scrollbars, StickyCandidate, Tooltip, + WithScrollbar, prelude::*, v_flex, }; use util::{ ResultExt, TakeUntilExt, TryFutureExt, maybe, @@ -286,6 +286,8 @@ actions!( ExpandSelectedEntry, /// Collapses the selected entry in the project tree. CollapseSelectedEntry, + /// Collapses the selected entry and its children in the project tree. + CollapseSelectedEntryAndChildren, /// Collapses all entries in the project tree. CollapseAllEntries, /// Creates a new directory. @@ -1112,6 +1114,7 @@ impl ProjectPanel { .is_some() }; + let entity = cx.entity(); let context_menu = ContextMenu::build(window, cx, |menu, _, _| { menu.context(self.focus_handle.clone()).map(|menu| { if is_read_only { @@ -1199,9 +1202,23 @@ impl ProjectPanel { ) .action("Remove from Project", Box::new(RemoveFromProject)) }) - .when(is_root, |menu| { - menu.separator() - .action("Collapse All", Box::new(CollapseAllEntries)) + .when(is_dir && !is_root, |menu| { + menu.separator().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); + }); + }, + ), + ) }) } }) @@ -1367,7 +1384,52 @@ impl ProjectPanel { } } - pub fn collapse_all_entries( + fn collapse_selected_entry_and_children( + &mut self, + _: &CollapseSelectedEntryAndChildren, + 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.collapse_all_for_entry(worktree_id, entry_id, cx); + + self.update_visible_entries(Some((worktree_id, entry_id)), false, false, window, cx); + cx.notify(); + } + } + + /// 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; + + 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 { + 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); + cx.notify(); + } + + fn collapse_all_entries( &mut self, _: &CollapseAllEntries, window: &mut Window, @@ -6217,6 +6279,7 @@ 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::collapse_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 9dd1a025d697bef63b8a2f7c24a5aec021a44cb5..5cc4d0b6a16ff9426888a3dbe494a919d4197e65 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -6602,6 +6602,608 @@ async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) { } } +#[gpui::test] +async fn test_collapse_selected_entry_and_children_action(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "dir1": { + "subdir1": { + "nested1": { + "file1.txt": "", + "file2.txt": "" + }, + }, + "subdir2": { + "file3.txt": "" + } + }, + "dir2": { + "file4.txt": "" + } + }), + ) + .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); + + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + cx.run_until_parked(); + + toggle_expand_dir(&panel, "root/dir1", cx); + toggle_expand_dir(&panel, "root/dir1/subdir1", cx); + toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx); + toggle_expand_dir(&panel, "root/dir1/subdir2", cx); + toggle_expand_dir(&panel, "root/dir2", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root", + " v dir1", + " v subdir1", + " v nested1", + " file1.txt", + " file2.txt", + " v subdir2", + " file3.txt", + " v dir2 <== selected", + " file4.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_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root", + " > dir1 <== selected", + " v dir2", + " file4.txt", + ], + "dir1 and all its children should be collapsed, dir2 should remain expanded" + ); + + 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", + " > subdir2", + " v dir2", + " file4.txt", + ], + "After re-expanding dir1, its children should still be collapsed" + ); +} + +#[gpui::test] +async fn test_collapse_root_single_worktree(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "dir1": { + "subdir1": { + "file1.txt": "" + }, + "file2.txt": "" + }, + "dir2": { + "file3.txt": "" + } + }), + ) + .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); + + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + 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); + + 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" + ); + + // Select the root and collapse it and its children + select_path(&panel, "root", cx); + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx); + }); + cx.run_until_parked(); + + // The root and all its children should be collapsed + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &["> root <== selected"], + "Root and all children should be collapsed" + ); + + // Re-expand root and dir1, verify children were recursively collapsed + toggle_expand_dir(&panel, "root", cx); + 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 root and dir1, subdir1 should still be collapsed" + ); +} + +#[gpui::test] +async fn test_collapse_root_multi_worktree(cx: &mut gpui::TestAppContext) { + init_test(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 project = Project::test( + fs.clone(), + [path!("/root1").as_ref(), path!("/root2").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); + + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + 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); + + 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" + ); + + // Select root1 and collapse it and its children. + // In a multi-worktree project, this should only collapse the selected worktree, + // leaving other worktrees unaffected. + select_path(&panel, "root1", cx); + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "> root1 <== selected", + "v root2", + " v dir2", + " file3.txt", + " file4.txt", + ], + "Only root1 should be collapsed, root2 should remain expanded" + ); + + // Re-expand root1 and verify its children were recursively collapsed + toggle_expand_dir(&panel, "root1", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root1 <== selected", + " > dir1", + "v root2", + " v dir2", + " file3.txt", + " file4.txt", + ], + "After re-expanding root1, dir1 should still be collapsed, root2 should be unaffected" + ); +} + +#[gpui::test] +async fn test_collapse_non_root_multi_worktree(cx: &mut gpui::TestAppContext) { + init_test(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": { + "subdir2": { + "file3.txt": "" + }, + "file4.txt": "" + } + }), + ) + .await; + + let project = Project::test( + fs.clone(), + [path!("/root1").as_ref(), path!("/root2").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); + + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + 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); + toggle_expand_dir(&panel, "root2/dir2/subdir2", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root1", + " v dir1", + " v subdir1", + " file1.txt", + " file2.txt", + "v root2", + " v dir2", + " v subdir2 <== selected", + " file3.txt", + " file4.txt", + ], + "Initial state with directories expanded across worktrees" + ); + + // Select dir1 in root1 and collapse it + select_path(&panel, "root1/dir1", cx); + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root1", + " > dir1 <== selected", + "v root2", + " v dir2", + " v subdir2", + " file3.txt", + " file4.txt", + ], + "Only dir1 should be collapsed, root2 should be completely unaffected" + ); + + // Re-expand dir1 and verify subdir1 was recursively collapsed + toggle_expand_dir(&panel, "root1/dir1", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root1", + " v dir1 <== selected", + " > subdir1", + " file2.txt", + "v root2", + " v dir2", + " v subdir2", + " file3.txt", + " file4.txt", + ], + "After re-expanding dir1, subdir1 should still be collapsed" + ); +} + +#[gpui::test] +async fn test_collapse_all_for_root_single_worktree(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "dir1": { + "subdir1": { + "file1.txt": "" + }, + "file2.txt": "" + }, + "dir2": { + "file3.txt": "" + } + }), + ) + .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); + + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + 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); + + 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" + ); + + select_path(&panel, "root", 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 <== selected", " > dir1", " > dir2"], + "Root should remain expanded but all children should be collapsed" + ); + + 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" + ); +} + +#[gpui::test] +async fn test_collapse_all_for_root_multi_worktree(cx: &mut gpui::TestAppContext) { + init_test(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 project = Project::test( + fs.clone(), + [path!("/root1").as_ref(), path!("/root2").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); + + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + 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); + + 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" + ); + + select_path(&panel, "root1", 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), + &[ + "> root1 <== selected", + "v root2", + " v dir2", + " file3.txt", + " file4.txt", + ], + "With multiple worktrees, root1 should collapse completely (including itself)" + ); +} + +#[gpui::test] +async fn test_collapse_all_for_root_noop_on_non_root(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "dir1": { + "subdir1": { + "file1.txt": "" + }, + }, + "dir2": { + "file2.txt": "" + } + }), + ) + .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); + + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + 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); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root", + " v dir1", + " v subdir1", + " file1.txt", + " v dir2 <== selected", + " file2.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" + ); +} + #[gpui::test] async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) { init_test(cx);