feat(tabs): step to the next/previous tab without the switcher (#867)

Add SelectNextTab / SelectPrevTab, which move to the neighbouring tab in
the order the strip or sidebar shows them, wrapping, with no popup.
Defaults: Cmd+Shift+] / Cmd+Shift+[ on macOS, Ctrl+PgDn / Ctrl+PgUp
elsewhere. The tmux preset's prefix n / p now bind these (tmux
next-window semantics) instead of the MRU switcher, which never
auto-committed there.

NextTab / PrevTab keep their config names and Ctrl+Tab, but are labelled
Recent Tab Switcher on the Keybindings page; the palette's Next/Previous
Tab entries now show the SelectNextTab chord they actually run.
This commit is contained in:
l0ng-ai
2026-09-23 15:20:34 +08:00
parent d534c085b9
commit 962fd87ea5
11 changed files with 136 additions and 30 deletions
+2 -2
View File
@@ -45,7 +45,7 @@ the panel tabs. They are all in the command palette, and all bindable here.
```json
{
"keybindings": {
"NextTab": "cmd-shift-]",
"NextTab": "ctrl-alt-]",
"SplitRight": ["cmd-d"],
"ResizePaneLeft": "ctrl-alt-left",
"ToggleFullscreen": ""
@@ -57,7 +57,7 @@ An action's value takes one of two shapes:
| Value | Means |
|---|---|
| `"cmd-shift-]"` | **Add** this shortcut. The default keeps working — <kbd>⌃ ⇥</kbd> still switches tabs |
| `"ctrl-alt-]"` | **Add** this shortcut. The default keeps working — <kbd>⌃ ⇥</kbd> still opens the tab switcher |
| `["cmd-d"]` | **Replace**: exactly these shortcuts, nothing else. List several to have several |
| `""` or `[]` | **Unbind** the action |
+2 -1
View File
@@ -13,7 +13,8 @@ it shows what *your* copy is bound to. This is the shipped default.
| New Tab | <kbd>⌘ T</kbd> | <kbd>Ctrl ⇧ T</kbd> |
| Close Pane / Tab | <kbd>⌘ W</kbd> | <kbd>Ctrl ⇧ W</kbd> |
| Reopen Closed Tab | <kbd>⌘ ⇧ T</kbd> | <kbd>Alt ⇧ T</kbd> |
| Next Tab · Previous Tab | <kbd>⌃ ⇥</kbd> · <kbd>⌃ ⇧ ⇥</kbd> | same |
| Next Tab · Previous Tab | <kbd>⌘ ⇧ ]</kbd> · <kbd>⌘ ⇧ [</kbd> | <kbd>Ctrl PgDn</kbd> · <kbd>Ctrl PgUp</kbd> |
| Recent Tab Switcher (hold to browse, release to pick) | <kbd>⌃ ⇥</kbd> · <kbd>⌃ ⇧ ⇥</kbd> | same |
| Go to Tab 1–9 | <kbd>⌘ 1</kbd>…<kbd>⌘ 9</kbd> | <kbd>Alt 1</kbd>…<kbd>Alt 9</kbd> |
| New Workspace | <kbd>⌘ ⇧ N</kbd> | <kbd>Ctrl ⇧ N</kbd> |
| Switch Workspace | <kbd>⌘ ⇧ O</kbd> | <kbd>Ctrl ⇧ O</kbd> |
+2
View File
@@ -52,6 +52,8 @@ actions!(
SwapPanePrev,
NextTab,
PrevTab,
SelectNextTab,
SelectPrevTab,
ActivateTab1,
ActivateTab2,
ActivateTab3,
+69 -12
View File
@@ -4660,17 +4660,14 @@ impl Tty7App {
mru_order(&stamps, self.active)
}
/// The neighbouring tab in the order the strip shows them, wrapping at
/// the ends — no switcher, no MRU (#867). "Shows" matters with the
/// sidebar grouping tabs: the next row is not always the next index.
fn cycle_tab(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
let n = self.tabs.len();
if n < 2 {
return;
let order = self.visual_tab_order(cx);
if let Some(next) = step_in_order(&order, self.active, forward) {
self.activate(next, window, cx);
}
let next = if forward {
(self.active + 1) % n
} else {
(self.active + n - 1) % n
};
self.activate(next, window, cx);
}
pub(crate) fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
@@ -5545,8 +5542,8 @@ impl Tty7App {
ResizePaneDown => self.resize_pane(Dir::Down, window, cx),
SwapPaneNext => self.swap_pane(true, window, cx),
SwapPanePrev => self.swap_pane(false, window, cx),
NextTab => self.cycle_tab(true, window, cx),
PrevTab => self.cycle_tab(false, window, cx),
SelectNextTab => self.cycle_tab(true, window, cx),
SelectPrevTab => self.cycle_tab(false, window, cx),
ToggleMaximizePane => self.toggle_maximize(window, cx),
ToggleFullscreen => self.toggle_fullscreen(window, cx),
ToggleTabSidebar => self.toggle_tab_sidebar(cx),
@@ -8556,6 +8553,12 @@ impl Render for Tty7App {
.on_action(
cx.listener(|this, _: &PrevTab, window, cx| this.tab_switch(false, window, cx)),
)
.on_action(cx.listener(|this, _: &SelectNextTab, window, cx| {
this.cycle_tab(true, window, cx)
}))
.on_action(cx.listener(|this, _: &SelectPrevTab, window, cx| {
this.cycle_tab(false, window, cx)
}))
.on_action(cx.listener(|this, _: &ActivateTab1, window, cx| {
this.activate_visual(0, window, cx)
}))
@@ -8806,6 +8809,24 @@ impl Render for Tty7App {
/// A zero stamp means the tab was never activated, and those keep strip order
/// at the back. `active` leads regardless — its own stamp only lands on the
/// next frame.
/// The tab after (or before) `active` in `order`, wrapping round. `None`
/// when there is nowhere else to go; a tab missing from `order` starts from
/// its end, so the step still lands on a tab the user can see.
fn step_in_order(order: &[usize], active: usize, forward: bool) -> Option<usize> {
let n = order.len();
if n < 2 {
return None;
}
let pos = order.iter().position(|&i| i == active);
let next = match (pos, forward) {
(Some(p), true) => (p + 1) % n,
(Some(p), false) => (p + n - 1) % n,
(None, true) => 0,
(None, false) => n - 1,
};
Some(order[next]).filter(|&i| i != active)
}
fn mru_order(stamps: &[u64], active: usize) -> Vec<usize> {
let mut order: Vec<usize> = (0..stamps.len()).collect();
order.sort_by_key(|&i| (stamps[i] == 0, std::cmp::Reverse(stamps[i]), i));
@@ -9880,7 +9901,7 @@ mod tests {
TabAgentSession, clear_window_override_values, close_prompt, document_column_px,
join_shell_args, leaf_shares_the_window_daemon, mru_order, pane_free_for,
parse_ssh_connect_input, parse_ssh_option_words, rename_outcome, side_panel_max,
split_shell_args, strip_band, wd_path_saveable,
split_shell_args, step_in_order, strip_band, wd_path_saveable,
};
use gpui::{Edges, point, px, size};
@@ -10222,6 +10243,42 @@ mod tests {
assert!(mru_order(&[], 0).is_empty());
}
#[test]
fn stepping_through_tabs_follows_the_strip_and_wraps() {
let order = [0, 1, 2];
assert_eq!(step_in_order(&order, 0, true), Some(1));
assert_eq!(step_in_order(&order, 2, true), Some(0));
assert_eq!(step_in_order(&order, 0, false), Some(2));
assert_eq!(step_in_order(&order, 1, false), Some(0));
// Pressing it again keeps going — this is not the MRU switcher, which
// bounces between the last two tabs (#867).
let mut at = 0;
let seen: Vec<usize> = (0..4)
.map(|_| {
at = step_in_order(&order, at, true).unwrap();
at
})
.collect();
assert_eq!(seen, vec![1, 2, 0, 1]);
}
#[test]
fn stepping_follows_the_grouped_sidebar_order_not_the_index() {
// Sidebar groups reorder the rows: index 2 is shown second.
let order = [0, 2, 1, 3];
assert_eq!(step_in_order(&order, 0, true), Some(2));
assert_eq!(step_in_order(&order, 2, true), Some(1));
assert_eq!(step_in_order(&order, 3, true), Some(0));
assert_eq!(step_in_order(&order, 0, false), Some(3));
}
#[test]
fn stepping_has_nowhere_to_go_with_one_tab() {
assert_eq!(step_in_order(&[], 0, true), None);
assert_eq!(step_in_order(&[0], 0, true), None);
assert_eq!(step_in_order(&[0], 0, false), None);
}
#[test]
fn restore_only_attaches_panes_the_workspace_owns_or_nobody_claims() {
let ours = crate::core::session::WorkspaceId::new();
+2
View File
@@ -1482,6 +1482,8 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::CmdSwapPanePrevious => "Swap Pane Previous",
L10nKey::CmdNextTab => "Next Tab",
L10nKey::CmdPreviousTab => "Previous Tab",
L10nKey::CmdRecentTabSwitcher => "Recent Tab Switcher",
L10nKey::CmdRecentTabSwitcherReverse => "Recent Tab Switcher (Reverse)",
L10nKey::CmdCopyWorkingDirectory => "Copy Working Directory",
L10nKey::CmdCopySessionId => "Copy Session ID",
L10nKey::CmdCopySessionIdSubtitle => "the coding agent's own session id",
+2
View File
@@ -1546,6 +1546,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdSwapPanePrevious => "前のペインと入れ替え",
L10nKey::CmdNextTab => "次のタブ",
L10nKey::CmdPreviousTab => "前のタブ",
L10nKey::CmdRecentTabSwitcher => "最近のタブを切り替える",
L10nKey::CmdRecentTabSwitcherReverse => "最近のタブを切り替える(逆順)",
L10nKey::CmdCopyWorkingDirectory => "作業ディレクトリをコピー",
L10nKey::CmdCopySessionId => "セッション ID をコピー",
L10nKey::CmdCopySessionIdSubtitle => "コーディングエージェント自身のセッション ID",
+2
View File
@@ -1198,6 +1198,8 @@ l10n_keys! {
CmdSwapPanePrevious,
CmdNextTab,
CmdPreviousTab,
CmdRecentTabSwitcher,
CmdRecentTabSwitcherReverse,
CmdCopyWorkingDirectory,
CmdCopySessionId,
CmdCopySessionIdSubtitle,
+2
View File
@@ -1391,6 +1391,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdSwapPanePrevious => "与上一窗格交换",
L10nKey::CmdNextTab => "下一标签页",
L10nKey::CmdPreviousTab => "上一标签页",
L10nKey::CmdRecentTabSwitcher => "最近标签页切换器",
L10nKey::CmdRecentTabSwitcherReverse => "最近标签页切换器(反向)",
L10nKey::CmdCopyWorkingDirectory => "复制工作目录",
L10nKey::CmdCopySessionId => "复制会话 ID",
L10nKey::CmdCopySessionIdSubtitle => "编码 agent 自身的会话 ID",
+42 -6
View File
@@ -370,8 +370,22 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
("ResizePaneDown", ""),
("SwapPaneNext", ""),
("SwapPanePrev", ""),
// Ctrl+Tab opens the most-recently-used switcher (a quick tap lands
// on the tab you were just in); these step to the neighbouring tab
// in strip order with no popup (#867). ⌘⇧] / ⌘⇧[ is iTerm2's and
// Safari's chord. Off macOS Ctrl+Shift+] is FocusNextPane, so they
// take Ctrl+PgDn / Ctrl+PgUp, the chord GNOME Terminal, xfce4-terminal
// and every browser use for the same thing.
("NextTab", "ctrl-tab"),
("PrevTab", "ctrl-shift-tab"),
(
"SelectNextTab",
per_platform("secondary-shift-]", "ctrl-pagedown"),
),
(
"SelectPrevTab",
per_platform("secondary-shift-[", "ctrl-pageup"),
),
("ActivateTab1", per_platform("secondary-1", "alt-1")),
("ActivateTab2", per_platform("secondary-2", "alt-2")),
("ActivateTab3", per_platform("secondary-3", "alt-3")),
@@ -654,8 +668,20 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
CommandGroup::TabsPanes,
t(L10nKey::CmdSwapPanePrevious).to_string(),
),
"NextTab" => (CommandGroup::TabsPanes, t(L10nKey::CmdNextTab).to_string()),
// `NextTab` / `PrevTab` keep their names — `Config::keybindings` is
// keyed by them — but what they do is open the MRU switcher, so that
// is what the page calls them. "Next Tab" belongs to the action that
// actually goes to the next tab, the same one the palette runs.
"NextTab" => (
CommandGroup::TabsPanes,
t(L10nKey::CmdRecentTabSwitcher).to_string(),
),
"PrevTab" => (
CommandGroup::TabsPanes,
t(L10nKey::CmdRecentTabSwitcherReverse).to_string(),
),
"SelectNextTab" => (CommandGroup::TabsPanes, t(L10nKey::CmdNextTab).to_string()),
"SelectPrevTab" => (
CommandGroup::TabsPanes,
t(L10nKey::CmdPreviousTab).to_string(),
),
@@ -1018,8 +1044,11 @@ fn tmux_preset(prefix: &str) -> Vec<(String, String)> {
("ToggleMaximizePane", p("z")),
("FocusNextPane", p("o")),
("FocusPrevPane", p(";")),
("NextTab", p("n")),
("PrevTab", p("p")),
// tmux's `next-window` / `previous-window`: straight to the
// neighbour, no chooser. The MRU switcher it used to open never
// committed here — the modifier it waits on is not held (#867).
("SelectNextTab", p("n")),
("SelectPrevTab", p("p")),
("ActivateTab1", p("1")),
("ActivateTab2", p("2")),
("ActivateTab3", p("3")),
@@ -1340,6 +1369,8 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
"SwapPanePrev" => KeyBinding::new(keystroke, SwapPanePrev, None),
"NextTab" => KeyBinding::new(keystroke, NextTab, None),
"PrevTab" => KeyBinding::new(keystroke, PrevTab, None),
"SelectNextTab" => KeyBinding::new(keystroke, SelectNextTab, None),
"SelectPrevTab" => KeyBinding::new(keystroke, SelectPrevTab, None),
"ActivateTab1" => KeyBinding::new(keystroke, ActivateTab1, None),
"ActivateTab2" => KeyBinding::new(keystroke, ActivateTab2, None),
"ActivateTab3" => KeyBinding::new(keystroke, ActivateTab3, None),
@@ -2519,17 +2550,22 @@ mod gpui_tests {
running_on_json(
cx,
r#"{"keybinding_preset": "tmux",
"keybindings": {"NextTab": "ctrl-alt-]", "SplitRight": ["ctrl-alt-d"]}}"#,
"keybindings": {"SelectNextTab": "ctrl-alt-]", "SplitRight": ["ctrl-alt-d"]}}"#,
);
// The preset is a scheme, and it still replaces the default chord.
assert!(fired(cx, "ctrl-tab").is_empty());
assert!(fired(cx, per_platform("secondary-shift-]", "ctrl-pagedown")).is_empty());
// A chord added on top of it is added to the preset's chord.
assert_eq!(
fired(cx, "ctrl-b n").first(),
Some(&NextTab::name_for_type())
Some(&SelectNextTab::name_for_type())
);
assert_eq!(
fired(cx, "ctrl-alt-]").first(),
Some(&SelectNextTab::name_for_type())
);
// The MRU switcher the preset no longer takes over keeps Ctrl+Tab.
assert_eq!(
fired(cx, "ctrl-tab").first(),
Some(&NextTab::name_for_type())
);
// A list replaces the preset's chord the way it replaces a default.
+10 -8
View File
@@ -48,8 +48,8 @@ pub enum CommandKind {
ResizePaneDown,
SwapPaneNext,
SwapPanePrev,
NextTab,
PrevTab,
SelectNextTab,
SelectPrevTab,
ToggleMaximizePane,
ToggleFullscreen,
ToggleTabSidebar,
@@ -154,8 +154,8 @@ impl CommandKind {
ResizePaneDown => "resize-pane-down",
SwapPaneNext => "swap-pane-next",
SwapPanePrev => "swap-pane-prev",
NextTab => "next-tab",
PrevTab => "prev-tab",
SelectNextTab => "next-tab",
SelectPrevTab => "prev-tab",
ToggleMaximizePane => "zoom-pane",
ToggleFullscreen => "full-screen",
ToggleTabSidebar => "tab-bar-position",
@@ -264,8 +264,10 @@ impl CommandKind {
ResizePaneDown => "ResizePaneDown",
SwapPaneNext => "SwapPaneNext",
SwapPanePrev => "SwapPanePrev",
NextTab => "NextTab",
PrevTab => "PrevTab",
// What the palette runs is the plain next/previous step, not the
// MRU switcher `NextTab` opens, so its chord hint is that one's.
SelectNextTab => "SelectNextTab",
SelectPrevTab => "SelectPrevTab",
ToggleMaximizePane => "ToggleMaximizePane",
ToggleFullscreen => "ToggleFullscreen",
ToggleTabSidebar => "ToggleTabSidebar",
@@ -458,8 +460,8 @@ impl Command {
Command::localized(L10nKey::CmdResizePaneDown, ResizePaneDown),
Command::localized(L10nKey::CmdSwapPaneNext, SwapPaneNext),
Command::localized(L10nKey::CmdSwapPanePrevious, SwapPanePrev),
Command::localized(L10nKey::CmdNextTab, NextTab),
Command::localized(L10nKey::CmdPreviousTab, PrevTab),
Command::localized(L10nKey::CmdNextTab, SelectNextTab),
Command::localized(L10nKey::CmdPreviousTab, SelectPrevTab),
Command::localized(L10nKey::CmdCopyWorkingDirectory, CopyWorkingDirectory),
Command::localized(L10nKey::CmdCopySessionId, CopyAgentSessionId)
.with_subtitle(t(L10nKey::CmdCopySessionIdSubtitle)),
+1 -1
View File
@@ -1902,7 +1902,7 @@ impl Tty7App {
.collect()
}
fn visual_tab_order(&self, cx: &gpui::App) -> Vec<usize> {
pub(crate) fn visual_tab_order(&self, cx: &gpui::App) -> Vec<usize> {
if cx.global::<Config>().tab_bar_position != crate::core::config::TabBarPosition::Left {
return (0..self.tabs.len()).collect();
}