Skip to content

Commit 4f59dee

Browse files
authored
refactor: favorites system rewrite (#797)
* refactor: favorites storage — batch delete, targeted fetch, remove sync tracking, schema migration v2 * feat: add Cmd+D Save as Favorite shortcut and Query menu item * fix: favorites review fixes — localization, notification-based save flow, Cmd+B shortcut * fix: change Save as Favorite shortcut from Cmd+B to Cmd+Shift+B to avoid system conflict * fix: move Save as Favorite notification observer to MainEditorContentView (always rendered) * fix: cap query/results size, move buildSystemPrompt off MainActor (#774) * fix: AI code highlighting dedup, inline suggestion batching, scroll anchor, size check * fix: AI maxOutputTokens config, Gemini fallback models, conversation cap, minor cleanup * fix: change Save as Favorite shortcut to Cmd+D (Apple standard for bookmarks) * fix: skip Return-to-insert while folder rename is active * fix: center empty state vertically in favorites sidebar * fix: use ContentUnavailableView for tables empty state to match favorites * fix: fetch folders async in edit dialog when not passed by caller
1 parent 49e0210 commit 4f59dee

13 files changed

+228
-85
lines changed

TablePro/Core/Services/Infrastructure/AppNotifications.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ extension Notification.Name {
3535
// MARK: - SQL Favorites
3636

3737
static let sqlFavoritesDidUpdate = Notification.Name("sqlFavoritesDidUpdate")
38+
static let saveAsFavoriteRequested = Notification.Name("saveAsFavoriteRequested")
3839

3940
// MARK: - Plugins
4041

TablePro/Core/Storage/SQLFavoriteManager.swift

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
import Foundation
77
import os
88

9-
/// Manages SQL favorites with notifications and sync tracking
10-
internal final class SQLFavoriteManager {
9+
/// Manages SQL favorites with notifications
10+
internal final class SQLFavoriteManager: @unchecked Sendable {
1111
static let shared = SQLFavoriteManager()
1212
private static let logger = Logger(subsystem: "com.TablePro", category: "SQLFavoriteManager")
1313

@@ -27,7 +27,6 @@ internal final class SQLFavoriteManager {
2727
func addFavorite(_ favorite: SQLFavorite) async -> Bool {
2828
let result = await storage.addFavorite(favorite)
2929
if result {
30-
SyncChangeTracker.shared.markDirty(.favorite, id: favorite.id.uuidString)
3130
postUpdateNotification()
3231
}
3332
return result
@@ -36,7 +35,6 @@ internal final class SQLFavoriteManager {
3635
func updateFavorite(_ favorite: SQLFavorite) async -> Bool {
3736
let result = await storage.updateFavorite(favorite)
3837
if result {
39-
SyncChangeTracker.shared.markDirty(.favorite, id: favorite.id.uuidString)
4038
postUpdateNotification()
4139
}
4240
return result
@@ -45,24 +43,22 @@ internal final class SQLFavoriteManager {
4543
func deleteFavorite(id: UUID) async -> Bool {
4644
let result = await storage.deleteFavorite(id: id)
4745
if result {
48-
SyncChangeTracker.shared.markDeleted(.favorite, id: id.uuidString)
4946
postUpdateNotification()
5047
}
5148
return result
5249
}
5350

5451
func deleteFavorites(ids: [UUID]) async {
55-
for id in ids {
56-
let result = await storage.deleteFavorite(id: id)
57-
if result {
58-
SyncChangeTracker.shared.markDeleted(.favorite, id: id.uuidString)
59-
}
60-
}
61-
if !ids.isEmpty {
52+
let result = await storage.deleteFavorites(ids: ids)
53+
if result {
6254
postUpdateNotification()
6355
}
6456
}
6557

58+
func fetchFavorite(id: UUID) async -> SQLFavorite? {
59+
await storage.fetchFavorite(id: id)
60+
}
61+
6662
func fetchFavorites(
6763
connectionId: UUID? = nil,
6864
folderId: UUID? = nil,
@@ -76,7 +72,6 @@ internal final class SQLFavoriteManager {
7672
func addFolder(_ folder: SQLFavoriteFolder) async -> Bool {
7773
let result = await storage.addFolder(folder)
7874
if result {
79-
SyncChangeTracker.shared.markDirty(.favoriteFolder, id: folder.id.uuidString)
8075
postUpdateNotification()
8176
}
8277
return result
@@ -85,7 +80,6 @@ internal final class SQLFavoriteManager {
8580
func updateFolder(_ folder: SQLFavoriteFolder) async -> Bool {
8681
let result = await storage.updateFolder(folder)
8782
if result {
88-
SyncChangeTracker.shared.markDirty(.favoriteFolder, id: folder.id.uuidString)
8983
postUpdateNotification()
9084
}
9185
return result
@@ -94,7 +88,6 @@ internal final class SQLFavoriteManager {
9488
func deleteFolder(id: UUID) async -> Bool {
9589
let result = await storage.deleteFolder(id: id)
9690
if result {
97-
SyncChangeTracker.shared.markDeleted(.favoriteFolder, id: id.uuidString)
9891
postUpdateNotification()
9992
}
10093
return result

TablePro/Core/Storage/SQLFavoriteStorage.swift

Lines changed: 131 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ internal final class SQLFavoriteStorage {
4444

4545
deinit {
4646
if let db = db {
47-
sqlite3_close(db)
47+
sqlite3_close_v2(db)
4848
}
4949
if Self.isRunningTests, let dbPath = dbPath {
5050
try? FileManager.default.removeItem(atPath: dbPath)
@@ -119,14 +119,75 @@ internal final class SQLFavoriteStorage {
119119
private func migrateIfNeeded() {
120120
let currentVersion = getUserVersion()
121121

122-
// Future migrations go here:
123-
// if currentVersion < 2 {
124-
// execute("ALTER TABLE favorites ADD COLUMN new_col TEXT;")
125-
// }
122+
if currentVersion < 1 {
123+
// Fresh database — tables already created without is_synced, jump to latest version
124+
setUserVersion(2)
125+
return
126+
}
126127

127-
let targetVersion: Int32 = 1
128-
if currentVersion < targetVersion {
129-
setUserVersion(targetVersion)
128+
if currentVersion < 2 {
129+
// Remove is_synced column using rename-recreate-copy pattern
130+
// (SQLite < 3.35.0 doesn't support ALTER TABLE DROP COLUMN)
131+
execute("ALTER TABLE favorites RENAME TO favorites_old")
132+
execute("""
133+
CREATE TABLE IF NOT EXISTS favorites (
134+
id TEXT PRIMARY KEY, name TEXT NOT NULL, query TEXT NOT NULL,
135+
keyword TEXT, folder_id TEXT, connection_id TEXT,
136+
sort_order INTEGER NOT NULL DEFAULT 0, created_at REAL NOT NULL, updated_at REAL NOT NULL
137+
)
138+
""")
139+
execute("""
140+
INSERT INTO favorites SELECT id, name, query, keyword, folder_id, connection_id,
141+
sort_order, created_at, updated_at FROM favorites_old
142+
""")
143+
execute("DROP TABLE favorites_old")
144+
145+
execute("ALTER TABLE folders RENAME TO folders_old")
146+
execute("""
147+
CREATE TABLE IF NOT EXISTS folders (
148+
id TEXT PRIMARY KEY, name TEXT NOT NULL, parent_id TEXT, connection_id TEXT,
149+
sort_order INTEGER NOT NULL DEFAULT 0, created_at REAL NOT NULL, updated_at REAL NOT NULL
150+
)
151+
""")
152+
execute("""
153+
INSERT INTO folders SELECT id, name, parent_id, connection_id,
154+
sort_order, created_at, updated_at FROM folders_old
155+
""")
156+
execute("DROP TABLE folders_old")
157+
158+
// Recreate indexes dropped with the old tables
159+
execute("CREATE INDEX IF NOT EXISTS idx_favorites_connection ON favorites(connection_id);")
160+
execute("CREATE INDEX IF NOT EXISTS idx_favorites_folder ON favorites(folder_id);")
161+
execute("CREATE INDEX IF NOT EXISTS idx_favorites_keyword ON favorites(keyword);")
162+
execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_favorites_keyword_scope ON favorites(keyword, connection_id) WHERE keyword IS NOT NULL;")
163+
execute("CREATE INDEX IF NOT EXISTS idx_folders_connection ON folders(connection_id);")
164+
execute("CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);")
165+
166+
// Recreate FTS5 triggers referencing the new table
167+
execute("DROP TRIGGER IF EXISTS favorites_ai;")
168+
execute("DROP TRIGGER IF EXISTS favorites_ad;")
169+
execute("DROP TRIGGER IF EXISTS favorites_au;")
170+
execute("""
171+
CREATE TRIGGER IF NOT EXISTS favorites_ai AFTER INSERT ON favorites BEGIN
172+
INSERT INTO favorites_fts(rowid, name, query, keyword) VALUES (new.rowid, new.name, new.query, new.keyword);
173+
END;
174+
""")
175+
execute("""
176+
CREATE TRIGGER IF NOT EXISTS favorites_ad AFTER DELETE ON favorites BEGIN
177+
INSERT INTO favorites_fts(favorites_fts, rowid, name, query, keyword) VALUES('delete', old.rowid, old.name, old.query, old.keyword);
178+
END;
179+
""")
180+
execute("""
181+
CREATE TRIGGER IF NOT EXISTS favorites_au AFTER UPDATE ON favorites BEGIN
182+
INSERT INTO favorites_fts(favorites_fts, rowid, name, query, keyword) VALUES('delete', old.rowid, old.name, old.query, old.keyword);
183+
INSERT INTO favorites_fts(rowid, name, query, keyword) VALUES (new.rowid, new.name, new.query, new.keyword);
184+
END;
185+
""")
186+
187+
// Rebuild FTS5 index to match new table rowids
188+
execute("INSERT INTO favorites_fts(favorites_fts) VALUES('rebuild');")
189+
190+
setUserVersion(2)
130191
}
131192
}
132193

@@ -158,8 +219,7 @@ internal final class SQLFavoriteStorage {
158219
connection_id TEXT,
159220
sort_order INTEGER NOT NULL DEFAULT 0,
160221
created_at REAL NOT NULL,
161-
updated_at REAL NOT NULL,
162-
is_synced INTEGER DEFAULT 0
222+
updated_at REAL NOT NULL
163223
);
164224
"""
165225

@@ -171,8 +231,7 @@ internal final class SQLFavoriteStorage {
171231
connection_id TEXT,
172232
sort_order INTEGER NOT NULL DEFAULT 0,
173233
created_at REAL NOT NULL,
174-
updated_at REAL NOT NULL,
175-
is_synced INTEGER DEFAULT 0
234+
updated_at REAL NOT NULL
176235
);
177236
"""
178237

@@ -225,8 +284,14 @@ internal final class SQLFavoriteStorage {
225284

226285
private func execute(_ sql: String) {
227286
var statement: OpaquePointer?
228-
if sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK {
229-
sqlite3_step(statement)
287+
let prepareResult = sqlite3_prepare_v2(db, sql, -1, &statement, nil)
288+
if prepareResult == SQLITE_OK {
289+
let stepResult = sqlite3_step(statement)
290+
if stepResult != SQLITE_DONE && stepResult != SQLITE_ROW {
291+
Self.logger.error("sqlite3_step failed (\(stepResult)): \(String(cString: sqlite3_errmsg(self.db)))")
292+
}
293+
} else {
294+
Self.logger.error("sqlite3_prepare_v2 failed (\(prepareResult)): \(String(cString: sqlite3_errmsg(self.db)))")
230295
}
231296
sqlite3_finalize(statement)
232297
}
@@ -371,6 +436,58 @@ internal final class SQLFavoriteStorage {
371436
}
372437
}
373438

439+
func deleteFavorites(ids: [UUID]) async -> Bool {
440+
guard !ids.isEmpty else { return true }
441+
let idStrings = ids.map { $0.uuidString }
442+
return await performDatabaseWork { [weak self] in
443+
guard let self = self else { return false }
444+
445+
let placeholders = ids.map { _ in "?" }.joined(separator: ",")
446+
let sql = "DELETE FROM favorites WHERE id IN (\(placeholders));"
447+
448+
var statement: OpaquePointer?
449+
guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else {
450+
return false
451+
}
452+
453+
defer { sqlite3_finalize(statement) }
454+
455+
let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
456+
for (index, idString) in idStrings.enumerated() {
457+
sqlite3_bind_text(statement, Int32(index + 1), idString, -1, SQLITE_TRANSIENT)
458+
}
459+
460+
let result = sqlite3_step(statement)
461+
if result != SQLITE_DONE {
462+
Self.logger.error("Failed to batch delete favorites: \(String(cString: sqlite3_errmsg(self.db)))")
463+
}
464+
return result == SQLITE_DONE
465+
}
466+
}
467+
468+
func fetchFavorite(id: UUID) async -> SQLFavorite? {
469+
let idString = id.uuidString
470+
return await performDatabaseWork { [weak self] in
471+
guard let self = self else { return nil }
472+
473+
let sql = "SELECT id, name, query, keyword, folder_id, connection_id, sort_order, created_at, updated_at FROM favorites WHERE id = ? LIMIT 1;"
474+
var statement: OpaquePointer?
475+
guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else {
476+
return nil
477+
}
478+
479+
defer { sqlite3_finalize(statement) }
480+
481+
let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
482+
sqlite3_bind_text(statement, 1, idString, -1, SQLITE_TRANSIENT)
483+
484+
if sqlite3_step(statement) == SQLITE_ROW {
485+
return self.parseFavorite(from: statement)
486+
}
487+
return nil
488+
}
489+
}
490+
374491
func fetchFavorites(
375492
connectionId: UUID? = nil,
376493
folderId: UUID? = nil,

TablePro/Models/UI/KeyboardShortcutModels.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
7272
case duplicateRow
7373
case truncateTable
7474
case previewFKReference
75+
case saveAsFavorite
7576

7677
// View
7778
case toggleTableBrowser
@@ -98,7 +99,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
9899
case .manageConnections, .newTab, .openDatabase, .openFile, .switchConnection,
99100
.saveChanges, .saveAs, .previewSQL, .closeTab, .refresh,
100101
.executeQuery, .explainQuery, .formatQuery, .export, .importData, .quickSwitcher,
101-
.previousPage, .nextPage:
102+
.previousPage, .nextPage, .saveAsFavorite:
102103
return .file
103104
case .undo, .redo, .cut, .copy, .copyWithHeaders, .copyAsJson, .paste,
104105
.delete, .selectAll, .clearSelection, .addRow,
@@ -148,6 +149,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
148149
case .duplicateRow: return String(localized: "Duplicate Row")
149150
case .truncateTable: return String(localized: "Truncate Table")
150151
case .previewFKReference: return String(localized: "Preview FK Reference")
152+
case .saveAsFavorite: return String(localized: "Save as Favorite")
151153
case .toggleTableBrowser: return String(localized: "Toggle Table Browser")
152154
case .toggleInspector: return String(localized: "Toggle Inspector")
153155
case .toggleFilters: return String(localized: "Toggle Filters")
@@ -486,6 +488,7 @@ struct KeyboardSettings: Codable, Equatable {
486488
.duplicateRow: KeyCombo(key: "d", command: true, shift: true),
487489
.truncateTable: KeyCombo(key: "delete", option: true, isSpecialKey: true),
488490
.previewFKReference: KeyCombo(key: "space", isSpecialKey: true),
491+
.saveAsFavorite: KeyCombo(key: "d", command: true),
489492

490493
// View
491494
.toggleTableBrowser: KeyCombo(key: "0", command: true),

TablePro/TableProApp.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,14 @@ struct AppMenuCommands: Commands {
322322

323323
Divider()
324324

325+
Button(String(localized: "Save as Favorite")) {
326+
actions?.saveAsFavorite()
327+
}
328+
.optionalKeyboardShortcut(shortcut(for: .saveAsFavorite))
329+
.disabled(!(actions?.canSaveAsFavorite ?? false))
330+
331+
Divider()
332+
325333
Button(String(localized: "Explain with AI")) {
326334
actions?.aiExplainQuery()
327335
}

TablePro/Views/Editor/HistoryPanelView.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ struct HistoryPanelView: View {
5555
FavoriteEditDialog(
5656
connectionId: connectionId,
5757
favorite: nil,
58-
initialQuery: item.query
58+
initialQuery: item.query,
59+
folders: []
5960
)
6061
}
6162
}

TablePro/Views/Main/Child/MainEditorContentView.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,14 @@ struct MainEditorContentView: View {
117117
FavoriteEditDialog(
118118
connectionId: connectionId,
119119
favorite: nil,
120-
initialQuery: item.query
120+
initialQuery: item.query,
121+
folders: []
121122
)
122123
}
124+
.onReceive(NotificationCenter.default.publisher(for: .saveAsFavoriteRequested)) { notification in
125+
guard let query = notification.userInfo?["query"] as? String else { return }
126+
favoriteDialogQuery = FavoriteDialogQuery(query: query)
127+
}
123128
.onChange(of: tabManager.tabIds) { _, newIds in
124129
guard !sortCache.isEmpty || !tabProviderCache.isEmpty || !erDiagramViewModels.isEmpty
125130
|| !serverDashboardViewModels.isEmpty else {

TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@ extension MainContentCoordinator {
2828
}
2929
}
3030

31+
func saveCurrentQueryAsFavorite() {
32+
guard let tab = tabManager.selectedTab,
33+
tab.tabType == .query else { return }
34+
let query = tab.query.trimmingCharacters(in: .whitespacesAndNewlines)
35+
guard !query.isEmpty else { return }
36+
NotificationCenter.default.post(
37+
name: .saveAsFavoriteRequested,
38+
object: nil,
39+
userInfo: ["query": query]
40+
)
41+
}
42+
3143
/// Run a favorite's query: uses current tab if empty, otherwise opens a new tab.
3244
func runFavoriteInNewTab(_ favorite: SQLFavorite) {
3345
if tabManager.tabs.isEmpty {

TablePro/Views/Main/MainContentCommandActions.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,15 @@ final class MainContentCommandActions {
623623
coordinator?.openImportDialog()
624624
}
625625

626+
func saveAsFavorite() {
627+
coordinator?.saveCurrentQueryAsFavorite()
628+
}
629+
630+
var canSaveAsFavorite: Bool {
631+
guard let tab = coordinator?.tabManager.selectedTab else { return false }
632+
return tab.tabType == .query && !tab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
633+
}
634+
626635
func previewSQL() {
627636
coordinator?.handlePreviewSQL(
628637
pendingTruncates: pendingTruncates.wrappedValue,

TablePro/Views/Main/MainContentCoordinator.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,6 @@ final class MainContentCoordinator {
128128
// Removed: showErrorAlert and errorAlertMessage - errors now display inline
129129
var activeSheet: ActiveSheet?
130130
var importFileURL: URL?
131-
var pendingSaveAsFavoriteQuery: String?
132131
var needsLazyLoad = false
133132
var sidebarLoadingState: SidebarLoadingState = .idle
134133

0 commit comments

Comments
 (0)