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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ 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.
- Editor toolbar buttons show their keyboard shortcut in the tooltip, and it updates if you rebind the shortcut.

### 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
Expand Down
19 changes: 11 additions & 8 deletions TablePro/Core/Autocomplete/SQLCompletionProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion TablePro/Models/UI/KeyboardShortcutModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
21 changes: 20 additions & 1 deletion TablePro/Views/Editor/QueryEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,19 @@ 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))

Button(action: { onSaveAsFavorite?(queryText) }) {
Image(systemName: "star")
.frame(width: 24, height: 24)
}
.buttonStyle(.borderless)
.help(shortcutHint(String(localized: "Save as Favorite"), for: .saveAsFavorite))
.accessibilityLabel(String(localized: "Save as Favorite"))
.disabled(!hasQueryText)

Divider()
.frame(height: 16)

Expand All @@ -126,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)
Expand All @@ -135,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 ?? []
Expand All @@ -158,6 +175,7 @@ struct QueryEditorView: View {
}
.buttonStyle(.bordered)
.controlSize(.small)
.help(shortcutHint(String(localized: "Explain"), for: .explainQuery))
.disabled(!hasQueryText)
} else {
Menu {
Expand All @@ -178,6 +196,7 @@ struct QueryEditorView: View {
}
.menuStyle(.borderlessButton)
.fixedSize()
.help(shortcutHint(String(localized: "Explain"), for: .explainQuery))
.disabled(!hasQueryText)
}
}
Expand Down
1 change: 1 addition & 0 deletions TablePro/Views/RightSidebar/EditableFieldView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ internal struct FieldDetailView: View {

TypeBadge(context.columnType.badgeLabel)
}
.textSelection(.enabled)
}

private func editorMinHeight(for kind: FieldEditorKind) -> CGFloat? {
Expand Down
40 changes: 40 additions & 0 deletions TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
20 changes: 20 additions & 0 deletions TableProTests/Models/KeyboardShortcutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
5 changes: 3 additions & 2 deletions docs/features/favorites.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down
1 change: 1 addition & 0 deletions docs/features/keyboard-shortcuts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading