workspace: Add `Close Multibuffers` pane context menu entry (#39199)

Lukas Wirth created

Release Notes:

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

Change summary

crates/agent_ui/src/agent_diff.rs            |   4 
crates/collab_ui/src/channel_view.rs         |   4 
crates/debugger_ui/src/stack_trace_view.rs   |   4 
crates/diagnostics/src/buffer_diagnostics.rs |   4 
crates/diagnostics/src/diagnostics.rs        |   4 
crates/editor/src/editor.rs                  |  14 
crates/editor/src/editor_tests.rs            |  26 +
crates/editor/src/element.rs                 |  46 ++-
crates/editor/src/items.rs                   |   9 
crates/git_ui/src/commit_view.rs             |   4 
crates/git_ui/src/file_diff_view.rs          |   4 
crates/git_ui/src/project_diff.rs            |   4 
crates/git_ui/src/text_diff_view.rs          |   4 
crates/image_viewer/src/image_viewer.rs      |   4 
crates/onboarding/src/multibuffer_hint.rs    |   4 
crates/repl/src/notebook/notebook_ui.rs      |   4 
crates/search/src/project_search.rs          |   4 
crates/terminal_view/src/terminal_view.rs    |   4 
crates/workspace/src/item.rs                 |  40 ++-
crates/workspace/src/pane.rs                 | 255 ++++++++++++++++++++-
crates/workspace/src/workspace.rs            |  29 +
crates/zed/src/zed/quick_action_bar.rs       |   3 
22 files changed, 333 insertions(+), 145 deletions(-)

Detailed changes

crates/agent_ui/src/agent_diff.rs 🔗

@@ -562,10 +562,6 @@ impl Item for AgentDiffPane {
         self.editor.for_each_project_item(cx, f)
     }
 
-    fn is_singleton(&self, _: &App) -> bool {
-        false
-    }
-
     fn set_nav_history(
         &mut self,
         nav_history: ItemNavHistory,

crates/collab_ui/src/channel_view.rs 🔗

@@ -508,10 +508,6 @@ impl Item for ChannelView {
         }))
     }
 
