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

Nils Koch and Finn Evers created

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>

Change summary

crates/project_panel/src/project_panel.rs       |  26 ++++
crates/project_panel/src/project_panel_tests.rs | 105 +++++++++++++++++++
2 files changed, 130 insertions(+), 1 deletion(-)

Detailed changes

crates/project_panel/src/project_panel.rs 🔗

@@ -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();
     }

crates/project_panel/src/project_panel_tests.rs 🔗

@@ -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);