diff --git a/CHANGELOG.md b/CHANGELOG.md index 2044a24a9..3c2a92702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A plus button in the bottom bar of the Tables sidebar opens a menu to create a new table or view, without right-clicking. It's disabled while safe mode blocks writes. - The sidebar can show every database on the server as an expandable tree. Switch a connection between the flat list and the tree from the View menu (Sidebar Layout); right-click a database or schema to set it active. Set the default layout for new connections in Settings, General. Applies to MySQL, MariaDB, PostgreSQL, MSSQL, ClickHouse, Redshift; SQLite, Redis, MongoDB, BigQuery keep their existing sidebar. (#139) - A connection can read its password from a file, environment variable, or command at connect time instead of the Keychain, so scripts can provision a connection without entering the password by hand. (#1254) +- Function-key shortcuts: F5 to refresh, F9 to run a query, F1 to open documentation. F5 and F9 work alongside Cmd+R and Cmd+Return, and all three are assignable in Settings > Keyboard. ### Changed diff --git a/Packages/TableProCore/Sources/TableProPluginKit/SSLHandshakeError.swift b/Packages/TableProCore/Sources/TableProPluginKit/SSLHandshakeError.swift index 5c0124e77..bdc7fed27 100644 --- a/Packages/TableProCore/Sources/TableProPluginKit/SSLHandshakeError.swift +++ b/Packages/TableProCore/Sources/TableProPluginKit/SSLHandshakeError.swift @@ -8,6 +8,9 @@ public enum SSLHandshakeError: Error, LocalizedError, Sendable { case clientCertRequired(serverMessage: String) case cipherMismatch(serverMessage: String) case unknown(serverMessage: String) + case clientKeyPassphraseRequired(serverMessage: String) + case clientKeyPassphraseIncorrect(serverMessage: String) + case clientKeyInvalid(serverMessage: String) public var serverMessage: String { switch self { @@ -16,6 +19,9 @@ public enum SSLHandshakeError: Error, LocalizedError, Sendable { .untrustedCertificate(let msg), .hostnameMismatch(let msg), .clientCertRequired(let msg), + .clientKeyPassphraseRequired(let msg), + .clientKeyPassphraseIncorrect(let msg), + .clientKeyInvalid(let msg), .cipherMismatch(let msg), .unknown(let msg): return msg @@ -34,6 +40,12 @@ public enum SSLHandshakeError: Error, LocalizedError, Sendable { return String(localized: "The server's TLS certificate does not match the hostname being connected to.") case .clientCertRequired: return String(localized: "The server requires a client certificate for TLS mutual authentication.") + case .clientKeyPassphraseRequired: + return String(localized: "The client private key is encrypted and needs a passphrase.") + case .clientKeyPassphraseIncorrect: + return String(localized: "The passphrase for the client private key is incorrect.") + case .clientKeyInvalid: + return String(localized: "The client private key could not be read. It may be malformed or in an unsupported format.") case .cipherMismatch: return String(localized: "The server and TablePro could not agree on a TLS cipher or protocol version.") case .unknown: @@ -86,6 +98,12 @@ public enum SSLHandshakeError: Error, LocalizedError, Sendable { return String(localized: "Switch SSL Mode to Verify CA (validates the CA chain but skips hostname check), or update the host field to match the certificate.") case .clientCertRequired: return String(localized: "Provide the client certificate and key paths in the SSL tab.") + case .clientKeyPassphraseRequired: + return String(localized: "Open the connection editor, switch to the SSL tab, and enter the Key Passphrase.") + case .clientKeyPassphraseIncorrect: + return String(localized: "Open the connection editor, switch to the SSL tab, and correct the Key Passphrase.") + case .clientKeyInvalid: + return String(localized: "Check that the Client Key path points to a valid PEM private key.") case .cipherMismatch: return String(localized: "Update the server's TLS configuration or use a newer database server version that supports modern ciphers.") case .unknown: diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 744957125..97c7f6895 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -16,10 +16,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var hasRunPostLaunchActivation = false - private static var isUITesting: Bool { - ProcessInfo.processInfo.environment["TABLEPRO_UI_TESTING"] == "1" - } - // MARK: - URL & File Open func applicationWillFinishLaunching(_ notification: Notification) { @@ -71,6 +67,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { UNUserNotificationCenter.current().delegate = self PluginNotificationService.shared.setUp() ChatToolBootstrap.register() + FunctionKeyShortcutMonitor.shared.start() NSWorkspace.shared.notificationCenter.addObserver( self, selector: #selector(handleSystemDidWake), @@ -97,14 +94,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidBecomeActive(_ notification: Notification) { runPostLaunchActivationIfNeeded() - guard !Self.isUITesting else { return } SyncCoordinator.shared.syncIfNeeded() } private func runPostLaunchActivationIfNeeded() { guard !hasRunPostLaunchActivation else { return } hasRunPostLaunchActivation = true - guard !Self.isUITesting else { return } ConnectionStorage.shared.migratePluginSecureFieldsIfNeeded() AnalyticsService.shared.startPeriodicHeartbeat() diff --git a/TablePro/Core/KeyboardHandling/FunctionKeyShortcutMonitor.swift b/TablePro/Core/KeyboardHandling/FunctionKeyShortcutMonitor.swift new file mode 100644 index 000000000..3066ba2f2 --- /dev/null +++ b/TablePro/Core/KeyboardHandling/FunctionKeyShortcutMonitor.swift @@ -0,0 +1,77 @@ +// +// FunctionKeyShortcutMonitor.swift +// TablePro +// +// Dispatches function-key shortcuts (F1–F12) that can't ride on SwiftUI menu +// key equivalents: secondary bindings whose primary already owns a menu +// shortcut (e.g. F5 alongside ⌘R), and Help actions with no menu shortcut. +// + +import AppKit +import Combine +import Foundation + +@MainActor +final class FunctionKeyShortcutMonitor { + static let shared = FunctionKeyShortcutMonitor() + + private var eventMonitor: Any? + + private init() {} + + func start() { + guard eventMonitor == nil else { return } + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self else { return event } + return self.handle(event) + } + } + + func stop() { + if let eventMonitor { + NSEvent.removeMonitor(eventMonitor) + } + eventMonitor = nil + } + + private func handle(_ event: NSEvent) -> NSEvent? { + guard let action = matchedAction(for: event) else { return event } + if NSApp.keyWindow?.firstResponder is ShortcutRecorderNSView { + return event + } + return perform(action) ? nil : event + } + + private func matchedAction(for event: NSEvent) -> ShortcutAction? { + let keyboard = AppSettingsManager.shared.keyboard + for action in ShortcutAction.allCases where action.supportsFunctionKeyPrimary { + if let combo = keyboard.shortcut(for: action), combo.isFunctionKey, combo.matches(event) { + return action + } + } + for action in ShortcutAction.allCases where action.supportsFunctionKeyAlternate { + if let combo = keyboard.alternateShortcut(for: action), combo.isFunctionKey, combo.matches(event) { + return action + } + } + return nil + } + + private func perform(_ action: ShortcutAction) -> Bool { + switch action { + case .refresh: + AppCommands.shared.refreshData.send(nil) + return true + case .executeQuery: + guard let actions = CommandActionsRegistry.shared.current else { return false } + actions.runQuery() + return true + case .openDocumentation: + guard let url = URL(string: "https://docs.tablepro.app") else { return false } + NSWorkspace.shared.open(url) + return true + default: + return false + } + } +} diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift index 3f17ccaf6..c3b017356 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift @@ -58,7 +58,7 @@ struct RefreshToolbarButton: View { } label: { Label("Refresh", systemImage: "arrow.clockwise") } - .help(String(localized: "Refresh (⌘R)")) + .help(String(localized: "Refresh (⌘R / F5)")) .disabled(state.connectionState != .connected) } } diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift index 1ab57a5f1..b2fb12bf4 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift @@ -180,6 +180,7 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { let item = NSToolbarItem(itemIdentifier: Self.inspector) item.label = String(localized: "Inspector") item.paletteLabel = String(localized: "Inspector") + item.toolTip = String(localized: "Toggle Inspector (⌘⌥I)") return item case Self.dashboard: return hostingItem( diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index 364741302..adfa8093e 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -17,6 +17,7 @@ enum ShortcutCategory: String, Codable, CaseIterable, Identifiable { case view case tabs case ai + case help var id: String { rawValue } @@ -27,6 +28,7 @@ enum ShortcutCategory: String, Codable, CaseIterable, Identifiable { case .view: return String(localized: "View") case .tabs: return String(localized: "Tabs") case .ai: return String(localized: "AI") + case .help: return String(localized: "Help") } } } @@ -100,6 +102,9 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case aiExplainQuery case aiOptimizeQuery + // Help + case openDocumentation + var id: String { rawValue } var category: ShortcutCategory { @@ -122,6 +127,8 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { return .tabs case .aiExplainQuery, .aiOptimizeQuery: return .ai + case .openDocumentation: + return .help } } @@ -134,6 +141,24 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { } } + var supportsFunctionKeyAlternate: Bool { + switch self { + case .refresh, .executeQuery: + return true + default: + return false + } + } + + var supportsFunctionKeyPrimary: Bool { + switch self { + case .openDocumentation: + return true + default: + return false + } + } + var displayName: String { switch self { case .manageConnections: return String(localized: "Manage Connections") @@ -189,6 +214,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .showNextTab: return String(localized: "Show Next Tab") case .aiExplainQuery: return String(localized: "Explain with AI") case .aiOptimizeQuery: return String(localized: "Optimize with AI") + case .openDocumentation: return String(localized: "Open Documentation") } } } @@ -239,10 +265,11 @@ struct KeyCombo: Codable, Equatable, Hashable { let hasOption = flags.contains(.option) let hasControl = flags.contains(.control) - // Require at least Cmd or Control (or special bare keys: escape, delete, space) + // Require at least Cmd or Control (or special bare keys: escape, delete, space, function keys) let specialKeyCode = Self.specialKeyName(for: event.keyCode) let isAllowedBareKey = event.keyCode == 53 || event.keyCode == 51 || event.keyCode == 117 || event.keyCode == 49 + || Self.isFunctionKeyName(specialKeyCode) if !hasCommand && !hasControl && !isAllowedBareKey { return nil @@ -287,6 +314,9 @@ struct KeyCombo: Codable, Equatable, Hashable { // swiftlint:disable:next force_unwrapping case "forwardDelete": return KeyEquivalent(Character(UnicodeScalar(NSDeleteFunctionKey)!)) default: + if let scalar = Self.functionKeyScalar(for: key) { + return KeyEquivalent(Character(scalar)) + } guard key.count == 1 else { return .escape } return KeyEquivalent(Character(key)) } @@ -308,6 +338,10 @@ struct KeyCombo: Codable, Equatable, Hashable { command || shift || option || control } + var isFunctionKey: Bool { + isSpecialKey && Self.isFunctionKeyName(key) + } + /// Human-readable display string (e.g. "⌘S", "⇧⌘P") var displayString: String { var parts: [String] = [] @@ -337,7 +371,9 @@ struct KeyCombo: Codable, Equatable, Hashable { case "end": return "↘" case "pageUp": return "⇞" case "pageDown": return "⇟" - default: return key.count == 1 ? key.uppercased() : "?" + default: + if isFunctionKey { return key.uppercased() } + return key.count == 1 ? key.uppercased() : "?" } } return key.uppercased() @@ -362,10 +398,39 @@ struct KeyCombo: Codable, Equatable, Hashable { case 119: return "end" case 116: return "pageUp" case 121: return "pageDown" + case 122: return "f1" + case 120: return "f2" + case 99: return "f3" + case 118: return "f4" + case 96: return "f5" + case 97: return "f6" + case 98: return "f7" + case 100: return "f8" + case 101: return "f9" + case 109: return "f10" + case 103: return "f11" + case 111: return "f12" default: return nil } } + private static func functionKeyNumber(for key: String) -> Int? { + guard key.hasPrefix("f"), let number = Int(key.dropFirst()), (1...12).contains(number) else { + return nil + } + return number + } + + static func isFunctionKeyName(_ key: String?) -> Bool { + guard let key else { return false } + return functionKeyNumber(for: key) != nil + } + + private static func functionKeyScalar(for key: String) -> UnicodeScalar? { + guard let number = functionKeyNumber(for: key) else { return nil } + return UnicodeScalar(UInt32(NSF1FunctionKey + (number - 1))) + } + // MARK: - Event Matching /// Check if this combo matches a given NSEvent (for runtime key dispatch) @@ -420,15 +485,21 @@ struct KeyboardSettings: Codable, Equatable { /// the old stored key becomes a harmless no-op (never matched by any action). var shortcuts: [String: KeyCombo] + /// User-customized secondary (function-key) bindings (action rawValue → KeyCombo). + /// Only contains overrides; missing entries use `defaultAlternates`. + var alternates: [String: KeyCombo] + static let `default` = KeyboardSettings(shortcuts: [:]) - init(shortcuts: [String: KeyCombo] = [:]) { + init(shortcuts: [String: KeyCombo] = [:], alternates: [String: KeyCombo] = [:]) { self.shortcuts = shortcuts + self.alternates = alternates } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) shortcuts = try container.decodeIfPresent([String: KeyCombo].self, forKey: .shortcuts) ?? [:] + alternates = try container.decodeIfPresent([String: KeyCombo].self, forKey: .alternates) ?? [:] } /// Get the effective shortcut for an action (user override or default) @@ -445,10 +516,23 @@ struct KeyboardSettings: Codable, Equatable { shortcuts[action.rawValue] != nil } - /// Find a conflicting action for the given combo, excluding the specified action + /// Get the effective secondary (function-key) shortcut for an action. + /// Returns nil if there is none or the user explicitly cleared it. + func alternateShortcut(for action: ShortcutAction) -> KeyCombo? { + let combo = alternates[action.rawValue] ?? Self.defaultAlternates[action] + guard let combo, !combo.isCleared else { return nil } + return combo + } + + func isAlternateCustomized(_ action: ShortcutAction) -> Bool { + alternates[action.rawValue] != nil + } + + /// Find a conflicting action for the given combo, excluding the specified action. + /// Checks both primary and secondary bindings of every other action. func findConflict(for combo: KeyCombo, excluding action: ShortcutAction) -> ShortcutAction? { for otherAction in ShortcutAction.allCases where otherAction != action { - if shortcut(for: otherAction) == combo { + if shortcut(for: otherAction) == combo || alternateShortcut(for: otherAction) == combo { return otherAction } } @@ -471,13 +555,28 @@ struct KeyboardSettings: Codable, Equatable { shortcuts.removeValue(forKey: action.rawValue) } + /// Set a secondary (function-key) shortcut override for an action + mutating func setAlternate(_ combo: KeyCombo, for action: ShortcutAction) { + alternates[action.rawValue] = combo + } + + /// Clear a secondary shortcut (action will have no function-key binding) + mutating func clearAlternate(for action: ShortcutAction) { + alternates[action.rawValue] = KeyCombo.cleared + } + + /// Reset a secondary shortcut to its default + mutating func resetAlternate(for action: ShortcutAction) { + alternates.removeValue(forKey: action.rawValue) + } + /// Drop overrides that can never dispatch (bare keys on menu-driven actions), /// reverting them to their default. Cleared and unknown overrides are kept. func sanitized() -> KeyboardSettings { var cleaned = shortcuts for (rawValue, combo) in shortcuts { guard let action = ShortcutAction(rawValue: rawValue), !combo.isCleared else { continue } - if !combo.hasModifier, !action.allowsBareKey { + if !combo.hasModifier, !action.allowsBareKey, !combo.isFunctionKey { cleaned.removeValue(forKey: rawValue) } } @@ -485,9 +584,12 @@ struct KeyboardSettings: Codable, Equatable { } /// Build a SwiftUI KeyboardShortcut for the given action. - /// Returns nil if the user has cleared (unassigned) the shortcut. + /// Returns nil if the user has cleared (unassigned) the shortcut, or if the + /// binding is a function key. Those dispatch through FunctionKeyShortcutMonitor + /// instead of the menu, since SwiftUI menu items don't reliably register + /// function-key equivalents. func keyboardShortcut(for action: ShortcutAction) -> KeyboardShortcut? { - guard let combo = shortcut(for: action), !combo.isCleared else { + guard let combo = shortcut(for: action), !combo.isCleared, !combo.isFunctionKey else { return nil } return KeyboardShortcut(combo.keyEquivalent, modifiers: combo.eventModifiers) @@ -557,6 +659,15 @@ struct KeyboardSettings: Codable, Equatable { // AI .aiExplainQuery: KeyCombo(key: "l", command: true), .aiOptimizeQuery: KeyCombo(key: "l", command: true, option: true), + + // Help + .openDocumentation: KeyCombo(key: "f1", isSpecialKey: true), + ] + + /// Default secondary (function-key) bindings, dispatched by FunctionKeyShortcutMonitor. + static let defaultAlternates: [ShortcutAction: KeyCombo] = [ + .refresh: KeyCombo(key: "f5", isSpecialKey: true), + .executeQuery: KeyCombo(key: "f9", isSpecialKey: true), ] } diff --git a/TablePro/Views/Main/Child/MainStatusBarView.swift b/TablePro/Views/Main/Child/MainStatusBarView.swift index 3f6fc1b25..d03491791 100644 --- a/TablePro/Views/Main/Child/MainStatusBarView.swift +++ b/TablePro/Views/Main/Child/MainStatusBarView.swift @@ -70,15 +70,6 @@ struct MainStatusBarView: View { private var isStructureMode: Bool { viewMode == .structure } private var showsDataChrome: Bool { !isStructureMode } - private var filterToggleHelp: String { - let label = String(localized: "Toggle Filters") - guard let combo = AppSettingsManager.shared.keyboard.shortcut(for: .toggleFilters), - !combo.isCleared else { - return label - } - return "\(label) (\(combo.displayString))" - } - var body: some View { HStack { if snapshot.tabId != nil { @@ -171,6 +162,7 @@ struct MainStatusBarView: View { } } .controlSize(.small) + .help(String(localized: "Show or Hide Columns")) .popover(isPresented: $showColumnPopover) { ColumnVisibilityPopover( columns: allColumns, @@ -200,7 +192,7 @@ struct MainStatusBarView: View { } .toggleStyle(.button) .controlSize(.small) - .help(filterToggleHelp) + .help(String(localized: "Toggle Filters (⇧⌘F)")) } if snapshot.tabType == .table, snapshot.hasTableName, showsPaginationControls { diff --git a/TablePro/Views/Settings/KeyboardSettingsView.swift b/TablePro/Views/Settings/KeyboardSettingsView.swift index bd2c318b3..4dbfd55cc 100644 --- a/TablePro/Views/Settings/KeyboardSettingsView.swift +++ b/TablePro/Views/Settings/KeyboardSettingsView.swift @@ -16,6 +16,7 @@ struct KeyboardSettingsView: View { @State private var conflictAlert: ConflictAlertState? @State private var systemReservedAlert: ShortcutAction? @State private var needsModifierAlert: ShortcutAction? + @State private var needsFunctionKeyAlert: ShortcutAction? var body: some View { VStack(spacing: 0) { @@ -46,7 +47,7 @@ struct KeyboardSettingsView: View { Button(String(localized: "Reset to Defaults")) { settings = .default } - .disabled(settings.shortcuts.isEmpty) + .disabled(settings.shortcuts.isEmpty && settings.alternates.isEmpty) } } } @@ -64,10 +65,16 @@ struct KeyboardSettingsView: View { } Button(String(localized: "Reassign")) { if let state = conflictAlert { - // Clear the conflicting action's shortcut - settings.clearShortcut(for: state.conflictingAction) - // Assign the new combo to the intended action - settings.setShortcut(state.combo, for: state.action) + if settings.alternateShortcut(for: state.conflictingAction) == state.combo { + settings.clearAlternate(for: state.conflictingAction) + } else { + settings.clearShortcut(for: state.conflictingAction) + } + if state.isAlternate { + settings.setAlternate(state.combo, for: state.action) + } else { + settings.setShortcut(state.combo, for: state.action) + } } conflictAlert = nil } @@ -104,6 +111,19 @@ struct KeyboardSettingsView: View { } message: { Text(String(localized: "This action needs a modifier key like ⌘ or ⌥. A plain key won't reach the menu reliably.")) } + .alert( + String(localized: "Function Key Required"), + isPresented: Binding( + get: { needsFunctionKeyAlert != nil }, + set: { if !$0 { needsFunctionKeyAlert = nil } } + ) + ) { + Button(String(localized: "OK"), role: .cancel) { + needsFunctionKeyAlert = nil + } + } message: { + Text(String(localized: "The secondary shortcut must be a function key (F1–F12).")) + } } // MARK: - Shortcut Row @@ -114,6 +134,22 @@ struct KeyboardSettingsView: View { Text(action.displayName) .frame(maxWidth: .infinity, alignment: .leading) + if action.supportsFunctionKeyAlternate { + ShortcutRecorderView( + combo: Binding( + get: { settings.alternateShortcut(for: action) }, + set: { _ in } + ), + onRecord: { newCombo in + handleRecordAlternate(newCombo, for: action) + }, + onClear: { + settings.clearAlternate(for: action) + } + ) + .frame(width: 110, height: 24) + } + ShortcutRecorderView( combo: Binding( get: { settings.shortcut(for: action) }, @@ -147,7 +183,12 @@ struct KeyboardSettingsView: View { return } - if !combo.hasModifier, !action.allowsBareKey { + if combo.isFunctionKey { + if !action.supportsFunctionKeyPrimary { + needsModifierAlert = action + return + } + } else if !combo.hasModifier, !action.allowsBareKey { needsModifierAlert = action return } @@ -163,6 +204,30 @@ struct KeyboardSettingsView: View { settings.setShortcut(combo, for: action) } + + private func handleRecordAlternate(_ combo: KeyCombo, for action: ShortcutAction) { + guard combo.isFunctionKey else { + needsFunctionKeyAlert = action + return + } + + if combo.isSystemReserved { + systemReservedAlert = action + return + } + + if let conflict = settings.findConflict(for: combo, excluding: action) { + conflictAlert = ConflictAlertState( + action: action, + conflictingAction: conflict, + combo: combo, + isAlternate: true + ) + return + } + + settings.setAlternate(combo, for: action) + } } // MARK: - Conflict Alert State @@ -171,4 +236,5 @@ private struct ConflictAlertState { let action: ShortcutAction let conflictingAction: ShortcutAction let combo: KeyCombo + var isAlternate = false } diff --git a/TableProTests/Core/KeyboardShortcutModelsTests.swift b/TableProTests/Core/KeyboardShortcutModelsTests.swift new file mode 100644 index 000000000..398032d05 --- /dev/null +++ b/TableProTests/Core/KeyboardShortcutModelsTests.swift @@ -0,0 +1,99 @@ +// +// KeyboardShortcutModelsTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import Testing + +@Suite("KeyboardShortcutModels function keys") +struct KeyboardShortcutModelsTests { + @Test("Function-key combo reports isFunctionKey and displays as F5") + func functionKeyComboBasics() { + let f5 = KeyCombo(key: "f5", isSpecialKey: true) + #expect(f5.isFunctionKey) + #expect(!f5.hasModifier) + #expect(f5.displayString == "F5") + } + + @Test("Non-function special and letter keys are not function keys") + func nonFunctionKeys() { + #expect(!KeyCombo(key: "escape", isSpecialKey: true).isFunctionKey) + #expect(!KeyCombo(key: "f", command: true).isFunctionKey) + #expect(!KeyCombo(key: "f13", isSpecialKey: true).isFunctionKey) + } + + @Test("Function-key keyEquivalent maps to the AppKit function-key scalar") + func functionKeyEquivalent() throws { + let f5 = KeyCombo(key: "f5", isSpecialKey: true) + let expected = try #require(UnicodeScalar(UInt32(NSF1FunctionKey + 4))) + #expect(f5.keyEquivalent.character == Character(expected)) + } + + @Test("Function-key combo round-trips through Codable") + func functionKeyCodable() throws { + let f9 = KeyCombo(key: "f9", isSpecialKey: true) + let data = try JSONEncoder().encode(f9) + let decoded = try JSONDecoder().decode(KeyCombo.self, from: data) + #expect(decoded == f9) + } + + @Test("Default alternates bind F5 to refresh and F9 to execute query") + func defaultAlternates() { + let settings = KeyboardSettings.default + #expect(settings.alternateShortcut(for: .refresh) == KeyCombo(key: "f5", isSpecialKey: true)) + #expect(settings.alternateShortcut(for: .executeQuery) == KeyCombo(key: "f9", isSpecialKey: true)) + #expect(settings.alternateShortcut(for: .formatQuery) == nil) + } + + @Test("Open Documentation defaults to F1 with no menu shortcut") + func openDocumentationDefault() { + let settings = KeyboardSettings.default + #expect(settings.shortcut(for: .openDocumentation) == KeyCombo(key: "f1", isSpecialKey: true)) + #expect(settings.keyboardShortcut(for: .openDocumentation) == nil) + } + + @Test("Menu shortcut still resolves for non-function primary bindings") + func menuShortcutForRefresh() { + #expect(KeyboardSettings.default.keyboardShortcut(for: .refresh) != nil) + } + + @Test("Only documentation accepts a function key as its primary shortcut") + func functionKeyPrimarySupport() { + #expect(ShortcutAction.openDocumentation.supportsFunctionKeyPrimary) + #expect(!ShortcutAction.formatQuery.supportsFunctionKeyPrimary) + #expect(!ShortcutAction.refresh.supportsFunctionKeyPrimary) + } + + @Test("Sanitize keeps function-key overrides without a modifier") + func sanitizeKeepsFunctionKeys() { + var settings = KeyboardSettings.default + settings.setShortcut(KeyCombo(key: "f2", isSpecialKey: true), for: .openDocumentation) + let cleaned = settings.sanitized() + #expect(cleaned.shortcut(for: .openDocumentation) == KeyCombo(key: "f2", isSpecialKey: true)) + } + + @Test("Conflict detection spots an alternate binding") + func conflictWithAlternate() { + let f5 = KeyCombo(key: "f5", isSpecialKey: true) + #expect(KeyboardSettings.default.findConflict(for: f5, excluding: .openDocumentation) == .refresh) + } + + @Test("Cleared alternate resolves to nil") + func clearedAlternate() { + var settings = KeyboardSettings.default + settings.clearAlternate(for: .refresh) + #expect(settings.alternateShortcut(for: .refresh) == nil) + } + + @Test("Alternates persist through Codable") + func settingsAlternatesCodable() throws { + var settings = KeyboardSettings.default + settings.setAlternate(KeyCombo(key: "f7", isSpecialKey: true), for: .executeQuery) + let data = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(KeyboardSettings.self, from: data) + #expect(decoded.alternateShortcut(for: .executeQuery) == KeyCombo(key: "f7", isSpecialKey: true)) + } +} diff --git a/TableProTests/Models/KeyboardShortcutTests.swift b/TableProTests/Models/KeyboardShortcutTests.swift index 5b4585d29..bf49ab34f 100644 --- a/TableProTests/Models/KeyboardShortcutTests.swift +++ b/TableProTests/Models/KeyboardShortcutTests.swift @@ -53,10 +53,14 @@ struct BareKeyValidationTests { #expect(!KeyCombo(key: "space", isSpecialKey: true).hasModifier) } - @Test("Every bare-key default belongs to an action that allows bare keys") + @Test("Every bare-key default belongs to an action that accepts it") func bareKeyDefaultsAreAllowed() { for (action, combo) in KeyboardSettings.defaultShortcuts where !combo.hasModifier { - #expect(action.allowsBareKey, "\(action.rawValue) ships a bare-key default but does not allow bare keys") + if combo.isFunctionKey { + #expect(action.supportsFunctionKeyPrimary, "\(action.rawValue) ships a function-key default but does not support a function-key primary") + } else { + #expect(action.allowsBareKey, "\(action.rawValue) ships a bare-key default but does not allow bare keys") + } } } } diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 4097066ba..69115feb5 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -13,7 +13,9 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut | Action | Shortcut | |--------|----------| -| Execute query | `Cmd+Enter` | +| Execute query | `Cmd+Enter` or `F9` | +| Refresh | `Cmd+R` or `F5` | +| Open documentation | `F1` | | New connection | `Cmd+N` | | Open history | `Cmd+Y` | | Settings | `Cmd+,` | @@ -36,7 +38,7 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut | Action | Shortcut | Description | |--------|----------|-------------| -| Execute query | `Cmd+Enter` | Run query at cursor. Shows parameter panel if `:name` placeholders are detected | +| Execute query | `Cmd+Enter` or `F9` | Run query at cursor. Shows parameter panel if `:name` placeholders are detected | | Execute all statements | `Cmd+Shift+Enter` | Run all statements in the editor. Shows parameter panel if parameters are detected | | Cancel query | `Cmd+.` | Stop the currently running query | | Explain query | `Option+Cmd+E` | Show execution plan for query at cursor | @@ -192,7 +194,7 @@ Inherits the standard `NSDocument` shortcuts. |--------|----------| | New connection | `Cmd+N` | | Switch connection | `Cmd+Control+C` | -| Refresh connection | `Cmd+R` | +| Refresh connection | `Cmd+R` or `F5` | | Delete selected connections | `Cmd+Delete` | ### View @@ -343,6 +345,10 @@ Most menu shortcuts are rebindable in **Settings** > **Keyboard**. Menu actions need a modifier key (`Cmd`, `Option`, `Control`, or `Shift`). A plain key like `Space` won't reach the menu, so TablePro asks you to add a modifier. Data grid actions that read the key directly, like Preview FK Reference (`Space`) and Cancel edit (`Escape`), are the exception. + +Refresh and Execute Query have a second field for a function-key shortcut (F5 and F9 by default), which works alongside the main `Cmd` shortcut. Function keys (F1–F12) don't need a modifier. + + ### Clearing a Shortcut 1. Click the shortcut field for the action