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/ContentView.swift b/TablePro/ContentView.swift index 5501023f..ec5d2e4e 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,16 @@ struct ContentView: View { ) } + 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/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/SSH/LibSSH2Tunnel.swift b/TablePro/Core/SSH/LibSSH2Tunnel.swift index 78c14ad0..ac84339c 100644 --- a/TablePro/Core/SSH/LibSSH2Tunnel.swift +++ b/TablePro/Core/SSH/LibSSH2Tunnel.swift @@ -30,6 +30,13 @@ 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 + ) + /// Callback invoked when the tunnel dies (keep-alive failure, etc.) var onDeath: ((UUID) -> Void)? @@ -64,34 +71,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 +285,57 @@ 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 { tasks in + tasks.removeAll { $0.isCancelled } + tasks.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) + } + } + + while self.isRunning { + var pollFDs = [ + pollfd(fd: clientFD, events: Int16(POLLIN), revents: 0), + pollfd(fd: self.socketFD, events: Int16(POLLIN), revents: 0), + ] - let pollResult = poll(&pollFDs, 2, 100) // 100ms timeout - if pollResult < 0 { break } + let pollResult = poll(&pollFDs, 2, 100) // 100ms timeout + if pollResult < 0 { break } - // Read from SSH channel -> write to client + // 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 +352,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 + } + + // 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 } } } } - - relayTasks.withLock { $0.append(task) } } /// Wait for the SSH socket to become ready, based on libssh2's block directions. 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..653f4757 --- /dev/null +++ b/TablePro/Core/Storage/SQLFavoriteManager.swift @@ -0,0 +1,128 @@ +// +// SQLFavoriteManager.swift +// TablePro +// + +import Foundation +import os + +/// Manages SQL favorites with notifications and sync tracking +internal 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 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, + 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..174f6123 --- /dev/null +++ b/TablePro/Core/Storage/SQLFavoriteStorage.swift @@ -0,0 +1,832 @@ +// +// SQLFavoriteStorage.swift +// TablePro +// + +import Foundation +import os +import SQLite3 + +/// Thread-safe SQLite storage for SQL favorites with FTS5 full-text search +internal 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 + } + + 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 } + + 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) + + // 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 { + 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) + let moveFavResult = sqlite3_step(moveFavStatement) + sqlite3_finalize(moveFavStatement) + if moveFavResult != SQLITE_DONE { + sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) + return false + } + } else { + sqlite3_finalize(moveFavStatement) + sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) + return false + } + + // 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) + let moveSubResult = sqlite3_step(moveSubStatement) + sqlite3_finalize(moveSubStatement) + if moveSubResult != SQLITE_DONE { + sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) + return false + } + } else { + sqlite3_finalize(moveSubStatement) + sqlite3_exec(self.db, "ROLLBACK;", nil, nil, nil) + return false + } + + // 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 { + 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 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: 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 != ?" + } + + 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, bindIndex, keyword, -1, SQLITE_TRANSIENT) + bindIndex += 1 + + if let connId = connectionIdString { + sqlite3_bind_text(statement, bindIndex, connId, -1, SQLITE_TRANSIENT) + bindIndex += 1 + } + + if let excludeId = excludeIdString { + sqlite3_bind_text(statement, bindIndex, 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/Core/Utilities/SQL/SQLStatementScanner.swift b/TablePro/Core/Utilities/SQL/SQLStatementScanner.swift index 12222443..46f7f7ef 100644 --- a/TablePro/Core/Utilities/SQL/SQLStatementScanner.swift +++ b/TablePro/Core/Utilities/SQL/SQLStatementScanner.swift @@ -11,6 +11,7 @@ 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 @@ -27,6 +28,22 @@ enum SQLStatementScanner { 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) + let withoutSemicolon = trimmed.hasSuffix(";") + ? String(trimmed.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) + : trimmed + if !withoutSemicolon.isEmpty { + results.append(trimmed) + } + return true + } + return results + } + static func statementAtCursor(in sql: String, cursorPosition: Int) -> String { var result = locatedStatementAtCursor(in: sql, cursorPosition: cursorPosition) .sql diff --git a/TablePro/Models/Query/SQLFavorite.swift b/TablePro/Models/Query/SQLFavorite.swift new file mode 100644 index 00000000..2689600c --- /dev/null +++ b/TablePro/Models/Query/SQLFavorite.swift @@ -0,0 +1,42 @@ +// +// SQLFavorite.swift +// TablePro +// + +import Foundation + +/// A saved SQL query that can be quickly recalled and optionally expanded via keyword +internal 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? = nil, + updatedAt: Date? = nil + ) { + let now = 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 ?? now + self.updatedAt = updatedAt ?? now + } +} diff --git a/TablePro/Models/Query/SQLFavoriteFolder.swift b/TablePro/Models/Query/SQLFavoriteFolder.swift new file mode 100644 index 00000000..f95dd05e --- /dev/null +++ b/TablePro/Models/Query/SQLFavoriteFolder.swift @@ -0,0 +1,36 @@ +// +// SQLFavoriteFolder.swift +// TablePro +// + +import Foundation + +/// A folder for organizing SQL favorites into a hierarchy +internal 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? = 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 ?? now + self.updatedAt = updatedAt ?? now + } +} diff --git a/TablePro/Models/UI/SharedSidebarState.swift b/TablePro/Models/UI/SharedSidebarState.swift index 8b695adb..2666f1f4 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 +internal 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..e2830376 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -1773,6 +1773,9 @@ } } } + }, + "Add" : { + }, "Add Check Constraint" : { "localizations" : { @@ -6028,6 +6031,12 @@ } } } + }, + "Delete Folder" : { + + }, + "Delete Folder?" : { + }, "Delete Foreign Key" : { "extractionState" : "stale", @@ -6692,6 +6701,9 @@ } } } + }, + "Edit..." : { + }, "Editing" : { "localizations" : { @@ -8079,6 +8091,9 @@ }, "Fast" : { + }, + "Favorites" : { + }, "Feature Routing" : { "localizations" : { @@ -8225,6 +8240,9 @@ } } } + }, + "Filter favorites" : { + }, "Filter logic mode" : { "localizations" : { @@ -8372,6 +8390,9 @@ }, "Focus Border" : { + }, + "Folder name" : { + }, "Font" : { "extractionState" : "stale", @@ -8572,6 +8593,9 @@ } } } + }, + "Global:" : { + }, "Go" : { "localizations" : { @@ -9398,6 +9422,9 @@ } } } + }, + "Insert in Editor" : { + }, "INSERT Statement(s)" : { "localizations" : { @@ -9989,6 +10016,15 @@ }, "Keyword" : { + }, + "Keyword cannot contain spaces" : { + + }, + "Keyword:" : { + + }, + "keyword: %@" : { + }, "Language:" : { "localizations" : { @@ -10781,6 +10817,9 @@ } } } + }, + "Move to" : { + }, "Move Up" : { "extractionState" : "stale", @@ -10881,6 +10920,9 @@ } } } + }, + "Name:" : { + }, "Navigate to referenced row" : { "localizations" : { @@ -11029,6 +11071,15 @@ } } } + }, + "New Favorite" : { + + }, + "New Favorite..." : { + + }, + "New Folder" : { + }, "New Group" : { "localizations" : { @@ -11111,6 +11162,9 @@ } } } + }, + "New Subfolder" : { + }, "New Tab" : { "localizations" : { @@ -11420,6 +11474,9 @@ } } } + }, + "No Favorites" : { + }, "No Foreign Keys Yet" : { "localizations" : { @@ -11536,6 +11593,9 @@ } } } + }, + "No Matching Favorites" : { + }, "No matching fields" : { "localizations" : { @@ -14026,6 +14086,9 @@ } } } + }, + "Query:" : { + }, "Quick search across all columns..." : { "localizations" : { @@ -14877,6 +14940,9 @@ } } } + }, + "Root Level" : { + }, "Row %lld" : { "localizations" : { @@ -15181,6 +15247,12 @@ } } } + }, + "Save as Favorite" : { + + }, + "Save as Favorite..." : { + }, "Save as Preset..." : { "localizations" : { @@ -15262,6 +15334,9 @@ } } } + }, + "Save frequently used queries\nfor quick access." : { + }, "Save Sidebar Changes" : { "localizations" : { @@ -16007,6 +16082,9 @@ }, "Settings:" : { + }, + "Shadows the SQL keyword '%@'" : { + }, "Share anonymous usage data" : { "localizations" : { @@ -17437,7 +17515,6 @@ } }, "Tables" : { - "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -17676,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" : { @@ -17801,6 +17881,9 @@ }, "This is a registry theme." : { + }, + "This keyword is already in use" : { + }, "This Mac" : { @@ -19488,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 new file mode 100644 index 00000000..c9fb1cca --- /dev/null +++ b/TablePro/ViewModels/FavoritesSidebarViewModel.swift @@ -0,0 +1,219 @@ +// +// FavoritesSidebarViewModel.swift +// TablePro +// + +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]) + 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 +internal final class FavoritesSidebarViewModel { + // MARK: - State + + var treeItems: [FavoriteTreeItem] = [] + var isLoading = false + var editDialogItem: FavoriteEditItem? + var editingFavorite: SQLFavorite? + var editingQuery: String? + var editingFolderId: UUID? + var renamingFolderId: UUID? + var renamingFolderName: String = "" + var expandedFolderIds: Set = [] + + // 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 ? $0.sortOrder < $1.sortOrder : $0.name.localizedStandardCompare($1.name) == .orderedAscending } + + 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 ? $0.sortOrder < $1.sortOrder : $0.name.localizedStandardCompare($1.name) == .orderedAscending } + + for fav in levelFavorites { + items.append(.favorite(fav)) + } + + return items + } + + // MARK: - Actions + + func createFavorite(query: String? = nil, folderId: UUID? = nil) { + if let folderId { + expandedFolderIds.insert(folderId) + } + editDialogItem = FavoriteEditItem(favorite: nil, query: query, folderId: folderId) + } + + func editFavorite(_ favorite: SQLFavorite) { + editDialogItem = FavoriteEditItem(favorite: favorite, query: nil, folderId: favorite.folderId) + } + + func deleteFavorite(_ favorite: SQLFavorite) { + Task { + _ = await manager.deleteFavorite(id: favorite.id) + } + } + + 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)) + } + } + + func createFolder(parentId: UUID? = nil) { + if let parentId { + expandedFolderIds.insert(parentId) + } + Task { + let folder = SQLFavoriteFolder( + name: String(localized: "New Folder"), + parentId: parentId, + connectionId: connectionId + ) + let success = await manager.addFolder(folder) + if success { + expandedFolderIds.insert(folder.id) + await loadFavorites() + startRenameFolder(folder) + } + } + } + + func deleteFolder(_ folder: SQLFavoriteFolder) { + Task { + _ = await manager.deleteFolder(id: folder.id) + } + } + + 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 + 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) || + 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 + } + } + } +} diff --git a/TablePro/Views/Components/ConflictResolutionView.swift b/TablePro/Views/Components/ConflictResolutionView.swift index 0c620621..867edf00 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: String(localized: "Name"), value: name) + } } } diff --git a/TablePro/Views/Editor/AIEditorContextMenu.swift b/TablePro/Views/Editor/AIEditorContextMenu.swift index d57b4640..399357a0 100644 --- a/TablePro/Views/Editor/AIEditorContextMenu.swift +++ b/TablePro/Views/Editor/AIEditorContextMenu.swift @@ -12,8 +12,10 @@ 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)? override init(title: String) { super.init(title: title) @@ -45,6 +47,18 @@ 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) + saveAsFavItem.isEnabled = (fullText?()?.isEmpty == false) + menu.addItem(saveAsFavItem) + // AI items — only when text is selected guard hasSelection?() == true else { return } @@ -80,4 +94,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 if let text = fullText?(), !text.isEmpty { + onSaveAsFavorite?(text) + } + } } diff --git a/TablePro/Views/Editor/HistoryPanelView.swift b/TablePro/Views/Editor/HistoryPanelView.swift index 6822172c..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? @@ -21,6 +22,7 @@ struct HistoryPanelView: View { @State private var searchTask: Task? @State private var copyButtonTitle = "Copy Query" @State private var copyResetTask: Task? + @State private var favoriteDialogQuery: FavoriteDialogQuery? @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(item: $favoriteDialogQuery) { item in + FavoriteEditDialog( + connectionId: connectionId, + favorite: nil, + initialQuery: item.query, + forceGlobal: true + ) + } } } @@ -186,6 +196,12 @@ private extension HistoryPanelView { Label(String(localized: "Run in New Tab"), systemImage: "play") } + Button { + favoriteDialogQuery = FavoriteDialogQuery(query: entry.query) + } label: { + Label(String(localized: "Save as Favorite"), systemImage: "star") + } + Divider() Button(role: .destructive) { @@ -431,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/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..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,6 +43,13 @@ 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) } // MARK: - CodeSuggestionDelegate diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index c299db64..bbe0ec1f 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 @@ -164,8 +165,12 @@ 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) } 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..beb5538c 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -66,6 +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 favoriteDialogQuery: FavoriteDialogQuery? // Native macOS window tabs — no LRU tracking needed (single tab per window) @@ -100,13 +101,20 @@ struct MainEditorContentView: View { // Global History Panel if isHistoryVisible { Divider() - HistoryPanelView() + HistoryPanelView(connectionId: connectionId) .frame(height: 300) .transition(.move(edge: .bottom).combined(with: .opacity)) } } .background(.background) .animation(.easeInOut(duration: 0.2), value: isHistoryVisible) + .sheet(item: $favoriteDialogQuery) { item in + FavoriteEditDialog( + connectionId: connectionId, + favorite: nil, + initialQuery: item.query + ) + } .onChange(of: tabManager.tabs.count) { // Clean up caches for closed tabs let openTabIds = Set(tabManager.tabs.map(\.id)) @@ -190,6 +198,7 @@ struct MainEditorContentView: View { onExecute: { coordinator.runQuery() }, schemaProvider: coordinator.schemaProvider, databaseType: coordinator.connection.type, + connectionId: coordinator.connection.id, onCloseTab: { NSApp.keyWindow?.close() }, @@ -208,6 +217,10 @@ struct MainEditorContentView: View { onAIOptimize: { text in coordinator.showAIChatPanel() coordinator.aiViewModel?.handleOptimizeSelection(text) + }, + onSaveAsFavorite: { text in + guard !text.isEmpty else { return } + favoriteDialogQuery = FavoriteDialogQuery(query: text) } ) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift new file mode 100644 index 00000000..bd7f1ce0 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift @@ -0,0 +1,53 @@ +// +// MainContentCoordinator+Favorites.swift +// TablePro +// + +import Foundation + +extension MainContentCoordinator { + /// Insert a favorite's query into the current editor tab. + /// 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 { + 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) + } + } + + /// 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 { + tabManager.tabs[tabIndex].query = favorite.query + return + } + + let payload = EditorTabPayload( + connectionId: connection.id, + tabType: .query, + databaseName: connection.database, + initialQuery: favorite.query + ) + WindowOpener.shared.openNativeTab(payload) + } +} 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/FavoriteEditDialog.swift b/TablePro/Views/Sidebar/FavoriteEditDialog.swift new file mode 100644 index 00000000..b7229f2d --- /dev/null +++ b/TablePro/Views/Sidebar/FavoriteEditDialog.swift @@ -0,0 +1,218 @@ +// +// FavoriteEditDialog.swift +// TablePro +// + +import SwiftUI + +/// Wrapper for `.sheet(item:)` to ensure the query is passed reliably +internal struct FavoriteDialogQuery: Identifiable { + let id = UUID() + let query: String +} + +/// Dialog for creating or editing a SQL favorite +internal 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 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 || isKeywordWarning) + } + + 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: 16) { + Form { + TextField("Name:", text: $name) + TextField("Keyword:", text: $keyword) + .onChange(of: keyword) { _, newValue in + validateKeyword(newValue) + } + + if let error = keywordError { + LabeledContent {} label: { + Text(error) + .foregroundStyle(isKeywordWarning ? .orange : .red) + .font(.callout) + } + } + + LabeledContent("Query:") { + TextEditor(text: $query) + .font(.system(.body, design: .monospaced)) + .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("Global:", isOn: $isGlobal) + .help(String(localized: "When enabled, this favorite is visible in all connections")) + .onChange(of: isGlobal) { + validateKeyword(keyword) + } + } + } + .formStyle(.columns) + + HStack { + Spacer() + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button(isEditing ? "Save" : "Add") { + save() + } + .keyboardShortcut(.defaultAction) + .disabled(!isValid || isSaving) + } + } + .padding(20) + .frame(width: 480) + .onAppear { + if let fav = favorite { + name = fav.name + query = fav.query + keyword = fav.keyword ?? "" + isGlobal = forceGlobal || fav.connectionId == nil + } else { + isGlobal = forceGlobal + 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(" ") { + isKeywordWarning = false + 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( + trimmed, + connectionId: scopeConnectionId, + excludingFavoriteId: favorite?.id + ) + guard currentId == validationId else { return } + if !available { + isKeywordWarning = false + 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()) { + isKeywordWarning = true + keywordError = String( + localized: "Shadows the SQL keyword '\(trimmed.uppercased())'" + ) + } else { + isKeywordWarning = false + 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 + let success: Bool + if let existing = favorite { + var updated = existing + updated.name = trimmedName + updated.query = trimmedQuery + updated.keyword = keywordValue + updated.connectionId = scopeConnectionId + updated.updatedAt = Date() + success = await SQLFavoriteManager.shared.updateFavorite(updated) + } else { + let newFavorite = SQLFavorite( + name: trimmedName, + query: trimmedQuery, + keyword: keywordValue, + folderId: folderId, + connectionId: scopeConnectionId + ) + success = await SQLFavoriteManager.shared.addFavorite(newFavorite) + } + if success { + dismiss() + } else { + isSaving = false + } + } + } +} diff --git a/TablePro/Views/Sidebar/FavoriteRowView.swift b/TablePro/Views/Sidebar/FavoriteRowView.swift new file mode 100644 index 00000000..7c478d1e --- /dev/null +++ b/TablePro/Views/Sidebar/FavoriteRowView.swift @@ -0,0 +1,47 @@ +// +// FavoriteRowView.swift +// TablePro +// + +import SwiftUI + +/// Row view for a single SQL favorite in the sidebar +internal struct FavoriteRowView: View { + let favorite: SQLFavorite + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "star.fill") + .font(.system(size: 10)) + .foregroundStyle(.yellow) + .accessibilityHidden(true) + + 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)) + ) + .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 new file mode 100644 index 00000000..39161f1f --- /dev/null +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -0,0 +1,363 @@ +// +// FavoritesTabView.swift +// TablePro +// +// Full-tab view for SQL favorites in the sidebar. +// + +import SwiftUI + +/// Full-tab favorites view with folder hierarchy and bottom toolbar +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 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 + } + + 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(item: $viewModel.editDialogItem) { item in + FavoriteEditDialog( + connectionId: connectionId, + favorite: item.favorite, + initialQuery: item.query, + folderId: item.folderId + ) + } + .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 + + private func favoritesList(_ items: [FavoriteTreeItem]) -> some View { + List(selection: $selectedFavoriteIds) { + flattenedRows(items) + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + .onDeleteCommand { + deleteSelectedFavorites() + } + } + + /// 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) + .accessibilityLabel(String(localized: "Folder name")) + .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 + } + ) + } + } + } + + private func deleteSelectedFavorites() { + let allFavorites = collectFavorites(from: viewModel.treeItems) + let toDelete = allFavorites.filter { selectedFavoriteIds.contains("fav-\($0.id)") } + guard !toDelete.isEmpty else { return } + viewModel.deleteFavorites(toDelete) + 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 + + 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) + + 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) + } + + 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)) + .accessibilityLabel(String(localized: "New Folder")) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } +} + +// MARK: - Context Menus + +private struct FavoriteItemContextMenu: View { + let favorite: SQLFavorite + let viewModel: FavoritesSidebarViewModel + var coordinator: MainContentCoordinator? + + private var folders: [SQLFavoriteFolder] { + collectFolders(from: viewModel.treeItems) + } + + var body: some View { + Button(String(localized: "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") + } + + 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) { + viewModel.deleteFavorite(favorite) + } label: { + 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 { + let folder: SQLFavoriteFolder + let viewModel: FavoritesSidebarViewModel + var onDelete: (SQLFavoriteFolder) -> Void + + var body: some View { + Button(String(localized: "Rename")) { + viewModel.startRenameFolder(folder) + } + + Button(String(localized: "New Favorite...")) { + viewModel.createFavorite(folderId: folder.id) + } + + Button(String(localized: "New Subfolder")) { + viewModel.createFolder(parentId: folder.id) + } + + Divider() + + Button(role: .destructive) { + onDelete(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..8cf6855b 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,36 @@ 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() + .allowsHitTesting(sidebarState.selectedSidebarTab == .tables) + + FavoritesTabView( + connectionId: connectionId, + searchText: viewModel.debouncedSearchText, + coordinator: coordinator + ) + .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) { + 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 +140,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 { 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/Core/Utilities/SQLStatementScannerTests.swift b/TableProTests/Core/Utilities/SQLStatementScannerTests.swift index f0eed725..18448ddb 100644 --- a/TableProTests/Core/Utilities/SQLStatementScannerTests.swift +++ b/TableProTests/Core/Utilities/SQLStatementScannerTests.swift @@ -108,6 +108,24 @@ final class SQLStatementScannerTests: XCTestCase { ) } + // 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"] + ) + } + // MARK: - statementAtCursor func testCursorInFirstStatement() { @@ -120,7 +138,6 @@ final class SQLStatementScannerTests: XCTestCase { 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,7 +154,6 @@ 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" 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 + } + } + } +} diff --git a/docs/docs.json b/docs/docs.json index 3fa708c8..74a901a2 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", @@ -254,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 new file mode 100644 index 00000000..0b096e7f --- /dev/null +++ b/docs/features/sql-favorites.mdx @@ -0,0 +1,60 @@ +--- +title: SQL Favorites +description: Save frequently used queries with optional keyword shortcuts for autocomplete expansion +--- + +# SQL Favorites + +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 + +Three ways to save a query: + +- **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 dialog has four fields: + +| Field | Description | +|-------|-------------| +| 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 tab + +The sidebar has a **Tables / Favorites** segmented control at the top. Switch to the **Favorites** tab to see your saved queries. + +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 **+ 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 + +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 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 + +- **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 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 new file mode 100644 index 00000000..34cd1907 --- /dev/null +++ b/docs/vi/features/sql-favorites.mdx @@ -0,0 +1,60 @@ +--- +title: SQL Favorites +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 + +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. + +## Lưu favorite + +Ba cách để lưu query: + +- **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 + +Hộp thoại có bốn trường: + +| Trường | Mô tả | +|--------|-------| +| 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 | + +## Tab sidebar + +Sidebar có segmented control **Tables / Favorites** ở trên cùng. Chuyển sang tab **Favorites** để xem các query đã lưu. + +Tab Favorites hỗ trợ: + +- **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 + +### Thư mục + +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. + +Để di chuyển favorite giữa các thư mục, click chuột phải và dùng submenu **Move to**. + +## Mở rộng keyword + +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. + +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. + +### Phạm vi + +- 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 全文索引,覆盖名称、关键词和查询内容。