diff --git a/CHANGELOG.md b/CHANGELOG.md index c61ec2e1d..730bf80af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/TablePro/Extensions/View+OptionalShortcut.swift b/TablePro/Extensions/View+OptionalShortcut.swift index 51554d4e7..1022a4343 100644 --- a/TablePro/Extensions/View+OptionalShortcut.swift +++ b/TablePro/Extensions/View+OptionalShortcut.swift @@ -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) + } } diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index ef48cc3c2..23a9bf73c 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -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. @@ -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 } } } diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 7e36b70cc..2cb8adf29 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -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") { @@ -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() @@ -500,25 +500,25 @@ 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() @@ -526,7 +526,7 @@ struct AppMenuCommands: Commands { Button(String(localized: "Save as Favorite")) { actions?.saveAsFavorite() } - .optionalKeyboardShortcut(shortcut(for: .saveAsFavorite)) + .dataGridShortcut(.saveAsFavorite, keyboard: settingsManager.keyboard, yieldingTo: actions) .disabled(!(actions?.canSaveAsFavorite ?? false)) Divider() @@ -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...") { @@ -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() @@ -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) } diff --git a/TablePro/Views/Main/MainContentCommandActions+TextInputFocus.swift b/TablePro/Views/Main/MainContentCommandActions+TextInputFocus.swift new file mode 100644 index 000000000..3f5d179be --- /dev/null +++ b/TablePro/Views/Main/MainContentCommandActions+TextInputFocus.swift @@ -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 + } +} diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 395028adf..2e988aebf 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -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] = [] @@ -81,6 +95,9 @@ final class MainContentCommandActions { for task in notificationTasks { task.cancel() } + if let textInputFocusObserver { + NotificationCenter.default.removeObserver(textInputFocusObserver) + } } // MARK: - Async Notification Helper diff --git a/TableProTests/Models/KeyboardShortcutTests.swift b/TableProTests/Models/KeyboardShortcutTests.swift index 160fa14cd..b7773743e 100644 --- a/TableProTests/Models/KeyboardShortcutTests.swift +++ b/TableProTests/Models/KeyboardShortcutTests.swift @@ -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") diff --git a/TableProTests/Views/Main/CommandActionsFocusGateTests.swift b/TableProTests/Views/Main/CommandActionsFocusGateTests.swift new file mode 100644 index 000000000..02a8f5b9d --- /dev/null +++ b/TableProTests/Views/Main/CommandActionsFocusGateTests.swift @@ -0,0 +1,95 @@ +// +// CommandActionsFocusGateTests.swift +// TableProTests +// +// Pins the focus gate that stops a data-grid menu shortcut from claiming a +// keystroke the focused text input already owns. +// + +import Foundation +import SwiftUI +@testable import TablePro +import Testing + +@MainActor @Suite("CommandActions focus gate") +struct CommandActionsFocusGateTests { + private func makeSUT() -> MainContentCommandActions { + let connection = TestFixtures.makeConnection() + let state = SessionStateFactory.create(connection: connection, payload: nil) + let coordinator = state.coordinator + + var selectedTables: Set = [] + var pendingTruncates: Set = [] + var pendingDeletes: Set = [] + var tableOperationOptions: [String: TableOperationOptions] = [:] + + return MainContentCommandActions( + coordinator: coordinator, + connection: connection, + selectionState: coordinator.selectionState, + selectedTables: Binding(get: { selectedTables }, set: { selectedTables = $0 }), + pendingTruncates: Binding(get: { pendingTruncates }, set: { pendingTruncates = $0 }), + pendingDeletes: Binding(get: { pendingDeletes }, set: { pendingDeletes = $0 }), + tableOperationOptions: Binding( + get: { tableOperationOptions }, + set: { tableOperationOptions = $0 } + ), + rightPanelState: RightPanelState() + ) + } + + @Test("A fresh instance reports no text input focus") + func defaultsToNoTextInputFocus() { + #expect(!makeSUT().focusOwnsTextInput) + } + + @Test("Delete keeps Cmd+Delete while the grid holds focus") + func deleteKeepsShortcutWithoutTextFocus() { + let actions = makeSUT() + actions.focusOwnsTextInput = false + + #expect(!actions.yieldsToFocusedTextInput(.delete, boundKey: .special(.delete, command: true))) + } + + @Test("Delete yields Cmd+Delete to a focused text input") + func deleteYieldsToTextFocus() { + let actions = makeSUT() + actions.focusOwnsTextInput = true + + #expect(actions.yieldsToFocusedTextInput(.delete, boundKey: .special(.delete, command: true))) + } + + @Test("Truncate Table yields Option+Delete to a focused text input") + func truncateYieldsToTextFocus() { + let actions = makeSUT() + actions.focusOwnsTextInput = true + + #expect(actions.yieldsToFocusedTextInput(.truncateTable, boundKey: .special(.delete, option: true))) + } + + @Test("A grid action with no colliding binding keeps its shortcut under text focus") + func nonCollidingGridActionKeepsShortcut() { + let actions = makeSUT() + actions.focusOwnsTextInput = true + + #expect( + !actions.yieldsToFocusedTextInput(.addRow, boundKey: .character("n", command: true, shift: true)) + ) + } + + @Test("An editor action keeps its shortcut under text focus") + func editorActionKeepsShortcut() { + let actions = makeSUT() + actions.focusOwnsTextInput = true + + #expect(!actions.yieldsToFocusedTextInput(.executeQuery, boundKey: .special(.delete, command: true))) + } + + @Test("An unbound grid action yields nothing") + func unboundGridActionYieldsNothing() { + let actions = makeSUT() + actions.focusOwnsTextInput = true + + #expect(!actions.yieldsToFocusedTextInput(.delete, boundKey: nil)) + } +} diff --git a/TableProUITests/QueryTabDeleteLineUITests.swift b/TableProUITests/QueryTabDeleteLineUITests.swift new file mode 100644 index 000000000..f388a13a2 --- /dev/null +++ b/TableProUITests/QueryTabDeleteLineUITests.swift @@ -0,0 +1,102 @@ +import XCTest + +final class QueryTabDeleteLineUITests: XCTestCase { + private let query = "SELECT * FROM Genre;" + + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().terminate() + } + + func testCommandDeleteDeletesTheEditorLineAfterRunningAQuery() throws { + let app = launchWithSampleDatabase() + let editor = openQueryTab(in: app) + + app.typeText(query) + XCTAssertTrue(waitForValue(query, in: editor, timeout: 5)) + executeQuery(in: app) + + app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: .command) + + XCTAssertTrue( + waitForValue("", in: editor, timeout: 5), + "Cmd+Delete must delete to the start of the line; got '\(editor.value as? String ?? "nil")'" + ) + } + + func testCommandDeleteDeletesTheEditorLineAfterSelectingAResultRow() throws { + let app = launchWithSampleDatabase() + let editor = openQueryTab(in: app) + + app.typeText(query) + XCTAssertTrue(waitForValue(query, in: editor, timeout: 5)) + executeQuery(in: app) + + let firstRow = app.windows.firstMatch.tables.firstMatch.tableRows.element(boundBy: 0) + XCTAssertTrue(firstRow.waitForExistence(timeout: 10)) + firstRow.click() + + editor.click() + app.typeKey(XCUIKeyboardKey.rightArrow.rawValue, modifierFlags: .command) + app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: .command) + + XCTAssertTrue( + waitForValue("", in: editor, timeout: 5), + "A selected result row must not steal Cmd+Delete from the editor; got " + + "'\(editor.value as? String ?? "nil")'" + ) + } + + private func launchWithSampleDatabase() -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["TABLEPRO_UI_TESTING"] = "1" + app.launch() + + let menuBar = app.menuBars.firstMatch + XCTAssertTrue(menuBar.waitForExistence(timeout: 10)) + menuBar.menuBarItems["File"].click() + let openSample = menuBar.menuItems["Open Sample Database"] + XCTAssertTrue(openSample.waitForExistence(timeout: 5)) + openSample.click() + + XCTAssertTrue(editorTextView(in: app).waitForExistence(timeout: 15)) + return app + } + + private func openQueryTab(in app: XCUIApplication) -> XCUIElement { + app.typeKey("t", modifierFlags: .command) + let editor = editorTextView(in: app) + XCTAssertTrue(editor.waitForExistence(timeout: 10)) + XCTAssertTrue(waitForValue("", in: editor, timeout: 5), "A new tab starts with an empty editor") + return editor + } + + private func executeQuery(in app: XCUIApplication) { + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: .command) + let results = app.windows.firstMatch.tables.firstMatch + XCTAssertTrue(results.waitForExistence(timeout: 15), "The query must produce a result grid") + } + + private func editorTextView(in app: XCUIApplication) -> XCUIElement { + let window = app.windows.firstMatch + let identified = window.textViews.matching(identifier: "sql-editor-textview").firstMatch + if identified.exists { + return identified + } + return window.textViews.firstMatch + } + + private func waitForValue(_ expected: String, in element: XCUIElement, timeout: TimeInterval) -> Bool { + let deadline = Date(timeIntervalSinceNow: timeout) + while Date() < deadline { + if (element.value as? String) == expected { + return true + } + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) + } + return (element.value as? String) == expected + } +} diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 5a7974bb1..cb2c50e26 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -93,6 +93,10 @@ Every row except Find Next and Find Previous is built into the editor and cannot Truncate table opens a dialog with **Cascade** and **Ignore foreign key checks**, then marks the tables as a pending truncate that `Cmd+S` runs. + +`Cmd+Delete` and `Option+Delete` step aside while the SQL editor or a text field has focus. There they keep their standard macOS meaning: delete to the start of the line, and delete the previous word. The grid commands come back as soon as you click into the grid, and `Delete` on its own still deletes the selected rows. + + ### Clipboard | Action | Shortcut |