feat(tabs): switch to recently used tabs with Control-Tab - #3061
Merged
Merged
Conversation
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
This was referenced Sep 23, 2026
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Ctrl+Tabnow goes back to the tab you used before this one, wherever it sits in the strip. HoldControland pressTabagain to walk further back through a list of the window's tabs, most recent first, and let go to switch.Ctrl+Shift+Tabstarts 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:
Cmd+Shift+[and]step the strip by position throughQueryTabManager.selectTab(offsetBy:).Ctrl+Tab. A Window menu that holds Move Tab to New Window / Merge All Windows but notselectPreviousTab:/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 aCtrl+Tabapp item:Ctrl+Tabswitched the native window tab and never reached the app item.Ctrl+Tab. Its key monitor sent every Tab chord except plainShift+Tabto Indent when more than one line was selected, soCtrl+TabandCtrl+Shift+Tabindented the query instead of reaching any menu item. That was already true for anyone who had boundCtrl+Tabto something.What changed
QueryTabManager.selectedTabIdgets adidSet, 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.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.RecentTabSwitcherControllerthen installs one local monitor forkeyDownandflagsChangedfor 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 fromNSEvent.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.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.Ctrl+Tabnow belongs to recent tabs. View > Show Tab Bar and Show All Tabs are unaffected (measured).handleTabpasses any Tab chord holding Control or Command; plain Tab and Shift+Tab behave as before.TextViewController.precedingKeyDownClaimis 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+TabandCtrl+Tabused to accept the ghost text.Ctrl+Tabfalls back to the window's own tabs when the window has no other editor tab (the connection window, andAppDelegatefor 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, soCtrl+Tabstill moves focus out of a multi-line text field there. Key equivalents are re-synced on every window becoming key, not only connection windows.noBareControlDefaultsgets a named exception for the two Control-Tab defaults only. The rule exists becauseCtrl+1/Ctrl+2switched Spaces (refactor(shortcuts)!: rewrite keyboard handling on a single menu-driven authority with hardware key codes #1556);Ctrl+Tabis not a system hotkey.Measured
"\t"+[.control, .shift]key equivalent vs a realCtrl+Shift+Tab(CGEvent, characters U+0019)BoundKeyneeded no changeCtrl+TabitemCtrl+Tabswitched the window tab, app item never firedselectPreviousTab:/selectNextTab:Ctrl+Tabmenu item, enabled or disabled, over a focused text viewselectNextKeyView: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 realCtrl+Shift+Tabposted to a running app with a main menuTabChordTestsagainst the oldhandleTabTests
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.TabChordTests(fails 6 ways against the oldhandleTab),PrecedingKeyDownClaimTests, alongsideEditorKeyChainOrderTests: 13 tests, all passing.RecentTabSwitchingUITestsholds Control withXCUIElement.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+Tabdead 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 leftCtrl+Tabunbound 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 realCtrl+Shift+Tab(a real event posted to a running app fires it; pinned bytypedChordsDispatch).Before / After
Screenshots pending. The list only shows while
Controlis 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.pngandrecent-tabs-dark.pngare 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:
After:
Not in this change