mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-25 16:02:36 +00:00
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:
@@ -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 |
|
||||
|
||||
|
||||
@@ -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> |
|
||||
|
||||
@@ -52,6 +52,8 @@ actions!(
|
||||
SwapPanePrev,
|
||||
NextTab,
|
||||
PrevTab,
|
||||
SelectNextTab,
|
||||
SelectPrevTab,
|
||||
ActivateTab1,
|
||||
ActivateTab2,
|
||||
ActivateTab3,
|
||||
|
||||
+69
-12
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1198,6 +1198,8 @@ l10n_keys! {
|
||||
CmdSwapPanePrevious,
|
||||
CmdNextTab,
|
||||
CmdPreviousTab,
|
||||
CmdRecentTabSwitcher,
|
||||
CmdRecentTabSwitcherReverse,
|
||||
CmdCopyWorkingDirectory,
|
||||
CmdCopySessionId,
|
||||
CmdCopySessionIdSubtitle,
|
||||
|
||||
@@ -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
@@ -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
@@ -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)),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user