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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `Cmd+Delete` in the SQL editor deletes to the start of the line again. (#2022)
- `Option+Delete` in the SQL editor deletes the previous word again. (#2022)
- Refreshing a table no longer fails with "Query cancelled" on every second click. (#2021)
- Stopping a query no longer shows a red error or records the query as failed in history.
- Stopping a MySQL, MariaDB, or Redis query no longer cancels the next one you run.
Expand Down
16 changes: 16 additions & 0 deletions TablePro/Extensions/View+OptionalShortcut.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,20 @@ internal extension View {
self
}
}

/// Apply a data-grid action's shortcut, except while a focused text input owns
/// that combination. AppKit matches a menu key equivalent before the first
/// responder sees `keyDown`, and it consumes the event whether the item is
/// enabled or not, so dropping the key equivalent is the only way to hand the
/// keystroke back. Disabling as well shows the command as unavailable.
/// Naming the action once keeps the shortcut and the guard from drifting apart.
func dataGridShortcut(
_ action: ShortcutAction,
keyboard: KeyboardSettings,
yieldingTo actions: MainContentCommandActions?
) -> some View {
let yields = actions?.yieldsToFocusedTextInput(action, boundKey: keyboard.shortcut(for: action)) == true
return optionalKeyboardShortcut(yields ? nil : keyboard.keyboardShortcut(for: action))
.disabled(yields)
}
}
36 changes: 33 additions & 3 deletions TablePro/Models/UI/KeyboardShortcutModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,24 @@ extension ShortcutAction {
(.special(.downArrow, option: true), String(localized: "Move Line Down"))
]

/// AppKit's own text-editing key bindings, taken from `StandardKeyBinding.dict`.
/// The editor's local key monitor claims `editorBuiltIns` before the menu bar
/// sees them, but these resolve at the very end of dispatch, inside the focused
/// responder's `interpretKeyEvents`. An enabled menu key equivalent is matched
/// long before that, so a data-grid action bound to one of these has to stand
/// down while a text input holds focus.
static let standardTextEditingBindings: [(key: BoundKey, name: String)] = [
(.special(.delete, command: true), String(localized: "Delete to Beginning of Line")),
(.special(.delete, option: true), String(localized: "Delete Word Backward")),
(.special(.forwardDelete, option: true), String(localized: "Delete Word Forward")),
(.special(.leftArrow, command: true), String(localized: "Move to Beginning of Line")),
(.special(.rightArrow, command: true), String(localized: "Move to End of Line")),
(.special(.upArrow, command: true), String(localized: "Move to Beginning of Document")),
(.special(.downArrow, command: true), String(localized: "Move to End of Document")),
(.special(.leftArrow, option: true), String(localized: "Move Word Left")),
(.special(.rightArrow, option: true), String(localized: "Move Word Right"))
]

/// App-level shortcuts that are wired directly in the menu and are not
/// customizable: tab selection (Cmd+1 through Cmd+9) and editor zoom. These
/// fire regardless of focus, so a user binding would silently collide.
Expand All @@ -283,14 +301,26 @@ extension ShortcutAction {
}()

/// The name of a reserved command this combo would shadow: an app-level menu
/// shortcut (always), or a built-in editor command when the action can fire
/// while the editor is focused.
/// shortcut (always), or a built-in editor or system text command when the
/// action can fire while the editor is focused.
static func reservedConflict(for key: BoundKey, context: ShortcutContext) -> String? {
if let appName = reservedAppShortcuts.first(where: { $0.key == key })?.name {
return appName
}
guard context == .editor || context == .global else { return nil }
return editorBuiltIns.first(where: { $0.key == key })?.name
if let editorName = editorBuiltIns.first(where: { $0.key == key })?.name {
return editorName
}
return standardTextEditingBindings.first(where: { $0.key == key })?.name
}

/// Whether this action's binding duplicates one of AppKit's standard
/// text-editing bindings, which the focused responder owns. Only data-grid
/// actions yield: an editor or global action is meant to fire while text is
/// focused, so it keeps its key equivalent and the recorder warns instead.
func shadowsStandardTextEditingBinding(_ key: BoundKey?) -> Bool {
guard context == .dataGrid, let key, !key.isCleared else { return false }
return Self.standardTextEditingBindings.contains { $0.key == key }
}
}

