Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
- **Extensions** for SQLite and local libSQL connections, loading sqlite-vec, SpatiaLite and other libraries on connect. (#2502)

### Changed
Expand All @@ -60,6 +61,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.
- SQLite 3.53.4 built into the SQLite and libSQL drivers in place of the macOS copy.

### Removed
Expand Down Expand Up @@ -323,6 +325,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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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])
}
}
Original file line number Diff line number Diff line change
@@ -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")
}
}
2 changes: 2 additions & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
13 changes: 13 additions & 0 deletions TablePro/Core/Menu/AppDelegate+MainMenuActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 34 additions & 2 deletions TablePro/Core/Menu/MainMenuBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
Expand Down
41 changes: 36 additions & 5 deletions TablePro/Core/Menu/WindowMenuBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(_:))
Expand Down
Loading
Loading