Skip to content

Commit 972e2d1

Browse files
committed
feat(tabs): switch to recently used tabs with Control-Tab
1 parent 3f827b0 commit 972e2d1

41 files changed

Lines changed: 2634 additions & 31 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3838
- **File > Session**, with the agent session commands and the assistant's conversation commands.
3939
- Eight more rebindable commands in **Settings > Keyboard**, among them the sidebar's lists and the session commands.
4040
- **Global** on a saved query folder's menu, for a folder every connection shows.
41+
- Recent-tab switching on Control-Tab, with a list of the window's tabs while Control is held. (#2524)
4142

4243
### Changed
4344

@@ -59,6 +60,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5960
- Middle-dot separators dropped from the CSV inspector's status bar and the query history rows.
6061
- Connection marked with a tinted symbol rather than a color dot in the query history rows.
6162
- Safe Mode list offering only the levels a connection allows, with the reason under it and in the toolbar tooltip.
63+
- **Show Previous Window Tab** and **Show Next Window Tab** for window tabs, with no default shortcut.
6264

6365
### Removed
6466

@@ -321,6 +323,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
321323
- Destination folder and the first database reading as one path in the backup result sheet. (#3046)
322324
- Only the last line of a failed backup's error shown, which on `pg_dump` is the hint rather than the cause.
323325
- Backup failure reported as an exit code alone when the tool wrote its message and exited at once.
326+
- Show Previous Tab and Show Next Tab listed twice in the Window menu.
327+
- Control-Tab and Control-Shift-Tab indenting a multi-line selection in the SQL editor.
328+
- Shift-Tab and Control-Tab accepting an inline AI suggestion instead of outdenting or reaching the menu.
324329

325330
### Security
326331

Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,13 @@ extension TextViewController {
166166
setUpAppearanceChangedObserver()
167167
}
168168

169+
/// Asked before any link of any editor's chain, whichever of its views holds focus, for a key
170+
/// session the app holds open across a whole window, such as a Control-Tab still held down. A
171+
/// session with a monitor of its own would race this one, because AppKit runs same-mask local
172+
/// monitors in no defined order, and the editor's find field or Vim could take its Escape.
173+
/// Returning true claims the key.
174+
public static var precedingKeyDownClaim: (@MainActor (NSEvent) -> Bool)?
175+
169176
func setUpKeyBindings(eventMonitor: inout Any?) {
170177
eventMonitor = NSEvent.addLocalMonitorForEvents(
171178
matching: [.keyDown]
@@ -190,9 +197,11 @@ extension TextViewController {
190197
}
191198

192199
/// The chain, with the two focus questions answered by the caller so a test can drive the order
193-
/// without a key window. Links, in order: the app's coordinators, the completion list, the find
194-
/// panel, and the editor's own commands.
200+
/// without a key window. Links, in order: the app-wide preceding claim, the app's coordinators,
201+
/// the completion list, the find panel, and the editor's own commands.
195202
func claimKeyDown(_ event: NSEvent, textViewHasFocus: Bool, findPanelHasFocus: Bool) -> NSEvent? {
203+
if let precedingClaim = Self.precedingKeyDownClaim, precedingClaim(event) { return nil }
204+
196205
if textViewHasFocus {
197206
for coordinator in textCoordinators.values()
198207
where coordinator.textViewShouldClaimKeyDown(controller: self, event: event) == nil {
@@ -285,10 +294,16 @@ extension TextViewController {
285294
/// If the Shift key is pressed, it handles unindenting. If no modifier key is pressed, it checks if multiple lines
286295
/// are highlighted and handles indenting accordingly.
287296
///
297+
/// A Tab chord that holds Control or Command is never an edit. Control-Tab moves focus or switches
298+
/// tabs and Command-Tab switches apps, so both pass on to the menu bar and the key-view loop
299+
/// instead of indenting a multi-line selection.
300+
///
288301
/// - Returns: The original event if it should be passed on, or `nil` to indicate handling within the method.
289302
func handleTab(event: NSEvent, modifierFlags: UInt) -> NSEvent? {
290303
let shiftKey = NSEvent.ModifierFlags.shift.rawValue
304+
let chordKeys = NSEvent.ModifierFlags([.control, .command]).rawValue
291305

306+
guard modifierFlags & chordKeys == 0 else { return event }
292307
if modifierFlags == shiftKey {
293308
handleIndent(inwards: true)
294309
} else {
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//
2+
// PrecedingKeyDownClaimTests.swift
3+
// TableProEditorKitTests
4+
//
5+
6+
import AppKit
7+
import Carbon.HIToolbox
8+
@testable import TableProEditorKit
9+
import TableProTextEngine
10+
import Testing
11+
12+
@MainActor
13+
private final class RecordingCoordinator: TextViewCoordinator {
14+
private(set) var seenKeyCodes: [Int] = []
15+
16+
func prepareCoordinator(controller: TextViewController) { }
17+
18+
func textViewShouldClaimKeyDown(controller: TextViewController, event: NSEvent) -> NSEvent? {
19+
seenKeyCodes.append(Int(event.keyCode))
20+
return event
21+
}
22+
}
23+
24+
/// Serialized because the claim is one static for every editor in the process.
25+
@Suite("The app-wide claim runs ahead of every editor link", .serialized)
26+
@MainActor
27+
internal struct PrecedingKeyDownClaimTests {
28+
@Test("A claimed key reaches neither the coordinators nor the text")
29+
func claimedKeyStopsTheChain() throws {
30+
TextViewController.precedingKeyDownClaim = { $0.keyCode == UInt16(kVK_Tab) }
31+
defer { TextViewController.precedingKeyDownClaim = nil }
32+
let (window, editor) = Mock.focusedTextViewController(string: "SELECT 1\nFROM t")
33+
editor.setCursorPositions([CursorPosition(range: NSRange(location: 0, length: 12))])
34+
let coordinator = RecordingCoordinator()
35+
editor.textCoordinators = [WeakCoordinator(coordinator)]
36+
let tab = try #require(Mock.keyDown(keyCode: kVK_Tab, characters: "\t", in: window))
37+
38+
#expect(editor.claimKeyDown(tab, textViewHasFocus: true, findPanelHasFocus: false) == nil)
39+
#expect(coordinator.seenKeyCodes.isEmpty)
40+
#expect(editor.textView.string == "SELECT 1\nFROM t")
41+
}
42+
43+
/// The find field holds focus instead of the text view, and its own Escape closes the panel. A
44+
/// Control-Tab held open must still get that Escape first.
45+
@Test("A claimed Escape does not close a focused find panel")
46+
func claimedEscapeBeatsTheFindPanel() throws {
47+
TextViewController.precedingKeyDownClaim = { $0.keyCode == UInt16(kVK_Escape) }
48+
defer { TextViewController.precedingKeyDownClaim = nil }
49+
let (window, editor) = Mock.focusedTextViewController(string: "SELECT ")
50+
let finder = try #require(editor.findViewController)
51+
finder.showFindPanel(animated: false)
52+
defer { finder.hideFindPanel(animated: false) }
53+
let escape = try #require(Mock.keyDown(keyCode: kVK_Escape, characters: "\u{1b}", in: window))
54+
55+
#expect(editor.claimKeyDown(escape, textViewHasFocus: false, findPanelHasFocus: true) == nil)
56+
#expect(finder.viewModel.isShowingFindPanel)
57+
}
58+
59+
@Test("An unclaimed key goes down the chain as before")
60+
func unclaimedKeyContinues() throws {
61+
TextViewController.precedingKeyDownClaim = { _ in false }
62+
defer { TextViewController.precedingKeyDownClaim = nil }
63+
let (window, editor) = Mock.focusedTextViewController(string: "SELECT ")
64+
let coordinator = RecordingCoordinator()
65+
editor.textCoordinators = [WeakCoordinator(coordinator)]
66+
let escape = try #require(Mock.keyDown(keyCode: kVK_Escape, characters: "\u{1b}", in: window))
67+
68+
_ = editor.claimKeyDown(escape, textViewHasFocus: true, findPanelHasFocus: false)
69+
70+
#expect(coordinator.seenKeyCodes == [kVK_Escape])
71+
}
72+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//
2+
// TabChordTests.swift
3+
// TableProEditorKitTests
4+
//
5+
6+
import AppKit
7+
import Carbon.HIToolbox
8+
@testable import TableProEditorKit
9+
import TableProTextEngine
10+
import Testing
11+
12+
@Suite("Tab chords in the editor's key chain")
13+
@MainActor
14+
internal struct TabChordTests {
15+
nonisolated private static let text = "SELECT 1\nFROM t\nWHERE x"
16+
nonisolated private static let twoLines = NSRange(location: 0, length: 15)
17+
18+
private func press(
19+
_ modifiers: NSEvent.ModifierFlags,
20+
characters: String,
21+
selecting range: NSRange = twoLines
22+
) throws -> (claimed: Bool, text: String) {
23+
let (window, editor) = Mock.focusedTextViewController(string: Self.text)
24+
editor.setCursorPositions([CursorPosition(range: range)])
25+
let event = try #require(
26+
Mock.keyDown(keyCode: kVK_Tab, characters: characters, modifiers: modifiers, in: window)
27+
)
28+
let result = editor.claimKeyDown(event, textViewHasFocus: true, findPanelHasFocus: false)
29+
return (result == nil, editor.textView.string)
30+
}
31+
32+
/// The editor took every Tab chord but plain Shift-Tab as an indent while two lines were
33+
/// selected, so a menu command bound to Control-Tab never fired there and Control-Shift-Tab
34+
/// indented rather than outdented.
35+
@Test("Control-Tab, Control-Shift-Tab and Command-Tab pass on over a multi-line selection")
36+
func chordsPassOn() throws {
37+
let chords: [(name: String, modifiers: NSEvent.ModifierFlags, characters: String)] = [
38+
("Control-Tab", .control, "\t"),
39+
("Control-Shift-Tab", [.control, .shift], "\u{19}"),
40+
("Command-Tab", .command, "\t")
41+
]
42+
for chord in chords {
43+
let result = try press(chord.modifiers, characters: chord.characters)
44+
45+
#expect(result.claimed == false, "\(chord.name)")
46+
#expect(result.text == Self.text, "\(chord.name)")
47+
}
48+
}
49+
50+
@Test("Tab still indents a multi-line selection")
51+
func tabIndents() throws {
52+
let result = try press([], characters: "\t")
53+
54+
#expect(result.claimed)
55+
#expect(result.text != Self.text)
56+
#expect(result.text.hasPrefix(" ") || result.text.hasPrefix("\t"))
57+
}
58+
59+
@Test("Shift-Tab still outdents")
60+
func shiftTabOutdents() throws {
61+
let (window, editor) = Mock.focusedTextViewController(string: " SELECT 1\n FROM t")
62+
editor.setCursorPositions([CursorPosition(range: NSRange(location: 0, length: 20))])
63+
let event = try #require(
64+
Mock.keyDown(keyCode: kVK_Tab, characters: "\u{19}", modifiers: .shift, in: window)
65+
)
66+
67+
#expect(editor.claimKeyDown(event, textViewHasFocus: true, findPanelHasFocus: false) == nil)
68+
#expect(editor.textView.string == "SELECT 1\nFROM t")
69+
}
70+
}

TablePro/AppDelegate.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
2727
/// Installed before any window exists, so the bar is correct from the first frame.
2828
/// Nothing else owns it now that the app no longer runs a SwiftUI `App`.
2929
MainMenuBuilder.install(keyboard: AppSettingsManager.shared.keyboard)
30+
MainMenuBuilder.syncKeyEquivalentsOnKeyWindowChange()
3031
LaunchTracer.shared.mark(.menuInstalled)
3132

3233
_ = InspectorDocumentController()
@@ -82,6 +83,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
8283
WindowOpener.shared.setSettingsPresenter { SettingsWindowController.present(pane: $0) }
8384
WindowOpener.shared.setCompareSyncPresenter { CompareSyncWindowController.present(prefillSource: $0) }
8485
KeyRepeatFilter.shared.install()
86+
RecentTabSwitcherController.installEditorKeyClaim()
8587
let syncSettings = AppSettingsStorage.shared.loadSync()
8688
let passwordSyncExpected = syncSettings.enabled && syncSettings.syncConnections && syncSettings.syncPasswords
8789
AppStorageEnvironment.shared.defaults.set(passwordSyncExpected, forKey: KeychainHelper.passwordSyncEnabledKey)

TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,12 @@ final class InlineSuggestionManager {
216216
event.window === textView.window,
217217
textView.window?.firstResponder === textView else { return false }
218218

219-
guard event.keyCode == KeyCode.tab.rawValue, !textView.hasMarkedText() else {
219+
/// Only a bare Tab accepts. Control-Tab switches tabs, Command-Tab switches apps and
220+
/// Shift-Tab outdents, and each of them arriving here with ghost text on screen used to
221+
/// insert the suggestion instead.
222+
guard event.keyCode == KeyCode.tab.rawValue,
223+
event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty,
224+
!textView.hasMarkedText() else {
220225
dismissSuggestion()
221226
return false
222227
}

TablePro/Core/Menu/AppDelegate+MainMenuActions.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,21 @@ extension AppDelegate: NSMenuItemValidation {
124124
NSWorkspace.shared.open(url)
125125
}
126126

127+
/// The last stop for Control-Tab, reached from a window with no editor tabs of its own, a CSV
128+
/// document above all, whose windows always join one tab group. There the chord switches the
129+
/// window's tabs, as AppKit's own item would have.
130+
@objc func switchToRecentTab(_ sender: Any?) {
131+
NSApp.keyWindow?.selectNextTab(sender)
132+
}
133+
134+
@objc func switchToLeastRecentTab(_ sender: Any?) {
135+
NSApp.keyWindow?.selectPreviousTab(sender)
136+
}
137+
127138
public func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
128139
switch menuItem.action {
140+
case #selector(switchToRecentTab(_:)), #selector(switchToLeastRecentTab(_:)):
141+
return (NSApp.keyWindow?.tabbedWindows?.count ?? 0) > 1
129142
case #selector(checkForUpdates(_:)):
130143
/// Menu validation is the one moment AppKit gives an already-built item, and a
131144
/// deferred update has no other way to reach this title.

TablePro/Core/Menu/MainMenuBuilder.swift

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,20 +44,52 @@ enum MainMenuBuilder {
4444

4545
static func syncKeyEquivalents(keyboard: KeyboardSettings) {
4646
guard let menu = NSApp.mainMenu else { return }
47-
syncKeyEquivalents(keyboard: keyboard, actions: keyWindowCommandActions(), to: menu)
47+
let keyWindow = NSApp.keyWindow
48+
syncKeyEquivalents(
49+
keyboard: keyboard,
50+
actions: keyWindowCommandActions(),
51+
keyWindowHasTabs: keyWindow?.contentViewController is MainSplitViewController
52+
|| (keyWindow?.tabbedWindows?.count ?? 0) > 1,
53+
to: menu
54+
)
55+
}
56+
57+
/// Every window, not only a connection window, changes which key equivalents hold. Settings and
58+
/// the connection form hold no tabs of either kind, and a Control-Tab left bound there would
59+
/// swallow the chord that moves focus out of their multi-line text fields.
60+
static func syncKeyEquivalentsOnKeyWindowChange() {
61+
NotificationCenter.default.addObserver(
62+
forName: NSWindow.didBecomeKeyNotification,
63+
object: nil,
64+
queue: .main
65+
) { _ in
66+
MainActor.assumeIsolated { syncKeyEquivalents() }
67+
}
4868
}
4969

5070
/// `actions` is nil whenever the key window owns none (the welcome window, Settings,
5171
/// a window that is still connecting, or no key window at all). Nothing yields then,
5272
/// which restores every key equivalent a text field had stripped.
73+
///
74+
/// The one exception is Control-Tab. It has something to switch only in a connection window or
75+
/// in a window tab group; anywhere else it yields, because a disabled item still takes the
76+
/// chord, and in a text view Control-Tab is the way to the next control. A connection window
77+
/// counts whether or not its session is up yet: its command actions arrive with the session,
78+
/// and nothing re-syncs the menu at that moment.
5379
static func syncKeyEquivalents(
5480
keyboard: KeyboardSettings,
5581
actions: MainContentCommandActions?,
82+
keyWindowHasTabs: Bool = true,
5683
to menu: NSMenu
5784
) {
5885
MainMenuKeyEquivalentSync.applyTextInputYield(
5986
keyboard: keyboard,
60-
yields: { action, key in actions?.yieldsToFocusedTextInput(action, boundKey: key) ?? false },
87+
yields: { action, key in
88+
if action.switchesRecentTabs {
89+
return !keyWindowHasTabs
90+
}
91+
return actions?.yieldsToFocusedTextInput(action, boundKey: key) ?? false
92+
},
6193
to: menu
6294
)
6395
}

TablePro/Core/Menu/WindowMenuBuilder.swift

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,18 @@
55

66
import AppKit
77

8-
/// AppKit appends the open-window list to whichever menu is assigned to `NSApp.windowsMenu`, and
9-
/// that is all it appends. It does not contribute the window-tabbing commands: a menu built in code
10-
/// gets the window list and nothing else, measured with two windows actually in one tab group. The
11-
/// app still opts into window tabbing through `NSWindow.tabbingMode`, so the commands that go with
12-
/// it are built here. `NSWindow` implements both and validates them itself, so they dim when the
8+
/// AppKit appends the open-window list to whichever menu is assigned to `NSApp.windowsMenu`. The
9+
/// window-tabbing commands are built here because the app opts into window tabbing through
10+
/// `NSWindow.tabbingMode`, and `NSWindow` implements and validates all four, so they dim when the
1311
/// window is not part of a tab group.
12+
///
13+
/// The two that switch window tabs have to be built here too, under their own names. When a menu
14+
/// does not already hold `selectPreviousTab:` and `selectNextTab:`, AppKit inserts its own the first
15+
/// time the menu is shown, titled Show Previous Tab and Show Next Tab and bound to Control-Shift-Tab
16+
/// and Control-Tab. Measured: that put a second pair with the editor tabs' titles in this menu, and
17+
/// once inserted it took Control-Tab ahead of Switch to Recent Tab and switched the window tab
18+
/// instead. Owning both actions stops the insertion, and leaves View's Show Tab Bar and Show All
19+
/// Tabs in place. The names are Xcode's, which has both kinds of tab as well.
1420
@MainActor
1521
enum WindowMenuBuilder {
1622
static let tabNumberRange = 1...9
@@ -40,6 +46,31 @@ enum WindowMenuBuilder {
4046
shortcut: .showNextTab,
4147
keyboard: keyboard
4248
),
49+
MenuItemFactory.item(
50+
String(localized: "Switch to Recent Tab"),
51+
action: #selector(MainSplitViewController.switchToRecentTab(_:)),
52+
shortcut: .switchToRecentTab,
53+
keyboard: keyboard
54+
),
55+
MenuItemFactory.item(
56+
String(localized: "Switch to Least Recent Tab"),
57+
action: #selector(MainSplitViewController.switchToLeastRecentTab(_:)),
58+
shortcut: .switchToLeastRecentTab,
59+
keyboard: keyboard
60+
),
61+
MenuItemFactory.separator,
62+
MenuItemFactory.item(
63+
String(localized: "Show Previous Window Tab"),
64+
action: #selector(NSWindow.selectPreviousTab(_:)),
65+
shortcut: .showPreviousWindowTab,
66+
keyboard: keyboard
67+
),
68+
MenuItemFactory.item(
69+
String(localized: "Show Next Window Tab"),
70+
action: #selector(NSWindow.selectNextTab(_:)),
71+
shortcut: .showNextWindowTab,
72+
keyboard: keyboard
73+
),
4374
MenuItemFactory.item(
4475
String(localized: "Move Tab to New Window"),
4576
action: #selector(NSWindow.moveTabToNewWindow(_:))

0 commit comments

Comments
 (0)