project_panel: Collapse top-level entries in Collapse all entries command (#38310)

Closes #11760

The command `project panel: collapse all entries` currently does not
collapse top-level entries (the workspaces themselves). I think this
should be expected behaviour if you only have a single workspace in your
project. However, if you have multiple workspaces, we should collapse
their top-level folders as well. This is the expected behaviour in the
screenshots in #11760.

For more context: Atm the `.retain` function empties the
`self.expanded_dir_ids` Hash Map, because the `expanded_entries` Vec is
(almost) never empty - it contains the id of the `root_entry` of the
workspace.


https://github.com/zed-industries/zed/blob/d48d6a745409a8998998ed59c28493a1aa733ebb/crates/project_panel/src/project_panel.rs#L1148-L1152

We then update the `self.expanded_dir_ids` in the
`update_visible_entries` function, and since the Hash Map is empty, we
execute the `hash_map::Entry::Vacant` arm of the following match
statement.


https://github.com/zed-industries/zed/blob/d48d6a745409a8998998ed59c28493a1aa733ebb/crates/project_panel/src/project_panel.rs#L3062-L3073

This change makes sure that we do not clear the `expanded_dir_ids`
HashMap and always keep the keys for all visible workspaces and
therefore we run the `hash_map::Entry::Occupied` arm, which does not
override the `expanded_dir_ids` anymore.



https://github.com/user-attachments/assets/b607523b-2ea2-4159-8edf-aed7bca05e3a

cc @MrSubidubi 

Release Notes:

- N/A *or* Added/Fixed/Improved ...

---------

Co-authored-by: Finn Evers <finn.evers@outlook.de>
This commit is contained in:
Nils Koch
2025-09-17 17:58:46 +02:00
committed by GitHub
co-authored by Finn Evers
parent 52521efc7b
commit 50326ddc35
2 changed files with 130 additions and 1 deletions
+25 -1
View File
@@ -1147,8 +1147,32 @@ impl ProjectPanel {
) {
// 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).worktrees(cx).count() > 1;
let project = self.project.read(cx);
self.expanded_dir_ids
.retain(|_, expanded_entries| expanded_entries.is_empty());
.iter_mut()
.for_each(|(worktree_id, expanded_entries)| {
if multiple_worktrees {
*expanded_entries = Default::default();
return;
}
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)
});
match root_entry_id {
Some(id) => {
expanded_entries.retain(|entry_id| entry_id == &id);
}
None => *expanded_entries = Default::default(),
};
});
self.update_visible_entries(None, cx);
cx.notify();
}
@@ -2747,6 +2747,111 @@ async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
);
}
#[gpui::test]
async fn test_collapse_all_entries_multiple_worktrees(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 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();
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",]
);
}
#[gpui::test]
async fn test_collapse_all_entries_with_collapsed_root(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/project_root",
json!({
"dir_1": {
"nested_dir": {
"file_a.py": "# File contents",
"file_b.py": "# File contents",
"file_c.py": "# File contents",
},
"file_1.py": "# File contents",
"file_2.py": "# File contents",
"file_3.py": "# File contents",
},
"dir_2": {
"file_1.py": "# File contents",
"file_2.py": "# File contents",
"file_3.py": "# File contents",
}
}),
)
.await;
let project = Project::test(fs.clone(), ["/project_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();
// Open project_root/dir_1 to ensure that a nested directory is expanded
toggle_expand_dir(&panel, "project_root/dir_1", cx);
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v project_root",
" v dir_1 <== selected",
" > nested_dir",
" file_1.py",
" file_2.py",
" file_3.py",
" > dir_2",
]
);
// Close root directory
toggle_expand_dir(&panel, "project_root", cx);
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&["> project_root <== selected"]
);
// Run collapse_all_entries and make sure root is not expanded
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 <== selected"]
);
}
#[gpui::test]
async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
init_test(cx);