Skip to content

Commit 3dff736

Browse files
committed
feat: add SQL favorites with keyword expansion and sidebar UI
1 parent 55f21b9 commit 3dff736

26 files changed

Lines changed: 1886 additions & 1 deletion

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- SQL Favorites: save and organize frequently used queries with optional keyword bindings for autocomplete expansion
1213
- Copy selected rows as JSON from context menu and Edit menu
1314
- iCloud Sync (Pro): sync connections, groups, tags, settings, and query history across Macs via CloudKit
1415
- Pro feature gating system with license-aware UI overlay for Pro-only features

TablePro/Core/Autocomplete/CompletionEngine.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ final class CompletionEngine {
4646

4747
// MARK: - Public API
4848

49+
/// Update favorite keywords for autocomplete expansion
50+
func updateFavoriteKeywords(_ keywords: [String: (name: String, query: String)]) {
51+
provider.updateFavoriteKeywords(keywords)
52+
}
53+
4954
/// Get completions for the given text and cursor position
5055
/// This is a pure function - no side effects
5156
func getCompletions(

TablePro/Core/Autocomplete/SQLCompletionItem.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ enum SQLCompletionKind: String, CaseIterable {
1818
case schema // Database/schema names
1919
case alias // Table aliases
2020
case `operator` // Operators (=, <>, LIKE, etc.)
21+
case favorite // Saved SQL favorite (keyword expansion)
2122

2223
/// SF Symbol for display
2324
var iconName: String {
@@ -30,6 +31,7 @@ enum SQLCompletionKind: String, CaseIterable {
3031
case .schema: return "s.circle.fill"
3132
case .alias: return "a.circle.fill"
3233
case .operator: return "equal.circle.fill"
34+
case .favorite: return "star.circle.fill"
3335
}
3436
}
3537

@@ -44,12 +46,14 @@ enum SQLCompletionKind: String, CaseIterable {
4446
case .schema: return .systemGreen
4547
case .alias: return .systemGray
4648
case .operator: return .systemIndigo
49+
case .favorite: return .systemYellow
4750
}
4851
}
4952

5053
/// Base sort priority (lower = higher priority in same context)
5154
var basePriority: Int {
5255
switch self {
56+
case .favorite: return 50
5357
case .column: return 100
5458
case .table: return 200
5559
case .view: return 210
@@ -259,4 +263,15 @@ extension SQLCompletionItem {
259263
documentation: documentation
260264
)
261265
}
266+
267+
/// Create a favorite keyword expansion item
268+
static func favorite(keyword: String, name: String, query: String) -> SQLCompletionItem {
269+
SQLCompletionItem(
270+
label: keyword,
271+
kind: .favorite,
272+
insertText: query,
273+
detail: name,
274+
documentation: String(query.prefix(200))
275+
)
276+
}
262277
}

TablePro/Core/Autocomplete/SQLCompletionProvider.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ final class SQLCompletionProvider {
1717
private var databaseType: DatabaseType?
1818
private var cachedDialect: SQLDialectDescriptor?
1919
private var cachedStatementCompletions: [CompletionEntry] = []
20+
private var favoriteKeywords: [String: (name: String, query: String)] = [:]
2021

2122
/// Minimum prefix length to trigger suggestions
2223
private let minPrefixLength = 1
@@ -41,6 +42,11 @@ final class SQLCompletionProvider {
4142
self.cachedStatementCompletions = statementCompletions
4243
}
4344

45+
/// Update cached favorite keywords for autocomplete expansion
46+
func updateFavoriteKeywords(_ keywords: [String: (name: String, query: String)]) {
47+
self.favoriteKeywords = keywords
48+
}
49+
4450
// MARK: - Public API
4551

4652
/// Get completion suggestions for the current cursor position
@@ -81,6 +87,14 @@ final class SQLCompletionProvider {
8187
) async -> [SQLCompletionItem] {
8288
var items: [SQLCompletionItem] = []
8389

90+
// Check for favorite keyword matches first (highest priority)
91+
if !favoriteKeywords.isEmpty && !context.prefix.isEmpty {
92+
let lowerPrefix = context.prefix.lowercased()
93+
for (keyword, value) in favoriteKeywords where keyword.lowercased().hasPrefix(lowerPrefix) {
94+
items.append(.favorite(keyword: keyword, name: value.name, query: value.query))
95+
}
96+
}
97+
8498
// If we have a dot prefix, we're looking for columns of a specific table
8599
if let dotPrefix = context.dotPrefix {
86100
// Resolve the table name from alias or direct reference

TablePro/Core/Services/Infrastructure/AppNotifications.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,8 @@ extension Notification.Name {
1818

1919
static let connectionUpdated = Notification.Name("connectionUpdated")
2020
static let databaseDidConnect = Notification.Name("databaseDidConnect")
21+
22+
// MARK: - SQL Favorites
23+
24+
static let sqlFavoritesDidUpdate = Notification.Name("sqlFavoritesDidUpdate")
2125
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
//
2+
// SQLFavoriteManager.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import os
8+
9+
/// Manages SQL favorites with notifications and sync tracking
10+
final class SQLFavoriteManager {
11+
static let shared = SQLFavoriteManager()
12+
private static let logger = Logger(subsystem: "com.TablePro", category: "SQLFavoriteManager")
13+
14+
private let storage: SQLFavoriteStorage
15+
16+
/// Creates an isolated manager with its own storage. For testing only.
17+
init(isolatedStorage: SQLFavoriteStorage) {
18+
self.storage = isolatedStorage
19+
}
20+
21+
private init() {
22+
self.storage = SQLFavoriteStorage.shared
23+
}
24+
25+
// MARK: - Favorites
26+
27+
func addFavorite(_ favorite: SQLFavorite) async -> Bool {
28+
let result = await storage.addFavorite(favorite)
29+
if result {
30+
SyncChangeTracker.shared.markDirty(.favorite, id: favorite.id.uuidString)
31+
postUpdateNotification()
32+
}
33+
return result
34+
}
35+
36+
func updateFavorite(_ favorite: SQLFavorite) async -> Bool {
37+
let result = await storage.updateFavorite(favorite)
38+
if result {
39+
SyncChangeTracker.shared.markDirty(.favorite, id: favorite.id.uuidString)
40+
postUpdateNotification()
41+
}
42+
return result
43+
}
44+
45+
func deleteFavorite(id: UUID) async -> Bool {
46+
let result = await storage.deleteFavorite(id: id)
47+
if result {
48+
SyncChangeTracker.shared.markDeleted(.favorite, id: id.uuidString)
49+
postUpdateNotification()
50+
}
51+
return result
52+
}
53+
54+
func fetchFavorites(
55+
connectionId: UUID? = nil,
56+
folderId: UUID? = nil,
57+
searchText: String? = nil
58+
) async -> [SQLFavorite] {
59+
await storage.fetchFavorites(connectionId: connectionId, folderId: folderId, searchText: searchText)
60+
}
61+
62+
// MARK: - Folders
63+
64+
func addFolder(_ folder: SQLFavoriteFolder) async -> Bool {
65+
let result = await storage.addFolder(folder)
66+
if result {
67+
SyncChangeTracker.shared.markDirty(.favoriteFolder, id: folder.id.uuidString)
68+
postUpdateNotification()
69+
}
70+
return result
71+
}
72+
73+
func updateFolder(_ folder: SQLFavoriteFolder) async -> Bool {
74+
let result = await storage.updateFolder(folder)
75+
if result {
76+
SyncChangeTracker.shared.markDirty(.favoriteFolder, id: folder.id.uuidString)
77+
postUpdateNotification()
78+
}
79+
return result
80+
}
81+
82+
func deleteFolder(id: UUID) async -> Bool {
83+
let result = await storage.deleteFolder(id: id)
84+
if result {
85+
SyncChangeTracker.shared.markDeleted(.favoriteFolder, id: id.uuidString)
86+
postUpdateNotification()
87+
}
88+
return result
89+
}
90+
91+
func fetchFolders(connectionId: UUID? = nil) async -> [SQLFavoriteFolder] {
92+
await storage.fetchFolders(connectionId: connectionId)
93+
}
94+
95+
// MARK: - Keyword Support
96+
97+
func fetchKeywordMap(connectionId: UUID? = nil) async -> [String: (name: String, query: String)] {
98+
await storage.fetchKeywordMap(connectionId: connectionId)
99+
}
100+
101+
func isKeywordAvailable(
102+
_ keyword: String,
103+
connectionId: UUID?,
104+
excludingFavoriteId: UUID? = nil
105+
) async -> Bool {
106+
await storage.isKeywordAvailable(keyword, connectionId: connectionId, excludingFavoriteId: excludingFavoriteId)
107+
}
108+
109+
// MARK: - Notifications
110+
111+
private func postUpdateNotification() {
112+
DispatchQueue.main.async {
113+
NotificationCenter.default.post(name: .sqlFavoritesDidUpdate, object: nil)
114+
}
115+
}
116+
}

0 commit comments

Comments
 (0)