@@ -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<Self>,
+ ) {
+ 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<Self>) {
+ 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))
@@ -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);