From e22cd202a67d736756161702c1a2c90fe79bb0e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 1 Jun 2026 12:38:12 +0700 Subject: [PATCH 1/2] fix(editor): favorite keyword autocomplete, Cmd+D shortcut, and selectable Details fields --- CHANGELOG.md | 13 ++++++ .../Autocomplete/SQLCompletionProvider.swift | 19 +++++---- .../Models/UI/KeyboardShortcutModels.swift | 3 +- TablePro/Views/Editor/QueryEditorView.swift | 9 +++++ .../RightSidebar/EditableFieldView.swift | 1 + .../SQLCompletionProviderTests.swift | 40 +++++++++++++++++++ .../Models/KeyboardShortcutTests.swift | 20 ++++++++++ docs/features/favorites.mdx | 5 ++- docs/features/keyboard-shortcuts.mdx | 1 + 9 files changed, 100 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c0c971af..f8caedb25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Save the current query as a favorite from a star button in the SQL editor toolbar. +- Field names and types in the row Details panel can now be selected and copied. + +### Changed + +- Save as Favorite uses Cmd+D again. The Cmd+Control+D set in 0.47.0 is reserved by macOS for Look Up, so it never fired. + +### Fixed + +- Favorite keyword suggestions now show in the editor autocomplete when you type the keyword. They were being dropped before reaching the popup. + ## [0.47.0] - 2026-06-01 ### Added diff --git a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index 737d24b09..c86bd8e95 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -111,14 +111,6 @@ final class SQLCompletionProvider { ) async -> [SQLCompletionItem] { var items: [SQLCompletionItem] = [] - // Check for favorite keyword matches first (highest priority) - if !favoriteKeywords.isEmpty && !context.prefix.isEmpty { - let lowerPrefix = context.prefix.lowercased() - for (keyword, value) in favoriteKeywords where keyword.lowercased().hasPrefix(lowerPrefix) { - items.append(.favorite(keyword: keyword, name: value.name, query: value.query)) - } - } - // If we have a dot prefix, we're looking for columns of a specific table if let dotPrefix = context.dotPrefix { // Resolve the table name from alias or direct reference @@ -456,9 +448,20 @@ final class SQLCompletionProvider { items += await schemaProvider.tableCompletionItems() } + items += favoriteCompletions(matching: context.prefix) + return items } + private func favoriteCompletions(matching prefix: String) -> [SQLCompletionItem] { + guard !prefix.isEmpty, !favoriteKeywords.isEmpty else { return [] } + let lowerPrefix = prefix.lowercased() + return favoriteKeywords + .filter { $0.key.lowercased().hasPrefix(lowerPrefix) } + .sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending } + .map { SQLCompletionItem.favorite(keyword: $0.key, name: $0.value.name, query: $0.value.query) } + } + /// SQL data type keywords (database-aware), with a slight priority boost /// so they sort before generic constraint keywords in CREATE TABLE context. /// Uses plugin-provided dialect data when available; falls back to common SQL types. diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index 364741302..fef817698 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -401,6 +401,7 @@ struct KeyCombo: Codable, Equatable, Hashable { KeyCombo(key: "q", command: true, control: true), // Lock Screen KeyCombo(key: "f", command: true, control: true), // Full Screen KeyCombo(key: "d", command: true, option: true), // Toggle Dock + KeyCombo(key: "d", command: true, control: true), // Look Up / Define ] /// Check if this combo is reserved by the system @@ -535,7 +536,7 @@ struct KeyboardSettings: Codable, Equatable { .duplicateRow: KeyCombo(key: "d", command: true, shift: true), .truncateTable: KeyCombo(key: "delete", option: true, isSpecialKey: true), .previewFKReference: KeyCombo(key: "space", isSpecialKey: true), - .saveAsFavorite: KeyCombo(key: "d", command: true, control: true), + .saveAsFavorite: KeyCombo(key: "d", command: true), // View .toggleTableBrowser: KeyCombo(key: "0", command: true), diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index 747548cca..568f86707 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -112,6 +112,15 @@ struct QueryEditorView: View { .accessibilityLabel(String(localized: "Format Query")) .optionalKeyboardShortcut(AppSettingsManager.shared.keyboard.keyboardShortcut(for: .formatQuery)) + Button(action: { onSaveAsFavorite?(queryText) }) { + Image(systemName: "star") + .frame(width: 24, height: 24) + } + .buttonStyle(.borderless) + .help(String(localized: "Save as Favorite (⌘D)")) + .accessibilityLabel(String(localized: "Save as Favorite")) + .disabled(!hasQueryText) + Divider() .frame(height: 16) diff --git a/TablePro/Views/RightSidebar/EditableFieldView.swift b/TablePro/Views/RightSidebar/EditableFieldView.swift index c5e3c0b1d..b65a393e7 100644 --- a/TablePro/Views/RightSidebar/EditableFieldView.swift +++ b/TablePro/Views/RightSidebar/EditableFieldView.swift @@ -119,6 +119,7 @@ internal struct FieldDetailView: View { TypeBadge(context.columnType.badgeLabel) } + .textSelection(.enabled) } private func editorMinHeight(for kind: FieldEditorKind) -> CGFloat? { diff --git a/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift b/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift index 4ab358da5..735980a61 100644 --- a/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift @@ -1049,4 +1049,44 @@ struct SQLCompletionProviderTests { let hasStar = items.contains { $0.label == "*" } #expect(hasStar, "COUNT( should suggest *") } + + // MARK: - Favorite keyword expansion + + @Test("Favorite keyword expands at statement start") + func testFavoriteKeywordExpandsAtStatementStart() async { + provider.updateFavoriteKeywords(["report": (name: "Daily Report", query: "SELECT * FROM reports")]) + let text = "rep" + let (items, _) = await provider.getCompletions(text: text, cursorPosition: text.count) + let favorite = items.first { $0.kind == .favorite } + #expect(favorite?.label == "report", "Typing the keyword prefix should surface the favorite") + #expect(favorite?.insertText == "SELECT * FROM reports", "Selecting it inserts the full query") + #expect(favorite?.detail == "Daily Report", "The favorite name is shown as detail") + } + + @Test("Favorite keyword survives clause branches that rebuild candidates") + func testFavoriteKeywordSurvivesClauseRebuild() async { + provider.updateFavoriteKeywords(["usr": (name: "Users", query: "SELECT * FROM users")]) + let text = "usr" + let (items, _) = await provider.getCompletions(text: text, cursorPosition: text.count) + let hasFavorite = items.contains { $0.kind == .favorite && $0.label == "usr" } + #expect(hasFavorite, "Favorite must not be discarded by the candidate switch") + } + + @Test("Favorite keyword not offered after a dot prefix") + func testFavoriteKeywordNotOfferedAfterDot() async { + provider.updateFavoriteKeywords(["col": (name: "Columns", query: "SELECT 1")]) + let text = "users.col" + let (items, _) = await provider.getCompletions(text: text, cursorPosition: text.count) + let hasFavorite = items.contains { $0.kind == .favorite } + #expect(!hasFavorite, "Column completion after a dot must not expand favorites") + } + + @Test("Non-matching prefix does not surface favorites") + func testFavoriteKeywordRequiresPrefixMatch() async { + provider.updateFavoriteKeywords(["report": (name: "Daily Report", query: "SELECT 1")]) + let text = "SEL" + let (items, _) = await provider.getCompletions(text: text, cursorPosition: text.count) + let hasFavorite = items.contains { $0.kind == .favorite } + #expect(!hasFavorite, "Favorites appear only when the typed token matches their keyword") + } } diff --git a/TableProTests/Models/KeyboardShortcutTests.swift b/TableProTests/Models/KeyboardShortcutTests.swift index 5b4585d29..db89cf6f8 100644 --- a/TableProTests/Models/KeyboardShortcutTests.swift +++ b/TableProTests/Models/KeyboardShortcutTests.swift @@ -30,6 +30,26 @@ struct ShortcutActionDefaultsTests { func cancelQueryDefault() { #expect(KeyboardSettings.defaultShortcuts[.cancelQuery] == KeyCombo(key: ".", command: true)) } + + @Test("Save as Favorite default is Cmd+D") + func saveAsFavoriteDefault() { + #expect(KeyboardSettings.defaultShortcuts[.saveAsFavorite] == KeyCombo(key: "d", command: true)) + } +} + +@Suite("System reserved shortcuts") +struct SystemReservedShortcutTests { + @Test("Ctrl+Cmd+D is reserved by macOS for Look Up") + func ctrlCmdDIsReserved() { + #expect(KeyCombo(key: "d", command: true, control: true).isSystemReserved) + } + + @Test("No default shortcut collides with a system-reserved combo") + func defaultsAvoidSystemReserved() { + for (action, combo) in KeyboardSettings.defaultShortcuts { + #expect(!combo.isSystemReserved, "\(action.rawValue) ships a system-reserved default: \(combo.displayString)") + } + } } @Suite("Bare-key validation") diff --git a/docs/features/favorites.mdx b/docs/features/favorites.mdx index c6ddb2688..256113a5a 100644 --- a/docs/features/favorites.mdx +++ b/docs/features/favorites.mdx @@ -24,8 +24,9 @@ Save queries you run often. Organize them in folders, assign keyword shortcuts, ## Creating an SQL Favorite -Three ways to save a favorite: +Ways to save a favorite: +- **From the editor toolbar**: Click the star button above the editor, or press `Cmd+D` - **From the editor**: Right-click selected SQL > **Save as Favorite** - **From query history**: Right-click an entry > **Save as Favorite** - **From the sidebar**: Click **+ New Favorite** in the Favorites tab @@ -48,7 +49,7 @@ Enter a name, the SQL text, and optionally a keyword and scope. ## Keyword Expansion -Assign a unique keyword to a favorite (e.g., `selall`). Type the keyword in the editor and it appears as an autocomplete suggestion. Select it to insert the full SQL. +Assign a unique keyword to a favorite (e.g., `selall`). Start typing the keyword in the SQL editor and it shows up in the autocomplete popup as a starred suggestion with the favorite's name. Press Tab or Enter to insert the full SQL. Keywords must be unique across all favorites in the same scope. diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 4097066ba..366eb8fc6 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -41,6 +41,7 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut | Cancel query | `Cmd+.` | Stop the currently running query | | Explain query | `Option+Cmd+E` | Show execution plan for query at cursor | | Format SQL | `Cmd+Shift+L` | Format SQL query | +| Save as Favorite | `Cmd+D` | Save the current query as a favorite | ### Text Editing From 966f0b4d44be40f9a42eb7678c92ca9cea69352e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 1 Jun 2026 12:58:32 +0700 Subject: [PATCH 2/2] refactor(editor): derive toolbar tooltip shortcuts from the keyboard bindings --- CHANGELOG.md | 1 + TablePro/Views/Editor/QueryEditorView.swift | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8caedb25..e54b256a9 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 ### Changed - Save as Favorite uses Cmd+D again. The Cmd+Control+D set in 0.47.0 is reserved by macOS for Look Up, so it never fired. +- Editor toolbar buttons show their keyboard shortcut in the tooltip, and it updates if you rebind the shortcut. ### Fixed diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index 568f86707..5da9a9d9f 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -108,7 +108,7 @@ struct QueryEditorView: View { .frame(width: 24, height: 24) } .buttonStyle(.borderless) - .help(String(localized: "Format Query (⇧⌘L)")) + .help(shortcutHint(String(localized: "Format Query"), for: .formatQuery)) .accessibilityLabel(String(localized: "Format Query")) .optionalKeyboardShortcut(AppSettingsManager.shared.keyboard.keyboardShortcut(for: .formatQuery)) @@ -117,7 +117,7 @@ struct QueryEditorView: View { .frame(width: 24, height: 24) } .buttonStyle(.borderless) - .help(String(localized: "Save as Favorite (⌘D)")) + .help(shortcutHint(String(localized: "Save as Favorite"), for: .saveAsFavorite)) .accessibilityLabel(String(localized: "Save as Favorite")) .disabled(!hasQueryText) @@ -135,6 +135,7 @@ struct QueryEditorView: View { } .buttonStyle(.borderedProminent) .controlSize(.small) + .help(shortcutHint(String(localized: "Execute"), for: .executeQuery)) .optionalKeyboardShortcut(AppSettingsManager.shared.keyboard.keyboardShortcut(for: .executeQuery)) } .padding(.horizontal, 12) @@ -144,6 +145,13 @@ struct QueryEditorView: View { // MARK: - Helpers + private func shortcutHint(_ label: String, for action: ShortcutAction) -> String { + guard let combo = AppSettingsManager.shared.keyboard.shortcut(for: action), !combo.isCleared else { + return label + } + return "\(label) (\(combo.displayString))" + } + @ViewBuilder private func explainButton(hasQueryText: Bool) -> some View { let variants = databaseType?.explainVariants ?? [] @@ -167,6 +175,7 @@ struct QueryEditorView: View { } .buttonStyle(.bordered) .controlSize(.small) + .help(shortcutHint(String(localized: "Explain"), for: .explainQuery)) .disabled(!hasQueryText) } else { Menu { @@ -187,6 +196,7 @@ struct QueryEditorView: View { } .menuStyle(.borderlessButton) .fixedSize() + .help(shortcutHint(String(localized: "Explain"), for: .explainQuery)) .disabled(!hasQueryText) } }