Expand Down
26 changes: 13 additions & 13 deletions TablePro/TableProApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,19 +53,19 @@ struct PasteboardCommands: Commands {
actions?.copySelectedRows()
}
}
.optionalKeyboardShortcut(shortcut(for: .copyRowsExplicit))
.dataGridShortcut(.copyRowsExplicit, keyboard: settingsManager.keyboard, yieldingTo: actions)
.disabled(!(actions?.hasRowSelection ?? false))

Button("Copy with Headers") {
actions?.copySelectedRowsWithHeaders()
}
.optionalKeyboardShortcut(shortcut(for: .copyWithHeaders))
.dataGridShortcut(.copyWithHeaders, keyboard: settingsManager.keyboard, yieldingTo: actions)
.disabled(!(actions?.hasRowSelection ?? false))

Button("Copy as JSON") {
actions?.copySelectedRowsAsJson()
}
.optionalKeyboardShortcut(shortcut(for: .copyAsJson))
.dataGridShortcut(.copyAsJson, keyboard: settingsManager.keyboard, yieldingTo: actions)
.disabled(!(actions?.hasRowSelection ?? false))

Button("Paste") {
Expand All @@ -81,7 +81,7 @@ struct PasteboardCommands: Commands {
Button("Delete") {
actions?.deleteSelectedRows()
}
.optionalKeyboardShortcut(shortcut(for: .delete))
.dataGridShortcut(.delete, keyboard: settingsManager.keyboard, yieldingTo: actions)
.disabled(!(actions?.isCurrentTabEditable ?? false) && !(actions?.hasTableSelection ?? false))

Divider()
Expand Down Expand Up @@ -500,33 +500,33 @@ struct AppMenuCommands: Commands {
Button(String(localized: "Previous Page")) {
actions?.goToPreviousPage()
}
.optionalKeyboardShortcut(shortcut(for: .previousPage))
.dataGridShortcut(.previousPage, keyboard: settingsManager.keyboard, yieldingTo: actions)
.disabled(!(actions?.isConnected ?? false))

Button(String(localized: "Next Page")) {
actions?.goToNextPage()
}
.optionalKeyboardShortcut(shortcut(for: .nextPage))
.dataGridShortcut(.nextPage, keyboard: settingsManager.keyboard, yieldingTo: actions)
.disabled(!(actions?.isConnected ?? false))

Button(String(localized: "First Page")) {
actions?.goToFirstPage()
}
.optionalKeyboardShortcut(shortcut(for: .firstPage))
.dataGridShortcut(.firstPage, keyboard: settingsManager.keyboard, yieldingTo: actions)
.disabled(!(actions?.isConnected ?? false))

Button(String(localized: "Last Page")) {
actions?.goToLastPage()
}
.optionalKeyboardShortcut(shortcut(for: .lastPage))
.dataGridShortcut(.lastPage, keyboard: settingsManager.keyboard, yieldingTo: actions)
.disabled(!(actions?.isConnected ?? false))

Divider()

Button(String(localized: "Save as Favorite")) {
actions?.saveAsFavorite()
}
.optionalKeyboardShortcut(shortcut(for: .saveAsFavorite))
.dataGridShortcut(.saveAsFavorite, keyboard: settingsManager.keyboard, yieldingTo: actions)
.disabled(!(actions?.canSaveAsFavorite ?? false))

Divider()
Expand All @@ -548,7 +548,7 @@ struct AppMenuCommands: Commands {
Button(String(localized: "Preview FK Reference")) {
actions?.previewFKReference()
}
.optionalKeyboardShortcut(shortcut(for: .previewFKReference))
.dataGridShortcut(.previewFKReference, keyboard: settingsManager.keyboard, yieldingTo: actions)
.disabled(!(actions?.isConnected ?? false))

Button("Switch Connection...") {
Expand Down Expand Up @@ -621,13 +621,13 @@ struct AppMenuCommands: Commands {
Button("Add Row") {
actions?.addNewRow()
}
.optionalKeyboardShortcut(shortcut(for: .addRow))
.dataGridShortcut(.addRow, keyboard: settingsManager.keyboard, yieldingTo: actions)
.disabled(!(actions?.isCurrentTabEditable ?? false) || actions?.isReadOnly ?? false)

Button("Duplicate Row") {
actions?.duplicateRow()
}
.optionalKeyboardShortcut(shortcut(for: .duplicateRow))
.dataGridShortcut(.duplicateRow, keyboard: settingsManager.keyboard, yieldingTo: actions)
.disabled(!(actions?.isCurrentTabEditable ?? false) || actions?.isReadOnly ?? false)

Divider()
Expand Down Expand Up @@ -689,7 +689,7 @@ struct AppMenuCommands: Commands {
Button("Truncate Table") {
actions?.truncateTables()
}
.optionalKeyboardShortcut(shortcut(for: .truncateTable))
.dataGridShortcut(.truncateTable, keyboard: settingsManager.keyboard, yieldingTo: actions)
.disabled(!(actions?.hasTableSelection ?? false) || actions?.isReadOnly ?? false)
}

Expand Down
64 changes: 64 additions & 0 deletions TablePro/Views/Main/MainContentCommandActions+TextInputFocus.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//
// MainContentCommandActions+TextInputFocus.swift
// TablePro
//
// Tracks whether a text input owns first responder, so a data-grid menu
// shortcut can hand a standard text-editing key back to the focused responder.
//

import AppKit
import Foundation

extension MainContentCommandActions {
/// AppKit matches a menu key equivalent before the first responder ever sees
/// `keyDown`, and it consumes the event even when the item is disabled, so a
/// grid shortcut that duplicates one of AppKit's own text-editing bindings has
/// to give up its key equivalent while a text input is focused. The keystroke
/// then reaches the responder, which runs the standard binding itself.
func yieldsToFocusedTextInput(_ action: ShortcutAction, boundKey: BoundKey?) -> Bool {
guard focusOwnsTextInput else { return false }
return action.shadowsStandardTextEditingBinding(boundKey)
}

func updateTextInputFocusTracking() {
if let textInputFocusObserver {
NotificationCenter.default.removeObserver(textInputFocusObserver)
self.textInputFocusObserver = nil
}
refreshFocusOwnsTextInput()
guard let window else { return }
textInputFocusObserver = NotificationCenter.default.addObserver(
forName: NSWindow.didUpdateNotification,
object: window,
queue: .main
) { [weak self] _ in
guard let self else { return }
MainActor.assumeIsolated {
self.scheduleTextInputFocusCheck()
}
}
}

/// `didUpdateNotification` fires once per event-loop pass, so the check is
/// coalesced onto the next run loop turn and only writes on a transition.
private func scheduleTextInputFocusCheck() {
guard !isTextInputFocusCheckScheduled else { return }
isTextInputFocusCheckScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.isTextInputFocusCheckScheduled = false
self.refreshFocusOwnsTextInput()
}
}

/// `NSTextInputClient` covers every responder that edits text: the SQL editor
/// (`CodeEditTextView.TextView` is `NSView`-based but conforms), an `NSTextView`
/// field editor over a grid cell, and the sidebar filter field. It excludes
/// `NSTableView` and `NSOutlineView`, so selecting rows or sidebar tables keeps
/// the grid commands enabled.
private func refreshFocusOwnsTextInput() {
let owns = window?.firstResponder is NSTextInputClient
guard owns != focusOwnsTextInput else { return }
focusOwnsTextInput = owns
}
}
19 changes: 18 additions & 1 deletion TablePro/Views/Main/MainContentCommandActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,24 @@ final class MainContentCommandActions {
@ObservationIgnored private let rightPanelState: RightPanelState

/// The window this instance belongs to — used for key-window guards.
@ObservationIgnored weak var window: NSWindow?
@ObservationIgnored weak var window: NSWindow? {
didSet {
guard window !== oldValue else { return }
updateTextInputFocusTracking()
}
}

// MARK: - State

/// Whether a text input holds first responder in this instance's window.
/// Stored rather than computed so Observation wakes the menu when focus
/// crosses that boundary; `NSWindow.firstResponder` publishes no change.
var focusOwnsTextInput = false

@ObservationIgnored var textInputFocusObserver: NSObjectProtocol?

@ObservationIgnored var isTextInputFocusCheckScheduled = false

/// Task handles for async notification observers; cancelled on deinit.
@ObservationIgnored private var notificationTasks: [Task<Void, Never>] = []

Expand Down Expand Up @@ -81,6 +95,9 @@ final class MainContentCommandActions {
for task in notificationTasks {
task.cancel()
}
if let textInputFocusObserver {
NotificationCenter.default.removeObserver(textInputFocusObserver)
}
}

// MARK: - Async Notification Helper
Expand Down
61 changes: 61 additions & 0 deletions TableProTests/Models/KeyboardShortcutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,67 @@ struct ReservedShortcutTests {
}
}
}

@Test("Cmd+Delete conflicts with the system delete-to-line-start binding in editor context")
func commandDeleteConflictsInEditor() {
let key = BoundKey.special(.delete, command: true)
#expect(ShortcutAction.reservedConflict(for: key, context: .editor) != nil)
}

@Test("Option+Delete conflicts with the system delete-word binding in global context")
func optionDeleteConflictsGlobally() {
let key = BoundKey.special(.delete, option: true)
#expect(ShortcutAction.reservedConflict(for: key, context: .global) != nil)
}

@Test("Cmd+Delete does not conflict in data-grid context because focus resolves it")
func commandDeleteDoesNotConflictInGrid() {
let key = BoundKey.special(.delete, command: true)
#expect(ShortcutAction.reservedConflict(for: key, context: .dataGrid) == nil)
}
}

@Suite("Standard text-editing bindings")
struct StandardTextEditingBindingTests {
@Test("Delete shadows the system delete-to-line-start binding")
func deleteShadowsCommandDelete() {
#expect(ShortcutAction.delete.shadowsStandardTextEditingBinding(.special(.delete, command: true)))
}

@Test("Truncate Table shadows the system delete-word binding")
func truncateShadowsOptionDelete() {
#expect(ShortcutAction.truncateTable.shadowsStandardTextEditingBinding(.special(.delete, option: true)))
}

@Test("A grid action bound to a bare key shadows nothing")
func bareKeyShadowsNothing() {
#expect(!ShortcutAction.delete.shadowsStandardTextEditingBinding(.special(.delete)))
}

@Test("A grid action with no colliding binding shadows nothing")
func nonCollidingGridActionShadowsNothing() {
#expect(!ShortcutAction.addRow.shadowsStandardTextEditingBinding(.character("n", command: true, shift: true)))
}

@Test("An editor action keeps its shortcut even on a colliding key")
func editorActionNeverShadows() {
#expect(!ShortcutAction.executeQuery.shadowsStandardTextEditingBinding(.special(.delete, command: true)))
}

@Test("A missing or cleared binding shadows nothing")
func missingBindingShadowsNothing() {
#expect(!ShortcutAction.delete.shadowsStandardTextEditingBinding(nil))
#expect(!ShortcutAction.delete.shadowsStandardTextEditingBinding(.cleared))
}

@Test("Only Delete and Truncate Table shadow a system binding by default")
func defaultsThatShadow() {
let shadowing = KeyboardSettings.defaultShortcuts
.filter { $0.key.shadowsStandardTextEditingBinding($0.value) }
.map(\.key.rawValue)
.sorted()
#expect(shadowing == [ShortcutAction.delete.rawValue, ShortcutAction.truncateTable.rawValue].sorted())
}
}

@Suite("Bare-key validation")
Expand Down
Loading
Loading