Skip to content

feat(tabs): switch to recently used tabs with Control-Tab - #3061

Merged
datlechin merged 2 commits into
mainfrom
feat/2524-recent-tab-switching
Sep 23, 2026
Merged

datlechin merged 2 commits into
mainfrom
feat/2524-recent-tab-switching

Conversation

@datlechin

Copy link
Copy Markdown
Member

Summary

Ctrl+Tab now goes back to the tab you used before this one, wherever it sits in the strip. Hold Control and press Tab again to walk further back through a list of the window's tabs, most recent first, and let go to switch. Ctrl+Shift+Tab starts at the tab used longest ago. The order covers every connection in the window. Both commands are in the Window menu and rebindable in Settings > Keyboard.

Fixes #2524

Why it could not work before

Three things stood in the way, and only the first was the missing feature:

  1. Nothing recorded use order. Cmd+Shift+[ and ] step the strip by position through QueryTabManager.selectTab(offsetBy:).
  2. AppKit already owned Ctrl+Tab. A Window menu that holds Move Tab to New Window / Merge All Windows but not selectPreviousTab: / selectNextTab: gets AppKit's own Show Previous Tab (Ctrl+Shift+Tab) and Show Next Tab (Ctrl+Tab) inserted the first time it is shown. Read from the running 0.75 app over AX: the Window menu lists Show Previous Tab and Show Next Tab twice. Measured with a probe of the same menu shape plus a Ctrl+Tab app item: Ctrl+Tab switched the native window tab and never reached the app item.
  3. The SQL editor swallowed Ctrl+Tab. Its key monitor sent every Tab chord except plain Shift+Tab to Indent when more than one line was selected, so Ctrl+Tab and Ctrl+Shift+Tab indented the query instead of reaching any menu item. That was already true for anyone who had bound Ctrl+Tab to something.

