From 46017f9612a771e529ad22173794092e1591586f Mon Sep 17 00:00:00 2001 From: David Baldwin Date: Thu, 5 Feb 2026 15:25:20 -0500 Subject: [PATCH] Add tab switcher mode similar to vim/helix buffer picker (#47079) Implements the enhancement requested in #24655 - enables tab switcher to show all open files across all panes deduped. It's called "follow mode" because whichever is the active pane is ALWAYS where the file, terminal, etc will be opened from the tab switcher (not married to this mode name). Adds `follow_mode: true` option to `tab_switcher::ToggleAll`. When enabled: - **Shows all tabs across panes** - Same file in multiple panes appears once (deduped by path) - **Opens in active pane** - Selecting a file opens it in current pane, creating independent editor instances - **Moves terminals** - Non-file items (terminals, multibuffers) move to current pane instead of duplicating - **Preview & restore** - Navigate with arrow keys / tab to preview, Escape restores original state Intended for vim/helix `:buffers` command workflow while respecting Zed's tab-based paradigm. ### Usage ```json ["tab_switcher::ToggleAll", {"follow_mode": true}] ``` ### Implementation **Preview pattern:** Clone item handles during navigation, clean up on dismiss - **Files**: Preview with cloned handle, confirm closes preview and opens fresh (independent editors) - **Terminals**: Preview with cloned handle, confirm removes from source (moves to current pane) **Key technical details:** - Ignore workspace events during follow mode to prevent list re-sorting from preview operations - Preserve preview tab status on confirm - Close operations remove from all panes in follow mode - Remove from source before activating to ensure correct focus management ### Test Coverage 21 tests total (14 new follow mode tests): - File behavior: deduplication, independent editors, preview status preservation, close behavior - Non-file behavior: terminal movement, focus management, single pane scenarios - General: MRU stability, restore on dismiss ### Release Notes - Added `follow_mode` option to `tab_switcher::ToggleAll` for vim-style buffer list showing all deduped open files across panes opening in active pane ## Q&A **Q: Why not tie this to vim mode?** A: This feature is different enough from vim/helix buffer behavior that I opted not to couple them. It is an approximation using Zed's tab switcher/picker and would take architectural changes to implement real vim/helix buffers. Additionally, both follow mode and regular tab switcher mode are useful window/tab management workflows that vim or non-vim users may want regardless of their editing mode preference. **Q: How to use this?** A: For a vim/helix-like workflow: 1. **Bind the follow mode tab switcher** - Add to your keymap: ```json { "context": "Workspace", "bindings": { "cmd-i": ["tab_switcher::ToggleAll", {"follow_mode": true}] } } ``` 2. **Hide the tab bar (optional)** - Add to settings: ```json { "tab_bar": { "show": false } } ``` 3. **Move tabs to other pane on close (optional)** - Add to your keymap to join tabs into next pane when closing: ```json { "context": "Pane", "bindings": { "cmd-w": "pane::JoinIntoNext" } } ``` This makes closing a tab behave more like vim's split closure - items move to the adjacent pane instead of being closed. If you find that you'd rather have the tab bar visible, there's a pretty good chance you don't need/want this feature. Vim/helix don't have visible tab bars, so this feature is primarily for users who want that sort of experience where buffers are not tied to a specific split/pane and closing a split does not impact the available buffers. ## Future Work A few additional features could make for more complete vim/helix-like buffer workflow: **`:bd` (buffer delete) improvements:** - Should close the buffer from ALL panes, not just the current one - Current implementation: `workspace::CloseActiveItem` only closes in current pane - Needed: New action like `workspace::CloseActiveItemAcrossPanes` and/or update vim command mapping **`:q` (quit) improvements:** - Should close the split/pane while preserving its tabs - Behavior: merge tabs into next pane (like `pane::JoinIntoNext`), unless it's the last pane - Last pane: close the pane and all its items - Current implementation: `workspace::CloseActiveItem` closes active item, not the pane - Needed: New action or pane closure logic that moves tabs before closing These are complementary to this feature and can be done as separate PRs. Release Notes: - Add `tab_switcher::ToggleUnique` to allow a more vim-like tab switching experience. --- crates/tab_switcher/src/tab_switcher.rs | 201 +++++++++++++++--- crates/tab_switcher/src/tab_switcher_tests.rs | 198 ++++++++++++++++- 2 files changed, 366 insertions(+), 33 deletions(-) diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 85bb5fbba6ad49f556ecca9a4863972adb8666ce..f4a7c9d202e54f9239ba5d611a6c92a5b6e3bfc4 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod tab_switcher_tests; -use collections::HashMap; +use collections::{HashMap, HashSet}; use editor::items::{ entry_diagnostic_aware_icon_decoration_and_color, entry_git_aware_label_color, }; @@ -44,7 +44,10 @@ actions!( /// Closes the selected item in the tab switcher. CloseSelectedItem, /// Toggles between showing all tabs or just the current pane's tabs. - ToggleAll + ToggleAll, + /// Toggles the tab switcher showing all tabs across all panes, deduplicated by path. + /// Opens selected items in the active pane. + OpenInActivePane, ] ); @@ -67,7 +70,7 @@ impl TabSwitcher { ) { workspace.register_action(|workspace, action: &Toggle, window, cx| { let Some(tab_switcher) = workspace.active_modal::(cx) else { - Self::open(workspace, action.select_last, false, window, cx); + Self::open(workspace, action.select_last, false, false, window, cx); return; }; @@ -79,7 +82,19 @@ impl TabSwitcher { }); workspace.register_action(|workspace, _action: &ToggleAll, window, cx| { let Some(tab_switcher) = workspace.active_modal::(cx) else { - Self::open(workspace, false, true, window, cx); + Self::open(workspace, false, true, false, window, cx); + return; + }; + + tab_switcher.update(cx, |tab_switcher, cx| { + tab_switcher + .picker + .update(cx, |picker, cx| picker.cycle_selection(window, cx)) + }); + }); + workspace.register_action(|workspace, _action: &OpenInActivePane, window, cx| { + let Some(tab_switcher) = workspace.active_modal::(cx) else { + Self::open(workspace, false, true, true, window, cx); return; }; @@ -95,6 +110,7 @@ impl TabSwitcher { workspace: &mut Workspace, select_last: bool, is_global: bool, + open_in_active_pane: bool, window: &mut Window, cx: &mut Context, ) { @@ -133,6 +149,7 @@ impl TabSwitcher { weak_pane, weak_workspace, is_global, + open_in_active_pane, window, cx, original_items, @@ -235,6 +252,7 @@ pub struct TabSwitcherDelegate { matches: Vec, original_items: Vec<(Entity, usize)>, is_all_panes: bool, + open_in_active_pane: bool, restored_items: bool, } @@ -318,6 +336,7 @@ impl TabSwitcherDelegate { pane: WeakEntity, workspace: WeakEntity, is_all_panes: bool, + open_in_active_pane: bool, window: &mut Window, cx: &mut Context, original_items: Vec<(Entity, usize)>, @@ -332,6 +351,7 @@ impl TabSwitcherDelegate { project, matches: Vec::new(), is_all_panes, + open_in_active_pane, original_items, restored_items: false, } @@ -405,7 +425,7 @@ impl TabSwitcherDelegate { } } - let matches = if query.is_empty() { + let mut matches = if query.is_empty() { let history = workspace.read(cx).recently_activated_items(cx); all_items .sort_by_key(|tab| (Reverse(history.get(&tab.item.item_id())), tab.item_index)); @@ -435,6 +455,17 @@ impl TabSwitcherDelegate { .collect() }; + if self.open_in_active_pane { + let mut seen_paths: HashSet = HashSet::default(); + matches.retain(|tab| { + if let Some(path) = tab.item.project_path(cx) { + seen_paths.insert(path) + } else { + true + } + }); + } + let selected_item_id = self.selected_item_id(); self.matches = matches; self.selected_index = self.compute_selected_index(selected_item_id, window, cx); @@ -553,14 +584,45 @@ impl TabSwitcherDelegate { let Some(tab_match) = self.matches.get(ix) else { return; }; - let Some(pane) = tab_match.pane.upgrade() else { - return; - }; - pane.update(cx, |pane, cx| { - pane.close_item_by_id(tab_match.item.item_id(), SaveIntent::Close, window, cx) - .detach_and_log_err(cx); - }); + if self.open_in_active_pane + && let Some(project_path) = tab_match.item.project_path(cx) + { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + let panes_and_items: Vec<_> = workspace + .read(cx) + .panes() + .iter() + .map(|pane| { + let items_to_close: Vec<_> = pane + .read(cx) + .items() + .filter(|item| item.project_path(cx) == Some(project_path.clone())) + .map(|item| item.item_id()) + .collect(); + (pane.clone(), items_to_close) + }) + .collect(); + + for (pane, items_to_close) in panes_and_items { + for item_id in items_to_close { + pane.update(cx, |pane, cx| { + pane.close_item_by_id(item_id, SaveIntent::Close, window, cx) + .detach_and_log_err(cx); + }); + } + } + } else { + let Some(pane) = tab_match.pane.upgrade() else { + return; + }; + pane.update(cx, |pane, cx| { + pane.close_item_by_id(tab_match.item.item_id(), SaveIntent::Close, window, cx) + .detach_and_log_err(cx); + }); + } } /// Updates the selected index to ensure it matches the pane's active item, @@ -590,6 +652,74 @@ impl TabSwitcherDelegate { self.selected_index = index; } + + fn confirm_open_in_active_pane( + &mut self, + selected_match: TabMatch, + window: &mut Window, + cx: &mut Context>, + ) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + + let current_pane = self + .pane + .upgrade() + .filter(|pane| { + workspace + .read(cx) + .panes() + .iter() + .any(|p| p.entity_id() == pane.entity_id()) + }) + .or_else(|| selected_match.pane.upgrade()); + + let Some(current_pane) = current_pane else { + return; + }; + + if let Some(index) = current_pane + .read(cx) + .index_for_item(selected_match.item.as_ref()) + { + current_pane.update(cx, |pane, cx| { + pane.activate_item(index, true, true, window, cx); + }); + } else if selected_match.item.project_path(cx).is_some() + && selected_match.item.can_split(cx) + { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + let database_id = workspace.read(cx).database_id(); + let task = selected_match.item.clone_on_split(database_id, window, cx); + let current_pane = current_pane.downgrade(); + cx.spawn_in(window, async move |_, cx| { + if let Some(clone) = task.await { + current_pane + .update_in(cx, |pane, window, cx| { + pane.add_item(clone, true, true, None, window, cx); + }) + .log_err(); + } + }) + .detach(); + } else { + let Some(source_pane) = selected_match.pane.upgrade() else { + return; + }; + workspace::move_item( + &source_pane, + ¤t_pane, + selected_match.item.item_id(), + current_pane.read(cx).items_len(), + true, + window, + cx, + ); + } + } } impl PickerDelegate for TabSwitcherDelegate { @@ -619,17 +749,19 @@ impl PickerDelegate for TabSwitcherDelegate { ) { self.selected_index = ix; - let Some(selected_match) = self.matches.get(self.selected_index()) else { - return; - }; - selected_match - .pane - .update(cx, |pane, cx| { - if let Some(index) = pane.index_for_item(selected_match.item.as_ref()) { - pane.activate_item(index, false, false, window, cx); - } - }) - .ok(); + if !self.open_in_active_pane { + let Some(selected_match) = self.matches.get(self.selected_index()) else { + return; + }; + selected_match + .pane + .update(cx, |pane, cx| { + if let Some(index) = pane.index_for_item(selected_match.item.as_ref()) { + pane.activate_item(index, false, false, window, cx); + } + }) + .ok(); + } cx.notify(); } @@ -653,7 +785,7 @@ impl PickerDelegate for TabSwitcherDelegate { window: &mut Window, cx: &mut Context>, ) { - let Some(selected_match) = self.matches.get(self.selected_index()) else { + let Some(selected_match) = self.matches.get(self.selected_index()).cloned() else { return; }; @@ -663,14 +795,19 @@ impl PickerDelegate for TabSwitcherDelegate { this.activate_item(*index, false, false, window, cx); }) } - selected_match - .pane - .update(cx, |pane, cx| { - if let Some(index) = pane.index_for_item(selected_match.item.as_ref()) { - pane.activate_item(index, true, true, window, cx); - } - }) - .ok(); + + if self.open_in_active_pane { + self.confirm_open_in_active_pane(selected_match, window, cx); + } else { + selected_match + .pane + .update(cx, |pane, cx| { + if let Some(index) = pane.index_for_item(selected_match.item.as_ref()) { + pane.activate_item(index, true, true, window, cx); + } + }) + .ok(); + } } fn dismissed(&mut self, window: &mut Window, cx: &mut Context>) { diff --git a/crates/tab_switcher/src/tab_switcher_tests.rs b/crates/tab_switcher/src/tab_switcher_tests.rs index 85177f29ed8f39527cdedb991db756bd5f8d08d5..a55dfb6cb7326fae327ab6e7de39cf9c62ad4427 100644 --- a/crates/tab_switcher/src/tab_switcher_tests.rs +++ b/crates/tab_switcher/src/tab_switcher_tests.rs @@ -5,7 +5,7 @@ use menu::SelectPrevious; use project::{Project, ProjectPath}; use serde_json::json; use util::{path, rel_path::rel_path}; -use workspace::{ActivatePreviousItem, AppState, Workspace}; +use workspace::{ActivatePreviousItem, AppState, Workspace, item::test::TestItem}; #[ctor::ctor] fn init_logger() { @@ -343,3 +343,199 @@ fn assert_tab_switcher_is_closed(workspace: Entity, cx: &mut VisualTe ); }); } + +#[track_caller] +fn open_tab_switcher_for_active_pane( + workspace: &Entity, + cx: &mut VisualTestContext, +) -> Entity> { + cx.dispatch_action(OpenInActivePane); + get_active_tab_switcher(workspace, cx) +} + +#[gpui::test] +async fn test_open_in_active_pane_deduplicates_files_by_path(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "1.txt": "", + "2.txt": "", + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + open_buffer("1.txt", &workspace, cx).await; + open_buffer("2.txt", &workspace, cx).await; + + workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane( + workspace.active_pane().clone(), + workspace::SplitDirection::Right, + window, + cx, + ); + }); + open_buffer("1.txt", &workspace, cx).await; + + let tab_switcher = open_tab_switcher_for_active_pane(&workspace, cx); + + tab_switcher.read_with(cx, |picker, _cx| { + assert_eq!( + picker.delegate.matches.len(), + 2, + "should show 2 unique files despite 3 tabs" + ); + }); +} + +#[gpui::test] +async fn test_open_in_active_pane_clones_files_to_current_pane(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree(path!("/root"), json!({"1.txt": ""})) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + open_buffer("1.txt", &workspace, cx).await; + + workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane( + workspace.active_pane().clone(), + workspace::SplitDirection::Right, + window, + cx, + ); + }); + + let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec()); + + let tab_switcher = open_tab_switcher_for_active_pane(&workspace, cx); + tab_switcher.update(cx, |picker, _| { + picker.delegate.selected_index = 0; + }); + + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + + let editor_1 = panes[0].read_with(cx, |pane, cx| { + pane.active_item() + .and_then(|item| item.act_as::(cx)) + .expect("pane 1 should have editor") + }); + + let editor_2 = panes[1].read_with(cx, |pane, cx| { + pane.active_item() + .and_then(|item| item.act_as::(cx)) + .expect("pane 2 should have editor") + }); + + assert_ne!( + editor_1.entity_id(), + editor_2.entity_id(), + "should clone to new instance" + ); +} + +#[gpui::test] +async fn test_open_in_active_pane_moves_terminals_to_current_pane(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let test_item = cx.new(|cx| TestItem::new(cx).with_label("terminal")); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(test_item.clone()), None, true, window, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane( + workspace.active_pane().clone(), + workspace::SplitDirection::Right, + window, + cx, + ); + }); + + let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec()); + + let tab_switcher = open_tab_switcher_for_active_pane(&workspace, cx); + tab_switcher.update(cx, |picker, _| { + picker.delegate.selected_index = 0; + }); + + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + + assert!( + !panes[0].read_with(cx, |pane, _| { + pane.items() + .any(|item| item.item_id() == test_item.item_id()) + }), + "should be removed from pane 1" + ); + assert!( + panes[1].read_with(cx, |pane, _| { + pane.items() + .any(|item| item.item_id() == test_item.item_id()) + }), + "should be moved to pane 2" + ); +} + +#[gpui::test] +async fn test_open_in_active_pane_closes_file_in_all_panes(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree(path!("/root"), json!({"1.txt": ""})) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + open_buffer("1.txt", &workspace, cx).await; + + workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane( + workspace.active_pane().clone(), + workspace::SplitDirection::Right, + window, + cx, + ); + }); + open_buffer("1.txt", &workspace, cx).await; + + let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec()); + + let tab_switcher = open_tab_switcher_for_active_pane(&workspace, cx); + tab_switcher.update(cx, |picker, _| { + picker.delegate.selected_index = 0; + }); + + cx.dispatch_action(CloseSelectedItem); + cx.run_until_parked(); + + for pane in &panes { + assert_eq!( + pane.read_with(cx, |pane, _| pane.items_len()), + 0, + "all panes should be empty" + ); + } +}