From 972e2d1ef181de7d5d46925ac6542b3ffb4b17a4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 23 Sep 2026 12:44:29 +0700 Subject: [PATCH] feat(tabs): switch to recently used tabs with Control-Tab --- CHANGELOG.md | 5 + .../TextViewController+Lifecycle.swift | 19 +- .../PrecedingKeyDownClaimTests.swift | 72 +++++ .../Controller/TabChordTests.swift | 70 ++++ TablePro/AppDelegate.swift | 2 + .../InlineSuggestionManager.swift | 7 +- .../Menu/AppDelegate+MainMenuActions.swift | 13 + TablePro/Core/Menu/MainMenuBuilder.swift | 36 ++- TablePro/Core/Menu/WindowMenuBuilder.swift | 41 ++- TablePro/Core/RecentTabs/RecentTabOrder.swift | 58 ++++ .../RecentTabSwitcherController.swift | 306 ++++++++++++++++++ .../RecentTabSwitcherKeyCommand.swift | 69 ++++ .../RecentTabs/RecentTabSwitcherSession.swift | 97 ++++++ .../MainSplitViewController+ContentMode.swift | 1 + ...inSplitViewController+MenuValidation.swift | 12 + .../MainSplitViewController+RecentTabs.swift | 203 ++++++++++++ .../MainSplitViewController.swift | 10 +- .../Infrastructure/TabWindowController.swift | 2 + .../WorkspaceRailViewController.swift | 2 + TablePro/Models/Query/QueryTabManager.swift | 73 ++++- .../Models/UI/KeyboardShortcutModels.swift | 21 +- TablePro/Resources/Localizable.xcstrings | 170 ++++++++++ .../QuickSwitcher/QuickSwitcherPanel.swift | 53 ++- ...herPanelController+RecentTabSwitcher.swift | 34 ++ .../RecentTabSwitcherRow.swift | 53 +++ .../RecentTabSwitcherView.swift | 51 +++ ...ineSuggestionManagerCompositionTests.swift | 28 +- .../Menu/WindowMenuTabCommandsTests.swift | 197 +++++++++++ .../Core/RecentTabs/RecentTabOrderTests.swift | 118 +++++++ .../RecentTabSwitcherControllerTests.swift | 282 ++++++++++++++++ .../RecentTabSwitcherSessionTests.swift | 201 ++++++++++++ .../Models/KeyboardShortcutTests.swift | 20 +- .../Query/QueryTabManagerRecencyTests.swift | 184 +++++++++++ .../Models/ShortcutUniquenessTests.swift | 13 +- .../RecentTabSwitchingUITests.swift | 120 +++++++ docs/features/keyboard-shortcuts.mdx | 3 + docs/features/tabs.mdx | 15 +- docs/features/vim-mode.mdx | 2 +- docs/features/workspace-rail.mdx | 2 +- docs/images/recent-tabs-dark.png | Bin 0 -> 22823 bytes docs/images/recent-tabs.png | Bin 0 -> 22869 bytes 41 files changed, 2634 insertions(+), 31 deletions(-) create mode 100644 Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/PrecedingKeyDownClaimTests.swift create mode 100644 Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TabChordTests.swift create mode 100644 TablePro/Core/RecentTabs/RecentTabOrder.swift create mode 100644 TablePro/Core/RecentTabs/RecentTabSwitcherController.swift create mode 100644 TablePro/Core/RecentTabs/RecentTabSwitcherKeyCommand.swift create mode 100644 TablePro/Core/RecentTabs/RecentTabSwitcherSession.swift create mode 100644 TablePro/Core/Services/Infrastructure/MainSplitViewController+RecentTabs.swift create mode 100644 TablePro/Views/RecentTabSwitcher/QuickSwitcherPanelController+RecentTabSwitcher.swift create mode 100644 TablePro/Views/RecentTabSwitcher/RecentTabSwitcherRow.swift create mode 100644 TablePro/Views/RecentTabSwitcher/RecentTabSwitcherView.swift create mode 100644 TableProTests/Core/Menu/WindowMenuTabCommandsTests.swift create mode 100644 TableProTests/Core/RecentTabs/RecentTabOrderTests.swift create mode 100644 TableProTests/Core/RecentTabs/RecentTabSwitcherControllerTests.swift create mode 100644 TableProTests/Core/RecentTabs/RecentTabSwitcherSessionTests.swift create mode 100644 TableProTests/Models/Query/QueryTabManagerRecencyTests.swift create mode 100644 TableProUITests/RecentTabSwitchingUITests.swift create mode 100644 docs/images/recent-tabs-dark.png create mode 100644 docs/images/recent-tabs.png diff --git a/CHANGELOG.md b/CHANGELOG.md index e2e438bd59..d14e6afb48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **File > Session**, with the agent session commands and the assistant's conversation commands. - Eight more rebindable commands in **Settings > Keyboard**, among them the sidebar's lists and the session commands. - **Global** on a saved query folder's menu, for a folder every connection shows. +- Recent-tab switching on Control-Tab, with a list of the window's tabs while Control is held. (#2524) ### Changed @@ -59,6 +60,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Middle-dot separators dropped from the CSV inspector's status bar and the query history rows. - Connection marked with a tinted symbol rather than a color dot in the query history rows. - Safe Mode list offering only the levels a connection allows, with the reason under it and in the toolbar tooltip. +- **Show Previous Window Tab** and **Show Next Window Tab** for window tabs, with no default shortcut. ### Removed @@ -321,6 +323,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Destination folder and the first database reading as one path in the backup result sheet. (#3046) - Only the last line of a failed backup's error shown, which on `pg_dump` is the hint rather than the cause. - Backup failure reported as an exit code alone when the tool wrote its message and exited at once. +- Show Previous Tab and Show Next Tab listed twice in the Window menu. +- Control-Tab and Control-Shift-Tab indenting a multi-line selection in the SQL editor. +- Shift-Tab and Control-Tab accepting an inline AI suggestion instead of outdenting or reaching the menu. ### Security diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift b/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift index 110d9c4a61..7967e3a3bb 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift @@ -166,6 +166,13 @@ extension TextViewController { setUpAppearanceChangedObserver() } + /// Asked before any link of any editor's chain, whichever of its views holds focus, for a key + /// session the app holds open across a whole window, such as a Control-Tab still held down. A + /// session with a monitor of its own would race this one, because AppKit runs same-mask local + /// monitors in no defined order, and the editor's find field or Vim could take its Escape. + /// Returning true claims the key. + public static var precedingKeyDownClaim: (@MainActor (NSEvent) -> Bool)? + func setUpKeyBindings(eventMonitor: inout Any?) { eventMonitor = NSEvent.addLocalMonitorForEvents( matching: [.keyDown] @@ -190,9 +197,11 @@ extension TextViewController { } /// The chain, with the two focus questions answered by the caller so a test can drive the order - /// without a key window. Links, in order: the app's coordinators, the completion list, the find - /// panel, and the editor's own commands. + /// without a key window. Links, in order: the app-wide preceding claim, the app's coordinators, + /// the completion list, the find panel, and the editor's own commands. func claimKeyDown(_ event: NSEvent, textViewHasFocus: Bool, findPanelHasFocus: Bool) -> NSEvent? { + if let precedingClaim = Self.precedingKeyDownClaim, precedingClaim(event) { return nil } + if textViewHasFocus { for coordinator in textCoordinators.values() where coordinator.textViewShouldClaimKeyDown(controller: self, event: event) == nil { @@ -285,10 +294,16 @@ extension TextViewController { /// If the Shift key is pressed, it handles unindenting. If no modifier key is pressed, it checks if multiple lines /// are highlighted and handles indenting accordingly. /// + /// A Tab chord that holds Control or Command is never an edit. Control-Tab moves focus or switches + /// tabs and Command-Tab switches apps, so both pass on to the menu bar and the key-view loop + /// instead of indenting a multi-line selection. + /// /// - Returns: The original event if it should be passed on, or `nil` to indicate handling within the method. func handleTab(event: NSEvent, modifierFlags: UInt) -> NSEvent? { let shiftKey = NSEvent.ModifierFlags.shift.rawValue + let chordKeys = NSEvent.ModifierFlags([.control, .command]).rawValue + guard modifierFlags & chordKeys == 0 else { return event } if modifierFlags == shiftKey { handleIndent(inwards: true) } else { diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/PrecedingKeyDownClaimTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/PrecedingKeyDownClaimTests.swift new file mode 100644 index 0000000000..4f37a06afb --- /dev/null +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/PrecedingKeyDownClaimTests.swift @@ -0,0 +1,72 @@ +// +// PrecedingKeyDownClaimTests.swift +// TableProEditorKitTests +// + +import AppKit +import Carbon.HIToolbox +@testable import TableProEditorKit +import TableProTextEngine +import Testing + +@MainActor +private final class RecordingCoordinator: TextViewCoordinator { + private(set) var seenKeyCodes: [Int] = [] + + func prepareCoordinator(controller: TextViewController) { } + + func textViewShouldClaimKeyDown(controller: TextViewController, event: NSEvent) -> NSEvent? { + seenKeyCodes.append(Int(event.keyCode)) + return event + } +} + +/// Serialized because the claim is one static for every editor in the process. +@Suite("The app-wide claim runs ahead of every editor link", .serialized) +@MainActor +internal struct PrecedingKeyDownClaimTests { + @Test("A claimed key reaches neither the coordinators nor the text") + func claimedKeyStopsTheChain() throws { + TextViewController.precedingKeyDownClaim = { $0.keyCode == UInt16(kVK_Tab) } + defer { TextViewController.precedingKeyDownClaim = nil } + let (window, editor) = Mock.focusedTextViewController(string: "SELECT 1\nFROM t") + editor.setCursorPositions([CursorPosition(range: NSRange(location: 0, length: 12))]) + let coordinator = RecordingCoordinator() + editor.textCoordinators = [WeakCoordinator(coordinator)] + let tab = try #require(Mock.keyDown(keyCode: kVK_Tab, characters: "\t", in: window)) + + #expect(editor.claimKeyDown(tab, textViewHasFocus: true, findPanelHasFocus: false) == nil) + #expect(coordinator.seenKeyCodes.isEmpty) + #expect(editor.textView.string == "SELECT 1\nFROM t") + } + + /// The find field holds focus instead of the text view, and its own Escape closes the panel. A + /// Control-Tab held open must still get that Escape first. + @Test("A claimed Escape does not close a focused find panel") + func claimedEscapeBeatsTheFindPanel() throws { + TextViewController.precedingKeyDownClaim = { $0.keyCode == UInt16(kVK_Escape) } + defer { TextViewController.precedingKeyDownClaim = nil } + let (window, editor) = Mock.focusedTextViewController(string: "SELECT ") + let finder = try #require(editor.findViewController) + finder.showFindPanel(animated: false) + defer { finder.hideFindPanel(animated: false) } + let escape = try #require(Mock.keyDown(keyCode: kVK_Escape, characters: "\u{1b}", in: window)) + + #expect(editor.claimKeyDown(escape, textViewHasFocus: false, findPanelHasFocus: true) == nil) + #expect(finder.viewModel.isShowingFindPanel) + } + + @Test("An unclaimed key goes down the chain as before") + func unclaimedKeyContinues() throws { + TextViewController.precedingKeyDownClaim = { _ in false } + defer { TextViewController.precedingKeyDownClaim = nil } + let (window, editor) = Mock.focusedTextViewController(string: "SELECT ") + let coordinator = RecordingCoordinator() + editor.textCoordinators = [WeakCoordinator(coordinator)] + let escape = try #require(Mock.keyDown(keyCode: kVK_Escape, characters: "\u{1b}", in: window)) + + _ = editor.claimKeyDown(escape, textViewHasFocus: true, findPanelHasFocus: false) + + #expect(coordinator.seenKeyCodes == [kVK_Escape]) + } +} diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TabChordTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TabChordTests.swift new file mode 100644 index 0000000000..495186c95d --- /dev/null +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TabChordTests.swift @@ -0,0 +1,70 @@ +// +// TabChordTests.swift +// TableProEditorKitTests +// + +import AppKit +import Carbon.HIToolbox +@testable import TableProEditorKit +import TableProTextEngine +import Testing + +@Suite("Tab chords in the editor's key chain") +@MainActor +internal struct TabChordTests { + nonisolated private static let text = "SELECT 1\nFROM t\nWHERE x" + nonisolated private static let twoLines = NSRange(location: 0, length: 15) + + private func press( + _ modifiers: NSEvent.ModifierFlags, + characters: String, + selecting range: NSRange = twoLines + ) throws -> (claimed: Bool, text: String) { + let (window, editor) = Mock.focusedTextViewController(string: Self.text) + editor.setCursorPositions([CursorPosition(range: range)]) + let event = try #require( + Mock.keyDown(keyCode: kVK_Tab, characters: characters, modifiers: modifiers, in: window) + ) + let result = editor.claimKeyDown(event, textViewHasFocus: true, findPanelHasFocus: false) + return (result == nil, editor.textView.string) + } + + /// The editor took every Tab chord but plain Shift-Tab as an indent while two lines were + /// selected, so a menu command bound to Control-Tab never fired there and Control-Shift-Tab + /// indented rather than outdented. + @Test("Control-Tab, Control-Shift-Tab and Command-Tab pass on over a multi-line selection") + func chordsPassOn() throws { + let chords: [(name: String, modifiers: NSEvent.ModifierFlags, characters: String)] = [ + ("Control-Tab", .control, "\t"), + ("Control-Shift-Tab", [.control, .shift], "\u{19}"), + ("Command-Tab", .command, "\t") + ] + for chord in chords { + let result = try press(chord.modifiers, characters: chord.characters) + + #expect(result.claimed == false, "\(chord.name)") + #expect(result.text == Self.text, "\(chord.name)") + } + } + + @Test("Tab still indents a multi-line selection") + func tabIndents() throws { + let result = try press([], characters: "\t") + + #expect(result.claimed) + #expect(result.text != Self.text) + #expect(result.text.hasPrefix(" ") || result.text.hasPrefix("\t")) + } + + @Test("Shift-Tab still outdents") + func shiftTabOutdents() throws { + let (window, editor) = Mock.focusedTextViewController(string: " SELECT 1\n FROM t") + editor.setCursorPositions([CursorPosition(range: NSRange(location: 0, length: 20))]) + let event = try #require( + Mock.keyDown(keyCode: kVK_Tab, characters: "\u{19}", modifiers: .shift, in: window) + ) + + #expect(editor.claimKeyDown(event, textViewHasFocus: true, findPanelHasFocus: false) == nil) + #expect(editor.textView.string == "SELECT 1\nFROM t") + } +} diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index c56976b67b..cf7733c4ce 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -27,6 +27,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { /// Installed before any window exists, so the bar is correct from the first frame. /// Nothing else owns it now that the app no longer runs a SwiftUI `App`. MainMenuBuilder.install(keyboard: AppSettingsManager.shared.keyboard) + MainMenuBuilder.syncKeyEquivalentsOnKeyWindowChange() LaunchTracer.shared.mark(.menuInstalled) _ = InspectorDocumentController() @@ -82,6 +83,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { WindowOpener.shared.setSettingsPresenter { SettingsWindowController.present(pane: $0) } WindowOpener.shared.setCompareSyncPresenter { CompareSyncWindowController.present(prefillSource: $0) } KeyRepeatFilter.shared.install() + RecentTabSwitcherController.installEditorKeyClaim() let syncSettings = AppSettingsStorage.shared.loadSync() let passwordSyncExpected = syncSettings.enabled && syncSettings.syncConnections && syncSettings.syncPasswords AppStorageEnvironment.shared.defaults.set(passwordSyncExpected, forKey: KeychainHelper.passwordSyncEnabledKey) diff --git a/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift index 808c11678d..ed8b1fe569 100644 --- a/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift +++ b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift @@ -216,7 +216,12 @@ final class InlineSuggestionManager { event.window === textView.window, textView.window?.firstResponder === textView else { return false } - guard event.keyCode == KeyCode.tab.rawValue, !textView.hasMarkedText() else { + /// Only a bare Tab accepts. Control-Tab switches tabs, Command-Tab switches apps and + /// Shift-Tab outdents, and each of them arriving here with ghost text on screen used to + /// insert the suggestion instead. + guard event.keyCode == KeyCode.tab.rawValue, + event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty, + !textView.hasMarkedText() else { dismissSuggestion() return false } diff --git a/TablePro/Core/Menu/AppDelegate+MainMenuActions.swift b/TablePro/Core/Menu/AppDelegate+MainMenuActions.swift index f6c31ddce1..f7b37cb947 100644 --- a/TablePro/Core/Menu/AppDelegate+MainMenuActions.swift +++ b/TablePro/Core/Menu/AppDelegate+MainMenuActions.swift @@ -124,8 +124,21 @@ extension AppDelegate: NSMenuItemValidation { NSWorkspace.shared.open(url) } + /// The last stop for Control-Tab, reached from a window with no editor tabs of its own, a CSV + /// document above all, whose windows always join one tab group. There the chord switches the + /// window's tabs, as AppKit's own item would have. + @objc func switchToRecentTab(_ sender: Any?) { + NSApp.keyWindow?.selectNextTab(sender) + } + + @objc func switchToLeastRecentTab(_ sender: Any?) { + NSApp.keyWindow?.selectPreviousTab(sender) + } + public func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { switch menuItem.action { + case #selector(switchToRecentTab(_:)), #selector(switchToLeastRecentTab(_:)): + return (NSApp.keyWindow?.tabbedWindows?.count ?? 0) > 1 case #selector(checkForUpdates(_:)): /// Menu validation is the one moment AppKit gives an already-built item, and a /// deferred update has no other way to reach this title. diff --git a/TablePro/Core/Menu/MainMenuBuilder.swift b/TablePro/Core/Menu/MainMenuBuilder.swift index 715f44484e..478285622e 100644 --- a/TablePro/Core/Menu/MainMenuBuilder.swift +++ b/TablePro/Core/Menu/MainMenuBuilder.swift @@ -44,20 +44,52 @@ enum MainMenuBuilder { static func syncKeyEquivalents(keyboard: KeyboardSettings) { guard let menu = NSApp.mainMenu else { return } - syncKeyEquivalents(keyboard: keyboard, actions: keyWindowCommandActions(), to: menu) + let keyWindow = NSApp.keyWindow + syncKeyEquivalents( + keyboard: keyboard, + actions: keyWindowCommandActions(), + keyWindowHasTabs: keyWindow?.contentViewController is MainSplitViewController + || (keyWindow?.tabbedWindows?.count ?? 0) > 1, + to: menu + ) + } + + /// Every window, not only a connection window, changes which key equivalents hold. Settings and + /// the connection form hold no tabs of either kind, and a Control-Tab left bound there would + /// swallow the chord that moves focus out of their multi-line text fields. + static func syncKeyEquivalentsOnKeyWindowChange() { + NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: nil, + queue: .main + ) { _ in + MainActor.assumeIsolated { syncKeyEquivalents() } + } } /// `actions` is nil whenever the key window owns none (the welcome window, Settings, /// a window that is still connecting, or no key window at all). Nothing yields then, /// which restores every key equivalent a text field had stripped. + /// + /// The one exception is Control-Tab. It has something to switch only in a connection window or + /// in a window tab group; anywhere else it yields, because a disabled item still takes the + /// chord, and in a text view Control-Tab is the way to the next control. A connection window + /// counts whether or not its session is up yet: its command actions arrive with the session, + /// and nothing re-syncs the menu at that moment. static func syncKeyEquivalents( keyboard: KeyboardSettings, actions: MainContentCommandActions?, + keyWindowHasTabs: Bool = true, to menu: NSMenu ) { MainMenuKeyEquivalentSync.applyTextInputYield( keyboard: keyboard, - yields: { action, key in actions?.yieldsToFocusedTextInput(action, boundKey: key) ?? false }, + yields: { action, key in + if action.switchesRecentTabs { + return !keyWindowHasTabs + } + return actions?.yieldsToFocusedTextInput(action, boundKey: key) ?? false + }, to: menu ) } diff --git a/TablePro/Core/Menu/WindowMenuBuilder.swift b/TablePro/Core/Menu/WindowMenuBuilder.swift index 7a8ed9de31..ba1e58a527 100644 --- a/TablePro/Core/Menu/WindowMenuBuilder.swift +++ b/TablePro/Core/Menu/WindowMenuBuilder.swift @@ -5,12 +5,18 @@ import AppKit -/// AppKit appends the open-window list to whichever menu is assigned to `NSApp.windowsMenu`, and -/// that is all it appends. It does not contribute the window-tabbing commands: a menu built in code -/// gets the window list and nothing else, measured with two windows actually in one tab group. The -/// app still opts into window tabbing through `NSWindow.tabbingMode`, so the commands that go with -/// it are built here. `NSWindow` implements both and validates them itself, so they dim when the +/// AppKit appends the open-window list to whichever menu is assigned to `NSApp.windowsMenu`. The +/// window-tabbing commands are built here because the app opts into window tabbing through +/// `NSWindow.tabbingMode`, and `NSWindow` implements and validates all four, so they dim when the /// window is not part of a tab group. +/// +/// The two that switch window tabs have to be built here too, under their own names. When a menu +/// does not already hold `selectPreviousTab:` and `selectNextTab:`, AppKit inserts its own the first +/// time the menu is shown, titled Show Previous Tab and Show Next Tab and bound to Control-Shift-Tab +/// and Control-Tab. Measured: that put a second pair with the editor tabs' titles in this menu, and +/// once inserted it took Control-Tab ahead of Switch to Recent Tab and switched the window tab +/// instead. Owning both actions stops the insertion, and leaves View's Show Tab Bar and Show All +/// Tabs in place. The names are Xcode's, which has both kinds of tab as well. @MainActor enum WindowMenuBuilder { static let tabNumberRange = 1...9 @@ -40,6 +46,31 @@ enum WindowMenuBuilder { shortcut: .showNextTab, keyboard: keyboard ), + MenuItemFactory.item( + String(localized: "Switch to Recent Tab"), + action: #selector(MainSplitViewController.switchToRecentTab(_:)), + shortcut: .switchToRecentTab, + keyboard: keyboard + ), + MenuItemFactory.item( + String(localized: "Switch to Least Recent Tab"), + action: #selector(MainSplitViewController.switchToLeastRecentTab(_:)), + shortcut: .switchToLeastRecentTab, + keyboard: keyboard + ), + MenuItemFactory.separator, + MenuItemFactory.item( + String(localized: "Show Previous Window Tab"), + action: #selector(NSWindow.selectPreviousTab(_:)), + shortcut: .showPreviousWindowTab, + keyboard: keyboard + ), + MenuItemFactory.item( + String(localized: "Show Next Window Tab"), + action: #selector(NSWindow.selectNextTab(_:)), + shortcut: .showNextWindowTab, + keyboard: keyboard + ), MenuItemFactory.item( String(localized: "Move Tab to New Window"), action: #selector(NSWindow.moveTabToNewWindow(_:)) diff --git a/TablePro/Core/RecentTabs/RecentTabOrder.swift b/TablePro/Core/RecentTabs/RecentTabOrder.swift new file mode 100644 index 0000000000..9b55dd8233 --- /dev/null +++ b/TablePro/Core/RecentTabs/RecentTabOrder.swift @@ -0,0 +1,58 @@ +// +// RecentTabOrder.swift +// TablePro +// + +import Foundation + +/// One editor tab in a window that may host several connections. A tab id alone is not enough, +/// because committing a switch has to know which connection to bring on screen first. +internal struct RecentTabReference: Hashable { + internal let connectionId: UUID + internal let tabId: UUID +} + +/// What one connection in the window contributes: its tabs in strip order and when each was last +/// selected. +internal struct RecentTabSource: Equatable { + internal let connectionId: UUID + internal let tabIds: [UUID] + internal let activationSequence: [UUID: UInt64] +} + +/// The window's tabs in the order they were last used, which is what Control-Tab walks. +/// +/// Derived on demand from each tab manager's activation record rather than kept as a list of its +/// own, so a tab that closes, moves to another window or arrives from a restore can never leave a +/// stale entry behind: whatever is open is exactly what is ordered. +internal enum RecentTabOrder { + /// The tab on screen always leads, whatever its sequence says. A connection restored in the + /// background selects its tab after the one the window shows, and without this the first + /// press would land on a tab the user never looked at. + /// + /// A tab never selected since it opened has no sequence, and follows the used ones in the order + /// the rail and the strips show them. + internal static func order(sources: [RecentTabSource], current: RecentTabReference?) -> [RecentTabReference] { + var used: [(reference: RecentTabReference, sequence: UInt64)] = [] + var unused: [RecentTabReference] = [] + + for source in sources { + for tabId in source.tabIds { + let reference = RecentTabReference(connectionId: source.connectionId, tabId: tabId) + guard let sequence = source.activationSequence[tabId] else { + unused.append(reference) + continue + } + used.append((reference, sequence)) + } + } + + let ordered = used.sorted { $0.sequence > $1.sequence }.map(\.reference) + unused + guard let current, let index = ordered.firstIndex(of: current), index > 0 else { return ordered } + + var reordered = ordered + reordered.remove(at: index) + reordered.insert(current, at: 0) + return reordered + } +} diff --git a/TablePro/Core/RecentTabs/RecentTabSwitcherController.swift b/TablePro/Core/RecentTabs/RecentTabSwitcherController.swift new file mode 100644 index 0000000000..b848764cd2 --- /dev/null +++ b/TablePro/Core/RecentTabs/RecentTabSwitcherController.swift @@ -0,0 +1,306 @@ +// +// RecentTabSwitcherController.swift +// TablePro +// + +import AppKit +import Combine +import os +import TableProEditorKit + +/// What the switcher's rows read. Published so the list follows every step while it is on screen. +@MainActor +internal final class RecentTabSwitcherModel: ObservableObject { + @Published internal private(set) var session: RecentTabSwitcherSession + + internal init(session: RecentTabSwitcherSession) { + self.session = session + } + + internal func update(_ session: RecentTabSwitcherSession) { + self.session = session + } +} + +/// Where the switcher's list is drawn. The window's floating panel in the app, nothing in a test. +@MainActor +internal protocol RecentTabSwitcherPresenting: AnyObject { + func presentRecentTabSwitcher(_ model: RecentTabSwitcherModel, over window: NSWindow?, onClose: @escaping () -> Void) + func dismissRecentTabSwitcher() +} + +/// Runs one Control-Tab: the first press arrives through the menu, and everything after it, the +/// repeats, Shift, Escape and the release that commits, arrives here while the chord is held. +/// +/// The menu cannot carry the rest. A key equivalent fires on a press and never on a release, so +/// committing when the modifier comes up needs `flagsChanged`, which only an event monitor sees. +/// The monitor lives exactly as long as the switch, and the editor's own key chain asks +/// `claimKeyDown(_:)` first, so the order AppKit runs same-mask monitors in never decides which of +/// the two gets a key. +@MainActor +internal final class RecentTabSwitcherController { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "RecentTabSwitcher") + + /// Long enough that a tap to the previous tab shows nothing, the way Firefox (200 ms) and Zed + /// (300 ms) hold theirs back. + internal static let defaultPickerDelay: TimeInterval = 0.2 + + private static weak var activeController: RecentTabSwitcherController? + + private weak var presenter: RecentTabSwitcherPresenting? + private let pickerDelay: TimeInterval + private let bindings: @MainActor () -> (forward: BoundKey?, backward: BoundKey?) + private let announce: (String) -> Void + + private var model: RecentTabSwitcherModel? + private var heldModifiers: NSEvent.ModifierFlags = [] + private var isOpen: ((RecentTabReference) -> Bool)? + private var onCommit: ((RecentTabReference) -> Void)? + private weak var hostWindow: NSWindow? + private var eventMonitor: Any? + private var observers: [any NSObjectProtocol] = [] + private var pickerWorkItem: DispatchWorkItem? + + internal init( + presenter: RecentTabSwitcherPresenting?, + pickerDelay: TimeInterval = defaultPickerDelay, + bindings: @escaping @MainActor () -> (forward: BoundKey?, backward: BoundKey?) = userBindings, + announce: @escaping (String) -> Void = AccessibilityAnnouncement.post + ) { + self.presenter = presenter + self.pickerDelay = pickerDelay + self.bindings = bindings + self.announce = announce + } + + internal var isActive: Bool { model != nil } + + internal var session: RecentTabSwitcherSession? { model?.session } + + internal static func userBindings() -> (forward: BoundKey?, backward: BoundKey?) { + let keyboard = AppSettingsManager.shared.keyboard + return (keyboard.shortcut(for: .switchToRecentTab), keyboard.shortcut(for: .switchToLeastRecentTab)) + } + + /// Puts the switch ahead of every editor's key chain, whichever of an editor's views holds focus, + /// so a find field or Vim can never take the Escape that ends a switch. + internal static func installEditorKeyClaim() { + TextViewController.precedingKeyDownClaim = claimKeyDown + } + + /// Hands a key to the switch in progress, for a key chain that runs ahead of the switcher's own + /// monitor. True when the switch took it. + internal static func claimKeyDown(_ event: NSEvent) -> Bool { + guard let active = activeController, active.isActive, event.type == .keyDown else { return false } + return active.handleKeyDown(keyCode: event.keyCode, modifiers: event.modifierFlags) + } + + // MARK: - Session + + /// `trigger` is the event that ran the command. A switch the user is not holding a modifier for, + /// because it came from a pointer in the menu or from a binding with no modifier, has no release + /// to wait for, so it lands on the first candidate at once. + /// + /// The release is read from the event stream only, never from `NSEvent.modifierFlags`. The + /// command runs inside the dispatch of the press that fired it, so the monitor is in place before + /// AppKit takes the release off the queue, however fast the tap. The live flags describe the + /// hardware instead, and measured, an event handed to the app rather than typed leaves them at + /// zero, which would end every such switch on its first press. + internal func begin( + candidates: [RecentTabCandidate], + leadsWithCurrentTab: Bool = true, + direction: RecentTabSwitchDirection, + trigger: NSEvent?, + window: NSWindow?, + isOpen: @escaping (RecentTabReference) -> Bool, + onCommit: @escaping (RecentTabReference) -> Void + ) { + cancel() + guard let session = RecentTabSwitcherSession( + candidates: candidates, + direction: direction, + leadsWithCurrentTab: leadsWithCurrentTab + ) else { return } + + let held = RecentTabSwitcherKeyCommand.heldModifiers(of: trigger) + guard !held.isEmpty else { + onCommit(session.highlighted.reference) + return + } + + model = RecentTabSwitcherModel(session: session) + heldModifiers = held + self.isOpen = isOpen + self.onCommit = onCommit + hostWindow = window + Self.activeController = self + + installEventMonitor() + observeEnd(of: window) + schedulePicker() + announce(session.highlighted.title) + Self.logger.debug("begin candidates=\(candidates.count, privacy: .public)") + } + + /// True for every key while a switch is held. A key the switch does not use is swallowed rather + /// than passed on, because the modifier is still down and the editor would read it as a chord. + /// + /// A key that arrives without the held modifier means the release was never seen: AppKit runs + /// no local monitor while a menu or a drag is tracking, and a modifier let go of then is lost. + /// The switch ends and the key goes where it was meant to, rather than every key being + /// swallowed until something else ends it. + @discardableResult + internal func handleKeyDown(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool { + guard isActive else { return false } + guard !RecentTabSwitcherKeyCommand.releases(modifiers, held: heldModifiers) else { + cancel() + return false + } + let bound = bindings() + let command = RecentTabSwitcherKeyCommand.resolve( + keyCode: keyCode, + modifiers: modifiers, + forward: bound.forward, + backward: bound.backward + ) + switch command { + case let .step(direction): + step(direction) + case .commit: + commit() + case .cancel: + cancel() + case .ignore: + break + } + return true + } + + internal func handleModifiersChanged(_ modifiers: NSEvent.ModifierFlags) { + guard isActive, RecentTabSwitcherKeyCommand.releases(modifiers, held: heldModifiers) else { return } + commit() + } + + internal func cancel() { + guard isActive else { return } + Self.logger.debug("cancel") + end() + } + + private func step(_ direction: RecentTabSwitchDirection) { + guard var session = model?.session, let isOpen else { return } + guard session.step(direction, keeping: isOpen) else { + cancel() + return + } + model?.update(session) + announce(session.highlighted.title) + } + + /// A highlighted tab that closed while the chord was held is not replaced by its neighbour: the + /// user never chose that one, so the switch ends where it started. + private func commit() { + guard let target = model?.session.highlighted.reference, let isOpen, let onCommit else { return } + end() + guard isOpen(target) else { return } + onCommit(target) + } + + /// Clears the state before the panel goes, because closing the panel calls back into + /// `cancel()`, which must find nothing left to cancel. + private func end() { + model = nil + isOpen = nil + onCommit = nil + heldModifiers = [] + hostWindow = nil + pickerWorkItem?.cancel() + pickerWorkItem = nil + if let eventMonitor { + NSEvent.removeMonitor(eventMonitor) + } + eventMonitor = nil + observers.forEach(NotificationCenter.default.removeObserver) + observers.removeAll() + if Self.activeController === self { + Self.activeController = nil + } + presenter?.dismissRecentTabSwitcher() + } + + // MARK: - Wiring + + /// A press of any mouse button ends the switch and goes through untouched. With Control held a + /// click is a secondary click, and the contextual menu it opens tracks events itself, so a + /// release of Control inside it would never reach this monitor. + private func installEventMonitor() { + let mask: NSEvent.EventTypeMask = [.keyDown, .flagsChanged, .leftMouseDown, .rightMouseDown, .otherMouseDown] + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: mask) { [weak self] nsEvent in + nonisolated(unsafe) let event = nsEvent + let consumed = MainActor.assumeIsolated { () -> Bool in + guard let self else { return false } + switch event.type { + case .flagsChanged: + self.handleModifiersChanged(event.modifierFlags) + return false + case .keyDown: + return self.handleKeyDown(keyCode: event.keyCode, modifiers: event.modifierFlags) + default: + self.cancel() + return false + } + } + return consumed ? nil : nsEvent + } + } + + /// A switch belongs to the window and to the moment. Anything that takes the keyboard away, + /// another window, another app, a menu opening, or the window closing, ends it without + /// switching. + private func observeEnd(of window: NSWindow?) { + let center = NotificationCenter.default + let endSwitch: @Sendable (Notification) -> Void = { [weak self] _ in + MainActor.assumeIsolated { self?.cancel() } + } + observers.append(center.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main, + using: endSwitch + )) + observers.append(center.addObserver( + forName: NSMenu.didBeginTrackingNotification, + object: nil, + queue: .main, + using: endSwitch + )) + guard let window else { return } + observers.append(center.addObserver( + forName: NSWindow.didResignKeyNotification, + object: window, + queue: .main, + using: endSwitch + )) + observers.append(center.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main, + using: endSwitch + )) + } + + private func schedulePicker() { + let work = DispatchWorkItem { [weak self] in + MainActor.assumeIsolated { self?.showPicker() } + } + pickerWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + pickerDelay, execute: work) + } + + private func showPicker() { + guard let model else { return } + presenter?.presentRecentTabSwitcher(model, over: hostWindow) { [weak self] in + self?.cancel() + } + } +} diff --git a/TablePro/Core/RecentTabs/RecentTabSwitcherKeyCommand.swift b/TablePro/Core/RecentTabs/RecentTabSwitcherKeyCommand.swift new file mode 100644 index 0000000000..515e5b4796 --- /dev/null +++ b/TablePro/Core/RecentTabs/RecentTabSwitcherKeyCommand.swift @@ -0,0 +1,69 @@ +// +// RecentTabSwitcherKeyCommand.swift +// TablePro +// + +import AppKit + +/// What a key pressed while the switch is held does to it. +internal enum RecentTabSwitcherKeyCommand: Equatable { + case step(RecentTabSwitchDirection) + case commit + case cancel + /// Swallowed without effect. A key typed mid-switch belongs to neither the switcher nor the + /// editor behind it, which would otherwise receive it with the modifier still down. + case ignore + + private static let chordModifiers: NSEvent.ModifierFlags = [.command, .shift, .option, .control] + + /// Either binding with Shift flipped walks the other way, the way Shift reverses Command-Tab, so + /// the reverse direction works even for a user who only bound the forward command. + /// + /// The bindings are read first, so a chord bound to Control-Return or Control-Escape steps like + /// any other rather than committing or cancelling the switch it started. + internal static func resolve( + keyCode: UInt16, + modifiers: NSEvent.ModifierFlags, + forward: BoundKey?, + backward: BoundKey? + ) -> RecentTabSwitcherKeyCommand { + let pressed = modifiers.intersection(chordModifiers) + let bindings: [(key: BoundKey?, direction: RecentTabSwitchDirection)] = [ + (forward, .forward), + (backward, .backward) + ] + for binding in bindings { + guard let key = binding.key, !key.isCleared, key.keyCode == keyCode, + pressed.subtracting(.shift) == key.modifierFlags.subtracting(.shift) + else { continue } + return .step(pressed.contains(.shift) == key.shift ? binding.direction : binding.direction.reversed) + } + + switch KeyCode(rawValue: keyCode) { + case .escape: + return .cancel + case .return, .enter: + return .commit + case .upArrow: + return .step(.backward) + case .downArrow: + return .step(.forward) + default: + return .ignore + } + } + + /// The modifiers a keyboard-started switch waits on: whatever the chord held apart from Shift, + /// which only reverses. Empty for a switch started from the menu with the pointer, or from a + /// binding with no modifier, and such a switch has nothing to wait for. + internal static func heldModifiers(of event: NSEvent?) -> NSEvent.ModifierFlags { + guard let event, event.type == .keyDown else { return [] } + return event.modifierFlags.intersection([.command, .option, .control]) + } + + /// Whether a modifier change ends the switch: any held modifier coming up commits, the way + /// letting go of Command commits the app switcher. + internal static func releases(_ modifiers: NSEvent.ModifierFlags, held: NSEvent.ModifierFlags) -> Bool { + !modifiers.intersection(.deviceIndependentFlagsMask).isSuperset(of: held) + } +} diff --git a/TablePro/Core/RecentTabs/RecentTabSwitcherSession.swift b/TablePro/Core/RecentTabs/RecentTabSwitcherSession.swift new file mode 100644 index 0000000000..4048164d15 --- /dev/null +++ b/TablePro/Core/RecentTabs/RecentTabSwitcherSession.swift @@ -0,0 +1,97 @@ +// +// RecentTabSwitcherSession.swift +// TablePro +// + +import Foundation + +internal enum RecentTabSwitchDirection: Equatable { + case forward + case backward + + internal var reversed: RecentTabSwitchDirection { + self == .forward ? .backward : .forward + } + + fileprivate var offset: Int { + self == .forward ? 1 : -1 + } +} + +/// A row of the switcher: the tab it switches to and what it says about that tab. +internal struct RecentTabCandidate: Identifiable, Equatable { + internal let reference: RecentTabReference + internal let title: String + internal let detail: String + internal let symbolName: String + + internal var id: RecentTabReference { reference } +} + +/// One press-hold-release of the switch command, from its first press to the tab it lands on. +/// +/// The list is taken when the switch starts and walked from there, the way the app switcher walks +/// the apps, so pressing the chord again moves through the same order rather than a list the last +/// step just changed. The tab on screen, when there is one, sits at index zero and is part of the +/// cycle, so walking all the way round and releasing stays where the user started. +internal struct RecentTabSwitcherSession: Equatable { + internal private(set) var candidates: [RecentTabCandidate] + internal private(set) var highlightedIndex: Int + private let leadsWithCurrentTab: Bool + + /// Nil when there is nothing to switch to. Forward starts on the tab used before this one, + /// backward on the one used longest ago. + /// + /// `leadsWithCurrentTab` is false when the connection on screen has no tab open, and then every + /// candidate is somewhere else: forward starts on the most recent of them rather than skipping it. + internal init?( + candidates: [RecentTabCandidate], + direction: RecentTabSwitchDirection, + leadsWithCurrentTab: Bool = true + ) { + guard candidates.count >= Self.minimumCount(leadsWithCurrentTab: leadsWithCurrentTab) else { return nil } + self.candidates = candidates + self.leadsWithCurrentTab = leadsWithCurrentTab + let first = leadsWithCurrentTab ? 1 : 0 + self.highlightedIndex = direction == .forward ? first : candidates.count - 1 + } + + private static func minimumCount(leadsWithCurrentTab: Bool) -> Int { + leadsWithCurrentTab ? 2 : 1 + } + + internal var highlighted: RecentTabCandidate { + candidates[highlightedIndex] + } + + internal mutating func step(_ direction: RecentTabSwitchDirection) { + let count = candidates.count + highlightedIndex = ((highlightedIndex + direction.offset) % count + count) % count + } + + /// Steps over the candidates still open, dropping the ones that closed while the switch was + /// held. When the highlighted tab itself closed, the step lands where it would have from that + /// tab: forward on the one after it, backward on the one before. Returns false once nothing is + /// left to switch to. + internal mutating func step(_ direction: RecentTabSwitchDirection, keeping isOpen: (RecentTabReference) -> Bool) -> Bool { + let highlightedReference = highlighted.reference + let remaining = candidates.filter { isOpen($0.reference) } + guard remaining.count >= Self.minimumCount(leadsWithCurrentTab: leadsWithCurrentTab) else { return false } + + if let kept = remaining.firstIndex(where: { $0.reference == highlightedReference }) { + candidates = remaining + highlightedIndex = kept + step(direction) + return true + } + + let followers = candidates[(highlightedIndex + 1)...].filter { isOpen($0.reference) }.count + let after = (remaining.count - followers) % remaining.count + candidates = remaining + highlightedIndex = after + if direction == .backward { + step(.backward) + } + return true + } +} diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+ContentMode.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+ContentMode.swift index 0262f36769..feda818ac2 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+ContentMode.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+ContentMode.swift @@ -108,6 +108,7 @@ internal extension MainSplitViewController { /// column, so all three follow the swap rather than whichever tab is selected behind it. func applyContentMode(for workspace: ConnectionWorkspace) { syncPanes(of: workspace) + syncFrontmostTabManager() guard workspaces.selectedConnectionId == workspace.connectionId else { return } showSelectedContentPanes() showSelectedTrailingPane() diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 8c63edccf1..233d44c3ee 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -84,6 +84,12 @@ struct MenuValidationContext: Equatable { var canCloseOtherTabs = false var canCloseTabsForOtherDatabases = false var canCloseAllTabs = false + /// Whether the connections this window can show hold two tabs between them, which is the least + /// Control-Tab needs to switch anywhere. + var hasRecentTabToSwitchTo = false + /// Whether the window sits in a window tab group, where Control-Tab falls back to switching the + /// window's tabs when there is no editor tab to switch to. + var hasOtherWindowTabs = false var canPinResultTab = false /// The selected tab's browse history. Separate flags rather than one, because Back and Forward /// run out independently and an item that is disabled has to say which one it is. @@ -196,6 +202,9 @@ extension MainSplitViewController: NSMenuItemValidation { return context.hasSelectedWorkspace case #selector(selectNextEditorTab(_:)), #selector(selectPreviousEditorTab(_:)): return context.isConnected + case #selector(switchToRecentTab(_:)), #selector(switchToLeastRecentTab(_:)): + return (context.isConnected && !context.isAgentMode && context.hasRecentTabToSwitchTo) + || context.hasOtherWindowTabs case #selector(closeOtherTabs(_:)): return context.canCloseOtherTabs @@ -542,6 +551,7 @@ extension MainSplitViewController: NSMenuItemValidation { hasAssistantConversation: conversations != nil, hasStoredConversations: conversations?.conversations.isEmpty == false, canFocusAssistant: canFocusAssistant, + hasOtherWindowTabs: hasOtherWindowTabs, canToggleWorkspaceRail: canToggleWorkspaceRail ) } @@ -583,6 +593,8 @@ extension MainSplitViewController: NSMenuItemValidation { canCloseOtherTabs: actions.canCloseOtherTabs, canCloseTabsForOtherDatabases: actions.canCloseTabsForOtherDatabases, canCloseAllTabs: actions.canCloseAllTabs, + hasRecentTabToSwitchTo: hasRecentTabToSwitchTo, + hasOtherWindowTabs: hasOtherWindowTabs, canPinResultTab: actions.canPinResultTab, canNavigateBack: actions.canNavigateBack, canNavigateForward: actions.canNavigateForward, diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+RecentTabs.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+RecentTabs.swift new file mode 100644 index 0000000000..94f4c5454b --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+RecentTabs.swift @@ -0,0 +1,203 @@ +// +// MainSplitViewController+RecentTabs.swift +// TablePro +// + +import AppKit + +/// Control-Tab across every connection this window hosts. The order is derived from each +/// connection's own record of when its tabs were selected, so nothing here has to follow a tab +/// that closes, moves to another window or arrives from a restore. +internal extension MainSplitViewController { + @objc func switchToRecentTab(_ sender: Any?) { + switchRecentTab(.forward, sender: sender) + } + + @objc func switchToLeastRecentTab(_ sender: Any?) { + switchRecentTab(.backward, sender: sender) + } + + /// Control-Tab is the chord AppKit gives window tabs, so where there is no editor tab to switch + /// to it still switches the window's tabs rather than doing nothing. + private func switchRecentTab(_ direction: RecentTabSwitchDirection, sender: Any?) { + guard canSwitchToRecentTab else { + switch direction { + case .forward: + view.window?.selectNextTab(sender) + case .backward: + view.window?.selectPreviousTab(sender) + } + return + } + beginRecentTabSwitch(direction) + } + + var canSwitchToRecentTab: Bool { + isConnected && contentMode != .agent && hasRecentTabToSwitchTo + } + + var hasOtherWindowTabs: Bool { + (view.window?.tabbedWindows?.count ?? 0) > 1 + } + + /// A tab other than the one on screen, which a connection with no tab open of its own can still + /// switch to in another connection. + var hasRecentTabToSwitchTo: Bool { + let tabCount = recentTabSources().reduce(0) { $0 + $1.tabIds.count } + let showsOneOfThem = currentRecentTab.map(hostsOpenTab) ?? false + return tabCount > (showsOneOfThem ? 1 : 0) + } + + private func beginRecentTabSwitch(_ direction: RecentTabSwitchDirection) { + quickSwitcherPanel.dismiss() + let candidates = recentTabCandidates() + recentTabSwitcher.begin( + candidates: candidates, + leadsWithCurrentTab: candidates.first.map { $0.reference == currentRecentTab } ?? false, + direction: direction, + trigger: NSApp.currentEvent, + window: view.window, + isOpen: { [weak self] reference in self?.hostsOpenTab(reference) ?? false }, + onCommit: { [weak self] reference in self?.showRecentTab(reference) } + ) + } + + /// Only a connection that is on and browsing contributes. A tab behind a connecting or failed + /// pane, or behind Agent mode, is a tab the user could not see after switching to it. + private var recentTabWorkspaces: [ConnectionWorkspace] { + workspaces.workspaces.filter(showsTabs) + } + + private func showsTabs(_ workspace: ConnectionWorkspace) -> Bool { + workspace.sessionState != nil + && workspace.phase == .connected + && workspace.resolvedPane == .content + && workspace.resolvedContentMode == .browse + } + + /// Settles which connection's tabs count as seen: the one on screen, while the window is key and + /// its tabs are showing, and no other. Decided here from the window's own state rather than + /// cached per coordinator, because a window hosting several connections has exactly one answer + /// and every connection has to hear it, including one that just left the screen. + func syncFrontmostTabManager() { + let frontmost = Self.frontmostConnectionId( + selectedConnectionId: workspaces.selectedConnectionId, + windowIsKey: view.window?.isKeyWindow == true, + showsTabs: { [weak self] connectionId in + guard let self, let workspace = self.workspaces.workspace(for: connectionId) else { return false } + return self.showsTabs(workspace) + } + ) + for workspace in workspaces.workspaces { + workspace.sessionState?.tabManager.isFrontmost = workspace.connectionId == frontmost + } + } + + /// At most one connection per window, and none while the window is not key. + static func frontmostConnectionId( + selectedConnectionId: UUID?, + windowIsKey: Bool, + showsTabs: (UUID) -> Bool + ) -> UUID? { + guard windowIsKey, let selectedConnectionId, showsTabs(selectedConnectionId) else { return nil } + return selectedConnectionId + } + + private func recentTabSources() -> [RecentTabSource] { + recentTabWorkspaces.compactMap { workspace in + guard let manager = workspace.sessionState?.tabManager else { return nil } + return RecentTabSource( + connectionId: workspace.connectionId, + tabIds: manager.tabIds, + activationSequence: manager.activationSequence + ) + } + } + + private var currentRecentTab: RecentTabReference? { + guard let workspace = workspaces.selected, + let tabId = workspace.sessionState?.tabManager.selectedTab?.id else { return nil } + return RecentTabReference(connectionId: workspace.connectionId, tabId: tabId) + } + + /// A row names its connection only when the window hosts more than one, where two connections + /// can hold tabs with the same title. + func recentTabCandidates() -> [RecentTabCandidate] { + let hosted = recentTabWorkspaces + let namesConnection = hosted.count > 1 + var rows: [RecentTabReference: RecentTabCandidate] = [:] + + for workspace in hosted { + guard let tabs = workspace.sessionState?.tabManager.tabs else { continue } + let target = workspace.connection.flatMap { PluginManager.shared.containerSwitchTarget(for: $0.type) } + let labels = EditorTabLabelResolver.resolve(tabs: tabs, target: target) + let connectionName = namesConnection ? workspace.connection?.name : nil + for tab in tabs { + let reference = RecentTabReference(connectionId: workspace.connectionId, tabId: tab.id) + let title = labels[tab.id]?.text ?? tab.title + rows[reference] = RecentTabCandidate( + reference: reference, + title: title, + detail: Self.recentTabDetail( + container: Self.recentTabContainer(of: tab, title: title, target: target), + connectionName: connectionName + ), + symbolName: Self.recentTabSymbol(for: tab) + ) + } + } + + return RecentTabOrder.order(sources: recentTabSources(), current: currentRecentTab) + .compactMap { rows[$0] } + } + + /// The tab is selected in its own connection first and the connection brought on screen second. + /// Bringing a connection on screen records its selected tab as used, so the other order would + /// record the tab it happened to be showing as the one the user came from. + private func showRecentTab(_ reference: RecentTabReference) { + guard hostsOpenTab(reference), + let manager = workspaces.workspace(for: reference.connectionId)?.sessionState?.tabManager + else { return } + manager.selectedTabId = reference.tabId + workspaces.select(reference.connectionId) + } + + private func hostsOpenTab(_ reference: RecentTabReference) -> Bool { + guard let workspace = recentTabWorkspaces.first(where: { $0.connectionId == reference.connectionId }), + let manager = workspace.sessionState?.tabManager else { return false } + return manager.tabs.contains { $0.id == reference.tabId } + } + + /// The database or schema a table tab reads, unless the title already carries it. + private static func recentTabContainer(of tab: QueryTab, title: String, target: ContainerSwitchTarget?) -> String? { + guard tab.tableContext.tableName != nil, + let container = WorkspaceAnchoring.containerName(of: tab, target: target), + !container.isEmpty, !title.hasPrefix("\(container).") else { return nil } + return container + } + + private static func recentTabDetail(container: String?, connectionName: String?) -> String { + [container, connectionName].compactMap { $0 }.joined(separator: " \u{00B7} ") + } + + private static func recentTabSymbol(for tab: QueryTab) -> String { + switch tab.tabType { + case .query: + return "doc.text" + case .table: + return tab.tableContext.isView ? "eye" : "tablecells" + case .createTable: + return "tablecells.badge.ellipsis" + case .erDiagram: + return "point.3.connected.trianglepath.dotted" + case .serverDashboard: + return "gauge.with.dots.needle.33percent" + case .usersRoles: + return "person.2" + case .insights: + return "chart.bar" + case .objectSource: + return "curlybraces.square" + } + } +} diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 9ba2c06e41..d3d7bc375a 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -173,6 +173,10 @@ internal final class MainSplitViewController: NSSplitViewController { lazy var switcherPresenter = ToolbarSwitcherPresenter(panelController: quickSwitcherPanel) + /// Control-Tab, which walks every tab this window hosts across all of its connections, so it + /// belongs to the window and draws in the window's one panel. + lazy var recentTabSwitcher = RecentTabSwitcherController(presenter: quickSwitcherPanel) + // MARK: - Toolbar internal var toolbarOwner: MainWindowToolbar? @@ -357,7 +361,8 @@ internal final class MainSplitViewController: NSSplitViewController { /// A connection joining or leaving the window changes what every rail in the app lists, /// which is what this event is for. Which row is current is a separate question, answered /// by the rail reading its host back, so a selection change must not come through here. - workspaces.onMembershipChange = { + workspaces.onMembershipChange = { [weak self] in + self?.recentTabSwitcher.cancel() AppEvents.shared.connectionWindowsChanged.send() } @@ -675,6 +680,7 @@ internal final class MainSplitViewController: NSSplitViewController { /// Only this window's rail moved, and only its highlight. Broadcasting instead made every /// rail in the app rebuild its whole entry list to answer a question none of them asked. navigationSidebar?.railController.refreshSelection() + syncFrontmostTabManager() /// The toolbar's shape follows the workspace, not only its coordinator. Pointing the toolbar /// reaches it through a repoint, and a switch between two workspaces that both have no @@ -689,6 +695,7 @@ internal final class MainSplitViewController: NSSplitViewController { syncSelectedPanes() applyPaneChrome() applyWindowTitle() + syncFrontmostTabManager() SessionRecoveryTracker.sync() } @@ -742,6 +749,7 @@ internal final class MainSplitViewController: NSSplitViewController { showSelectedContentPanes() showSelectedTrailingPane() } + syncFrontmostTabManager() guard phaseChanged else { return } if workspaces.selectedConnectionId == connectionId { applyPaneChrome() diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index 1820aeb452..0cd6935c13 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -239,6 +239,7 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { if let splitVC = window.contentViewController as? MainSplitViewController { splitVC.startActivationConnectIfNeeded() + splitVC.syncFrontmostTabManager() } guard let coordinator = MainContentCoordinator.coordinator(forWindow: window) else { return } @@ -272,6 +273,7 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { // Closing or backgrounding this window leaves its yield on a menu it no longer // owns, and the window taking over may not be one of ours. MainMenuBuilder.syncKeyEquivalents() + (window.contentViewController as? MainSplitViewController)?.syncFrontmostTabManager() guard let coordinator = MainContentCoordinator.coordinator(forWindow: window) else { return } Self.lifecycleLogger.debug( diff --git a/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift b/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift index 6ca87d23a1..d2abbeb336 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift @@ -546,7 +546,9 @@ internal final class WorkspaceRailViewController: NSViewController { to=\(Self.describe(workspace), privacy: .public) """ ) + coordinator.tabManager.defersActivationRecord = true Task { @MainActor in + defer { coordinator.tabManager.defersActivationRecord = false } await coordinator.switchContainer(to: workspace.container) let landed = WorkspaceRailStore.browsedWorkspace(for: workspace.connectionId) if landed == workspace { diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 2ea3eddfc3..f6dd20a554 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -15,18 +15,58 @@ final class QueryTabManager: ObservableObject { _tabIndexMapDirty = true if oldValue.map(\.id) != tabs.map(\.id) { tabStructureVersion += 1 + pruneActivations(keeping: tabs) } publishAnchorChange(oldTabs: oldValue, newTabs: tabs) syncTabSessionRegistry(oldTabs: oldValue, newTabs: tabs) } } - @Published var selectedTabId: UUID? + /// Every writer of the selection comes through here, which makes this the one place that sees + /// every tab the user switches to: a click in the strip, Command 1 to 9, a new tab, a table + /// opened from the sidebar, a reopened tab and the neighbour a close lands on alike. A view + /// modifier could not stand in for it, because a connection the window is not showing has its + /// views unparented. + @Published var selectedTabId: UUID? { + didSet { + guard selectedTabId != oldValue else { return } + scheduleActivationRecord() + } + } + + /// Whether the window shows this connection's tabs, in the key window. The window sets it, for + /// every connection it hosts at once. Only then is a selection a tab the user switched to: a + /// restore that finishes for a connection in the background selects a tab nobody has seen, and + /// recording it would send the next Control-Tab there. Coming to the front records the selected + /// tab, because the user is now looking at it. + var isFrontmost = false { + didSet { + guard isFrontmost != oldValue else { return } + scheduleActivationRecord() + } + } @Published var tabStructureVersion: Int = 0 var pendingFocusTabId: UUID? + /// When each open tab was last the selected one, as a sequence number rather than a time, so + /// two selections in one run-loop turn still order. The sequence is shared by every tab manager + /// so a window hosting several connections can order all of their tabs together. + private(set) var activationSequence: [UUID: UInt64] = [:] + private static var lastActivationSequence: UInt64 = 0 + private var activationRecordPending = false + + /// Held while the window is on its way to a tab across an await, such as the rail switching a + /// connection's database before it selects the tab that database holds. The tab shown in the + /// meantime is a waypoint, and the one landed on is recorded when the hold lifts. + var defersActivationRecord = false { + didSet { + guard !defersActivationRecord, oldValue else { return } + scheduleActivationRecord() + } + } + private var _tabIndexMap: [UUID: Int] = [:] private var _tabIndexMapDirty = true @@ -111,6 +151,37 @@ final class QueryTabManager: ObservableObject { selectedTabId = tabs[((current + offset) % count + count) % count].id } + // MARK: - Recency + + /// The tab is recorded once the turn settles, not at each write. Opening a table into another + /// connection brings that connection forward and then selects the new tab in the same turn, so + /// the tab it was showing is never drawn; recorded as it passed through, it would be what the + /// next Control-Tab went back to. + private func scheduleActivationRecord() { + guard isFrontmost, !defersActivationRecord, !activationRecordPending else { return } + activationRecordPending = true + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { self?.recordSettledActivation() } + } + } + + private func recordSettledActivation() { + activationRecordPending = false + guard isFrontmost, !defersActivationRecord, let id = selectedTab?.id else { return } + recordActivation(of: id) + } + + private func recordActivation(of id: UUID) { + Self.lastActivationSequence += 1 + activationSequence[id] = Self.lastActivationSequence + } + + private func pruneActivations(keeping tabs: [QueryTab]) { + guard !activationSequence.isEmpty else { return } + let openIds = Set(tabs.map(\.id)) + activationSequence = activationSequence.filter { openIds.contains($0.key) } + } + func bindTabSessionRegistry(_ registry: TabSessionRegistry) { tabSessionRegistry = registry for tab in tabs where registry.session(for: tab.id) == nil { diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index 6ad73391a4..a4cde30422 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -155,6 +155,10 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case focusAssistant case showPreviousTab case showNextTab + case switchToRecentTab + case switchToLeastRecentTab + case showPreviousWindowTab + case showNextWindowTab case toggleWorkspaceRail case showPreviousWorkspace case showNextWorkspace @@ -187,7 +191,8 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { .previousResultTab, .nextResultTab, .pinResultTab, .closeResultTab, .focusSidebarSearch, .focusObjectList, .focusEditor, .focusResults, .focusInspector, .focusAssistant, - .showPreviousTab, .showNextTab, + .showPreviousTab, .showNextTab, .switchToRecentTab, .switchToLeastRecentTab, + .showPreviousWindowTab, .showNextWindowTab, .toggleWorkspaceRail, .showPreviousWorkspace, .showNextWorkspace: return .navigation } @@ -218,6 +223,10 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { } } + var switchesRecentTabs: Bool { + self == .switchToRecentTab || self == .switchToLeastRecentTab + } + var allowsBareKey: Bool { switch self { case .previewFKReference, .clearSelection, .delete: @@ -316,6 +325,10 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .focusAssistant: return String(localized: "Focus Assistant") case .showPreviousTab: return String(localized: "Show Previous Tab") case .showNextTab: return String(localized: "Show Next Tab") + case .switchToRecentTab: return String(localized: "Switch to Recent Tab") + case .switchToLeastRecentTab: return String(localized: "Switch to Least Recent Tab") + case .showPreviousWindowTab: return String(localized: "Show Previous Window Tab") + case .showNextWindowTab: return String(localized: "Show Next Window Tab") case .toggleWorkspaceRail: return String(localized: "Toggle Connections") case .showPreviousWorkspace: return String(localized: "Show Previous Connection") case .showNextWorkspace: return String(localized: "Show Next Connection") @@ -645,6 +658,12 @@ struct KeyboardSettings: Codable, Equatable { .focusAssistant: .character("a", command: true, option: true, control: true), .showPreviousTab: .character("[", command: true, shift: true), .showNextTab: .character("]", command: true, shift: true), + /// The one Control chord among the defaults. Control-Tab is not a system hotkey, it is the + /// chord AppKit itself gives tab switching in every app with window tabs, and it is what + /// DataGrip, VS Code and Zed switch recent tabs with. Show Previous and Next Window Tab + /// have no default, since this is the chord AppKit would otherwise give them. + .switchToRecentTab: .special(.tab, control: true), + .switchToLeastRecentTab: .special(.tab, shift: true, control: true), .toggleWorkspaceRail: .character("0", command: true, option: true), .showPreviousWorkspace: .special(.upArrow, command: true, control: true), .showNextWorkspace: .special(.downArrow, command: true, control: true) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 052e9b367a..b962e3b7cb 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -171,6 +171,176 @@ } } }, + "Recent Tabs" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "최근 탭", + "state" : "translated" + } + }, + "tr" : { + "stringUnit" : { + "value" : "Son Sekmeler", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Tab gần đây", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "最近的标签页", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "最近的分頁", + "state" : "translated" + } + } + } + }, + "Show Next Window Tab" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "다음 윈도우 탭 보기", + "state" : "translated" + } + }, + "tr" : { + "stringUnit" : { + "value" : "Sonraki Pencere Sekmesini Göster", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Hiện tab cửa sổ tiếp theo", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "显示下一个窗口标签页", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "顯示下一個視窗分頁", + "state" : "translated" + } + } + } + }, + "Show Previous Window Tab" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "이전 윈도우 탭 보기", + "state" : "translated" + } + }, + "tr" : { + "stringUnit" : { + "value" : "Önceki Pencere Sekmesini Göster", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Hiện tab cửa sổ trước", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "显示上一个窗口标签页", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "顯示上一個視窗分頁", + "state" : "translated" + } + } + } + }, + "Switch to Least Recent Tab" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "가장 오래전에 사용한 탭으로 전환", + "state" : "translated" + } + }, + "tr" : { + "stringUnit" : { + "value" : "En Eski Kullanılan Sekmeye Geç", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Chuyển đến tab dùng lâu nhất", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "切换到最久未用的标签页", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "切換到最久未用的分頁", + "state" : "translated" + } + } + } + }, + "Switch to Recent Tab" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "최근 탭으로 전환", + "state" : "translated" + } + }, + "tr" : { + "stringUnit" : { + "value" : "Son Sekmeye Geç", + "state" : "translated" + } + }, + "vi" : { + "stringUnit" : { + "value" : "Chuyển đến tab gần đây", + "state" : "translated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "value" : "切换到最近的标签页", + "state" : "translated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "value" : "切換到最近的分頁", + "state" : "translated" + } + } + } + }, "Use Selection for Find" : { "localizations" : { "ko" : { diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift index b64262d752..5cca43ff45 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift @@ -8,8 +8,25 @@ import SwiftUI private let fallbackScreenFrame = NSRect(x: 0, y: 0, width: 1_280, height: 800) +/// Whether a panel takes the keyboard. Open Quickly and the switchers do: they own a search field. +/// The recent-tab switcher must not: it is driven by a chord held in the window behind it, and a +/// panel that took key status would dim that window's title bar and move the key events away from +/// the editor for the length of a Control-Tab. +internal enum QuickSwitcherPanelFocus: Equatable { + case key + case passive +} + internal final class QuickSwitcherPanel: NSPanel { - init(hostingController: NSHostingController, surfaceCornerRadius: CGFloat) { + private let focus: QuickSwitcherPanelFocus + + init( + hostingController: NSHostingController, + surfaceCornerRadius: CGFloat, + focus: QuickSwitcherPanelFocus = .key, + accessibilityIdentifier: String = "quick-switcher-panel" + ) { + self.focus = focus hostingController.sizingOptions = [] let proposal = NSScreen.main?.visibleFrame.size ?? fallbackScreenFrame.size let contentSize = hostingController.sizeThatFits(in: proposal) @@ -23,7 +40,7 @@ internal final class QuickSwitcherPanel: NSPanel { /// the window's accessibility identifier, so a client can scope a search to this panel /// instead of walking the whole application. A SwiftUI modifier could not do it, because an /// identifier on the content view overwrites the one every control inside it publishes. - identifier = NSUserInterfaceItemIdentifier("quick-switcher-panel") + identifier = NSUserInterfaceItemIdentifier(accessibilityIdentifier) isFloatingPanel = true level = .floating collectionBehavior.insert(.fullScreenAuxiliary) @@ -35,6 +52,9 @@ internal final class QuickSwitcherPanel: NSPanel { animationBehavior = .utilityWindow contentViewController = hostingController setContentSize(contentSize) + /// A panel that never becomes key still takes the clicks that land on it. The passive one + /// is a readout of a chord held elsewhere, so a click goes to whatever is under it. + ignoresMouseEvents = focus == .passive maskContentToSurfaceShape(cornerRadius: surfaceCornerRadius) } @@ -56,7 +76,7 @@ internal final class QuickSwitcherPanel: NSPanel { layer.masksToBounds = true } - override var canBecomeKey: Bool { true } + override var canBecomeKey: Bool { focus == .key } override var canBecomeMain: Bool { false } override func resignKey() { @@ -81,6 +101,7 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { private var panel: QuickSwitcherPanel? private var anchor: Anchor? private var presentedIdentity: String? + private var onClose: (() -> Void)? var isPresented: Bool { panel != nil } @@ -91,9 +112,19 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { panel != nil && presentedIdentity == identity } - func present(_ content: some View, over parentWindow: NSWindow?, identity: String? = nil) { + /// `onClose` runs however the panel goes, including when another presentation replaces it, + /// which is how a caller that keeps state of its own alongside the panel learns to drop it. + func present( + _ content: some View, + over parentWindow: NSWindow?, + identity: String? = nil, + focus: QuickSwitcherPanelFocus = .key, + accessibilityIdentifier: String = "quick-switcher-panel", + onClose: (() -> Void)? = nil + ) { dismiss() presentedIdentity = identity + self.onClose = onClose let sizeReportingContent = content.onGeometryChange(for: CGSize.self) { proxy in proxy.size @@ -104,7 +135,9 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { let panel = QuickSwitcherPanel( hostingController: hostingController, - surfaceCornerRadius: QuickSwitcherMetrics.cornerRadius + surfaceCornerRadius: QuickSwitcherMetrics.cornerRadius, + focus: focus, + accessibilityIdentifier: accessibilityIdentifier ) panel.delegate = self self.panel = panel @@ -117,7 +150,12 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { top: reference.maxY - reference.height * Self.topOffsetRatio ) applyAnchor(to: panel) - panel.makeKeyAndOrderFront(nil) + switch focus { + case .key: + panel.makeKeyAndOrderFront(nil) + case .passive: + panel.orderFront(nil) + } } func dismiss() { @@ -129,6 +167,9 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { panel = nil anchor = nil presentedIdentity = nil + let closed = onClose + onClose = nil + closed?() } func windowDidResize(_ notification: Notification) { diff --git a/TablePro/Views/RecentTabSwitcher/QuickSwitcherPanelController+RecentTabSwitcher.swift b/TablePro/Views/RecentTabSwitcher/QuickSwitcherPanelController+RecentTabSwitcher.swift new file mode 100644 index 0000000000..8538268ffe --- /dev/null +++ b/TablePro/Views/RecentTabSwitcher/QuickSwitcherPanelController+RecentTabSwitcher.swift @@ -0,0 +1,34 @@ +// +// QuickSwitcherPanelController+RecentTabSwitcher.swift +// TablePro +// + +import AppKit + +/// The recent-tab list goes in the window's one floating panel rather than a panel of its own, so +/// it and Open Quickly can never be up together over the same point: presenting either closes the +/// other, and the close tells the switch in progress to end. +extension QuickSwitcherPanelController: RecentTabSwitcherPresenting { + internal static let recentTabSwitcherIdentity = "recent-tab-switcher" + internal static let recentTabSwitcherAccessibilityIdentifier = "recent-tab-switcher-panel" + + internal func presentRecentTabSwitcher( + _ model: RecentTabSwitcherModel, + over window: NSWindow?, + onClose: @escaping () -> Void + ) { + present( + RecentTabSwitcherView(model: model), + over: window, + identity: Self.recentTabSwitcherIdentity, + focus: .passive, + accessibilityIdentifier: Self.recentTabSwitcherAccessibilityIdentifier, + onClose: onClose + ) + } + + internal func dismissRecentTabSwitcher() { + guard isPresenting(Self.recentTabSwitcherIdentity) else { return } + dismiss() + } +} diff --git a/TablePro/Views/RecentTabSwitcher/RecentTabSwitcherRow.swift b/TablePro/Views/RecentTabSwitcher/RecentTabSwitcherRow.swift new file mode 100644 index 0000000000..563d30b667 --- /dev/null +++ b/TablePro/Views/RecentTabSwitcher/RecentTabSwitcherRow.swift @@ -0,0 +1,53 @@ +// +// RecentTabSwitcherRow.swift +// TablePro +// + +import SwiftUI + +internal struct RecentTabSwitcherRow: View { + let candidate: RecentTabCandidate + let isHighlighted: Bool + + private var secondary: Color { + isHighlighted ? Color.emphasizedSelectionLabel.opacity(0.85) : Color.secondary + } + + var body: some View { + HStack(spacing: 10) { + Image(systemName: candidate.symbolName) + .font(.callout.weight(.medium)) + .foregroundStyle(secondary) + .frame(width: 18) + .accessibilityHidden(true) + + Text(candidate.title) + .font(.body) + .foregroundStyle(isHighlighted ? Color.emphasizedSelectionLabel : Color.primary) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(1) + + Spacer(minLength: 8) + + if !candidate.detail.isEmpty { + Text(candidate.detail) + .font(.callout) + .foregroundStyle(secondary) + .lineLimit(1) + .truncationMode(.head) + } + } + .padding(.horizontal, 18) + .frame(height: RecentTabSwitcherMetrics.rowHeight) + .background { + if isHighlighted { + RoundedRectangle(cornerRadius: QuickSwitcherMetrics.rowCornerRadius, style: .continuous) + .fill(Color(nsColor: .selectedContentBackgroundColor)) + .padding(.horizontal, QuickSwitcherMetrics.rowInset) + } + } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(isHighlighted ? [.isSelected] : []) + } +} diff --git a/TablePro/Views/RecentTabSwitcher/RecentTabSwitcherView.swift b/TablePro/Views/RecentTabSwitcher/RecentTabSwitcherView.swift new file mode 100644 index 0000000000..0926a8e8e6 --- /dev/null +++ b/TablePro/Views/RecentTabSwitcher/RecentTabSwitcherView.swift @@ -0,0 +1,51 @@ +// +// RecentTabSwitcherView.swift +// TablePro +// + +import SwiftUI + +internal enum RecentTabSwitcherMetrics { + static let width: CGFloat = 480 + static let rowHeight: CGFloat = 40 +} + +/// The list Control-Tab walks, drawn on the same surface as Open Quickly so the app has one kind of +/// floating panel. It takes no clicks and no focus: the chord held in the window behind it drives +/// it, and holding Control turns a click into a secondary click anyway. +internal struct RecentTabSwitcherView: View { + @ObservedObject var model: RecentTabSwitcherModel + + var body: some View { + QuickSwitcherGlassGroup { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(model.session.candidates.enumerated()), id: \.element.id) { index, candidate in + RecentTabSwitcherRow( + candidate: candidate, + isHighlighted: index == model.session.highlightedIndex + ) + .id(candidate.id) + } + } + .padding(.vertical, QuickSwitcherMetrics.listVerticalPadding) + } + .frame(height: listHeight) + .onAppear { proxy.scrollTo(model.session.highlighted.id) } + .onValueChange(of: model.session.highlightedIndex) { _, _ in + proxy.scrollTo(model.session.highlighted.id) + } + } + .frame(width: RecentTabSwitcherMetrics.width) + .quickSwitcherSurface(cornerRadius: QuickSwitcherMetrics.cornerRadius) + } + .accessibilityElement(children: .contain) + .accessibilityLabel(Text("Recent Tabs")) + } + + private var listHeight: CGFloat { + let visibleRows = min(model.session.candidates.count, QuickSwitcherMetrics.maxVisibleRows) + return CGFloat(visibleRows) * RecentTabSwitcherMetrics.rowHeight + QuickSwitcherMetrics.listVerticalPadding * 2 + } +} diff --git a/TableProTests/Core/AI/InlineSuggestionManagerCompositionTests.swift b/TableProTests/Core/AI/InlineSuggestionManagerCompositionTests.swift index 7c25753447..effa344b6c 100644 --- a/TableProTests/Core/AI/InlineSuggestionManagerCompositionTests.swift +++ b/TableProTests/Core/AI/InlineSuggestionManagerCompositionTests.swift @@ -63,8 +63,8 @@ internal struct InlineSuggestionManagerCompositionTests { let manager: InlineSuggestionManager let source: RecordingInlineSource - func tab() -> NSEvent? { - EditorControllerFixture.keyDown(keyCode: kVK_Tab, characters: "\t", in: window) + func tab(modifiers: NSEvent.ModifierFlags = []) -> NSEvent? { + EditorControllerFixture.keyDown(keyCode: kVK_Tab, characters: "\t", modifiers: modifiers, in: window) } } @@ -164,6 +164,30 @@ internal struct InlineSuggestionManagerCompositionTests { #expect(harness.source.dismissed.count == 1) } + /// Control-Tab is the recent-tab chord and must reach the menu bar, and Shift-Tab outdents. Both + /// used to insert the suggestion because only the key code was checked. + @Test("A Tab chord with a modifier leaves the suggestion and the text alone") + func modifiedTabDoesNotAccept() async throws { + let chords: [(name: String, modifiers: NSEvent.ModifierFlags)] = [ + ("Control-Tab", .control), + ("Control-Shift-Tab", [.control, .shift]), + ("Shift-Tab", .shift), + ("Command-Tab", .command) + ] + for chord in chords { + let harness = makeHarness() + defer { harness.manager.uninstall() } + harness.manager.requestSuggestion() + await waitUntil { !harness.source.shown.isEmpty } + try #require(harness.source.shown.count == 1, "\(chord.name)") + let event = try #require(harness.tab(modifiers: chord.modifiers)) + + #expect(harness.manager.consumesKeyDown(event) == false, "\(chord.name)") + #expect(harness.editor.textView.string == "SELECT * ", "\(chord.name)") + #expect(harness.source.accepted.isEmpty, "\(chord.name)") + } + } + @Test("Tab accepts a shown suggestion when nothing is composing") func tabAcceptsSettledSuggestion() async throws { let harness = makeHarness() diff --git a/TableProTests/Core/Menu/WindowMenuTabCommandsTests.swift b/TableProTests/Core/Menu/WindowMenuTabCommandsTests.swift new file mode 100644 index 0000000000..8e9226b13c --- /dev/null +++ b/TableProTests/Core/Menu/WindowMenuTabCommandsTests.swift @@ -0,0 +1,197 @@ +// +// WindowMenuTabCommandsTests.swift +// TableProTests +// + +import AppKit +import Carbon.HIToolbox +@testable import TablePro +import Testing + +private func flattenItems(_ menu: NSMenu) -> [NSMenuItem] { + menu.items.flatMap { item -> [NSMenuItem] in + guard let submenu = item.submenu, submenu !== NSApp.servicesMenu else { return [item] } + return [item] + flattenItems(submenu) + } +} + +private final class FiringTarget: NSObject { + var fired = 0 + @objc func fire(_ sender: Any?) { fired += 1 } +} + +@Suite("Window menu tab commands") +@MainActor +struct WindowMenuTabCommandsTests { + private func windowItems(_ keyboard: KeyboardSettings = KeyboardSettings()) throws -> [NSMenuItem] { + let menu = try #require(WindowMenuBuilder.build(keyboard: keyboard).submenu) + return menu.items + } + + private func item(for action: Selector, in items: [NSMenuItem]) -> NSMenuItem? { + items.first { $0.action == action } + } + + /// AppKit inserts its own Show Previous Tab and Show Next Tab, on Control-Shift-Tab and + /// Control-Tab, into a Window menu that does not hold these two actions. Measured in 0.75: the + /// menu then listed both titles twice, and the inserted pair took Control-Tab first. + @Test("The menu owns the window-tab commands under their own names") + func ownsWindowTabCommands() throws { + let items = try windowItems() + let previous = try #require(item(for: #selector(NSWindow.selectPreviousTab(_:)), in: items)) + let next = try #require(item(for: #selector(NSWindow.selectNextTab(_:)), in: items)) + + #expect(previous.title == String(localized: "Show Previous Window Tab")) + #expect(next.title == String(localized: "Show Next Window Tab")) + #expect(previous.keyEquivalent.isEmpty) + #expect(next.keyEquivalent.isEmpty) + } + + @Test("Control-Tab and Control-Shift-Tab each belong to one recent-tab command") + func controlTabClaimedOnce() throws { + let items = try windowItems() + let forward = try #require(item(for: #selector(MainSplitViewController.switchToRecentTab(_:)), in: items)) + let backward = try #require(item(for: #selector(MainSplitViewController.switchToLeastRecentTab(_:)), in: items)) + + #expect(forward.keyEquivalent == "\t") + #expect(forward.keyEquivalentModifierMask == .control) + #expect(backward.keyEquivalent == "\t") + #expect(backward.keyEquivalentModifierMask == [.control, .shift]) + #expect(items.filter { $0.keyEquivalent == "\t" }.count == 2) + } + + @Test("Show Previous Tab and Show Next Tab keep the strip-order shortcuts") + func stripOrderCommandsUnchanged() throws { + let items = try windowItems() + let previous = try #require(item(for: #selector(MainSplitViewController.selectPreviousEditorTab(_:)), in: items)) + let next = try #require(item(for: #selector(MainSplitViewController.selectNextEditorTab(_:)), in: items)) + + #expect(previous.keyEquivalent == "[") + #expect(next.keyEquivalent == "]") + #expect(previous.keyEquivalentModifierMask == [.command, .shift]) + } + + /// The chord a real keyboard produces, with the character AppKit derives: Control-Shift-Tab + /// reports U+0019 rather than a tab. Built from `CGEvent` so the characters are the ones a user + /// would type, not ones a test chose. + private func typedTab(control: Bool, shift: Bool) throws -> NSEvent { + let event = try #require(CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_Tab), keyDown: true)) + var flags: CGEventFlags = [] + if control { flags.insert(.maskControl) } + if shift { flags.insert(.maskShift) } + event.flags = flags + return try #require(NSEvent(cgEvent: event)) + } + + private func dispatches(_ shortcut: ShortcutAction, event: NSEvent) -> Bool { + let target = FiringTarget() + let menu = NSMenu() + menu.addItem(MenuItemFactory.item( + "Probe", + action: #selector(FiringTarget.fire(_:)), + shortcut: shortcut, + keyboard: KeyboardSettings(), + target: target + )) + return menu.performKeyEquivalent(with: event) && target.fired == 1 + } + + @Test("A typed Control-Tab and Control-Shift-Tab reach their menu items") + func typedChordsDispatch() throws { + #expect(dispatches(.switchToRecentTab, event: try typedTab(control: true, shift: false))) + #expect(dispatches(.switchToLeastRecentTab, event: try typedTab(control: true, shift: true))) + #expect(dispatches(.switchToRecentTab, event: try typedTab(control: true, shift: true)) == false) + } + + /// A disabled item still takes its chord, and in a text view Control-Tab moves focus to the next + /// control, so a window with no tabs of either kind has to lose the key equivalents outright. + @Test("Control-Tab is dropped where the key window holds no tabs, and kept where it does") + func controlTabYieldsWithoutTabs() throws { + let menu = MainMenuBuilder.build(keyboard: KeyboardSettings()) + let recent = MenuItemFactory.identifier(for: .switchToRecentTab) + let findItem = { flattenItems(menu).first { $0.identifier == recent } } + + MainMenuBuilder.syncKeyEquivalents(keyboard: KeyboardSettings(), actions: nil, keyWindowHasTabs: false, to: menu) + #expect(findItem()?.keyEquivalent.isEmpty == true) + + MainMenuBuilder.syncKeyEquivalents(keyboard: KeyboardSettings(), actions: nil, keyWindowHasTabs: true, to: menu) + #expect(findItem()?.keyEquivalent == "\t") + #expect(findItem()?.keyEquivalentModifierMask == .control) + } + + @Test("Window-tab switching can be given a shortcut in Settings") + func windowTabCommandsAreRebindable() throws { + var keyboard = KeyboardSettings() + keyboard.setShortcut(.character("[", command: true, option: true, control: true), for: .showPreviousWindowTab) + let previous = try #require(item(for: #selector(NSWindow.selectPreviousTab(_:)), in: try windowItems(keyboard))) + + #expect(previous.keyEquivalent == "[") + #expect(previous.keyEquivalentModifierMask == [.command, .option, .control]) + } +} + +@Suite("Recent tab switching menu validation") +struct RecentTabMenuValidationTests { + private let selectors = [ + #selector(MainSplitViewController.switchToRecentTab(_:)), + #selector(MainSplitViewController.switchToLeastRecentTab(_:)) + ] + + private func context(connected: Bool = true, agent: Bool = false, hasTab: Bool = true) -> MenuValidationContext { + var context = MenuValidationContext() + context.hasSelectedWorkspace = true + context.isConnected = connected + context.isAgentMode = agent + context.hasRecentTabToSwitchTo = hasTab + return context + } + + @Test("Enabled when the window has another tab to switch to") + @MainActor + func enabledWithTwoTabs() { + for selector in selectors { + #expect(MainSplitViewController.isEnabled(selector, context: context())) + } + } + + @Test("Dimmed with one tab, in Agent mode, and while not connected") + @MainActor + func dimmedWithoutSomethingToSwitchTo() { + for selector in selectors { + #expect(MainSplitViewController.isEnabled(selector, context: context(hasTab: false)) == false) + #expect(MainSplitViewController.isEnabled(selector, context: context(agent: true)) == false) + #expect(MainSplitViewController.isEnabled(selector, context: context(connected: false)) == false) + } + } + + /// Control-Tab is AppKit's window-tab chord. A window in a tab group with nothing to switch to in + /// its own strip still offers it, and the command switches the window's tabs instead. + @Test("Enabled in a window tab group even with no editor tab to switch to") + @MainActor + func windowTabsKeepTheChord() { + var fallback = context(connected: false, hasTab: false) + fallback.hasOtherWindowTabs = true + for selector in selectors { + #expect(MainSplitViewController.isEnabled(selector, context: fallback)) + } + } + + @Test("At most the connection on screen is frontmost, and only in the key window") + @MainActor + func frontmostRule() { + let selected = UUID() + + #expect(MainSplitViewController.frontmostConnectionId( + selectedConnectionId: selected, windowIsKey: true, showsTabs: { _ in true } + ) == selected) + #expect(MainSplitViewController.frontmostConnectionId( + selectedConnectionId: selected, windowIsKey: false, showsTabs: { _ in true } + ) == nil) + #expect(MainSplitViewController.frontmostConnectionId( + selectedConnectionId: selected, windowIsKey: true, showsTabs: { _ in false } + ) == nil) + #expect(MainSplitViewController.frontmostConnectionId( + selectedConnectionId: nil, windowIsKey: true, showsTabs: { _ in true } + ) == nil) + } +} diff --git a/TableProTests/Core/RecentTabs/RecentTabOrderTests.swift b/TableProTests/Core/RecentTabs/RecentTabOrderTests.swift new file mode 100644 index 0000000000..57e39b35c8 --- /dev/null +++ b/TableProTests/Core/RecentTabs/RecentTabOrderTests.swift @@ -0,0 +1,118 @@ +// +// RecentTabOrderTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Recent tab order across a window's connections") +@MainActor +struct RecentTabOrderTests { + private func source(_ manager: QueryTabManager, connection: UUID) -> RecentTabSource { + RecentTabSource( + connectionId: connection, + tabIds: manager.tabIds, + activationSequence: manager.activationSequence + ) + } + + private func current(_ manager: QueryTabManager, connection: UUID) -> RecentTabReference? { + manager.selectedTab.map { RecentTabReference(connectionId: connection, tabId: $0.id) } + } + + /// The report: two tabs in use that are not neighbours, so stepping the strip from D reaches + /// C. B rather than A, because A is first in the strip and a broken record would still reach it + /// through the strip-order tail. + @Test("From D, the previous tab is the one used before it, not the strip neighbour C") + func issueReproduction() async throws { + let connection = UUID() + let manager = QueryTabManager() + manager.isFrontmost = true + for title in ["A", "B", "C", "D"] { + manager.addTab(title: title) + } + let tabB = manager.tabs[1] + let tabD = try #require(manager.tabs.last) + manager.selectedTabId = tabB.id + try? await Task.sleep(for: .milliseconds(20)) + manager.selectedTabId = tabD.id + try? await Task.sleep(for: .milliseconds(20)) + + let order = RecentTabOrder.order( + sources: [source(manager, connection: connection)], + current: current(manager, connection: connection) + ) + + #expect(order.map(\.tabId).prefix(2) == [tabD.id, tabB.id]) + } + + @Test("Tabs never selected follow the used ones, in the order their strips show them") + func unusedTabsFollowInStripOrder() { + let connection = UUID() + let used = UUID() + let unusedFirst = UUID() + let unusedSecond = UUID() + let source = RecentTabSource( + connectionId: connection, + tabIds: [unusedFirst, used, unusedSecond], + activationSequence: [used: 7] + ) + + let order = RecentTabOrder.order(sources: [source], current: nil) + + #expect(order.map(\.tabId) == [used, unusedFirst, unusedSecond]) + } + + @Test("The tab on screen leads even when a background connection selected a tab after it") + func currentTabLeads() { + let shown = RecentTabReference(connectionId: UUID(), tabId: UUID()) + let background = RecentTabReference(connectionId: UUID(), tabId: UUID()) + let sources = [ + RecentTabSource(connectionId: shown.connectionId, tabIds: [shown.tabId], activationSequence: [shown.tabId: 1]), + RecentTabSource( + connectionId: background.connectionId, + tabIds: [background.tabId], + activationSequence: [background.tabId: 2] + ) + ] + + #expect(RecentTabOrder.order(sources: sources, current: shown) == [shown, background]) + } + + @Test("Tabs of two connections interleave by when they were used") + func connectionsInterleave() { + let first = UUID() + let second = UUID() + let firstOld = UUID() + let firstNew = UUID() + let secondMiddle = UUID() + let sources = [ + RecentTabSource(connectionId: first, tabIds: [firstOld, firstNew], activationSequence: [firstOld: 1, firstNew: 3]), + RecentTabSource(connectionId: second, tabIds: [secondMiddle], activationSequence: [secondMiddle: 2]) + ] + + let order = RecentTabOrder.order(sources: sources, current: nil) + + #expect(order == [ + RecentTabReference(connectionId: first, tabId: firstNew), + RecentTabReference(connectionId: second, tabId: secondMiddle), + RecentTabReference(connectionId: first, tabId: firstOld) + ]) + } + + @Test("A current tab that is not open changes nothing") + func unknownCurrentIsIgnored() { + let connection = UUID() + let tab = UUID() + let source = RecentTabSource(connectionId: connection, tabIds: [tab], activationSequence: [:]) + + let order = RecentTabOrder.order( + sources: [source], + current: RecentTabReference(connectionId: connection, tabId: UUID()) + ) + + #expect(order == [RecentTabReference(connectionId: connection, tabId: tab)]) + } +} diff --git a/TableProTests/Core/RecentTabs/RecentTabSwitcherControllerTests.swift b/TableProTests/Core/RecentTabs/RecentTabSwitcherControllerTests.swift new file mode 100644 index 0000000000..5fc0bb72cd --- /dev/null +++ b/TableProTests/Core/RecentTabs/RecentTabSwitcherControllerTests.swift @@ -0,0 +1,282 @@ +// +// RecentTabSwitcherControllerTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import Testing + +@MainActor +private final class RecordingPresenter: RecentTabSwitcherPresenting { + private(set) var presented: RecentTabSwitcherModel? + private(set) var dismissCount = 0 + private(set) var onClose: (() -> Void)? + + func presentRecentTabSwitcher(_ model: RecentTabSwitcherModel, over window: NSWindow?, onClose: @escaping () -> Void) { + presented = model + self.onClose = onClose + } + + func dismissRecentTabSwitcher() { + dismissCount += 1 + } +} + +/// Serialized because a switch in progress is app-wide: the editor's key chain finds it through one +/// static, and two tests holding switches at once would each see the other's. +@Suite("Recent tab switcher controller", .serialized) +@MainActor +struct RecentTabSwitcherControllerTests { + private let presenter = RecordingPresenter() + private let list: [RecentTabCandidate] = { + let connection = UUID() + return (0..<4).map { index in + RecentTabCandidate( + reference: RecentTabReference(connectionId: connection, tabId: UUID()), + title: "Tab \(index)", + detail: "", + symbolName: "doc.text" + ) + } + }() + + private final class Recorder { + var committed: [RecentTabReference] = [] + var announced: [String] = [] + } + + private func makeController(_ recorder: Recorder, pickerDelay: TimeInterval = 600) -> RecentTabSwitcherController { + RecentTabSwitcherController( + presenter: presenter, + pickerDelay: pickerDelay, + bindings: { + (forward: .special(.tab, control: true), backward: .special(.tab, shift: true, control: true)) + }, + announce: { recorder.announced.append($0) } + ) + } + + private func controlTab(shift: Bool = false) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: shift ? [.control, .shift] : .control, + timestamp: 0, + windowNumber: 0, + context: nil, + characters: shift ? "\u{19}" : "\t", + charactersIgnoringModifiers: shift ? "\u{19}" : "\t", + isARepeat: false, + keyCode: KeyCode.tab.rawValue + )) + } + + private func begin( + _ controller: RecentTabSwitcherController, + _ recorder: Recorder, + trigger: NSEvent?, + direction: RecentTabSwitchDirection = .forward, + isOpen: @escaping (RecentTabReference) -> Bool = { _ in true } + ) { + controller.begin( + candidates: list, + direction: direction, + trigger: trigger, + window: nil, + isOpen: isOpen, + onCommit: { recorder.committed.append($0) } + ) + } + + @Test("Chosen from the menu with the pointer, it switches to the previous tab at once") + func pointerSwitchesAtOnce() { + let recorder = Recorder() + let controller = makeController(recorder) + + begin(controller, recorder, trigger: nil) + + #expect(recorder.committed == [list[1].reference]) + #expect(controller.isActive == false) + #expect(presenter.presented == nil) + } + + @Test("A tap lands on the previous tab when Control comes up, with no list drawn") + func tapSwitchesOnRelease() throws { + let recorder = Recorder() + let controller = makeController(recorder) + + begin(controller, recorder, trigger: try controlTab()) + controller.handleModifiersChanged([]) + + #expect(recorder.committed == [list[1].reference]) + #expect(controller.isActive == false) + #expect(presenter.presented == nil) + } + + @Test("Holding Control, each Tab moves on, and letting go switches there") + func holdCycleRelease() throws { + let recorder = Recorder() + let controller = makeController(recorder) + + begin(controller, recorder, trigger: try controlTab()) + #expect(controller.isActive) + #expect(recorder.committed.isEmpty) + + #expect(controller.handleKeyDown(keyCode: KeyCode.tab.rawValue, modifiers: .control)) + #expect(controller.session?.highlightedIndex == 2) + + controller.handleModifiersChanged([]) + + #expect(recorder.committed == [list[2].reference]) + #expect(recorder.announced == ["Tab 1", "Tab 2"]) + #expect(controller.isActive == false) + } + + @Test("Shift walks back, and the reverse chord starts at the tab used longest ago") + func shiftWalksBack() throws { + let recorder = Recorder() + let controller = makeController(recorder) + + begin(controller, recorder, trigger: try controlTab(shift: true), direction: .backward) + #expect(controller.session?.highlightedIndex == 3) + + controller.handleKeyDown(keyCode: KeyCode.tab.rawValue, modifiers: [.control, .shift]) + controller.handleModifiersChanged([]) + + #expect(recorder.committed == [list[2].reference]) + } + + @Test("A modifier change that keeps Control down does not switch") + func shiftAloneDoesNotCommit() throws { + let recorder = Recorder() + let controller = makeController(recorder) + begin(controller, recorder, trigger: try controlTab()) + + controller.handleModifiersChanged([.control, .shift]) + + #expect(controller.isActive) + #expect(recorder.committed.isEmpty) + controller.cancel() + } + + @Test("Escape ends the switch where it started") + func escapeCancels() throws { + let recorder = Recorder() + let controller = makeController(recorder) + begin(controller, recorder, trigger: try controlTab()) + + controller.handleKeyDown(keyCode: KeyCode.escape.rawValue, modifiers: .control) + controller.handleModifiersChanged([]) + + #expect(recorder.committed.isEmpty) + #expect(controller.isActive == false) + } + + @Test("A tab that closed while the chord was held is not switched to") + func closedTargetIsNotCommitted() throws { + let recorder = Recorder() + let controller = makeController(recorder) + let closed = list[1].reference + begin(controller, recorder, trigger: try controlTab(), isOpen: { $0 != closed }) + + controller.handleModifiersChanged([]) + + #expect(recorder.committed.isEmpty) + } + + @Test("A quick tap never draws the list, and a held switch draws it after the delay") + func pickerWaitsForTheDelay() async throws { + let recorder = Recorder() + let controller = makeController(recorder, pickerDelay: 0) + begin(controller, recorder, trigger: try controlTab()) + #expect(presenter.presented == nil) + + try await Task.sleep(for: .milliseconds(200)) + + #expect(presenter.presented?.session.highlightedIndex == 1) + controller.handleModifiersChanged([]) + #expect(presenter.dismissCount == 1) + } + + @Test("Another panel replacing the list ends the switch without switching") + func panelCloseCancels() async throws { + let recorder = Recorder() + let controller = makeController(recorder, pickerDelay: 0) + begin(controller, recorder, trigger: try controlTab()) + try await Task.sleep(for: .milliseconds(200)) + let onClose = try #require(presenter.onClose) + + onClose() + + #expect(controller.isActive == false) + #expect(recorder.committed.isEmpty) + } + + @Test("While a switch is held the editor's key chain hands its keys to it") + func editorChainDefersToTheSwitch() throws { + let recorder = Recorder() + let controller = makeController(recorder) + let press = try controlTab() + #expect(RecentTabSwitcherController.claimKeyDown(press) == false) + + begin(controller, recorder, trigger: press) + + #expect(RecentTabSwitcherController.claimKeyDown(press)) + #expect(controller.session?.highlightedIndex == 2) + controller.cancel() + #expect(RecentTabSwitcherController.claimKeyDown(press) == false) + } + + @Test("With no tab on screen, the switch lands on the most recent tab rather than the second") + func noCurrentTabLandsOnTheFirstCandidate() { + let recorder = Recorder() + let controller = makeController(recorder) + + controller.begin( + candidates: list, + leadsWithCurrentTab: false, + direction: .forward, + trigger: nil, + window: nil, + isOpen: { _ in true }, + onCommit: { recorder.committed.append($0) } + ) + + #expect(recorder.committed == [list[0].reference]) + } + + /// AppKit runs no local monitor while a menu or a drag tracks, so a Control released there never + /// arrives. The next key shows it: the switch has to end and let that key through rather than + /// swallow it and every key after. + @Test("A key that arrives without the held modifier ends the switch and passes through") + func missedReleaseEndsTheSwitch() throws { + let recorder = Recorder() + let controller = makeController(recorder) + begin(controller, recorder, trigger: try controlTab()) + + let consumed = controller.handleKeyDown(keyCode: KeyCode.a.rawValue, modifiers: []) + + #expect(consumed == false) + #expect(controller.isActive == false) + #expect(recorder.committed.isEmpty) + } + + @Test("One tab is nothing to switch to") + func singleTabDoesNothing() throws { + let recorder = Recorder() + let controller = makeController(recorder) + + controller.begin( + candidates: Array(list.prefix(1)), + direction: .forward, + trigger: try controlTab(), + window: nil, + isOpen: { _ in true }, + onCommit: { recorder.committed.append($0) } + ) + + #expect(controller.isActive == false) + #expect(recorder.committed.isEmpty) + } +} diff --git a/TableProTests/Core/RecentTabs/RecentTabSwitcherSessionTests.swift b/TableProTests/Core/RecentTabs/RecentTabSwitcherSessionTests.swift new file mode 100644 index 0000000000..4350f08962 --- /dev/null +++ b/TableProTests/Core/RecentTabs/RecentTabSwitcherSessionTests.swift @@ -0,0 +1,201 @@ +// +// RecentTabSwitcherSessionTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import Testing + +private func candidates(_ count: Int) -> [RecentTabCandidate] { + let connection = UUID() + return (0.. RecentTabSwitcherKeyCommand { + RecentTabSwitcherKeyCommand.resolve(keyCode: key.rawValue, modifiers: modifiers, forward: forward, backward: backward) + } + + @Test("The two chords step forward and back") + func chordsStep() { + #expect(resolve(.tab, .control) == .step(.forward)) + #expect(resolve(.tab, [.control, .shift]) == .step(.backward)) + } + + @Test("Shift reverses the forward chord even when nothing is bound to the reverse one") + func shiftReversesWithoutAReverseBinding() { + #expect(resolve(.tab, [.control, .shift], backward: nil) == .step(.backward)) + } + + @Test("A rebound chord steps too, and the old one does nothing") + func reboundChord() { + let rebound = BoundKey.special(.space, option: true) + + #expect(resolve(.tab, .control, forward: rebound, backward: nil) == .ignore) + #expect(resolve(.space, .option, forward: rebound, backward: nil) == .step(.forward)) + #expect(resolve(.space, [.option, .shift], forward: rebound, backward: nil) == .step(.backward)) + } + + /// A chord can be rebound to Control-Return or Control-Escape. Read as commit or cancel, every + /// press after the first would end the switch it started. + @Test("A binding on Return or Escape steps instead of committing or cancelling") + func bindingsWinOverControlKeys() { + #expect(resolve(.return, .control, forward: .special(.return, control: true), backward: nil) == .step(.forward)) + #expect(resolve(.escape, .control, forward: .special(.escape, control: true), backward: nil) == .step(.forward)) + #expect(resolve(.return, .control, forward: .special(.tab, control: true), backward: nil) == .commit) + } + + @Test("Escape cancels, Return commits, and the arrows move") + func controlKeys() { + #expect(resolve(.escape, .control) == .cancel) + #expect(resolve(.return, .control) == .commit) + #expect(resolve(.enter, []) == .commit) + #expect(resolve(.upArrow, []) == .step(.backward)) + #expect(resolve(.downArrow, []) == .step(.forward)) + } + + @Test("Any other key is swallowed") + func otherKeysAreIgnored() { + #expect(resolve(.a, .control) == .ignore) + #expect(resolve(.tab, [.control, .option]) == .ignore) + } + + @Test("The held modifiers come from the key press, never from Shift or a pointer") + func heldModifiers() throws { + let press = try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.control, .shift], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "\u{19}", + charactersIgnoringModifiers: "\u{19}", + isARepeat: false, + keyCode: KeyCode.tab.rawValue + )) + + #expect(RecentTabSwitcherKeyCommand.heldModifiers(of: press) == .control) + #expect(RecentTabSwitcherKeyCommand.heldModifiers(of: nil).isEmpty) + } + + @Test("Letting go of any held modifier ends the switch") + func releases() { + #expect(RecentTabSwitcherKeyCommand.releases([], held: .control)) + #expect(RecentTabSwitcherKeyCommand.releases(.command, held: [.command, .option])) + #expect(RecentTabSwitcherKeyCommand.releases([.control, .shift], held: .control) == false) + } +} diff --git a/TableProTests/Models/KeyboardShortcutTests.swift b/TableProTests/Models/KeyboardShortcutTests.swift index d3deaedf88..1cadc86ebc 100644 --- a/TableProTests/Models/KeyboardShortcutTests.swift +++ b/TableProTests/Models/KeyboardShortcutTests.swift @@ -57,13 +57,31 @@ struct ShortcutActionDefaultsTests { @Suite("Default shortcut hygiene") struct DefaultShortcutHygieneTests { - @Test("No default uses Control without Command") + /// Control-Tab is not a system hotkey the way Control-1 is (that one switches Spaces, which is + /// why this rule exists). It is the chord AppKit itself gives tab switching in every app with + /// window tabs, so the recent-tab commands are the one exception, and only on Tab. + private static let controlTabDefaults: Set = [.switchToRecentTab, .switchToLeastRecentTab] + + @Test("No default uses Control without Command, apart from Control-Tab tab switching") func noBareControlDefaults() { for (action, key) in KeyboardSettings.defaultShortcuts where key.control && !key.command { + if Self.controlTabDefaults.contains(action), key.keyCode == KeyCode.tab.rawValue, !key.option { + continue + } Issue.record("\(action.rawValue) uses Control without Command: \(key.displayString)") } } + @Test("Recent tabs are Control-Tab and Control-Shift-Tab, and window tabs have no default") + func recentTabDefaults() { + #expect(KeyboardSettings.defaultShortcuts[.switchToRecentTab] == .special(.tab, control: true)) + #expect(KeyboardSettings.defaultShortcuts[.switchToLeastRecentTab] == .special(.tab, shift: true, control: true)) + #expect(KeyboardSettings.defaultShortcuts[.showPreviousWindowTab] == nil) + #expect(KeyboardSettings.defaultShortcuts[.showNextWindowTab] == nil) + #expect(ShortcutAction.switchToRecentTab.category == .navigation) + #expect(ShortcutAction.switchToRecentTab.context == .global) + } + @Test("No two defaults collide within overlapping contexts") func defaultsAreUniqueWithinContext() { let entries = Array(KeyboardSettings.defaultShortcuts) diff --git a/TableProTests/Models/Query/QueryTabManagerRecencyTests.swift b/TableProTests/Models/Query/QueryTabManagerRecencyTests.swift new file mode 100644 index 0000000000..81d64294d5 --- /dev/null +++ b/TableProTests/Models/Query/QueryTabManagerRecencyTests.swift @@ -0,0 +1,184 @@ +// +// QueryTabManagerRecencyTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Query tab manager records when each tab was last selected") +@MainActor +struct QueryTabManagerRecencyTests { + /// A selection is recorded once the main queue turn it happened in has finished. + private func settle() async { + try? await Task.sleep(for: .milliseconds(20)) + } + + private func makeManager(tabCount: Int) async -> QueryTabManager { + let manager = QueryTabManager() + manager.isFrontmost = true + for index in 0.. UInt64 { + manager.activationSequence[tab.id] ?? 0 + } + + @Test("Selecting a tab records it after every tab selected before it") + func selectionIsRecordedInOrder() async throws { + let manager = await makeManager(tabCount: 4) + let first = try #require(manager.tabs.first) + let second = manager.tabs[1] + + manager.selectedTabId = second.id + await settle() + manager.selectedTabId = first.id + await settle() + + #expect(sequence(of: first, in: manager) > sequence(of: second, in: manager)) + #expect(sequence(of: second, in: manager) > 0) + } + + /// Opening a table into another connection brings that connection forward and then selects the + /// new tab in the same turn, so the tab it was showing is never drawn. + @Test("A tab selected and replaced within one turn is never recorded") + func transientSelectionIsNotRecorded() async throws { + let manager = await makeManager(tabCount: 3) + let first = try #require(manager.tabs.first) + let second = manager.tabs[1] + + manager.selectedTabId = first.id + manager.selectedTabId = second.id + await settle() + + #expect(manager.activationSequence[first.id] == nil) + #expect(sequence(of: second, in: manager) > 0) + } + + @Test("Writing the selection it already has records nothing new") + func reselectingTheSameTabIsNotAUse() async throws { + let manager = await makeManager(tabCount: 2) + let selected = try #require(manager.selectedTab) + let before = sequence(of: selected, in: manager) + + manager.selectedTabId = selected.id + await settle() + + #expect(sequence(of: selected, in: manager) == before) + } + + @Test("Editing a tab's content is not a use of it") + func mutatingContentRecordsNothing() async throws { + let manager = await makeManager(tabCount: 2) + let before = manager.activationSequence + + manager.tabs[0].title = "Renamed" + await settle() + + #expect(manager.activationSequence == before) + } + + @Test("A closed tab leaves the record, and the neighbour the close lands on joins it") + func closingPrunesAndRecordsTheSuccessor() async throws { + let manager = await makeManager(tabCount: 3) + let middle = try #require(manager.tabs.dropFirst().first) + let last = try #require(manager.tabs.last) + manager.selectedTabId = middle.id + await settle() + let beforeClose = sequence(of: middle, in: manager) + + manager.closeTab(id: middle.id) + await settle() + + #expect(manager.activationSequence[middle.id] == nil) + #expect(sequence(of: last, in: manager) > beforeClose) + } + + /// A restore that finishes for a connection the window is not showing selects a tab nobody has + /// seen. Recorded, it would be where the next Control-Tab went instead of the tab the user left. + @Test("A selection made while the connection is in the background is not a use") + func backgroundSelectionIsNotRecorded() async throws { + let manager = await makeManager(tabCount: 2) + let first = try #require(manager.tabs.first) + let before = manager.activationSequence + manager.isFrontmost = false + + manager.selectedTabId = first.id + await settle() + + #expect(manager.activationSequence == before) + } + + @Test("Coming to the front records the tab the connection is showing") + func comingToTheFrontRecordsTheSelection() async throws { + let manager = await makeManager(tabCount: 2) + let first = try #require(manager.tabs.first) + manager.isFrontmost = false + manager.selectedTabId = first.id + let other = await makeManager(tabCount: 1) + + manager.isFrontmost = true + await settle() + + #expect(sequence(of: first, in: manager) > (other.activationSequence.values.max() ?? 0)) + } + + @Test("Going to the back and front again within one turn records nothing") + func flickerRecordsNothing() async throws { + let manager = await makeManager(tabCount: 2) + let before = manager.activationSequence + + manager.isFrontmost = false + let first = try #require(manager.tabs.first) + manager.selectedTabId = first.id + manager.isFrontmost = true + manager.isFrontmost = false + await settle() + + #expect(manager.activationSequence == before) + } + + /// A per-manager counter would pass a weaker version of this: each manager's own numbers still + /// order its own tabs. Only a shared counter lets one connection's fresh tab outrank another + /// connection's many older selections, which is what ordering a whole window needs. + @Test("Two tab managers draw from one sequence, so a window can order their tabs together") + func sequenceIsSharedAcrossManagers() async throws { + let one = await makeManager(tabCount: 1) + let two = await makeManager(tabCount: 3) + for tab in two.tabs { + two.selectedTabId = tab.id + await settle() + } + let firstTab = try #require(one.tabs.first) + one.isFrontmost = false + + one.isFrontmost = true + await settle() + + #expect(sequence(of: firstTab, in: one) > (two.activationSequence.values.max() ?? 0)) + } + + /// The rail switches a connection's database before it selects the tab that database holds, and + /// the tab shown while the switch runs is only a waypoint. + @Test("A held record skips the tab passed through and records the one landed on") + func deferredRecordSkipsTheWaypoint() async throws { + let manager = await makeManager(tabCount: 3) + let waypoint = manager.tabs[0] + let landing = manager.tabs[1] + manager.defersActivationRecord = true + + manager.selectedTabId = waypoint.id + await settle() + manager.selectedTabId = landing.id + manager.defersActivationRecord = false + await settle() + + #expect(manager.activationSequence[waypoint.id] == nil) + #expect(sequence(of: landing, in: manager) > 0) + } +} diff --git a/TableProTests/Models/ShortcutUniquenessTests.swift b/TableProTests/Models/ShortcutUniquenessTests.swift index 94c49aa5f0..eec48f6dce 100644 --- a/TableProTests/Models/ShortcutUniquenessTests.swift +++ b/TableProTests/Models/ShortcutUniquenessTests.swift @@ -50,14 +50,15 @@ struct ShortcutUniquenessTests { } } - /// Fourteen actions ship with nothing bound: the six that always did, and the eight the connection - /// window's revamp made rebindable for the first time. Counted rather than listed, because the - /// number is the claim: adding a default to one of them is a decision about a combo that is - /// already taken, and it has to be made on purpose. - @Test("Fourteen actions ship unbound") + /// Sixteen actions ship with nothing bound: the six that always did, the eight the connection + /// window's revamp made rebindable for the first time, and the two window-tab commands, whose + /// Control-Tab went to the recent-tab switcher. Counted rather than listed, because the number is + /// the claim: adding a default to one of them is a decision about a combo that is already taken, + /// and it has to be made on purpose. + @Test("Sixteen actions ship unbound") func unboundActionsAreCounted() { let unbound = ShortcutAction.allCases.filter { KeyboardSettings.defaultShortcuts[$0] == nil } - #expect(unbound.count == 14, "Unbound: \(unbound.map(\.rawValue).sorted())") + #expect(unbound.count == 16, "Unbound: \(unbound.map(\.rawValue).sorted())") for action in unbound { #expect(KeyboardSettings.default.shortcut(for: action) == nil, "\(action.rawValue)") } diff --git a/TableProUITests/RecentTabSwitchingUITests.swift b/TableProUITests/RecentTabSwitchingUITests.swift new file mode 100644 index 0000000000..a5aed77194 --- /dev/null +++ b/TableProUITests/RecentTabSwitchingUITests.swift @@ -0,0 +1,120 @@ +// +// RecentTabSwitchingUITests.swift +// TableProUITests +// +// Control-Tab walks the window's tabs in the order they were used, not the order the strip draws +// them. (#2524) +// + +import AppKit +import XCTest + +final class RecentTabSwitchingUITests: UITestCase { + /// One launch for the whole gesture, since each phase leaves the order the next one starts from. + /// + /// After opening Album, Artist, Customer and Employee, then selecting Album and Employee, the + /// order is Employee, Album, Customer, Artist. A tap goes back one tab; holding Control and + /// pressing Tab twice goes back two, and the list shows while Control is held. + func testControlTabWalksTabsInTheOrderTheyWereUsed() throws { + let app = try launchWithSampleDatabase() + let window = try readyWindow(of: app) + + openTables(["Album", "Artist", "Customer", "Employee"], in: window) + XCTAssertTrue( + waitForPredicate(timeout: 20) { + Set(self.tabLabels(in: window)).isSuperset(of: ["Album", "Artist", "Customer", "Employee"]) + }, + "The strip must show a tab per opened table, got \(tabLabels(in: window))" + ) + + select("Album", in: window) + select("Employee", in: window) + + app.typeKey(.tab, modifierFlags: .control) + XCTAssertTrue( + waitForSelection("Album", in: window), + "A tap must go back to Album, the tab used before Employee, not its strip neighbour. Got " + + (selectedTabLabel(in: window) ?? "none") + ) + + app.typeKey(.tab, modifierFlags: .control) + XCTAssertTrue(waitForSelection("Employee", in: window), "A second tap must come back to Employee") + + let panel = switcherPanel(in: app) + XCUIElement.perform(withKeyModifiers: .control) { + app.typeKey(.tab, modifierFlags: []) + XCTAssertTrue(panel.waitToExist(timeout: 10), "Holding Control must show the list of recent tabs") + app.typeKey(.tab, modifierFlags: []) + } + XCTAssertTrue( + waitForSelection("Customer", in: window), + "Two presses while holding Control must land two tabs back, on Customer. Got " + + (selectedTabLabel(in: window) ?? "none") + ) + XCTAssertTrue( + waitForPredicate(timeout: 10) { !panel.exists }, + "Letting go of Control must close the list" + ) + + XCUIElement.perform(withKeyModifiers: .control) { + app.typeKey(.tab, modifierFlags: []) + app.typeKey(.escape, modifierFlags: []) + } + Thread.sleep(forTimeInterval: 1) + XCTAssertEqual(selectedTabLabel(in: window), "Customer", "Escape must end the switch where it started") + } + + // MARK: - Helpers + + private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { + let window = app.windows.firstMatch + XCTAssertTrue(window.waitToExist(timeout: 30)) + XCTAssertTrue( + waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, + "The object browser must list the sample database's tables" + ) + return window + } + + /// Double-clicked, because a single click opens a preview tab that the next table takes over. + private func openTables(_ names: [String], in window: XCUIElement) { + for name in names { + let row = objectBrowserRow(name, in: window) + XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list \(name)") + row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() + Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) + } + } + + private func tabElements(in window: XCUIElement) -> [XCUIElement] { + window.descendants(matching: .any) + .matching(identifier: "editor-tab") + .allElementsBoundByIndex + } + + private func tabLabels(in window: XCUIElement) -> [String] { + tabElements(in: window).map { $0.label } + } + + private func selectedTabLabel(in window: XCUIElement) -> String? { + tabElements(in: window).first { $0.isSelected }?.label + } + + private func waitForSelection(_ name: String, in window: XCUIElement) -> Bool { + waitForPredicate(timeout: 10) { self.selectedTabLabel(in: window) == name } + } + + private func select(_ name: String, in window: XCUIElement) { + let tab = window.descendants(matching: .any) + .matching(identifier: "editor-tab") + .matching(NSPredicate(format: "label == %@", name)) + .firstMatch + XCTAssertTrue(waitUntilHittable(tab, timeout: 20), "The \(name) tab must be on screen") + tab.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click() + XCTAssertTrue(waitForSelection(name, in: window), "Clicking the \(name) tab must select it") + } + + private func switcherPanel(in app: XCUIApplication) -> XCUIElement { + app.children(matching: .any).matching(identifier: "recent-tab-switcher-panel").firstMatch + } +} diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 8816753709..f40e7bc294 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -183,6 +183,9 @@ See [Filtering](/features/filtering) for the filter bar itself. | Select tab 1-9 | `Cmd+1` through `Cmd+9` | | Previous tab | `Cmd+Shift+[` | | Next tab | `Cmd+Shift+]` | +| Switch to recent tab | `Ctrl+Tab` | +| Switch to least recent tab | `Ctrl+Shift+Tab` | +| Previous / next window tab | none by default, rebindable | | Minimize | `Cmd+M` | | Zoom | none | | Move tab to new window | none | diff --git a/docs/features/tabs.mdx b/docs/features/tabs.mdx index 818d9cf740..ce30961b55 100644 --- a/docs/features/tabs.mdx +++ b/docs/features/tabs.mdx @@ -33,6 +33,8 @@ Both stage cell edits as pending changes. A query tab does it only when its quer | Go to a tab by position | `Cmd+1` through `Cmd+9` | | Previous tab | `Cmd+Shift+[` | | Next tab | `Cmd+Shift+]` | +| Tab used before this one | `Ctrl+Tab` | +| Tab used longest ago | `Ctrl+Shift+Tab` | **Close Other Tabs**, **Close All Tabs**, and **Close Tabs for Other Databases** ship unbound. Bind them in **Settings > Keyboard** under Navigation. @@ -101,7 +103,18 @@ The strip keeps one row and scrolls it, the way every macOS tab bar does. Scroll ## Switching tabs -`Cmd+1` through `Cmd+9` jump by position, `Cmd+Shift+[` and `Cmd+Shift+]` step, and **Window > Show Previous Tab** and **Window > Show Next Tab** do the same from the menu bar. A tab scrolled out of sight is pulled back into view. Switching keeps SQL, cursor, results, scroll position, sort, filters, and pending changes. +`Cmd+1` through `Cmd+9` jump by position, `Cmd+Shift+[` and `Cmd+Shift+]` step along the strip, and **Window > Show Previous Tab** and **Window > Show Next Tab** do the same from the menu bar. A tab scrolled out of sight is pulled back into view. Switching keeps SQL, cursor, results, scroll position, sort, filters, and pending changes. + +`Ctrl+Tab` goes back to the tab you used before this one, wherever it sits in the strip. Press it again to return. The order covers every connection in the window, so it also bounces between a tab on one connection and a tab on another. + +To reach further back, keep `Control` held and press `Tab` again. After a moment the window's tabs appear in a list, most recent first, and letting go of `Control` switches to the highlighted one. `Shift` walks the list the other way and `Esc` stays where you started. `Ctrl+Shift+Tab` starts at the tab used longest ago. + + + A floating list of five tabs over the connection window, the second one highlighted, each with its database or connection beside it + A floating list of five tabs over the connection window, the second one highlighted, each with its database or connection beside it + + +Both commands are in the **Window** menu as **Switch to Recent Tab** and **Switch to Least Recent Tab**. Chosen there with the pointer, or bound in **Settings > Keyboard** to a function key with no modifier, each one switches straight to its tab without showing the list. Tabs behind Agent mode or a connection that is not connected are left out. In a window with no other editor tab to go to, `Ctrl+Tab` and `Ctrl+Shift+Tab` switch the window's own tabs instead, when it is part of a macOS window tab group. Loaded rows are the exception, and only in bulk. Background table tabs keep their rows up to a budget read from the Mac's memory: two under 8 GB, three at 8 GB, five at 16 GB, eight at 32 GB or more, halved under memory pressure. Past that, the tabs you touched longest ago drop their rows and re-query when you return. Thirty table tabs cost one query on return, not thirty resident result sets. diff --git a/docs/features/vim-mode.mdx b/docs/features/vim-mode.mdx index 80c76ff3f6..832d66cbf9 100644 --- a/docs/features/vim-mode.mdx +++ b/docs/features/vim-mode.mdx @@ -207,7 +207,7 @@ Outside insert mode, a `Ctrl` letter goes to vim, never to the editor's own bind | `Ctrl+R` | Redo | Nothing | | `Ctrl+H` `Ctrl+N` `Ctrl+P` | Same as `h`, `j`, `k` | Same as `h`, `j`, `k` | -Any other `Ctrl` letter does nothing. That includes `Ctrl+E` and `Ctrl+Y`, which like `zt`, `zz` and `zb` are read and discarded: the view scrolls only to follow the cursor. `Ctrl+Space` still opens completions, and `Ctrl+Tab` and `Ctrl+Shift+Tab` still move focus to the next and previous control. A shortcut of `Ctrl` and a letter assigned in **Settings > Keyboard** does not fire from the SQL editor outside insert mode. +Any other `Ctrl` letter does nothing. That includes `Ctrl+E` and `Ctrl+Y`, which like `zt`, `zz` and `zb` are read and discarded: the view scrolls only to follow the cursor. `Ctrl+Space` still opens completions, and `Ctrl+Tab` and `Ctrl+Shift+Tab` still [switch tabs](/features/tabs#switching-tabs). To leave the editor from the keyboard, use a command under **View > Focus**. A shortcut of `Ctrl` and a letter assigned in **Settings > Keyboard** does not fire from the SQL editor outside insert mode. In insert and replace mode, vim takes these and leaves every other `Ctrl` combination to the editor: diff --git a/docs/features/workspace-rail.mdx b/docs/features/workspace-rail.mdx index 649608b5df..5002d56316 100644 --- a/docs/features/workspace-rail.mdx +++ b/docs/features/workspace-rail.mdx @@ -56,7 +56,7 @@ One window hosts every connection you have open, and the tab strip below the too To put two connections side by side, right-click an entry and choose **Open in New Window**. It moves with its tabs, its session, and anything unsaved. The item is absent when the window has no other connection left to show. -A first window opens at 1200x800 and goes no smaller than 720x480; later windows reuse the last size and position. Whether a new one joins a tab group follows **Prefer tabs when opening documents** in System Settings > Desktop & Dock. Inside a group, **Window > Move Tab to New Window** and **Window > Merge All Windows** apply; both dim outside one. Those are macOS window tabs, which hold whole windows, not the editor tab strip, which holds one connection's editors. +A first window opens at 1200x800 and goes no smaller than 720x480; later windows reuse the last size and position. Whether a new one joins a tab group follows **Prefer tabs when opening documents** in System Settings > Desktop & Dock. Inside a group, **Window > Show Previous Window Tab**, **Window > Show Next Window Tab**, **Window > Move Tab to New Window** and **Window > Merge All Windows** apply; all four dim outside one. `Ctrl+Tab` goes to editor tabs first and switches window tabs only when the window has no other editor tab to go to. Those are macOS window tabs, which hold whole windows, not the editor tab strip, which holds one connection's editors. ## Closing and disconnecting diff --git a/docs/images/recent-tabs-dark.png b/docs/images/recent-tabs-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..8cb5160b8a98e0b8e5e5ea0691ff25be6f9eb6cc GIT binary patch literal 22823 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 K#keDvZvQWz!%&p~ literal 0 HcmV?d00001 diff --git a/docs/images/recent-tabs.png b/docs/images/recent-tabs.png new file mode 100644 index 0000000000000000000000000000000000000000..8c8936c915a7e8240f6ef6b64d15b79ef1b34378 GIT binary patch literal 22869 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 THf