diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index f4a7c9d202e54f9239ba5d611a6c92a5b6e3bfc4..0fb13c85d21797e4d57728c88fc8bb014a898f78 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -591,29 +591,15 @@ impl TabSwitcherDelegate { let Some(workspace) = self.workspace.upgrade() else { return; }; - let panes_and_items: Vec<_> = workspace - .read(cx) - .panes() - .iter() - .map(|pane| { - let items_to_close: Vec<_> = pane - .read(cx) - .items() - .filter(|item| item.project_path(cx) == Some(project_path.clone())) - .map(|item| item.item_id()) - .collect(); - (pane.clone(), items_to_close) - }) - .collect(); - - for (pane, items_to_close) in panes_and_items { - for item_id in items_to_close { - pane.update(cx, |pane, cx| { - pane.close_item_by_id(item_id, SaveIntent::Close, window, cx) - .detach_and_log_err(cx); - }); - } - } + workspace.update(cx, |workspace, cx| { + workspace.close_items_with_project_path( + &project_path, + SaveIntent::Close, + true, + window, + cx, + ); + }); } else { let Some(pane) = tab_match.pane.upgrade() else { return; diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 423c3b387b197edd2d8e86398b09157fdcb7711a..bb74cb1e73f78f82ed97dd94b52f33875a65aa27 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -1627,12 +1627,12 @@ fn generate_commands(_: &App) -> Vec { VimCommand::new(("cq", "uit"), zed_actions::Quit), VimCommand::new( ("bd", "elete"), - workspace::CloseActiveItem { + workspace::CloseItemInAllPanes { save_intent: Some(SaveIntent::Close), close_pinned: false, }, ) - .bang(workspace::CloseActiveItem { + .bang(workspace::CloseItemInAllPanes { save_intent: Some(SaveIntent::Skip), close_pinned: true, }), diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index f025131758760d6c4db5250e2bbdb12a50d4ee01..8ea32dbf39e9a2df67f9121f818bdead84a56954 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -782,6 +782,10 @@ impl Pane { self.active_item_index } + pub fn is_active_item_pinned(&self) -> bool { + self.is_tab_pinned(self.active_item_index) + } + pub fn activation_history(&self) -> &[ActivationHistoryEntry] { &self.activation_history } @@ -1620,6 +1624,26 @@ impl Pane { }) } + pub fn close_items_for_project_path( + &mut self, + project_path: &ProjectPath, + save_intent: SaveIntent, + close_pinned: bool, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + let pinned_item_ids = self.pinned_item_ids(); + let matching_item_ids: Vec<_> = self + .items() + .filter(|item| item.project_path(cx).as_ref() == Some(project_path)) + .map(|item| item.item_id()) + .collect(); + self.close_items(window, cx, save_intent, move |item_id| { + matching_item_ids.contains(&item_id) + && (close_pinned || !pinned_item_ids.contains(&item_id)) + }) + } + pub fn close_other_items( &mut self, action: &CloseOtherItems, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 8dff340e264abd583c471b95d96c90e14486a2c5..12d3642b6d62abead277bdcce4f9a0c664c8cb45 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -385,6 +385,17 @@ pub struct CloseInactiveTabsAndPanes { pub save_intent: Option, } +/// Closes the active item across all panes. +#[derive(Clone, PartialEq, Debug, Deserialize, Default, JsonSchema, Action)] +#[action(namespace = workspace)] +#[serde(deny_unknown_fields)] +pub struct CloseItemInAllPanes { + #[serde(default)] + pub save_intent: Option, + #[serde(default)] + pub close_pinned: bool, +} + /// Sends a sequence of keystrokes to the active element. #[derive(Clone, Deserialize, PartialEq, JsonSchema, Action)] #[action(namespace = workspace)] @@ -3287,6 +3298,61 @@ impl Workspace { } } + /// Closes the active item across all panes. + pub fn close_item_in_all_panes( + &mut self, + action: &CloseItemInAllPanes, + window: &mut Window, + cx: &mut Context, + ) { + let Some(active_item) = self.active_pane().read(cx).active_item() else { + return; + }; + + let save_intent = action.save_intent.unwrap_or(SaveIntent::Close); + let close_pinned = action.close_pinned; + + if let Some(project_path) = active_item.project_path(cx) { + self.close_items_with_project_path( + &project_path, + save_intent, + close_pinned, + window, + cx, + ); + } else if close_pinned || !self.active_pane().read(cx).is_active_item_pinned() { + let item_id = active_item.item_id(); + self.active_pane().update(cx, |pane, cx| { + pane.close_item_by_id(item_id, save_intent, window, cx) + .detach_and_log_err(cx); + }); + } + } + + /// Closes all items with the given project path across all panes. + pub fn close_items_with_project_path( + &mut self, + project_path: &ProjectPath, + save_intent: SaveIntent, + close_pinned: bool, + window: &mut Window, + cx: &mut Context, + ) { + let panes = self.panes().to_vec(); + for pane in panes { + pane.update(cx, |pane, cx| { + pane.close_items_for_project_path( + project_path, + save_intent, + close_pinned, + window, + cx, + ) + .detach_and_log_err(cx); + }); + } + } + fn close_all_internal( &mut self, retain_active_pane: bool, @@ -6164,6 +6230,7 @@ impl Workspace { )) .on_action(cx.listener(Self::close_inactive_items_and_panes)) .on_action(cx.listener(Self::close_all_items_and_panes)) + .on_action(cx.listener(Self::close_item_in_all_panes)) .on_action(cx.listener(Self::save_all)) .on_action(cx.listener(Self::send_keystrokes)) .on_action(cx.listener(Self::add_folder_to_project)) @@ -11940,6 +12007,101 @@ mod tests { }) } + #[gpui::test] + async fn test_close_item_in_all_panes(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({ "test.txt": "" })).await; + + let project = Project::test(fs, ["root".as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let pane_a = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + // Add item to pane A with project path + let item_a = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "test.txt", cx)]) + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item_a.clone()), None, true, window, cx) + }); + + // Split to create pane B + let pane_b = workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane(pane_a.clone(), SplitDirection::Right, window, cx) + }); + + // Add item with SAME project path to pane B, and pin it + let item_b = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "test.txt", cx)]) + }); + pane_b.update_in(cx, |pane, window, cx| { + pane.add_item(Box::new(item_b.clone()), true, true, None, window, cx); + pane.set_pinned_count(1); + }); + + assert_eq!(pane_a.read_with(cx, |pane, _| pane.items_len()), 1); + assert_eq!(pane_b.read_with(cx, |pane, _| pane.items_len()), 1); + + // close_pinned: false should only close the unpinned copy + workspace.update_in(cx, |workspace, window, cx| { + workspace.close_item_in_all_panes( + &CloseItemInAllPanes { + save_intent: Some(SaveIntent::Close), + close_pinned: false, + }, + window, + cx, + ) + }); + cx.executor().run_until_parked(); + + let item_count_a = pane_a.read_with(cx, |pane, _| pane.items_len()); + let item_count_b = pane_b.read_with(cx, |pane, _| pane.items_len()); + assert_eq!(item_count_a, 0, "Unpinned item in pane A should be closed"); + assert_eq!(item_count_b, 1, "Pinned item in pane B should remain"); + + // Split again, seeing as closing the previous item also closed its + // pane, so only pane remains, which does not allow us to properly test + // that both items close when `close_pinned: true`. + let pane_c = workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane(pane_b.clone(), SplitDirection::Right, window, cx) + }); + + // Add an item with the same project path to pane C so that + // close_item_in_all_panes can determine what to close across all panes + // (it reads the active item from the active pane, and split_pane + // creates an empty pane). + let item_c = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "test.txt", cx)]) + }); + pane_c.update_in(cx, |pane, window, cx| { + pane.add_item(Box::new(item_c.clone()), true, true, None, window, cx); + }); + + // close_pinned: true should close the pinned copy too + workspace.update_in(cx, |workspace, window, cx| { + let panes_count = workspace.panes().len(); + assert_eq!(panes_count, 2, "Workspace should have two panes (B and C)"); + + workspace.close_item_in_all_panes( + &CloseItemInAllPanes { + save_intent: Some(SaveIntent::Close), + close_pinned: true, + }, + window, + cx, + ) + }); + cx.executor().run_until_parked(); + + let item_count_b = pane_b.read_with(cx, |pane, _| pane.items_len()); + let item_count_c = pane_c.read_with(cx, |pane, _| pane.items_len()); + assert_eq!(item_count_b, 0, "Pinned item in pane B should be closed"); + assert_eq!(item_count_c, 0, "Unpinned item in pane C should be closed"); + } + mod register_project_item_tests { use super::*;