From 686785e85eb877098d7bce2d2fdb8a399f2bb745 Mon Sep 17 00:00:00 2001 From: David Baldwin Date: Sat, 17 Jan 2026 17:15:16 -0500 Subject: [PATCH 1/4] Add tab switcher follow mode --- crates/tab_switcher/src/tab_switcher.rs | 309 ++++- crates/tab_switcher/src/tab_switcher_tests.rs | 1035 ++++++++++++++++- 2 files changed, 1301 insertions(+), 43 deletions(-) diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 85bb5fbba6ad49..ff85a50ca6dd1d 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, }; @@ -38,13 +38,23 @@ pub struct Toggle { #[serde(default)] pub select_last: bool, } + +/// Toggles the tab switcher showing all tabs across all panes. +#[derive(PartialEq, Clone, Deserialize, JsonSchema, Default, Action)] +#[action(namespace = tab_switcher)] +#[serde(deny_unknown_fields)] +pub struct ToggleAll { + /// When true, deduplicates tabs by project path, previews in the active + /// pane, and always opens selected items in the active pane. + #[serde(default)] + pub follow_mode: bool, +} + actions!( tab_switcher, [ /// Closes the selected item in the tab switcher. CloseSelectedItem, - /// Toggles between showing all tabs or just the current pane's tabs. - ToggleAll ] ); @@ -67,7 +77,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; }; @@ -77,9 +87,9 @@ impl TabSwitcher { .update(cx, |picker, cx| picker.cycle_selection(window, cx)) }); }); - workspace.register_action(|workspace, _action: &ToggleAll, window, cx| { + 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, action.follow_mode, window, cx); return; }; @@ -95,6 +105,7 @@ impl TabSwitcher { workspace: &mut Workspace, select_last: bool, is_global: bool, + follow_mode: bool, window: &mut Window, cx: &mut Context, ) { @@ -133,6 +144,7 @@ impl TabSwitcher { weak_pane, weak_workspace, is_global, + follow_mode, window, cx, original_items, @@ -235,6 +247,8 @@ pub struct TabSwitcherDelegate { matches: Vec, original_items: Vec<(Entity, usize)>, is_all_panes: bool, + follow_mode: bool, + preview_added_item_id: Option, restored_items: bool, } @@ -318,6 +332,7 @@ impl TabSwitcherDelegate { pane: WeakEntity, workspace: WeakEntity, is_all_panes: bool, + follow_mode: bool, window: &mut Window, cx: &mut Context, original_items: Vec<(Entity, usize)>, @@ -332,6 +347,8 @@ impl TabSwitcherDelegate { project, matches: Vec::new(), is_all_panes, + follow_mode, + preview_added_item_id: None, original_items, restored_items: false, } @@ -346,16 +363,20 @@ impl TabSwitcherDelegate { return; }; cx.subscribe_in(&workspace, window, |tab_switcher, _, event, window, cx| { - match event { - WorkspaceEvent::ItemAdded { .. } | WorkspaceEvent::PaneRemoved => { - tab_switcher.picker.update(cx, |picker, cx| { + tab_switcher.picker.update(cx, |picker, cx| { + // In follow mode, ignore workspace events since we manage preview state ourselves + // and don't want the list re-sorting during navigation + if picker.delegate.follow_mode { + return; + } + + match event { + WorkspaceEvent::ItemAdded { .. } | WorkspaceEvent::PaneRemoved => { let query = picker.query(cx); picker.delegate.update_matches(query, window, cx); cx.notify(); - }) - } - WorkspaceEvent::ItemRemoved { .. } => { - tab_switcher.picker.update(cx, |picker, cx| { + } + WorkspaceEvent::ItemRemoved { .. } => { let query = picker.query(cx); picker.delegate.update_matches(query, window, cx); @@ -366,10 +387,10 @@ impl TabSwitcherDelegate { // updated to match the pane's state. picker.delegate.sync_selected_index(cx); cx.notify(); - }) + } + _ => {} } - _ => {} - }; + }) }) .detach(); } @@ -409,6 +430,18 @@ impl TabSwitcherDelegate { 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)); + + if self.follow_mode { + let mut seen_paths: HashSet = HashSet::default(); + all_items.retain(|tab| { + if let Some(path) = tab.item.project_path(cx) { + seen_paths.insert(path) + } else { + true + } + }); + } + all_items } else { let candidates = all_items @@ -421,7 +454,7 @@ impl TabSwitcherDelegate { )) }) .collect::>(); - smol::block_on(fuzzy::match_strings( + let mut results: Vec = smol::block_on(fuzzy::match_strings( &candidates, &query, true, @@ -432,7 +465,20 @@ impl TabSwitcherDelegate { )) .into_iter() .map(|m| all_items[m.candidate_id].clone()) - .collect() + .collect(); + + if self.follow_mode { + let mut seen_paths: HashSet = HashSet::default(); + results.retain(|tab| { + if let Some(path) = tab.item.project_path(cx) { + seen_paths.insert(path) + } else { + true + } + }); + } + + results }; let selected_item_id = self.selected_item_id(); @@ -516,7 +562,6 @@ impl TabSwitcherDelegate { } if let Some(selected_item_id) = prev_selected_item_id { - // If the previously selected item is still in the list, select its new position. if let Some(item_index) = self .matches .iter() @@ -524,7 +569,6 @@ impl TabSwitcherDelegate { { return item_index; } - // Otherwise, try to preserve the previously selected index. return self.selected_index.min(self.matches.len() - 1); } @@ -535,7 +579,7 @@ impl TabSwitcherDelegate { } // This only runs when initially opening the picker - // Index 0 is already active, so don't preselect it for switching. + // Index 0 is already active (MRU sorted), so don't preselect it for switching. if self.matches.len() > 1 { self.set_selected_index(1, window, cx); return 1; @@ -553,14 +597,57 @@ impl TabSwitcherDelegate { let Some(tab_match) = self.matches.get(ix) else { return; }; + + if self.follow_mode { + if let Some(project_path) = tab_match.item.project_path(cx) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + // Collect panes and items to close first to avoid borrow conflicts + 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); + }); + } + } + + // Remove from matches without re-sorting (we ignore workspace events in follow mode) + self.matches.remove(ix); + self.selected_index = self.selected_index.min(self.matches.len().saturating_sub(1)); + 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); }); + + // Remove from matches in follow mode without re-sorting + if self.follow_mode { + self.matches.remove(ix); + self.selected_index = self.selected_index.min(self.matches.len().saturating_sub(1)); + } } /// Updates the selected index to ensure it matches the pane's active item, @@ -590,6 +677,133 @@ impl TabSwitcherDelegate { self.selected_index = index; } + + fn confirm_follow_mode( + &mut self, + selected_match: TabMatch, + window: &mut Window, + cx: &mut Context>, + ) { + let Some(current_pane) = self.pane.upgrade() else { + return; + }; + + if let Some(project_path) = selected_match.item.project_path(cx) { + let was_preview = selected_match.preview; + if let Some(item_id) = self.preview_added_item_id.take() { + current_pane.update(cx, |pane, cx| { + pane.close_item_by_id(item_id, SaveIntent::Skip, window, cx) + .detach_and_log_err(cx); + }); + } + + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + workspace + .update(cx, |workspace, cx| { + workspace.open_path_preview( + project_path, + Some(current_pane.downgrade()), + true, + was_preview, + true, + window, + cx, + ) + }) + .detach_and_log_err(cx); + } else { + self.preview_added_item_id = None; + + // Check if source pane is the same as current pane (single pane scenario) + let source_is_current = selected_match + .pane + .upgrade() + .map(|source| source.entity_id() == current_pane.entity_id()) + .unwrap_or(false); + + if source_is_current { + // Item is already in current pane, just activate it + 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 { + // Item in different pane - remove from source, then activate + let item_id = selected_match.item.item_id(); + + if let Some(source_pane) = selected_match.pane.upgrade() { + source_pane.update(cx, |pane, cx| { + // Allow pane to close if it becomes empty + pane.remove_item(item_id, false, true, window, cx); + }); + } + + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + workspace.update(cx, |workspace, cx| { + workspace.activate_item(selected_match.item.as_ref(), true, true, window, cx); + }); + } + } + } + + fn set_selected_index_follow_mode( + &mut self, + window: &mut Window, + cx: &mut Context>, + ) { + self.restore_follow_mode_preview_state(window, cx); + + let Some(selected_match) = self.matches.get(self.selected_index()).cloned() else { + return; + }; + let Some(current_pane) = self.pane.upgrade() else { + return; + }; + + let item_in_current_pane = current_pane + .read(cx) + .index_for_item(selected_match.item.as_ref()); + + if let Some(index) = item_in_current_pane { + // No need to track state - original_items will restore on dismiss. + current_pane.update(cx, |pane, cx| { + pane.activate_item(index, false, false, window, cx); + }); + } else { + let item = selected_match.item.boxed_clone(); + let item_id = item.item_id(); + current_pane.update(cx, |pane, cx| { + pane.add_item(item, false, false, None, window, cx); + }); + self.preview_added_item_id = Some(item_id); + } + } + + fn restore_follow_mode_preview_state( + &mut self, + window: &mut Window, + cx: &mut Context>, + ) { + let Some(item_id) = self.preview_added_item_id.take() else { + return; + }; + let Some(current_pane) = self.pane.upgrade() else { + return; + }; + + current_pane.update(cx, |pane, cx| { + pane.close_item_by_id(item_id, SaveIntent::Skip, window, cx) + .detach_and_log_err(cx); + }); + } } impl PickerDelegate for TabSwitcherDelegate { @@ -619,17 +833,21 @@ 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.follow_mode { + self.set_selected_index_follow_mode(window, cx); + } else { + 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); + } + }) + .log_err(); + } cx.notify(); } @@ -653,7 +871,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,17 +881,24 @@ 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.follow_mode { + self.confirm_follow_mode(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); + } + }) + .log_err(); + } } fn dismissed(&mut self, window: &mut Window, cx: &mut Context>) { + self.restore_follow_mode_preview_state(window, cx); + if !self.restored_items { for (pane, index) in self.original_items.iter() { pane.update(cx, |this, cx| { diff --git a/crates/tab_switcher/src/tab_switcher_tests.rs b/crates/tab_switcher/src/tab_switcher_tests.rs index 85177f29ed8f39..5070f58c96cb1b 100644 --- a/crates/tab_switcher/src/tab_switcher_tests.rs +++ b/crates/tab_switcher/src/tab_switcher_tests.rs @@ -5,7 +5,10 @@ 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 +346,1033 @@ fn assert_tab_switcher_is_closed(workspace: Entity, cx: &mut VisualTe ); }); } + +#[track_caller] +fn open_tab_switcher_follow_mode( + workspace: &Entity, + cx: &mut VisualTestContext, +) -> Entity> { + cx.dispatch_action(ToggleAll { follow_mode: true }); + get_active_tab_switcher(workspace, cx) +} + +#[gpui::test] +async fn test_toggle_all_follow_mode_dedupes_by_path(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "1.txt": "First file", + "2.txt": "Second file", + }), + ) + .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 files in first pane + let _tab_1 = open_buffer("1.txt", &workspace, cx).await; + let _tab_2 = open_buffer("2.txt", &workspace, cx).await; + + // Split and open same file in second pane + workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane( + workspace.active_pane().clone(), + workspace::SplitDirection::Right, + window, + cx, + ); + }); + let _tab_1_in_pane2 = open_buffer("1.txt", &workspace, cx).await; + + // ToggleAll with follow_mode should dedupe - show only 2 items, not 3 + let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); + tab_switcher.update(cx, |tab_switcher, _| { + assert_eq!( + tab_switcher.delegate.matches.len(), + 2, + "should dedupe same file across panes" + ); + }); +} + +#[gpui::test] +async fn test_toggle_all_follow_mode_previews_in_active_pane(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "1.txt": "First file", + "2.txt": "Second file", + }), + ) + .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)); + + let _tab_1 = open_buffer("1.txt", &workspace, cx).await; + let _tab_2 = open_buffer("2.txt", &workspace, cx).await; + + // 2.txt is active. MRU order is [2.txt, 1.txt] + let initial_active = workspace.read_with(cx, |workspace, cx| { + workspace.active_item(cx).map(|i| i.item_id()) + }); + + // Open picker - it selects index 1 (1.txt) by default and previews it + let _tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); + cx.run_until_parked(); + + // The active item should have changed to 1.txt (preview) + let previewed_active = workspace.read_with(cx, |workspace, cx| { + workspace.active_item(cx).map(|i| i.item_id()) + }); + assert_ne!( + initial_active, previewed_active, + "active item should change when picker opens (preview in active pane)" + ); + + // Dismiss the picker + cx.dispatch_action(menu::Cancel); + cx.run_until_parked(); + + // The active item should be restored to the original (2.txt) + let restored_active = workspace.read_with(cx, |workspace, cx| { + workspace.active_item(cx).map(|i| i.item_id()) + }); + assert_eq!( + initial_active, restored_active, + "active item should be restored after dismiss" + ); +} + +#[gpui::test] +async fn test_toggle_all_follow_mode_opens_in_current_pane(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "1.txt": "First file", + "2.txt": "Second file", + }), + ) + .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 file in first pane + let _tab_1 = open_buffer("1.txt", &workspace, cx).await; + + // Split and open different file in second pane + workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane( + workspace.active_pane().clone(), + workspace::SplitDirection::Right, + window, + cx, + ); + }); + let _tab_2 = open_buffer("2.txt", &workspace, cx).await; + + // Now we're in pane 2. Get a reference to it. + let current_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + // Open tab switcher and select 1.txt (which is in pane 1) + let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); + + // Find and select 1.txt + tab_switcher.update(cx, |picker, cx| { + for (i, m) in picker.delegate.matches.iter().enumerate() { + if m.item.tab_content_text(0, cx).contains("1.txt") { + picker.delegate.selected_index = i; + break; + } + } + }); + + // Confirm selection + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + + // The file should now be open in the current pane (pane 2), not pane 1 + current_pane.read_with(cx, |pane, cx| { + let active_item = pane.active_item().expect("pane should have active item"); + assert!( + active_item.tab_content_text(0, cx).contains("1.txt"), + "1.txt should be open in the current pane" + ); + }); +} + +#[gpui::test] +async fn test_toggle_all_follow_mode_close_removes_from_all_panes(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "1.txt": "First file", + "2.txt": "Second file", + }), + ) + .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 file in first pane + let _tab_1 = open_buffer("1.txt", &workspace, cx).await; + let _tab_2 = open_buffer("2.txt", &workspace, cx).await; + + // Split and open same file in second pane + workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane( + workspace.active_pane().clone(), + workspace::SplitDirection::Right, + window, + cx, + ); + }); + let _tab_1_pane2 = open_buffer("1.txt", &workspace, cx).await; + + // Verify 1.txt is in both panes + let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec()); + assert_eq!(panes.len(), 2); + + let count_1txt_before: usize = panes + .iter() + .map(|pane| { + pane.read_with(cx, |pane, cx| { + pane.items() + .filter(|item| item.tab_content_text(0, cx).contains("1.txt")) + .count() + }) + }) + .sum(); + assert_eq!(count_1txt_before, 2, "1.txt should be in both panes"); + + // Open follow mode tab switcher + let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); + + // Find and select 1.txt + let idx = tab_switcher.read_with(cx, |picker, cx| { + picker + .delegate + .matches + .iter() + .position(|m| m.item.tab_content_text(0, cx).contains("1.txt")) + .unwrap() + }); + + tab_switcher.update(cx, |picker, _| { + picker.delegate.selected_index = idx; + }); + + // Close the selected item + cx.dispatch_action(CloseSelectedItem); + cx.run_until_parked(); + + // 1.txt should be closed in ALL panes + let count_1txt_after: usize = panes + .iter() + .map(|pane| { + pane.read_with(cx, |pane, cx| { + pane.items() + .filter(|item| item.tab_content_text(0, cx).contains("1.txt")) + .count() + }) + }) + .sum(); + assert_eq!(count_1txt_after, 0, "1.txt should be closed in all panes"); + + // Verify item is removed from picker matches + let matches_count_after = tab_switcher.read_with(cx, |picker, _cx| { + picker.delegate.matches.len() + }); + assert_eq!(matches_count_after, 1, "picker should have 1 item after closing one of two"); +} + +#[gpui::test] +async fn test_toggle_all_follow_mode_close_preserves_list_order( + cx: &mut gpui::TestAppContext, +) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "1.txt": "First file", + "2.txt": "Second file", + "3.txt": "Third file", + }), + ) + .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 files in MRU order: 1, 2, 3 + let _tab_1 = open_buffer("1.txt", &workspace, cx).await; + let _tab_2 = open_buffer("2.txt", &workspace, cx).await; + let _tab_3 = open_buffer("3.txt", &workspace, cx).await; + + let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); + + // Capture initial order + let initial_order = tab_switcher.read_with(cx, |picker, cx| { + picker + .delegate + .matches + .iter() + .map(|m| m.item.tab_content_text(0, cx)) + .collect::>() + }); + + assert_eq!(initial_order.len(), 3); + + // Close the middle item (index 1) + tab_switcher.update(cx, |picker, _| { + picker.delegate.selected_index = 1; + }); + cx.dispatch_action(CloseSelectedItem); + cx.run_until_parked(); + + // Verify list has 2 items and order is preserved (not re-sorted) + let final_order = tab_switcher.read_with(cx, |picker, cx| { + picker + .delegate + .matches + .iter() + .map(|m| m.item.tab_content_text(0, cx)) + .collect::>() + }); + + assert_eq!(final_order.len(), 2, "should have 2 items after closing 1"); + assert_eq!( + final_order[0], initial_order[0], + "first item should remain in same position" + ); + assert_eq!( + final_order[1], initial_order[2], + "third item should now be second (middle item removed)" + ); + + // Verify selected index adjusted correctly (stayed at 1, which is now the last item) + let selected_index = tab_switcher.read_with(cx, |picker, _cx| { + picker.delegate.selected_index + }); + assert_eq!(selected_index, 1, "selected index should be 1 (last item)"); +} + +#[gpui::test] +async fn test_toggle_all_follow_mode_close_non_file_item_updates_picker( + 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)); + + // Create two non-file items + let test_item_1 = cx.new(|cx| TestItem::new(cx).with_label("non-file-1")); + let test_item_2 = cx.new(|cx| TestItem::new(cx).with_label("non-file-2")); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(test_item_1.clone()), None, true, window, cx); + workspace.add_item_to_active_pane(Box::new(test_item_2.clone()), None, true, window, cx); + }); + + let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); + + // Verify we have 2 items, MRU sorted (test_item_2 is most recent, so at index 0) + let initial_items = tab_switcher.read_with(cx, |picker, _cx| { + picker + .delegate + .matches + .iter() + .map(|m| m.item.item_id()) + .collect::>() + }); + assert_eq!(initial_items.len(), 2); + assert_eq!(initial_items[0], test_item_2.item_id(), "most recent should be first"); + + // Close the first item (test_item_2) + tab_switcher.update(cx, |picker, _| { + picker.delegate.selected_index = 0; + }); + cx.dispatch_action(CloseSelectedItem); + cx.run_until_parked(); + + // Verify picker list updated + let count_after = tab_switcher.read_with(cx, |picker, _cx| picker.delegate.matches.len()); + assert_eq!(count_after, 1, "picker should show 1 item after closing"); + + // Verify the remaining item is test_item_1 + let remaining_item = tab_switcher.read_with(cx, |picker, _cx| { + picker.delegate.matches.first().unwrap().item.item_id() + }); + assert_eq!(remaining_item, test_item_1.item_id()); +} + +#[gpui::test] +async fn test_toggle_all_follow_mode_single_pane_non_file_item_activates( + cx: &mut gpui::TestAppContext, +) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree(path!("/root"), json!({"file.txt": "content"})) + .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 a file + let _file = open_buffer("file.txt", &workspace, cx).await; + + // Add a non-file item (terminal-like) + 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); + }); + + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + // File is now active (most recent) + let _file2 = open_buffer("file.txt", &workspace, cx).await; + + // Open picker and select the non-file item + let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); + let item_index = tab_switcher.read_with(cx, |picker, _cx| { + picker + .delegate + .matches + .iter() + .position(|m| m.item.item_id() == test_item.item_id()) + .expect("test item should be in matches") + }); + + tab_switcher.update_in(cx, |picker, window, cx| { + picker.delegate.set_selected_index(item_index, window, cx); + }); + + // Confirm selection + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + + // Verify item is still in pane and is now active + let still_in_pane = pane.read_with(cx, |pane, _| { + pane.items() + .any(|item| item.item_id() == test_item.item_id()) + }); + assert!( + still_in_pane, + "non-file item should still be in pane (not removed)" + ); + + let is_active = pane.read_with(cx, |pane, _| { + pane.active_item() + .map(|item| item.item_id() == test_item.item_id()) + .unwrap_or(false) + }); + assert!(is_active, "non-file item should be active after confirm"); +} + +#[gpui::test] +async fn test_toggle_all_follow_mode_creates_independent_editors( + cx: &mut gpui::TestAppContext, +) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "1.txt": "First file", + "2.txt": "Second file", + }), + ) + .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 file in first pane + let _tab_1 = open_buffer("1.txt", &workspace, cx).await; + + // Split and open different file in second pane + workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane( + workspace.active_pane().clone(), + workspace::SplitDirection::Right, + window, + cx, + ); + }); + let _tab_2 = open_buffer("2.txt", &workspace, cx).await; + + let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec()); + let pane_1 = panes[0].clone(); + let pane_2 = panes[1].clone(); + + // Open follow mode picker in pane 2 and select 1.txt from pane 1 + let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); + tab_switcher.update(cx, |picker, cx| { + for (i, m) in picker.delegate.matches.iter().enumerate() { + if m.item.tab_content_text(0, cx).contains("1.txt") { + picker.delegate.selected_index = i; + break; + } + } + }); + + // Confirm selection + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + + // Now both panes should have 1.txt open, but as independent editor instances + // Get the editor from each pane + let editor_1 = pane_1.read_with(cx, |pane, cx| { + pane.items() + .find(|item| item.tab_content_text(0, cx).contains("1.txt")) + .expect("pane 1 should have 1.txt") + .act_as::(cx) + .expect("should be an editor") + }); + + let editor_2 = pane_2.read_with(cx, |pane, cx| { + pane.items() + .find(|item| item.tab_content_text(0, cx).contains("1.txt")) + .expect("pane 2 should have 1.txt") + .act_as::(cx) + .expect("should be an editor") + }); + + // Verify they are different entity instances (not mirrored) + assert_ne!( + editor_1.entity_id(), + editor_2.entity_id(), + "editors should be independent instances, not the same entity" + ); +} + +#[gpui::test] +async fn test_toggle_all_follow_mode_moves_non_file_item_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)); + + // Create a non-file item (TestItem with no project_items) in first pane + let test_item = cx.new(|cx| TestItem::new(cx).with_label("test-terminal")); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(test_item.clone()), None, true, window, cx); + }); + + // Split and create second pane + 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()); + assert_eq!(panes.len(), 2); + let pane_1 = panes[0].clone(); + let pane_2 = panes[1].clone(); + + // Verify test item is in pane 1 + let item_in_pane_1_before = pane_1.read_with(cx, |pane, _| { + pane.items() + .any(|item| item.item_id() == test_item.item_id()) + }); + assert!( + item_in_pane_1_before, + "test item should be in pane 1 initially" + ); + + // Open follow mode picker in pane 2 and select the test item + let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); + let item_index = tab_switcher.read_with(cx, |picker, _cx| { + picker + .delegate + .matches + .iter() + .position(|m| m.item.item_id() == test_item.item_id()) + .expect("test item should be in matches") + }); + + tab_switcher.update_in(cx, |picker, window, cx| { + picker.delegate.set_selected_index(item_index, window, cx); + }); + + // Confirm selection + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + + // Test item should now be in pane 2 only, not pane 1 + let item_in_pane_1_after = pane_1.read_with(cx, |pane, _| { + pane.items() + .any(|item| item.item_id() == test_item.item_id()) + }); + let item_in_pane_2_after = pane_2.read_with(cx, |pane, _| { + pane.items() + .any(|item| item.item_id() == test_item.item_id()) + }); + + assert!( + !item_in_pane_1_after, + "test item should be removed from pane 1" + ); + assert!(item_in_pane_2_after, "test item should be in pane 2"); +} + +#[gpui::test] +async fn test_toggle_all_follow_mode_non_file_item_dismiss_restores( + 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)); + + // Create a non-file item in first pane + let test_item = cx.new(|cx| TestItem::new(cx).with_label("test-terminal")); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(test_item.clone()), None, true, window, cx); + }); + + // Split and create second pane + 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 pane_1 = panes[0].clone(); + let pane_2 = panes[1].clone(); + + // Open follow mode picker in pane 2 and select the test item (this previews it) + let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); + let item_index = tab_switcher.read_with(cx, |picker, _cx| { + picker + .delegate + .matches + .iter() + .position(|m| m.item.item_id() == test_item.item_id()) + .expect("test item should be in matches") + }); + + tab_switcher.update_in(cx, |picker, window, cx| { + picker.delegate.set_selected_index(item_index, window, cx); + }); + cx.run_until_parked(); + + // During preview, item should be in both panes + let item_in_pane_1_preview = pane_1.read_with(cx, |pane, _| { + pane.items() + .any(|item| item.item_id() == test_item.item_id()) + }); + let item_in_pane_2_preview = pane_2.read_with(cx, |pane, _| { + pane.items() + .any(|item| item.item_id() == test_item.item_id()) + }); + + assert!( + item_in_pane_1_preview, + "test item should still be in pane 1 during preview" + ); + assert!( + item_in_pane_2_preview, + "test item should be previewed in pane 2" + ); + + // Dismiss the picker + cx.dispatch_action(menu::Cancel); + cx.run_until_parked(); + + // Item should be back to only pane 1 + let item_in_pane_1_after = pane_1.read_with(cx, |pane, _| { + pane.items() + .any(|item| item.item_id() == test_item.item_id()) + }); + let item_in_pane_2_after = pane_2.read_with(cx, |pane, _| { + pane.items() + .any(|item| item.item_id() == test_item.item_id()) + }); + + assert!( + item_in_pane_1_after, + "test item should be restored to pane 1" + ); + assert!( + !item_in_pane_2_after, + "test item should be removed from pane 2" + ); +} + +#[gpui::test] +async fn test_toggle_all_follow_mode_mru_order_stable_during_navigation( + cx: &mut gpui::TestAppContext, +) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "1.txt": "First file", + "2.txt": "Second file", + "3.txt": "Third file", + }), + ) + .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 files in specific MRU order: 1, 2, 3 (so 3 is most recent) + let _tab_1 = open_buffer("1.txt", &workspace, cx).await; + let _tab_2 = open_buffer("2.txt", &workspace, cx).await; + let _tab_3 = open_buffer("3.txt", &workspace, cx).await; + + // Open follow mode picker + let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); + + // Capture initial order + let initial_order = tab_switcher.read_with(cx, |picker, cx| { + picker + .delegate + .matches + .iter() + .map(|m| m.item.tab_content_text(0, cx)) + .collect::>() + }); + + // Navigate through the list (this triggers preview and ItemAdded events) + cx.dispatch_action(menu::SelectNext); + cx.run_until_parked(); + cx.dispatch_action(menu::SelectNext); + cx.run_until_parked(); + cx.dispatch_action(menu::SelectPrevious); + cx.run_until_parked(); + + // Verify order hasn't changed despite navigation/preview + let final_order = tab_switcher.read_with(cx, |picker, cx| { + picker + .delegate + .matches + .iter() + .map(|m| m.item.tab_content_text(0, cx)) + .collect::>() + }); + + assert_eq!( + initial_order, final_order, + "list order should remain stable during navigation despite workspace events" + ); + + // Verify MRU order: most recent (3.txt) should be first + assert!( + initial_order[0].contains("3.txt"), + "most recently used file should be first in list" + ); +} + +#[gpui::test] +async fn test_toggle_all_follow_mode_focus_stays_in_current_pane( + cx: &mut gpui::TestAppContext, +) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "1.txt": "First file", + "2.txt": "Second file", + }), + ) + .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 file in first pane + let _tab_1 = open_buffer("1.txt", &workspace, cx).await; + + // Split and open different file in second pane + workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane( + workspace.active_pane().clone(), + workspace::SplitDirection::Right, + window, + cx, + ); + }); + let _tab_2 = open_buffer("2.txt", &workspace, cx).await; + + let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec()); + let pane_2 = panes[1].clone(); + + // Verify we're in pane 2 + let initial_active_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + assert_eq!(initial_active_pane.entity_id(), pane_2.entity_id()); + + // Open follow mode picker in pane 2 and select 1.txt from pane 1 + let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); + tab_switcher.update(cx, |picker, cx| { + for (i, m) in picker.delegate.matches.iter().enumerate() { + if m.item.tab_content_text(0, cx).contains("1.txt") { + picker.delegate.selected_index = i; + break; + } + } + }); + + // Confirm selection + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + + // Verify focus stayed in pane 2 + let final_active_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + assert_eq!( + final_active_pane.entity_id(), + pane_2.entity_id(), + "focus should remain in pane 2 (current pane) after confirming file from pane 1" + ); +} + +#[gpui::test] +async fn test_toggle_all_follow_mode_preserves_preview_status( + cx: &mut gpui::TestAppContext, +) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "1.txt": "First file", + "2.txt": "Second file", + }), + ) + .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 1.txt as a preview item + let project_path = ProjectPath { + worktree_id: project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }), + path: rel_path("1.txt").into(), + }; + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path_preview(project_path, None, true, true, true, window, cx) + }) + .await + .unwrap(); + + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + // Verify 1.txt is a preview + let is_preview_before = pane.read_with(cx, |pane, _cx| { + pane.active_item() + .map(|item| pane.is_active_preview_item(item.item_id())) + .unwrap_or(false) + }); + assert!(is_preview_before, "1.txt should be a preview item initially"); + + // Open follow mode tab switcher and select 1.txt + let _tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + + // Verify 1.txt is still a preview + let is_preview_after = pane.read_with(cx, |pane, _cx| { + pane.active_item() + .map(|item| pane.is_active_preview_item(item.item_id())) + .unwrap_or(false) + }); + assert!( + is_preview_after, + "1.txt should still be a preview item after tab switcher confirm" + ); +} + +#[gpui::test] +async fn test_toggle_preserves_preview_status(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "1.txt": "First file", + "2.txt": "Second file", + }), + ) + .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 1.txt as a preview item + let project_path = ProjectPath { + worktree_id: project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }), + path: rel_path("1.txt").into(), + }; + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path_preview(project_path, None, true, true, true, window, cx) + }) + .await + .unwrap(); + + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + // Verify 1.txt is a preview + let is_preview_before = pane.read_with(cx, |pane, _cx| { + pane.active_item() + .map(|item| pane.is_active_preview_item(item.item_id())) + .unwrap_or(false) + }); + assert!(is_preview_before, "1.txt should be a preview item initially"); + + // Open regular tab switcher (non-follow mode) and confirm + let _tab_switcher = open_tab_switcher(false, &workspace, cx); + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + + // Verify 1.txt is still a preview in non-follow mode + let is_preview_after = pane.read_with(cx, |pane, _cx| { + pane.active_item() + .map(|item| pane.is_active_preview_item(item.item_id())) + .unwrap_or(false) + }); + assert!( + is_preview_after, + "1.txt should still be a preview item after regular tab switcher confirm" + ); +} + +#[gpui::test] +async fn test_toggle_all_follow_mode_non_file_item_focus_stays_in_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)); + + // Create a non-file item in first pane + let test_item = cx.new(|cx| TestItem::new(cx).with_label("test-terminal")); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(test_item.clone()), None, true, window, cx); + }); + + // Split and create second pane + 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 pane_2 = panes[1].clone(); + + // Verify we're in pane 2 + let initial_active_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + assert_eq!(initial_active_pane.entity_id(), pane_2.entity_id()); + + // Open follow mode picker in pane 2 and select the test item from pane 1 + let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); + let item_index = tab_switcher.read_with(cx, |picker, _cx| { + picker + .delegate + .matches + .iter() + .position(|m| m.item.item_id() == test_item.item_id()) + .expect("test item should be in matches") + }); + + tab_switcher.update_in(cx, |picker, window, cx| { + picker.delegate.set_selected_index(item_index, window, cx); + }); + + // Confirm selection + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + + // Verify focus stayed in pane 2 + let final_active_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + assert_eq!( + final_active_pane.entity_id(), + pane_2.entity_id(), + "focus should remain in pane 2 (current pane) after confirming non-file item from pane 1" + ); +} From 524db3a2026ce4c26be1ae92aebfb76348259471 Mon Sep 17 00:00:00 2001 From: David Baldwin Date: Tue, 27 Jan 2026 19:56:06 -0500 Subject: [PATCH 2/4] Remove previews and make standalone command --- crates/tab_switcher/src/tab_switcher.rs | 364 +++---- crates/tab_switcher/src/tab_switcher_tests.rs | 948 +----------------- 2 files changed, 187 insertions(+), 1125 deletions(-) diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index ff85a50ca6dd1d..09aecf8eee2e5c 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -38,23 +38,16 @@ pub struct Toggle { #[serde(default)] pub select_last: bool, } - -/// Toggles the tab switcher showing all tabs across all panes. -#[derive(PartialEq, Clone, Deserialize, JsonSchema, Default, Action)] -#[action(namespace = tab_switcher)] -#[serde(deny_unknown_fields)] -pub struct ToggleAll { - /// When true, deduplicates tabs by project path, previews in the active - /// pane, and always opens selected items in the active pane. - #[serde(default)] - pub follow_mode: bool, -} - actions!( tab_switcher, [ /// Closes the selected item in the tab switcher. CloseSelectedItem, + /// Toggles between showing all tabs or just the current pane's tabs. + ToggleAll, + /// Toggles the tab switcher showing all tabs across all panes, deduplicated by path. + /// Opens selected items in the active pane. + ToggleUnique, ] ); @@ -87,9 +80,21 @@ impl TabSwitcher { .update(cx, |picker, cx| picker.cycle_selection(window, cx)) }); }); - workspace.register_action(|workspace, action: &ToggleAll, window, cx| { + workspace.register_action(|workspace, _action: &ToggleAll, window, cx| { + let Some(tab_switcher) = workspace.active_modal::(cx) else { + 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: &ToggleUnique, window, cx| { let Some(tab_switcher) = workspace.active_modal::(cx) else { - Self::open(workspace, false, true, action.follow_mode, window, cx); + Self::open(workspace, false, true, true, window, cx); return; }; @@ -105,7 +110,7 @@ impl TabSwitcher { workspace: &mut Workspace, select_last: bool, is_global: bool, - follow_mode: bool, + is_unique: bool, window: &mut Window, cx: &mut Context, ) { @@ -144,7 +149,7 @@ impl TabSwitcher { weak_pane, weak_workspace, is_global, - follow_mode, + is_unique, window, cx, original_items, @@ -247,8 +252,7 @@ pub struct TabSwitcherDelegate { matches: Vec, original_items: Vec<(Entity, usize)>, is_all_panes: bool, - follow_mode: bool, - preview_added_item_id: Option, + is_unique: bool, restored_items: bool, } @@ -332,7 +336,7 @@ impl TabSwitcherDelegate { pane: WeakEntity, workspace: WeakEntity, is_all_panes: bool, - follow_mode: bool, + is_unique: bool, window: &mut Window, cx: &mut Context, original_items: Vec<(Entity, usize)>, @@ -347,8 +351,7 @@ impl TabSwitcherDelegate { project, matches: Vec::new(), is_all_panes, - follow_mode, - preview_added_item_id: None, + is_unique, original_items, restored_items: false, } @@ -363,20 +366,16 @@ impl TabSwitcherDelegate { return; }; cx.subscribe_in(&workspace, window, |tab_switcher, _, event, window, cx| { - tab_switcher.picker.update(cx, |picker, cx| { - // In follow mode, ignore workspace events since we manage preview state ourselves - // and don't want the list re-sorting during navigation - if picker.delegate.follow_mode { - return; - } - - match event { - WorkspaceEvent::ItemAdded { .. } | WorkspaceEvent::PaneRemoved => { + match event { + WorkspaceEvent::ItemAdded { .. } | WorkspaceEvent::PaneRemoved => { + tab_switcher.picker.update(cx, |picker, cx| { let query = picker.query(cx); picker.delegate.update_matches(query, window, cx); cx.notify(); - } - WorkspaceEvent::ItemRemoved { .. } => { + }) + } + WorkspaceEvent::ItemRemoved { .. } => { + tab_switcher.picker.update(cx, |picker, cx| { let query = picker.query(cx); picker.delegate.update_matches(query, window, cx); @@ -387,10 +386,10 @@ impl TabSwitcherDelegate { // updated to match the pane's state. picker.delegate.sync_selected_index(cx); cx.notify(); - } - _ => {} + }) } - }) + _ => {} + }; }) .detach(); } @@ -426,22 +425,10 @@ 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)); - - if self.follow_mode { - let mut seen_paths: HashSet = HashSet::default(); - all_items.retain(|tab| { - if let Some(path) = tab.item.project_path(cx) { - seen_paths.insert(path) - } else { - true - } - }); - } - all_items } else { let candidates = all_items @@ -454,7 +441,7 @@ impl TabSwitcherDelegate { )) }) .collect::>(); - let mut results: Vec = smol::block_on(fuzzy::match_strings( + smol::block_on(fuzzy::match_strings( &candidates, &query, true, @@ -465,22 +452,20 @@ impl TabSwitcherDelegate { )) .into_iter() .map(|m| all_items[m.candidate_id].clone()) - .collect(); - - if self.follow_mode { - let mut seen_paths: HashSet = HashSet::default(); - results.retain(|tab| { - if let Some(path) = tab.item.project_path(cx) { - seen_paths.insert(path) - } else { - true - } - }); - } - - results + .collect() }; + if self.is_unique { + 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); @@ -562,6 +547,7 @@ impl TabSwitcherDelegate { } if let Some(selected_item_id) = prev_selected_item_id { + // If the previously selected item is still in the list, select its new position. if let Some(item_index) = self .matches .iter() @@ -569,6 +555,7 @@ impl TabSwitcherDelegate { { return item_index; } + // Otherwise, try to preserve the previously selected index. return self.selected_index.min(self.matches.len() - 1); } @@ -579,7 +566,7 @@ impl TabSwitcherDelegate { } // This only runs when initially opening the picker - // Index 0 is already active (MRU sorted), so don't preselect it for switching. + // Index 0 is already active, so don't preselect it for switching. if self.matches.len() > 1 { self.set_selected_index(1, window, cx); return 1; @@ -598,55 +585,41 @@ impl TabSwitcherDelegate { return; }; - if self.follow_mode { - if let Some(project_path) = tab_match.item.project_path(cx) { - let Some(workspace) = self.workspace.upgrade() else { - return; - }; - // Collect panes and items to close first to avoid borrow conflicts - 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); - }); - } - } - - // Remove from matches without re-sorting (we ignore workspace events in follow mode) - self.matches.remove(ix); - self.selected_index = self.selected_index.min(self.matches.len().saturating_sub(1)); + if self.is_unique && let Some(project_path) = tab_match.item.project_path(cx) { + let Some(workspace) = self.workspace.upgrade() 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); - }); + }; + 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(); - // Remove from matches in follow mode without re-sorting - if self.follow_mode { - self.matches.remove(ix); - self.selected_index = self.selected_index.min(self.matches.len().saturating_sub(1)); + 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); + }); } } @@ -678,132 +651,73 @@ impl TabSwitcherDelegate { self.selected_index = index; } - fn confirm_follow_mode( + fn confirm_unique( &mut self, selected_match: TabMatch, window: &mut Window, cx: &mut Context>, ) { - let Some(current_pane) = self.pane.upgrade() else { + let Some(workspace) = self.workspace.upgrade() else { return; }; - if let Some(project_path) = selected_match.item.project_path(cx) { - let was_preview = selected_match.preview; - if let Some(item_id) = self.preview_added_item_id.take() { - current_pane.update(cx, |pane, cx| { - pane.close_item_by_id(item_id, SaveIntent::Skip, window, cx) - .detach_and_log_err(cx); - }); - } - - let Some(workspace) = self.workspace.upgrade() else { - return; - }; - workspace - .update(cx, |workspace, cx| { - workspace.open_path_preview( - project_path, - Some(current_pane.downgrade()), - true, - was_preview, - true, - window, - cx, - ) - }) - .detach_and_log_err(cx); - } else { - self.preview_added_item_id = None; - - // Check if source pane is the same as current pane (single pane scenario) - let source_is_current = selected_match - .pane - .upgrade() - .map(|source| source.entity_id() == current_pane.entity_id()) - .unwrap_or(false); - - if source_is_current { - // Item is already in current pane, just activate it - if let Some(index) = current_pane + let current_pane = self + .pane + .upgrade() + .filter(|pane| { + workspace .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 { - // Item in different pane - remove from source, then activate - let item_id = selected_match.item.item_id(); - - if let Some(source_pane) = selected_match.pane.upgrade() { - source_pane.update(cx, |pane, cx| { - // Allow pane to close if it becomes empty - pane.remove_item(item_id, false, true, window, cx); - }); - } - - let Some(workspace) = self.workspace.upgrade() else { - return; - }; - workspace.update(cx, |workspace, cx| { - workspace.activate_item(selected_match.item.as_ref(), true, true, window, cx); - }); - } - } - } - - fn set_selected_index_follow_mode( - &mut self, - window: &mut Window, - cx: &mut Context>, - ) { - self.restore_follow_mode_preview_state(window, cx); + .panes() + .iter() + .any(|p| p.entity_id() == pane.entity_id()) + }) + .or_else(|| selected_match.pane.upgrade()); - let Some(selected_match) = self.matches.get(self.selected_index()).cloned() else { - return; - }; - let Some(current_pane) = self.pane.upgrade() else { + let Some(current_pane) = current_pane else { return; }; - let item_in_current_pane = current_pane + if let Some(index) = current_pane .read(cx) - .index_for_item(selected_match.item.as_ref()); - - if let Some(index) = item_in_current_pane { - // No need to track state - original_items will restore on dismiss. + .index_for_item(selected_match.item.as_ref()) + { current_pane.update(cx, |pane, cx| { - pane.activate_item(index, false, false, window, 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 item = selected_match.item.boxed_clone(); - let item_id = item.item_id(); - current_pane.update(cx, |pane, cx| { - pane.add_item(item, false, false, None, window, cx); - }); - self.preview_added_item_id = Some(item_id); + 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, + ); } } - - fn restore_follow_mode_preview_state( - &mut self, - window: &mut Window, - cx: &mut Context>, - ) { - let Some(item_id) = self.preview_added_item_id.take() else { - return; - }; - let Some(current_pane) = self.pane.upgrade() else { - return; - }; - - current_pane.update(cx, |pane, cx| { - pane.close_item_by_id(item_id, SaveIntent::Skip, window, cx) - .detach_and_log_err(cx); - }); - } } impl PickerDelegate for TabSwitcherDelegate { @@ -833,9 +747,7 @@ impl PickerDelegate for TabSwitcherDelegate { ) { self.selected_index = ix; - if self.follow_mode { - self.set_selected_index_follow_mode(window, cx); - } else { + if !self.is_unique { let Some(selected_match) = self.matches.get(self.selected_index()) else { return; }; @@ -846,7 +758,7 @@ impl PickerDelegate for TabSwitcherDelegate { pane.activate_item(index, false, false, window, cx); } }) - .log_err(); + .ok(); } cx.notify(); } @@ -882,8 +794,8 @@ impl PickerDelegate for TabSwitcherDelegate { }) } - if self.follow_mode { - self.confirm_follow_mode(selected_match, window, cx); + if self.is_unique { + self.confirm_unique(selected_match, window, cx); } else { selected_match .pane @@ -892,13 +804,11 @@ impl PickerDelegate for TabSwitcherDelegate { pane.activate_item(index, true, true, window, cx); } }) - .log_err(); + .ok(); } } fn dismissed(&mut self, window: &mut Window, cx: &mut Context>) { - self.restore_follow_mode_preview_state(window, cx); - if !self.restored_items { for (pane, index) in self.original_items.iter() { pane.update(cx, |this, cx| { diff --git a/crates/tab_switcher/src/tab_switcher_tests.rs b/crates/tab_switcher/src/tab_switcher_tests.rs index 5070f58c96cb1b..a4f40cfa624da0 100644 --- a/crates/tab_switcher/src/tab_switcher_tests.rs +++ b/crates/tab_switcher/src/tab_switcher_tests.rs @@ -5,10 +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, - item::test::TestItem, -}; +use workspace::{ActivatePreviousItem, AppState, Workspace, item::test::TestItem}; #[ctor::ctor] fn init_logger() { @@ -348,26 +345,25 @@ fn assert_tab_switcher_is_closed(workspace: Entity, cx: &mut VisualTe } #[track_caller] -fn open_tab_switcher_follow_mode( +fn open_tab_switcher_unique( workspace: &Entity, cx: &mut VisualTestContext, ) -> Entity> { - cx.dispatch_action(ToggleAll { follow_mode: true }); + cx.dispatch_action(ToggleUnique); get_active_tab_switcher(workspace, cx) } #[gpui::test] -async fn test_toggle_all_follow_mode_dedupes_by_path(cx: &mut gpui::TestAppContext) { +async fn test_toggle_unique_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": "First file", - "2.txt": "Second file", + "1.txt": "", + "2.txt": "", }), ) .await; @@ -376,11 +372,9 @@ async fn test_toggle_all_follow_mode_dedupes_by_path(cx: &mut gpui::TestAppConte let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - // Open files in first pane - let _tab_1 = open_buffer("1.txt", &workspace, cx).await; - let _tab_2 = open_buffer("2.txt", &workspace, cx).await; + open_buffer("1.txt", &workspace, cx).await; + open_buffer("2.txt", &workspace, cx).await; - // Split and open same file in second pane workspace.update_in(cx, |workspace, window, cx| { workspace.split_pane( workspace.active_pane().clone(), @@ -389,98 +383,30 @@ async fn test_toggle_all_follow_mode_dedupes_by_path(cx: &mut gpui::TestAppConte cx, ); }); - let _tab_1_in_pane2 = open_buffer("1.txt", &workspace, cx).await; - - // ToggleAll with follow_mode should dedupe - show only 2 items, not 3 - let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); - tab_switcher.update(cx, |tab_switcher, _| { - assert_eq!( - tab_switcher.delegate.matches.len(), - 2, - "should dedupe same file across panes" - ); - }); -} - -#[gpui::test] -async fn test_toggle_all_follow_mode_previews_in_active_pane(cx: &mut gpui::TestAppContext) { - let app_state = init_test(cx); - - app_state - .fs - .as_fake() - .insert_tree( - path!("/root"), - json!({ - "1.txt": "First file", - "2.txt": "Second file", - }), - ) - .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)); - - let _tab_1 = open_buffer("1.txt", &workspace, cx).await; - let _tab_2 = open_buffer("2.txt", &workspace, cx).await; - - // 2.txt is active. MRU order is [2.txt, 1.txt] - let initial_active = workspace.read_with(cx, |workspace, cx| { - workspace.active_item(cx).map(|i| i.item_id()) - }); - - // Open picker - it selects index 1 (1.txt) by default and previews it - let _tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); - cx.run_until_parked(); - - // The active item should have changed to 1.txt (preview) - let previewed_active = workspace.read_with(cx, |workspace, cx| { - workspace.active_item(cx).map(|i| i.item_id()) - }); - assert_ne!( - initial_active, previewed_active, - "active item should change when picker opens (preview in active pane)" - ); + open_buffer("1.txt", &workspace, cx).await; - // Dismiss the picker - cx.dispatch_action(menu::Cancel); - cx.run_until_parked(); + let tab_switcher = open_tab_switcher_unique(&workspace, cx); - // The active item should be restored to the original (2.txt) - let restored_active = workspace.read_with(cx, |workspace, cx| { - workspace.active_item(cx).map(|i| i.item_id()) + tab_switcher.read_with(cx, |picker, _cx| { + assert_eq!(picker.delegate.matches.len(), 2, "should show 2 unique files despite 3 tabs"); }); - assert_eq!( - initial_active, restored_active, - "active item should be restored after dismiss" - ); } #[gpui::test] -async fn test_toggle_all_follow_mode_opens_in_current_pane(cx: &mut gpui::TestAppContext) { +async fn test_toggle_unique_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": "First file", - "2.txt": "Second file", - }), - ) + .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 file in first pane - let _tab_1 = open_buffer("1.txt", &workspace, cx).await; + open_buffer("1.txt", &workspace, cx).await; - // Split and open different file in second pane workspace.update_in(cx, |workspace, window, cx| { workspace.split_pane( workspace.active_pane().clone(), @@ -489,422 +415,44 @@ async fn test_toggle_all_follow_mode_opens_in_current_pane(cx: &mut gpui::TestAp cx, ); }); - let _tab_2 = open_buffer("2.txt", &workspace, cx).await; - - // Now we're in pane 2. Get a reference to it. - let current_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); - - // Open tab switcher and select 1.txt (which is in pane 1) - let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); - - // Find and select 1.txt - tab_switcher.update(cx, |picker, cx| { - for (i, m) in picker.delegate.matches.iter().enumerate() { - if m.item.tab_content_text(0, cx).contains("1.txt") { - picker.delegate.selected_index = i; - break; - } - } - }); - - // Confirm selection - cx.dispatch_action(menu::Confirm); - cx.run_until_parked(); - - // The file should now be open in the current pane (pane 2), not pane 1 - current_pane.read_with(cx, |pane, cx| { - let active_item = pane.active_item().expect("pane should have active item"); - assert!( - active_item.tab_content_text(0, cx).contains("1.txt"), - "1.txt should be open in the current pane" - ); - }); -} - -#[gpui::test] -async fn test_toggle_all_follow_mode_close_removes_from_all_panes(cx: &mut gpui::TestAppContext) { - let app_state = init_test(cx); - app_state - .fs - .as_fake() - .insert_tree( - path!("/root"), - json!({ - "1.txt": "First file", - "2.txt": "Second file", - }), - ) - .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 file in first pane - let _tab_1 = open_buffer("1.txt", &workspace, cx).await; - let _tab_2 = open_buffer("2.txt", &workspace, cx).await; - - // Split and open same file in second pane - workspace.update_in(cx, |workspace, window, cx| { - workspace.split_pane( - workspace.active_pane().clone(), - workspace::SplitDirection::Right, - window, - cx, - ); - }); - let _tab_1_pane2 = open_buffer("1.txt", &workspace, cx).await; - - // Verify 1.txt is in both panes let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec()); - assert_eq!(panes.len(), 2); - - let count_1txt_before: usize = panes - .iter() - .map(|pane| { - pane.read_with(cx, |pane, cx| { - pane.items() - .filter(|item| item.tab_content_text(0, cx).contains("1.txt")) - .count() - }) - }) - .sum(); - assert_eq!(count_1txt_before, 2, "1.txt should be in both panes"); - - // Open follow mode tab switcher - let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); - - // Find and select 1.txt - let idx = tab_switcher.read_with(cx, |picker, cx| { - picker - .delegate - .matches - .iter() - .position(|m| m.item.tab_content_text(0, cx).contains("1.txt")) - .unwrap() - }); - tab_switcher.update(cx, |picker, _| { - picker.delegate.selected_index = idx; - }); - - // Close the selected item - cx.dispatch_action(CloseSelectedItem); - cx.run_until_parked(); - - // 1.txt should be closed in ALL panes - let count_1txt_after: usize = panes - .iter() - .map(|pane| { - pane.read_with(cx, |pane, cx| { - pane.items() - .filter(|item| item.tab_content_text(0, cx).contains("1.txt")) - .count() - }) - }) - .sum(); - assert_eq!(count_1txt_after, 0, "1.txt should be closed in all panes"); - - // Verify item is removed from picker matches - let matches_count_after = tab_switcher.read_with(cx, |picker, _cx| { - picker.delegate.matches.len() - }); - assert_eq!(matches_count_after, 1, "picker should have 1 item after closing one of two"); -} - -#[gpui::test] -async fn test_toggle_all_follow_mode_close_preserves_list_order( - cx: &mut gpui::TestAppContext, -) { - let app_state = init_test(cx); - - app_state - .fs - .as_fake() - .insert_tree( - path!("/root"), - json!({ - "1.txt": "First file", - "2.txt": "Second file", - "3.txt": "Third file", - }), - ) - .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 files in MRU order: 1, 2, 3 - let _tab_1 = open_buffer("1.txt", &workspace, cx).await; - let _tab_2 = open_buffer("2.txt", &workspace, cx).await; - let _tab_3 = open_buffer("3.txt", &workspace, cx).await; - - let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); - - // Capture initial order - let initial_order = tab_switcher.read_with(cx, |picker, cx| { - picker - .delegate - .matches - .iter() - .map(|m| m.item.tab_content_text(0, cx)) - .collect::>() - }); - - assert_eq!(initial_order.len(), 3); - - // Close the middle item (index 1) - tab_switcher.update(cx, |picker, _| { - picker.delegate.selected_index = 1; - }); - cx.dispatch_action(CloseSelectedItem); - cx.run_until_parked(); - - // Verify list has 2 items and order is preserved (not re-sorted) - let final_order = tab_switcher.read_with(cx, |picker, cx| { - picker - .delegate - .matches - .iter() - .map(|m| m.item.tab_content_text(0, cx)) - .collect::>() - }); - - assert_eq!(final_order.len(), 2, "should have 2 items after closing 1"); - assert_eq!( - final_order[0], initial_order[0], - "first item should remain in same position" - ); - assert_eq!( - final_order[1], initial_order[2], - "third item should now be second (middle item removed)" - ); - - // Verify selected index adjusted correctly (stayed at 1, which is now the last item) - let selected_index = tab_switcher.read_with(cx, |picker, _cx| { - picker.delegate.selected_index - }); - assert_eq!(selected_index, 1, "selected index should be 1 (last item)"); -} - -#[gpui::test] -async fn test_toggle_all_follow_mode_close_non_file_item_updates_picker( - 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)); - - // Create two non-file items - let test_item_1 = cx.new(|cx| TestItem::new(cx).with_label("non-file-1")); - let test_item_2 = cx.new(|cx| TestItem::new(cx).with_label("non-file-2")); - - workspace.update_in(cx, |workspace, window, cx| { - workspace.add_item_to_active_pane(Box::new(test_item_1.clone()), None, true, window, cx); - workspace.add_item_to_active_pane(Box::new(test_item_2.clone()), None, true, window, cx); - }); - - let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); - - // Verify we have 2 items, MRU sorted (test_item_2 is most recent, so at index 0) - let initial_items = tab_switcher.read_with(cx, |picker, _cx| { - picker - .delegate - .matches - .iter() - .map(|m| m.item.item_id()) - .collect::>() - }); - assert_eq!(initial_items.len(), 2); - assert_eq!(initial_items[0], test_item_2.item_id(), "most recent should be first"); - - // Close the first item (test_item_2) + let tab_switcher = open_tab_switcher_unique(&workspace, cx); tab_switcher.update(cx, |picker, _| { picker.delegate.selected_index = 0; }); - cx.dispatch_action(CloseSelectedItem); - cx.run_until_parked(); - - // Verify picker list updated - let count_after = tab_switcher.read_with(cx, |picker, _cx| picker.delegate.matches.len()); - assert_eq!(count_after, 1, "picker should show 1 item after closing"); - - // Verify the remaining item is test_item_1 - let remaining_item = tab_switcher.read_with(cx, |picker, _cx| { - picker.delegate.matches.first().unwrap().item.item_id() - }); - assert_eq!(remaining_item, test_item_1.item_id()); -} - -#[gpui::test] -async fn test_toggle_all_follow_mode_single_pane_non_file_item_activates( - cx: &mut gpui::TestAppContext, -) { - let app_state = init_test(cx); - - app_state - .fs - .as_fake() - .insert_tree(path!("/root"), json!({"file.txt": "content"})) - .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 a file - let _file = open_buffer("file.txt", &workspace, cx).await; - - // Add a non-file item (terminal-like) - 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); - }); - - let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); - // File is now active (most recent) - let _file2 = open_buffer("file.txt", &workspace, cx).await; - - // Open picker and select the non-file item - let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); - let item_index = tab_switcher.read_with(cx, |picker, _cx| { - picker - .delegate - .matches - .iter() - .position(|m| m.item.item_id() == test_item.item_id()) - .expect("test item should be in matches") - }); - - tab_switcher.update_in(cx, |picker, window, cx| { - picker.delegate.set_selected_index(item_index, window, cx); - }); - - // Confirm selection cx.dispatch_action(menu::Confirm); cx.run_until_parked(); - // Verify item is still in pane and is now active - let still_in_pane = pane.read_with(cx, |pane, _| { - pane.items() - .any(|item| item.item_id() == test_item.item_id()) - }); - assert!( - still_in_pane, - "non-file item should still be in pane (not removed)" - ); - - let is_active = pane.read_with(cx, |pane, _| { + let editor_1 = panes[0].read_with(cx, |pane, cx| { pane.active_item() - .map(|item| item.item_id() == test_item.item_id()) - .unwrap_or(false) - }); - assert!(is_active, "non-file item should be active after confirm"); -} - -#[gpui::test] -async fn test_toggle_all_follow_mode_creates_independent_editors( - cx: &mut gpui::TestAppContext, -) { - let app_state = init_test(cx); - - app_state - .fs - .as_fake() - .insert_tree( - path!("/root"), - json!({ - "1.txt": "First file", - "2.txt": "Second file", - }), - ) - .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 file in first pane - let _tab_1 = open_buffer("1.txt", &workspace, cx).await; - - // Split and open different file in second pane - workspace.update_in(cx, |workspace, window, cx| { - workspace.split_pane( - workspace.active_pane().clone(), - workspace::SplitDirection::Right, - window, - cx, - ); - }); - let _tab_2 = open_buffer("2.txt", &workspace, cx).await; - - let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec()); - let pane_1 = panes[0].clone(); - let pane_2 = panes[1].clone(); - - // Open follow mode picker in pane 2 and select 1.txt from pane 1 - let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); - tab_switcher.update(cx, |picker, cx| { - for (i, m) in picker.delegate.matches.iter().enumerate() { - if m.item.tab_content_text(0, cx).contains("1.txt") { - picker.delegate.selected_index = i; - break; - } - } - }); - - // Confirm selection - cx.dispatch_action(menu::Confirm); - cx.run_until_parked(); - - // Now both panes should have 1.txt open, but as independent editor instances - // Get the editor from each pane - let editor_1 = pane_1.read_with(cx, |pane, cx| { - pane.items() - .find(|item| item.tab_content_text(0, cx).contains("1.txt")) - .expect("pane 1 should have 1.txt") - .act_as::(cx) - .expect("should be an editor") + .and_then(|item| item.act_as::(cx)) + .expect("pane 1 should have editor") }); - let editor_2 = pane_2.read_with(cx, |pane, cx| { - pane.items() - .find(|item| item.tab_content_text(0, cx).contains("1.txt")) - .expect("pane 2 should have 1.txt") - .act_as::(cx) - .expect("should be an 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") }); - // Verify they are different entity instances (not mirrored) - assert_ne!( - editor_1.entity_id(), - editor_2.entity_id(), - "editors should be independent instances, not the same entity" - ); + assert_ne!(editor_1.entity_id(), editor_2.entity_id(), "should clone to new instance"); } #[gpui::test] -async fn test_toggle_all_follow_mode_moves_non_file_item_to_current_pane( - cx: &mut gpui::TestAppContext, -) { +async fn test_toggle_unique_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)); - // Create a non-file item (TestItem with no project_items) in first pane - let test_item = cx.new(|cx| TestItem::new(cx).with_label("test-terminal")); + 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); }); - // Split and create second pane workspace.update_in(cx, |workspace, window, cx| { workspace.split_pane( workspace.active_pane().clone(), @@ -915,424 +463,46 @@ async fn test_toggle_all_follow_mode_moves_non_file_item_to_current_pane( }); let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec()); - assert_eq!(panes.len(), 2); - let pane_1 = panes[0].clone(); - let pane_2 = panes[1].clone(); - - // Verify test item is in pane 1 - let item_in_pane_1_before = pane_1.read_with(cx, |pane, _| { - pane.items() - .any(|item| item.item_id() == test_item.item_id()) - }); - assert!( - item_in_pane_1_before, - "test item should be in pane 1 initially" - ); - - // Open follow mode picker in pane 2 and select the test item - let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); - let item_index = tab_switcher.read_with(cx, |picker, _cx| { - picker - .delegate - .matches - .iter() - .position(|m| m.item.item_id() == test_item.item_id()) - .expect("test item should be in matches") - }); - tab_switcher.update_in(cx, |picker, window, cx| { - picker.delegate.set_selected_index(item_index, window, cx); + let tab_switcher = open_tab_switcher_unique(&workspace, cx); + tab_switcher.update(cx, |picker, _| { + picker.delegate.selected_index = 0; }); - // Confirm selection cx.dispatch_action(menu::Confirm); cx.run_until_parked(); - // Test item should now be in pane 2 only, not pane 1 - let item_in_pane_1_after = pane_1.read_with(cx, |pane, _| { - pane.items() - .any(|item| item.item_id() == test_item.item_id()) - }); - let item_in_pane_2_after = pane_2.read_with(cx, |pane, _| { - pane.items() - .any(|item| item.item_id() == test_item.item_id()) - }); - - assert!( - !item_in_pane_1_after, - "test item should be removed from pane 1" - ); - assert!(item_in_pane_2_after, "test item should be in pane 2"); -} - -#[gpui::test] -async fn test_toggle_all_follow_mode_non_file_item_dismiss_restores( - 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)); - - // Create a non-file item in first pane - let test_item = cx.new(|cx| TestItem::new(cx).with_label("test-terminal")); - workspace.update_in(cx, |workspace, window, cx| { - workspace.add_item_to_active_pane(Box::new(test_item.clone()), None, true, window, cx); - }); - - // Split and create second pane - 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 pane_1 = panes[0].clone(); - let pane_2 = panes[1].clone(); - - // Open follow mode picker in pane 2 and select the test item (this previews it) - let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); - let item_index = tab_switcher.read_with(cx, |picker, _cx| { - picker - .delegate - .matches - .iter() - .position(|m| m.item.item_id() == test_item.item_id()) - .expect("test item should be in matches") - }); - - tab_switcher.update_in(cx, |picker, window, cx| { - picker.delegate.set_selected_index(item_index, window, cx); - }); - cx.run_until_parked(); - - // During preview, item should be in both panes - let item_in_pane_1_preview = pane_1.read_with(cx, |pane, _| { - pane.items() - .any(|item| item.item_id() == test_item.item_id()) - }); - let item_in_pane_2_preview = pane_2.read_with(cx, |pane, _| { - pane.items() - .any(|item| item.item_id() == test_item.item_id()) - }); - - assert!( - item_in_pane_1_preview, - "test item should still be in pane 1 during preview" - ); - assert!( - item_in_pane_2_preview, - "test item should be previewed in pane 2" - ); - - // Dismiss the picker - cx.dispatch_action(menu::Cancel); - cx.run_until_parked(); - - // Item should be back to only pane 1 - let item_in_pane_1_after = pane_1.read_with(cx, |pane, _| { - pane.items() - .any(|item| item.item_id() == test_item.item_id()) - }); - let item_in_pane_2_after = pane_2.read_with(cx, |pane, _| { - pane.items() - .any(|item| item.item_id() == test_item.item_id()) - }); - assert!( - item_in_pane_1_after, - "test item should be restored to pane 1" - ); - assert!( - !item_in_pane_2_after, - "test item should be removed from pane 2" - ); -} - -#[gpui::test] -async fn test_toggle_all_follow_mode_mru_order_stable_during_navigation( - cx: &mut gpui::TestAppContext, -) { - let app_state = init_test(cx); - - app_state - .fs - .as_fake() - .insert_tree( - path!("/root"), - json!({ - "1.txt": "First file", - "2.txt": "Second file", - "3.txt": "Third file", - }), - ) - .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 files in specific MRU order: 1, 2, 3 (so 3 is most recent) - let _tab_1 = open_buffer("1.txt", &workspace, cx).await; - let _tab_2 = open_buffer("2.txt", &workspace, cx).await; - let _tab_3 = open_buffer("3.txt", &workspace, cx).await; - - // Open follow mode picker - let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); - - // Capture initial order - let initial_order = tab_switcher.read_with(cx, |picker, cx| { - picker - .delegate - .matches - .iter() - .map(|m| m.item.tab_content_text(0, cx)) - .collect::>() - }); - - // Navigate through the list (this triggers preview and ItemAdded events) - cx.dispatch_action(menu::SelectNext); - cx.run_until_parked(); - cx.dispatch_action(menu::SelectNext); - cx.run_until_parked(); - cx.dispatch_action(menu::SelectPrevious); - cx.run_until_parked(); - - // Verify order hasn't changed despite navigation/preview - let final_order = tab_switcher.read_with(cx, |picker, cx| { - picker - .delegate - .matches - .iter() - .map(|m| m.item.tab_content_text(0, cx)) - .collect::>() - }); - - assert_eq!( - initial_order, final_order, - "list order should remain stable during navigation despite workspace events" + !panes[0].read_with(cx, |pane, _| { + pane.items() + .any(|item| item.item_id() == test_item.item_id()) + }), + "should be removed from pane 1" ); - - // Verify MRU order: most recent (3.txt) should be first assert!( - initial_order[0].contains("3.txt"), - "most recently used file should be first in list" - ); -} - -#[gpui::test] -async fn test_toggle_all_follow_mode_focus_stays_in_current_pane( - cx: &mut gpui::TestAppContext, -) { - let app_state = init_test(cx); - - app_state - .fs - .as_fake() - .insert_tree( - path!("/root"), - json!({ - "1.txt": "First file", - "2.txt": "Second file", - }), - ) - .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 file in first pane - let _tab_1 = open_buffer("1.txt", &workspace, cx).await; - - // Split and open different file in second pane - workspace.update_in(cx, |workspace, window, cx| { - workspace.split_pane( - workspace.active_pane().clone(), - workspace::SplitDirection::Right, - window, - cx, - ); - }); - let _tab_2 = open_buffer("2.txt", &workspace, cx).await; - - let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec()); - let pane_2 = panes[1].clone(); - - // Verify we're in pane 2 - let initial_active_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); - assert_eq!(initial_active_pane.entity_id(), pane_2.entity_id()); - - // Open follow mode picker in pane 2 and select 1.txt from pane 1 - let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); - tab_switcher.update(cx, |picker, cx| { - for (i, m) in picker.delegate.matches.iter().enumerate() { - if m.item.tab_content_text(0, cx).contains("1.txt") { - picker.delegate.selected_index = i; - break; - } - } - }); - - // Confirm selection - cx.dispatch_action(menu::Confirm); - cx.run_until_parked(); - - // Verify focus stayed in pane 2 - let final_active_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); - assert_eq!( - final_active_pane.entity_id(), - pane_2.entity_id(), - "focus should remain in pane 2 (current pane) after confirming file from pane 1" - ); -} - -#[gpui::test] -async fn test_toggle_all_follow_mode_preserves_preview_status( - cx: &mut gpui::TestAppContext, -) { - let app_state = init_test(cx); - - app_state - .fs - .as_fake() - .insert_tree( - path!("/root"), - json!({ - "1.txt": "First file", - "2.txt": "Second file", - }), - ) - .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 1.txt as a preview item - let project_path = ProjectPath { - worktree_id: project.read_with(cx, |project, cx| { - project.worktrees(cx).next().unwrap().read(cx).id() + panes[1].read_with(cx, |pane, _| { + pane.items() + .any(|item| item.item_id() == test_item.item_id()) }), - path: rel_path("1.txt").into(), - }; - workspace - .update_in(cx, |workspace, window, cx| { - workspace.open_path_preview(project_path, None, true, true, true, window, cx) - }) - .await - .unwrap(); - - let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); - - // Verify 1.txt is a preview - let is_preview_before = pane.read_with(cx, |pane, _cx| { - pane.active_item() - .map(|item| pane.is_active_preview_item(item.item_id())) - .unwrap_or(false) - }); - assert!(is_preview_before, "1.txt should be a preview item initially"); - - // Open follow mode tab switcher and select 1.txt - let _tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); - cx.dispatch_action(menu::Confirm); - cx.run_until_parked(); - - // Verify 1.txt is still a preview - let is_preview_after = pane.read_with(cx, |pane, _cx| { - pane.active_item() - .map(|item| pane.is_active_preview_item(item.item_id())) - .unwrap_or(false) - }); - assert!( - is_preview_after, - "1.txt should still be a preview item after tab switcher confirm" + "should be moved to pane 2" ); } #[gpui::test] -async fn test_toggle_preserves_preview_status(cx: &mut gpui::TestAppContext) { +async fn test_toggle_unique_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": "First file", - "2.txt": "Second file", - }), - ) + .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 1.txt as a preview item - let project_path = ProjectPath { - worktree_id: project.read_with(cx, |project, cx| { - project.worktrees(cx).next().unwrap().read(cx).id() - }), - path: rel_path("1.txt").into(), - }; - workspace - .update_in(cx, |workspace, window, cx| { - workspace.open_path_preview(project_path, None, true, true, true, window, cx) - }) - .await - .unwrap(); - - let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + open_buffer("1.txt", &workspace, cx).await; - // Verify 1.txt is a preview - let is_preview_before = pane.read_with(cx, |pane, _cx| { - pane.active_item() - .map(|item| pane.is_active_preview_item(item.item_id())) - .unwrap_or(false) - }); - assert!(is_preview_before, "1.txt should be a preview item initially"); - - // Open regular tab switcher (non-follow mode) and confirm - let _tab_switcher = open_tab_switcher(false, &workspace, cx); - cx.dispatch_action(menu::Confirm); - cx.run_until_parked(); - - // Verify 1.txt is still a preview in non-follow mode - let is_preview_after = pane.read_with(cx, |pane, _cx| { - pane.active_item() - .map(|item| pane.is_active_preview_item(item.item_id())) - .unwrap_or(false) - }); - assert!( - is_preview_after, - "1.txt should still be a preview item after regular tab switcher confirm" - ); -} - -#[gpui::test] -async fn test_toggle_all_follow_mode_non_file_item_focus_stays_in_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)); - - // Create a non-file item in first pane - let test_item = cx.new(|cx| TestItem::new(cx).with_label("test-terminal")); - workspace.update_in(cx, |workspace, window, cx| { - workspace.add_item_to_active_pane(Box::new(test_item.clone()), None, true, window, cx); - }); - - // Split and create second pane workspace.update_in(cx, |workspace, window, cx| { workspace.split_pane( workspace.active_pane().clone(), @@ -1341,38 +511,20 @@ async fn test_toggle_all_follow_mode_non_file_item_focus_stays_in_current_pane( cx, ); }); + open_buffer("1.txt", &workspace, cx).await; let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec()); - let pane_2 = panes[1].clone(); - - // Verify we're in pane 2 - let initial_active_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); - assert_eq!(initial_active_pane.entity_id(), pane_2.entity_id()); - - // Open follow mode picker in pane 2 and select the test item from pane 1 - let tab_switcher = open_tab_switcher_follow_mode(&workspace, cx); - let item_index = tab_switcher.read_with(cx, |picker, _cx| { - picker - .delegate - .matches - .iter() - .position(|m| m.item.item_id() == test_item.item_id()) - .expect("test item should be in matches") - }); - tab_switcher.update_in(cx, |picker, window, cx| { - picker.delegate.set_selected_index(item_index, window, cx); + let tab_switcher = open_tab_switcher_unique(&workspace, cx); + tab_switcher.update(cx, |picker, _| { + picker.delegate.selected_index = 0; }); - // Confirm selection - cx.dispatch_action(menu::Confirm); + cx.dispatch_action(CloseSelectedItem); cx.run_until_parked(); - // Verify focus stayed in pane 2 - let final_active_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); - assert_eq!( - final_active_pane.entity_id(), - pane_2.entity_id(), - "focus should remain in pane 2 (current pane) after confirming non-file item from pane 1" - ); + for pane in &panes { + assert_eq!(pane.read_with(cx, |pane, _| pane.items_len()), 0, "all panes should be empty"); + } } + From 23f70a2fdd2861d99057c0bcbdbe987f81da1882 Mon Sep 17 00:00:00 2001 From: David Baldwin Date: Thu, 5 Feb 2026 08:36:23 -0500 Subject: [PATCH 3/4] Fix formatting --- crates/tab_switcher/src/tab_switcher.rs | 4 +++- crates/tab_switcher/src/tab_switcher_tests.rs | 19 +++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 09aecf8eee2e5c..1da94e76e84bfd 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -585,7 +585,9 @@ impl TabSwitcherDelegate { return; }; - if self.is_unique && let Some(project_path) = tab_match.item.project_path(cx) { + if self.is_unique + && let Some(project_path) = tab_match.item.project_path(cx) + { let Some(workspace) = self.workspace.upgrade() else { return; }; diff --git a/crates/tab_switcher/src/tab_switcher_tests.rs b/crates/tab_switcher/src/tab_switcher_tests.rs index a4f40cfa624da0..bdba0edc9060bc 100644 --- a/crates/tab_switcher/src/tab_switcher_tests.rs +++ b/crates/tab_switcher/src/tab_switcher_tests.rs @@ -388,7 +388,11 @@ async fn test_toggle_unique_deduplicates_files_by_path(cx: &mut gpui::TestAppCon let tab_switcher = open_tab_switcher_unique(&workspace, cx); tab_switcher.read_with(cx, |picker, _cx| { - assert_eq!(picker.delegate.matches.len(), 2, "should show 2 unique files despite 3 tabs"); + assert_eq!( + picker.delegate.matches.len(), + 2, + "should show 2 unique files despite 3 tabs" + ); }); } @@ -438,7 +442,11 @@ async fn test_toggle_unique_clones_files_to_current_pane(cx: &mut gpui::TestAppC .expect("pane 2 should have editor") }); - assert_ne!(editor_1.entity_id(), editor_2.entity_id(), "should clone to new instance"); + assert_ne!( + editor_1.entity_id(), + editor_2.entity_id(), + "should clone to new instance" + ); } #[gpui::test] @@ -524,7 +532,10 @@ async fn test_toggle_unique_closes_file_in_all_panes(cx: &mut gpui::TestAppConte cx.run_until_parked(); for pane in &panes { - assert_eq!(pane.read_with(cx, |pane, _| pane.items_len()), 0, "all panes should be empty"); + assert_eq!( + pane.read_with(cx, |pane, _| pane.items_len()), + 0, + "all panes should be empty" + ); } } - From 358cfd1f6d0ccf477731a9b8a9203f39f2497b17 Mon Sep 17 00:00:00 2001 From: David Baldwin Date: Thu, 5 Feb 2026 09:18:39 -0500 Subject: [PATCH 4/4] Rename to OpenInActivePane --- crates/tab_switcher/src/tab_switcher.rs | 26 +++++++++---------- crates/tab_switcher/src/tab_switcher_tests.rs | 20 +++++++------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 1da94e76e84bfd..f4a7c9d202e54f 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -47,7 +47,7 @@ actions!( ToggleAll, /// Toggles the tab switcher showing all tabs across all panes, deduplicated by path. /// Opens selected items in the active pane. - ToggleUnique, + OpenInActivePane, ] ); @@ -92,7 +92,7 @@ impl TabSwitcher { .update(cx, |picker, cx| picker.cycle_selection(window, cx)) }); }); - workspace.register_action(|workspace, _action: &ToggleUnique, 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; @@ -110,7 +110,7 @@ impl TabSwitcher { workspace: &mut Workspace, select_last: bool, is_global: bool, - is_unique: bool, + open_in_active_pane: bool, window: &mut Window, cx: &mut Context, ) { @@ -149,7 +149,7 @@ impl TabSwitcher { weak_pane, weak_workspace, is_global, - is_unique, + open_in_active_pane, window, cx, original_items, @@ -252,7 +252,7 @@ pub struct TabSwitcherDelegate { matches: Vec, original_items: Vec<(Entity, usize)>, is_all_panes: bool, - is_unique: bool, + open_in_active_pane: bool, restored_items: bool, } @@ -336,7 +336,7 @@ impl TabSwitcherDelegate { pane: WeakEntity, workspace: WeakEntity, is_all_panes: bool, - is_unique: bool, + open_in_active_pane: bool, window: &mut Window, cx: &mut Context, original_items: Vec<(Entity, usize)>, @@ -351,7 +351,7 @@ impl TabSwitcherDelegate { project, matches: Vec::new(), is_all_panes, - is_unique, + open_in_active_pane, original_items, restored_items: false, } @@ -455,7 +455,7 @@ impl TabSwitcherDelegate { .collect() }; - if self.is_unique { + 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) { @@ -585,7 +585,7 @@ impl TabSwitcherDelegate { return; }; - if self.is_unique + if self.open_in_active_pane && let Some(project_path) = tab_match.item.project_path(cx) { let Some(workspace) = self.workspace.upgrade() else { @@ -653,7 +653,7 @@ impl TabSwitcherDelegate { self.selected_index = index; } - fn confirm_unique( + fn confirm_open_in_active_pane( &mut self, selected_match: TabMatch, window: &mut Window, @@ -749,7 +749,7 @@ impl PickerDelegate for TabSwitcherDelegate { ) { self.selected_index = ix; - if !self.is_unique { + if !self.open_in_active_pane { let Some(selected_match) = self.matches.get(self.selected_index()) else { return; }; @@ -796,8 +796,8 @@ impl PickerDelegate for TabSwitcherDelegate { }) } - if self.is_unique { - self.confirm_unique(selected_match, window, cx); + if self.open_in_active_pane { + self.confirm_open_in_active_pane(selected_match, window, cx); } else { selected_match .pane diff --git a/crates/tab_switcher/src/tab_switcher_tests.rs b/crates/tab_switcher/src/tab_switcher_tests.rs index bdba0edc9060bc..a55dfb6cb7326f 100644 --- a/crates/tab_switcher/src/tab_switcher_tests.rs +++ b/crates/tab_switcher/src/tab_switcher_tests.rs @@ -345,16 +345,16 @@ fn assert_tab_switcher_is_closed(workspace: Entity, cx: &mut VisualTe } #[track_caller] -fn open_tab_switcher_unique( +fn open_tab_switcher_for_active_pane( workspace: &Entity, cx: &mut VisualTestContext, ) -> Entity> { - cx.dispatch_action(ToggleUnique); + cx.dispatch_action(OpenInActivePane); get_active_tab_switcher(workspace, cx) } #[gpui::test] -async fn test_toggle_unique_deduplicates_files_by_path(cx: &mut gpui::TestAppContext) { +async fn test_open_in_active_pane_deduplicates_files_by_path(cx: &mut gpui::TestAppContext) { let app_state = init_test(cx); app_state .fs @@ -385,7 +385,7 @@ async fn test_toggle_unique_deduplicates_files_by_path(cx: &mut gpui::TestAppCon }); open_buffer("1.txt", &workspace, cx).await; - let tab_switcher = open_tab_switcher_unique(&workspace, cx); + let tab_switcher = open_tab_switcher_for_active_pane(&workspace, cx); tab_switcher.read_with(cx, |picker, _cx| { assert_eq!( @@ -397,7 +397,7 @@ async fn test_toggle_unique_deduplicates_files_by_path(cx: &mut gpui::TestAppCon } #[gpui::test] -async fn test_toggle_unique_clones_files_to_current_pane(cx: &mut gpui::TestAppContext) { +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 @@ -422,7 +422,7 @@ async fn test_toggle_unique_clones_files_to_current_pane(cx: &mut gpui::TestAppC let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec()); - let tab_switcher = open_tab_switcher_unique(&workspace, cx); + let tab_switcher = open_tab_switcher_for_active_pane(&workspace, cx); tab_switcher.update(cx, |picker, _| { picker.delegate.selected_index = 0; }); @@ -450,7 +450,7 @@ async fn test_toggle_unique_clones_files_to_current_pane(cx: &mut gpui::TestAppC } #[gpui::test] -async fn test_toggle_unique_moves_terminals_to_current_pane(cx: &mut gpui::TestAppContext) { +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) = @@ -472,7 +472,7 @@ async fn test_toggle_unique_moves_terminals_to_current_pane(cx: &mut gpui::TestA let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec()); - let tab_switcher = open_tab_switcher_unique(&workspace, cx); + let tab_switcher = open_tab_switcher_for_active_pane(&workspace, cx); tab_switcher.update(cx, |picker, _| { picker.delegate.selected_index = 0; }); @@ -497,7 +497,7 @@ async fn test_toggle_unique_moves_terminals_to_current_pane(cx: &mut gpui::TestA } #[gpui::test] -async fn test_toggle_unique_closes_file_in_all_panes(cx: &mut gpui::TestAppContext) { +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 @@ -523,7 +523,7 @@ async fn test_toggle_unique_closes_file_in_all_panes(cx: &mut gpui::TestAppConte let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec()); - let tab_switcher = open_tab_switcher_unique(&workspace, cx); + let tab_switcher = open_tab_switcher_for_active_pane(&workspace, cx); tab_switcher.update(cx, |picker, _| { picker.delegate.selected_index = 0; });