-    fn is_singleton(&self, _cx: &App) -> bool {
-        false
-    }
-
     fn navigate(
         &mut self,
         data: Box<dyn Any>,

crates/debugger_ui/src/stack_trace_view.rs 🔗

@@ -354,10 +354,6 @@ impl Item for StackTraceView {
         self.editor.for_each_project_item(cx, f)
     }
 
-    fn is_singleton(&self, _: &App) -> bool {
-        false
-    }
-
     fn set_nav_history(
         &mut self,
         nav_history: ItemNavHistory,

crates/diagnostics/src/buffer_diagnostics.rs 🔗

@@ -730,10 +730,6 @@ impl Item for BufferDiagnosticsEditor {
         self.multibuffer.read(cx).is_dirty(cx)
     }
 
-    fn is_singleton(&self, _cx: &App) -> bool {
-        false
-    }
-
     fn navigate(
         &mut self,
         data: Box<dyn Any>,

crates/diagnostics/src/diagnostics.rs 🔗

@@ -716,10 +716,6 @@ impl Item for ProjectDiagnosticsEditor {
         self.editor.for_each_project_item(cx, f)
     }
 
-    fn is_singleton(&self, _: &App) -> bool {
-        false
-    }
-
     fn set_nav_history(
         &mut self,
         nav_history: ItemNavHistory,

crates/editor/src/editor.rs 🔗

@@ -201,7 +201,7 @@ use workspace::{
     CollaboratorId, Item as WorkspaceItem, ItemId, ItemNavHistory, OpenInTerminal, OpenTerminal,
     RestoreOnStartupBehavior, SERIALIZATION_THROTTLE_TIME, SplitDirection, TabBarSettings, Toast,
     ViewId, Workspace, WorkspaceId, WorkspaceSettings,
-    item::{ItemHandle, PreviewTabsSettings, SaveOptions},
+    item::{ItemBufferKind, ItemHandle, PreviewTabsSettings, SaveOptions},
     notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt},
     searchable::SearchEvent,
 };
@@ -7286,7 +7286,7 @@ impl Editor {
     }
 
     pub fn supports_minimap(&self, cx: &App) -> bool {
-        !self.minimap_visibility.disabled() && self.is_singleton(cx)
+        !self.minimap_visibility.disabled() && self.buffer_kind(cx) == ItemBufferKind::Singleton
     }
 
     fn edit_predictions_enabled_in_buffer(
@@ -17952,7 +17952,7 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        if self.is_singleton(cx) {
+        if self.buffer_kind(cx) == ItemBufferKind::Singleton {
             let selection = self.selections.newest::<Point>(cx);
 
             let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
@@ -18018,7 +18018,7 @@ impl Editor {
     }
 
     pub fn fold(&mut self, _: &actions::Fold, window: &mut Window, cx: &mut Context<Self>) {
-        if self.is_singleton(cx) {
+        if self.buffer_kind(cx) == ItemBufferKind::Singleton {
             let mut to_fold = Vec::new();
             let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
             let selections = self.selections.all_adjusted(cx);
@@ -18275,7 +18275,7 @@ impl Editor {
     }
 
     pub fn unfold_lines(&mut self, _: &UnfoldLines, _window: &mut Window, cx: &mut Context<Self>) {
-        if self.is_singleton(cx) {
+        if self.buffer_kind(cx) == ItemBufferKind::Singleton {
             let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
             let buffer = &display_map.buffer_snapshot;
             let selections = self.selections.all::<Point>(cx);
@@ -19056,7 +19056,7 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Option<Entity<Self>> {
-        (minimap_settings.minimap_enabled() && self.is_singleton(cx))
+        (minimap_settings.minimap_enabled() && self.buffer_kind(cx) == ItemBufferKind::Singleton)
             .then(|| self.initialize_new_minimap(minimap_settings, window, cx))
     }
 
@@ -21834,7 +21834,7 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Editor>,
     ) {
-        if self.is_singleton(cx)
+        if self.buffer_kind(cx) == ItemBufferKind::Singleton
             && !self.mode.is_minimap()
             && WorkspaceSettings::get(None, cx).restore_on_startup != RestoreOnStartupBehavior::None
         {

crates/editor/src/editor_tests.rs 🔗

@@ -18798,8 +18798,9 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) {
             let active_item = workspace
                 .active_item(cx)
                 .expect("should have an active item after adding the multi buffer");
-            assert!(
-                !active_item.is_singleton(cx),
+            assert_eq!(
+                active_item.buffer_kind(cx),
+                ItemBufferKind::Multibuffer,
                 "A multi buffer was expected to active after adding"
             );
             active_item.item_id()
@@ -18827,8 +18828,9 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) {
                 first_item_id, multibuffer_item_id,
                 "Should navigate into the 1st buffer and activate it"
             );
-            assert!(
-                active_item.is_singleton(cx),
+            assert_eq!(
+                active_item.buffer_kind(cx),
+                ItemBufferKind::Singleton,
                 "New active item should be a singleton buffer"
             );
             assert_eq!(
@@ -18858,7 +18860,7 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) {
                 multibuffer_item_id,
                 "Should navigate back to the multi buffer"
             );
-            assert!(!active_item.is_singleton(cx));
+            assert_eq!(active_item.buffer_kind(cx), ItemBufferKind::Multibuffer);
         })
         .unwrap();
 
@@ -18886,8 +18888,9 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) {
                 second_item_id, first_item_id,
                 "Should navigate into the 2nd buffer and activate it"
             );
-            assert!(
-                active_item.is_singleton(cx),
+            assert_eq!(
+                active_item.buffer_kind(cx),
+                ItemBufferKind::Singleton,
                 "New active item should be a singleton buffer"
             );
             assert_eq!(
@@ -18917,7 +18920,7 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) {
                 multibuffer_item_id,
                 "Should navigate back from the 2nd buffer to the multi buffer"
             );
-            assert!(!active_item.is_singleton(cx));
+            assert_eq!(active_item.buffer_kind(cx), ItemBufferKind::Multibuffer);
         })
         .unwrap();
 
@@ -18943,8 +18946,9 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) {
             );
             assert_ne!(third_item_id, first_item_id);
             assert_ne!(third_item_id, second_item_id);
-            assert!(
-                active_item.is_singleton(cx),
+            assert_eq!(
+                active_item.buffer_kind(cx),
+                ItemBufferKind::Singleton,
                 "New active item should be a singleton buffer"
             );
             assert_eq!(
@@ -18972,7 +18976,7 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) {
                 multibuffer_item_id,
                 "Should navigate back from the 3rd buffer to the multi buffer"
             );
-            assert!(!active_item.is_singleton(cx));
+            assert_eq!(active_item.buffer_kind(cx), ItemBufferKind::Multibuffer);
         })
         .unwrap();
 }

crates/editor/src/element.rs 🔗

@@ -96,7 +96,8 @@ use util::post_inc;
 use util::{RangeExt, ResultExt, debug_panic};
 use workspace::{
     CollaboratorId, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel, Workspace,
-    item::Item, notifications::NotifyTaskExt,
+    item::{Item, ItemBufferKind},
+    notifications::NotifyTaskExt,
 };
 
 /// Determines what kinds of highlights should be applied to a lines background.
@@ -376,7 +377,7 @@ impl EditorElement {
         register_action(editor, window, Editor::move_to_enclosing_bracket);
         register_action(editor, window, Editor::undo_selection);
         register_action(editor, window, Editor::redo_selection);
-        if !editor.read(cx).is_singleton(cx) {
+        if editor.read(cx).buffer_kind(cx) == ItemBufferKind::Multibuffer {
             register_action(editor, window, Editor::expand_excerpts);
             register_action(editor, window, Editor::expand_excerpts_up);
             register_action(editor, window, Editor::expand_excerpts_down);
@@ -1765,7 +1766,7 @@ impl EditorElement {
         let show_scrollbars = match scrollbar_settings.show {
             ShowScrollbar::Auto => {
                 let editor = self.editor.read(cx);
-                let is_singleton = editor.is_singleton(cx);
+                let is_singleton = editor.buffer_kind(cx) == ItemBufferKind::Singleton;
                 // Git
                 (is_singleton && scrollbar_settings.git_diff && snapshot.buffer_snapshot.has_diff_hunks())
                 ||
@@ -3281,7 +3282,7 @@ impl EditorElement {
     ) -> Vec<Option<AnyElement>> {
         let include_fold_statuses = EditorSettings::get_global(cx).gutter.folds
             && snapshot.mode.is_full()
-            && self.editor.read(cx).is_singleton(cx);
+            && self.editor.read(cx).buffer_kind(cx) == ItemBufferKind::Singleton;
         if include_fold_statuses {
             row_infos
                 .iter()
@@ -5810,7 +5811,7 @@ impl EditorElement {
     }
 
     fn paint_line_numbers(&mut self, layout: &mut EditorLayout, window: &mut Window, cx: &mut App) {
-        let is_singleton = self.editor.read(cx).is_singleton(cx);
+        let is_singleton = self.editor.read(cx).buffer_kind(cx) == ItemBufferKind::Singleton;
 
         let line_height = layout.position_map.line_height;
         window.set_cursor_style(CursorStyle::Arrow, &layout.gutter_hitbox);
@@ -6622,7 +6623,7 @@ impl EditorElement {
         cx: &mut App,
     ) {
         self.editor.update(cx, |editor, cx| {
-            if !editor.is_singleton(cx)
+            if editor.buffer_kind(cx) != ItemBufferKind::Singleton
                 || !editor
                     .scrollbar_marker_state
                     .should_refresh(scrollbar_layout.hitbox.size)
@@ -8774,25 +8775,26 @@ impl Element for EditorElement {
                         .map(|editor| {
                             editor.update(cx, |editor, cx| {
                                 let all_selections = editor.selections.all::<Point>(cx);
-                                let selected_buffer_ids = if editor.is_singleton(cx) {
-                                    Vec::new()
-                                } else {
-                                    let mut selected_buffer_ids =
-                                        Vec::with_capacity(all_selections.len());
-
-                                    for selection in all_selections {
-                                        for buffer_id in snapshot
-                                            .buffer_snapshot
-                                            .buffer_ids_for_range(selection.range())
-                                        {
-                                            if selected_buffer_ids.last() != Some(&buffer_id) {
-                                                selected_buffer_ids.push(buffer_id);
+                                let selected_buffer_ids =
+                                    if editor.buffer_kind(cx) == ItemBufferKind::Singleton {
+                                        Vec::new()
+                                    } else {
+                                        let mut selected_buffer_ids =
+                                            Vec::with_capacity(all_selections.len());
+
+                                        for selection in all_selections {
+                                            for buffer_id in snapshot
+                                                .buffer_snapshot
+                                                .buffer_ids_for_range(selection.range())
+                                            {
+                                                if selected_buffer_ids.last() != Some(&buffer_id) {
+                                                    selected_buffer_ids.push(buffer_id);
+                                                }
                                             }
                                         }
-                                    }
 
-                                    selected_buffer_ids
-                                };
+                                        selected_buffer_ids
+                                    };
 
                                 let mut selections = editor
                                     .selections

crates/editor/src/items.rs 🔗

@@ -43,7 +43,7 @@ use util::{ResultExt, TryFutureExt, paths::PathExt};
 use workspace::{
     CollaboratorId, ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId,
     invalid_buffer_view::InvalidBufferView,
-    item::{FollowableItem, Item, ItemEvent, ProjectItem, SaveOptions},
+    item::{FollowableItem, Item, ItemBufferKind, ItemEvent, ProjectItem, SaveOptions},
     searchable::{
         Direction, FilteredSearchRange, SearchEvent, SearchableItem, SearchableItemHandle,
     },
@@ -747,8 +747,11 @@ impl Item for Editor {
             .for_each_buffer(|buffer| f(buffer.entity_id(), buffer.read(cx)));
     }
 
-    fn is_singleton(&self, cx: &App) -> bool {
-        self.buffer.read(cx).is_singleton()
+    fn buffer_kind(&self, cx: &App) -> ItemBufferKind {
+        match self.buffer.read(cx).is_singleton() {
+            true => ItemBufferKind::Singleton,
+            false => ItemBufferKind::Multibuffer,
+        }
     }
 
     fn can_save_as(&self, cx: &App) -> bool {

crates/git_ui/src/commit_view.rs 🔗

@@ -452,10 +452,6 @@ impl Item for CommitView {
             .update(cx, |editor, cx| editor.deactivated(window, cx));
     }
 
-    fn is_singleton(&self, _: &App) -> bool {
-        false
-    }
-
     fn act_as_type<'a>(
         &'a self,
         type_id: TypeId,

crates/git_ui/src/file_diff_view.rs 🔗

@@ -263,10 +263,6 @@ impl Item for FileDiffView {
             .update(cx, |editor, cx| editor.deactivated(window, cx));
     }
 
-    fn is_singleton(&self, _: &App) -> bool {
-        false
-    }
-
     fn act_as_type<'a>(
         &'a self,
         type_id: TypeId,

crates/git_ui/src/project_diff.rs 🔗

@@ -613,10 +613,6 @@ impl Item for ProjectDiff {
         self.editor.for_each_project_item(cx, f)
     }
 
-    fn is_singleton(&self, _: &App) -> bool {
-        false
-    }
-
     fn set_nav_history(
         &mut self,
         nav_history: ItemNavHistory,

crates/git_ui/src/text_diff_view.rs 🔗

@@ -324,10 +324,6 @@ impl Item for TextDiffView {
             .update(cx, |editor, cx| editor.deactivated(window, cx));
     }
 
-    fn is_singleton(&self, _: &App) -> bool {
-        false
-    }
-
     fn act_as_type<'a>(
         &'a self,
         type_id: TypeId,

crates/image_viewer/src/image_viewer.rs 🔗

@@ -98,10 +98,6 @@ impl Item for ImageView {
         f(self.image_item.entity_id(), self.image_item.read(cx))
     }
 
-    fn is_singleton(&self, _cx: &App) -> bool {
-        true
-    }
-
     fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
         let abs_path = self.image_item.read(cx).abs_path(cx)?;
         let file_path = abs_path.compact().to_string_lossy().into_owned();

crates/onboarding/src/multibuffer_hint.rs 🔗

@@ -5,7 +5,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
 use db::kvp::KEY_VALUE_STORE;
 use gpui::{App, EntityId, EventEmitter, Subscription};
 use ui::{IconButtonShape, Tooltip, prelude::*};
-use workspace::item::{ItemEvent, ItemHandle};
+use workspace::item::{ItemBufferKind, ItemEvent, ItemHandle};
 use workspace::{ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView};
 
 pub struct MultibufferHint {
@@ -79,7 +79,7 @@ impl MultibufferHint {
             return ToolbarItemLocation::Hidden;
         };
 
-        if active_pane_item.is_singleton(cx)
+        if active_pane_item.buffer_kind(cx) == ItemBufferKind::Singleton
             || active_pane_item.breadcrumbs(cx.theme(), cx).is_none()
             || !active_pane_item.can_save(cx)
         {

crates/repl/src/notebook/notebook_ui.rs 🔗

@@ -724,10 +724,6 @@ impl Item for NotebookEditor {
         f(self.notebook_item.entity_id(), self.notebook_item.read(cx))
     }
 
-    fn is_singleton(&self, _cx: &App) -> bool {
-        true
-    }
-
     fn tab_content(&self, params: TabContentParams, window: &Window, cx: &App) -> AnyElement {
         Label::new(self.tab_content_text(params.detail.unwrap_or(0), cx))
             .single_line()

crates/search/src/project_search.rs 🔗

@@ -522,10 +522,6 @@ impl Item for ProjectSearchView {
         self.results_editor.for_each_project_item(cx, f)
     }
 
-    fn is_singleton(&self, _: &App) -> bool {
-        false
-    }
-
     fn can_save(&self, _: &App) -> bool {
         true
     }

crates/terminal_view/src/terminal_view.rs 🔗

@@ -1257,10 +1257,6 @@ impl Item for TerminalView {
         false
     }
 
-    fn is_singleton(&self, _cx: &App) -> bool {
-        true
-    }
-
     fn as_searchable(&self, handle: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
         Some(Box::new(handle.clone()))
     }

crates/workspace/src/item.rs 🔗

@@ -196,6 +196,13 @@ pub enum TabTooltipContent {
     Custom(Box<dyn Fn(&mut Window, &mut App) -> AnyView>),
 }
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum ItemBufferKind {
+    Multibuffer,
+    Singleton,
+    None,
+}
+
 pub trait Item: Focusable + EventEmitter<Self::Event> + Render + Sized {
     type Event;
 
@@ -260,8 +267,8 @@ pub trait Item: Focusable + EventEmitter<Self::Event> + Render + Sized {
         _: &mut dyn FnMut(EntityId, &dyn project::ProjectItem),
     ) {
     }
-    fn is_singleton(&self, _cx: &App) -> bool {
-        false
+    fn buffer_kind(&self, _cx: &App) -> ItemBufferKind {
+        ItemBufferKind::None
     }
     fn set_nav_history(&mut self, _: ItemNavHistory, _window: &mut Window, _: &mut Context<Self>) {}
     fn clone_on_split(
@@ -467,7 +474,7 @@ pub trait ItemHandle: 'static + Send {
         _: &App,
         _: &mut dyn FnMut(EntityId, &dyn project::ProjectItem),
     );
-    fn is_singleton(&self, cx: &App) -> bool;
+    fn buffer_kind(&self, cx: &App) -> ItemBufferKind;
     fn boxed_clone(&self) -> Box<dyn ItemHandle>;
     fn clone_on_split(
         &self,
@@ -616,7 +623,7 @@ impl<T: Item> ItemHandle for Entity<T> {
     fn project_path(&self, cx: &App) -> Option<ProjectPath> {
         let this = self.read(cx);
         let mut result = None;
-        if this.is_singleton(cx) {
+        if this.buffer_kind(cx) == ItemBufferKind::Singleton {
             this.for_each_project_item(cx, &mut |_, item| {
                 result = item.project_path(cx);
             });
@@ -674,8 +681,8 @@ impl<T: Item> ItemHandle for Entity<T> {
         self.read(cx).for_each_project_item(cx, f)
     }
 
-    fn is_singleton(&self, cx: &App) -> bool {
-        self.read(cx).is_singleton(cx)
+    fn buffer_kind(&self, cx: &App) -> ItemBufferKind {
+        self.read(cx).buffer_kind(cx)
     }
 
     fn boxed_clone(&self) -> Box<dyn ItemHandle> {
@@ -1292,7 +1299,10 @@ impl<T: FollowableItem> WeakFollowableItemHandle for WeakEntity<T> {
 #[cfg(any(test, feature = "test-support"))]
 pub mod test {
     use super::{Item, ItemEvent, SerializableItem, TabContentParams};
-    use crate::{ItemId, ItemNavHistory, Workspace, WorkspaceId, item::SaveOptions};
+    use crate::{
+        ItemId, ItemNavHistory, Workspace, WorkspaceId,
+        item::{ItemBufferKind, SaveOptions},
+    };
     use gpui::{
         AnyElement, App, AppContext as _, Context, Entity, EntityId, EventEmitter, Focusable,
         InteractiveElement, IntoElement, Render, SharedString, Task, WeakEntity, Window,
@@ -1315,7 +1325,7 @@ pub mod test {
         pub save_as_count: usize,
         pub reload_count: usize,
         pub is_dirty: bool,
-        pub is_singleton: bool,
+        pub buffer_kind: ItemBufferKind,
         pub has_conflict: bool,
         pub has_deleted_file: bool,
         pub project_items: Vec<Entity<TestProjectItem>>,
@@ -1399,7 +1409,7 @@ pub mod test {
                 has_conflict: false,
                 has_deleted_file: false,
                 project_items: Vec::new(),
-                is_singleton: true,
+                buffer_kind: ItemBufferKind::Singleton,
                 nav_history: None,
                 tab_descriptions: None,
                 tab_detail: Default::default(),
@@ -1420,8 +1430,8 @@ pub mod test {
             self
         }
 
-        pub fn with_singleton(mut self, singleton: bool) -> Self {
-            self.is_singleton = singleton;
+        pub fn with_buffer_kind(mut self, buffer_kind: ItemBufferKind) -> Self {
+            self.buffer_kind = buffer_kind;
             self
         }
 
@@ -1516,8 +1526,8 @@ pub mod test {
                 .for_each(|item| f(item.entity_id(), item.read(cx)))
         }
 
-        fn is_singleton(&self, _: &App) -> bool {
-            self.is_singleton
+        fn buffer_kind(&self, _: &App) -> ItemBufferKind {
+            self.buffer_kind
         }
 
         fn set_nav_history(
@@ -1564,7 +1574,7 @@ pub mod test {
                 save_as_count: self.save_as_count,
                 reload_count: self.reload_count,
                 is_dirty: self.is_dirty,
-                is_singleton: self.is_singleton,
+                buffer_kind: self.buffer_kind,
                 has_conflict: self.has_conflict,
                 has_deleted_file: self.has_deleted_file,
                 project_items: self.project_items.clone(),
@@ -1598,7 +1608,7 @@ pub mod test {
         }
 
         fn can_save_as(&self, _cx: &App) -> bool {
-            self.is_singleton
+            self.buffer_kind == ItemBufferKind::Singleton
         }
 
         fn save(

crates/workspace/src/pane.rs 🔗

@@ -4,9 +4,9 @@ use crate::{
     WorkspaceItemBuilder,
     invalid_buffer_view::InvalidBufferView,
     item::{
-        ActivateOnClose, ClosePosition, Item, ItemHandle, ItemSettings, PreviewTabsSettings,
-        ProjectItemKind, SaveOptions, ShowCloseButton, ShowDiagnostics, TabContentParams,
-        TabTooltipContent, WeakItemHandle,
+        ActivateOnClose, ClosePosition, Item, ItemBufferKind, ItemHandle, ItemSettings,
+        PreviewTabsSettings, ProjectItemKind, SaveOptions, ShowCloseButton, ShowDiagnostics,
+        TabContentParams, TabTooltipContent, WeakItemHandle,
     },
     move_item,
     notifications::NotifyResultExt,
@@ -125,6 +125,17 @@ pub struct CloseOtherItems {
     pub close_pinned: bool,
 }
 
+/// Closes all multibuffers in the pane.
+#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default, Action)]
+#[action(namespace = pane)]
+#[serde(deny_unknown_fields)]
+pub struct CloseMultibufferItems {
+    #[serde(default)]
+    pub save_intent: Option<SaveIntent>,
+    #[serde(default)]
+    pub close_pinned: bool,
+}
+
 /// Closes all items in the pane.
 #[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default, Action)]
 #[action(namespace = pane)]
@@ -900,7 +911,7 @@ impl Pane {
         let mut existing_item = None;
         if let Some(project_entry_id) = project_entry_id {
             for (index, item) in self.items.iter().enumerate() {
-                if item.is_singleton(cx)
+                if item.buffer_kind(cx) == ItemBufferKind::Singleton
                     && item.project_entry_ids(cx).as_slice() == [project_entry_id]
                 {
                     let item = item.boxed_clone();
@@ -910,7 +921,9 @@ impl Pane {
             }
         } else {
             for (index, item) in self.items.iter().enumerate() {
-                if item.is_singleton(cx) && item.project_path(cx).as_ref() == Some(&project_path) {
+                if item.buffer_kind(cx) == ItemBufferKind::Singleton
+                    && item.project_path(cx).as_ref() == Some(&project_path)
+                {
                     let item = item.boxed_clone();
                     existing_item = Some((index, item));
                     break;
@@ -1046,7 +1059,7 @@ impl Pane {
             self.close_items_on_item_open(window, cx);
         }
 
-        if item.is_singleton(cx)
+        if item.buffer_kind(cx) == ItemBufferKind::Singleton
             && let Some(&entry_id) = item.project_entry_ids(cx).first()
         {
             let Some(project) = self.project.upgrade() else {
@@ -1077,7 +1090,7 @@ impl Pane {
         };
 
         // Does the item already exist?
-        let project_entry_id = if item.is_singleton(cx) {
+        let project_entry_id = if item.buffer_kind(cx) == ItemBufferKind::Singleton {
             item.project_entry_ids(cx).first().copied()
         } else {
             None
@@ -1086,7 +1099,7 @@ impl Pane {
         let existing_item_index = self.items.iter().position(|existing_item| {
             if existing_item.item_id() == item.item_id() {
                 true
-            } else if existing_item.is_singleton(cx) {
+            } else if existing_item.buffer_kind(cx) == ItemBufferKind::Singleton {
                 existing_item
                     .project_entry_ids(cx)
                     .first()
@@ -1202,7 +1215,9 @@ impl Pane {
         cx: &App,
     ) -> Option<Box<dyn ItemHandle>> {
         self.items.iter().find_map(|item| {
-            if item.is_singleton(cx) && (item.project_entry_ids(cx).as_slice() == [entry_id]) {
+            if item.buffer_kind(cx) == ItemBufferKind::Singleton
+                && (item.project_entry_ids(cx).as_slice() == [entry_id])
+            {
                 Some(item.boxed_clone())
             } else {
                 None
@@ -1216,7 +1231,8 @@ impl Pane {
         cx: &App,
     ) -> Option<Box<dyn ItemHandle>> {
         self.items.iter().find_map(move |item| {
-            if item.is_singleton(cx) && (item.project_path(cx).as_slice() == [project_path.clone()])
+            if item.buffer_kind(cx) == ItemBufferKind::Singleton
+                && (item.project_path(cx).as_slice() == [project_path.clone()])
             {
                 Some(item.boxed_clone())
             } else {
@@ -1475,6 +1491,30 @@ impl Pane {
         )
     }
 
+    pub fn close_multibuffer_items(
+        &mut self,
+        action: &CloseMultibufferItems,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        if self.items.is_empty() {
+            return Task::ready(Ok(()));
+        }
+
+        let pinned_item_ids = self.pinned_item_ids();
+        let multibuffer_items = self.multibuffer_item_ids(cx);
+
+        self.close_items(
+            window,
+            cx,
+            action.save_intent.unwrap_or(SaveIntent::Close),
+            move |item_id| {
+                (action.close_pinned || !pinned_item_ids.contains(&item_id))
+                    && multibuffer_items.contains(&item_id)
+            },
+        )
+    }
+
     pub fn close_clean_items(
         &mut self,
         action: &CloseCleanItems,
@@ -1637,14 +1677,14 @@ impl Pane {
             }
         });
         if dirty_project_item_ids.is_empty() {
-            return !(item.is_singleton(cx) && item.is_dirty(cx));
+            return !(item.buffer_kind(cx) == ItemBufferKind::Singleton && item.is_dirty(cx));
         }
 
         for open_item in workspace.items(cx) {
             if open_item.item_id() == item.item_id() {
                 continue;
             }
-            if !open_item.is_singleton(cx) {
+            if open_item.buffer_kind(cx) != ItemBufferKind::Singleton {
                 continue;
             }
             let other_project_item_ids = open_item.project_item_model_ids(cx);
@@ -1999,7 +2039,7 @@ impl Pane {
                 item.is_dirty(cx),
                 item.can_save(cx),
                 item.can_save_as(cx),
-                item.is_singleton(cx),
+                item.buffer_kind(cx) == ItemBufferKind::Singleton,
                 item.has_deleted_file(cx),
             )
         })?;
@@ -2281,7 +2321,9 @@ impl Pane {
         cx: &mut Context<Pane>,
     ) -> Option<()> {
         let item_id = self.items().find_map(|item| {
-            if item.is_singleton(cx) && item.project_entry_ids(cx).as_slice() == [entry_id] {
+            if item.buffer_kind(cx) == ItemBufferKind::Singleton
+                && item.project_entry_ids(cx).as_slice() == [entry_id]
+            {
                 Some(item.item_id())
             } else {
                 None
@@ -2701,12 +2743,15 @@ impl Pane {
                     .child(label),
             );
 
-        let single_entry_to_resolve = self.items[ix]
-            .is_singleton(cx)
+        let single_entry_to_resolve = (self.items[ix].buffer_kind(cx) == ItemBufferKind::Singleton)
             .then(|| self.items[ix].project_entry_ids(cx).get(0).copied())
             .flatten();
 
         let total_items = self.items.len();
+        let has_multibuffer_items = self
+            .items
+            .iter()
+            .any(|item| item.buffer_kind(cx) == ItemBufferKind::Multibuffer);
         let has_items_to_left = ix > 0;
         let has_items_to_right = ix < total_items - 1;
         let has_clean_items = self.items.iter().any(|item| !item.is_dirty(cx));
@@ -2727,6 +2772,10 @@ impl Pane {
                         save_intent: None,
                         close_pinned: false,
                     };
+                    let close_multibuffers_action = CloseMultibufferItems {
+                        save_intent: None,
+                        close_pinned: false,
+                    };
                     let close_items_to_the_left_action = CloseItemsToTheLeft {
                         close_pinned: false,
                     };
@@ -2764,6 +2813,24 @@ impl Pane {
                                         .detach_and_log_err(cx);
                                     })),
                             ))
+                            // We make this optional, instead of using disabled as to not overwhelm the context menu unnecessarily
+                            .extend(has_multibuffer_items.then(|| {
+                                ContextMenuItem::Entry(
+                                    ContextMenuEntry::new("Close Multibuffers")
+                                        .action(Box::new(close_multibuffers_action.clone()))
+                                        .handler(window.handler_for(
+                                            &pane,
+                                            move |pane, window, cx| {
+                                                pane.close_multibuffer_items(
+                                                    &close_multibuffers_action,
+                                                    window,
+                                                    cx,
+                                                )
+                                                .detach_and_log_err(cx);
+                                            },
+                                        )),
+                                )
+                            }))
                             .separator()
                             .item(ContextMenuItem::Entry(
                                 ContextMenuEntry::new("Close Left")
@@ -3485,6 +3552,13 @@ impl Pane {
         }
     }
 
+    fn multibuffer_item_ids(&self, cx: &mut Context<Pane>) -> Vec<EntityId> {
+        self.items()
+            .filter(|item| item.buffer_kind(cx) == ItemBufferKind::Multibuffer)
+            .map(|item| item.item_id())
+            .collect()
+    }
+
     pub fn drag_split_direction(&self) -> Option<SplitDirection> {
         self.drag_split_direction
     }
@@ -3706,6 +3780,12 @@ impl Render for Pane {
                         .detach_and_log_err(cx)
                 }),
             )
+            .on_action(cx.listener(
+                |pane: &mut Self, action: &CloseMultibufferItems, window, cx| {
+                    pane.close_multibuffer_items(action, window, cx)
+                        .detach_and_log_err(cx)
+                },
+            ))
             .on_action(
                 cx.listener(|pane: &mut Self, action: &RevealInProjectPanel, _, cx| {
                     let entry_id = action
@@ -5655,7 +5735,7 @@ mod tests {
             pane.add_item(
                 Box::new(cx.new(|cx| {
                     TestItem::new(cx)
-                        .with_singleton(true)
+                        .with_buffer_kind(ItemBufferKind::Singleton)
                         .with_label("buffer 1")
                         .with_project_items(&[TestProjectItem::new(1, "one.txt", cx)])
                 })),
@@ -5673,7 +5753,7 @@ mod tests {
             pane.add_item(
                 Box::new(cx.new(|cx| {
                     TestItem::new(cx)
-                        .with_singleton(true)
+                        .with_buffer_kind(ItemBufferKind::Singleton)
                         .with_label("buffer 1")
                         .with_project_items(&[TestProjectItem::new(1, "1.txt", cx)])
                 })),
@@ -5691,7 +5771,7 @@ mod tests {
             pane.add_item(
                 Box::new(cx.new(|cx| {
                     TestItem::new(cx)
-                        .with_singleton(true)
+                        .with_buffer_kind(ItemBufferKind::Singleton)
                         .with_label("buffer 2")
                         .with_project_items(&[TestProjectItem::new(2, "2.txt", cx)])
                 })),
@@ -5709,7 +5789,7 @@ mod tests {
             pane.add_item(
                 Box::new(cx.new(|cx| {
                     TestItem::new(cx)
-                        .with_singleton(false)
+                        .with_buffer_kind(ItemBufferKind::Multibuffer)
                         .with_label("multibuffer 1")
                         .with_project_items(&[TestProjectItem::new(1, "1.txt", cx)])
                 })),
@@ -5727,7 +5807,7 @@ mod tests {
             pane.add_item(
                 Box::new(cx.new(|cx| {
                     TestItem::new(cx)
-                        .with_singleton(false)
+                        .with_buffer_kind(ItemBufferKind::Multibuffer)
                         .with_label("multibuffer 1b")
                         .with_project_items(&[TestProjectItem::new(1, "1.txt", cx)])
                 })),
@@ -6281,6 +6361,139 @@ mod tests {
         assert_item_labels(&pane, [], cx);
     }
 
+    #[gpui::test]
+    async fn test_close_multibuffer_items(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+
+        let project = Project::test(fs, None, cx).await;
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
+
+        let add_labeled_item = |pane: &Entity<Pane>,
+                                label,
+                                is_dirty,
+                                kind: ItemBufferKind,
+                                cx: &mut VisualTestContext| {
+            pane.update_in(cx, |pane, window, cx| {
+                let labeled_item = Box::new(cx.new(|cx| {
+                    TestItem::new(cx)
+                        .with_label(label)
+                        .with_dirty(is_dirty)
+                        .with_buffer_kind(kind)
+                }));
+                pane.add_item(labeled_item.clone(), false, false, None, window, cx);
+                labeled_item
+            })
+        };
+
+        let item_a = add_labeled_item(&pane, "A", false, ItemBufferKind::Multibuffer, cx);
+        add_labeled_item(&pane, "B", false, ItemBufferKind::Multibuffer, cx);
+        add_labeled_item(&pane, "C", false, ItemBufferKind::Singleton, cx);
+        assert_item_labels(&pane, ["A", "B", "C*"], cx);
+
+        pane.update_in(cx, |pane, window, cx| {
+            let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
+            pane.pin_tab_at(ix, window, cx);
+            pane.close_multibuffer_items(
+                &CloseMultibufferItems {
+                    save_intent: None,
+                    close_pinned: false,
+                },
+                window,
+                cx,
+            )
+        })
+        .await
+        .unwrap();
+        assert_item_labels(&pane, ["A!", "C*"], cx);
+
+        pane.update_in(cx, |pane, window, cx| {
+            let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
+            pane.unpin_tab_at(ix, window, cx);
+            pane.close_multibuffer_items(
+                &CloseMultibufferItems {
+                    save_intent: None,
+                    close_pinned: false,
+                },
+                window,
+                cx,
+            )
+        })
+        .await
+        .unwrap();
+
+        assert_item_labels(&pane, ["C*"], cx);
+
+        add_labeled_item(&pane, "A", true, ItemBufferKind::Singleton, cx).update(cx, |item, cx| {
+            item.project_items
+                .push(TestProjectItem::new_dirty(1, "A.txt", cx))
+        });
+        add_labeled_item(&pane, "B", true, ItemBufferKind::Multibuffer, cx).update(
+            cx,
+            |item, cx| {
+                item.project_items
+                    .push(TestProjectItem::new_dirty(2, "B.txt", cx))
+            },
+        );
+        add_labeled_item(&pane, "D", true, ItemBufferKind::Multibuffer, cx).update(
+            cx,
+            |item, cx| {
+                item.project_items
+                    .push(TestProjectItem::new_dirty(3, "D.txt", cx))
+            },
+        );
+        assert_item_labels(&pane, ["C", "A^", "B^", "D*^"], cx);
+
+        let save = pane.update_in(cx, |pane, window, cx| {
+            pane.close_multibuffer_items(
+                &CloseMultibufferItems {
+                    save_intent: None,
+                    close_pinned: false,
+                },
+                window,
+                cx,
+            )
+        });
+
+        cx.executor().run_until_parked();
+        cx.simulate_prompt_answer("Save all");
+        save.await.unwrap();
+        assert_item_labels(&pane, ["C", "A*^"], cx);
+
+        add_labeled_item(&pane, "B", true, ItemBufferKind::Multibuffer, cx).update(
+            cx,
+            |item, cx| {
+                item.project_items
+                    .push(TestProjectItem::new_dirty(2, "B.txt", cx))
+            },
+        );
+        add_labeled_item(&pane, "D", true, ItemBufferKind::Multibuffer, cx).update(
+            cx,
+            |item, cx| {
+                item.project_items
+                    .push(TestProjectItem::new_dirty(3, "D.txt", cx))
+            },
+        );
+        assert_item_labels(&pane, ["C", "A^", "B^", "D*^"], cx);
+        let save = pane.update_in(cx, |pane, window, cx| {
+            pane.close_multibuffer_items(
+                &CloseMultibufferItems {
+                    save_intent: None,
+                    close_pinned: false,
+                },
+                window,
+                cx,
+            )
+        });
+
+        cx.executor().run_until_parked();
+        cx.simulate_prompt_answer("Discard all");
+        save.await.unwrap();
+        assert_item_labels(&pane, ["C", "A*^"], cx);
+    }
+
     #[gpui::test]
     async fn test_close_with_save_intent(cx: &mut TestAppContext) {
         init_test(cx);

crates/workspace/src/workspace.rs 🔗

@@ -60,7 +60,10 @@ use notifications::{
     simple_message_notification::MessageNotification,
 };
 pub use pane::*;
-pub use pane_group::*;
+pub use pane_group::{
+    ActivePaneDecorator, HANDLE_HITBOX_SIZE, Member, PaneAxis, PaneGroup, PaneRenderContext,
+    SplitDirection,
+};
 use persistence::{DB, SerializedWindowBounds, model::SerializedWorkspace};
 pub use persistence::{
     DB as WORKSPACE_DB, WorkspaceDb, delete_unloaded_items,
@@ -117,11 +120,11 @@ pub use workspace_settings::{
 };
 use zed_actions::{Spawn, feedback::FileBugReport};
 
-use crate::notifications::NotificationId;
 use crate::persistence::{
     SerializedAxis,
     model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup},
 };
+use crate::{item::ItemBufferKind, notifications::NotificationId};
 
 pub const SERIALIZATION_THROTTLE_TIME: Duration = Duration::from_millis(200);
 
@@ -2521,8 +2524,12 @@ impl Workspace {
             };
 
             for (pane, item) in dirty_items {
-                let (singleton, project_entry_ids) =
-                    cx.update(|_, cx| (item.is_singleton(cx), item.project_entry_ids(cx)))?;
+                let (singleton, project_entry_ids) = cx.update(|_, cx| {
+                    (
+                        item.buffer_kind(cx) == ItemBufferKind::Singleton,
+                        item.project_entry_ids(cx),
+                    )
+                })?;
                 if (singleton || !project_entry_ids.is_empty())
                     && !Pane::save_item(project.clone(), &pane, &*item, save_intent, cx).await?
                 {
@@ -4805,7 +4812,7 @@ impl Workspace {
                             .items()
                             .find_map(|item| {
                                 let item = item.to_followable_item_handle(cx)?;
-                                if item.is_singleton(cx)
+                                if item.buffer_kind(cx) == ItemBufferKind::Singleton
                                     && item.project_item_model_ids(cx).as_slice()
                                         == [buffer_entity_id]
                                 {
@@ -8192,7 +8199,7 @@ mod tests {
     use crate::{
         dock::{PanelEvent, test::TestPanel},
         item::{
-            ItemEvent,
+            ItemBufferKind, ItemEvent,
             test::{TestItem, TestProjectItem},
         },
     };
@@ -8553,7 +8560,7 @@ mod tests {
         let item_2_3 = cx.new(|cx| {
             TestItem::new(cx)
                 .with_dirty(true)
-                .with_singleton(false)
+                .with_buffer_kind(ItemBufferKind::Multibuffer)
                 .with_project_items(&[
                     single_entry_items[2].read(cx).project_items[0].clone(),
                     single_entry_items[3].read(cx).project_items[0].clone(),
@@ -8562,7 +8569,7 @@ mod tests {
         let item_3_4 = cx.new(|cx| {
             TestItem::new(cx)
                 .with_dirty(true)
-                .with_singleton(false)
+                .with_buffer_kind(ItemBufferKind::Multibuffer)
                 .with_project_items(&[
                     single_entry_items[3].read(cx).project_items[0].clone(),
                     single_entry_items[4].read(cx).project_items[0].clone(),
@@ -9583,7 +9590,7 @@ mod tests {
         let dirty_multi_buffer_with_both = cx.new(|cx| {
             TestItem::new(cx)
                 .with_dirty(true)
-                .with_singleton(false)
+                .with_buffer_kind(ItemBufferKind::Multibuffer)
                 .with_label("Fake Project Search")
                 .with_project_items(&[
                     dirty_regular_buffer.read(cx).project_items[0].clone(),
@@ -9734,7 +9741,7 @@ mod tests {
         let dirty_multi_buffer_with_both = cx.new(|cx| {
             TestItem::new(cx)
                 .with_dirty(true)
-                .with_singleton(false)
+                .with_buffer_kind(ItemBufferKind::Multibuffer)
                 .with_label("Fake Project Search")
                 .with_project_items(&[
                     dirty_regular_buffer.read(cx).project_items[0].clone(),
@@ -10148,7 +10155,7 @@ mod tests {
         let dirty_multi_buffer = cx.new(|cx| {
             TestItem::new(cx)
                 .with_dirty(true)
-                .with_singleton(false)
+                .with_buffer_kind(ItemBufferKind::Multibuffer)
                 .with_label("Fake Project Search")
                 .with_project_items(&[
                     dirty_regular_buffer.read(cx).project_items[0].clone(),

crates/zed/src/zed/quick_action_bar.rs 🔗

@@ -23,6 +23,7 @@ use ui::{
     IconName, IconSize, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*,
 };
 use vim_mode_setting::VimModeSetting;
+use workspace::item::ItemBufferKind;
 use workspace::{
     ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, item::ItemHandle,
 };
@@ -131,7 +132,7 @@ impl Render for QuickActionBar {
         let code_action_enabled = editor_value.code_actions_enabled_for_toolbar(cx);
         let focus_handle = editor_value.focus_handle(cx);
 
-        let search_button = editor.is_singleton(cx).then(|| {
+        let search_button = (editor.buffer_kind(cx) == ItemBufferKind::Singleton).then(|| {
             QuickActionBarButton::new(
                 "toggle buffer search",
                 search::SEARCH_ICON,