@@ -1,7 +1,7 @@
#[cfg(test)]
mod tab_switcher_tests;
-use collections::HashMap;
+use collections::{HashMap, HashSet};
use editor::items::{
entry_diagnostic_aware_icon_decoration_and_color, entry_git_aware_label_color,
};
@@ -44,7 +44,10 @@ actions!(
/// Closes the selected item in the tab switcher.
CloseSelectedItem,
/// Toggles between showing all tabs or just the current pane's tabs.
- ToggleAll
+ ToggleAll,
+ /// Toggles the tab switcher showing all tabs across all panes, deduplicated by path.
+ /// Opens selected items in the active pane.
+ OpenInActivePane,
]
);
@@ -67,7 +70,7 @@ impl TabSwitcher {
) {
workspace.register_action(|workspace, action: &Toggle, window, cx| {
let Some(tab_switcher) = workspace.active_modal::<Self>(cx) else {
- Self::open(workspace, action.select_last, false, window, cx);
+ Self::open(workspace, action.select_last, false, false, window, cx);
return;
};
@@ -79,7 +82,19 @@ impl TabSwitcher {
});
workspace.register_action(|workspace, _action: &ToggleAll, window, cx| {
let Some(tab_switcher) = workspace.active_modal::<Self>(cx) else {
- Self::open(workspace, false, true, window, cx);
+ Self::open(workspace, false, true, false, window, cx);
+ return;
+ };
+
+ tab_switcher.update(cx, |tab_switcher, cx| {
+ tab_switcher
+ .picker
+ .update(cx, |picker, cx| picker.cycle_selection(window, cx))
+ });
+ });
+ workspace.register_action(|workspace, _action: &OpenInActivePane, window, cx| {
+ let Some(tab_switcher) = workspace.active_modal::<Self>(cx) else {
+ Self::open(workspace, false, true, true, window, cx);
return;
};
@@ -95,6 +110,7 @@ impl TabSwitcher {
workspace: &mut Workspace,
select_last: bool,
is_global: bool,
+ open_in_active_pane: bool,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
@@ -133,6 +149,7 @@ impl TabSwitcher {
weak_pane,
weak_workspace,
is_global,
+ open_in_active_pane,
window,
cx,
original_items,
@@ -235,6 +252,7 @@ pub struct TabSwitcherDelegate {
matches: Vec<TabMatch>,
original_items: Vec<(Entity<Pane>, usize)>,
is_all_panes: bool,
+ open_in_active_pane: bool,
restored_items: bool,
}
@@ -318,6 +336,7 @@ impl TabSwitcherDelegate {
pane: WeakEntity<Pane>,
workspace: WeakEntity<Workspace>,
is_all_panes: bool,
+ open_in_active_pane: bool,
window: &mut Window,
cx: &mut Context<TabSwitcher>,
original_items: Vec<(Entity<Pane>, usize)>,
@@ -332,6 +351,7 @@ impl TabSwitcherDelegate {
project,
matches: Vec::new(),
is_all_panes,
+ open_in_active_pane,
original_items,
restored_items: false,
}
@@ -405,7 +425,7 @@ impl TabSwitcherDelegate {
}
}
- let matches = if query.is_empty() {
+ let mut matches = if query.is_empty() {
let history = workspace.read(cx).recently_activated_items(cx);
all_items
.sort_by_key(|tab| (Reverse(history.get(&tab.item.item_id())), tab.item_index));
@@ -435,6 +455,17 @@ impl TabSwitcherDelegate {
.collect()
};
+ if self.open_in_active_pane {
+ let mut seen_paths: HashSet<project::ProjectPath> = HashSet::default();
+ matches.retain(|tab| {
+ if let Some(path) = tab.item.project_path(cx) {
+ seen_paths.insert(path)
+ } else {
+ true
+ }
+ });
+ }
+
let selected_item_id = self.selected_item_id();
self.matches = matches;
self.selected_index = self.compute_selected_index(selected_item_id, window, cx);
@@ -553,14 +584,45 @@ impl TabSwitcherDelegate {
let Some(tab_match) = self.matches.get(ix) else {
return;
};
- let Some(pane) = tab_match.pane.upgrade() else {
- return;
- };
- pane.update(cx, |pane, cx| {
- pane.close_item_by_id(tab_match.item.item_id(), SaveIntent::Close, window, cx)
- .detach_and_log_err(cx);
- });
+ if self.open_in_active_pane
+ && let Some(project_path) = tab_match.item.project_path(cx)
+ {
+ 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);
+ });
+ }
+ }
+ } else {
+ let Some(pane) = tab_match.pane.upgrade() else {
+ return;
+ };
+ pane.update(cx, |pane, cx| {
+ pane.close_item_by_id(tab_match.item.item_id(), SaveIntent::Close, window, cx)
+ .detach_and_log_err(cx);
+ });
+ }
}
/// Updates the selected index to ensure it matches the pane's active item,
@@ -590,6 +652,74 @@ impl TabSwitcherDelegate {
self.selected_index = index;
}
+
+ fn confirm_open_in_active_pane(
+ &mut self,
+ selected_match: TabMatch,
+ window: &mut Window,
+ cx: &mut Context<Picker<TabSwitcherDelegate>>,
+ ) {
+ let Some(workspace) = self.workspace.upgrade() else {
+ return;
+ };
+
+ let current_pane = self
+ .pane
+ .upgrade()
+ .filter(|pane| {
+ workspace
+ .read(cx)
+ .panes()
+ .iter()
+ .any(|p| p.entity_id() == pane.entity_id())
+ })
+ .or_else(|| selected_match.pane.upgrade());
+
+ let Some(current_pane) = current_pane else {
+ return;
+ };
+
+ if let Some(index) = current_pane
+ .read(cx)
+ .index_for_item(selected_match.item.as_ref())
+ {
+ current_pane.update(cx, |pane, cx| {
+ pane.activate_item(index, true, true, window, cx);
+ });
+ } else if selected_match.item.project_path(cx).is_some()
+ && selected_match.item.can_split(cx)
+ {
+ let Some(workspace) = self.workspace.upgrade() else {
+ return;
+ };
+ let database_id = workspace.read(cx).database_id();
+ let task = selected_match.item.clone_on_split(database_id, window, cx);
+ let current_pane = current_pane.downgrade();
+ cx.spawn_in(window, async move |_, cx| {
+ if let Some(clone) = task.await {
+ current_pane
+ .update_in(cx, |pane, window, cx| {
+ pane.add_item(clone, true, true, None, window, cx);
+ })
+ .log_err();
+ }
+ })
+ .detach();
+ } else {
+ let Some(source_pane) = selected_match.pane.upgrade() else {
+ return;
+ };
+ workspace::move_item(
+ &source_pane,
+ ¤t_pane,
+ selected_match.item.item_id(),
+ current_pane.read(cx).items_len(),
+ true,
+ window,
+ cx,
+ );
+ }
+ }
}
impl PickerDelegate for TabSwitcherDelegate {
@@ -619,17 +749,19 @@ impl PickerDelegate for TabSwitcherDelegate {
) {
self.selected_index = ix;
- let Some(selected_match) = self.matches.get(self.selected_index()) else {
- return;
- };
- selected_match
- .pane
- .update(cx, |pane, cx| {
- if let Some(index) = pane.index_for_item(selected_match.item.as_ref()) {
- pane.activate_item(index, false, false, window, cx);
- }
- })
- .ok();
+ if !self.open_in_active_pane {
+ let Some(selected_match) = self.matches.get(self.selected_index()) else {
+ return;
+ };
+ selected_match
+ .pane
+ .update(cx, |pane, cx| {
+ if let Some(index) = pane.index_for_item(selected_match.item.as_ref()) {
+ pane.activate_item(index, false, false, window, cx);
+ }
+ })
+ .ok();
+ }
cx.notify();
}
@@ -653,7 +785,7 @@ impl PickerDelegate for TabSwitcherDelegate {
window: &mut Window,
cx: &mut Context<Picker<TabSwitcherDelegate>>,
) {
- let Some(selected_match) = self.matches.get(self.selected_index()) else {
+ let Some(selected_match) = self.matches.get(self.selected_index()).cloned() else {
return;
};
@@ -663,14 +795,19 @@ impl PickerDelegate for TabSwitcherDelegate {
this.activate_item(*index, false, false, window, cx);
})
}
- selected_match
- .pane
- .update(cx, |pane, cx| {
- if let Some(index) = pane.index_for_item(selected_match.item.as_ref()) {
- pane.activate_item(index, true, true, window, cx);
- }
- })
- .ok();
+
+ if self.open_in_active_pane {
+ self.confirm_open_in_active_pane(selected_match, window, cx);
+ } else {
+ selected_match
+ .pane
+ .update(cx, |pane, cx| {
+ if let Some(index) = pane.index_for_item(selected_match.item.as_ref()) {
+ pane.activate_item(index, true, true, window, cx);
+ }
+ })
+ .ok();
+ }
}
fn dismissed(&mut self, window: &mut Window, cx: &mut Context<Picker<TabSwitcherDelegate>>) {
@@ -5,7 +5,7 @@ use menu::SelectPrevious;
use project::{Project, ProjectPath};
use serde_json::json;
use util::{path, rel_path::rel_path};
-use workspace::{ActivatePreviousItem, AppState, Workspace};
+use workspace::{ActivatePreviousItem, AppState, Workspace, item::test::TestItem};
#[ctor::ctor]
fn init_logger() {
@@ -343,3 +343,199 @@ fn assert_tab_switcher_is_closed(workspace: Entity<Workspace>, cx: &mut VisualTe
);
});
}
+
+#[track_caller]
+fn open_tab_switcher_for_active_pane(
+ workspace: &Entity<Workspace>,
+ cx: &mut VisualTestContext,
+) -> Entity<Picker<TabSwitcherDelegate>> {
+ cx.dispatch_action(OpenInActivePane);
+ get_active_tab_switcher(workspace, cx)
+}
+
+#[gpui::test]
+async fn test_open_in_active_pane_deduplicates_files_by_path(cx: &mut gpui::TestAppContext) {
+ let app_state = init_test(cx);
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(
+ path!("/root"),
+ json!({
+ "1.txt": "",
+ "2.txt": "",
+ }),
+ )
+ .await;
+
+ let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
+ let (workspace, cx) =
+ cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+ open_buffer("1.txt", &workspace, cx).await;
+ open_buffer("2.txt", &workspace, cx).await;
+
+ workspace.update_in(cx, |workspace, window, cx| {
+ workspace.split_pane(
+ workspace.active_pane().clone(),
+ workspace::SplitDirection::Right,
+ window,
+ cx,
+ );
+ });
+ open_buffer("1.txt", &workspace, cx).await;
+
+ let tab_switcher = open_tab_switcher_for_active_pane(&workspace, cx);
+
+ tab_switcher.read_with(cx, |picker, _cx| {
+ assert_eq!(
+ picker.delegate.matches.len(),
+ 2,
+ "should show 2 unique files despite 3 tabs"
+ );
+ });
+}
+
+#[gpui::test]
+async fn test_open_in_active_pane_clones_files_to_current_pane(cx: &mut gpui::TestAppContext) {
+ let app_state = init_test(cx);
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(path!("/root"), json!({"1.txt": ""}))
+ .await;
+
+ let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
+ let (workspace, cx) =
+ cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+ open_buffer("1.txt", &workspace, cx).await;
+
+ workspace.update_in(cx, |workspace, window, cx| {
+ workspace.split_pane(
+ workspace.active_pane().clone(),
+ workspace::SplitDirection::Right,
+ window,
+ cx,
+ );
+ });
+
+ let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec());
+
+ let tab_switcher = open_tab_switcher_for_active_pane(&workspace, cx);
+ tab_switcher.update(cx, |picker, _| {
+ picker.delegate.selected_index = 0;
+ });
+
+ cx.dispatch_action(menu::Confirm);
+ cx.run_until_parked();
+
+ let editor_1 = panes[0].read_with(cx, |pane, cx| {
+ pane.active_item()
+ .and_then(|item| item.act_as::<Editor>(cx))
+ .expect("pane 1 should have editor")
+ });
+
+ let editor_2 = panes[1].read_with(cx, |pane, cx| {
+ pane.active_item()
+ .and_then(|item| item.act_as::<Editor>(cx))
+ .expect("pane 2 should have editor")
+ });
+
+ assert_ne!(
+ editor_1.entity_id(),
+ editor_2.entity_id(),
+ "should clone to new instance"
+ );
+}
+
+#[gpui::test]
+async fn test_open_in_active_pane_moves_terminals_to_current_pane(cx: &mut gpui::TestAppContext) {
+ let app_state = init_test(cx);
+ let project = Project::test(app_state.fs.clone(), [], cx).await;
+ let (workspace, cx) =
+ cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+ let test_item = cx.new(|cx| TestItem::new(cx).with_label("terminal"));
+ workspace.update_in(cx, |workspace, window, cx| {
+ workspace.add_item_to_active_pane(Box::new(test_item.clone()), None, true, window, cx);
+ });
+
+ workspace.update_in(cx, |workspace, window, cx| {
+ workspace.split_pane(
+ workspace.active_pane().clone(),
+ workspace::SplitDirection::Right,
+ window,
+ cx,
+ );
+ });
+
+ let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec());
+
+ let tab_switcher = open_tab_switcher_for_active_pane(&workspace, cx);
+ tab_switcher.update(cx, |picker, _| {
+ picker.delegate.selected_index = 0;
+ });
+
+ cx.dispatch_action(menu::Confirm);
+ cx.run_until_parked();
+
+ assert!(
+ !panes[0].read_with(cx, |pane, _| {
+ pane.items()
+ .any(|item| item.item_id() == test_item.item_id())
+ }),
+ "should be removed from pane 1"
+ );
+ assert!(
+ panes[1].read_with(cx, |pane, _| {
+ pane.items()
+ .any(|item| item.item_id() == test_item.item_id())
+ }),
+ "should be moved to pane 2"
+ );
+}
+
+#[gpui::test]
+async fn test_open_in_active_pane_closes_file_in_all_panes(cx: &mut gpui::TestAppContext) {
+ let app_state = init_test(cx);
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(path!("/root"), json!({"1.txt": ""}))
+ .await;
+
+ let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
+ let (workspace, cx) =
+ cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+ open_buffer("1.txt", &workspace, cx).await;
+
+ workspace.update_in(cx, |workspace, window, cx| {
+ workspace.split_pane(
+ workspace.active_pane().clone(),
+ workspace::SplitDirection::Right,
+ window,
+ cx,
+ );
+ });
+ open_buffer("1.txt", &workspace, cx).await;
+
+ let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec());
+
+ let tab_switcher = open_tab_switcher_for_active_pane(&workspace, cx);
+ tab_switcher.update(cx, |picker, _| {
+ picker.delegate.selected_index = 0;
+ });
+
+ cx.dispatch_action(CloseSelectedItem);
+ cx.run_until_parked();
+
+ for pane in &panes {
+ assert_eq!(
+ pane.read_with(cx, |pane, _| pane.items_len()),
+ 0,
+ "all panes should be empty"
+ );
+ }
+}