diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 85bb5fbba6ad49f556ecca9a4863972adb8666ce..f4a7c9d202e54f9239ba5d611a6c92a5b6e3bfc4 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -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::(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::(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::(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, ) { @@ -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, original_items: Vec<(Entity, usize)>, is_all_panes: bool, + open_in_active_pane: bool, restored_items: bool, } @@ -318,6 +336,7 @@ impl TabSwitcherDelegate { pane: WeakEntity, workspace: WeakEntity, is_all_panes: bool, + open_in_active_pane: bool, window: &mut Window, cx: &mut Context, original_items: Vec<(Entity, 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 = 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>, + ) { + 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>, ) { - 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>) { diff --git a/crates/tab_switcher/src/tab_switcher_tests.rs b/crates/tab_switcher/src/tab_switcher_tests.rs index 85177f29ed8f39527cdedb991db756bd5f8d08d5..a55dfb6cb7326fae327ab6e7de39cf9c62ad4427 100644 --- a/crates/tab_switcher/src/tab_switcher_tests.rs +++ b/crates/tab_switcher/src/tab_switcher_tests.rs @@ -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, cx: &mut VisualTe ); }); } + +#[track_caller] +fn open_tab_switcher_for_active_pane( + workspace: &Entity, + cx: &mut VisualTestContext, +) -> Entity> { + 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::(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::(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" + ); + } +}