From 3dff736d93209116d242a35bd58e894bb4f5ed8e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 14:30:46 +0700 Subject: [PATCH 01/20] feat: add SQL favorites with keyword expansion and sidebar UI --- CHANGELOG.md | 1 + .../Core/Autocomplete/CompletionEngine.swift | 5 + .../Core/Autocomplete/SQLCompletionItem.swift | 15 + .../Autocomplete/SQLCompletionProvider.swift | 14 + .../Infrastructure/AppNotifications.swift | 4 + .../Core/Storage/SQLFavoriteManager.swift | 116 +++ .../Core/Storage/SQLFavoriteStorage.swift | 809 ++++++++++++++++++ TablePro/Core/Sync/SyncRecordMapper.swift | 4 + TablePro/Models/Query/SQLFavorite.swift | 41 + TablePro/Models/Query/SQLFavoriteFolder.swift | 35 + .../FavoritesSidebarViewModel.swift | 187 ++++ .../Views/Editor/AIEditorContextMenu.swift | 20 + TablePro/Views/Editor/HistoryPanelView.swift | 17 + TablePro/Views/Editor/QueryEditorView.swift | 6 +- .../Views/Editor/SQLCompletionAdapter.swift | 5 + .../Views/Editor/SQLEditorCoordinator.swift | 2 + TablePro/Views/Editor/SQLEditorView.swift | 42 + .../Main/Child/MainEditorContentView.swift | 14 + .../MainContentCoordinator+Favorites.swift | 25 + .../Views/Sidebar/FavoriteEditDialog.swift | 205 +++++ TablePro/Views/Sidebar/FavoriteRowView.swift | 36 + .../Sidebar/FavoritesSidebarSection.swift | 166 ++++ TablePro/Views/Sidebar/SidebarView.swift | 12 + docs/docs.json | 2 + docs/features/sql-favorites.mdx | 52 ++ docs/vi/features/sql-favorites.mdx | 52 ++ 26 files changed, 1886 insertions(+), 1 deletion(-) create mode 100644 TablePro/Core/Storage/SQLFavoriteManager.swift create mode 100644 TablePro/Core/Storage/SQLFavoriteStorage.swift create mode 100644 TablePro/Models/Query/SQLFavorite.swift create mode 100644 TablePro/Models/Query/SQLFavoriteFolder.swift create mode 100644 TablePro/ViewModels/FavoritesSidebarViewModel.swift create mode 100644 TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift create mode 100644 TablePro/Views/Sidebar/FavoriteEditDialog.swift create mode 100644 TablePro/Views/Sidebar/FavoriteRowView.swift create mode 100644 TablePro/Views/Sidebar/FavoritesSidebarSection.swift create mode 100644 docs/features/sql-favorites.mdx create mode 100644 docs/vi/features/sql-favorites.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b783771..3a9e9061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- SQL Favorites: save and organize frequently used queries with optional keyword bindings for autocomplete expansion - Copy selected rows as JSON from context menu and Edit menu - iCloud Sync (Pro): sync connections, groups, tags, settings, and query history across Macs via CloudKit - Pro feature gating system with license-aware UI overlay for Pro-only features diff --git a/TablePro/Core/Autocomplete/CompletionEngine.swift b/TablePro/Core/Autocomplete/CompletionEngine.swift index e0f736b5..8238c9a4 100644 --- a/TablePro/Core/Autocomplete/CompletionEngine.swift +++ b/TablePro/Core/Autocomplete/CompletionEngine.swift @@ -46,6 +46,11 @@ final class CompletionEngine { // MARK: - Public API + /// Update favorite keywords for autocomplete expansion + func updateFavoriteKeywords(_ keywords: [String: (name: String, query: String)]) { + provider.updateFavoriteKeywords(keywords) + } + /// Get completions for the given text and cursor position /// This is a pure function - no side effects func getCompletions( diff --git a/TablePro/Core/Autocomplete/SQLCompletionItem.swift b/TablePro/Core/Autocomplete/SQLCompletionItem.swift index fabcd5d7..532ff756 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionItem.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionItem.swift @@ -18,6 +18,7 @@ enum SQLCompletionKind: String, CaseIterable { case schema // Database/schema names case alias // Table aliases case `operator` // Operators (=, <>, LIKE, etc.) + case favorite // Saved SQL favorite (keyword expansion) /// SF Symbol for display var iconName: String { @@ -30,6 +31,7 @@ enum SQLCompletionKind: String, CaseIterable { case .schema: return "s.circle.fill" case .alias: return "a.circle.fill" case .operator: return "equal.circle.fill" + case .favorite: return "star.circle.fill" } } @@ -44,12 +46,14 @@ enum SQLCompletionKind: String, CaseIterable { case .schema: return .systemGreen case .alias: return .systemGray case .operator: return .systemIndigo + case .favorite: return .systemYellow } } /// Base sort priority (lower = higher priority in same context) var basePriority: Int { switch self { + case .favorite: return 50 case .column: return 100 case .table: return 200 case .view: return 210 @@ -259,4 +263,15 @@ extension SQLCompletionItem { documentation: documentation ) } + + /// Create a favorite keyword expansion item + static func favorite(keyword: String, name: String, query: String) -> SQLCompletionItem { + SQLCompletionItem( + label: keyword, + kind: .favorite, + insertText: query, + detail: name, + documentation: String(query.prefix(200)) + ) + } } diff --git a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index 246170fe..9d9673a9 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -17,6 +17,7 @@ final class SQLCompletionProvider { private var databaseType: DatabaseType? private var cachedDialect: SQLDialectDescriptor? private var cachedStatementCompletions: [CompletionEntry] = [] + private var favoriteKeywords: [String: (name: String, query: String)] = [:] /// Minimum prefix length to trigger suggestions private let minPrefixLength = 1 @@ -41,6 +42,11 @@ final class SQLCompletionProvider { self.cachedStatementCompletions = statementCompletions } + /// Update cached favorite keywords for autocomplete expansion + func updateFavoriteKeywords(_ keywords: [String: (name: String, query: String)]) { + self.favoriteKeywords = keywords + } + // MARK: - Public API /// Get completion suggestions for the current cursor position @@ -81,6 +87,14 @@ 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 diff --git a/TablePro/Core/Services/Infrastructure/AppNotifications.swift b/TablePro/Core/Services/Infrastructure/AppNotifications.swift index f0043105..741453cc 100644 --- a/TablePro/Core/Services/Infrastructure/AppNotifications.swift +++ b/TablePro/Core/Services/Infrastructure/AppNotifications.swift @@ -18,4 +18,8 @@ extension Notification.Name { static let connectionUpdated = Notification.Name("connectionUpdated") static let databaseDidConnect = Notification.Name("databaseDidConnect") + + // MARK: - SQL Favorites + + static let sqlFavoritesDidUpdate = Notification.Name("sqlFavoritesDidUpdate") } diff --git a/TablePro/Core/Storage/SQLFavoriteManager.swift b/TablePro/Core/Storage/SQLFavoriteManager.swift new file mode 100644 index 00000000..ab316749 --- /dev/null +++ b/TablePro/Core/Storage/SQLFavoriteManager.swift @@ -0,0 +1,116 @@ +// +// SQLFavoriteManager.swift +// TablePro +// + +import Foundation +import os + +/// Manages SQL favorites with notifications and sync tracking +final class SQLFavoriteManager { + static let shared = SQLFavoriteManager() + private static let logger = Logger(subsystem: "com.TablePro", category: "SQLFavoriteManager") + + private let storage: SQLFavoriteStorage + + /// Creates an isolated manager with its own storage. For testing only. + init(isolatedStorage: SQLFavoriteStorage) { + self.storage = isolatedStorage + } + + private init() { + self.storage = SQLFavoriteStorage.shared + } + + // MARK: - Favorites + + func addFavorite(_ favorite: SQLFavorite) async -> Bool { + let result = await storage.addFavorite(favorite) + if result { + SyncChangeTracker.shared.markDirty(.favorite, id: favorite.id.uuidString) + postUpdateNotification() + } + return result + } + + func updateFavorite(_ favorite: SQLFavorite) async -> Bool { + let result = await storage.updateFavorite(favorite) + if result { + SyncChangeTracker.shared.markDirty(.favorite, id: favorite.id.uuidString) + postUpdateNotification() + } + return result + } + + func deleteFavorite(id: UUID) async -> Bool { + let result = await storage.deleteFavorite(id: id) + if result { + SyncChangeTracker.shared.markDeleted(.favorite, id: id.uuidString) + postUpdateNotification() + } + return result + } + + func fetchFavorites( + connectionId: UUID? = nil, + folderId: UUID? = nil, + searchText: String? = nil + ) async -> [SQLFavorite] { + await storage.fetchFavorites(connectionId: connectionId, folderId: folderId, searchText: searchText) + } + + // MARK: - Folders + + func addFolder(_ folder: SQLFavoriteFolder) async -> Bool { + let result = await storage.addFolder(folder) + if result { + SyncChangeTracker.shared.markDirty(.favoriteFolder, id: folder.id.uuidString) + postUpdateNotification() + } + return result + } + + func updateFolder(_ folder: SQLFavoriteFolder) async -> Bool { + let result = await storage.updateFolder(folder) + if result { + SyncChangeTracker.shared.markDirty(.favoriteFolder, id: folder.id.uuidString) + postUpdateNotification() + } + return result + } + + func deleteFolder(id: UUID) async -> Bool { + let result = await storage.deleteFolder(id: id) + if result { + SyncChangeTracker.shared.markDeleted(.favoriteFolder, id: id.uuidString) + postUpdateNotification() + } + return result + } + + func fetchFolders(connectionId: UUID? = nil) async -> [SQLFavoriteFolder] { + await storage.fetchFolders(connectionId: connectionId) + } + + // MARK: - Keyword Support + + func fetchKeywordMap(connectionId: UUID? = nil) async -> [String: (name: String, query: String)] { + await storage.fetchKeywordMap(connectionId: connectionId) + } + + func isKeywordAvailable( + _ keyword: String, + connectionId: UUID?, + excludingFavoriteId: UUID? = nil + ) async -> Bool { + await storage.isKeywordAvailable(keyword, connectionId: connectionId, excludingFavoriteId: excludingFavoriteId) + } + + // MARK: - Notifications + + private func postUpdateNotification() { + DispatchQueue.main.async { + NotificationCenter.default.post(name: .sqlFavoritesDidUpdate, object: nil) + } + } +} diff --git a/TablePro/Core/Storage/SQLFavoriteStorage.swift b/TablePro/Core/Storage/SQLFavoriteStorage.swift new file mode 100644 index 00000000..83e7e572 --- /dev/null +++ b/TablePro/Core/Storage/SQLFavoriteStorage.swift @@ -0,0 +1,809 @@ +// +// SQLFavoriteStorage.swift +// TablePro +// + +import Foundation +import os +import SQLite3 + +/// Thread-safe SQLite storage for SQL favorites with FTS5 full-text search +final class SQLFavoriteStorage { + static let shared = SQLFavoriteStorage() + private static let logger = Logger(subsystem: "com.TablePro", category: "SQLFavoriteStorage") + + private let queue = DispatchQueue(label: "com.TablePro.sqlfavorites", qos: .utility) + private var db: OpaquePointer? + + private static var isRunningTests: Bool { + NSClassFromString("XCTestCase") != nil + } + + private init() { + queue.async { [weak self] in + self?.setupDatabase() + } + } + + /// Creates an isolated instance with a unique database file. For testing only. + init(isolatedForTesting: Bool) { + testDatabaseSuffix = isolatedForTesting ? "_\(UUID().uuidString)" : nil + let semaphore = DispatchSemaphore(value: 0) + queue.async { [self] in + setupDatabase() + semaphore.signal() + } + semaphore.wait() + } + + private var testDatabaseSuffix: String? + + private var dbPath: String? + + deinit { + if let db = db { + sqlite3_close(db) + } + if Self.isRunningTests, let dbPath = dbPath { + try? FileManager.default.removeItem(atPath: dbPath) + for suffix in ["-wal", "-shm"] { + try? FileManager.default.removeItem(atPath: dbPath + suffix) + } + } + } + + // MARK: - Database Work Helpers + + private func performDatabaseWork(_ work: @escaping () throws -> T) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + queue.async { + do { + let result = try work() + continuation.resume(returning: result) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private func performDatabaseWork(_ work: @escaping () -> T) async -> T { + await withCheckedContinuation { continuation in + queue.async { + let result = work() + continuation.resume(returning: result) + } + } + } + + // MARK: - Database Setup + + private func setupDatabase() { + let fileManager = FileManager.default + guard + let appSupport = fileManager.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first + else { + Self.logger.error("Unable to access application support directory") + return + } + let tableProDir = appSupport.appendingPathComponent("TablePro") + + try? fileManager.createDirectory(at: tableProDir, withIntermediateDirectories: true) + + let suffix = testDatabaseSuffix ?? "" + let dbFileName = Self.isRunningTests + ? "sql_favorites_test_\(ProcessInfo.processInfo.processIdentifier)\(suffix).db" + : "sql_favorites.db" + let dbPath = tableProDir.appendingPathComponent(dbFileName).path(percentEncoded: false) + + self.dbPath = dbPath + + if sqlite3_open(dbPath, &db) != SQLITE_OK { + Self.logger.error("Error opening database") + return + } + + execute("PRAGMA journal_mode=WAL;") + execute("PRAGMA synchronous=NORMAL;") + + createTables() + } + + private func createTables() { + let favoritesTable = """ + CREATE TABLE IF NOT EXISTS favorites ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + query TEXT NOT NULL, + keyword TEXT, + folder_id TEXT, + connection_id TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + is_synced INTEGER DEFAULT 0 + ); + """ + + let foldersTable = """ + CREATE TABLE IF NOT EXISTS folders ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + parent_id TEXT, + connection_id TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + is_synced INTEGER DEFAULT 0 + ); + """ + + let ftsTable = """ + CREATE VIRTUAL TABLE IF NOT EXISTS favorites_fts USING fts5( + name, query, keyword, + content='favorites', + content_rowid='rowid' + ); + """ + + let ftsInsertTrigger = """ + CREATE TRIGGER IF NOT EXISTS favorites_ai AFTER INSERT ON favorites BEGIN + INSERT INTO favorites_fts(rowid, name, query, keyword) VALUES (new.rowid, new.name, new.query, new.keyword); + END; + """ + + let ftsDeleteTrigger = """ + CREATE TRIGGER IF NOT EXISTS favorites_ad AFTER DELETE ON favorites BEGIN + INSERT INTO favorites_fts(favorites_fts, rowid, name, query, keyword) VALUES('delete', old.rowid, old.name, old.query, old.keyword); + END; + """ + + let ftsUpdateTrigger = """ + CREATE TRIGGER IF NOT EXISTS favorites_au AFTER UPDATE ON favorites BEGIN + INSERT INTO favorites_fts(favorites_fts, rowid, name, query, keyword) VALUES('delete', old.rowid, old.name, old.query, old.keyword); + INSERT INTO favorites_fts(rowid, name, query, keyword) VALUES (new.rowid, new.name, new.query, new.keyword); + END; + """ + + let indexes = [ + "CREATE INDEX IF NOT EXISTS idx_favorites_connection ON favorites(connection_id);", + "CREATE INDEX IF NOT EXISTS idx_favorites_folder ON favorites(folder_id);", + "CREATE INDEX IF NOT EXISTS idx_favorites_keyword ON favorites(keyword);", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_favorites_keyword_scope ON favorites(keyword, connection_id) WHERE keyword IS NOT NULL;", + "CREATE INDEX IF NOT EXISTS idx_folders_connection ON folders(connection_id);", + "CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);", + ] + + execute(favoritesTable) + execute(foldersTable) + execute(ftsTable) + execute(ftsInsertTrigger) + execute(ftsDeleteTrigger) + execute(ftsUpdateTrigger) + indexes.forEach { execute($0) } + } + + // MARK: - Helper Methods + + private func execute(_ sql: String) { + var statement: OpaquePointer? + if sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK { + sqlite3_step(statement) + } + sqlite3_finalize(statement) + } + + // MARK: - Favorite Operations + + func addFavorite(_ favorite: SQLFavorite) async -> Bool { + let idString = favorite.id.uuidString + let nameString = favorite.name + let queryString = favorite.query + let keywordString = favorite.keyword + let folderIdString = favorite.folderId?.uuidString + let connectionIdString = favorite.connectionId?.uuidString + let sortOrder = Int32(favorite.sortOrder) + let createdAt = favorite.createdAt.timeIntervalSince1970 + let updatedAt = favorite.updatedAt.timeIntervalSince1970 + + return await performDatabaseWork { [weak self] in + guard let self = self else { return false } + + let sql = """ + INSERT INTO favorites (id, name, query, keyword, folder_id, connection_id, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + """ + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { + return false + } + + defer { sqlite3_finalize(statement) } + + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + sqlite3_bind_text(statement, 1, idString, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(statement, 2, nameString, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(statement, 3, queryString, -1, SQLITE_TRANSIENT) + + if let keyword = keywordString { + sqlite3_bind_text(statement, 4, keyword, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 4) + } + + if let folderId = folderIdString { + sqlite3_bind_text(statement, 5, folderId, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 5) + } + + if let connectionId = connectionIdString { + sqlite3_bind_text(statement, 6, connectionId, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 6) + } + + sqlite3_bind_int(statement, 7, sortOrder) + sqlite3_bind_double(statement, 8, createdAt) + sqlite3_bind_double(statement, 9, updatedAt) + + let result = sqlite3_step(statement) + if result != SQLITE_DONE { + Self.logger.error("Failed to add favorite: \(String(cString: sqlite3_errmsg(self.db)))") + } + return result == SQLITE_DONE + } + } + + func updateFavorite(_ favorite: SQLFavorite) async -> Bool { + let idString = favorite.id.uuidString + let nameString = favorite.name + let queryString = favorite.query + let keywordString = favorite.keyword + let folderIdString = favorite.folderId?.uuidString + let connectionIdString = favorite.connectionId?.uuidString + let sortOrder = Int32(favorite.sortOrder) + let updatedAt = favorite.updatedAt.timeIntervalSince1970 + + return await performDatabaseWork { [weak self] in + guard let self = self else { return false } + + let sql = """ + UPDATE favorites SET name = ?, query = ?, keyword = ?, folder_id = ?, connection_id = ?, sort_order = ?, updated_at = ? + WHERE id = ?; + """ + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { + return false + } + + defer { sqlite3_finalize(statement) } + + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + sqlite3_bind_text(statement, 1, nameString, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(statement, 2, queryString, -1, SQLITE_TRANSIENT) + + if let keyword = keywordString { + sqlite3_bind_text(statement, 3, keyword, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 3) + } + + if let folderId = folderIdString { + sqlite3_bind_text(statement, 4, folderId, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 4) + } + + if let connectionId = connectionIdString { + sqlite3_bind_text(statement, 5, connectionId, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 5) + } + + sqlite3_bind_int(statement, 6, sortOrder) + sqlite3_bind_double(statement, 7, updatedAt) + sqlite3_bind_text(statement, 8, idString, -1, SQLITE_TRANSIENT) + + let result = sqlite3_step(statement) + return result == SQLITE_DONE + } + } + + func deleteFavorite(id: UUID) async -> Bool { + let idString = id.uuidString + return await performDatabaseWork { [weak self] in + guard let self = self else { return false } + + let sql = "DELETE FROM favorites WHERE id = ?;" + var statement: OpaquePointer? + guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { + return false + } + + defer { sqlite3_finalize(statement) } + + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, idString, -1, SQLITE_TRANSIENT) + return sqlite3_step(statement) == SQLITE_DONE + } + } + + func fetchFavorites( + connectionId: UUID? = nil, + folderId: UUID? = nil, + searchText: String? = nil + ) async -> [SQLFavorite] { + let connectionIdString = connectionId?.uuidString + let folderIdString = folderId?.uuidString + + return await performDatabaseWork { [weak self] in + guard let self = self else { return [] } + + var sql: String + var bindIndex: Int32 = 1 + var hasConnectionFilter = false + var hasFolderFilter = false + + if let searchText = searchText, !searchText.isEmpty { + sql = """ + SELECT f.id, f.name, f.query, f.keyword, f.folder_id, f.connection_id, f.sort_order, f.created_at, f.updated_at + FROM favorites f + INNER JOIN favorites_fts ON f.rowid = favorites_fts.rowid + WHERE favorites_fts MATCH ? + """ + + if connectionIdString != nil { + sql += " AND (f.connection_id IS NULL OR f.connection_id = ?)" + hasConnectionFilter = true + } + + if folderIdString != nil { + sql += " AND f.folder_id = ?" + hasFolderFilter = true + } + } else { + sql = """ + SELECT id, name, query, keyword, folder_id, connection_id, sort_order, created_at, updated_at + FROM favorites + """ + + var whereClauses: [String] = [] + + if connectionIdString != nil { + whereClauses.append("(connection_id IS NULL OR connection_id = ?)") + hasConnectionFilter = true + } + + if folderIdString != nil { + whereClauses.append("folder_id = ?") + hasFolderFilter = true + } else if searchText == nil { + whereClauses.append("folder_id IS NULL") + } + + if !whereClauses.isEmpty { + sql += " WHERE " + whereClauses.joined(separator: " AND ") + } + } + + sql += " ORDER BY sort_order ASC, name ASC;" + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { + return [] + } + + defer { sqlite3_finalize(statement) } + + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + if let searchText = searchText, !searchText.isEmpty { + let sanitized = "\"\(searchText.replacingOccurrences(of: "\"", with: "\"\""))\"" + sqlite3_bind_text(statement, bindIndex, sanitized, -1, SQLITE_TRANSIENT) + bindIndex += 1 + } + + if let connId = connectionIdString, hasConnectionFilter { + sqlite3_bind_text(statement, bindIndex, connId, -1, SQLITE_TRANSIENT) + bindIndex += 1 + } + + if let foldId = folderIdString, hasFolderFilter { + sqlite3_bind_text(statement, bindIndex, foldId, -1, SQLITE_TRANSIENT) + bindIndex += 1 + } + + var favorites: [SQLFavorite] = [] + while sqlite3_step(statement) == SQLITE_ROW { + if let favorite = self.parseFavorite(from: statement) { + favorites.append(favorite) + } + } + + return favorites + } + } + + // MARK: - Folder Operations + + func addFolder(_ folder: SQLFavoriteFolder) async -> Bool { + let idString = folder.id.uuidString + let nameString = folder.name + let parentIdString = folder.parentId?.uuidString + let connectionIdString = folder.connectionId?.uuidString + let sortOrder = Int32(folder.sortOrder) + let createdAt = folder.createdAt.timeIntervalSince1970 + let updatedAt = folder.updatedAt.timeIntervalSince1970 + + return await performDatabaseWork { [weak self] in + guard let self = self else { return false } + + let sql = """ + INSERT INTO folders (id, name, parent_id, connection_id, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?); + """ + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { + return false + } + + defer { sqlite3_finalize(statement) } + + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + sqlite3_bind_text(statement, 1, idString, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(statement, 2, nameString, -1, SQLITE_TRANSIENT) + + if let parentId = parentIdString { + sqlite3_bind_text(statement, 3, parentId, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 3) + } + + if let connectionId = connectionIdString { + sqlite3_bind_text(statement, 4, connectionId, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 4) + } + + sqlite3_bind_int(statement, 5, sortOrder) + sqlite3_bind_double(statement, 6, createdAt) + sqlite3_bind_double(statement, 7, updatedAt) + + let result = sqlite3_step(statement) + return result == SQLITE_DONE + } + } + + func updateFolder(_ folder: SQLFavoriteFolder) async -> Bool { + let idString = folder.id.uuidString + let nameString = folder.name + let parentIdString = folder.parentId?.uuidString + let connectionIdString = folder.connectionId?.uuidString + let sortOrder = Int32(folder.sortOrder) + let updatedAt = folder.updatedAt.timeIntervalSince1970 + + return await performDatabaseWork { [weak self] in + guard let self = self else { return false } + + let sql = """ + UPDATE folders SET name = ?, parent_id = ?, connection_id = ?, sort_order = ?, updated_at = ? + WHERE id = ?; + """ + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { + return false + } + + defer { sqlite3_finalize(statement) } + + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + sqlite3_bind_text(statement, 1, nameString, -1, SQLITE_TRANSIENT) + + if let parentId = parentIdString { + sqlite3_bind_text(statement, 2, parentId, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 2) + } + + if let connectionId = connectionIdString { + sqlite3_bind_text(statement, 3, connectionId, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 3) + } + + sqlite3_bind_int(statement, 4, sortOrder) + sqlite3_bind_double(statement, 5, updatedAt) + sqlite3_bind_text(statement, 6, idString, -1, SQLITE_TRANSIENT) + + let result = sqlite3_step(statement) + return result == SQLITE_DONE + } + } + + func deleteFolder(id: UUID) async -> Bool { + let idString = id.uuidString + return await performDatabaseWork { [weak self] in + guard let self = self else { return false } + + let inTransaction = sqlite3_exec(self.db, "BEGIN IMMEDIATE;", nil, nil, nil) == SQLITE_OK + + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + // Find the parent_id of the folder being deleted + let findParentSQL = "SELECT parent_id FROM folders WHERE id = ?;" + var findStatement: OpaquePointer? + guard sqlite3_prepare_v2(self.db, findParentSQL, -1, &findStatement, nil) == SQLITE_OK else { + if inTransaction { sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) } + return false + } + + sqlite3_bind_text(findStatement, 1, idString, -1, SQLITE_TRANSIENT) + + var parentId: String? + if sqlite3_step(findStatement) == SQLITE_ROW { + parentId = sqlite3_column_text(findStatement, 0).map { String(cString: $0) } + } + sqlite3_finalize(findStatement) + + // Move child favorites to the parent folder + let moveFavoritesSQL = "UPDATE favorites SET folder_id = ? WHERE folder_id = ?;" + var moveFavStatement: OpaquePointer? + if sqlite3_prepare_v2(self.db, moveFavoritesSQL, -1, &moveFavStatement, nil) == SQLITE_OK { + if let parentId = parentId { + sqlite3_bind_text(moveFavStatement, 1, parentId, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(moveFavStatement, 1) + } + sqlite3_bind_text(moveFavStatement, 2, idString, -1, SQLITE_TRANSIENT) + sqlite3_step(moveFavStatement) + } + sqlite3_finalize(moveFavStatement) + + // Move child subfolders to the parent folder + let moveSubfoldersSQL = "UPDATE folders SET parent_id = ? WHERE parent_id = ?;" + var moveSubStatement: OpaquePointer? + if sqlite3_prepare_v2(self.db, moveSubfoldersSQL, -1, &moveSubStatement, nil) == SQLITE_OK { + if let parentId = parentId { + sqlite3_bind_text(moveSubStatement, 1, parentId, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(moveSubStatement, 1) + } + sqlite3_bind_text(moveSubStatement, 2, idString, -1, SQLITE_TRANSIENT) + sqlite3_step(moveSubStatement) + } + sqlite3_finalize(moveSubStatement) + + // Delete the folder + let deleteSQL = "DELETE FROM folders WHERE id = ?;" + var deleteStatement: OpaquePointer? + guard sqlite3_prepare_v2(self.db, deleteSQL, -1, &deleteStatement, nil) == SQLITE_OK else { + if inTransaction { sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) } + return false + } + + sqlite3_bind_text(deleteStatement, 1, idString, -1, SQLITE_TRANSIENT) + let result = sqlite3_step(deleteStatement) + sqlite3_finalize(deleteStatement) + + if inTransaction { + if result == SQLITE_DONE { + sqlite3_exec(self.db, "COMMIT;", nil, nil, nil) + } else { + sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) + } + } + + return result == SQLITE_DONE + } + } + + func fetchFolders(connectionId: UUID? = nil) async -> [SQLFavoriteFolder] { + let connectionIdString = connectionId?.uuidString + + return await performDatabaseWork { [weak self] in + guard let self = self else { return [] } + + var sql = """ + SELECT id, name, parent_id, connection_id, sort_order, created_at, updated_at + FROM folders + """ + + if connectionIdString != nil { + sql += " WHERE (connection_id IS NULL OR connection_id = ?)" + } + + sql += " ORDER BY sort_order ASC, name ASC;" + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { + return [] + } + + defer { sqlite3_finalize(statement) } + + if let connId = connectionIdString { + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, connId, -1, SQLITE_TRANSIENT) + } + + var folders: [SQLFavoriteFolder] = [] + while sqlite3_step(statement) == SQLITE_ROW { + if let folder = self.parseFolder(from: statement) { + folders.append(folder) + } + } + + return folders + } + } + + // MARK: - Keyword Support + + func fetchKeywordMap(connectionId: UUID? = nil) async -> [String: (name: String, query: String)] { + let connectionIdString = connectionId?.uuidString + + return await performDatabaseWork { [weak self] in + guard let self = self else { return [:] } + + var sql = """ + SELECT keyword, name, query FROM favorites + WHERE keyword IS NOT NULL + """ + + if connectionIdString != nil { + sql += " AND (connection_id IS NULL OR connection_id = ?)" + } + + sql += ";" + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { + return [:] + } + + defer { sqlite3_finalize(statement) } + + if let connId = connectionIdString { + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, connId, -1, SQLITE_TRANSIENT) + } + + var map: [String: (name: String, query: String)] = [:] + while sqlite3_step(statement) == SQLITE_ROW { + guard let keyword = sqlite3_column_text(statement, 0).map({ String(cString: $0) }), + let name = sqlite3_column_text(statement, 1).map({ String(cString: $0) }), + let query = sqlite3_column_text(statement, 2).map({ String(cString: $0) }) + else { + continue + } + map[keyword] = (name: name, query: query) + } + + return map + } + } + + func isKeywordAvailable( + _ keyword: String, + connectionId: UUID?, + excludingFavoriteId: UUID? = nil + ) async -> Bool { + let connectionIdString = connectionId?.uuidString + let excludeIdString = excludingFavoriteId?.uuidString + + return await performDatabaseWork { [weak self] in + guard let self = self else { return false } + + var sql = """ + SELECT COUNT(*) FROM favorites + WHERE keyword = ? + AND (connection_id IS NULL OR connection_id = ? OR ? IS NULL) + """ + + if excludeIdString != nil { + sql += " AND id != ?" + } + + sql += ";" + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { + return false + } + + defer { sqlite3_finalize(statement) } + + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + sqlite3_bind_text(statement, 1, keyword, -1, SQLITE_TRANSIENT) + + if let connId = connectionIdString { + sqlite3_bind_text(statement, 2, connId, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(statement, 3, connId, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 2) + sqlite3_bind_null(statement, 3) + } + + if let excludeId = excludeIdString { + sqlite3_bind_text(statement, 4, excludeId, -1, SQLITE_TRANSIENT) + } + + if sqlite3_step(statement) == SQLITE_ROW { + return sqlite3_column_int(statement, 0) == 0 + } + return false + } + } + + // MARK: - Parsing Helpers + + private func parseFavorite(from statement: OpaquePointer?) -> SQLFavorite? { + guard let statement = statement else { return nil } + + guard let idString = sqlite3_column_text(statement, 0).map({ String(cString: $0) }), + let id = UUID(uuidString: idString), + let name = sqlite3_column_text(statement, 1).map({ String(cString: $0) }), + let query = sqlite3_column_text(statement, 2).map({ String(cString: $0) }) + else { + return nil + } + + let keyword = sqlite3_column_text(statement, 3).map { String(cString: $0) } + let folderId = sqlite3_column_text(statement, 4).flatMap { UUID(uuidString: String(cString: $0)) } + let connectionId = sqlite3_column_text(statement, 5).flatMap { UUID(uuidString: String(cString: $0)) } + let sortOrder = Int(sqlite3_column_int(statement, 6)) + let createdAt = Date(timeIntervalSince1970: sqlite3_column_double(statement, 7)) + let updatedAt = Date(timeIntervalSince1970: sqlite3_column_double(statement, 8)) + + return SQLFavorite( + id: id, + name: name, + query: query, + keyword: keyword, + folderId: folderId, + connectionId: connectionId, + sortOrder: sortOrder, + createdAt: createdAt, + updatedAt: updatedAt + ) + } + + private func parseFolder(from statement: OpaquePointer?) -> SQLFavoriteFolder? { + guard let statement = statement else { return nil } + + guard let idString = sqlite3_column_text(statement, 0).map({ String(cString: $0) }), + let id = UUID(uuidString: idString), + let name = sqlite3_column_text(statement, 1).map({ String(cString: $0) }) + else { + return nil + } + + let parentId = sqlite3_column_text(statement, 2).flatMap { UUID(uuidString: String(cString: $0)) } + let connectionId = sqlite3_column_text(statement, 3).flatMap { UUID(uuidString: String(cString: $0)) } + let sortOrder = Int(sqlite3_column_int(statement, 4)) + let createdAt = Date(timeIntervalSince1970: sqlite3_column_double(statement, 5)) + let updatedAt = Date(timeIntervalSince1970: sqlite3_column_double(statement, 6)) + + return SQLFavoriteFolder( + id: id, + name: name, + parentId: parentId, + connectionId: connectionId, + sortOrder: sortOrder, + createdAt: createdAt, + updatedAt: updatedAt + ) + } +} diff --git a/TablePro/Core/Sync/SyncRecordMapper.swift b/TablePro/Core/Sync/SyncRecordMapper.swift index a41ff468..f72f7df4 100644 --- a/TablePro/Core/Sync/SyncRecordMapper.swift +++ b/TablePro/Core/Sync/SyncRecordMapper.swift @@ -16,6 +16,8 @@ enum SyncRecordType: String, CaseIterable { case tag = "ConnectionTag" case settings = "AppSettings" case queryHistory = "QueryHistory" + case favorite = "SQLFavorite" + case favoriteFolder = "SQLFavoriteFolder" } /// Pure-function mapper between local models and CKRecord @@ -40,6 +42,8 @@ struct SyncRecordMapper { case .tag: recordName = "Tag_\(id)" case .settings: recordName = "Settings_\(id)" case .queryHistory: recordName = "History_\(id)" + case .favorite: recordName = "Favorite_\(id)" + case .favoriteFolder: recordName = "FavoriteFolder_\(id)" } return CKRecord.ID(recordName: recordName, zoneID: zone) } diff --git a/TablePro/Models/Query/SQLFavorite.swift b/TablePro/Models/Query/SQLFavorite.swift new file mode 100644 index 00000000..991a907a --- /dev/null +++ b/TablePro/Models/Query/SQLFavorite.swift @@ -0,0 +1,41 @@ +// +// SQLFavorite.swift +// TablePro +// + +import Foundation + +/// A saved SQL query that can be quickly recalled and optionally expanded via keyword +struct SQLFavorite: Identifiable, Codable, Hashable { + let id: UUID + var name: String + var query: String + var keyword: String? + var folderId: UUID? + var connectionId: UUID? + var sortOrder: Int + let createdAt: Date + var updatedAt: Date + + init( + id: UUID = UUID(), + name: String, + query: String, + keyword: String? = nil, + folderId: UUID? = nil, + connectionId: UUID? = nil, + sortOrder: Int = 0, + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.name = name + self.query = query + self.keyword = keyword + self.folderId = folderId + self.connectionId = connectionId + self.sortOrder = sortOrder + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/TablePro/Models/Query/SQLFavoriteFolder.swift b/TablePro/Models/Query/SQLFavoriteFolder.swift new file mode 100644 index 00000000..ee090165 --- /dev/null +++ b/TablePro/Models/Query/SQLFavoriteFolder.swift @@ -0,0 +1,35 @@ +// +// SQLFavoriteFolder.swift +// TablePro +// + +import Foundation + +/// A folder for organizing SQL favorites into a hierarchy +struct SQLFavoriteFolder: Identifiable, Codable, Hashable { + let id: UUID + var name: String + var parentId: UUID? + var connectionId: UUID? + var sortOrder: Int + let createdAt: Date + var updatedAt: Date + + init( + id: UUID = UUID(), + name: String, + parentId: UUID? = nil, + connectionId: UUID? = nil, + sortOrder: Int = 0, + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.name = name + self.parentId = parentId + self.connectionId = connectionId + self.sortOrder = sortOrder + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/TablePro/ViewModels/FavoritesSidebarViewModel.swift b/TablePro/ViewModels/FavoritesSidebarViewModel.swift new file mode 100644 index 00000000..8bc66fa6 --- /dev/null +++ b/TablePro/ViewModels/FavoritesSidebarViewModel.swift @@ -0,0 +1,187 @@ +// +// FavoritesSidebarViewModel.swift +// TablePro +// + +import Foundation +import Observation + +/// Tree node for displaying favorites and folders in a hierarchy +enum FavoriteTreeItem: Identifiable, Hashable { + case folder(SQLFavoriteFolder, children: [FavoriteTreeItem]) + case favorite(SQLFavorite) + + var id: String { + switch self { + case .folder(let folder, _): return "folder-\(folder.id)" + case .favorite(let fav): return "fav-\(fav.id)" + } + } +} + +/// ViewModel for the favorites sidebar section +@MainActor @Observable +final class FavoritesSidebarViewModel { + // MARK: - State + + var treeItems: [FavoriteTreeItem] = [] + var isLoading = false + var showEditDialog = false + var editingFavorite: SQLFavorite? + var editingQuery: String? + var editingFolderId: UUID? + + var isFavoritesExpanded: Bool = { + let key = "sidebar.isFavoritesExpanded" + if UserDefaults.standard.object(forKey: key) != nil { + return UserDefaults.standard.bool(forKey: key) + } + return true + }() { + didSet { UserDefaults.standard.set(isFavoritesExpanded, forKey: "sidebar.isFavoritesExpanded") } + } + + // MARK: - Dependencies + + private let connectionId: UUID + private let manager = SQLFavoriteManager.shared + @ObservationIgnored private var notificationObserver: NSObjectProtocol? + + init(connectionId: UUID) { + self.connectionId = connectionId + + notificationObserver = NotificationCenter.default.addObserver( + forName: .sqlFavoritesDidUpdate, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + await self?.loadFavorites() + } + } + } + + deinit { + if let observer = notificationObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + // MARK: - Loading + + func loadFavorites() async { + isLoading = true + defer { isLoading = false } + + async let favoritesResult = manager.fetchFavorites(connectionId: connectionId) + async let foldersResult = manager.fetchFolders(connectionId: connectionId) + + let favorites = await favoritesResult + let folders = await foldersResult + + treeItems = buildTree(folders: folders, favorites: favorites, parentId: nil) + } + + // MARK: - Tree Building + + private func buildTree( + folders: [SQLFavoriteFolder], + favorites: [SQLFavorite], + parentId: UUID? + ) -> [FavoriteTreeItem] { + var items: [FavoriteTreeItem] = [] + + let levelFolders = folders + .filter { $0.parentId == parentId } + .sorted { $0.sortOrder < $1.sortOrder } + + for folder in levelFolders { + let children = buildTree(folders: folders, favorites: favorites, parentId: folder.id) + items.append(.folder(folder, children: children)) + } + + let levelFavorites = favorites + .filter { $0.folderId == parentId } + .sorted { $0.sortOrder < $1.sortOrder } + + for fav in levelFavorites { + items.append(.favorite(fav)) + } + + return items + } + + // MARK: - Actions + + func createFavorite(query: String? = nil, folderId: UUID? = nil) { + editingFavorite = nil + editingQuery = query + editingFolderId = folderId + showEditDialog = true + } + + func editFavorite(_ favorite: SQLFavorite) { + editingFavorite = favorite + editingQuery = nil + showEditDialog = true + } + + func deleteFavorite(_ favorite: SQLFavorite) { + Task { + _ = await manager.deleteFavorite(id: favorite.id) + } + } + + func createFolder(parentId: UUID? = nil) { + Task { + let folder = SQLFavoriteFolder( + name: String(localized: "New Folder"), + parentId: parentId, + connectionId: connectionId + ) + _ = await manager.addFolder(folder) + } + } + + func deleteFolder(_ folder: SQLFavoriteFolder) { + Task { + _ = await manager.deleteFolder(id: folder.id) + } + } + + func renameFolder(_ folder: SQLFavoriteFolder, to newName: String) { + Task { + var updated = folder + updated.name = newName + updated.updatedAt = Date() + _ = await manager.updateFolder(updated) + } + } + + // MARK: - Filtering + + func filteredItems(searchText: String) -> [FavoriteTreeItem] { + guard !searchText.isEmpty else { return treeItems } + return filterTree(treeItems, searchText: searchText) + } + + private func filterTree(_ items: [FavoriteTreeItem], searchText: String) -> [FavoriteTreeItem] { + items.compactMap { item in + switch item { + case .favorite(let fav): + if fav.name.localizedCaseInsensitiveContains(searchText) || + (fav.keyword?.localizedCaseInsensitiveContains(searchText) == true) { + return item + } + return nil + case .folder(let folder, let children): + let filteredChildren = filterTree(children, searchText: searchText) + if !filteredChildren.isEmpty || + folder.name.localizedCaseInsensitiveContains(searchText) { + return .folder(folder, children: filteredChildren) + } + return nil + } + } + } +} diff --git a/TablePro/Views/Editor/AIEditorContextMenu.swift b/TablePro/Views/Editor/AIEditorContextMenu.swift index d57b4640..393f504d 100644 --- a/TablePro/Views/Editor/AIEditorContextMenu.swift +++ b/TablePro/Views/Editor/AIEditorContextMenu.swift @@ -14,6 +14,7 @@ final class AIEditorContextMenu: NSMenu, NSMenuDelegate { var selectedText: (() -> String?)? var onExplainWithAI: ((String) -> Void)? var onOptimizeWithAI: ((String) -> Void)? + var onSaveAsFavorite: ((String) -> Void)? override init(title: String) { super.init(title: title) @@ -45,6 +46,17 @@ final class AIEditorContextMenu: NSMenu, NSMenuDelegate { let selectAllItem = NSMenuItem(title: String(localized: "Select All"), action: #selector(NSText.selectAll(_:)), keyEquivalent: "") menu.addItem(selectAllItem) + menu.addItem(.separator()) + + let saveAsFavItem = NSMenuItem( + title: String(localized: "Save as Favorite..."), + action: #selector(handleSaveAsFavorite), + keyEquivalent: "" + ) + saveAsFavItem.target = self + saveAsFavItem.image = NSImage(systemSymbolName: "star", accessibilityDescription: nil) + menu.addItem(saveAsFavItem) + // AI items — only when text is selected guard hasSelection?() == true else { return } @@ -80,4 +92,12 @@ final class AIEditorContextMenu: NSMenu, NSMenuDelegate { guard let text = selectedText?() else { return } onOptimizeWithAI?(text) } + + @objc private func handleSaveAsFavorite() { + if let text = selectedText?(), !text.isEmpty { + onSaveAsFavorite?(text) + } else { + onSaveAsFavorite?("") + } + } } diff --git a/TablePro/Views/Editor/HistoryPanelView.swift b/TablePro/Views/Editor/HistoryPanelView.swift index 6822172c..440956f3 100644 --- a/TablePro/Views/Editor/HistoryPanelView.swift +++ b/TablePro/Views/Editor/HistoryPanelView.swift @@ -21,6 +21,8 @@ struct HistoryPanelView: View { @State private var searchTask: Task? @State private var copyButtonTitle = "Copy Query" @State private var copyResetTask: Task? + @State private var showFavoriteDialog = false + @State private var favoriteQuery: String? @FocusedValue(\.commandActions) private var actions private let dataProvider = HistoryDataProvider() @@ -49,6 +51,14 @@ struct HistoryPanelView: View { .onReceive(NotificationCenter.default.publisher(for: .queryHistoryDidUpdate)) { _ in loadData() } + .sheet(isPresented: $showFavoriteDialog) { + FavoriteEditDialog( + connectionId: UUID(), + favorite: nil, + initialQuery: favoriteQuery, + forceGlobal: true + ) + } } } @@ -186,6 +196,13 @@ private extension HistoryPanelView { Label(String(localized: "Run in New Tab"), systemImage: "play") } + Button { + favoriteQuery = entry.query + showFavoriteDialog = true + } label: { + Label(String(localized: "Save as Favorite"), systemImage: "star") + } + Divider() Button(role: .destructive) { diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index d7b93312..2f1a6761 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -21,12 +21,14 @@ struct QueryEditorView: View { var onExecute: () -> Void var schemaProvider: SQLSchemaProvider? var databaseType: DatabaseType? + var connectionId: UUID? var onCloseTab: (() -> Void)? var onExecuteQuery: (() -> Void)? var onExplain: ((ClickHouseExplainVariant?) -> Void)? var onExplainVariant: ((ExplainVariant) -> Void)? var onAIExplain: ((String) -> Void)? var onAIOptimize: ((String) -> Void)? + var onSaveAsFavorite: ((String) -> Void)? @State private var vimMode: VimMode = .normal @@ -46,11 +48,13 @@ struct QueryEditorView: View { cursorPositions: $cursorPositions, schemaProvider: schemaProvider, databaseType: databaseType, + connectionId: connectionId, vimMode: $vimMode, onCloseTab: onCloseTab, onExecuteQuery: onExecuteQuery, onAIExplain: onAIExplain, - onAIOptimize: onAIOptimize + onAIOptimize: onAIOptimize, + onSaveAsFavorite: onSaveAsFavorite ) .frame(minHeight: 100) .clipped() diff --git a/TablePro/Views/Editor/SQLCompletionAdapter.swift b/TablePro/Views/Editor/SQLCompletionAdapter.swift index dd9b5ec8..4179a822 100644 --- a/TablePro/Views/Editor/SQLCompletionAdapter.swift +++ b/TablePro/Views/Editor/SQLCompletionAdapter.swift @@ -44,6 +44,11 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { ) } + /// Update favorite keywords for autocomplete expansion + func updateFavoriteKeywords(_ keywords: [String: (name: String, query: String)]) { + completionEngine?.updateFavoriteKeywords(keywords) + } + // MARK: - CodeSuggestionDelegate func completionTriggerCharacters() -> Set { diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index c299db64..d2173814 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -45,6 +45,7 @@ final class SQLEditorCoordinator: TextViewCoordinator { @ObservationIgnored var onExecuteQuery: (() -> Void)? @ObservationIgnored var onAIExplain: ((String) -> Void)? @ObservationIgnored var onAIOptimize: ((String) -> Void)? + @ObservationIgnored var onSaveAsFavorite: ((String) -> Void)? /// Whether the editor text view is currently the first responder. /// Used to guard cursor propagation — when the find panel highlights @@ -166,6 +167,7 @@ final class SQLEditorCoordinator: TextViewCoordinator { } menu.onExplainWithAI = { [weak self] text in self?.onAIExplain?(text) } menu.onOptimizeWithAI = { [weak self] text in self?.onAIOptimize?(text) } + menu.onSaveAsFavorite = { [weak self] text in self?.onSaveAsFavorite?(text) } contextMenu = menu } diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index cd602d76..fc93cf10 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -19,17 +19,20 @@ struct SQLEditorView: View { @Binding var cursorPositions: [CursorPosition] var schemaProvider: SQLSchemaProvider? var databaseType: DatabaseType? + var connectionId: UUID? @Binding var vimMode: VimMode var onCloseTab: (() -> Void)? var onExecuteQuery: (() -> Void)? var onAIExplain: ((String) -> Void)? var onAIOptimize: ((String) -> Void)? + var onSaveAsFavorite: ((String) -> Void)? @State private var editorState = SourceEditorState() @State private var completionAdapter: SQLCompletionAdapter? @State private var coordinator = SQLEditorCoordinator() @State private var editorReady = false @State private var editorConfiguration = makeConfiguration() + @State private var favoritesObserver: NSObjectProtocol? @Environment(\.colorScheme) private var colorScheme var body: some View { @@ -94,6 +97,8 @@ struct SQLEditorView: View { coordinator.onExecuteQuery = onExecuteQuery coordinator.onAIExplain = onAIExplain coordinator.onAIOptimize = onAIOptimize + coordinator.onSaveAsFavorite = onSaveAsFavorite + setupFavoritesObserver() } } else { Color(nsColor: .textBackgroundColor) @@ -106,11 +111,14 @@ struct SQLEditorView: View { coordinator.onExecuteQuery = onExecuteQuery coordinator.onAIExplain = onAIExplain coordinator.onAIOptimize = onAIOptimize + coordinator.onSaveAsFavorite = onSaveAsFavorite + setupFavoritesObserver() editorReady = true } } } .onDisappear { + teardownFavoritesObserver() coordinator.destroy() } .onChange(of: coordinator.vimMode) { _, newMode in @@ -118,6 +126,40 @@ struct SQLEditorView: View { } } + // MARK: - Favorites + + private func setupFavoritesObserver() { + teardownFavoritesObserver() + refreshFavoriteKeywords() + let adapter = completionAdapter + let connId = connectionId + favoritesObserver = NotificationCenter.default.addObserver( + forName: .sqlFavoritesDidUpdate, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in + let keywords = await SQLFavoriteManager.shared.fetchKeywordMap(connectionId: connId) + adapter?.updateFavoriteKeywords(keywords) + } + } + } + + private func refreshFavoriteKeywords() { + let connId = connectionId + Task { @MainActor in + let keywords = await SQLFavoriteManager.shared.fetchKeywordMap(connectionId: connId) + completionAdapter?.updateFavoriteKeywords(keywords) + } + } + + private func teardownFavoritesObserver() { + if let observer = favoritesObserver { + NotificationCenter.default.removeObserver(observer) + favoritesObserver = nil + } + } + // MARK: - Configuration private static func makeConfiguration() -> SourceEditorConfiguration { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index fb4025b5..dc0f1e07 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -66,6 +66,8 @@ struct MainEditorContentView: View { @State private var tabProviderVersions: [UUID: Int] = [:] @State private var tabProviderMetaVersions: [UUID: Int] = [:] @State private var cachedChangeManager: AnyChangeManager? + @State private var showFavoriteDialog = false + @State private var favoriteQuery: String? // Native macOS window tabs — no LRU tracking needed (single tab per window) @@ -107,6 +109,13 @@ struct MainEditorContentView: View { } .background(.background) .animation(.easeInOut(duration: 0.2), value: isHistoryVisible) + .sheet(isPresented: $showFavoriteDialog) { + FavoriteEditDialog( + connectionId: connectionId, + favorite: nil, + initialQuery: favoriteQuery + ) + } .onChange(of: tabManager.tabs.count) { // Clean up caches for closed tabs let openTabIds = Set(tabManager.tabs.map(\.id)) @@ -190,6 +199,7 @@ struct MainEditorContentView: View { onExecute: { coordinator.runQuery() }, schemaProvider: coordinator.schemaProvider, databaseType: coordinator.connection.type, + connectionId: coordinator.connection.id, onCloseTab: { NSApp.keyWindow?.close() }, @@ -208,6 +218,10 @@ struct MainEditorContentView: View { onAIOptimize: { text in coordinator.showAIChatPanel() coordinator.aiViewModel?.handleOptimizeSelection(text) + }, + onSaveAsFavorite: { text in + favoriteQuery = text.isEmpty ? nil : text + showFavoriteDialog = true } ) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift new file mode 100644 index 00000000..c4270b58 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift @@ -0,0 +1,25 @@ +// +// MainContentCoordinator+Favorites.swift +// TablePro +// + +import Foundation + +extension MainContentCoordinator { + /// Insert a favorite's query into the current editor tab + func insertFavorite(_ favorite: SQLFavorite) { + guard let tabIndex = tabManager.selectedTabIndex else { return } + tabManager.tabs[tabIndex].query = favorite.query + } + + /// Open a favorite's query in a new tab + func runFavoriteInNewTab(_ favorite: SQLFavorite) { + let payload = EditorTabPayload( + connectionId: connection.id, + tabType: .query, + databaseName: connection.database, + initialQuery: favorite.query + ) + WindowOpener.shared.openNativeTab(payload) + } +} diff --git a/TablePro/Views/Sidebar/FavoriteEditDialog.swift b/TablePro/Views/Sidebar/FavoriteEditDialog.swift new file mode 100644 index 00000000..b11e2ab7 --- /dev/null +++ b/TablePro/Views/Sidebar/FavoriteEditDialog.swift @@ -0,0 +1,205 @@ +// +// FavoriteEditDialog.swift +// TablePro +// + +import SwiftUI + +/// Dialog for creating or editing a SQL favorite +struct FavoriteEditDialog: View { + @Environment(\.dismiss) private var dismiss + + let connectionId: UUID + let favorite: SQLFavorite? + let initialQuery: String? + let folderId: UUID? + let forceGlobal: Bool + + @State private var name: String = "" + @State private var query: String = "" + @State private var keyword: String = "" + @State private var isGlobal: Bool = true + @State private var keywordError: String? + @State private var isSaving = false + + private var isEditing: Bool { favorite != nil } + private var isValid: Bool { + !name.trimmingCharacters(in: .whitespaces).isEmpty && + !query.trimmingCharacters(in: .whitespaces).isEmpty && + keywordError == nil + } + + /// Maximum query size (500KB, matching PersistedTab pattern) + private static let maxQuerySize = 500_000 + + init( + connectionId: UUID, + favorite: SQLFavorite? = nil, + initialQuery: String? = nil, + folderId: UUID? = nil, + forceGlobal: Bool = false + ) { + self.connectionId = connectionId + self.favorite = favorite + self.initialQuery = initialQuery + self.folderId = folderId + self.forceGlobal = forceGlobal + } + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text(isEditing ? "Edit Favorite" : "Save as Favorite") + .font(.headline) + Spacer() + } + .padding() + + Divider() + + // Form + Form { + TextField(String(localized: "Name"), text: $name) + + TextField(String(localized: "Keyword (optional)"), text: $keyword) + .textFieldStyle(.roundedBorder) + .onChange(of: keyword) { _, newValue in + validateKeyword(newValue) + } + + if let error = keywordError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Query") + .font(.caption) + .foregroundStyle(.secondary) + TextEditor(text: $query) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 120) + .border(Color(nsColor: .separatorColor)) + } + + if !forceGlobal { + Toggle(String(localized: "Global (visible in all connections)"), isOn: $isGlobal) + } + } + .padding() + + Divider() + + // Buttons + HStack { + Spacer() + Button(String(localized: "Cancel")) { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button(isEditing ? String(localized: "Save") : String(localized: "Add")) { + save() + } + .keyboardShortcut(.defaultAction) + .disabled(!isValid || isSaving) + } + .padding() + } + .frame(width: 480, height: 420) + .onAppear { + if let fav = favorite { + name = fav.name + query = fav.query + keyword = fav.keyword ?? "" + isGlobal = forceGlobal || fav.connectionId == nil + } else { + isGlobal = forceGlobal || true + if let q = initialQuery { + query = q + } + } + } + } + + // MARK: - Validation + + private func validateKeyword(_ value: String) { + let trimmed = value.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + keywordError = nil + return + } + if trimmed.contains(" ") { + keywordError = String(localized: "Keyword cannot contain spaces") + return + } + Task { @MainActor in + let scopeConnectionId = isGlobal ? nil : connectionId + let available = await SQLFavoriteManager.shared.isKeywordAvailable( + trimmed, + connectionId: scopeConnectionId, + excludingFavoriteId: favorite?.id + ) + if !available { + keywordError = String(localized: "This keyword is already in use") + } else { + let sqlKeywords: Set = [ + "select", "from", "where", "insert", "update", "delete", + "create", "drop", "alter", "join", "on", "and", "or", + "not", "in", "like", "between", "order", "group", "having", + "limit", "set", "values", "into", "as", "is", "null", + "true", "false", "case", "when", "then", "else", "end" + ] + if sqlKeywords.contains(trimmed.lowercased()) { + keywordError = String( + localized: "Warning: this shadows the SQL keyword '\(trimmed.uppercased())'" + ) + } else { + keywordError = nil + } + } + } + } + + // MARK: - Save + + private func save() { + isSaving = true + let trimmedName = name.trimmingCharacters(in: .whitespaces) + let trimmedKeyword = keyword.trimmingCharacters(in: .whitespaces) + let trimmedQuery: String + if (query as NSString).length > Self.maxQuerySize { + trimmedQuery = String(query.prefix(Self.maxQuerySize)) + } else { + trimmedQuery = query + } + + let scopeConnectionId = isGlobal ? nil : connectionId + let keywordValue = trimmedKeyword.isEmpty ? nil : trimmedKeyword + + Task { @MainActor in + if let existing = favorite { + var updated = existing + updated.name = trimmedName + updated.query = trimmedQuery + updated.keyword = keywordValue + updated.connectionId = scopeConnectionId + updated.updatedAt = Date() + _ = await SQLFavoriteManager.shared.updateFavorite(updated) + } else { + let newFavorite = SQLFavorite( + name: trimmedName, + query: trimmedQuery, + keyword: keywordValue, + folderId: folderId, + connectionId: scopeConnectionId + ) + _ = await SQLFavoriteManager.shared.addFavorite(newFavorite) + } + dismiss() + } + } +} diff --git a/TablePro/Views/Sidebar/FavoriteRowView.swift b/TablePro/Views/Sidebar/FavoriteRowView.swift new file mode 100644 index 00000000..c0862ece --- /dev/null +++ b/TablePro/Views/Sidebar/FavoriteRowView.swift @@ -0,0 +1,36 @@ +// +// FavoriteRowView.swift +// TablePro +// + +import SwiftUI + +/// Row view for a single SQL favorite in the sidebar +struct FavoriteRowView: View { + let favorite: SQLFavorite + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "star.fill") + .font(.system(size: 10)) + .foregroundStyle(.yellow) + + Text(favorite.name) + .lineLimit(1) + + Spacer() + + if let keyword = favorite.keyword, !keyword.isEmpty { + Text(keyword) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + Capsule() + .fill(Color(nsColor: .quaternaryLabelColor)) + ) + } + } + } +} diff --git a/TablePro/Views/Sidebar/FavoritesSidebarSection.swift b/TablePro/Views/Sidebar/FavoritesSidebarSection.swift new file mode 100644 index 00000000..37ce9b5d --- /dev/null +++ b/TablePro/Views/Sidebar/FavoritesSidebarSection.swift @@ -0,0 +1,166 @@ +// +// FavoritesSidebarSection.swift +// TablePro +// + +import SwiftUI + +/// Sidebar section displaying SQL favorites organized in folders +struct FavoritesSidebarSection: View { + @State private var viewModel: FavoritesSidebarViewModel + let searchText: String + private weak var coordinator: MainContentCoordinator? + + init(connectionId: UUID, searchText: String, coordinator: MainContentCoordinator?) { + _viewModel = State(wrappedValue: FavoritesSidebarViewModel(connectionId: connectionId)) + self.searchText = searchText + self.coordinator = coordinator + } + + var body: some View { + Section(isExpanded: $viewModel.isFavoritesExpanded) { + let items = viewModel.filteredItems(searchText: searchText) + if items.isEmpty { + Text("No favorites") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + .listRowSeparator(.hidden) + } else { + ForEach(items) { item in + favoriteTreeItemView(item) + } + } + } header: { + HStack { + Text("Favorites") + Spacer() + Button { + viewModel.createFavorite() + } label: { + Image(systemName: "plus") + .font(.system(size: 10)) + } + .buttonStyle(.borderless) + } + .contextMenu { + Button("New Favorite...") { + viewModel.createFavorite() + } + Button("New Folder") { + viewModel.createFolder() + } + } + } + .onAppear { + Task { await viewModel.loadFavorites() } + } + .sheet(isPresented: $viewModel.showEditDialog) { + FavoriteEditDialog( + connectionId: coordinator?.connectionId ?? UUID(), + favorite: viewModel.editingFavorite, + initialQuery: viewModel.editingQuery, + folderId: viewModel.editingFolderId + ) + } + } + + // MARK: - Tree Item Views + + @ViewBuilder + private func favoriteTreeItemView(_ item: FavoriteTreeItem) -> some View { + switch item { + case .favorite(let favorite): + FavoriteRowView(favorite: favorite) + .overlay { + DoubleClickDetector { + coordinator?.insertFavorite(favorite) + } + } + .contextMenu { + FavoriteItemContextMenu( + favorite: favorite, + viewModel: viewModel, + coordinator: coordinator + ) + } + case .folder(let folder, let children): + DisclosureGroup { + ForEach(children) { child in + favoriteTreeItemView(child) + } + } label: { + Label(folder.name, systemImage: "folder") + .contextMenu { + FolderContextMenu( + folder: folder, + viewModel: viewModel + ) + } + } + } + } +} + +// MARK: - Context Menus + +private struct FavoriteItemContextMenu: View { + let favorite: SQLFavorite + let viewModel: FavoritesSidebarViewModel + weak var coordinator: MainContentCoordinator? + + var body: some View { + Button("Edit...") { + viewModel.editFavorite(favorite) + } + + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(favorite.query, forType: .string) + } label: { + Label(String(localized: "Copy Query"), systemImage: "doc.on.doc") + } + + Button { + coordinator?.insertFavorite(favorite) + } label: { + Label(String(localized: "Insert in Editor"), systemImage: "text.insert") + } + + Button { + coordinator?.runFavoriteInNewTab(favorite) + } label: { + Label(String(localized: "Run in New Tab"), systemImage: "play") + } + + Divider() + + Button(role: .destructive) { + viewModel.deleteFavorite(favorite) + } label: { + Label(String(localized: "Delete"), systemImage: "trash") + } + } +} + +private struct FolderContextMenu: View { + let folder: SQLFavoriteFolder + let viewModel: FavoritesSidebarViewModel + + var body: some View { + Button("New Favorite...") { + viewModel.createFavorite(folderId: folder.id) + } + + Button("New Subfolder") { + viewModel.createFolder(parentId: folder.id) + } + + Divider() + + Button(role: .destructive) { + viewModel.deleteFolder(folder) + } label: { + Label(String(localized: "Delete Folder"), systemImage: "trash") + } + } +} diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 07778a71..1408ad74 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -179,6 +179,12 @@ struct SidebarView: View { let showAllLabel = String(localized: "Show All \(entityLabel)") return List(selection: selectedTablesBinding) { if filteredTables.isEmpty { + FavoritesSidebarSection( + connectionId: connectionId, + searchText: viewModel.debouncedSearchText, + coordinator: coordinator + ) + ContentUnavailableView( noMatchLabel, systemImage: "magnifyingglass" @@ -186,6 +192,12 @@ struct SidebarView: View { .listRowSeparator(.hidden) .listRowBackground(Color.clear) } else { + FavoritesSidebarSection( + connectionId: connectionId, + searchText: viewModel.debouncedSearchText, + coordinator: coordinator + ) + Section(isExpanded: $viewModel.isTablesExpanded) { ForEach(filteredTables) { table in TableRow( diff --git a/docs/docs.json b/docs/docs.json index 3fa708c8..83613019 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -59,6 +59,7 @@ "features/change-tracking", "features/tabs", "features/import-export", + "features/sql-favorites", "features/query-history", "features/ai-chat", "features/keyboard-shortcuts", @@ -154,6 +155,7 @@ "vi/features/change-tracking", "vi/features/tabs", "vi/features/import-export", + "vi/features/sql-favorites", "vi/features/query-history", "vi/features/ai-chat", "vi/features/keyboard-shortcuts", diff --git a/docs/features/sql-favorites.mdx b/docs/features/sql-favorites.mdx new file mode 100644 index 00000000..2c2c998d --- /dev/null +++ b/docs/features/sql-favorites.mdx @@ -0,0 +1,52 @@ +--- +title: SQL Favorites +description: Save and organize frequently used queries with optional keyword bindings for quick recall +--- + +# SQL Favorites + +Save SQL queries you use often as favorites. Organize them in folders, assign keyword shortcuts, and recall them from the sidebar or via autocomplete. + +## Saving a Favorite + +There are several ways to save a query as a favorite: + +- **Right-click in the editor** and select **Save as Favorite...** +- **Right-click a history entry** in the Query History panel and select **Save as Favorite** +- Click the **+** button in the **Favorites** sidebar section header + +The save dialog lets you set: + +| Field | Description | +|-------|-------------| +| Name | A descriptive name for the query | +| Keyword | Optional short alias for autocomplete expansion | +| Query | The SQL query text | +| Global | When enabled, the favorite is visible in all connections | + +## Sidebar + +Favorites appear in a collapsible **Favorites** section at the top of the sidebar, above the tables list. The section supports: + +- **Folders** for organizing favorites into groups (with nesting) +- **Search** filtering alongside tables when you type in the sidebar search field +- **Double-click** a favorite to insert its query into the current editor +- **Context menu** with options to edit, copy, insert, run in a new tab, or delete + +### Folders + +Create folders from the sidebar section header context menu or from a folder's own context menu. Deleting a folder moves its children to the parent folder (or root level) rather than deleting them. + +## Keyword Expansion + +Assign a keyword to a favorite, and it becomes available as an autocomplete suggestion. Type the keyword in the editor and the autocomplete popup shows the favorite with a star icon. Selecting it replaces the keyword with the full query text. + +Keywords must be unique within their scope (global or per-connection). The save dialog warns if a keyword shadows a SQL keyword like `SELECT` or `WHERE`. + +### Scope + +Favorites can be **global** (visible in all connections) or **connection-scoped** (visible only when connected to a specific database). Connection-scoped favorites and their keywords take precedence within that connection. + +## Storage + +Favorites are stored in a local SQLite database (`sql_favorites.db`) in `~/Library/Application Support/TablePro/`, separate from query history. Full-text search is powered by FTS5. diff --git a/docs/vi/features/sql-favorites.mdx b/docs/vi/features/sql-favorites.mdx new file mode 100644 index 00000000..5c585ede --- /dev/null +++ b/docs/vi/features/sql-favorites.mdx @@ -0,0 +1,52 @@ +--- +title: SQL Favorites +description: Luu va to chuc cac truy van thuong dung voi phim tat keyword de truy xuat nhanh +--- + +# SQL Favorites + +Luu cac truy van SQL ban thuong su dung thanh favorite. To chuc chung trong thu muc, gan phim tat keyword, va truy xuat tu sidebar hoac qua autocomplete. + +## Luu Favorite + +Co nhieu cach de luu mot truy van thanh favorite: + +- **Click chuot phai trong editor** va chon **Save as Favorite...** +- **Click chuot phai mot muc lich su** trong panel Query History va chon **Save as Favorite** +- Nhan nut **+** o header section **Favorites** trong sidebar + +Hop thoai luu cho phep ban thiet lap: + +| Truong | Mo ta | +|--------|-------| +| Name | Ten mo ta cho truy van | +| Keyword | Phim tat tuy chon de mo rong qua autocomplete | +| Query | Noi dung truy van SQL | +| Global | Khi bat, favorite hien thi trong tat ca cac ket noi | + +## Sidebar + +Favorites hien thi trong section **Favorites** co the thu gon o dau sidebar, phia tren danh sach bang. Section ho tro: + +- **Thu muc** de to chuc favorites thanh nhom (ho tro long nhau) +- **Tim kiem** loc cung voi bang khi ban go trong o tim kiem sidebar +- **Double-click** mot favorite de chen truy van vao editor hien tai +- **Menu ngu canh** voi cac tuy chon chinh sua, sao chep, chen, chay trong tab moi, hoac xoa + +### Thu muc + +Tao thu muc tu menu ngu canh header section sidebar hoac tu menu ngu canh cua thu muc. Xoa thu muc se chuyen cac muc con sang thu muc cha (hoac goc) thay vi xoa chung. + +## Mo rong Keyword + +Gan keyword cho favorite, va no se xuat hien nhu goi y autocomplete. Go keyword trong editor va popup autocomplete se hien thi favorite voi bieu tuong ngoi sao. Chon no se thay the keyword bang toan bo noi dung truy van. + +Keywords phai duy nhat trong pham vi cua chung (global hoac theo ket noi). Hop thoai luu se canh bao neu keyword trung voi tu khoa SQL nhu `SELECT` hoac `WHERE`. + +### Pham vi + +Favorites co the la **global** (hien thi trong tat ca cac ket noi) hoac **theo ket noi** (chi hien thi khi ket noi voi co so du lieu cu the). Favorites theo ket noi va keywords cua chung co uu tien trong ket noi do. + +## Luu tru + +Favorites duoc luu trong co so du lieu SQLite cuc bo (`sql_favorites.db`) tai `~/Library/Application Support/TablePro/`, tach biet voi lich su truy van. Tim kiem toan van duoc ho tro boi FTS5. From 2f64109b2fe188df487dfb9ead7d192551da5e2e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 14:37:02 +0700 Subject: [PATCH 02/20] fix: resolve recursive opaque type and exhaustive switch errors --- .../Components/ConflictResolutionView.swift | 4 +++ .../Sidebar/FavoritesSidebarSection.swift | 26 +++++++++++++++---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/TablePro/Views/Components/ConflictResolutionView.swift b/TablePro/Views/Components/ConflictResolutionView.swift index 0c620621..55f7c5d6 100644 --- a/TablePro/Views/Components/ConflictResolutionView.swift +++ b/TablePro/Views/Components/ConflictResolutionView.swift @@ -138,6 +138,10 @@ struct ConflictResolutionView: View { : query fieldRow(label: "Query", value: preview) } + case .favorite, .favoriteFolder: + if let name = record["name"] as? String { + fieldRow(label: "Name", value: name) + } } } diff --git a/TablePro/Views/Sidebar/FavoritesSidebarSection.swift b/TablePro/Views/Sidebar/FavoritesSidebarSection.swift index 37ce9b5d..df4bc52c 100644 --- a/TablePro/Views/Sidebar/FavoritesSidebarSection.swift +++ b/TablePro/Views/Sidebar/FavoritesSidebarSection.swift @@ -27,7 +27,11 @@ struct FavoritesSidebarSection: View { .listRowSeparator(.hidden) } else { ForEach(items) { item in - favoriteTreeItemView(item) + FavoriteTreeItemRow( + item: item, + viewModel: viewModel, + coordinator: coordinator + ) } } } header: { @@ -64,10 +68,18 @@ struct FavoritesSidebarSection: View { } } - // MARK: - Tree Item Views +} + +// MARK: - Recursive Tree Item View + +/// Separate struct to break the self-referential opaque return type +/// that occurs with recursive `@ViewBuilder` functions. +private struct FavoriteTreeItemRow: View { + let item: FavoriteTreeItem + let viewModel: FavoritesSidebarViewModel + weak var coordinator: MainContentCoordinator? - @ViewBuilder - private func favoriteTreeItemView(_ item: FavoriteTreeItem) -> some View { + var body: some View { switch item { case .favorite(let favorite): FavoriteRowView(favorite: favorite) @@ -86,7 +98,11 @@ struct FavoritesSidebarSection: View { case .folder(let folder, let children): DisclosureGroup { ForEach(children) { child in - favoriteTreeItemView(child) + FavoriteTreeItemRow( + item: child, + viewModel: viewModel, + coordinator: coordinator + ) } } label: { Label(folder.name, systemImage: "folder") From 3588e9d0ac3cfed69da4a119f80d136cdf5c7b29 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 15:19:02 +0700 Subject: [PATCH 03/20] feat: redesign favorites sidebar with segmented tabs and native macOS dialog --- TablePro/ContentView.swift | 20 +- TablePro/Models/UI/SharedSidebarState.swift | 38 ++- TablePro/Resources/Localizable.xcstrings | 67 ++++- .../FavoritesSidebarViewModel.swift | 36 ++- .../Views/Sidebar/FavoriteEditDialog.swift | 59 ++-- .../Sidebar/FavoritesSidebarSection.swift | 182 ------------ TablePro/Views/Sidebar/FavoritesTabView.swift | 276 ++++++++++++++++++ TablePro/Views/Sidebar/SidebarView.swift | 52 ++-- 8 files changed, 476 insertions(+), 254 deletions(-) delete mode 100644 TablePro/Views/Sidebar/FavoritesSidebarSection.swift create mode 100644 TablePro/Views/Sidebar/FavoritesTabView.swift diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 5501023f..769bd987 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -237,7 +237,7 @@ struct ContentView: View { .searchable( text: sidebarSearchTextBinding(for: currentSession.connection.id), placement: .sidebar, - prompt: "Filter" + prompt: sidebarSearchPrompt(for: currentSession.connection.id) ) .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 600) } detail: { @@ -355,6 +355,24 @@ struct ContentView: View { ) } + private func sidebarTabBinding(for connectionId: UUID) -> Binding { + let state = SharedSidebarState.forConnection(connectionId) + return Binding( + get: { state.selectedSidebarTab }, + set: { state.selectedSidebarTab = $0 } + ) + } + + private func sidebarSearchPrompt(for connectionId: UUID) -> String { + let state = SharedSidebarState.forConnection(connectionId) + switch state.selectedSidebarTab { + case .tables: + return String(localized: "Filter") + case .favorites: + return String(localized: "Filter favorites") + } + } + private var sessionTableOperationOptionsBinding: Binding<[String: TableOperationOptions]> { createSessionBinding( get: { $0.tableOperationOptions }, diff --git a/TablePro/Models/UI/SharedSidebarState.swift b/TablePro/Models/UI/SharedSidebarState.swift index 8b695adb..23cc5c4c 100644 --- a/TablePro/Models/UI/SharedSidebarState.swift +++ b/TablePro/Models/UI/SharedSidebarState.swift @@ -2,22 +2,56 @@ // SharedSidebarState.swift // TablePro // -// Shared sidebar state (selection + search) for cross-tab synchronization. +// Shared sidebar state (selection + search + tab) for cross-tab synchronization. // One instance per connection, shared across all native macOS tabs. // import Foundation +/// Which sidebar tab is active +enum SidebarTab: String, CaseIterable { + case tables + case favorites +} + @MainActor @Observable final class SharedSidebarState { var selectedTables: Set = [] var searchText: String = "" + var selectedSidebarTab: SidebarTab { + didSet { + UserDefaults.standard.set( + selectedSidebarTab.rawValue, + forKey: "sidebar.selectedTab.\(connectionId.uuidString)" + ) + } + } + + let connectionId: UUID + + private init(connectionId: UUID) { + self.connectionId = connectionId + let key = "sidebar.selectedTab.\(connectionId.uuidString)" + if let raw = UserDefaults.standard.string(forKey: key), + let tab = SidebarTab(rawValue: raw) { + self.selectedSidebarTab = tab + } else { + self.selectedSidebarTab = .tables + } + } + + /// Default init for previews and tests + init() { + self.connectionId = UUID() + self.selectedSidebarTab = .tables + } + private static var registry: [UUID: SharedSidebarState] = [:] static func forConnection(_ id: UUID) -> SharedSidebarState { if let existing = registry[id] { return existing } - let state = SharedSidebarState() + let state = SharedSidebarState(connectionId: id) registry[id] = state return state } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 9fb1af2a..f86ac142 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -1773,6 +1773,9 @@ } } } + }, + "Add" : { + }, "Add Check Constraint" : { "localizations" : { @@ -6028,6 +6031,9 @@ } } } + }, + "Delete Folder" : { + }, "Delete Foreign Key" : { "extractionState" : "stale", @@ -6692,6 +6698,9 @@ } } } + }, + "Edit..." : { + }, "Editing" : { "localizations" : { @@ -8079,6 +8088,9 @@ }, "Fast" : { + }, + "Favorites" : { + }, "Feature Routing" : { "localizations" : { @@ -8225,6 +8237,9 @@ } } } + }, + "Filter favorites" : { + }, "Filter logic mode" : { "localizations" : { @@ -8572,6 +8587,9 @@ } } } + }, + "Global:" : { + }, "Go" : { "localizations" : { @@ -9398,6 +9416,9 @@ } } } + }, + "Insert in Editor" : { + }, "INSERT Statement(s)" : { "localizations" : { @@ -9989,6 +10010,12 @@ }, "Keyword" : { + }, + "Keyword cannot contain spaces" : { + + }, + "Keyword:" : { + }, "Language:" : { "localizations" : { @@ -10881,6 +10908,9 @@ } } } + }, + "Name:" : { + }, "Navigate to referenced row" : { "localizations" : { @@ -11029,6 +11059,15 @@ } } } + }, + "New Favorite" : { + + }, + "New Favorite..." : { + + }, + "New Folder" : { + }, "New Group" : { "localizations" : { @@ -11111,6 +11150,9 @@ } } } + }, + "New Subfolder" : { + }, "New Tab" : { "localizations" : { @@ -11420,6 +11462,9 @@ } } } + }, + "No Favorites" : { + }, "No Foreign Keys Yet" : { "localizations" : { @@ -11536,6 +11581,9 @@ } } } + }, + "No Matching Favorites" : { + }, "No matching fields" : { "localizations" : { @@ -14026,6 +14074,9 @@ } } } + }, + "Query:" : { + }, "Quick search across all columns..." : { "localizations" : { @@ -15181,6 +15232,12 @@ } } } + }, + "Save as Favorite" : { + + }, + "Save as Favorite..." : { + }, "Save as Preset..." : { "localizations" : { @@ -15262,6 +15319,9 @@ } } } + }, + "Save frequently used queries\nfor quick access." : { + }, "Save Sidebar Changes" : { "localizations" : { @@ -17437,7 +17497,6 @@ } }, "Tables" : { - "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -17801,6 +17860,9 @@ }, "This is a registry theme." : { + }, + "This keyword is already in use" : { + }, "This Mac" : { @@ -19406,6 +19468,9 @@ } } } + }, + "Warning: this shadows the SQL keyword '%@'" : { + }, "Website" : { "localizations" : { diff --git a/TablePro/ViewModels/FavoritesSidebarViewModel.swift b/TablePro/ViewModels/FavoritesSidebarViewModel.swift index 8bc66fa6..e9447650 100644 --- a/TablePro/ViewModels/FavoritesSidebarViewModel.swift +++ b/TablePro/ViewModels/FavoritesSidebarViewModel.swift @@ -30,16 +30,9 @@ final class FavoritesSidebarViewModel { var editingFavorite: SQLFavorite? var editingQuery: String? var editingFolderId: UUID? - - var isFavoritesExpanded: Bool = { - let key = "sidebar.isFavoritesExpanded" - if UserDefaults.standard.object(forKey: key) != nil { - return UserDefaults.standard.bool(forKey: key) - } - return true - }() { - didSet { UserDefaults.standard.set(isFavoritesExpanded, forKey: "sidebar.isFavoritesExpanded") } - } + var renamingFolderId: UUID? + var renamingFolderName: String = "" + var expandedFolderIds: Set = [] // MARK: - Dependencies @@ -114,6 +107,9 @@ final class FavoritesSidebarViewModel { // MARK: - Actions func createFavorite(query: String? = nil, folderId: UUID? = nil) { + if let folderId { + expandedFolderIds.insert(folderId) + } editingFavorite = nil editingQuery = query editingFolderId = folderId @@ -133,13 +129,21 @@ final class FavoritesSidebarViewModel { } func createFolder(parentId: UUID? = nil) { + if let parentId { + expandedFolderIds.insert(parentId) + } Task { let folder = SQLFavoriteFolder( name: String(localized: "New Folder"), parentId: parentId, connectionId: connectionId ) - _ = await manager.addFolder(folder) + let success = await manager.addFolder(folder) + if success { + expandedFolderIds.insert(folder.id) + await loadFavorites() + startRenameFolder(folder) + } } } @@ -149,7 +153,15 @@ final class FavoritesSidebarViewModel { } } - func renameFolder(_ folder: SQLFavoriteFolder, to newName: String) { + func startRenameFolder(_ folder: SQLFavoriteFolder) { + renamingFolderId = folder.id + renamingFolderName = folder.name + } + + func commitRenameFolder(_ folder: SQLFavoriteFolder) { + let newName = renamingFolderName.trimmingCharacters(in: .whitespaces) + renamingFolderId = nil + guard !newName.isEmpty, newName != folder.name else { return } Task { var updated = folder updated.name = newName diff --git a/TablePro/Views/Sidebar/FavoriteEditDialog.swift b/TablePro/Views/Sidebar/FavoriteEditDialog.swift index b11e2ab7..0b9ce6e1 100644 --- a/TablePro/Views/Sidebar/FavoriteEditDialog.swift +++ b/TablePro/Views/Sidebar/FavoriteEditDialog.swift @@ -29,7 +29,6 @@ struct FavoriteEditDialog: View { keywordError == nil } - /// Maximum query size (500KB, matching PersistedTab pattern) private static let maxQuerySize = 500_000 init( @@ -47,68 +46,58 @@ struct FavoriteEditDialog: View { } var body: some View { - VStack(spacing: 0) { - // Header - HStack { - Text(isEditing ? "Edit Favorite" : "Save as Favorite") - .font(.headline) - Spacer() - } - .padding() - - Divider() - - // Form + VStack(spacing: 16) { Form { - TextField(String(localized: "Name"), text: $name) - - TextField(String(localized: "Keyword (optional)"), text: $keyword) - .textFieldStyle(.roundedBorder) + TextField("Name:", text: $name) + TextField("Keyword:", text: $keyword) .onChange(of: keyword) { _, newValue in validateKeyword(newValue) } if let error = keywordError { - Text(error) - .font(.caption) - .foregroundStyle(.red) + LabeledContent {} label: { + Text(error) + .foregroundStyle(error.hasPrefix("Warning") ? .orange : .red) + .font(.callout) + } } - VStack(alignment: .leading, spacing: 4) { - Text("Query") - .font(.caption) - .foregroundStyle(.secondary) + LabeledContent("Query:") { TextEditor(text: $query) .font(.system(.body, design: .monospaced)) - .frame(minHeight: 120) - .border(Color(nsColor: .separatorColor)) + .frame(height: 160) + .scrollContentBackground(.hidden) + .padding(4) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) } if !forceGlobal { - Toggle(String(localized: "Global (visible in all connections)"), isOn: $isGlobal) + Toggle("Global:", isOn: $isGlobal) } } - .padding() - - Divider() + .formStyle(.columns) - // Buttons HStack { Spacer() - Button(String(localized: "Cancel")) { + Button("Cancel") { dismiss() } .keyboardShortcut(.cancelAction) - Button(isEditing ? String(localized: "Save") : String(localized: "Add")) { + Button(isEditing ? "Save" : "Add") { save() } .keyboardShortcut(.defaultAction) .disabled(!isValid || isSaving) } - .padding() } - .frame(width: 480, height: 420) + .padding(20) + .frame(width: 480) .onAppear { if let fav = favorite { name = fav.name diff --git a/TablePro/Views/Sidebar/FavoritesSidebarSection.swift b/TablePro/Views/Sidebar/FavoritesSidebarSection.swift deleted file mode 100644 index df4bc52c..00000000 --- a/TablePro/Views/Sidebar/FavoritesSidebarSection.swift +++ /dev/null @@ -1,182 +0,0 @@ -// -// FavoritesSidebarSection.swift -// TablePro -// - -import SwiftUI - -/// Sidebar section displaying SQL favorites organized in folders -struct FavoritesSidebarSection: View { - @State private var viewModel: FavoritesSidebarViewModel - let searchText: String - private weak var coordinator: MainContentCoordinator? - - init(connectionId: UUID, searchText: String, coordinator: MainContentCoordinator?) { - _viewModel = State(wrappedValue: FavoritesSidebarViewModel(connectionId: connectionId)) - self.searchText = searchText - self.coordinator = coordinator - } - - var body: some View { - Section(isExpanded: $viewModel.isFavoritesExpanded) { - let items = viewModel.filteredItems(searchText: searchText) - if items.isEmpty { - Text("No favorites") - .font(.system(size: 11)) - .foregroundStyle(.tertiary) - .listRowSeparator(.hidden) - } else { - ForEach(items) { item in - FavoriteTreeItemRow( - item: item, - viewModel: viewModel, - coordinator: coordinator - ) - } - } - } header: { - HStack { - Text("Favorites") - Spacer() - Button { - viewModel.createFavorite() - } label: { - Image(systemName: "plus") - .font(.system(size: 10)) - } - .buttonStyle(.borderless) - } - .contextMenu { - Button("New Favorite...") { - viewModel.createFavorite() - } - Button("New Folder") { - viewModel.createFolder() - } - } - } - .onAppear { - Task { await viewModel.loadFavorites() } - } - .sheet(isPresented: $viewModel.showEditDialog) { - FavoriteEditDialog( - connectionId: coordinator?.connectionId ?? UUID(), - favorite: viewModel.editingFavorite, - initialQuery: viewModel.editingQuery, - folderId: viewModel.editingFolderId - ) - } - } - -} - -// MARK: - Recursive Tree Item View - -/// Separate struct to break the self-referential opaque return type -/// that occurs with recursive `@ViewBuilder` functions. -private struct FavoriteTreeItemRow: View { - let item: FavoriteTreeItem - let viewModel: FavoritesSidebarViewModel - weak var coordinator: MainContentCoordinator? - - var body: some View { - switch item { - case .favorite(let favorite): - FavoriteRowView(favorite: favorite) - .overlay { - DoubleClickDetector { - coordinator?.insertFavorite(favorite) - } - } - .contextMenu { - FavoriteItemContextMenu( - favorite: favorite, - viewModel: viewModel, - coordinator: coordinator - ) - } - case .folder(let folder, let children): - DisclosureGroup { - ForEach(children) { child in - FavoriteTreeItemRow( - item: child, - viewModel: viewModel, - coordinator: coordinator - ) - } - } label: { - Label(folder.name, systemImage: "folder") - .contextMenu { - FolderContextMenu( - folder: folder, - viewModel: viewModel - ) - } - } - } - } -} - -// MARK: - Context Menus - -private struct FavoriteItemContextMenu: View { - let favorite: SQLFavorite - let viewModel: FavoritesSidebarViewModel - weak var coordinator: MainContentCoordinator? - - var body: some View { - Button("Edit...") { - viewModel.editFavorite(favorite) - } - - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(favorite.query, forType: .string) - } label: { - Label(String(localized: "Copy Query"), systemImage: "doc.on.doc") - } - - Button { - coordinator?.insertFavorite(favorite) - } label: { - Label(String(localized: "Insert in Editor"), systemImage: "text.insert") - } - - Button { - coordinator?.runFavoriteInNewTab(favorite) - } label: { - Label(String(localized: "Run in New Tab"), systemImage: "play") - } - - Divider() - - Button(role: .destructive) { - viewModel.deleteFavorite(favorite) - } label: { - Label(String(localized: "Delete"), systemImage: "trash") - } - } -} - -private struct FolderContextMenu: View { - let folder: SQLFavoriteFolder - let viewModel: FavoritesSidebarViewModel - - var body: some View { - Button("New Favorite...") { - viewModel.createFavorite(folderId: folder.id) - } - - Button("New Subfolder") { - viewModel.createFolder(parentId: folder.id) - } - - Divider() - - Button(role: .destructive) { - viewModel.deleteFolder(folder) - } label: { - Label(String(localized: "Delete Folder"), systemImage: "trash") - } - } -} diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift new file mode 100644 index 00000000..619fb8b9 --- /dev/null +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -0,0 +1,276 @@ +// +// FavoritesTabView.swift +// TablePro +// +// Full-tab view for SQL favorites in the sidebar. +// + +import SwiftUI + +/// Full-tab favorites view with folder hierarchy and bottom toolbar +struct FavoritesTabView: View { + @State private var viewModel: FavoritesSidebarViewModel + let searchText: String + private weak var coordinator: MainContentCoordinator? + + init(connectionId: UUID, searchText: String, coordinator: MainContentCoordinator?) { + _viewModel = State(wrappedValue: FavoritesSidebarViewModel(connectionId: connectionId)) + self.searchText = searchText + self.coordinator = coordinator + } + + var body: some View { + Group { + let items = viewModel.filteredItems(searchText: searchText) + + if viewModel.treeItems.isEmpty && searchText.isEmpty && !viewModel.isLoading { + emptyState + } else if items.isEmpty { + noMatchState + } else { + favoritesList(items) + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 0) { + Divider() + bottomToolbar + } + } + .onAppear { + Task { await viewModel.loadFavorites() } + } + .sheet(isPresented: $viewModel.showEditDialog) { + FavoriteEditDialog( + connectionId: coordinator?.connectionId ?? UUID(), + favorite: viewModel.editingFavorite, + initialQuery: viewModel.editingQuery, + folderId: viewModel.editingFolderId + ) + } + } + + // MARK: - List + + private func favoritesList(_ items: [FavoriteTreeItem]) -> some View { + List { + ForEach(items) { item in + FavoriteTreeItemRow( + item: item, + viewModel: viewModel, + coordinator: coordinator + ) + } + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + } + + // MARK: - Empty States + + private var emptyState: some View { + VStack(spacing: 6) { + Image(systemName: "star") + .font(.system(size: 28, weight: .thin)) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + + Text("No Favorites") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + + Text("Save frequently used queries\nfor quick access.") + .font(.system(size: 11)) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var noMatchState: some View { + VStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: 28, weight: .thin)) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + + Text("No Matching Favorites") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Bottom Toolbar + + private var bottomToolbar: some View { + HStack(spacing: 8) { + Button { + viewModel.createFavorite() + } label: { + Label(String(localized: "New Favorite"), systemImage: "plus") + .font(.system(size: 11)) + } + .buttonStyle(.borderless) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + + Spacer() + + Button { + viewModel.createFolder() + } label: { + Image(systemName: "folder.badge.plus") + .font(.system(size: 11)) + } + .buttonStyle(.borderless) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } +} + +// MARK: - Recursive Tree Item View + +struct FavoriteTreeItemRow: View { + let item: FavoriteTreeItem + let viewModel: FavoritesSidebarViewModel + weak var coordinator: MainContentCoordinator? + @FocusState private var isRenameFocused: Bool + + var body: some View { + switch item { + case .favorite(let favorite): + FavoriteRowView(favorite: favorite) + .overlay { + DoubleClickDetector { + coordinator?.insertFavorite(favorite) + } + } + .contextMenu { + FavoriteItemContextMenu( + favorite: favorite, + viewModel: viewModel, + coordinator: coordinator + ) + } + case .folder(let folder, let children): + DisclosureGroup(isExpanded: Binding( + get: { viewModel.expandedFolderIds.contains(folder.id) }, + set: { isExpanded in + if isExpanded { + viewModel.expandedFolderIds.insert(folder.id) + } else { + viewModel.expandedFolderIds.remove(folder.id) + } + } + )) { + ForEach(children) { child in + FavoriteTreeItemRow( + item: child, + viewModel: viewModel, + coordinator: coordinator + ) + } + } label: { + if viewModel.renamingFolderId == folder.id { + HStack(spacing: 4) { + Image(systemName: "folder") + TextField( + "", + text: Binding( + get: { viewModel.renamingFolderName }, + set: { viewModel.renamingFolderName = $0 } + ) + ) + .textFieldStyle(.roundedBorder) + .focused($isRenameFocused) + .onSubmit { + viewModel.commitRenameFolder(folder) + } + .onExitCommand { + viewModel.renamingFolderId = nil + } + .onAppear { + isRenameFocused = true + } + } + } else { + Label(folder.name, systemImage: "folder") + .contextMenu { + FolderContextMenu( + folder: folder, + viewModel: viewModel + ) + } + } + } + } + } +} + +// MARK: - Context Menus + +private struct FavoriteItemContextMenu: View { + let favorite: SQLFavorite + let viewModel: FavoritesSidebarViewModel + weak var coordinator: MainContentCoordinator? + + var body: some View { + Button("Edit...") { + viewModel.editFavorite(favorite) + } + + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(favorite.query, forType: .string) + } label: { + Label(String(localized: "Copy Query"), systemImage: "doc.on.doc") + } + + Button { + coordinator?.insertFavorite(favorite) + } label: { + Label(String(localized: "Insert in Editor"), systemImage: "text.insert") + } + + Button { + coordinator?.runFavoriteInNewTab(favorite) + } label: { + Label(String(localized: "Run in New Tab"), systemImage: "play") + } + + Divider() + + Button(role: .destructive) { + viewModel.deleteFavorite(favorite) + } label: { + Label(String(localized: "Delete"), systemImage: "trash") + } + } +} + +private struct FolderContextMenu: View { + let folder: SQLFavoriteFolder + let viewModel: FavoritesSidebarViewModel + + var body: some View { + Button("Rename") { + viewModel.startRenameFolder(folder) + } + + Button("New Favorite...") { + viewModel.createFavorite(folderId: folder.id) + } + + Button("New Subfolder") { + viewModel.createFolder(parentId: folder.id) + } + + Divider() + + Button(role: .destructive) { + viewModel.deleteFolder(folder) + } label: { + Label(String(localized: "Delete Folder"), systemImage: "trash") + } + } +} diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 1408ad74..1c2dd1fe 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -9,12 +9,10 @@ import SwiftUI // MARK: - SidebarView -/// Sidebar view displaying list of database tables +/// Sidebar view with segmented tab picker for Tables and Favorites struct SidebarView: View { @State private var viewModel: SidebarViewModel - // Keep @Binding on the view for SwiftUI change tracking. - // The ViewModel stores the same bindings for write access. @Binding var tables: [TableInfo] var sidebarState: SharedSidebarState @Binding var pendingTruncates: Set @@ -25,8 +23,6 @@ struct SidebarView: View { var connectionId: UUID private weak var coordinator: MainContentCoordinator? - /// Computed on the view (not ViewModel) so SwiftUI tracks both - /// `@Binding var tables` and `@Published var searchText` as dependencies. private var filteredTables: [TableInfo] { guard !viewModel.debouncedSearchText.isEmpty else { return tables } return tables.filter { $0.name.localizedCaseInsensitiveContains(viewModel.debouncedSearchText) } @@ -81,8 +77,34 @@ struct SidebarView: View { // MARK: - Body var body: some View { - VStack(alignment: .leading, spacing: 0) { - content + ZStack(alignment: .top) { + tablesContent + .opacity(sidebarState.selectedSidebarTab == .tables ? 1 : 0) + .frame(maxHeight: sidebarState.selectedSidebarTab == .tables ? .infinity : 0) + .clipped() + + FavoritesTabView( + connectionId: connectionId, + searchText: viewModel.debouncedSearchText, + coordinator: coordinator + ) + .opacity(sidebarState.selectedSidebarTab == .favorites ? 1 : 0) + .frame(maxHeight: sidebarState.selectedSidebarTab == .favorites ? .infinity : 0) + .clipped() + } + .animation(.easeInOut(duration: 0.18), value: sidebarState.selectedSidebarTab) + .safeAreaInset(edge: .top, spacing: 0) { + Picker("", selection: Binding( + get: { sidebarState.selectedSidebarTab }, + set: { sidebarState.selectedSidebarTab = $0 } + )) { + Text("Tables").tag(SidebarTab.tables) + Text("Favorites").tag(SidebarTab.favorites) + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(.horizontal, 12) + .padding(.vertical, 6) } .frame(minWidth: 280) .onChange(of: sidebarState.searchText) { _, newValue in @@ -116,10 +138,10 @@ struct SidebarView: View { } } - // MARK: - Content States + // MARK: - Tables Content @ViewBuilder - private var content: some View { + private var tablesContent: some View { if let error = viewModel.errorMessage { errorState(message: error) } else if tables.isEmpty && viewModel.isLoading { @@ -179,12 +201,6 @@ struct SidebarView: View { let showAllLabel = String(localized: "Show All \(entityLabel)") return List(selection: selectedTablesBinding) { if filteredTables.isEmpty { - FavoritesSidebarSection( - connectionId: connectionId, - searchText: viewModel.debouncedSearchText, - coordinator: coordinator - ) - ContentUnavailableView( noMatchLabel, systemImage: "magnifyingglass" @@ -192,12 +208,6 @@ struct SidebarView: View { .listRowSeparator(.hidden) .listRowBackground(Color.clear) } else { - FavoritesSidebarSection( - connectionId: connectionId, - searchText: viewModel.debouncedSearchText, - coordinator: coordinator - ) - Section(isExpanded: $viewModel.isTablesExpanded) { ForEach(filteredTables) { table in TableRow( From d9f17d148b0eb2dce68e22c1752c2fd16b52619c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 15:19:09 +0700 Subject: [PATCH 04/20] fix: move SSH tunnel relay I/O off cooperative thread pool to prevent deadlock --- TablePro/Core/SSH/LibSSH2Tunnel.swift | 187 +++++++++++++++++--------- 1 file changed, 120 insertions(+), 67 deletions(-) diff --git a/TablePro/Core/SSH/LibSSH2Tunnel.swift b/TablePro/Core/SSH/LibSSH2Tunnel.swift index 78c14ad0..293cd382 100644 --- a/TablePro/Core/SSH/LibSSH2Tunnel.swift +++ b/TablePro/Core/SSH/LibSSH2Tunnel.swift @@ -30,6 +30,14 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { private let isAlive = OSAllocatedUnfairLock(initialState: true) private let relayTasks = OSAllocatedUnfairLock(initialState: [Task]()) + /// Dedicated queue for blocking I/O (poll, send, recv, libssh2 calls). + /// Keeps blocking work off the Swift cooperative thread pool. + private static let relayQueue = DispatchQueue( + label: "com.TablePro.ssh.relay", + qos: .utility, + attributes: .concurrent + ) + /// Callback invoked when the tunnel dies (keep-alive failure, etc.) var onDeath: ((UUID) -> Void)? @@ -64,34 +72,41 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { forwardingTask = Task.detached { [weak self] in guard let self else { return } - Self.logger.info("Forwarding started on port \(self.localPort) -> \(remoteHost):\(remotePort)") - while !Task.isCancelled && self.isRunning { - let clientFD = self.acceptClient() - guard clientFD >= 0 else { - if !Task.isCancelled && self.isRunning { - // accept timed out or was interrupted, retry - continue - } - break - } + await withCheckedContinuation { (continuation: CheckedContinuation) in + Self.relayQueue.async { [weak self] in + defer { continuation.resume() } + guard let self else { return } - let channel = self.openDirectTcpipChannel( - remoteHost: remoteHost, - remotePort: remotePort - ) + Self.logger.info("Forwarding started on port \(self.localPort) -> \(remoteHost):\(remotePort)") - guard let channel else { - Self.logger.error("Failed to open direct-tcpip channel") - Darwin.close(clientFD) - continue - } + while self.isRunning { + let clientFD = self.acceptClient() + guard clientFD >= 0 else { + if self.isRunning { + continue + } + break + } - Self.logger.debug("Client connected, relaying to \(remoteHost):\(remotePort)") - self.spawnRelay(clientFD: clientFD, channel: channel) - } + let channel = self.openDirectTcpipChannel( + remoteHost: remoteHost, + remotePort: remotePort + ) - Self.logger.info("Forwarding loop ended for port \(self.localPort)") + guard let channel else { + Self.logger.error("Failed to open direct-tcpip channel") + Darwin.close(clientFD) + continue + } + + Self.logger.debug("Client connected, relaying to \(remoteHost):\(remotePort)") + self.spawnRelay(clientFD: clientFD, channel: channel) + } + + Self.logger.info("Forwarding loop ended for port \(self.localPort)") + } + } } } @@ -271,36 +286,54 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { } /// Bidirectional relay between a client socket and an SSH channel. + /// Runs on a dedicated dispatch queue to avoid blocking Swift's cooperative thread pool. private func spawnRelay(clientFD: Int32, channel: OpaquePointer) { + // Wrap the blocking relay in a Task so close() can cancel/await it, + // but immediately hop to the dedicated dispatch queue for the actual I/O. let task = Task.detached { [weak self] in guard let self else { Darwin.close(clientFD) return } - let buffer = UnsafeMutablePointer.allocate(capacity: Self.relayBufferSize) - defer { - buffer.deallocate() - Darwin.close(clientFD) - // Only clean up libssh2 channel if the tunnel is still running. - // When close() tears down the tunnel, the session is freed first, - // making channel calls invalid (use-after-free). - if self.isRunning { - libssh2_channel_close(channel) - libssh2_channel_free(channel) + await withCheckedContinuation { (continuation: CheckedContinuation) in + Self.relayQueue.async { [weak self] in + defer { continuation.resume() } + guard let self else { + Darwin.close(clientFD) + return + } + self.runRelay(clientFD: clientFD, channel: channel) } } + } - while !Task.isCancelled && self.isRunning { - var pollFDs = [ - pollfd(fd: clientFD, events: Int16(POLLIN), revents: 0), - pollfd(fd: self.socketFD, events: Int16(POLLIN), revents: 0), - ] + relayTasks.withLock { $0.append(task) } + } + + /// Blocking relay loop — must only be called on `relayQueue`, never the cooperative pool. + private func runRelay(clientFD: Int32, channel: OpaquePointer) { + let buffer = UnsafeMutablePointer.allocate(capacity: Self.relayBufferSize) + defer { + buffer.deallocate() + Darwin.close(clientFD) + if self.isRunning { + libssh2_channel_close(channel) + libssh2_channel_free(channel) + } + } - let pollResult = poll(&pollFDs, 2, 100) // 100ms timeout - if pollResult < 0 { break } + while self.isRunning { + var pollFDs = [ + pollfd(fd: clientFD, events: Int16(POLLIN), revents: 0), + pollfd(fd: self.socketFD, events: Int16(POLLIN), revents: 0), + ] - // Read from SSH channel -> write to client + let pollResult = poll(&pollFDs, 2, 100) // 100ms timeout + if pollResult < 0 { break } + + // Only read from SSH channel when the SSH socket has data ready + if pollFDs[1].revents & Int16(POLLIN) != 0 { let channelRead = tablepro_libssh2_channel_read( channel, buffer, Self.relayBufferSize ) @@ -317,42 +350,62 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { totalSent += sent } } else if channelRead == 0 || libssh2_channel_eof(channel) != 0 { - // Channel EOF return } else if channelRead != Int(LIBSSH2_ERROR_EAGAIN) { - // Real error return } + } - // Read from client -> write to SSH channel - if pollFDs[0].revents & Int16(POLLIN) != 0 { - let clientRead = recv(clientFD, buffer, Self.relayBufferSize, 0) - if clientRead <= 0 { return } - - var totalWritten = 0 - while totalWritten < Int(clientRead) { - let written = tablepro_libssh2_channel_write( - channel, - buffer.advanced(by: totalWritten), - Int(clientRead) - totalWritten + // Also attempt a non-blocking channel read when poll timed out, + // because libssh2 may have buffered data internally + if pollResult == 0 { + let channelRead = tablepro_libssh2_channel_read( + channel, buffer, Self.relayBufferSize + ) + if channelRead > 0 { + var totalSent = 0 + while totalSent < Int(channelRead) { + let sent = send( + clientFD, + buffer.advanced(by: totalSent), + Int(channelRead) - totalSent, + 0 ) - if written > 0 { - totalWritten += Int(written) - } else if written == Int(LIBSSH2_ERROR_EAGAIN) { - _ = self.waitForSocket( - session: self.session, - socketFD: self.socketFD, - timeoutMs: 1_000 - ) - } else { - return - } + if sent <= 0 { return } + totalSent += sent } + } else if channelRead == 0 || libssh2_channel_eof(channel) != 0 { + return } + // Ignore EAGAIN on timeout read — no data buffered } - } - relayTasks.withLock { $0.append(task) } + // Read from client -> write to SSH channel + if pollFDs[0].revents & Int16(POLLIN) != 0 { + let clientRead = recv(clientFD, buffer, Self.relayBufferSize, 0) + if clientRead <= 0 { return } + + var totalWritten = 0 + while totalWritten < Int(clientRead) { + let written = tablepro_libssh2_channel_write( + channel, + buffer.advanced(by: totalWritten), + Int(clientRead) - totalWritten + ) + if written > 0 { + totalWritten += Int(written) + } else if written == Int(LIBSSH2_ERROR_EAGAIN) { + _ = self.waitForSocket( + session: self.session, + socketFD: self.socketFD, + timeoutMs: 1_000 + ) + } else { + return + } + } + } + } } /// Wait for the SSH socket to become ready, based on libssh2's block directions. From 461cd8f0917c020be771d289314d4e12a30cf4be Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 15:27:06 +0700 Subject: [PATCH 05/20] =?UTF-8?q?fix:=20polish=20SQL=20favorites=20?= =?UTF-8?q?=E2=80=94=20accessibility,=20delete=20confirmation,=20localizat?= =?UTF-8?q?ion,=20edge=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MainContentCoordinator+Favorites.swift | 11 +++-- .../Views/Sidebar/FavoriteEditDialog.swift | 12 +++-- TablePro/Views/Sidebar/FavoriteRowView.swift | 11 +++++ TablePro/Views/Sidebar/FavoritesTabView.swift | 47 +++++++++++++++---- 4 files changed, 67 insertions(+), 14 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift index c4270b58..e4831e20 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift @@ -6,10 +6,15 @@ import Foundation extension MainContentCoordinator { - /// Insert a favorite's query into the current editor tab + /// Insert a favorite's query into the current editor tab. + /// If the current tab is not a query tab, opens a new query tab instead. func insertFavorite(_ favorite: SQLFavorite) { - guard let tabIndex = tabManager.selectedTabIndex else { return } - tabManager.tabs[tabIndex].query = favorite.query + if let tabIndex = tabManager.selectedTabIndex, + tabManager.tabs[tabIndex].tabType == .query { + tabManager.tabs[tabIndex].query = favorite.query + } else { + runFavoriteInNewTab(favorite) + } } /// Open a favorite's query in a new tab diff --git a/TablePro/Views/Sidebar/FavoriteEditDialog.swift b/TablePro/Views/Sidebar/FavoriteEditDialog.swift index 0b9ce6e1..f421e2ba 100644 --- a/TablePro/Views/Sidebar/FavoriteEditDialog.swift +++ b/TablePro/Views/Sidebar/FavoriteEditDialog.swift @@ -20,6 +20,7 @@ struct FavoriteEditDialog: View { @State private var keyword: String = "" @State private var isGlobal: Bool = true @State private var keywordError: String? + @State private var isKeywordWarning = false @State private var isSaving = false private var isEditing: Bool { favorite != nil } @@ -57,7 +58,7 @@ struct FavoriteEditDialog: View { if let error = keywordError { LabeledContent {} label: { Text(error) - .foregroundStyle(error.hasPrefix("Warning") ? .orange : .red) + .foregroundStyle(isKeywordWarning ? .orange : .red) .font(.callout) } } @@ -78,6 +79,7 @@ struct FavoriteEditDialog: View { if !forceGlobal { Toggle("Global:", isOn: $isGlobal) + .help(String(localized: "When enabled, this favorite is visible in all connections")) } } .formStyle(.columns) @@ -105,7 +107,7 @@ struct FavoriteEditDialog: View { keyword = fav.keyword ?? "" isGlobal = forceGlobal || fav.connectionId == nil } else { - isGlobal = forceGlobal || true + isGlobal = true if let q = initialQuery { query = q } @@ -122,6 +124,7 @@ struct FavoriteEditDialog: View { return } if trimmed.contains(" ") { + isKeywordWarning = false keywordError = String(localized: "Keyword cannot contain spaces") return } @@ -133,6 +136,7 @@ struct FavoriteEditDialog: View { excludingFavoriteId: favorite?.id ) if !available { + isKeywordWarning = false keywordError = String(localized: "This keyword is already in use") } else { let sqlKeywords: Set = [ @@ -143,10 +147,12 @@ struct FavoriteEditDialog: View { "true", "false", "case", "when", "then", "else", "end" ] if sqlKeywords.contains(trimmed.lowercased()) { + isKeywordWarning = true keywordError = String( - localized: "Warning: this shadows the SQL keyword '\(trimmed.uppercased())'" + localized: "Shadows the SQL keyword '\(trimmed.uppercased())'" ) } else { + isKeywordWarning = false keywordError = nil } } diff --git a/TablePro/Views/Sidebar/FavoriteRowView.swift b/TablePro/Views/Sidebar/FavoriteRowView.swift index c0862ece..e788b590 100644 --- a/TablePro/Views/Sidebar/FavoriteRowView.swift +++ b/TablePro/Views/Sidebar/FavoriteRowView.swift @@ -14,6 +14,7 @@ struct FavoriteRowView: View { Image(systemName: "star.fill") .font(.system(size: 10)) .foregroundStyle(.yellow) + .accessibilityHidden(true) Text(favorite.name) .lineLimit(1) @@ -30,7 +31,17 @@ struct FavoriteRowView: View { Capsule() .fill(Color(nsColor: .quaternaryLabelColor)) ) + .accessibilityHidden(true) } } + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityDescription) + } + + private var accessibilityDescription: String { + if let keyword = favorite.keyword, !keyword.isEmpty { + return "\(favorite.name), \(String(localized: "keyword: \(keyword)"))" + } + return favorite.name } } diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 619fb8b9..42cc9c1f 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -10,6 +10,8 @@ import SwiftUI /// Full-tab favorites view with folder hierarchy and bottom toolbar struct FavoritesTabView: View { @State private var viewModel: FavoritesSidebarViewModel + @State private var folderToDelete: SQLFavoriteFolder? + @State private var showDeleteFolderAlert = false let searchText: String private weak var coordinator: MainContentCoordinator? @@ -48,6 +50,18 @@ struct FavoritesTabView: View { folderId: viewModel.editingFolderId ) } + .alert( + String(localized: "Delete Folder?"), + isPresented: $showDeleteFolderAlert, + presenting: folderToDelete + ) { folder in + Button(String(localized: "Cancel"), role: .cancel) {} + Button(String(localized: "Delete"), role: .destructive) { + viewModel.deleteFolder(folder) + } + } message: { folder in + Text("The folder \"\(folder.name)\" will be deleted. Items inside will be moved to the parent level.") + } } // MARK: - List @@ -58,7 +72,11 @@ struct FavoritesTabView: View { FavoriteTreeItemRow( item: item, viewModel: viewModel, - coordinator: coordinator + coordinator: coordinator, + onDeleteFolder: { folder in + folderToDelete = folder + showDeleteFolderAlert = true + } ) } } @@ -82,6 +100,15 @@ struct FavoritesTabView: View { .font(.system(size: 11)) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) .multilineTextAlignment(.center) + + Button { + viewModel.createFavorite() + } label: { + Label(String(localized: "New Favorite"), systemImage: "plus") + .font(.system(size: 12)) + } + .buttonStyle(.borderless) + .padding(.top, 4) } .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -134,6 +161,7 @@ struct FavoriteTreeItemRow: View { let item: FavoriteTreeItem let viewModel: FavoritesSidebarViewModel weak var coordinator: MainContentCoordinator? + var onDeleteFolder: ((SQLFavoriteFolder) -> Void)? @FocusState private var isRenameFocused: Bool var body: some View { @@ -167,7 +195,8 @@ struct FavoriteTreeItemRow: View { FavoriteTreeItemRow( item: child, viewModel: viewModel, - coordinator: coordinator + coordinator: coordinator, + onDeleteFolder: onDeleteFolder ) } } label: { @@ -198,7 +227,8 @@ struct FavoriteTreeItemRow: View { .contextMenu { FolderContextMenu( folder: folder, - viewModel: viewModel + viewModel: viewModel, + onDelete: onDeleteFolder ?? { _ in } ) } } @@ -215,7 +245,7 @@ private struct FavoriteItemContextMenu: View { weak var coordinator: MainContentCoordinator? var body: some View { - Button("Edit...") { + Button(String(localized: "Edit...")) { viewModel.editFavorite(favorite) } @@ -251,24 +281,25 @@ private struct FavoriteItemContextMenu: View { private struct FolderContextMenu: View { let folder: SQLFavoriteFolder let viewModel: FavoritesSidebarViewModel + var onDelete: (SQLFavoriteFolder) -> Void var body: some View { - Button("Rename") { + Button(String(localized: "Rename")) { viewModel.startRenameFolder(folder) } - Button("New Favorite...") { + Button(String(localized: "New Favorite...")) { viewModel.createFavorite(folderId: folder.id) } - Button("New Subfolder") { + Button(String(localized: "New Subfolder")) { viewModel.createFolder(parentId: folder.id) } Divider() Button(role: .destructive) { - viewModel.deleteFolder(folder) + onDelete(folder) } label: { Label(String(localized: "Delete Folder"), systemImage: "trash") } From d934d2af73467e5ad3b54d32339f8c00b03418a6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 15:29:21 +0700 Subject: [PATCH 06/20] fix: fetch all favorites including those inside folders for tree building --- TablePro/Core/Storage/SQLFavoriteStorage.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/TablePro/Core/Storage/SQLFavoriteStorage.swift b/TablePro/Core/Storage/SQLFavoriteStorage.swift index 83e7e572..b68dbcff 100644 --- a/TablePro/Core/Storage/SQLFavoriteStorage.swift +++ b/TablePro/Core/Storage/SQLFavoriteStorage.swift @@ -384,8 +384,6 @@ final class SQLFavoriteStorage { if folderIdString != nil { whereClauses.append("folder_id = ?") hasFolderFilter = true - } else if searchText == nil { - whereClauses.append("folder_id IS NULL") } if !whereClauses.isEmpty { From e30abd4681a945cd9978d69961b9f5c9aa78a2c2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 15:37:26 +0700 Subject: [PATCH 07/20] =?UTF-8?q?fix:=20favorites=20UX=20=E2=80=94=20full?= =?UTF-8?q?=20query=20fallback,=20search=20includes=20query=20text,=20hit?= =?UTF-8?q?=20testing,=20scope=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TablePro/ViewModels/FavoritesSidebarViewModel.swift | 3 ++- TablePro/Views/Editor/AIEditorContextMenu.swift | 6 ++++-- TablePro/Views/Editor/SQLEditorCoordinator.swift | 3 +++ TablePro/Views/Sidebar/FavoriteEditDialog.swift | 2 +- TablePro/Views/Sidebar/SidebarView.swift | 2 ++ 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/TablePro/ViewModels/FavoritesSidebarViewModel.swift b/TablePro/ViewModels/FavoritesSidebarViewModel.swift index e9447650..acdc2445 100644 --- a/TablePro/ViewModels/FavoritesSidebarViewModel.swift +++ b/TablePro/ViewModels/FavoritesSidebarViewModel.swift @@ -182,7 +182,8 @@ final class FavoritesSidebarViewModel { switch item { case .favorite(let fav): if fav.name.localizedCaseInsensitiveContains(searchText) || - (fav.keyword?.localizedCaseInsensitiveContains(searchText) == true) { + (fav.keyword?.localizedCaseInsensitiveContains(searchText) == true) || + fav.query.localizedCaseInsensitiveContains(searchText) { return item } return nil diff --git a/TablePro/Views/Editor/AIEditorContextMenu.swift b/TablePro/Views/Editor/AIEditorContextMenu.swift index 393f504d..399357a0 100644 --- a/TablePro/Views/Editor/AIEditorContextMenu.swift +++ b/TablePro/Views/Editor/AIEditorContextMenu.swift @@ -12,6 +12,7 @@ final class AIEditorContextMenu: NSMenu, NSMenuDelegate { /// Closure provided by the coordinator to check if text is selected var hasSelection: (() -> Bool)? var selectedText: (() -> String?)? + var fullText: (() -> String?)? var onExplainWithAI: ((String) -> Void)? var onOptimizeWithAI: ((String) -> Void)? var onSaveAsFavorite: ((String) -> Void)? @@ -55,6 +56,7 @@ final class AIEditorContextMenu: NSMenu, NSMenuDelegate { ) saveAsFavItem.target = self saveAsFavItem.image = NSImage(systemSymbolName: "star", accessibilityDescription: nil) + saveAsFavItem.isEnabled = (fullText?()?.isEmpty == false) menu.addItem(saveAsFavItem) // AI items — only when text is selected @@ -96,8 +98,8 @@ final class AIEditorContextMenu: NSMenu, NSMenuDelegate { @objc private func handleSaveAsFavorite() { if let text = selectedText?(), !text.isEmpty { onSaveAsFavorite?(text) - } else { - onSaveAsFavorite?("") + } else if let text = fullText?(), !text.isEmpty { + onSaveAsFavorite?(text) } } } diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index d2173814..bbe0ec1f 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -165,6 +165,9 @@ final class SQLEditorCoordinator: TextViewCoordinator { guard range.length > 0 else { return nil } return (textView.string as NSString).substring(with: range) } + menu.fullText = { [weak controller] in + controller?.textView?.string + } menu.onExplainWithAI = { [weak self] text in self?.onAIExplain?(text) } menu.onOptimizeWithAI = { [weak self] text in self?.onAIOptimize?(text) } menu.onSaveAsFavorite = { [weak self] text in self?.onSaveAsFavorite?(text) } diff --git a/TablePro/Views/Sidebar/FavoriteEditDialog.swift b/TablePro/Views/Sidebar/FavoriteEditDialog.swift index f421e2ba..eb9ffd59 100644 --- a/TablePro/Views/Sidebar/FavoriteEditDialog.swift +++ b/TablePro/Views/Sidebar/FavoriteEditDialog.swift @@ -107,7 +107,7 @@ struct FavoriteEditDialog: View { keyword = fav.keyword ?? "" isGlobal = forceGlobal || fav.connectionId == nil } else { - isGlobal = true + isGlobal = forceGlobal if let q = initialQuery { query = q } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 1c2dd1fe..8cf6855b 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -82,6 +82,7 @@ struct SidebarView: View { .opacity(sidebarState.selectedSidebarTab == .tables ? 1 : 0) .frame(maxHeight: sidebarState.selectedSidebarTab == .tables ? .infinity : 0) .clipped() + .allowsHitTesting(sidebarState.selectedSidebarTab == .tables) FavoritesTabView( connectionId: connectionId, @@ -91,6 +92,7 @@ struct SidebarView: View { .opacity(sidebarState.selectedSidebarTab == .favorites ? 1 : 0) .frame(maxHeight: sidebarState.selectedSidebarTab == .favorites ? .infinity : 0) .clipped() + .allowsHitTesting(sidebarState.selectedSidebarTab == .favorites) } .animation(.easeInOut(duration: 0.18), value: sidebarState.selectedSidebarTab) .safeAreaInset(edge: .top, spacing: 0) { From 2490b757322f01268a2152705d0e1d821a54c7b1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 15:44:01 +0700 Subject: [PATCH 08/20] fix: favorites selection state, keyboard delete, reliable dialog query passing --- TablePro/Views/Editor/HistoryPanelView.swift | 10 ++--- .../Main/Child/MainEditorContentView.swift | 11 +++--- .../Views/Sidebar/FavoriteEditDialog.swift | 6 +++ TablePro/Views/Sidebar/FavoritesTabView.swift | 38 ++++++++++++++++++- 4 files changed, 52 insertions(+), 13 deletions(-) diff --git a/TablePro/Views/Editor/HistoryPanelView.swift b/TablePro/Views/Editor/HistoryPanelView.swift index 440956f3..6ecbeea7 100644 --- a/TablePro/Views/Editor/HistoryPanelView.swift +++ b/TablePro/Views/Editor/HistoryPanelView.swift @@ -21,8 +21,7 @@ struct HistoryPanelView: View { @State private var searchTask: Task? @State private var copyButtonTitle = "Copy Query" @State private var copyResetTask: Task? - @State private var showFavoriteDialog = false - @State private var favoriteQuery: String? + @State private var favoriteDialogQuery: FavoriteDialogQuery? @FocusedValue(\.commandActions) private var actions private let dataProvider = HistoryDataProvider() @@ -51,11 +50,11 @@ struct HistoryPanelView: View { .onReceive(NotificationCenter.default.publisher(for: .queryHistoryDidUpdate)) { _ in loadData() } - .sheet(isPresented: $showFavoriteDialog) { + .sheet(item: $favoriteDialogQuery) { item in FavoriteEditDialog( connectionId: UUID(), favorite: nil, - initialQuery: favoriteQuery, + initialQuery: item.query, forceGlobal: true ) } @@ -197,8 +196,7 @@ private extension HistoryPanelView { } Button { - favoriteQuery = entry.query - showFavoriteDialog = true + favoriteDialogQuery = FavoriteDialogQuery(query: entry.query) } label: { Label(String(localized: "Save as Favorite"), systemImage: "star") } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index dc0f1e07..4a809b8c 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -66,8 +66,7 @@ struct MainEditorContentView: View { @State private var tabProviderVersions: [UUID: Int] = [:] @State private var tabProviderMetaVersions: [UUID: Int] = [:] @State private var cachedChangeManager: AnyChangeManager? - @State private var showFavoriteDialog = false - @State private var favoriteQuery: String? + @State private var favoriteDialogQuery: FavoriteDialogQuery? // Native macOS window tabs — no LRU tracking needed (single tab per window) @@ -109,11 +108,11 @@ struct MainEditorContentView: View { } .background(.background) .animation(.easeInOut(duration: 0.2), value: isHistoryVisible) - .sheet(isPresented: $showFavoriteDialog) { + .sheet(item: $favoriteDialogQuery) { item in FavoriteEditDialog( connectionId: connectionId, favorite: nil, - initialQuery: favoriteQuery + initialQuery: item.query ) } .onChange(of: tabManager.tabs.count) { @@ -220,8 +219,8 @@ struct MainEditorContentView: View { coordinator.aiViewModel?.handleOptimizeSelection(text) }, onSaveAsFavorite: { text in - favoriteQuery = text.isEmpty ? nil : text - showFavoriteDialog = true + guard !text.isEmpty else { return } + favoriteDialogQuery = FavoriteDialogQuery(query: text) } ) } diff --git a/TablePro/Views/Sidebar/FavoriteEditDialog.swift b/TablePro/Views/Sidebar/FavoriteEditDialog.swift index eb9ffd59..e66fe111 100644 --- a/TablePro/Views/Sidebar/FavoriteEditDialog.swift +++ b/TablePro/Views/Sidebar/FavoriteEditDialog.swift @@ -5,6 +5,12 @@ import SwiftUI +/// Wrapper for `.sheet(item:)` to ensure the query is passed reliably +struct FavoriteDialogQuery: Identifiable { + let id = UUID() + let query: String +} + /// Dialog for creating or editing a SQL favorite struct FavoriteEditDialog: View { @Environment(\.dismiss) private var dismiss diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 42cc9c1f..ac4ce42b 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -10,6 +10,7 @@ import SwiftUI /// Full-tab favorites view with folder hierarchy and bottom toolbar struct FavoritesTabView: View { @State private var viewModel: FavoritesSidebarViewModel + @State private var selectedFavoriteIds: Set = [] @State private var folderToDelete: SQLFavoriteFolder? @State private var showDeleteFolderAlert = false let searchText: String @@ -67,7 +68,7 @@ struct FavoritesTabView: View { // MARK: - List private func favoritesList(_ items: [FavoriteTreeItem]) -> some View { - List { + List(selection: $selectedFavoriteIds) { ForEach(items) { item in FavoriteTreeItemRow( item: item, @@ -78,10 +79,45 @@ struct FavoritesTabView: View { showDeleteFolderAlert = true } ) + .tag(item.id) } } .listStyle(.sidebar) .scrollContentBackground(.hidden) + .onDeleteCommand { + deleteSelectedFavorites() + } + .contextMenu { + if !selectedFavoriteIds.isEmpty { + Button(role: .destructive) { + deleteSelectedFavorites() + } label: { + Label(String(localized: "Delete Selected"), systemImage: "trash") + } + } + } + } + + private func deleteSelectedFavorites() { + let allFavorites = collectFavorites(from: viewModel.treeItems) + let toDelete = allFavorites.filter { selectedFavoriteIds.contains("fav-\($0.id)") } + for fav in toDelete { + viewModel.deleteFavorite(fav) + } + selectedFavoriteIds.removeAll() + } + + private func collectFavorites(from items: [FavoriteTreeItem]) -> [SQLFavorite] { + var result: [SQLFavorite] = [] + for item in items { + switch item { + case .favorite(let fav): + result.append(fav) + case .folder(_, let children): + result.append(contentsOf: collectFavorites(from: children)) + } + } + return result } // MARK: - Empty States From 79ce558d67bac8a1ab988ff54455c42e1e79c7ae Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 15:48:31 +0700 Subject: [PATCH 09/20] fix: add .tag() to nested favorite items inside DisclosureGroup to fix selection --- TablePro/Views/Sidebar/FavoritesTabView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index ac4ce42b..d93f6385 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -234,6 +234,7 @@ struct FavoriteTreeItemRow: View { coordinator: coordinator, onDeleteFolder: onDeleteFolder ) + .tag(child.id) } } label: { if viewModel.renamingFolderId == folder.id { From 86830f806c354a608948b61ac795ce1ca43df617 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 15:53:37 +0700 Subject: [PATCH 10/20] fix: batch delete favorites with single notification to prevent partial deletion --- TablePro/Core/Storage/SQLFavoriteManager.swift | 12 ++++++++++++ TablePro/ViewModels/FavoritesSidebarViewModel.swift | 6 ++++++ TablePro/Views/Sidebar/FavoritesTabView.swift | 5 ++--- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/TablePro/Core/Storage/SQLFavoriteManager.swift b/TablePro/Core/Storage/SQLFavoriteManager.swift index ab316749..5adf5982 100644 --- a/TablePro/Core/Storage/SQLFavoriteManager.swift +++ b/TablePro/Core/Storage/SQLFavoriteManager.swift @@ -51,6 +51,18 @@ final class SQLFavoriteManager { return result } + func deleteFavorites(ids: [UUID]) async { + for id in ids { + let result = await storage.deleteFavorite(id: id) + if result { + SyncChangeTracker.shared.markDeleted(.favorite, id: id.uuidString) + } + } + if !ids.isEmpty { + postUpdateNotification() + } + } + func fetchFavorites( connectionId: UUID? = nil, folderId: UUID? = nil, diff --git a/TablePro/ViewModels/FavoritesSidebarViewModel.swift b/TablePro/ViewModels/FavoritesSidebarViewModel.swift index acdc2445..b2075b93 100644 --- a/TablePro/ViewModels/FavoritesSidebarViewModel.swift +++ b/TablePro/ViewModels/FavoritesSidebarViewModel.swift @@ -128,6 +128,12 @@ final class FavoritesSidebarViewModel { } } + func deleteFavorites(_ favorites: [SQLFavorite]) { + Task { + await manager.deleteFavorites(ids: favorites.map(\.id)) + } + } + func createFolder(parentId: UUID? = nil) { if let parentId { expandedFolderIds.insert(parentId) diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index d93f6385..1972de5f 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -101,9 +101,8 @@ struct FavoritesTabView: View { private func deleteSelectedFavorites() { let allFavorites = collectFavorites(from: viewModel.treeItems) let toDelete = allFavorites.filter { selectedFavoriteIds.contains("fav-\($0.id)") } - for fav in toDelete { - viewModel.deleteFavorite(fav) - } + guard !toDelete.isEmpty else { return } + viewModel.deleteFavorites(toDelete) selectedFavoriteIds.removeAll() } From d004e3c66e904de3d89d061033c015bcacb7060e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 16:05:09 +0700 Subject: [PATCH 11/20] fix: inline tree rendering with AnyView for reliable List selection across folders --- TablePro/Views/Sidebar/FavoritesTabView.swift | 180 ++++++------- .../Storage/SQLFavoriteStorageTests.swift | 216 +++++++++++++++ .../FavoritesSidebarViewModelTests.swift | 252 ++++++++++++++++++ 3 files changed, 547 insertions(+), 101 deletions(-) create mode 100644 TableProTests/Core/Storage/SQLFavoriteStorageTests.swift create mode 100644 TableProTests/ViewModels/FavoritesSidebarViewModelTests.swift diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 1972de5f..d46c2ab0 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -13,6 +13,7 @@ struct FavoritesTabView: View { @State private var selectedFavoriteIds: Set = [] @State private var folderToDelete: SQLFavoriteFolder? @State private var showDeleteFolderAlert = false + @FocusState private var isRenameFocused: Bool let searchText: String private weak var coordinator: MainContentCoordinator? @@ -69,32 +70,92 @@ struct FavoritesTabView: View { private func favoritesList(_ items: [FavoriteTreeItem]) -> some View { List(selection: $selectedFavoriteIds) { - ForEach(items) { item in - FavoriteTreeItemRow( - item: item, - viewModel: viewModel, - coordinator: coordinator, - onDeleteFolder: { folder in - folderToDelete = folder - showDeleteFolderAlert = true - } - ) - .tag(item.id) - } + flattenedRows(items) } .listStyle(.sidebar) .scrollContentBackground(.hidden) .onDeleteCommand { deleteSelectedFavorites() } - .contextMenu { - if !selectedFavoriteIds.isEmpty { - Button(role: .destructive) { - deleteSelectedFavorites() - } label: { - Label(String(localized: "Delete Selected"), systemImage: "trash") + } + + /// Renders tree items with DisclosureGroup for folders. + /// Each favorite row gets `.tag()` so List selection works across all nesting levels. + private func flattenedRows(_ items: [FavoriteTreeItem]) -> AnyView { + AnyView( + ForEach(items) { item in + switch item { + case .favorite(let favorite): + FavoriteRowView(favorite: favorite) + .tag("fav-\(favorite.id)") + .overlay { + DoubleClickDetector { + coordinator?.insertFavorite(favorite) + } + } + .contextMenu { + FavoriteItemContextMenu( + favorite: favorite, + viewModel: viewModel, + coordinator: coordinator + ) + } + case .folder(let folder, let children): + DisclosureGroup(isExpanded: Binding( + get: { viewModel.expandedFolderIds.contains(folder.id) }, + set: { expanded in + if expanded { + viewModel.expandedFolderIds.insert(folder.id) + } else { + viewModel.expandedFolderIds.remove(folder.id) + } + } + )) { + flattenedRows(children) + } label: { + folderLabel(folder) + } } } + ) + } + + @ViewBuilder + private func folderLabel(_ folder: SQLFavoriteFolder) -> some View { + if viewModel.renamingFolderId == folder.id { + HStack(spacing: 4) { + Image(systemName: "folder") + TextField( + "", + text: Binding( + get: { viewModel.renamingFolderName }, + set: { viewModel.renamingFolderName = $0 } + ) + ) + .textFieldStyle(.roundedBorder) + .focused($isRenameFocused) + .onSubmit { + viewModel.commitRenameFolder(folder) + } + .onExitCommand { + viewModel.renamingFolderId = nil + } + .onAppear { + isRenameFocused = true + } + } + } else { + Label(folder.name, systemImage: "folder") + .contextMenu { + FolderContextMenu( + folder: folder, + viewModel: viewModel, + onDelete: { f in + folderToDelete = f + showDeleteFolderAlert = true + } + ) + } } } @@ -190,89 +251,6 @@ struct FavoritesTabView: View { } } -// MARK: - Recursive Tree Item View - -struct FavoriteTreeItemRow: View { - let item: FavoriteTreeItem - let viewModel: FavoritesSidebarViewModel - weak var coordinator: MainContentCoordinator? - var onDeleteFolder: ((SQLFavoriteFolder) -> Void)? - @FocusState private var isRenameFocused: Bool - - var body: some View { - switch item { - case .favorite(let favorite): - FavoriteRowView(favorite: favorite) - .overlay { - DoubleClickDetector { - coordinator?.insertFavorite(favorite) - } - } - .contextMenu { - FavoriteItemContextMenu( - favorite: favorite, - viewModel: viewModel, - coordinator: coordinator - ) - } - case .folder(let folder, let children): - DisclosureGroup(isExpanded: Binding( - get: { viewModel.expandedFolderIds.contains(folder.id) }, - set: { isExpanded in - if isExpanded { - viewModel.expandedFolderIds.insert(folder.id) - } else { - viewModel.expandedFolderIds.remove(folder.id) - } - } - )) { - ForEach(children) { child in - FavoriteTreeItemRow( - item: child, - viewModel: viewModel, - coordinator: coordinator, - onDeleteFolder: onDeleteFolder - ) - .tag(child.id) - } - } label: { - if viewModel.renamingFolderId == folder.id { - HStack(spacing: 4) { - Image(systemName: "folder") - TextField( - "", - text: Binding( - get: { viewModel.renamingFolderName }, - set: { viewModel.renamingFolderName = $0 } - ) - ) - .textFieldStyle(.roundedBorder) - .focused($isRenameFocused) - .onSubmit { - viewModel.commitRenameFolder(folder) - } - .onExitCommand { - viewModel.renamingFolderId = nil - } - .onAppear { - isRenameFocused = true - } - } - } else { - Label(folder.name, systemImage: "folder") - .contextMenu { - FolderContextMenu( - folder: folder, - viewModel: viewModel, - onDelete: onDeleteFolder ?? { _ in } - ) - } - } - } - } - } -} - // MARK: - Context Menus private struct FavoriteItemContextMenu: View { diff --git a/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift b/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift new file mode 100644 index 00000000..f4e88a85 --- /dev/null +++ b/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift @@ -0,0 +1,216 @@ +// +// SQLFavoriteStorageTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("SQLFavoriteStorage", .serialized) +struct SQLFavoriteStorageTests { + private let storage = SQLFavoriteStorage(isolatedForTesting: true) + + // MARK: - Helpers + + private func makeFavorite( + name: String = "Test Query", + query: String = "SELECT 1", + keyword: String? = nil, + folderId: UUID? = nil, + connectionId: UUID? = nil + ) -> SQLFavorite { + SQLFavorite( + name: name, + query: query, + keyword: keyword, + folderId: folderId, + connectionId: connectionId + ) + } + + private func makeFolder( + name: String = "Test Folder", + parentId: UUID? = nil, + connectionId: UUID? = nil + ) -> SQLFavoriteFolder { + SQLFavoriteFolder( + name: name, + parentId: parentId, + connectionId: connectionId + ) + } + + // MARK: - Favorite CRUD + + @Test("Add and fetch favorite") + func addAndFetch() async { + let fav = makeFavorite(name: "My Query", query: "SELECT * FROM users") + let added = await storage.addFavorite(fav) + #expect(added) + + let fetched = await storage.fetchFavorites() + #expect(fetched.contains { $0.id == fav.id }) + let found = fetched.first { $0.id == fav.id } + #expect(found?.name == "My Query") + #expect(found?.query == "SELECT * FROM users") + } + + @Test("Update favorite") + func updateFavorite() async { + var fav = makeFavorite(name: "Original") + _ = await storage.addFavorite(fav) + + fav.name = "Updated" + fav.keyword = "upd" + let updated = await storage.updateFavorite(fav) + #expect(updated) + + let fetched = await storage.fetchFavorites() + let found = fetched.first { $0.id == fav.id } + #expect(found?.name == "Updated") + #expect(found?.keyword == "upd") + } + + @Test("Delete favorite") + func deleteFavorite() async { + let fav = makeFavorite() + _ = await storage.addFavorite(fav) + + let deleted = await storage.deleteFavorite(id: fav.id) + #expect(deleted) + + let fetched = await storage.fetchFavorites() + #expect(!fetched.contains { $0.id == fav.id }) + } + + // MARK: - Favorites in Folders + + @Test("Favorite in folder is fetched when no folderId filter") + func favoriteInFolderFetchedWithoutFilter() async { + let folder = makeFolder(name: "Reports") + _ = await storage.addFolder(folder) + + let fav = makeFavorite(name: "In Folder", folderId: folder.id) + _ = await storage.addFavorite(fav) + + let allFavorites = await storage.fetchFavorites() + #expect(allFavorites.contains { $0.id == fav.id }) + #expect(allFavorites.first { $0.id == fav.id }?.folderId == folder.id) + } + + @Test("Fetch favorites filtered by folderId") + func fetchByFolderId() async { + let folder = makeFolder() + _ = await storage.addFolder(folder) + + let inFolder = makeFavorite(name: "In Folder", folderId: folder.id) + let atRoot = makeFavorite(name: "At Root") + _ = await storage.addFavorite(inFolder) + _ = await storage.addFavorite(atRoot) + + let folderFavs = await storage.fetchFavorites(folderId: folder.id) + #expect(folderFavs.contains { $0.id == inFolder.id }) + #expect(!folderFavs.contains { $0.id == atRoot.id }) + } + + // MARK: - Connection Scoping + + @Test("Fetch favorites by connectionId includes global and scoped") + func fetchByConnectionId() async { + let connId = UUID() + let global = makeFavorite(name: "Global", connectionId: nil) + let scoped = makeFavorite(name: "Scoped", connectionId: connId) + let other = makeFavorite(name: "Other Connection", connectionId: UUID()) + + _ = await storage.addFavorite(global) + _ = await storage.addFavorite(scoped) + _ = await storage.addFavorite(other) + + let fetched = await storage.fetchFavorites(connectionId: connId) + #expect(fetched.contains { $0.id == global.id }) + #expect(fetched.contains { $0.id == scoped.id }) + #expect(!fetched.contains { $0.id == other.id }) + } + + // MARK: - Folder CRUD + + @Test("Add and fetch folder") + func addAndFetchFolder() async { + let folder = makeFolder(name: "Reports") + let added = await storage.addFolder(folder) + #expect(added) + + let fetched = await storage.fetchFolders() + #expect(fetched.contains { $0.id == folder.id }) + } + + @Test("Delete folder moves children to parent") + func deleteFolderMovesChildren() async { + let parent = makeFolder(name: "Parent") + _ = await storage.addFolder(parent) + + let child = makeFolder(name: "Child", parentId: parent.id) + _ = await storage.addFolder(child) + + let fav = makeFavorite(name: "In Child", folderId: child.id) + _ = await storage.addFavorite(fav) + + _ = await storage.deleteFolder(id: child.id) + + // Favorite should now be in parent folder + let fetched = await storage.fetchFavorites() + let found = fetched.first { $0.id == fav.id } + #expect(found?.folderId == parent.id.uuidString || found?.folderId == parent.id) + } + + // MARK: - Keyword + + @Test("Keyword uniqueness check") + func keywordUniqueness() async { + let fav = makeFavorite(keyword: "sel") + _ = await storage.addFavorite(fav) + + let available = await storage.isKeywordAvailable("sel", connectionId: nil) + #expect(!available) + + let otherAvailable = await storage.isKeywordAvailable("other", connectionId: nil) + #expect(otherAvailable) + } + + @Test("Keyword uniqueness excludes self") + func keywordUniquenessExcludesSelf() async { + let fav = makeFavorite(keyword: "sel") + _ = await storage.addFavorite(fav) + + let available = await storage.isKeywordAvailable("sel", connectionId: nil, excludingFavoriteId: fav.id) + #expect(available) + } + + @Test("Fetch keyword map") + func fetchKeywordMap() async { + let fav1 = makeFavorite(name: "Q1", query: "SELECT 1", keyword: "q1") + let fav2 = makeFavorite(name: "Q2", query: "SELECT 2", keyword: "q2") + let noKeyword = makeFavorite(name: "No Keyword", query: "SELECT 3") + + _ = await storage.addFavorite(fav1) + _ = await storage.addFavorite(fav2) + _ = await storage.addFavorite(noKeyword) + + let map = await storage.fetchKeywordMap() + #expect(map["q1"]?.name == "Q1") + #expect(map["q2"]?.query == "SELECT 2") + #expect(map.count >= 2) + } + + // MARK: - FTS5 Search + + @Test("Search finds favorites by query text") + func searchByQueryText() async { + let fav = makeFavorite(name: "User Report", query: "SELECT * FROM large_table WHERE active = true") + _ = await storage.addFavorite(fav) + + let results = await storage.fetchFavorites(searchText: "large_table") + #expect(results.contains { $0.id == fav.id }) + } +} diff --git a/TableProTests/ViewModels/FavoritesSidebarViewModelTests.swift b/TableProTests/ViewModels/FavoritesSidebarViewModelTests.swift new file mode 100644 index 00000000..afe37414 --- /dev/null +++ b/TableProTests/ViewModels/FavoritesSidebarViewModelTests.swift @@ -0,0 +1,252 @@ +// +// FavoritesSidebarViewModelTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("FavoriteTreeItem") +struct FavoriteTreeItemTests { + // MARK: - Helpers + + private func makeFavorite( + id: UUID = UUID(), + name: String = "Test", + query: String = "SELECT 1", + keyword: String? = nil, + folderId: UUID? = nil + ) -> SQLFavorite { + SQLFavorite(id: id, name: name, query: query, keyword: keyword, folderId: folderId) + } + + private func makeFolder( + id: UUID = UUID(), + name: String = "Folder", + parentId: UUID? = nil + ) -> SQLFavoriteFolder { + SQLFavoriteFolder(id: id, name: name, parentId: parentId) + } + + // MARK: - Tree Item IDs + + @Test("Favorite tree item ID has 'fav-' prefix") + func favoriteItemId() { + let fav = makeFavorite() + let item = FavoriteTreeItem.favorite(fav) + #expect(item.id == "fav-\(fav.id)") + } + + @Test("Folder tree item ID has 'folder-' prefix") + func folderItemId() { + let folder = makeFolder() + let item = FavoriteTreeItem.folder(folder, children: []) + #expect(item.id == "folder-\(folder.id)") + } + + // MARK: - collectFavorites + + @Test("collectFavorites from flat list") + func collectFromFlat() { + let fav1 = makeFavorite(name: "A") + let fav2 = makeFavorite(name: "B") + let items: [FavoriteTreeItem] = [.favorite(fav1), .favorite(fav2)] + + let collected = collectFavorites(from: items) + #expect(collected.count == 2) + #expect(collected.contains { $0.id == fav1.id }) + #expect(collected.contains { $0.id == fav2.id }) + } + + @Test("collectFavorites from nested folders") + func collectFromNested() { + let fav1 = makeFavorite(name: "Root Fav") + let fav2 = makeFavorite(name: "In Folder") + let fav3 = makeFavorite(name: "In Subfolder") + + let subfolder = FavoriteTreeItem.folder( + makeFolder(name: "Sub"), + children: [.favorite(fav3)] + ) + let folder = FavoriteTreeItem.folder( + makeFolder(name: "Parent"), + children: [.favorite(fav2), subfolder] + ) + let items: [FavoriteTreeItem] = [.favorite(fav1), folder] + + let collected = collectFavorites(from: items) + #expect(collected.count == 3) + #expect(collected.contains { $0.id == fav1.id }) + #expect(collected.contains { $0.id == fav2.id }) + #expect(collected.contains { $0.id == fav3.id }) + } + + @Test("collectFavorites from empty tree") + func collectFromEmpty() { + let collected = collectFavorites(from: []) + #expect(collected.isEmpty) + } + + @Test("collectFavorites from folders only (no favorites)") + func collectFromFoldersOnly() { + let folder = FavoriteTreeItem.folder(makeFolder(), children: []) + let collected = collectFavorites(from: [folder]) + #expect(collected.isEmpty) + } + + // MARK: - Delete Selection Matching + + @Test("Selected favorite IDs match collectFavorites output") + func selectionMatching() { + let fav1 = makeFavorite(name: "A") + let fav2 = makeFavorite(name: "B") + let fav3 = makeFavorite(name: "C") + + let folder = FavoriteTreeItem.folder( + makeFolder(), + children: [.favorite(fav2)] + ) + let items: [FavoriteTreeItem] = [.favorite(fav1), folder, .favorite(fav3)] + + // Simulate selecting fav1 and fav2 (one at root, one in folder) + let selectedIds: Set = ["fav-\(fav1.id)", "fav-\(fav2.id)"] + + let allFavorites = collectFavorites(from: items) + let toDelete = allFavorites.filter { selectedIds.contains("fav-\($0.id)") } + + #expect(toDelete.count == 2) + #expect(toDelete.contains { $0.id == fav1.id }) + #expect(toDelete.contains { $0.id == fav2.id }) + #expect(!toDelete.contains { $0.id == fav3.id }) + } + + @Test("Folder selection IDs are excluded from favorite deletion") + func folderSelectionExcluded() { + let fav = makeFavorite() + let folder = makeFolder() + let items: [FavoriteTreeItem] = [ + .favorite(fav), + .folder(folder, children: []) + ] + + // Only the folder is selected + let selectedIds: Set = ["folder-\(folder.id)"] + + let allFavorites = collectFavorites(from: items) + let toDelete = allFavorites.filter { selectedIds.contains("fav-\($0.id)") } + + #expect(toDelete.isEmpty) + } + + @Test("Mixed selection of favorites and folders only deletes favorites") + func mixedSelection() { + let fav1 = makeFavorite(name: "A") + let fav2 = makeFavorite(name: "B") + let folder = makeFolder() + + let items: [FavoriteTreeItem] = [ + .favorite(fav1), + .folder(folder, children: [.favorite(fav2)]) + ] + + let selectedIds: Set = [ + "fav-\(fav1.id)", + "folder-\(folder.id)", + "fav-\(fav2.id)" + ] + + let allFavorites = collectFavorites(from: items) + let toDelete = allFavorites.filter { selectedIds.contains("fav-\($0.id)") } + + #expect(toDelete.count == 2) + #expect(toDelete.contains { $0.id == fav1.id }) + #expect(toDelete.contains { $0.id == fav2.id }) + } + + // MARK: - Filtering + + @Test("Filter tree by name") + func filterByName() { + let fav1 = makeFavorite(name: "User Report") + let fav2 = makeFavorite(name: "Sales Data") + let items: [FavoriteTreeItem] = [.favorite(fav1), .favorite(fav2)] + + let filtered = filterTree(items, searchText: "user") + #expect(filtered.count == 1) + if case .favorite(let f) = filtered.first { + #expect(f.id == fav1.id) + } + } + + @Test("Filter tree by keyword") + func filterByKeyword() { + let fav1 = makeFavorite(name: "A", keyword: "usr") + let fav2 = makeFavorite(name: "B", keyword: "sls") + let items: [FavoriteTreeItem] = [.favorite(fav1), .favorite(fav2)] + + let filtered = filterTree(items, searchText: "usr") + #expect(filtered.count == 1) + } + + @Test("Filter tree by query text") + func filterByQuery() { + let fav1 = makeFavorite(name: "A", query: "SELECT * FROM large_table") + let fav2 = makeFavorite(name: "B", query: "INSERT INTO logs") + let items: [FavoriteTreeItem] = [.favorite(fav1), .favorite(fav2)] + + let filtered = filterTree(items, searchText: "large_table") + #expect(filtered.count == 1) + } + + @Test("Filter tree preserves folder with matching children") + func filterPreservesFolder() { + let fav = makeFavorite(name: "Matching Item") + let folder = makeFolder(name: "Unrelated Folder") + let items: [FavoriteTreeItem] = [ + .folder(folder, children: [.favorite(fav)]) + ] + + let filtered = filterTree(items, searchText: "matching") + #expect(filtered.count == 1) + if case .folder(_, let children) = filtered.first { + #expect(children.count == 1) + } + } + + // MARK: - Private helpers (duplicated from ViewModel for testing) + + private func collectFavorites(from items: [FavoriteTreeItem]) -> [SQLFavorite] { + var result: [SQLFavorite] = [] + for item in items { + switch item { + case .favorite(let fav): + result.append(fav) + case .folder(_, let children): + result.append(contentsOf: collectFavorites(from: children)) + } + } + return result + } + + private func filterTree(_ items: [FavoriteTreeItem], searchText: String) -> [FavoriteTreeItem] { + items.compactMap { item in + switch item { + case .favorite(let fav): + if fav.name.localizedCaseInsensitiveContains(searchText) || + (fav.keyword?.localizedCaseInsensitiveContains(searchText) == true) || + fav.query.localizedCaseInsensitiveContains(searchText) { + return item + } + return nil + case .folder(let folder, let children): + let filteredChildren = filterTree(children, searchText: searchText) + if !filteredChildren.isEmpty || + folder.name.localizedCaseInsensitiveContains(searchText) { + return .folder(folder, children: filteredChildren) + } + return nil + } + } + } +} From ad8884c5b59cdcf50e94cf2a704eabc202d89808 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 16:09:40 +0700 Subject: [PATCH 12/20] feat: add drag and drop to move favorites between folders and to root --- .../FavoritesSidebarViewModel.swift | 10 ++++++++ TablePro/Views/Sidebar/FavoritesTabView.swift | 24 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/TablePro/ViewModels/FavoritesSidebarViewModel.swift b/TablePro/ViewModels/FavoritesSidebarViewModel.swift index b2075b93..d0c5e8c8 100644 --- a/TablePro/ViewModels/FavoritesSidebarViewModel.swift +++ b/TablePro/ViewModels/FavoritesSidebarViewModel.swift @@ -128,6 +128,16 @@ final class FavoritesSidebarViewModel { } } + func moveFavorite(id: UUID, toFolder folderId: UUID?) { + Task { + let allFavorites = await manager.fetchFavorites(connectionId: connectionId) + guard var favorite = allFavorites.first(where: { $0.id == id }) else { return } + favorite.folderId = folderId + favorite.updatedAt = Date() + _ = await manager.updateFavorite(favorite) + } + } + func deleteFavorites(_ favorites: [SQLFavorite]) { Task { await manager.deleteFavorites(ids: favorites.map(\.id)) diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index d46c2ab0..d4a59db3 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import UniformTypeIdentifiers /// Full-tab favorites view with folder hierarchy and bottom toolbar struct FavoritesTabView: View { @@ -77,6 +78,9 @@ struct FavoritesTabView: View { .onDeleteCommand { deleteSelectedFavorites() } + .onDrop(of: [.plainText], isTargeted: nil) { providers in + handleDrop(providers: providers, targetFolderId: nil) + } } /// Renders tree items with DisclosureGroup for folders. @@ -100,6 +104,9 @@ struct FavoritesTabView: View { coordinator: coordinator ) } + .onDrag { + NSItemProvider(object: favorite.id.uuidString as NSString) + } case .folder(let folder, let children): DisclosureGroup(isExpanded: Binding( get: { viewModel.expandedFolderIds.contains(folder.id) }, @@ -156,7 +163,24 @@ struct FavoritesTabView: View { } ) } + .onDrop(of: [.plainText], isTargeted: nil) { providers in + handleDrop(providers: providers, targetFolderId: folder.id) + } + } + } + + private func handleDrop(providers: [NSItemProvider], targetFolderId: UUID?) -> Bool { + guard let provider = providers.first else { return false } + provider.loadObject(ofClass: NSString.self) { object, _ in + guard let idString = object as? String, let favoriteId = UUID(uuidString: idString) else { return } + Task { @MainActor in + viewModel.moveFavorite(id: favoriteId, toFolder: targetFolderId) + if let targetFolderId { + viewModel.expandedFolderIds.insert(targetFolderId) + } + } } + return true } private func deleteSelectedFavorites() { From 5b4df29ee9cdac44025b9fa85828ef40436f106f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 16:12:51 +0700 Subject: [PATCH 13/20] fix: replace janky drag-drop with native 'Move to' context menu submenu --- TablePro/Views/Sidebar/FavoritesTabView.swift | 62 ++++++++++++------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index d4a59db3..f1862edd 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -6,7 +6,6 @@ // import SwiftUI -import UniformTypeIdentifiers /// Full-tab favorites view with folder hierarchy and bottom toolbar struct FavoritesTabView: View { @@ -78,9 +77,6 @@ struct FavoritesTabView: View { .onDeleteCommand { deleteSelectedFavorites() } - .onDrop(of: [.plainText], isTargeted: nil) { providers in - handleDrop(providers: providers, targetFolderId: nil) - } } /// Renders tree items with DisclosureGroup for folders. @@ -104,9 +100,6 @@ struct FavoritesTabView: View { coordinator: coordinator ) } - .onDrag { - NSItemProvider(object: favorite.id.uuidString as NSString) - } case .folder(let folder, let children): DisclosureGroup(isExpanded: Binding( get: { viewModel.expandedFolderIds.contains(folder.id) }, @@ -163,24 +156,7 @@ struct FavoritesTabView: View { } ) } - .onDrop(of: [.plainText], isTargeted: nil) { providers in - handleDrop(providers: providers, targetFolderId: folder.id) - } - } - } - - private func handleDrop(providers: [NSItemProvider], targetFolderId: UUID?) -> Bool { - guard let provider = providers.first else { return false } - provider.loadObject(ofClass: NSString.self) { object, _ in - guard let idString = object as? String, let favoriteId = UUID(uuidString: idString) else { return } - Task { @MainActor in - viewModel.moveFavorite(id: favoriteId, toFolder: targetFolderId) - if let targetFolderId { - viewModel.expandedFolderIds.insert(targetFolderId) - } - } } - return true } private func deleteSelectedFavorites() { @@ -282,6 +258,10 @@ private struct FavoriteItemContextMenu: View { let viewModel: FavoritesSidebarViewModel weak var coordinator: MainContentCoordinator? + private var folders: [SQLFavoriteFolder] { + collectFolders(from: viewModel.treeItems) + } + var body: some View { Button(String(localized: "Edit...")) { viewModel.editFavorite(favorite) @@ -306,6 +286,29 @@ private struct FavoriteItemContextMenu: View { Label(String(localized: "Run in New Tab"), systemImage: "play") } + if !folders.isEmpty { + Divider() + + Menu(String(localized: "Move to")) { + if favorite.folderId != nil { + Button(String(localized: "Root Level")) { + viewModel.moveFavorite(id: favorite.id, toFolder: nil) + } + + Divider() + } + + ForEach(folders) { folder in + if folder.id != favorite.folderId { + Button(folder.name) { + viewModel.moveFavorite(id: favorite.id, toFolder: folder.id) + viewModel.expandedFolderIds.insert(folder.id) + } + } + } + } + } + Divider() Button(role: .destructive) { @@ -314,6 +317,17 @@ private struct FavoriteItemContextMenu: View { Label(String(localized: "Delete"), systemImage: "trash") } } + + private func collectFolders(from items: [FavoriteTreeItem]) -> [SQLFavoriteFolder] { + var result: [SQLFavoriteFolder] = [] + for item in items { + if case .folder(let folder, let children) = item { + result.append(folder) + result.append(contentsOf: collectFolders(from: children)) + } + } + return result + } } private struct FolderContextMenu: View { From 3ed1c55d8c08faf23c9b77650d410f37fb26fa94 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 16:18:28 +0700 Subject: [PATCH 14/20] fix: reuse current empty query tab instead of opening new tab for favorites --- .../Extensions/MainContentCoordinator+Favorites.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift index e4831e20..3c1c02fb 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift @@ -17,8 +17,16 @@ extension MainContentCoordinator { } } - /// Open a favorite's query in a new tab + /// Run a favorite's query: reuses the current tab if it's an empty query tab, + /// otherwise opens a new tab. func runFavoriteInNewTab(_ favorite: SQLFavorite) { + if let tabIndex = tabManager.selectedTabIndex, + tabManager.tabs[tabIndex].tabType == .query, + tabManager.tabs[tabIndex].query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + tabManager.tabs[tabIndex].query = favorite.query + return + } + let payload = EditorTabPayload( connectionId: connection.id, tabType: .query, From 89202b2dc1cc80ba105820380980ecc192017a59 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 16:21:32 +0700 Subject: [PATCH 15/20] fix: create tab inline when no tabs exist instead of opening new window --- .../MainContentCoordinator+Favorites.swift | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift index 3c1c02fb..58056675 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift @@ -7,8 +7,13 @@ import Foundation extension MainContentCoordinator { /// Insert a favorite's query into the current editor tab. - /// If the current tab is not a query tab, opens a new query tab instead. + /// Creates a new tab if none exists, or opens a new tab if current is not a query tab. func insertFavorite(_ favorite: SQLFavorite) { + if tabManager.tabs.isEmpty { + tabManager.addTab(initialQuery: favorite.query) + return + } + if let tabIndex = tabManager.selectedTabIndex, tabManager.tabs[tabIndex].tabType == .query { tabManager.tabs[tabIndex].query = favorite.query @@ -17,9 +22,13 @@ extension MainContentCoordinator { } } - /// Run a favorite's query: reuses the current tab if it's an empty query tab, - /// otherwise opens a new tab. + /// Run a favorite's query: uses current tab if empty, otherwise opens a new tab. func runFavoriteInNewTab(_ favorite: SQLFavorite) { + if tabManager.tabs.isEmpty { + tabManager.addTab(initialQuery: favorite.query) + return + } + if let tabIndex = tabManager.selectedTabIndex, tabManager.tabs[tabIndex].tabType == .query, tabManager.tabs[tabIndex].query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { From 06ab215df234910f3f59d0e5a167fc295a61f464 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 16:24:33 +0700 Subject: [PATCH 16/20] fix: append favorite query to editor instead of replacing existing content --- .../Extensions/MainContentCoordinator+Favorites.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift index 58056675..bd7f1ce0 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift @@ -16,7 +16,13 @@ extension MainContentCoordinator { if let tabIndex = tabManager.selectedTabIndex, tabManager.tabs[tabIndex].tabType == .query { - tabManager.tabs[tabIndex].query = favorite.query + let existing = tabManager.tabs[tabIndex].query + .trimmingCharacters(in: .whitespacesAndNewlines) + if existing.isEmpty { + tabManager.tabs[tabIndex].query = favorite.query + } else { + tabManager.tabs[tabIndex].query += "\n\n" + favorite.query + } } else { runFavoriteInNewTab(favorite) } From 58644e2622eeb70cbdeac09b893d4f3ccca4b4ec Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 16:30:01 +0700 Subject: [PATCH 17/20] fix: preserve trailing semicolons in SQL statements for history and favorites --- .../Utilities/SQL/SQLStatementScanner.swift | 19 +++++-------- .../Utilities/SQLStatementScannerTests.swift | 28 +++++++++---------- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/TablePro/Core/Utilities/SQL/SQLStatementScanner.swift b/TablePro/Core/Utilities/SQL/SQLStatementScanner.swift index 12222443..ee40a443 100644 --- a/TablePro/Core/Utilities/SQL/SQLStatementScanner.swift +++ b/TablePro/Core/Utilities/SQL/SQLStatementScanner.swift @@ -14,12 +14,12 @@ enum SQLStatementScanner { static func allStatements(in sql: String) -> [String] { var results: [String] = [] scan(sql: sql, cursorPosition: nil) { rawSQL, _ in - var trimmed = rawSQL.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.hasSuffix(";") { - trimmed = String(trimmed.dropLast()) - .trimmingCharacters(in: .whitespacesAndNewlines) - } - if !trimmed.isEmpty { + let trimmed = rawSQL.trimmingCharacters(in: .whitespacesAndNewlines) + // Skip empty statements (bare semicolons, whitespace-only) + let withoutSemicolon = trimmed.hasSuffix(";") + ? String(trimmed.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) + : trimmed + if !withoutSemicolon.isEmpty { results.append(trimmed) } return true @@ -28,14 +28,9 @@ enum SQLStatementScanner { } static func statementAtCursor(in sql: String, cursorPosition: Int) -> String { - var result = locatedStatementAtCursor(in: sql, cursorPosition: cursorPosition) + locatedStatementAtCursor(in: sql, cursorPosition: cursorPosition) .sql .trimmingCharacters(in: .whitespacesAndNewlines) - if result.hasSuffix(";") { - result = String(result.dropLast()) - .trimmingCharacters(in: .whitespacesAndNewlines) - } - return result } static func locatedStatementAtCursor(in sql: String, cursorPosition: Int) -> LocatedStatement { diff --git a/TableProTests/Core/Utilities/SQLStatementScannerTests.swift b/TableProTests/Core/Utilities/SQLStatementScannerTests.swift index f0eed725..6a41dd8b 100644 --- a/TableProTests/Core/Utilities/SQLStatementScannerTests.swift +++ b/TableProTests/Core/Utilities/SQLStatementScannerTests.swift @@ -23,7 +23,7 @@ final class SQLStatementScannerTests: XCTestCase { func testSingleStatementWithTrailingSemicolon() { XCTAssertEqual( SQLStatementScanner.allStatements(in: "SELECT 1;"), - ["SELECT 1"] + ["SELECT 1;"] ) } @@ -31,7 +31,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT 1; SELECT 2; SELECT 3" XCTAssertEqual( SQLStatementScanner.allStatements(in: sql), - ["SELECT 1", "SELECT 2", "SELECT 3"] + ["SELECT 1;", "SELECT 2;", "SELECT 3"] ) } @@ -39,7 +39,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT 'a;b'; SELECT 2" XCTAssertEqual( SQLStatementScanner.allStatements(in: sql), - ["SELECT 'a;b'", "SELECT 2"] + ["SELECT 'a;b';", "SELECT 2"] ) } @@ -47,7 +47,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT \"a;b\"; SELECT 2" XCTAssertEqual( SQLStatementScanner.allStatements(in: sql), - ["SELECT \"a;b\"", "SELECT 2"] + ["SELECT \"a;b\";", "SELECT 2"] ) } @@ -55,7 +55,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT `a;b`; SELECT 2" XCTAssertEqual( SQLStatementScanner.allStatements(in: sql), - ["SELECT `a;b`", "SELECT 2"] + ["SELECT `a;b`;", "SELECT 2"] ) } @@ -63,7 +63,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT 1 -- comment; still comment\n; SELECT 2" XCTAssertEqual( SQLStatementScanner.allStatements(in: sql), - ["SELECT 1 -- comment; still comment", "SELECT 2"] + ["SELECT 1 -- comment; still comment\n;", "SELECT 2"] ) } @@ -71,7 +71,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT 1 /* comment; */ ; SELECT 2" XCTAssertEqual( SQLStatementScanner.allStatements(in: sql), - ["SELECT 1 /* comment; */", "SELECT 2"] + ["SELECT 1 /* comment; */ ;", "SELECT 2"] ) } @@ -79,7 +79,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT 'it\\'s'; SELECT 2" XCTAssertEqual( SQLStatementScanner.allStatements(in: sql), - ["SELECT 'it\\'s'", "SELECT 2"] + ["SELECT 'it\\'s';", "SELECT 2"] ) } @@ -87,7 +87,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT 'it''s'; SELECT 2" XCTAssertEqual( SQLStatementScanner.allStatements(in: sql), - ["SELECT 'it''s'", "SELECT 2"] + ["SELECT 'it''s';", "SELECT 2"] ) } @@ -95,7 +95,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT 1; ; \n ; SELECT 2" XCTAssertEqual( SQLStatementScanner.allStatements(in: sql), - ["SELECT 1", "SELECT 2"] + ["SELECT 1;", "SELECT 2"] ) } @@ -104,7 +104,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT 1 /* outer /* inner */ ; SELECT 2" XCTAssertEqual( SQLStatementScanner.allStatements(in: sql), - ["SELECT 1 /* outer /* inner */", "SELECT 2"] + ["SELECT 1 /* outer /* inner */ ;", "SELECT 2"] ) } @@ -114,7 +114,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT 1; SELECT 2" XCTAssertEqual( SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 3), - "SELECT 1" + "SELECT 1;" ) } @@ -140,7 +140,7 @@ final class SQLStatementScannerTests: XCTestCase { // cursor at position 8 (the ';') should belong to first statement XCTAssertEqual( SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 8), - "SELECT 1" + "SELECT 1;" ) } @@ -148,7 +148,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT 1; SELECT 2" XCTAssertEqual( SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 0), - "SELECT 1" + "SELECT 1;" ) } From f4a25eb2fa81b9f1dfa88eca7a5e13d3cdbc64ad Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 16:37:06 +0700 Subject: [PATCH 18/20] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20access=20control,=20validation,=20accessibility,=20thread=20?= =?UTF-8?q?safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TablePro/ContentView.swift | 8 ------ TablePro/Core/SSH/LibSSH2Tunnel.swift | 8 +++--- .../Core/Storage/SQLFavoriteManager.swift | 2 +- .../Core/Storage/SQLFavoriteStorage.swift | 26 ++++++++++++++---- TablePro/Models/Query/SQLFavorite.swift | 11 ++++---- TablePro/Models/Query/SQLFavoriteFolder.swift | 11 ++++---- TablePro/Models/UI/SharedSidebarState.swift | 2 +- TablePro/Resources/Localizable.xcstrings | 27 ++++++++++++++++--- .../FavoritesSidebarViewModel.swift | 8 +++--- .../Components/ConflictResolutionView.swift | 2 +- TablePro/Views/Editor/HistoryPanelView.swift | 5 ++-- .../Views/Editor/SQLCompletionAdapter.swift | 3 +++ .../Main/Child/MainEditorContentView.swift | 2 +- .../Views/Sidebar/FavoriteEditDialog.swift | 24 ++++++++++++----- TablePro/Views/Sidebar/FavoriteRowView.swift | 2 +- TablePro/Views/Sidebar/FavoritesTabView.swift | 8 ++++-- 16 files changed, 101 insertions(+), 48 deletions(-) diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 769bd987..ec5d2e4e 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -355,14 +355,6 @@ struct ContentView: View { ) } - private func sidebarTabBinding(for connectionId: UUID) -> Binding { - let state = SharedSidebarState.forConnection(connectionId) - return Binding( - get: { state.selectedSidebarTab }, - set: { state.selectedSidebarTab = $0 } - ) - } - private func sidebarSearchPrompt(for connectionId: UUID) -> String { let state = SharedSidebarState.forConnection(connectionId) switch state.selectedSidebarTab { diff --git a/TablePro/Core/SSH/LibSSH2Tunnel.swift b/TablePro/Core/SSH/LibSSH2Tunnel.swift index 293cd382..ac84339c 100644 --- a/TablePro/Core/SSH/LibSSH2Tunnel.swift +++ b/TablePro/Core/SSH/LibSSH2Tunnel.swift @@ -34,8 +34,7 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { /// Keeps blocking work off the Swift cooperative thread pool. private static let relayQueue = DispatchQueue( label: "com.TablePro.ssh.relay", - qos: .utility, - attributes: .concurrent + qos: .utility ) /// Callback invoked when the tunnel dies (keep-alive failure, etc.) @@ -308,7 +307,10 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { } } - relayTasks.withLock { $0.append(task) } + relayTasks.withLock { tasks in + tasks.removeAll { $0.isCancelled } + tasks.append(task) + } } /// Blocking relay loop — must only be called on `relayQueue`, never the cooperative pool. diff --git a/TablePro/Core/Storage/SQLFavoriteManager.swift b/TablePro/Core/Storage/SQLFavoriteManager.swift index 5adf5982..653f4757 100644 --- a/TablePro/Core/Storage/SQLFavoriteManager.swift +++ b/TablePro/Core/Storage/SQLFavoriteManager.swift @@ -7,7 +7,7 @@ import Foundation import os /// Manages SQL favorites with notifications and sync tracking -final class SQLFavoriteManager { +internal final class SQLFavoriteManager { static let shared = SQLFavoriteManager() private static let logger = Logger(subsystem: "com.TablePro", category: "SQLFavoriteManager") diff --git a/TablePro/Core/Storage/SQLFavoriteStorage.swift b/TablePro/Core/Storage/SQLFavoriteStorage.swift index b68dbcff..d5c8fcca 100644 --- a/TablePro/Core/Storage/SQLFavoriteStorage.swift +++ b/TablePro/Core/Storage/SQLFavoriteStorage.swift @@ -8,7 +8,7 @@ import os import SQLite3 /// Thread-safe SQLite storage for SQL favorites with FTS5 full-text search -final class SQLFavoriteStorage { +internal final class SQLFavoriteStorage { static let shared = SQLFavoriteStorage() private static let logger = Logger(subsystem: "com.TablePro", category: "SQLFavoriteStorage") @@ -564,9 +564,17 @@ final class SQLFavoriteStorage { sqlite3_bind_null(moveFavStatement, 1) } sqlite3_bind_text(moveFavStatement, 2, idString, -1, SQLITE_TRANSIENT) - sqlite3_step(moveFavStatement) + let moveFavResult = sqlite3_step(moveFavStatement) + sqlite3_finalize(moveFavStatement) + if moveFavResult != SQLITE_DONE { + if inTransaction { sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) } + return false + } + } else { + sqlite3_finalize(moveFavStatement) + if inTransaction { sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) } + return false } - sqlite3_finalize(moveFavStatement) // Move child subfolders to the parent folder let moveSubfoldersSQL = "UPDATE folders SET parent_id = ? WHERE parent_id = ?;" @@ -578,9 +586,17 @@ final class SQLFavoriteStorage { sqlite3_bind_null(moveSubStatement, 1) } sqlite3_bind_text(moveSubStatement, 2, idString, -1, SQLITE_TRANSIENT) - sqlite3_step(moveSubStatement) + let moveSubResult = sqlite3_step(moveSubStatement) + sqlite3_finalize(moveSubStatement) + if moveSubResult != SQLITE_DONE { + if inTransaction { sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) } + return false + } + } else { + sqlite3_finalize(moveSubStatement) + if inTransaction { sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) } + return false } - sqlite3_finalize(moveSubStatement) // Delete the folder let deleteSQL = "DELETE FROM folders WHERE id = ?;" diff --git a/TablePro/Models/Query/SQLFavorite.swift b/TablePro/Models/Query/SQLFavorite.swift index 991a907a..2689600c 100644 --- a/TablePro/Models/Query/SQLFavorite.swift +++ b/TablePro/Models/Query/SQLFavorite.swift @@ -6,7 +6,7 @@ import Foundation /// A saved SQL query that can be quickly recalled and optionally expanded via keyword -struct SQLFavorite: Identifiable, Codable, Hashable { +internal struct SQLFavorite: Identifiable, Codable, Hashable { let id: UUID var name: String var query: String @@ -25,9 +25,10 @@ struct SQLFavorite: Identifiable, Codable, Hashable { folderId: UUID? = nil, connectionId: UUID? = nil, sortOrder: Int = 0, - createdAt: Date = Date(), - updatedAt: Date = Date() + createdAt: Date? = nil, + updatedAt: Date? = nil ) { + let now = Date() self.id = id self.name = name self.query = query @@ -35,7 +36,7 @@ struct SQLFavorite: Identifiable, Codable, Hashable { self.folderId = folderId self.connectionId = connectionId self.sortOrder = sortOrder - self.createdAt = createdAt - self.updatedAt = updatedAt + self.createdAt = createdAt ?? now + self.updatedAt = updatedAt ?? now } } diff --git a/TablePro/Models/Query/SQLFavoriteFolder.swift b/TablePro/Models/Query/SQLFavoriteFolder.swift index ee090165..f95dd05e 100644 --- a/TablePro/Models/Query/SQLFavoriteFolder.swift +++ b/TablePro/Models/Query/SQLFavoriteFolder.swift @@ -6,7 +6,7 @@ import Foundation /// A folder for organizing SQL favorites into a hierarchy -struct SQLFavoriteFolder: Identifiable, Codable, Hashable { +internal struct SQLFavoriteFolder: Identifiable, Codable, Hashable { let id: UUID var name: String var parentId: UUID? @@ -21,15 +21,16 @@ struct SQLFavoriteFolder: Identifiable, Codable, Hashable { parentId: UUID? = nil, connectionId: UUID? = nil, sortOrder: Int = 0, - createdAt: Date = Date(), - updatedAt: Date = Date() + createdAt: Date? = nil, + updatedAt: Date? = nil ) { + let now = Date() self.id = id self.name = name self.parentId = parentId self.connectionId = connectionId self.sortOrder = sortOrder - self.createdAt = createdAt - self.updatedAt = updatedAt + self.createdAt = createdAt ?? now + self.updatedAt = updatedAt ?? now } } diff --git a/TablePro/Models/UI/SharedSidebarState.swift b/TablePro/Models/UI/SharedSidebarState.swift index 23cc5c4c..2666f1f4 100644 --- a/TablePro/Models/UI/SharedSidebarState.swift +++ b/TablePro/Models/UI/SharedSidebarState.swift @@ -9,7 +9,7 @@ import Foundation /// Which sidebar tab is active -enum SidebarTab: String, CaseIterable { +internal enum SidebarTab: String, CaseIterable { case tables case favorites } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index f86ac142..e2830376 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -6034,6 +6034,9 @@ }, "Delete Folder" : { + }, + "Delete Folder?" : { + }, "Delete Foreign Key" : { "extractionState" : "stale", @@ -8387,6 +8390,9 @@ }, "Focus Border" : { + }, + "Folder name" : { + }, "Font" : { "extractionState" : "stale", @@ -10016,6 +10022,9 @@ }, "Keyword:" : { + }, + "keyword: %@" : { + }, "Language:" : { "localizations" : { @@ -10808,6 +10817,9 @@ } } } + }, + "Move to" : { + }, "Move Up" : { "extractionState" : "stale", @@ -14928,6 +14940,9 @@ } } } + }, + "Root Level" : { + }, "Row %lld" : { "localizations" : { @@ -16067,6 +16082,9 @@ }, "Settings:" : { + }, + "Shadows the SQL keyword '%@'" : { + }, "Share anonymous usage data" : { "localizations" : { @@ -17735,6 +17753,9 @@ } } } + }, + "The folder \"%@\" will be deleted. Items inside will be moved to the parent level." : { + }, "The following %lld queries may permanently modify or delete data. This action cannot be undone.\n\n%@" : { "localizations" : { @@ -19468,9 +19489,6 @@ } } } - }, - "Warning: this shadows the SQL keyword '%@'" : { - }, "Website" : { "localizations" : { @@ -19553,6 +19571,9 @@ } } } + }, + "When enabled, this favorite is visible in all connections" : { + }, "When TablePro starts:" : { "localizations" : { diff --git a/TablePro/ViewModels/FavoritesSidebarViewModel.swift b/TablePro/ViewModels/FavoritesSidebarViewModel.swift index d0c5e8c8..aea08cb4 100644 --- a/TablePro/ViewModels/FavoritesSidebarViewModel.swift +++ b/TablePro/ViewModels/FavoritesSidebarViewModel.swift @@ -7,7 +7,7 @@ import Foundation import Observation /// Tree node for displaying favorites and folders in a hierarchy -enum FavoriteTreeItem: Identifiable, Hashable { +internal enum FavoriteTreeItem: Identifiable, Hashable { case folder(SQLFavoriteFolder, children: [FavoriteTreeItem]) case favorite(SQLFavorite) @@ -21,7 +21,7 @@ enum FavoriteTreeItem: Identifiable, Hashable { /// ViewModel for the favorites sidebar section @MainActor @Observable -final class FavoritesSidebarViewModel { +internal final class FavoritesSidebarViewModel { // MARK: - State var treeItems: [FavoriteTreeItem] = [] @@ -86,7 +86,7 @@ final class FavoritesSidebarViewModel { let levelFolders = folders .filter { $0.parentId == parentId } - .sorted { $0.sortOrder < $1.sortOrder } + .sorted { $0.sortOrder != $1.sortOrder ? $0.sortOrder < $1.sortOrder : $0.name.localizedStandardCompare($1.name) == .orderedAscending } for folder in levelFolders { let children = buildTree(folders: folders, favorites: favorites, parentId: folder.id) @@ -95,7 +95,7 @@ final class FavoritesSidebarViewModel { let levelFavorites = favorites .filter { $0.folderId == parentId } - .sorted { $0.sortOrder < $1.sortOrder } + .sorted { $0.sortOrder != $1.sortOrder ? $0.sortOrder < $1.sortOrder : $0.name.localizedStandardCompare($1.name) == .orderedAscending } for fav in levelFavorites { items.append(.favorite(fav)) diff --git a/TablePro/Views/Components/ConflictResolutionView.swift b/TablePro/Views/Components/ConflictResolutionView.swift index 55f7c5d6..867edf00 100644 --- a/TablePro/Views/Components/ConflictResolutionView.swift +++ b/TablePro/Views/Components/ConflictResolutionView.swift @@ -140,7 +140,7 @@ struct ConflictResolutionView: View { } case .favorite, .favoriteFolder: if let name = record["name"] as? String { - fieldRow(label: "Name", value: name) + fieldRow(label: String(localized: "Name"), value: name) } } } diff --git a/TablePro/Views/Editor/HistoryPanelView.swift b/TablePro/Views/Editor/HistoryPanelView.swift index 6ecbeea7..062b9d0b 100644 --- a/TablePro/Views/Editor/HistoryPanelView.swift +++ b/TablePro/Views/Editor/HistoryPanelView.swift @@ -11,6 +11,7 @@ import SwiftUI /// Query history panel with master-detail layout struct HistoryPanelView: View { + let connectionId: UUID // MARK: - State @State private var selectedEntryID: UUID? @@ -52,7 +53,7 @@ struct HistoryPanelView: View { } .sheet(item: $favoriteDialogQuery) { item in FavoriteEditDialog( - connectionId: UUID(), + connectionId: connectionId, favorite: nil, initialQuery: item.query, forceGlobal: true @@ -446,7 +447,7 @@ private struct HistoryRowSwiftUI: View { #if DEBUG struct HistoryPanelView_Previews: PreviewProvider { static var previews: some View { - HistoryPanelView() + HistoryPanelView(connectionId: UUID()) .frame(width: 600, height: 300) } } diff --git a/TablePro/Views/Editor/SQLCompletionAdapter.swift b/TablePro/Views/Editor/SQLCompletionAdapter.swift index 4179a822..b9c04e55 100644 --- a/TablePro/Views/Editor/SQLCompletionAdapter.swift +++ b/TablePro/Views/Editor/SQLCompletionAdapter.swift @@ -16,6 +16,7 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { // MARK: - Properties private var completionEngine: CompletionEngine? + private var favoriteKeywords: [String: (name: String, query: String)] = [:] private var suppressNextCompletion = false private var currentCompletionContext: CompletionContext? private var debounceGeneration: UInt64 = 0 @@ -42,10 +43,12 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { schemaProvider: provider, databaseType: databaseType, dialect: dialect, statementCompletions: completions ) + completionEngine?.updateFavoriteKeywords(favoriteKeywords) } /// Update favorite keywords for autocomplete expansion func updateFavoriteKeywords(_ keywords: [String: (name: String, query: String)]) { + favoriteKeywords = keywords completionEngine?.updateFavoriteKeywords(keywords) } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 4a809b8c..beb5538c 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -101,7 +101,7 @@ struct MainEditorContentView: View { // Global History Panel if isHistoryVisible { Divider() - HistoryPanelView() + HistoryPanelView(connectionId: connectionId) .frame(height: 300) .transition(.move(edge: .bottom).combined(with: .opacity)) } diff --git a/TablePro/Views/Sidebar/FavoriteEditDialog.swift b/TablePro/Views/Sidebar/FavoriteEditDialog.swift index e66fe111..b7229f2d 100644 --- a/TablePro/Views/Sidebar/FavoriteEditDialog.swift +++ b/TablePro/Views/Sidebar/FavoriteEditDialog.swift @@ -6,13 +6,13 @@ import SwiftUI /// Wrapper for `.sheet(item:)` to ensure the query is passed reliably -struct FavoriteDialogQuery: Identifiable { +internal struct FavoriteDialogQuery: Identifiable { let id = UUID() let query: String } /// Dialog for creating or editing a SQL favorite -struct FavoriteEditDialog: View { +internal struct FavoriteEditDialog: View { @Environment(\.dismiss) private var dismiss let connectionId: UUID @@ -28,12 +28,13 @@ struct FavoriteEditDialog: View { @State private var keywordError: String? @State private var isKeywordWarning = false @State private var isSaving = false + @State private var validationId = 0 private var isEditing: Bool { favorite != nil } private var isValid: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty && !query.trimmingCharacters(in: .whitespaces).isEmpty && - keywordError == nil + (keywordError == nil || isKeywordWarning) } private static let maxQuerySize = 500_000 @@ -86,6 +87,9 @@ struct FavoriteEditDialog: View { if !forceGlobal { Toggle("Global:", isOn: $isGlobal) .help(String(localized: "When enabled, this favorite is visible in all connections")) + .onChange(of: isGlobal) { + validateKeyword(keyword) + } } } .formStyle(.columns) @@ -134,6 +138,8 @@ struct FavoriteEditDialog: View { keywordError = String(localized: "Keyword cannot contain spaces") return } + validationId += 1 + let currentId = validationId Task { @MainActor in let scopeConnectionId = isGlobal ? nil : connectionId let available = await SQLFavoriteManager.shared.isKeywordAvailable( @@ -141,6 +147,7 @@ struct FavoriteEditDialog: View { connectionId: scopeConnectionId, excludingFavoriteId: favorite?.id ) + guard currentId == validationId else { return } if !available { isKeywordWarning = false keywordError = String(localized: "This keyword is already in use") @@ -182,6 +189,7 @@ struct FavoriteEditDialog: View { let keywordValue = trimmedKeyword.isEmpty ? nil : trimmedKeyword Task { @MainActor in + let success: Bool if let existing = favorite { var updated = existing updated.name = trimmedName @@ -189,7 +197,7 @@ struct FavoriteEditDialog: View { updated.keyword = keywordValue updated.connectionId = scopeConnectionId updated.updatedAt = Date() - _ = await SQLFavoriteManager.shared.updateFavorite(updated) + success = await SQLFavoriteManager.shared.updateFavorite(updated) } else { let newFavorite = SQLFavorite( name: trimmedName, @@ -198,9 +206,13 @@ struct FavoriteEditDialog: View { folderId: folderId, connectionId: scopeConnectionId ) - _ = await SQLFavoriteManager.shared.addFavorite(newFavorite) + success = await SQLFavoriteManager.shared.addFavorite(newFavorite) + } + if success { + dismiss() + } else { + isSaving = false } - dismiss() } } } diff --git a/TablePro/Views/Sidebar/FavoriteRowView.swift b/TablePro/Views/Sidebar/FavoriteRowView.swift index e788b590..7c478d1e 100644 --- a/TablePro/Views/Sidebar/FavoriteRowView.swift +++ b/TablePro/Views/Sidebar/FavoriteRowView.swift @@ -6,7 +6,7 @@ import SwiftUI /// Row view for a single SQL favorite in the sidebar -struct FavoriteRowView: View { +internal struct FavoriteRowView: View { let favorite: SQLFavorite var body: some View { diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index f1862edd..e5262c70 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -8,16 +8,18 @@ import SwiftUI /// Full-tab favorites view with folder hierarchy and bottom toolbar -struct FavoritesTabView: View { +internal struct FavoritesTabView: View { @State private var viewModel: FavoritesSidebarViewModel @State private var selectedFavoriteIds: Set = [] @State private var folderToDelete: SQLFavoriteFolder? @State private var showDeleteFolderAlert = false @FocusState private var isRenameFocused: Bool + let connectionId: UUID let searchText: String private weak var coordinator: MainContentCoordinator? init(connectionId: UUID, searchText: String, coordinator: MainContentCoordinator?) { + self.connectionId = connectionId _viewModel = State(wrappedValue: FavoritesSidebarViewModel(connectionId: connectionId)) self.searchText = searchText self.coordinator = coordinator @@ -46,7 +48,7 @@ struct FavoritesTabView: View { } .sheet(isPresented: $viewModel.showEditDialog) { FavoriteEditDialog( - connectionId: coordinator?.connectionId ?? UUID(), + connectionId: connectionId, favorite: viewModel.editingFavorite, initialQuery: viewModel.editingQuery, folderId: viewModel.editingFolderId @@ -133,6 +135,7 @@ struct FavoritesTabView: View { ) ) .textFieldStyle(.roundedBorder) + .accessibilityLabel(String(localized: "Folder name")) .focused($isRenameFocused) .onSubmit { viewModel.commitRenameFolder(folder) @@ -245,6 +248,7 @@ struct FavoritesTabView: View { } .buttonStyle(.borderless) .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .accessibilityLabel(String(localized: "New Folder")) } .padding(.horizontal, 12) .padding(.vertical, 6) From 3f6a45fcfbb0e1a797b3ee4a92e9f130e58e6aee Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 16:49:26 +0700 Subject: [PATCH 19/20] =?UTF-8?q?fix:=20critical=20review=20issues=20?= =?UTF-8?q?=E2=80=94=20semicolon=20regression,=20keyword=20scope=20SQL,=20?= =?UTF-8?q?transaction=20safety,=20sheet=20identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Storage/SQLFavoriteStorage.swift | 59 +++++++++++-------- .../Utilities/SQL/SQLStatementScanner.swift | 26 +++++++- .../FavoritesSidebarViewModel.swift | 19 +++--- ...ainContentCoordinator+MultiStatement.swift | 9 +-- .../MainContentCoordinator+SaveChanges.swift | 3 +- TablePro/Views/Sidebar/FavoritesTabView.swift | 12 ++-- .../Utilities/SQLStatementScannerTests.swift | 48 ++++++++++----- 7 files changed, 114 insertions(+), 62 deletions(-) diff --git a/TablePro/Core/Storage/SQLFavoriteStorage.swift b/TablePro/Core/Storage/SQLFavoriteStorage.swift index d5c8fcca..174f6123 100644 --- a/TablePro/Core/Storage/SQLFavoriteStorage.swift +++ b/TablePro/Core/Storage/SQLFavoriteStorage.swift @@ -534,7 +534,9 @@ internal final class SQLFavoriteStorage { return await performDatabaseWork { [weak self] in guard let self = self else { return false } - let inTransaction = sqlite3_exec(self.db, "BEGIN IMMEDIATE;", nil, nil, nil) == SQLITE_OK + guard sqlite3_exec(self.db, "BEGIN IMMEDIATE;", nil, nil, nil) == SQLITE_OK else { + return false + } let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) @@ -542,7 +544,7 @@ internal final class SQLFavoriteStorage { let findParentSQL = "SELECT parent_id FROM folders WHERE id = ?;" var findStatement: OpaquePointer? guard sqlite3_prepare_v2(self.db, findParentSQL, -1, &findStatement, nil) == SQLITE_OK else { - if inTransaction { sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) } + sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) return false } @@ -567,12 +569,12 @@ internal final class SQLFavoriteStorage { let moveFavResult = sqlite3_step(moveFavStatement) sqlite3_finalize(moveFavStatement) if moveFavResult != SQLITE_DONE { - if inTransaction { sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) } + sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) return false } } else { sqlite3_finalize(moveFavStatement) - if inTransaction { sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) } + sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) return false } @@ -589,12 +591,12 @@ internal final class SQLFavoriteStorage { let moveSubResult = sqlite3_step(moveSubStatement) sqlite3_finalize(moveSubStatement) if moveSubResult != SQLITE_DONE { - if inTransaction { sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) } + sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) return false } } else { sqlite3_finalize(moveSubStatement) - if inTransaction { sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) } + sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) return false } @@ -602,7 +604,7 @@ internal final class SQLFavoriteStorage { let deleteSQL = "DELETE FROM folders WHERE id = ?;" var deleteStatement: OpaquePointer? guard sqlite3_prepare_v2(self.db, deleteSQL, -1, &deleteStatement, nil) == SQLITE_OK else { - if inTransaction { sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) } + sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) return false } @@ -610,12 +612,10 @@ internal final class SQLFavoriteStorage { let result = sqlite3_step(deleteStatement) sqlite3_finalize(deleteStatement) - if inTransaction { - if result == SQLITE_DONE { - sqlite3_exec(self.db, "COMMIT;", nil, nil, nil) - } else { - sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) - } + if result == SQLITE_DONE { + sqlite3_exec(self.db, "COMMIT;", nil, nil, nil) + } else { + sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) } return result == SQLITE_DONE @@ -719,11 +719,22 @@ internal final class SQLFavoriteStorage { return await performDatabaseWork { [weak self] in guard let self = self else { return false } - var sql = """ - SELECT COUNT(*) FROM favorites - WHERE keyword = ? - AND (connection_id IS NULL OR connection_id = ? OR ? IS NULL) - """ + var sql: String + var bindIndex: Int32 = 1 + + if connectionIdString != nil { + sql = """ + SELECT COUNT(*) FROM favorites + WHERE keyword = ? + AND (connection_id IS NULL OR connection_id = ?) + """ + } else { + sql = """ + SELECT COUNT(*) FROM favorites + WHERE keyword = ? + AND connection_id IS NULL + """ + } if excludeIdString != nil { sql += " AND id != ?" @@ -740,18 +751,16 @@ internal final class SQLFavoriteStorage { let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) - sqlite3_bind_text(statement, 1, keyword, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(statement, bindIndex, keyword, -1, SQLITE_TRANSIENT) + bindIndex += 1 if let connId = connectionIdString { - sqlite3_bind_text(statement, 2, connId, -1, SQLITE_TRANSIENT) - sqlite3_bind_text(statement, 3, connId, -1, SQLITE_TRANSIENT) - } else { - sqlite3_bind_null(statement, 2) - sqlite3_bind_null(statement, 3) + sqlite3_bind_text(statement, bindIndex, connId, -1, SQLITE_TRANSIENT) + bindIndex += 1 } if let excludeId = excludeIdString { - sqlite3_bind_text(statement, 4, excludeId, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(statement, bindIndex, excludeId, -1, SQLITE_TRANSIENT) } if sqlite3_step(statement) == SQLITE_ROW { diff --git a/TablePro/Core/Utilities/SQL/SQLStatementScanner.swift b/TablePro/Core/Utilities/SQL/SQLStatementScanner.swift index ee40a443..46f7f7ef 100644 --- a/TablePro/Core/Utilities/SQL/SQLStatementScanner.swift +++ b/TablePro/Core/Utilities/SQL/SQLStatementScanner.swift @@ -11,11 +11,28 @@ enum SQLStatementScanner { let offset: Int } + /// Returns statements with trailing semicolons stripped — for driver execution. static func allStatements(in sql: String) -> [String] { + var results: [String] = [] + scan(sql: sql, cursorPosition: nil) { rawSQL, _ in + var trimmed = rawSQL.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasSuffix(";") { + trimmed = String(trimmed.dropLast()) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + if !trimmed.isEmpty { + results.append(trimmed) + } + return true + } + return results + } + + /// Returns statements preserving trailing semicolons — for display/history/favorites. + static func allStatementsPreservingSemicolons(in sql: String) -> [String] { var results: [String] = [] scan(sql: sql, cursorPosition: nil) { rawSQL, _ in let trimmed = rawSQL.trimmingCharacters(in: .whitespacesAndNewlines) - // Skip empty statements (bare semicolons, whitespace-only) let withoutSemicolon = trimmed.hasSuffix(";") ? String(trimmed.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) : trimmed @@ -28,9 +45,14 @@ enum SQLStatementScanner { } static func statementAtCursor(in sql: String, cursorPosition: Int) -> String { - locatedStatementAtCursor(in: sql, cursorPosition: cursorPosition) + var result = locatedStatementAtCursor(in: sql, cursorPosition: cursorPosition) .sql .trimmingCharacters(in: .whitespacesAndNewlines) + if result.hasSuffix(";") { + result = String(result.dropLast()) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + return result } static func locatedStatementAtCursor(in sql: String, cursorPosition: Int) -> LocatedStatement { diff --git a/TablePro/ViewModels/FavoritesSidebarViewModel.swift b/TablePro/ViewModels/FavoritesSidebarViewModel.swift index aea08cb4..c9fb1cca 100644 --- a/TablePro/ViewModels/FavoritesSidebarViewModel.swift +++ b/TablePro/ViewModels/FavoritesSidebarViewModel.swift @@ -6,6 +6,14 @@ import Foundation import Observation +/// Identity wrapper for presenting the favorite edit dialog via `.sheet(item:)` +internal struct FavoriteEditItem: Identifiable { + let id = UUID() + let favorite: SQLFavorite? + let query: String? + let folderId: UUID? +} + /// Tree node for displaying favorites and folders in a hierarchy internal enum FavoriteTreeItem: Identifiable, Hashable { case folder(SQLFavoriteFolder, children: [FavoriteTreeItem]) @@ -26,7 +34,7 @@ internal final class FavoritesSidebarViewModel { var treeItems: [FavoriteTreeItem] = [] var isLoading = false - var showEditDialog = false + var editDialogItem: FavoriteEditItem? var editingFavorite: SQLFavorite? var editingQuery: String? var editingFolderId: UUID? @@ -110,16 +118,11 @@ internal final class FavoritesSidebarViewModel { if let folderId { expandedFolderIds.insert(folderId) } - editingFavorite = nil - editingQuery = query - editingFolderId = folderId - showEditDialog = true + editDialogItem = FavoriteEditItem(favorite: nil, query: query, folderId: folderId) } func editFavorite(_ favorite: SQLFavorite) { - editingFavorite = favorite - editingQuery = nil - showEditDialog = true + editDialogItem = FavoriteEditItem(favorite: favorite, query: nil, folderId: favorite.folderId) } func deleteFavorite(_ favorite: SQLFavorite) { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift index d4a3e65e..a0e2a7b2 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift @@ -70,10 +70,11 @@ extension MainContentCoordinator { lastSelectSQL = sql } - // Record each statement individually in query history + // Record with semicolon preserved for history/favorites + let historySQL = sql.hasSuffix(";") ? sql : sql + ";" await MainActor.run { QueryHistoryManager.shared.recordQuery( - query: sql, + query: historySQL, connectionId: conn.id, databaseName: conn.database, executionTime: result.executionTime, @@ -160,8 +161,8 @@ extension MainContentCoordinator { tabManager.tabs[idx] = errTab } - // Record only the failing statement in history - let recordSQL = failedSQL ?? statements[min(executedCount, totalCount - 1)] + let rawSQL = failedSQL ?? statements[min(executedCount, totalCount - 1)] + let recordSQL = rawSQL.hasSuffix(";") ? rawSQL : rawSQL + ";" QueryHistoryManager.shared.recordQuery( query: recordSQL, connectionId: conn.id, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift index 98a1287b..aae7decf 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift @@ -200,8 +200,9 @@ extension MainContentCoordinator { let executionTime = Date().timeIntervalSince(statementStartTime) + let historySQL = statement.sql.trimmingCharacters(in: .whitespacesAndNewlines) QueryHistoryManager.shared.recordQuery( - query: statement.sql.trimmingCharacters(in: .whitespacesAndNewlines), + query: historySQL.hasSuffix(";") ? historySQL : historySQL + ";", connectionId: conn.id, databaseName: conn.database, executionTime: executionTime, diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index e5262c70..39161f1f 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -16,7 +16,7 @@ internal struct FavoritesTabView: View { @FocusState private var isRenameFocused: Bool let connectionId: UUID let searchText: String - private weak var coordinator: MainContentCoordinator? + private var coordinator: MainContentCoordinator? init(connectionId: UUID, searchText: String, coordinator: MainContentCoordinator?) { self.connectionId = connectionId @@ -46,12 +46,12 @@ internal struct FavoritesTabView: View { .onAppear { Task { await viewModel.loadFavorites() } } - .sheet(isPresented: $viewModel.showEditDialog) { + .sheet(item: $viewModel.editDialogItem) { item in FavoriteEditDialog( connectionId: connectionId, - favorite: viewModel.editingFavorite, - initialQuery: viewModel.editingQuery, - folderId: viewModel.editingFolderId + favorite: item.favorite, + initialQuery: item.query, + folderId: item.folderId ) } .alert( @@ -260,7 +260,7 @@ internal struct FavoritesTabView: View { private struct FavoriteItemContextMenu: View { let favorite: SQLFavorite let viewModel: FavoritesSidebarViewModel - weak var coordinator: MainContentCoordinator? + var coordinator: MainContentCoordinator? private var folders: [SQLFavoriteFolder] { collectFolders(from: viewModel.treeItems) diff --git a/TableProTests/Core/Utilities/SQLStatementScannerTests.swift b/TableProTests/Core/Utilities/SQLStatementScannerTests.swift index 6a41dd8b..18448ddb 100644 --- a/TableProTests/Core/Utilities/SQLStatementScannerTests.swift +++ b/TableProTests/Core/Utilities/SQLStatementScannerTests.swift @@ -23,7 +23,7 @@ final class SQLStatementScannerTests: XCTestCase { func testSingleStatementWithTrailingSemicolon() { XCTAssertEqual( SQLStatementScanner.allStatements(in: "SELECT 1;"), - ["SELECT 1;"] + ["SELECT 1"] ) } @@ -31,7 +31,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT 1; SELECT 2; SELECT 3" XCTAssertEqual( SQLStatementScanner.allStatements(in: sql), - ["SELECT 1;", "SELECT 2;", "SELECT 3"] + ["SELECT 1", "SELECT 2", "SELECT 3"] ) } @@ -39,7 +39,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT 'a;b'; SELECT 2" XCTAssertEqual( SQLStatementScanner.allStatements(in: sql), - ["SELECT 'a;b';", "SELECT 2"] + ["SELECT 'a;b'", "SELECT 2"] ) } @@ -47,7 +47,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT \"a;b\"; SELECT 2" XCTAssertEqual( SQLStatementScanner.allStatements(in: sql), - ["SELECT \"a;b\";", "SELECT 2"] + ["SELECT \"a;b\"", "SELECT 2"] ) } @@ -55,7 +55,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT `a;b`; SELECT 2" XCTAssertEqual( SQLStatementScanner.allStatements(in: sql), - ["SELECT `a;b`;", "SELECT 2"] + ["SELECT `a;b`", "SELECT 2"] ) } @@ -63,7 +63,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT 1 -- comment; still comment\n; SELECT 2" XCTAssertEqual( SQLStatementScanner.allStatements(in: sql), - ["SELECT 1 -- comment; still comment\n;", "SELECT 2"] + ["SELECT 1 -- comment; still comment", "SELECT 2"] ) } @@ -71,7 +71,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT 1 /* comment; */ ; SELECT 2" XCTAssertEqual( SQLStatementScanner.allStatements(in: sql), - ["SELECT 1 /* comment; */ ;", "SELECT 2"] + ["SELECT 1 /* comment; */", "SELECT 2"] ) } @@ -79,7 +79,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT 'it\\'s'; SELECT 2" XCTAssertEqual( SQLStatementScanner.allStatements(in: sql), - ["SELECT 'it\\'s';", "SELECT 2"] + ["SELECT 'it\\'s'", "SELECT 2"] ) } @@ -87,7 +87,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT 'it''s'; SELECT 2" XCTAssertEqual( SQLStatementScanner.allStatements(in: sql), - ["SELECT 'it''s';", "SELECT 2"] + ["SELECT 'it''s'", "SELECT 2"] ) } @@ -95,7 +95,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT 1; ; \n ; SELECT 2" XCTAssertEqual( SQLStatementScanner.allStatements(in: sql), - ["SELECT 1;", "SELECT 2"] + ["SELECT 1", "SELECT 2"] ) } @@ -104,7 +104,25 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT 1 /* outer /* inner */ ; SELECT 2" XCTAssertEqual( SQLStatementScanner.allStatements(in: sql), - ["SELECT 1 /* outer /* inner */ ;", "SELECT 2"] + ["SELECT 1 /* outer /* inner */", "SELECT 2"] + ) + } + + // MARK: - allStatementsPreservingSemicolons + + func testPreservingSemicolons() { + let sql = "SELECT 1; SELECT 2; SELECT 3" + XCTAssertEqual( + SQLStatementScanner.allStatementsPreservingSemicolons(in: sql), + ["SELECT 1;", "SELECT 2;", "SELECT 3"] + ) + } + + func testPreservingSemicolonsFiltersEmpty() { + let sql = "SELECT 1; ; \n ; SELECT 2" + XCTAssertEqual( + SQLStatementScanner.allStatementsPreservingSemicolons(in: sql), + ["SELECT 1;", "SELECT 2"] ) } @@ -114,13 +132,12 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT 1; SELECT 2" XCTAssertEqual( SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 3), - "SELECT 1;" + "SELECT 1" ) } func testCursorInSecondStatement() { let sql = "SELECT 1; SELECT 2" - // cursor at position 10 = 'S' of "SELECT 2" XCTAssertEqual( SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 10), "SELECT 2" @@ -137,10 +154,9 @@ final class SQLStatementScannerTests: XCTestCase { func testCursorAtSemicolon() { let sql = "SELECT 1; SELECT 2" - // cursor at position 8 (the ';') should belong to first statement XCTAssertEqual( SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 8), - "SELECT 1;" + "SELECT 1" ) } @@ -148,7 +164,7 @@ final class SQLStatementScannerTests: XCTestCase { let sql = "SELECT 1; SELECT 2" XCTAssertEqual( SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 0), - "SELECT 1;" + "SELECT 1" ) } From 024e249d0d2ea562fb3f71bf421d91ccc58c09fc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 16:58:15 +0700 Subject: [PATCH 20/20] =?UTF-8?q?docs:=20rewrite=20favorites=20docs=20?= =?UTF-8?q?=E2=80=94=20fix=20Vietnamese=20diacritics,=20add=20Chinese,=20u?= =?UTF-8?q?pdate=20for=20tab=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/docs.json | 1 + docs/features/sql-favorites.mdx | 56 +++++++++++++++----------- docs/vi/features/sql-favorites.mdx | 64 +++++++++++++++++------------- docs/zh/features/sql-favorites.mdx | 60 ++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 52 deletions(-) create mode 100644 docs/zh/features/sql-favorites.mdx diff --git a/docs/docs.json b/docs/docs.json index 83613019..74a901a2 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -256,6 +256,7 @@ "zh/features/change-tracking", "zh/features/tabs", "zh/features/import-export", + "zh/features/sql-favorites", "zh/features/query-history", "zh/features/ai-chat", "zh/features/keyboard-shortcuts", diff --git a/docs/features/sql-favorites.mdx b/docs/features/sql-favorites.mdx index 2c2c998d..0b096e7f 100644 --- a/docs/features/sql-favorites.mdx +++ b/docs/features/sql-favorites.mdx @@ -1,52 +1,60 @@ --- title: SQL Favorites -description: Save and organize frequently used queries with optional keyword bindings for quick recall +description: Save frequently used queries with optional keyword shortcuts for autocomplete expansion --- # SQL Favorites -Save SQL queries you use often as favorites. Organize them in folders, assign keyword shortcuts, and recall them from the sidebar or via autocomplete. +Save queries you run often as favorites. Organize them in folders, assign keyword shortcuts, and insert them from the sidebar or type a keyword to expand via autocomplete. -## Saving a Favorite +## Saving a favorite -There are several ways to save a query as a favorite: +Three ways to save a query: -- **Right-click in the editor** and select **Save as Favorite...** -- **Right-click a history entry** in the Query History panel and select **Save as Favorite** -- Click the **+** button in the **Favorites** sidebar section header +- **Right-click in the editor** and pick **Save as Favorite...** +- **Right-click a history entry** in the Query History panel and pick **Save as Favorite** +- Click **+ New Favorite** in the Favorites sidebar tab -The save dialog lets you set: +The dialog has four fields: | Field | Description | |-------|-------------| -| Name | A descriptive name for the query | -| Keyword | Optional short alias for autocomplete expansion | -| Query | The SQL query text | -| Global | When enabled, the favorite is visible in all connections | +| Name | A short name for the query | +| Keyword | Optional alias that expands to the full query via autocomplete | +| Query | The SQL text | +| Global | When on, the favorite shows in every connection | -## Sidebar +## Sidebar tab -Favorites appear in a collapsible **Favorites** section at the top of the sidebar, above the tables list. The section supports: +The sidebar has a **Tables / Favorites** segmented control at the top. Switch to the **Favorites** tab to see your saved queries. -- **Folders** for organizing favorites into groups (with nesting) -- **Search** filtering alongside tables when you type in the sidebar search field -- **Double-click** a favorite to insert its query into the current editor -- **Context menu** with options to edit, copy, insert, run in a new tab, or delete +The Favorites tab supports: + +- **Folders** with nesting (right-click a folder to rename, add subfolders, or delete) +- **Search** that filters by name, keyword, and query text +- **Double-click** to insert the query into the current editor +- **Context menu** to edit, copy query, insert, run in a new tab, move to a folder, or delete +- **Multi-select** with Cmd+click, then Delete key to remove selected items ### Folders -Create folders from the sidebar section header context menu or from a folder's own context menu. Deleting a folder moves its children to the parent folder (or root level) rather than deleting them. +Create folders from the **+ folder** button at the bottom of the sidebar, or from a folder's context menu. Deleting a folder moves its children up to the parent level rather than deleting them. + +To move a favorite between folders, right-click it and use the **Move to** submenu. -## Keyword Expansion +## Keyword expansion -Assign a keyword to a favorite, and it becomes available as an autocomplete suggestion. Type the keyword in the editor and the autocomplete popup shows the favorite with a star icon. Selecting it replaces the keyword with the full query text. +Assign a keyword to a favorite and it appears as an autocomplete suggestion with a star icon. Selecting it replaces the typed keyword with the full query. -Keywords must be unique within their scope (global or per-connection). The save dialog warns if a keyword shadows a SQL keyword like `SELECT` or `WHERE`. +Keywords must be unique per scope (global or per-connection). The dialog warns if a keyword shadows a SQL keyword like `SELECT` or `WHERE`, but does not block saving. ### Scope -Favorites can be **global** (visible in all connections) or **connection-scoped** (visible only when connected to a specific database). Connection-scoped favorites and their keywords take precedence within that connection. +- **Global** favorites are visible in all connections. +- **Connection-scoped** favorites are visible only in the connection they were created for. + +New favorites created from the sidebar default to connection-scoped. Favorites saved from the history panel are always global. ## Storage -Favorites are stored in a local SQLite database (`sql_favorites.db`) in `~/Library/Application Support/TablePro/`, separate from query history. Full-text search is powered by FTS5. +Favorites live in a SQLite database (`sql_favorites.db`) in `~/Library/Application Support/TablePro/`, separate from query history. Search uses FTS5 full-text indexing on name, keyword, and query text. diff --git a/docs/vi/features/sql-favorites.mdx b/docs/vi/features/sql-favorites.mdx index 5c585ede..34cd1907 100644 --- a/docs/vi/features/sql-favorites.mdx +++ b/docs/vi/features/sql-favorites.mdx @@ -1,52 +1,60 @@ --- title: SQL Favorites -description: Luu va to chuc cac truy van thuong dung voi phim tat keyword de truy xuat nhanh +description: Lưu các truy vấn thường dùng với phím tắt keyword để mở rộng qua autocomplete --- # SQL Favorites -Luu cac truy van SQL ban thuong su dung thanh favorite. To chuc chung trong thu muc, gan phim tat keyword, va truy xuat tu sidebar hoac qua autocomplete. +Lưu những query bạn hay chạy thành favorite. Sắp xếp vào thư mục, gán keyword shortcut, và chèn từ sidebar hoặc gõ keyword để mở rộng qua autocomplete. -## Luu Favorite +## Lưu favorite -Co nhieu cach de luu mot truy van thanh favorite: +Ba cách để lưu query: -- **Click chuot phai trong editor** va chon **Save as Favorite...** -- **Click chuot phai mot muc lich su** trong panel Query History va chon **Save as Favorite** -- Nhan nut **+** o header section **Favorites** trong sidebar +- **Click chuột phải trong editor** và chọn **Save as Favorite...** +- **Click chuột phải một mục lịch sử** trong panel Query History và chọn **Save as Favorite** +- Nhấn **+ New Favorite** trong tab Favorites ở sidebar -Hop thoai luu cho phep ban thiet lap: +Hộp thoại có bốn trường: -| Truong | Mo ta | +| Trường | Mô tả | |--------|-------| -| Name | Ten mo ta cho truy van | -| Keyword | Phim tat tuy chon de mo rong qua autocomplete | -| Query | Noi dung truy van SQL | -| Global | Khi bat, favorite hien thi trong tat ca cac ket noi | +| Name | Tên ngắn cho query | +| Keyword | Alias tùy chọn, gõ vào sẽ mở rộng thành query đầy đủ qua autocomplete | +| Query | Nội dung SQL | +| Global | Khi bật, favorite hiện trong mọi kết nối | -## Sidebar +## Tab sidebar -Favorites hien thi trong section **Favorites** co the thu gon o dau sidebar, phia tren danh sach bang. Section ho tro: +Sidebar có segmented control **Tables / Favorites** ở trên cùng. Chuyển sang tab **Favorites** để xem các query đã lưu. -- **Thu muc** de to chuc favorites thanh nhom (ho tro long nhau) -- **Tim kiem** loc cung voi bang khi ban go trong o tim kiem sidebar -- **Double-click** mot favorite de chen truy van vao editor hien tai -- **Menu ngu canh** voi cac tuy chon chinh sua, sao chep, chen, chay trong tab moi, hoac xoa +Tab Favorites hỗ trợ: -### Thu muc +- **Thư mục** với nhiều cấp lồng nhau (click chuột phải để đổi tên, thêm thư mục con, hoặc xóa) +- **Tìm kiếm** lọc theo tên, keyword, và nội dung query +- **Double-click** để chèn query vào editor hiện tại +- **Menu ngữ cảnh** để sửa, sao chép, chèn, chạy trong tab mới, di chuyển vào thư mục, hoặc xóa +- **Chọn nhiều** bằng Cmd+click, rồi nhấn Delete để xóa -Tao thu muc tu menu ngu canh header section sidebar hoac tu menu ngu canh cua thu muc. Xoa thu muc se chuyen cac muc con sang thu muc cha (hoac goc) thay vi xoa chung. +### Thư mục -## Mo rong Keyword +Tạo thư mục bằng nút **+ folder** ở cuối sidebar, hoặc từ menu ngữ cảnh của thư mục. Xóa thư mục sẽ chuyển các mục con lên cấp cha thay vì xóa chúng. -Gan keyword cho favorite, va no se xuat hien nhu goi y autocomplete. Go keyword trong editor va popup autocomplete se hien thi favorite voi bieu tuong ngoi sao. Chon no se thay the keyword bang toan bo noi dung truy van. +Để di chuyển favorite giữa các thư mục, click chuột phải và dùng submenu **Move to**. -Keywords phai duy nhat trong pham vi cua chung (global hoac theo ket noi). Hop thoai luu se canh bao neu keyword trung voi tu khoa SQL nhu `SELECT` hoac `WHERE`. +## Mở rộng keyword -### Pham vi +Gán keyword cho favorite và nó sẽ xuất hiện như gợi ý autocomplete với biểu tượng ngôi sao. Chọn nó sẽ thay thế keyword đã gõ bằng toàn bộ query. -Favorites co the la **global** (hien thi trong tat ca cac ket noi) hoac **theo ket noi** (chi hien thi khi ket noi voi co so du lieu cu the). Favorites theo ket noi va keywords cua chung co uu tien trong ket noi do. +Keyword phải duy nhất trong phạm vi (global hoặc theo kết nối). Hộp thoại cảnh báo nếu keyword trùng với từ khóa SQL như `SELECT` hay `WHERE`, nhưng không chặn lưu. -## Luu tru +### Phạm vi -Favorites duoc luu trong co so du lieu SQLite cuc bo (`sql_favorites.db`) tai `~/Library/Application Support/TablePro/`, tach biet voi lich su truy van. Tim kiem toan van duoc ho tro boi FTS5. +- Favorite **Global** hiện trong mọi kết nối. +- Favorite **theo kết nối** chỉ hiện trong kết nối đã tạo. + +Favorite tạo từ sidebar mặc định theo kết nối. Favorite lưu từ panel lịch sử luôn là global. + +## Lưu trữ + +Favorites lưu trong database SQLite (`sql_favorites.db`) tại `~/Library/Application Support/TablePro/`, tách biệt với lịch sử query. Tìm kiếm dùng FTS5 full-text indexing trên tên, keyword, và nội dung query. diff --git a/docs/zh/features/sql-favorites.mdx b/docs/zh/features/sql-favorites.mdx new file mode 100644 index 00000000..d7af1409 --- /dev/null +++ b/docs/zh/features/sql-favorites.mdx @@ -0,0 +1,60 @@ +--- +title: SQL 收藏 +description: 保存常用查询,可设置关键词快捷方式通过自动补全展开 +--- + +# SQL 收藏 + +将常用的查询保存为收藏。用文件夹整理,设置关键词快捷方式,从侧边栏插入或输入关键词通过自动补全展开。 + +## 保存收藏 + +三种保存方式: + +- **在编辑器中右键**,选择 **Save as Favorite...** +- **在查询历史面板中右键**某条记录,选择 **Save as Favorite** +- 在侧边栏 Favorites 标签页中点击 **+ New Favorite** + +对话框包含四个字段: + +| 字段 | 说明 | +|------|------| +| Name | 查询的简短名称 | +| Keyword | 可选的别名,输入后通过自动补全展开为完整查询 | +| Query | SQL 内容 | +| Global | 开启后,该收藏在所有连接中可见 | + +## 侧边栏标签页 + +侧边栏顶部有 **Tables / Favorites** 分段控件。切换到 **Favorites** 标签页查看已保存的查询。 + +Favorites 标签页支持: + +- **文件夹** 支持多级嵌套(右键可重命名、添加子文件夹或删除) +- **搜索** 按名称、关键词和查询内容筛选 +- **双击** 将查询插入当前编辑器 +- **右键菜单** 编辑、复制查询、插入、在新标签页运行、移至文件夹或删除 +- **多选** 使用 Cmd+点击,然后按 Delete 键删除 + +### 文件夹 + +通过侧边栏底部的 **+ folder** 按钮或文件夹右键菜单创建文件夹。删除文件夹会将其子项移至上级目录而非删除。 + +要在文件夹间移动收藏,右键点击并使用 **Move to** 子菜单。 + +## 关键词展开 + +为收藏设置关键词后,它会作为自动补全建议出现,带有星标图标。选择后会将输入的关键词替换为完整查询。 + +关键词在其作用域内(全局或按连接)必须唯一。对话框会在关键词与 `SELECT` 或 `WHERE` 等 SQL 关键字冲突时发出警告,但不会阻止保存。 + +### 作用域 + +- **全局** 收藏在所有连接中可见。 +- **按连接** 的收藏仅在创建时的连接中可见。 + +从侧边栏创建的收藏默认为按连接。从历史面板保存的收藏始终为全局。 + +## 存储 + +收藏保存在 `~/Library/Application Support/TablePro/` 的 SQLite 数据库(`sql_favorites.db`)中,与查询历史分开。搜索使用 FTS5 全文索引,覆盖名称、关键词和查询内容。