From 09f1086243a937e5b7a5821060e56840ace67686 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 26 Aug 2026 16:40:24 +0700 Subject: [PATCH] feat(settings): make Find a rebindable shortcut so Cmd+F can open the filter bar Claude-Session: https://claude.ai/code/session_014MXT7tDsb7Rsm1HhBnwhSj --- CHANGELOG.md | 1 + .../TextViewController+Lifecycle.swift | 4 -- TablePro/Core/Menu/EditMenuBuilder.swift | 4 +- .../Models/UI/KeyboardShortcutModels.swift | 14 ++-- .../Views/Settings/KeyboardSettingsView.swift | 4 +- .../Core/Menu/MainMenuBuilderTests.swift | 64 ++++++++++++++++++- .../Models/KeyboardShortcutTests.swift | 15 ++++- docs/features/keyboard-shortcuts.mdx | 4 +- docs/switching.mdx | 4 +- 9 files changed, 95 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8597c73277..e16a024c19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Restore Previous Values in the Edit menu, taking back a save that already committed. Starter license. (#2107) - Data Rewind settings in Settings > Data & Results, with an off switch and Clear Saved Changes. - Restore Previous Values in the toolbar's Table Actions group. +- Rebindable Find shortcut in Settings > Keyboard, for giving `Cmd+F` to the filter bar instead. ### Changed diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index 39e308626b..cf2b618e00 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -259,10 +259,6 @@ extension TextViewController { case (commandKey, "]"): handleIndent() return nil - case (commandKey, "f"): - _ = self.textView.resignFirstResponder() - self.findViewController?.showFindPanel() - return nil case ([commandKey, .shift], "D"): duplicateLine() return nil diff --git a/TablePro/Core/Menu/EditMenuBuilder.swift b/TablePro/Core/Menu/EditMenuBuilder.swift index bbfd01fe36..a814c8e865 100644 --- a/TablePro/Core/Menu/EditMenuBuilder.swift +++ b/TablePro/Core/Menu/EditMenuBuilder.swift @@ -119,8 +119,8 @@ enum EditMenuBuilder { MenuItemFactory.item( String(localized: "Find…"), action: #selector(MainSplitViewController.performFind(_:)), - keyEquivalent: "f", - modifiers: .command + shortcut: .find, + keyboard: keyboard ), MenuItemFactory.item( String(localized: "Find Next"), diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index 82ed1cdbf9..dc4f68bc66 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -82,6 +82,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case nextStatement case runStatementAndAdvance case previewSQL + case find case findNext case findPrevious case aiExplainQuery @@ -149,7 +150,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { .executeQueryWithoutLimit, .cancelQuery, .explainQuery, .formatQuery, .foldAll, .unfoldAll, .toggleFold, .previousStatement, .nextStatement, .runStatementAndAdvance, - .previewSQL, .findNext, .findPrevious, .aiExplainQuery, .aiOptimizeQuery: + .previewSQL, .find, .findNext, .findPrevious, .aiExplainQuery, .aiOptimizeQuery: return .editor case .undo, .redo, .cut, .copy, .copyRowsExplicit, .copyWithHeaders, .copyAsJson, .paste, .delete, .selectAll, .clearSelection, .addRow, .duplicateRow, @@ -167,8 +168,13 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { } } + /// `find` is deliberately `.global` rather than `.editor`: it dispatches to whichever + /// surface holds focus, and only a global context overlaps `.dataGrid`, which is what + /// lets it yield `Cmd+F` to a user who binds the filter bar there instead. var context: ShortcutContext { switch self { + case .find: + return .global case .executeQuery, .executeAllStatements, .executeQueryWithoutLimit, .cancelQuery, .explainQuery, .formatQuery, .foldAll, .unfoldAll, .toggleFold, .previousStatement, .nextStatement, .runStatementAndAdvance, @@ -224,6 +230,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .previousStatement: return String(localized: "Previous Statement") case .nextStatement: return String(localized: "Next Statement") case .runStatementAndAdvance: return String(localized: "Run Statement and Advance") + case .find: return String(localized: "Find") case .findNext: return String(localized: "Find Next") case .findPrevious: return String(localized: "Find Previous") case .export: return String(localized: "Export") @@ -281,7 +288,6 @@ extension ShortcutAction { (.character("/", command: true), String(localized: "Toggle Comment")), (.character("[", command: true), String(localized: "Indent")), (.character("]", command: true), String(localized: "Outdent")), - (.character("f", command: true), String(localized: "Find")), (.character("d", command: true, shift: true), String(localized: "Duplicate Line")), (.character("k", command: true, shift: true), String(localized: "Delete Line")), (.special(.space, control: true), String(localized: "Show Completions")), @@ -315,8 +321,7 @@ extension ShortcutAction { static let reservedAppShortcuts: [(key: BoundKey, name: String)] = { var shortcuts: [(key: BoundKey, name: String)] = [ (.character("=", command: true), String(localized: "Zoom In")), - (.character("-", command: true), String(localized: "Zoom Out")), - (.character("f", command: true), String(localized: "Find")) + (.character("-", command: true), String(localized: "Zoom Out")) ] for number in 1...9 { shortcuts.append(( @@ -517,6 +522,7 @@ struct KeyboardSettings: Codable, Equatable { .unfoldAll: .special(.rightArrow, command: true, shift: true, option: true), .toggleFold: .special(.leftArrow, command: true, option: true), .previewSQL: .character("p", command: true, shift: true), + .find: .character("f", command: true), .findNext: .character("g", command: true), .findPrevious: .character("g", command: true, shift: true), .aiExplainQuery: .character("l", command: true), diff --git a/TablePro/Views/Settings/KeyboardSettingsView.swift b/TablePro/Views/Settings/KeyboardSettingsView.swift index f01cd1bd33..acc5c2ba86 100644 --- a/TablePro/Views/Settings/KeyboardSettingsView.swift +++ b/TablePro/Views/Settings/KeyboardSettingsView.swift @@ -69,7 +69,9 @@ struct KeyboardSettingsView: View { } Button(String(localized: "Reassign")) { if let state = conflictAlert { - settings.clearShortcut(for: state.conflictingAction) + if settings.isCustomized(state.conflictingAction) { + settings.clearShortcut(for: state.conflictingAction) + } settings.setShortcut(state.combo, for: state.action) } conflictAlert = nil diff --git a/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/TableProTests/Core/Menu/MainMenuBuilderTests.swift index 77a3c093c0..aaa533b5b4 100644 --- a/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -122,14 +122,72 @@ struct MainMenuShortcutCoverageTests { #expect(item?.keyEquivalentModifierMask == [.command, .shift]) } - @Test("Find keeps Cmd+F and the filter bar no longer competes for it") - func findOwnsCommandF() { + @Test("Find ships on Cmd+F and the filter bar keeps Cmd+Option+F") + func findAndFilterDefaultsHold() { + #expect(KeyboardSettings.defaultShortcuts[.find] == .character("f", command: true)) #expect(KeyboardSettings.defaultShortcuts[.toggleFilters] == .character("f", command: true, option: true)) #expect( KeyboardSettings.defaultShortcuts[.focusSidebarSearch] == .character("f", command: true, option: true, control: true) ) - #expect(ShortcutAction.reservedAppShortcuts.contains { $0.key == .character("f", command: true) }) + } + + @Test("Cmd+F is customizable rather than reserved") + func commandFIsNoLongerReserved() { + let commandF = BoundKey.character("f", command: true) + #expect(!ShortcutAction.reservedAppShortcuts.contains { $0.key == commandF }) + #expect(!ShortcutAction.editorBuiltIns.contains { $0.key == commandF }) + #expect(ShortcutAction.reservedConflict(for: commandF, context: .dataGrid) == nil) + #expect(ShortcutAction.reservedConflict(for: commandF, context: .editor) == nil) + } + + @Test("Cmd+F for the filter bar is no longer refused outright, only reported against Find") + func recorderReportsFindRatherThanRefusingCommandF() { + let commandF = BoundKey.character("f", command: true) + #expect(ShortcutAction.reservedConflict(for: commandF, context: ShortcutAction.toggleFilters.context) == nil) + #expect(KeyboardSettings.default.findConflict(for: commandF, excluding: .toggleFilters) == .find) + } + + @Test("Reassigning Cmd+F leaves Find on its default so it recovers when the filter bar moves back") + func reassigningCommandFDoesNotStrandFind() { + var keyboard = KeyboardSettings() + #expect(!keyboard.isCustomized(.find)) + + keyboard.setShortcut(.character("f", command: true), for: .toggleFilters) + #expect(keyboard.shortcut(for: .find) == nil) + #expect(!keyboard.isCustomized(.find)) + + keyboard.setShortcut(.character("f", command: true, option: true), for: .toggleFilters) + #expect(keyboard.shortcut(for: .find) == .character("f", command: true)) + } + + @Test("Find yields Cmd+F to a user-bound filter bar instead of sharing it") + func findYieldsCommandFToFilterBar() { + var keyboard = KeyboardSettings() + keyboard.setShortcut(.character("f", command: true), for: .toggleFilters) + + #expect(keyboard.shortcut(for: .find) == nil) + #expect(keyboard.shortcut(for: .toggleFilters) == .character("f", command: true)) + + let menu = buildMenu() + MainMenuKeyEquivalentSync.apply(keyboard: keyboard, to: menu) + let claimants = flatten(menu).filter { + $0.keyEquivalent == "f" && $0.keyEquivalentModifierMask == [.command] + } + #expect(claimants.count == 1) + #expect(claimants.first?.identifier == MenuItemFactory.identifier(for: .toggleFilters)) + } + + @Test("Find… carries no hardcoded key equivalent") + func findMenuItemFollowsKeyboardSettings() { + var keyboard = KeyboardSettings() + keyboard.setShortcut(.character("f", command: true, shift: true), for: .find) + + let menu = buildMenu() + MainMenuKeyEquivalentSync.apply(keyboard: keyboard, to: menu) + let item = flatten(menu).first { $0.identifier == MenuItemFactory.identifier(for: .find) } + #expect(item?.keyEquivalent == "f") + #expect(item?.keyEquivalentModifierMask == [.command, .shift]) } } diff --git a/TableProTests/Models/KeyboardShortcutTests.swift b/TableProTests/Models/KeyboardShortcutTests.swift index 6e9fbfea98..746f00d19b 100644 --- a/TableProTests/Models/KeyboardShortcutTests.swift +++ b/TableProTests/Models/KeyboardShortcutTests.swift @@ -223,10 +223,21 @@ struct ShortcutConflictTests { #expect(conflict == .refresh) } - @Test("Editor action does not conflict with the data-grid Cmd+F filter") - func crossContextDoesNotConflict() { + @Test("Cmd+F is held by the global Find action, so an editor binding collides with it") + func commandFConflictsWithFind() { let settings = KeyboardSettings.default let conflict = settings.findConflict(for: .character("f", command: true), excluding: .executeQuery) + #expect(conflict == .find) + } + + @Test("A data-grid binding does not conflict with an editor-only default") + func crossContextDoesNotConflict() { + let settings = KeyboardSettings.default + #expect(KeyboardSettings.defaultShortcuts[.formatQuery] == .character("l", command: true, shift: true)) + let conflict = settings.findConflict( + for: .character("l", command: true, shift: true), + excluding: .previousPage + ) #expect(conflict == nil) } } diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 4e2e5d67df..e326c2a41c 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -78,7 +78,7 @@ The grid meanings come back the moment you click into the grid, and `Delete` on | Find next | `Cmd+G` | | Find previous | `Cmd+Shift+G` | -Every row except Find Next and Find Previous is built into the editor and cannot be rebound. Standard macOS text navigation and clipboard keys also apply. +Every row except Find, Find Next and Find Previous is built into the editor and cannot be rebound. Standard macOS text navigation and clipboard keys also apply. ## Data Grid @@ -273,7 +273,7 @@ A combination already taken by another action in the same context raises a dialo Menu actions need a modifier (`Cmd`, `Option`, `Ctrl`, or `Shift`); function keys `F1` through `F12` work bare, as do grid actions that read the key directly, like Preview FK Reference (`Space`). -Some shortcuts cannot be reassigned: editor built-ins such as `Cmd+/`, tab selection (`Cmd+1` through `Cmd+9`), text size (`Cmd+=`, `Cmd+-`), Find (`Cmd+F`), and macOS system shortcuts, which are read live from System Settings. The recorder warns if you try. +Some shortcuts cannot be reassigned: editor built-ins such as `Cmd+/`, tab selection (`Cmd+1` through `Cmd+9`), text size (`Cmd+=`, `Cmd+-`), and macOS system shortcuts, which are read live from System Settings. The recorder warns if you try. ## Outside the app diff --git a/docs/switching.mdx b/docs/switching.mdx index 4ddc2feaa5..9a44ba5c48 100644 --- a/docs/switching.mdx +++ b/docs/switching.mdx @@ -81,7 +81,9 @@ Set these up on your first day: | Explain the query | `Cmd+Option+E` | | Save the query as a favorite | `Cmd+D` | -A few cannot be reassigned, so learn them rather than fight them: editor built-ins such as `Cmd+/` for comment, `Cmd+[` and `Cmd+]` for indent, tab selection `Cmd+1` through `Cmd+9`, text size `Cmd+=` and `Cmd+-`, and `Cmd+F` for find. The recorder tells you when you hit one. +A few cannot be reassigned, so learn them rather than fight them: editor built-ins such as `Cmd+/` for comment, `Cmd+[` and `Cmd+]` for indent, tab selection `Cmd+1` through `Cmd+9`, and text size `Cmd+=` and `Cmd+-`. The recorder tells you when you hit one. + +Coming from TablePlus, where `Cmd+F` opens the row filter? Rebind **Toggle Filters** to `Cmd+F` in **Settings > Keyboard** and choose **Reassign** when it asks. Find gives up `Cmd+F` while the filter bar holds it, and takes it back if you move the filter bar off again. If you use Vim bindings, turn on [Vim Mode](/features/vim-mode) in **Settings > Editor** and most of this section stops mattering.