What changed

  • Recording. QueryTabManager.selectedTabId gets a didSet, so every way a tab becomes selected reaches one chokepoint with no dependency on a mounted view (a background connection's views are unparented). The stamp is a sequence number shared across tab managers, and it is taken once the main-queue turn settles, not at each write: opening a table into another connection brings that connection forward and selects the new tab in one turn, and the tab it passed through is never drawn. A selection counts only while the tab manager is frontmost, and the window decides that for every connection it hosts at once (syncFrontmostTabManager), from its own key state, the selected connection and whether that connection shows its tabs, on key changes, connection switches, phase transitions and mode changes. A restore that finishes for a background connection records nothing. The rail holds recording on a connection while it switches that connection's database, so the tab shown during the switch is not recorded either. Closed tabs are pruned.
  • Order. RecentTabOrder (pure) derives the window's order from every connected, browsing connection it hosts: the tab on screen, then used tabs newest first, then never-used tabs in rail and strip order. Nothing is stored, so a tab that closes, moves to another window or comes back from a restore can never leave a stale entry.
  • The switch. The menu item runs the first press. RecentTabSwitcherController then installs one local monitor for keyDown and flagsChanged for as long as the chord is held: Tab steps, Shift reverses, the arrows move, Return commits, Esc cancels, and releasing the held modifier commits. The release is read from the event stream, not from NSEvent.modifierFlags: the command runs inside the dispatch of the press, so the monitor is always in place before the release is dequeued. The editor's key chain hands keys to a switch in progress first, so the undefined order of same-mask monitors never decides which one gets a key. A switch chosen from the menu with the pointer, or bound to a bare function key, has no release to wait for and switches at once.
  • The list. Drawn in the window's existing floating panel (QuickSwitcherPanelController), which gains a passive mode that never becomes key. The connection window keeps key status and focus, and the title bar does not dim. Because it is the same panel, Open Quickly and the tab list close each other. The list appears after 200 ms, so a quick tap shows nothing. Each step is announced to VoiceOver.
  • Window menu. Now owns Show Previous Window Tab and Show Next Window Tab (Xcode's names for the same two-layer setup), which stops AppKit's insertion. They are rebindable and ship unbound, since Ctrl+Tab now belongs to recent tabs. View > Show Tab Bar and Show All Tabs are unaffected (measured).
  • Editor. handleTab passes any Tab chord holding Control or Command; plain Tab and Shift+Tab behave as before. TextViewController.precedingKeyDownClaim is a new link ahead of every editor link, whatever holds focus, so a switch in progress gets its Escape before the find field or Vim can. Inline AI suggestions accept only a bare Tab: Shift+Tab and Ctrl+Tab used to accept the ghost text.
  • Where there is nothing to switch. Ctrl+Tab falls back to the window's own tabs when the window has no other editor tab (the connection window, and AppDelegate for CSV documents, whose windows always tab together). In a window with neither, Settings and the connection form among them, the two key equivalents are dropped rather than left on a disabled item, so Ctrl+Tab still moves focus out of a multi-line text field there. Key equivalents are re-synced on every window becoming key, not only connection windows.
  • Strings. The five new strings are in the catalog with ko, tr, vi, zh-Hans and zh-Hant translations.
  • Hygiene test. noBareControlDefaults gets a named exception for the two Control-Tab defaults only. The rule exists because Ctrl+1/Ctrl+2 switched Spaces (refactor(shortcuts)!: rewrite keyboard handling on a single menu-driven authority with hardware key codes #1556); Ctrl+Tab is not a system hotkey.

Measured

Probe Result
"\t" + [.control, .shift] key equivalent vs a real Ctrl+Shift+Tab (CGEvent, characters U+0019) matches, so BoundKey needed no change
Window menu with AppKit's inserted items + an app Ctrl+Tab item Ctrl+Tab switched the window tab, app item never fired
Same, with the menu owning selectPreviousTab: / selectNextTab: app item fired, no insertion, View menu's tab items still present
A Ctrl+Tab menu item, enabled or disabled, over a focused text view swallows the key either way, selectNextKeyView: never runs. AppKit's inserted item does the same once the Window menu has been opened, which is why the key equivalents are dropped in windows with nothing to switch
"\t" + Control + Shift item vs a real Ctrl+Shift+Tab posted to a running app with a main menu fires (a review claimed it could not)
TabChordTests against the old handleTab fails 6 ways; passes with the fix

Tests

  • Unit: QueryTabManagerRecencyTests, RecentTabOrderTests (the issue's reproduction, built so strip order alone cannot pass it), RecentTabSwitcherSessionTests, RecentTabSwitcherKeyCommandTests, RecentTabSwitcherControllerTests, WindowMenuTabCommandsTests (including a real-event dispatch of both chords through a menu built from the settings), RecentTabMenuValidationTests, InlineSuggestionManagerCompositionTests, plus the shortcut default and count updates and the neighbouring menu, shortcut, tab manager and catalog suites. 43 suites, 405 cases, all passing locally.
  • Editor package: TabChordTests (fails 6 ways against the old handleTab), PrecedingKeyDownClaimTests, alongside EditorKeyChainOrderTests: 13 tests, all passing.
  • UI: RecentTabSwitchingUITests holds Control with XCUIElement.perform(withKeyModifiers:), taps and cycles, waits for the list and checks Escape. Not run locally: an installed TablePro was running, and XCUITest would have had to quit it. CI runs it.

Reviews

Codex review, Codex adversarial review, and a four-lens internal review whose findings were each adversarially verified. Fixed from them: frontmost state left on the first connection after a switch (the coordinator's cached flag was never seeded), transient and background selections recorded, the rail's database-switch waypoint recorded, inline suggestions swallowing Ctrl+Tab, a switch that could stay open after a release lost inside menu tracking (any mouse press, menu tracking or a key without the held modifier now ends it), Escape reaching a focused find field before the switch, Ctrl+Tab dead in window tab groups and swallowed in text fields of windows with nothing to switch, missing translations, stale Vim docs, a test that could not fail. A final Codex pass over those fixes found four more, also fixed: the yield keyed on command actions left Ctrl+Tab unbound in a connection window that finished connecting while key (it now keys on the window being a connection window), clicks landing on the passive panel were lost (it now ignores mouse events), a step taken after the highlighted tab closed skipped the next one, and a chord rebound onto Return or Escape committed or cancelled instead of stepping. Refuted by measurement: the claim that a "\t" + Control + Shift key equivalent never matches a real Ctrl+Shift+Tab (a real event posted to a running app fires it; pinned by typedChordsDispatch).

Before / After

Screenshots pending. The list only shows while Control is physically held, and the machine was in active use during this run, so driving it would have competed with the user's own keyboard. docs/images/recent-tabs.png and recent-tabs-dark.png are the standard placeholder cards until a light and dark shot of the list replaces them; the UI test waits for the list panel (recent-tab-switcher-panel) while Control is held.

Before, read from the 0.75 Window menu over AX, the two pairs:

Show Previous Tab   ⇧⌘[      (editor tabs)
Show Next Tab       ⇧⌘]
Show Previous Tab   ⌃⇧⇥      (inserted by AppKit, window tabs)
Show Next Tab       ⌃⇥

After:

Show Previous Tab            ⇧⌘[
Show Next Tab                ⇧⌘]
Switch to Recent Tab         ⌃⇥
Switch to Least Recent Tab   ⌃⇧⇥
Show Previous Window Tab     (unbound)
Show Next Window Tab         (unbound)

Not in this change

  • The order is not kept across a relaunch; after one it is the restored tab, then strip order.
  • No live preview while cycling, no closing tabs from the list, no pointer selection (holding Control turns a click into a secondary click).

@mintlify

mintlify Bot commented Sep 23, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
TablePro 🟢 Ready View Preview Sep 23, 2026, 5:45 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
@datlechin
datlechin merged commit 941fbea into main Sep 23, 2026
5 checks passed
@datlechin
datlechin deleted the feat/2524-recent-tab-switching branch September 23, 2026 08:15

This branch was successfully deployed

1 active deployment
staging - docs 04f54e5d Deployed Sep 23, 2026 by mintlify[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Switch between recently used tabs

1 participant