Add tab switcher mode similar to vim/helix buffer picker (#47079)

David Baldwin created

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.

Change summary

crates/tab_switcher/src/tab_switcher.rs       | 201 +++++++++++++++++---
crates/tab_switcher/src/tab_switcher_tests.rs | 198 ++++++++++++++++++++
2 files changed, 366 insertions(+), 33 deletions(-)

Detailed changes

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::<Self>(cx) else {
-                Self::open(workspace, action.select_last, false, window, cx);
+                Self::open(workspace, action.select_last, false, false, window, cx);
                 return;
             };
 
@@ -79,7 +82,19 @@ impl TabSwitcher {
         });
         workspace.register_action(|workspace, _action: &ToggleAll, window, cx| {
             let Some(tab_switcher) = workspace.active_modal::<Self>(cx) else {
-                Self::open(workspace, false, true, window, cx);
+                Self::open(workspace, false, true, false, window, cx);
+                return;
+            };
+
+            tab_switcher.update(cx, |tab_switcher, cx| {
+                tab_switcher
+                    .picker
+                    .update(cx, |picker, cx| picker.cycle_selection(window, cx))
+            });
+        });
+        workspace.register_action(|workspace, _action: &OpenInActivePane, window, cx| {
+            let Some(tab_switcher) = workspace.active_modal::<Self>(cx) else {
+                Self::open(workspace, false, true, true, window, cx);
                 return;
             };
 
@@ -95,6 +110,7 @@ impl TabSwitcher {
         workspace: &mut Workspace,
         select_last: bool,
         is_global: bool,
+        open_in_active_pane: bool,
         window: &mut Window,
         cx: &mut Context<Workspace>,
     ) {
@@ -133,6 +149,7 @@ impl TabSwitcher {
                 weak_pane,
                 weak_workspace,
                 is_global,
+                open_in_active_pane,
                 window,
                 cx,
                 original_items,
@@ -235,6 +252,7 @@ pub struct TabSwitcherDelegate {
     matches: Vec<TabMatch>,
     original_items: Vec<(Entity<Pane>, usize)>,
     is_all_panes: bool,
+    open_in_active_pane: bool,
     restored_items: bool,
 }
 
@@ -318,6 +336,7 @@ impl TabSwitcherDelegate {
         pane: WeakEntity<Pane>,
         workspace: WeakEntity<Workspace>,
         is_all_panes: bool,
+        open_in_active_pane: bool,
         window: &mut Window,
         cx: &mut Context<TabSwitcher>,
         original_items: Vec<(Entity<Pane>, usize)>,
@@ -332,6 +351,7 @@ impl TabSwitcherDelegate {
             project,
             matches: Vec::new(),
             is_all_panes,
+            open_in_active_pane,
             original_items,
             restored_items: false,
         }
@@ -405,7 +425,7 @@ impl TabSwitcherDelegate {
             }
         }
 
-        let matches = if query.is_empty() {
+        let mut matches = if query.is_empty() {
             let history = workspace.read(cx).recently_activated_items(cx);
             all_items
                 .sort_by_key(|tab| (Reverse(history.get(&tab.item.item_id())), tab.item_index));
@@ -435,6 +455,17 @@ impl TabSwitcherDelegate {
             .collect()
         };
 
+        if self.open_in_active_pane {
+            let mut seen_paths: HashSet<project::ProjectPath> = HashSet::default();
+            matches.retain(|tab| {
+                if let Some(path) = tab.item.project_path(cx) {
+                    seen_paths.insert(path)
+                } else {
+                    true
+                }
+            });
+        }
+
         let selected_item_id = self.selected_item_id();
         self.matches = matches;
         self.selected_index = self.compute_selected_index(selected_item_id, window, cx);
@@ -553,14 +584,45 @@ impl TabSwitcherDelegate {
         let Some(tab_match) = self.matches.get(ix) else {
             return;
         };
-        let Some(pane) = tab_match.pane.upgrade() else {
-            return;
-        };
 
-        pane.update(cx, |pane, cx| {
-            pane.close_item_by_id(tab_match.item.item_id(), SaveIntent::Close, window, cx)
-                .detach_and_log_err(cx);
-        });
+        if self.open_in_active_pane
+            && let Some(project_path) = tab_match.item.project_path(cx)
+        {
+            let Some(workspace) = self.workspace.upgrade() else {
+                return;
+            };
+            let panes_and_items: Vec<_> = workspace
+                .read(cx)
+                .panes()
+                .iter()
+                .map(|pane| {
+                    let items_to_close: Vec<_> = pane
+                        .read(cx)
+                        .items()
+                        .filter(|item| item.project_path(cx) == Some(project_path.clone()))
+                        .map(|item| item.item_id())
+                        .collect();
+                    (pane.clone(), items_to_close)
+                })
+                .collect();
+
+            for (pane, items_to_close) in panes_and_items {
+                for item_id in items_to_close {
+                    pane.update(cx, |pane, cx| {
+                        pane.close_item_by_id(item_id, SaveIntent::Close, window, cx)
+                            .detach_and_log_err(cx);
+                    });
+                }
+            }
+        } else {
+            let Some(pane) = tab_match.pane.upgrade() else {
+                return;
+            };
+            pane.update(cx, |pane, cx| {
+                pane.close_item_by_id(tab_match.item.item_id(), SaveIntent::Close, window, cx)
+                    .detach_and_log_err(cx);
+            });
+        }
     }
 
     /// Updates the selected index to ensure it matches the pane's active item,
@@ -590,6 +652,74 @@ impl TabSwitcherDelegate {
 
         self.selected_index = index;
     }
+
+    fn confirm_open_in_active_pane(
+        &mut self,
+        selected_match: TabMatch,
+        window: &mut Window,
+        cx: &mut Context<Picker<TabSwitcherDelegate>>,
+    ) {
+        let Some(workspace) = self.workspace.upgrade() else {
+            return;
+        };
+
+        let current_pane = self
+            .pane
+            .upgrade()
+            .filter(|pane| {
+                workspace
+                    .read(cx)
+                    .panes()
+                    .iter()
+                    .any(|p| p.entity_id() == pane.entity_id())
+            })
+            .or_else(|| selected_match.pane.upgrade());
+
+        let Some(current_pane) = current_pane else {
+            return;
+        };
+
+        if let Some(index) = current_pane
+            .read(cx)
+            .index_for_item(selected_match.item.as_ref())
+        {
+            current_pane.update(cx, |pane, cx| {
+                pane.activate_item(index, true, true, window, cx);
+            });
+        } else if selected_match.item.project_path(cx).is_some()
+            && selected_match.item.can_split(cx)
+        {
+            let Some(workspace) = self.workspace.upgrade() else {
+                return;
+            };
+            let database_id = workspace.read(cx).database_id();
+            let task = selected_match.item.clone_on_split(database_id, window, cx);
+            let current_pane = current_pane.downgrade();
+            cx.spawn_in(window, async move |_, cx| {
+                if let Some(clone) = task.await {
+                    current_pane
+                        .update_in(cx, |pane, window, cx| {
+                            pane.add_item(clone, true, true, None, window, cx);
+                        })
+                        .log_err();
+                }
+            })
+            .detach();
+        } else {
+            let Some(source_pane) = selected_match.pane.upgrade() else {
+                return;
+            };
+            workspace::move_item(
+                &source_pane,
+                &current_pane,
+                selected_match.item.item_id(),
+                current_pane.read(cx).items_len(),
+                true,
+                window,
+                cx,
+            );
+        }
+    }
 }
 
 impl PickerDelegate for TabSwitcherDelegate {
@@ -619,17 +749,19 @@ impl PickerDelegate for TabSwitcherDelegate {
     ) {
         self.selected_index = ix;
 
-        let Some(selected_match) = self.matches.get(self.selected_index()) else {
-            return;
-        };
-        selected_match
-            .pane
-            .update(cx, |pane, cx| {
-                if let Some(index) = pane.index_for_item(selected_match.item.as_ref()) {
-                    pane.activate_item(index, false, false, window, cx);
-                }
-            })
-            .ok();
+        if !self.open_in_active_pane {
+            let Some(selected_match) = self.matches.get(self.selected_index()) else {
+                return;
+            };
+            selected_match
+                .pane
+                .update(cx, |pane, cx| {
+                    if let Some(index) = pane.index_for_item(selected_match.item.as_ref()) {
+                        pane.activate_item(index, false, false, window, cx);
+                    }
+                })
+                .ok();
+        }
         cx.notify();
     }
 
@@ -653,7 +785,7 @@ impl PickerDelegate for TabSwitcherDelegate {
         window: &mut Window,
         cx: &mut Context<Picker<TabSwitcherDelegate>>,
     ) {
-        let Some(selected_match) = self.matches.get(self.selected_index()) else {
+        let Some(selected_match) = self.matches.get(self.selected_index()).cloned() else {
             return;
         };
 
@@ -663,14 +795,19 @@ impl PickerDelegate for TabSwitcherDelegate {
                 this.activate_item(*index, false, false, window, cx);
             })
         }
-        selected_match
-            .pane
-            .update(cx, |pane, cx| {
-                if let Some(index) = pane.index_for_item(selected_match.item.as_ref()) {
-                    pane.activate_item(index, true, true, window, cx);
-                }
-            })
-            .ok();
+
+        if self.open_in_active_pane {
+            self.confirm_open_in_active_pane(selected_match, window, cx);
+        } else {
+            selected_match
+                .pane
+                .update(cx, |pane, cx| {
+                    if let Some(index) = pane.index_for_item(selected_match.item.as_ref()) {
+                        pane.activate_item(index, true, true, window, cx);
+                    }
+                })
+                .ok();
+        }
     }
 
     fn dismissed(&mut self, window: &mut Window, cx: &mut Context<Picker<TabSwitcherDelegate>>) {

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<Workspace>, cx: &mut VisualTe
         );
     });
 }
+
+#[track_caller]
+fn open_tab_switcher_for_active_pane(
+    workspace: &Entity<Workspace>,
+    cx: &mut VisualTestContext,
+) -> Entity<Picker<TabSwitcherDelegate>> {
+    cx.dispatch_action(OpenInActivePane);
+    get_active_tab_switcher(workspace, cx)
+}
+
+#[gpui::test]
+async fn test_open_in_active_pane_deduplicates_files_by_path(cx: &mut gpui::TestAppContext) {
+    let app_state = init_test(cx);
+    app_state
+        .fs
+        .as_fake()
+        .insert_tree(
+            path!("/root"),
+            json!({
+                "1.txt": "",
+                "2.txt": "",
+            }),
+        )
+        .await;
+
+    let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
+    let (workspace, cx) =
+        cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+    open_buffer("1.txt", &workspace, cx).await;
+    open_buffer("2.txt", &workspace, cx).await;
+
+    workspace.update_in(cx, |workspace, window, cx| {
+        workspace.split_pane(
+            workspace.active_pane().clone(),
+            workspace::SplitDirection::Right,
+            window,
+            cx,
+        );
+    });
+    open_buffer("1.txt", &workspace, cx).await;
+
+    let tab_switcher = open_tab_switcher_for_active_pane(&workspace, cx);
+
+    tab_switcher.read_with(cx, |picker, _cx| {
+        assert_eq!(
+            picker.delegate.matches.len(),
+            2,
+            "should show 2 unique files despite 3 tabs"
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_open_in_active_pane_clones_files_to_current_pane(cx: &mut gpui::TestAppContext) {
+    let app_state = init_test(cx);
+    app_state
+        .fs
+        .as_fake()
+        .insert_tree(path!("/root"), json!({"1.txt": ""}))
+        .await;
+
+    let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
+    let (workspace, cx) =
+        cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+    open_buffer("1.txt", &workspace, cx).await;
+
+    workspace.update_in(cx, |workspace, window, cx| {
+        workspace.split_pane(
+            workspace.active_pane().clone(),
+            workspace::SplitDirection::Right,
+            window,
+            cx,
+        );
+    });
+
+    let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec());
+
+    let tab_switcher = open_tab_switcher_for_active_pane(&workspace, cx);
+    tab_switcher.update(cx, |picker, _| {
+        picker.delegate.selected_index = 0;
+    });
+
+    cx.dispatch_action(menu::Confirm);
+    cx.run_until_parked();
+
+    let editor_1 = panes[0].read_with(cx, |pane, cx| {
+        pane.active_item()
+            .and_then(|item| item.act_as::<Editor>(cx))
+            .expect("pane 1 should have editor")
+    });
+
+    let editor_2 = panes[1].read_with(cx, |pane, cx| {
+        pane.active_item()
+            .and_then(|item| item.act_as::<Editor>(cx))
+            .expect("pane 2 should have editor")
+    });
+
+    assert_ne!(
+        editor_1.entity_id(),
+        editor_2.entity_id(),
+        "should clone to new instance"
+    );
+}
+
+#[gpui::test]
+async fn test_open_in_active_pane_moves_terminals_to_current_pane(cx: &mut gpui::TestAppContext) {
+    let app_state = init_test(cx);
+    let project = Project::test(app_state.fs.clone(), [], cx).await;
+    let (workspace, cx) =
+        cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+    let test_item = cx.new(|cx| TestItem::new(cx).with_label("terminal"));
+    workspace.update_in(cx, |workspace, window, cx| {
+        workspace.add_item_to_active_pane(Box::new(test_item.clone()), None, true, window, cx);
+    });
+
+    workspace.update_in(cx, |workspace, window, cx| {
+        workspace.split_pane(
+            workspace.active_pane().clone(),
+            workspace::SplitDirection::Right,
+            window,
+            cx,
+        );
+    });
+
+    let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec());
+
+    let tab_switcher = open_tab_switcher_for_active_pane(&workspace, cx);
+    tab_switcher.update(cx, |picker, _| {
+        picker.delegate.selected_index = 0;
+    });
+
+    cx.dispatch_action(menu::Confirm);
+    cx.run_until_parked();
+
+    assert!(
+        !panes[0].read_with(cx, |pane, _| {
+            pane.items()
+                .any(|item| item.item_id() == test_item.item_id())
+        }),
+        "should be removed from pane 1"
+    );
+    assert!(
+        panes[1].read_with(cx, |pane, _| {
+            pane.items()
+                .any(|item| item.item_id() == test_item.item_id())
+        }),
+        "should be moved to pane 2"
+    );
+}
+
+#[gpui::test]
+async fn test_open_in_active_pane_closes_file_in_all_panes(cx: &mut gpui::TestAppContext) {
+    let app_state = init_test(cx);
+    app_state
+        .fs
+        .as_fake()
+        .insert_tree(path!("/root"), json!({"1.txt": ""}))
+        .await;
+
+    let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
+    let (workspace, cx) =
+        cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+    open_buffer("1.txt", &workspace, cx).await;
+
+    workspace.update_in(cx, |workspace, window, cx| {
+        workspace.split_pane(
+            workspace.active_pane().clone(),
+            workspace::SplitDirection::Right,
+            window,
+            cx,
+        );
+    });
+    open_buffer("1.txt", &workspace, cx).await;
+
+    let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec());
+
+    let tab_switcher = open_tab_switcher_for_active_pane(&workspace, cx);
+    tab_switcher.update(cx, |picker, _| {
+        picker.delegate.selected_index = 0;
+    });
+
+    cx.dispatch_action(CloseSelectedItem);
+    cx.run_until_parked();
+
+    for pane in &panes {
+        assert_eq!(
+            pane.read_with(cx, |pane, _| pane.items_len()),
+            0,
+            "all panes should be empty"
+        );
+    }
+}