editor: Remove folded buffer ID on all excerpts removed (#50525)

Dino created

Fix a bug in the editor's handling of the
`multi_buffer::Event::ExcerptsRemoved` event, where the display map's
`folded_buffers` set was not updated according to the list of removed
buffer ids.

Since the `ProjectSearchView` now relies on the
`Editor.has_any_buffer_folded` method in order to decide the state of
the expand/collapse all excerpts button this could lead to a
bug where, after an initial project search, all excerpts would be
collapsed, and performing a new search would leave the button in a wrong
state, as all excerpts from the new search result would be expanded, but
the button would still consider that there were folded excerpts for the
buffers.

Closes #50521

Release Notes:

- Fixed bug in project search where collapsing a single buffer then
performing a new search in the same view, would break the
expand/collapse all button behavior

Change summary

crates/editor/src/display_map.rs        |  4 +
crates/editor/src/editor.rs             |  1 
crates/editor/src/editor_tests.rs       | 71 +++++++++++++++++++++++++++
crates/multi_buffer/src/multi_buffer.rs |  2 
4 files changed, 78 insertions(+)

Detailed changes

crates/editor/src/display_map.rs 🔗

@@ -1003,6 +1003,10 @@ impl DisplayMap {
         &self.block_map.folded_buffers
     }
 
+    pub(super) fn clear_folded_buffer(&mut self, buffer_id: language::BufferId) {
+        self.block_map.folded_buffers.remove(&buffer_id);
+    }
+
     #[instrument(skip_all)]
     pub fn insert_creases(
         &mut self,

crates/editor/src/editor.rs 🔗

@@ -24145,6 +24145,7 @@ impl Editor {
                     self.display_map.update(cx, |display_map, cx| {
                         display_map.invalidate_semantic_highlights(*buffer_id);
                         display_map.clear_lsp_folding_ranges(*buffer_id, cx);
+                        display_map.clear_folded_buffer(*buffer_id);
                     });
                 }
                 jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx);

crates/editor/src/editor_tests.rs 🔗

@@ -24308,6 +24308,77 @@ async fn test_folding_buffers(cx: &mut TestAppContext) {
     );
 }
 
+#[gpui::test]
+async fn test_folded_buffers_cleared_on_excerpts_removed(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/root"),
+        json!({
+            "file_a.txt": "File A\nFile A\nFile A",
+            "file_b.txt": "File B\nFile B\nFile B",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
+    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let cx = &mut VisualTestContext::from_window(*window, cx);
+    let worktree = project.update(cx, |project, cx| {
+        let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
+        assert_eq!(worktrees.len(), 1);
+        worktrees.pop().unwrap()
+    });
+    let worktree_id = worktree.update(cx, |worktree, _| worktree.id());
+
+    let buffer_a = project
+        .update(cx, |project, cx| {
+            project.open_buffer((worktree_id, rel_path("file_a.txt")), cx)
+        })
+        .await
+        .unwrap();
+    let buffer_b = project
+        .update(cx, |project, cx| {
+            project.open_buffer((worktree_id, rel_path("file_b.txt")), cx)
+        })
+        .await
+        .unwrap();
+
+    let multi_buffer = cx.new(|cx| {
+        let mut multi_buffer = MultiBuffer::new(ReadWrite);
+        let range_a = Point::new(0, 0)..Point::new(2, 4);
+        let range_b = Point::new(0, 0)..Point::new(2, 4);
+
+        multi_buffer.set_excerpts_for_path(PathKey::sorted(0), buffer_a.clone(), [range_a], 0, cx);
+        multi_buffer.set_excerpts_for_path(PathKey::sorted(1), buffer_b.clone(), [range_b], 0, cx);
+        multi_buffer
+    });
+
+    let editor = cx.new_window_entity(|window, cx| {
+        Editor::new(
+            EditorMode::full(),
+            multi_buffer.clone(),
+            Some(project.clone()),
+            window,
+            cx,
+        )
+    });
+
+    editor.update(cx, |editor, cx| {
+        editor.fold_buffer(buffer_a.read(cx).remote_id(), cx);
+    });
+    assert!(editor.update(cx, |editor, cx| editor.has_any_buffer_folded(cx)));
+
+    // When the excerpts for `buffer_a` are removed, a
+    // `multi_buffer::Event::ExcerptsRemoved` event is emitted, which should be
+    // picked up by the editor and update the display map accordingly.
+    multi_buffer.update(cx, |multi_buffer, cx| {
+        multi_buffer.remove_excerpts_for_path(PathKey::sorted(0), cx)
+    });
+    assert!(!editor.update(cx, |editor, cx| editor.has_any_buffer_folded(cx)));
+}
+
 #[gpui::test]
 async fn test_folding_buffers_with_one_excerpt(cx: &mut TestAppContext) {
     init_test(cx, |_| {});

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -105,6 +105,8 @@ pub enum Event {
     },
     ExcerptsRemoved {
         ids: Vec<ExcerptId>,
+        /// Contains only buffer IDs for which all excerpts have been removed.
+        /// Buffers that still have remaining excerpts are never included.
         removed_buffer_ids: Vec<BufferId>,
     },
     ExcerptsExpanded {