diff --git a/CHANGELOG.md b/CHANGELOG.md index 1935ca938..37d582807 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The database switcher takes a multiple selection too, with the same actions on the right-click menu. - Drop Schema for PostgreSQL, SQL Server and SurrealDB. - Quick Switcher can search tables and views across every open connection. +- Quick Switcher can search saved queries and recent queries across every open connection. - SSL settings on mobile for MySQL, PostgreSQL and Redis: a mode picker plus CA, client certificate and client key. Import a PEM file, paste one, or use a PKCS#12 file. (#2083) - Legacy UUID Encoding on a MongoDB connection, so binary UUIDs written by the Java, C# or Python drivers read as UUIDs instead of hex. Filters, edits and MQL exports write the same bytes back. (#2086) diff --git a/TablePro/Core/Services/Infrastructure/QuickSwitcherHostResolver.swift b/TablePro/Core/Services/Infrastructure/QuickSwitcherHostResolver.swift new file mode 100644 index 000000000..aea8311c9 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/QuickSwitcherHostResolver.swift @@ -0,0 +1,25 @@ +// +// QuickSwitcherHostResolver.swift +// TablePro +// + +import Foundation + +internal enum QuickSwitcherHostResolver { + /// Which window opens a quick switcher result, given every window that could host it. + /// + /// The window the panel was opened from wins whenever it can host the result. Every other + /// window on the same connection and database is an equally good match, and the registry + /// holding them is a dictionary with no order, so picking one arbitrarily loads the result + /// into an editor the user is not looking at and pulls focus there. When the result belongs + /// to another connection, the most recently focused window of that connection wins. + internal static func host( + preferred: ID?, + candidates: [ID], + mostRecentlyFocused: ID? + ) -> ID? { + if let preferred, candidates.contains(preferred) { return preferred } + if let mostRecentlyFocused, candidates.contains(mostRecentlyFocused) { return mostRecentlyFocused } + return candidates.first + } +} diff --git a/TablePro/Core/Storage/SQLFavoriteManager.swift b/TablePro/Core/Storage/SQLFavoriteManager.swift index d68a8b067..a6d332f78 100644 --- a/TablePro/Core/Storage/SQLFavoriteManager.swift +++ b/TablePro/Core/Storage/SQLFavoriteManager.swift @@ -82,9 +82,15 @@ internal final class SQLFavoriteManager: @unchecked Sendable { func fetchFavorites( connectionId: UUID? = nil, folderId: UUID? = nil, - searchText: String? = nil + searchText: String? = nil, + allowedConnectionIds: Set? = nil ) async -> [SQLFavorite] { - await storage.fetchFavorites(connectionId: connectionId, folderId: folderId, searchText: searchText) + await storage.fetchFavorites( + connectionId: connectionId, + folderId: folderId, + searchText: searchText, + allowedConnectionIds: allowedConnectionIds + ) } // MARK: - Folders diff --git a/TablePro/Core/Storage/SQLFavoriteStorage.swift b/TablePro/Core/Storage/SQLFavoriteStorage.swift index 38fcbe9cc..d3dc5292c 100644 --- a/TablePro/Core/Storage/SQLFavoriteStorage.swift +++ b/TablePro/Core/Storage/SQLFavoriteStorage.swift @@ -541,15 +541,25 @@ internal actor SQLFavoriteStorage { func fetchFavorites( connectionId: UUID? = nil, folderId: UUID? = nil, - searchText: String? = nil + searchText: String? = nil, + allowedConnectionIds: Set? = nil ) -> [SQLFavorite] { + if let allowedConnectionIds, allowedConnectionIds.isEmpty { + return [] + } + let connectionIdString = connectionId?.uuidString let folderIdString = folderId?.uuidString + let allowedList = allowedConnectionIds.map { Array($0) } + let allowedPlaceholders = allowedList.map { + Array(repeating: "?", count: $0.count).joined(separator: ", ") + } var sql: String var bindIndex: Int32 = 1 var hasConnectionFilter = false var hasFolderFilter = false + var hasAllowedFilter = false let isJoined: Bool if let searchText = searchText, !searchText.isEmpty { @@ -566,6 +576,11 @@ internal actor SQLFavoriteStorage { hasConnectionFilter = true } + if let allowedPlaceholders { + sql += " AND (f.connection_id IS NULL OR f.connection_id IN (\(allowedPlaceholders)))" + hasAllowedFilter = true + } + if folderIdString != nil { sql += " AND f.folder_id = ?" hasFolderFilter = true @@ -584,6 +599,11 @@ internal actor SQLFavoriteStorage { hasConnectionFilter = true } + if let allowedPlaceholders { + whereClauses.append("(connection_id IS NULL OR connection_id IN (\(allowedPlaceholders)))") + hasAllowedFilter = true + } + if folderIdString != nil { whereClauses.append("folder_id = ?") hasFolderFilter = true @@ -616,6 +636,13 @@ internal actor SQLFavoriteStorage { bindIndex += 1 } + if let allowedList, hasAllowedFilter { + for allowedId in allowedList { + sqlite3_bind_text(statement, bindIndex, allowedId.uuidString, -1, SQLITE_TRANSIENT) + bindIndex += 1 + } + } + if let foldId = folderIdString, hasFolderFilter { sqlite3_bind_text(statement, bindIndex, foldId, -1, SQLITE_TRANSIENT) bindIndex += 1 diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index dd9327d73..5c5d31e37 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -373,6 +373,12 @@ struct TabTableContext: Equatable { var isView: Bool = false var primaryKeyColumn: String? { primaryKeyColumns.first } + + /// A tab opened without an explicit database carries an empty name and follows the window's + /// browse cursor, so comparing the stored value against a real database name never matches. + func resolvedDatabaseName(browsing browseDatabaseName: String) -> String { + databaseName.isEmpty ? browseDatabaseName : databaseName + } } struct TabQueryContent: Equatable { diff --git a/TablePro/Models/UI/QuickSwitcherItem.swift b/TablePro/Models/UI/QuickSwitcherItem.swift index 295f80308..c9537a9ca 100644 --- a/TablePro/Models/UI/QuickSwitcherItem.swift +++ b/TablePro/Models/UI/QuickSwitcherItem.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit /// The type of database object represented by a quick switcher item internal enum QuickSwitcherItemKind: String, Hashable, Sendable { @@ -25,25 +26,28 @@ internal enum QuickSwitcherCommitIntent: Sendable { case openStructure } -internal struct QuickSwitcherObjectTarget: Hashable, Sendable { +internal struct QuickSwitcherTarget: Hashable, Sendable { let connectionId: UUID let connectionName: String let databaseName: String? let schemaName: String? let databaseDisplayName: String? + let pathFieldRole: PathFieldRole init( connectionId: UUID, connectionName: String, databaseName: String?, schemaName: String?, - databaseDisplayName: String? = nil + databaseDisplayName: String? = nil, + pathFieldRole: PathFieldRole = .database ) { self.connectionId = connectionId self.connectionName = connectionName self.databaseName = databaseName self.schemaName = schemaName self.databaseDisplayName = databaseDisplayName + self.pathFieldRole = pathFieldRole } } @@ -61,6 +65,8 @@ internal enum QuickSwitcherScope: String, CaseIterable, Identifiable, Sendable { /// from the objects of the connection that opened the panel. var usesCrossConnectionCatalog: Bool { self == .connections } + var usesCrossConnectionQueries: Bool { self == .queries } + var includedKinds: Set? { switch self { case .all: return nil @@ -98,11 +104,15 @@ internal struct QuickSwitcherItem: Identifiable, Hashable, Sendable { let name: String let kind: QuickSwitcherItemKind let subtitle: String + /// Ranked at full weight, unlike the subtitle that carries it for display. A saved query's + /// subtitle also names its connection and database, and those must not score as strongly as + /// the keyword the author assigned. + var keyword: String? var matchedIndices: [Int] = [] var payload: String? var isOpenInTab: Bool = false var isReadOnly: Bool = false - var objectTarget: QuickSwitcherObjectTarget? + var target: QuickSwitcherTarget? static func tableItemId(name: String, isView: Bool) -> String { "table_\(name)_\(isView ? "VIEW" : "TABLE")" diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 2aa37460c..f84ccb201 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -24338,6 +24338,34 @@ } } }, + "Could Not Open Query" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sorgu Açılamadı" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể mở truy vấn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法打开查询" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法開啟查詢" + } + } + } + }, "Could Not Open Sample" : { "localizations" : { "tr" : { @@ -90794,6 +90822,34 @@ } } }, + "The target connection is no longer open." : { + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hedef bağlantı artık açık değil." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối đích không còn mở." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "目标连接已不再打开。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "目標連線已不再開啟。" + } + } + } + }, "The target database may be in a partial state. Review the database and clean up as needed." : { "comment" : "A message that appears when restoring a database and the user is advised to review and clean up the database.", "isCommentAutoGenerated" : true, diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 942ce7326..ee8d6f5e3 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -32,6 +32,15 @@ internal final class QuickSwitcherViewModel { let entries: [Entry] } + /// Deliberately not keyed on `connectionStatusVersion`: that counter bumps on every write to + /// `activeSessions`, including activity timestamps, so keying on it re-read every favorite and + /// history row while the panel sat open. The connected set and the content revision are what + /// this list actually depends on. + struct CrossConnectionQueryVersion: Hashable { + let connectedConnectionIds: [UUID] + let contentRevision: Int + } + struct Group: Identifiable, Sendable { let id: String let header: String? @@ -44,6 +53,7 @@ internal final class QuickSwitcherViewModel { @ObservationIgnored private let services: AppServices @ObservationIgnored private let connectionId: UUID + @ObservationIgnored private let defaults: UserDefaults @ObservationIgnored private let frecencyStore: QuickSwitcherFrecencyStore @ObservationIgnored internal var allItems: [QuickSwitcherItem] = [] { @@ -52,14 +62,21 @@ internal final class QuickSwitcherViewModel { @ObservationIgnored internal var crossConnectionItems: [QuickSwitcherItem] = [] { didSet { scheduleFilter(debounced: false) } } + @ObservationIgnored internal var crossConnectionQueryItems: [QuickSwitcherItem] = [] { + didSet { scheduleFilter(debounced: false) } + } @ObservationIgnored private var filterTask: Task? @ObservationIgnored private var activeLoadId = UUID() @ObservationIgnored private var activeCrossConnectionLoadId = UUID() + @ObservationIgnored private var activeCrossConnectionQueryLoadId = UUID() @ObservationIgnored private var loadedCrossConnectionVersion: CrossConnectionCatalogVersion? + @ObservationIgnored private var loadedCrossConnectionQueryVersion: CrossConnectionQueryVersion? private(set) var groups: [Group] = [] private(set) var isLoading = true private(set) var isLoadingCrossConnections = false + private(set) var isLoadingCrossConnectionQueries = false + private(set) var crossConnectionQueryContentRevision = 0 var selectedItemId: String? var searchText = "" { @@ -81,7 +98,10 @@ internal final class QuickSwitcherViewModel { } var isLoadingResults: Bool { - scope.usesCrossConnectionCatalog && isLoadingCrossConnections + if scope.usesCrossConnectionCatalog { + return isLoadingCrossConnections + } + return scope.usesCrossConnectionQueries && isLoadingCrossConnectionQueries } /// Nil outside the cross-connection scope, so a panel showing one connection's objects @@ -91,6 +111,11 @@ internal final class QuickSwitcherViewModel { return crossConnectionCatalogVersion } + var crossConnectionQueryLoadVersion: CrossConnectionQueryVersion? { + guard scope.usesCrossConnectionQueries else { return nil } + return crossConnectionQueryVersion + } + func listHeight(rowHeight: CGFloat, headerHeight: CGFloat, maxVisibleRows: Int) -> CGFloat { let headerCount = groups.filter { $0.header != nil }.count let naturalHeight = CGFloat(flatItems.count) * rowHeight + CGFloat(headerCount) * headerHeight @@ -101,6 +126,7 @@ internal final class QuickSwitcherViewModel { init(connectionId: UUID, services: AppServices, defaults: UserDefaults = .standard) { self.connectionId = connectionId self.services = services + self.defaults = defaults self.frecencyStore = QuickSwitcherFrecencyStore(connectionId: connectionId, defaults: defaults) } @@ -206,6 +232,7 @@ internal final class QuickSwitcherViewModel { name: favorite.name, kind: .savedQuery, subtitle: favorite.keyword ?? "", + keyword: favorite.keyword, payload: favorite.query )) } @@ -264,6 +291,100 @@ internal final class QuickSwitcherViewModel { crossConnectionItems = crossConnectionItems(for: sessions, loaded: loadedConnectionIds) } + func invalidateCrossConnectionQueryItems() { + crossConnectionQueryContentRevision &+= 1 + } + + func loadCrossConnectionQueryItems() async { + guard scope.usesCrossConnectionQueries else { return } + + let version = crossConnectionQueryVersion + guard loadedCrossConnectionQueryVersion != version else { return } + + let loadId = UUID() + activeCrossConnectionQueryLoadId = loadId + isLoadingCrossConnectionQueries = true + defer { + if activeCrossConnectionQueryLoadId == loadId { + isLoadingCrossConnectionQueries = false + } + } + + let targets = queryTargets() + async let favorites = services.sqlFavoriteManager.fetchFavorites( + allowedConnectionIds: Set(targets.keys) + ) + async let historyEntries = recentHistory(forConnections: Array(targets.keys)) + let (loadedFavorites, loadedHistoryEntries) = await (favorites, historyEntries) + + guard activeCrossConnectionQueryLoadId == loadId, + !Task.isCancelled, + version == crossConnectionQueryVersion else { return } + + loadedCrossConnectionQueryVersion = version + crossConnectionQueryItems = Self.makeCrossConnectionQueryItems( + favorites: loadedFavorites, + historyEntries: loadedHistoryEntries, + targets: targets, + currentConnectionId: connectionId + ) + } + + /// The panel's own connection stays listed while its session is reconnecting. Saved queries and + /// history are stored locally, so a session that dropped is no reason to hide the queries the + /// panel was opened next to, and the All scope keeps showing them either way. + private func queryTargets() -> [UUID: QuickSwitcherTarget] { + var targets = Dictionary( + connectedSessions().map { ($0.id, queryTarget(for: $0)) }, + uniquingKeysWith: { _, latest in latest } + ) + if targets[connectionId] == nil, + let session = services.databaseManager.session(for: connectionId) { + targets[connectionId] = queryTarget(for: session) + } + return targets + } + + /// One busy connection must not crowd every other one out of the list. A single query capped at + /// `maxResults` and ordered by recency returns nothing but the connection that ran the most + /// statements today, so each connection is read separately and the union is interleaved. + private func recentHistory(forConnections connectionIds: [UUID]) async -> [QueryHistoryEntry] { + let manager = services.queryHistoryManager + let limit = QuickSwitcherRanking.maxResults + let perConnection = await withTaskGroup(of: [QueryHistoryEntry].self) { group in + for id in connectionIds { + group.addTask { + await manager.fetchHistory(limit: limit, connectionId: id) + } + } + var collected: [[QueryHistoryEntry]] = [] + for await entries in group { + collected.append(entries) + } + return collected + } + return Self.interleaveByConnection(perConnection, limit: limit) + } + + nonisolated static func interleaveByConnection( + _ perConnection: [[QueryHistoryEntry]], + limit: Int + ) -> [QueryHistoryEntry] { + var queues = perConnection.filter { !$0.isEmpty } + var merged: [QueryHistoryEntry] = [] + var queueIndex = 0 + while merged.count < limit, !queues.isEmpty { + if queueIndex >= queues.count { queueIndex = 0 } + merged.append(queues[queueIndex].removeFirst()) + if queues[queueIndex].isEmpty { + queues.remove(at: queueIndex) + } else { + queueIndex += 1 + } + } + return merged.sorted { $0.executedAt > $1.executedAt } + } + private func connectedSessions() -> [ConnectionSession] { services.databaseManager.activeSessions.values .filter { $0.isConnected && $0.driver != nil } @@ -272,6 +393,23 @@ internal final class QuickSwitcherViewModel { } } + private func queryTarget(for session: ConnectionSession) -> QuickSwitcherTarget { + let scope = services.databaseManager.browseScope(for: session.id) + let databaseName = scope.flatMap { $0.database.isEmpty ? nil : $0.database } + let pathFieldRole = session.connection.type.pathFieldRole + return QuickSwitcherTarget( + connectionId: session.id, + connectionName: session.connection.name, + databaseName: databaseName, + schemaName: scope?.schema, + databaseDisplayName: Self.databaseDisplayName( + databaseName, + pathFieldRole: pathFieldRole + ), + pathFieldRole: pathFieldRole + ) + } + private func crossConnectionItems( for sessions: [ConnectionSession], loaded loadedConnectionIds: Set @@ -281,15 +419,17 @@ internal final class QuickSwitcherViewModel { .flatMap { session -> [QuickSwitcherItem] in guard let scope = services.databaseManager.browseScope(for: session.id) else { return [] } let databaseName = scope.database.isEmpty ? nil : scope.database - let target = QuickSwitcherObjectTarget( + let pathFieldRole = session.connection.type.pathFieldRole + let target = QuickSwitcherTarget( connectionId: session.id, connectionName: session.connection.name, databaseName: databaseName, schemaName: scope.schema, databaseDisplayName: Self.databaseDisplayName( databaseName, - pathFieldRole: session.connection.type.pathFieldRole - ) + pathFieldRole: pathFieldRole + ), + pathFieldRole: pathFieldRole ) return Self.makeCrossConnectionItems( tables: services.schemaService.allLoadedTables(for: session.id), @@ -320,30 +460,82 @@ internal final class QuickSwitcherViewModel { nonisolated static func makeCrossConnectionItems( tables: [TableInfo], - target: QuickSwitcherObjectTarget + target: QuickSwitcherTarget ) -> [QuickSwitcherItem] { tables.map { table in let presentation = tablePresentation(for: table.type) - let resolvedTarget = QuickSwitcherObjectTarget( + let resolvedTarget = QuickSwitcherTarget( connectionId: target.connectionId, connectionName: target.connectionName, databaseName: target.databaseName, schemaName: table.schema ?? target.schemaName, - databaseDisplayName: target.databaseDisplayName + databaseDisplayName: target.databaseDisplayName, + pathFieldRole: target.pathFieldRole ) return QuickSwitcherItem( id: "connection_\(target.connectionId.uuidString)_\(table.id)", name: table.name, kind: presentation.kind, - subtitle: objectPath(for: resolvedTarget), + subtitle: connectionPath(for: resolvedTarget), isReadOnly: !table.type.allowsRowEditing, - objectTarget: resolvedTarget + target: resolvedTarget ) } } + nonisolated static func makeCrossConnectionQueryItems( + favorites: [SQLFavorite], + historyEntries: [QueryHistoryEntry], + targets: [UUID: QuickSwitcherTarget], + currentConnectionId: UUID + ) -> [QuickSwitcherItem] { + let favoriteItems = favorites.compactMap { favorite -> QuickSwitcherItem? in + let targetConnectionId = favorite.connectionId ?? currentConnectionId + guard let target = targets[targetConnectionId] else { return nil } + let subtitle = [favorite.keyword, connectionPath(for: target)] + .compactMap { value in value.flatMap { $0.isEmpty ? nil : $0 } } + .joined(separator: " · ") + return QuickSwitcherItem( + id: "favorite_\(favorite.id.uuidString)", + name: favorite.name, + kind: .savedQuery, + subtitle: subtitle, + keyword: favorite.keyword, + payload: favorite.query, + target: target + ) + } + + let historyItems = historyEntries.compactMap { entry -> QuickSwitcherItem? in + guard let baseTarget = targets[entry.connectionId] else { return nil } + let databaseName = entry.databaseName.isEmpty ? nil : entry.databaseName + let target = QuickSwitcherTarget( + connectionId: baseTarget.connectionId, + connectionName: baseTarget.connectionName, + databaseName: databaseName, + schemaName: nil, + databaseDisplayName: databaseDisplayName( + databaseName, + pathFieldRole: baseTarget.pathFieldRole + ) + ) + return QuickSwitcherItem( + id: "history_\(entry.id.uuidString)", + name: entry.queryPreview, + kind: .queryHistory, + subtitle: [connectionPath(for: target), entry.formattedExecutionTime] + .filter { !$0.isEmpty } + .joined(separator: " · "), + payload: entry.query, + target: target + ) + } + + return Array((favoriteItems + historyItems).prefix(QuickSwitcherRanking.maxResults)) + } + func canOpenStructure(_ item: QuickSwitcherItem) -> Bool { - guard let target = item.objectTarget else { return true } + guard let target = item.target else { return true } return target.connectionId == connectionId } @@ -367,7 +559,15 @@ internal final class QuickSwitcherViewModel { } func recordSelection(_ item: QuickSwitcherItem, at date: Date = Date()) { - frecencyStore.recordAccess(itemId: item.id, at: date) + frecencyStore(for: item).recordAccess(itemId: item.id, at: date) + } + + /// A result from another connection is recorded against that connection. The store is keyed per + /// connection and the Recent section resolves its ids against the scope on screen, so recording + /// a foreign id here holds one of ten slots with something this connection can never show. + private func frecencyStore(for item: QuickSwitcherItem) -> QuickSwitcherFrecencyStore { + guard let target = item.target, target.connectionId != connectionId else { return frecencyStore } + return QuickSwitcherFrecencyStore(connectionId: target.connectionId, defaults: defaults) } /// Grouping sorts the whole scoped catalog, which across every open connection runs to @@ -412,7 +612,14 @@ internal final class QuickSwitcherViewModel { } private func scopedItems() -> [QuickSwitcherItem] { - let source = scope.usesCrossConnectionCatalog ? crossConnectionItems : allItems + let source: [QuickSwitcherItem] + if scope.usesCrossConnectionCatalog { + source = crossConnectionItems + } else if scope.usesCrossConnectionQueries { + source = crossConnectionQueryItems + } else { + source = allItems + } guard let includedKinds = scope.includedKinds else { return source } return source.filter { includedKinds.contains($0.kind) } } @@ -463,8 +670,8 @@ internal final class QuickSwitcherViewModel { let sortedItems = items .filter { !excludedIds.contains($0.id) } .sorted { lhs, rhs in - let lhsConnection = lhs.objectTarget?.connectionName ?? "" - let rhsConnection = rhs.objectTarget?.connectionName ?? "" + let lhsConnection = lhs.target?.connectionName ?? "" + let rhsConnection = rhs.target?.connectionName ?? "" let connectionOrder = lhsConnection.localizedStandardCompare(rhsConnection) if connectionOrder != .orderedSame { return connectionOrder == .orderedAscending } return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending @@ -475,7 +682,7 @@ internal final class QuickSwitcherViewModel { var grouped: [UUID: [QuickSwitcherItem]] = [:] var names: [UUID: String] = [:] for item in sortedItems { - guard let target = item.objectTarget else { continue } + guard let target = item.target else { continue } if grouped[target.connectionId] == nil { order.append(target.connectionId) names[target.connectionId] = target.connectionName @@ -522,13 +729,16 @@ internal final class QuickSwitcherViewModel { query: String ) -> (score: Double, matchedIndices: [Int])? { let nameMatch = FuzzyMatcher.match(query: query, candidate: item.name) - let subtitleWeight = item.kind == .savedQuery - ? QuickSwitcherRanking.keywordMatchWeight - : QuickSwitcherRanking.subtitleMatchPenalty - var subtitleScore: Double? + var secondaryScores: [Double] = [] + if let keyword = item.keyword, + !keyword.isEmpty, + let keywordMatch = FuzzyMatcher.match(query: query, candidate: keyword) { + secondaryScores.append(Double(keywordMatch.score) * QuickSwitcherRanking.keywordMatchWeight) + } if !item.subtitle.isEmpty, let subtitleMatch = FuzzyMatcher.match(query: query, candidate: item.subtitle) { - subtitleScore = Double(subtitleMatch.score) * subtitleWeight + secondaryScores.append(Double(subtitleMatch.score) * QuickSwitcherRanking.subtitleMatchPenalty) } + let subtitleScore = secondaryScores.max() switch (nameMatch, subtitleScore) { case let (match?, score?) where score > Double(match.score): @@ -563,7 +773,7 @@ internal final class QuickSwitcherViewModel { } } - nonisolated private static func objectPath(for target: QuickSwitcherObjectTarget) -> String { + nonisolated private static func connectionPath(for target: QuickSwitcherTarget) -> String { var components = [target.connectionName] if let databaseDisplayName = target.databaseDisplayName ?? target.databaseName, !databaseDisplayName.isEmpty { @@ -577,6 +787,13 @@ internal final class QuickSwitcherViewModel { return components.joined(separator: " / ") } + private var crossConnectionQueryVersion: CrossConnectionQueryVersion { + CrossConnectionQueryVersion( + connectedConnectionIds: queryTargets().keys.sorted { $0.uuidString < $1.uuidString }, + contentRevision: crossConnectionQueryContentRevision + ) + } + nonisolated static func databaseDisplayName( _ databaseName: String?, pathFieldRole: PathFieldRole diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 186f0694f..3880e9381 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -12,7 +12,7 @@ import TableProPluginKit private let navigationLogger = Logger(subsystem: "com.TablePro", category: "MainContentCoordinator+Navigation") -internal enum TableTabOpenDisposition: Equatable { +internal enum WindowTabOpenDisposition: Equatable { case currentCoordinator case focusedElsewhere } @@ -28,7 +28,7 @@ extension MainContentCoordinator { forceNonPreview: Bool = false, activateGridFocus: Bool = false, forceNewWindowTab: Bool = false - ) -> TableTabOpenDisposition? { + ) -> WindowTabOpenDisposition? { openTableTab( table.name, schema: schema ?? table.schema, @@ -49,7 +49,7 @@ extension MainContentCoordinator { forceNonPreview: Bool = false, activateGridFocus: Bool = false, forceNewWindowTab: Bool = false - ) -> TableTabOpenDisposition? { + ) -> WindowTabOpenDisposition? { let navigationModel = PluginMetadataRegistry.shared.snapshot( forTypeId: connection.type.pluginTypeId )?.navigationModel ?? .standard @@ -198,7 +198,7 @@ extension MainContentCoordinator { showStructure: Bool, activateGridFocus: Bool, includeSiblings: Bool - ) -> TableTabOpenDisposition? { + ) -> WindowTabOpenDisposition? { func matches(_ tab: QueryTab) -> Bool { tab.tabType == .table && tab.tableContext.tableName == tableName diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index d1443d0dc..a08f41145 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -31,12 +31,18 @@ extension MainContentCoordinator { } func handleQuickSwitcherSelection(_ item: QuickSwitcherItem, intent: QuickSwitcherCommitIntent = .open) { - if let target = item.objectTarget, target.connectionId != connectionId { + if let target = item.target, + item.kind == .savedQuery || item.kind == .queryHistory { + openQuickSwitcherQuery(item, target: target, intent: intent) + return + } + + if let target = item.target, target.connectionId != connectionId { openQuickSwitcherObject(item, target: target, intent: intent) return } - let schemaName = item.objectTarget?.schemaName + let schemaName = item.target?.schemaName switch item.kind { case .table, .systemTable: openTableTab( @@ -69,19 +75,25 @@ extension MainContentCoordinator { } case .savedQuery: - loadQueryIntoEditor(item.payload ?? item.name) + loadQueryIntoEditor( + item.payload ?? item.name, + forceNewWindowTab: intent == .openInNewWindowTab + ) case .queryHistory: - loadQueryIntoEditor(item.payload ?? item.name) + loadQueryIntoEditor( + item.payload ?? item.name, + forceNewWindowTab: intent == .openInNewWindowTab + ) } } private func openQuickSwitcherObject( _ item: QuickSwitcherItem, - target: QuickSwitcherObjectTarget, + target: QuickSwitcherTarget, intent: QuickSwitcherCommitIntent ) { - if let coordinator = Self.coordinator(browsing: target) { + if let coordinator = coordinator(browsing: target) { let disposition = coordinator.openTableTab( item.name, schema: target.schemaName, @@ -122,17 +134,67 @@ extension MainContentCoordinator { } } + private func openQuickSwitcherQuery( + _ item: QuickSwitcherItem, + target: QuickSwitcherTarget, + intent: QuickSwitcherCommitIntent + ) { + guard let session = services.databaseManager.session(for: target.connectionId), + session.isConnected, + session.driver != nil else { + AlertHelper.showErrorSheet( + title: String(localized: "Could Not Open Query"), + message: String(localized: "The target connection is no longer open."), + window: contentWindow + ) + return + } + + let query = item.payload ?? item.name + if let coordinator = coordinator(browsing: target) { + let disposition = coordinator.loadQueryIntoEditor( + query, + databaseName: target.databaseName, + forceNewWindowTab: intent == .openInNewWindowTab + ) + if disposition == .currentCoordinator, + let tabId = coordinator.tabManager.selectedTabId { + coordinator.selectTabAndFocusWindow(tabId) + } + return + } + + let payload = EditorTabPayload( + connectionId: target.connectionId, + tabType: .query, + databaseName: target.databaseName, + initialQuery: query + ) + openTabInNewWindow(payload) + } + /// A window already on the target database can open any of its schemas directly, because /// `openTableTab` carries the schema per tab. Matching on the browsed schema too would send /// most results down the router instead, which reconnects the connection and retargets that /// window's sidebar as a side effect of opening one table. - private static func coordinator(browsing target: QuickSwitcherObjectTarget) -> MainContentCoordinator? { - allActiveCoordinators().first { coordinator in - guard coordinator.connectionId == target.connectionId, - let session = coordinator.services.databaseManager.session(for: target.connectionId), - session.isConnected else { return false } - guard let databaseName = target.databaseName else { return true } - return coordinator.browseDatabaseName == databaseName - } + private func canHost(_ target: QuickSwitcherTarget) -> Bool { + guard connectionId == target.connectionId, + let session = services.databaseManager.session(for: target.connectionId), + session.isConnected else { return false } + guard let databaseName = target.databaseName else { return true } + return browseDatabaseName == databaseName + } + + /// Adapter over `QuickSwitcherHostResolver`, which owns the choice itself. + private func coordinator(browsing target: QuickSwitcherTarget) -> MainContentCoordinator? { + let candidates = Self.allActiveCoordinators().filter { $0.canHost(target) } + guard !candidates.isEmpty else { return nil } + let lastFocused = WindowLifecycleMonitor.shared.mostRecentWindow(for: target.connectionId) + let hostId = QuickSwitcherHostResolver.host( + preferred: canHost(target) ? instanceId : nil, + candidates: candidates.map(\.instanceId), + mostRecentlyFocused: candidates.first { $0.contentWindow === lastFocused }?.instanceId + ) + return candidates.first { $0.instanceId == hostId } } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index bd5c2e2c9..6e4d789fb 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -997,23 +997,38 @@ final class MainContentCoordinator { // MARK: - Editor Query Loading - func loadQueryIntoEditor(_ query: String) { - if let (tab, tabIndex) = tabManager.selectedTabAndIndex, - tab.tabType == .query { + @discardableResult + func loadQueryIntoEditor( + _ query: String, + databaseName: String? = nil, + forceNewWindowTab: Bool = false + ) -> WindowTabOpenDisposition { + let targetDatabaseName = databaseName ?? browseDatabaseName + if !forceNewWindowTab, + let (tab, tabIndex) = tabManager.selectedTabAndIndex, + tab.tabType == .query, + databaseName == nil + || tab.tableContext.resolvedDatabaseName(browsing: browseDatabaseName) == targetDatabaseName { tabManager.mutate(at: tabIndex) { $0.content.query = query $0.hasUserInteraction = true } - } else if tabManager.tabs.isEmpty { - tabManager.addTab(initialQuery: query, databaseName: browseDatabaseName) - } else { - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: query - ) - WindowManager.shared.openTab(payload: payload) + return .currentCoordinator + } + + if !forceNewWindowTab, tabManager.tabs.isEmpty { + tabManager.addTab(initialQuery: query, databaseName: targetDatabaseName) + return .currentCoordinator } + + let payload = EditorTabPayload( + connectionId: connection.id, + tabType: .query, + databaseName: targetDatabaseName, + initialQuery: query + ) + openTabInNewWindow(payload) + return .focusedElsewhere } var aiInsertReusesSelectedQueryTab: Bool { diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index 398a17d58..f0f098243 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -69,6 +69,15 @@ struct QuickSwitcherPanelView: View { .task(id: viewModel.crossConnectionLoadVersion) { await viewModel.loadCrossConnectionItems() } + .task(id: viewModel.crossConnectionQueryLoadVersion) { + await viewModel.loadCrossConnectionQueryItems() + } + .onReceive(AppEvents.shared.queryHistoryDidUpdate) { _ in + viewModel.invalidateCrossConnectionQueryItems() + } + .onReceive(AppEvents.shared.sqlFavoritesDidUpdate) { _ in + viewModel.invalidateCrossConnectionQueryItems() + } } } @@ -345,7 +354,7 @@ struct QuickSwitcherPanelContent: View { /// the commit is about to open and nothing else on the row carries that. private func showsSubtitle(for item: QuickSwitcherItem, isSelected: Bool) -> Bool { guard !item.subtitle.isEmpty else { return false } - return !isSelected || item.objectTarget != nil + return !isSelected || item.target != nil } private func keycap(_ label: String, isEmphasized: Bool) -> some View { @@ -399,11 +408,14 @@ struct QuickSwitcherPanelContent: View { viewModel.selectedItemId = item.id onCommit(item, .open) } - if item.kind == .table || item.kind == .view || item.kind == .systemTable { + if item.kind == .table || item.kind == .view || item.kind == .systemTable + || item.kind == .savedQuery || item.kind == .queryHistory { Button(String(localized: "Open in New Tab")) { viewModel.selectedItemId = item.id onCommit(item, .openInNewWindowTab) } + } + if item.kind == .table || item.kind == .view || item.kind == .systemTable { if viewModel.canOpenStructure(item) { Button(String(localized: "Open Structure")) { viewModel.selectedItemId = item.id diff --git a/TableProTests/Core/Services/QuickSwitcherHostResolverTests.swift b/TableProTests/Core/Services/QuickSwitcherHostResolverTests.swift new file mode 100644 index 000000000..03b69881a --- /dev/null +++ b/TableProTests/Core/Services/QuickSwitcherHostResolverTests.swift @@ -0,0 +1,92 @@ +// +// QuickSwitcherHostResolverTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("QuickSwitcherHostResolver") +struct QuickSwitcherHostResolverTests { + /// The registry the candidates come from is a dictionary with no order, so before this was + /// fixed the result landed in whichever window it happened to yield first, overwriting an + /// editor the user was not looking at. + @Test("The invoking window wins over every other window that could host") + func preferredWindowWins() { + let invoking = UUID() + let others = [UUID(), UUID(), UUID()] + + let host = QuickSwitcherHostResolver.host( + preferred: invoking, + candidates: others + [invoking], + mostRecentlyFocused: others[0] + ) + + #expect(host == invoking) + } + + @Test("The most recently focused window wins when the invoking window cannot host") + func mostRecentlyFocusedWinsWithoutPreferred() { + let candidates = [UUID(), UUID(), UUID()] + + let host = QuickSwitcherHostResolver.host( + preferred: nil, + candidates: candidates, + mostRecentlyFocused: candidates[2] + ) + + #expect(host == candidates[2]) + } + + @Test("A candidate is picked when nothing was recently focused") + func fallsBackToFirstCandidate() { + let candidates = [UUID(), UUID()] + + let host = QuickSwitcherHostResolver.host( + preferred: nil, + candidates: candidates, + mostRecentlyFocused: nil + ) + + #expect(host == candidates[0]) + } + + @Test("A recently focused window that cannot host is ignored") + func ignoresFocusedWindowOutsideCandidates() { + let candidates = [UUID(), UUID()] + + let host = QuickSwitcherHostResolver.host( + preferred: nil, + candidates: candidates, + mostRecentlyFocused: UUID() + ) + + #expect(host == candidates[0]) + } + + @Test("An invoking window that cannot host does not win") + func preferredOutsideCandidatesIsIgnored() { + let candidates = [UUID(), UUID()] + + let host = QuickSwitcherHostResolver.host( + preferred: UUID(), + candidates: candidates, + mostRecentlyFocused: candidates[1] + ) + + #expect(host == candidates[1]) + } + + @Test("No candidate means no host") + func noCandidatesYieldsNil() { + let host = QuickSwitcherHostResolver.host( + preferred: UUID(), + candidates: [UUID](), + mostRecentlyFocused: UUID() + ) + + #expect(host == nil) + } +} diff --git a/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift b/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift index 8c86abf6a..9daf6b631 100644 --- a/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift +++ b/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift @@ -338,4 +338,32 @@ struct SQLFavoriteStorageTests { let results = await storage.fetchFavorites(searchText: "large_table") #expect(results.contains { $0.id == fav.id }) } + + @Test("An allowed connection set keeps global favorites and drops the rest") + func allowedConnectionIdsKeepsGlobals() async { + let allowed = UUID() + let excluded = UUID() + let global = makeFavorite(name: "Global", connectionId: nil) + let allowedFavorite = makeFavorite(name: "Allowed", connectionId: allowed) + let excludedFavorite = makeFavorite(name: "Excluded", connectionId: excluded) + #expect(await storage.addFavorite(global)) + #expect(await storage.addFavorite(allowedFavorite)) + #expect(await storage.addFavorite(excludedFavorite)) + + let results = await storage.fetchFavorites(allowedConnectionIds: [allowed]) + let names = Set(results.map(\.name)) + + #expect(names.contains("Global")) + #expect(names.contains("Allowed")) + #expect(!names.contains("Excluded")) + } + + @Test("An empty allowed connection set returns nothing") + func emptyAllowedConnectionIdsReturnsNothing() async { + #expect(await storage.addFavorite(makeFavorite(name: "Global", connectionId: nil))) + + let results = await storage.fetchFavorites(allowedConnectionIds: []) + + #expect(results.isEmpty) + } } diff --git a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift index de61a00db..aa0ff700f 100644 --- a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift @@ -32,7 +32,9 @@ struct QuickSwitcherViewModelTests { private func makeServices( databaseManager: DatabaseManager, schemaService: SchemaService, - schemaRefreshService: SchemaRefreshService + schemaRefreshService: SchemaRefreshService, + sqlFavoriteManager: SQLFavoriteManager? = nil, + queryHistoryManager: QueryHistoryManager? = nil ) -> AppServices { let live = AppServices.live return AppServices( @@ -45,7 +47,7 @@ struct QuickSwitcherViewModelTests { schemaService: schemaService, schemaRefreshService: schemaRefreshService, schemaProviderRegistry: SchemaProviderRegistry(), - sqlFavoriteManager: live.sqlFavoriteManager, + sqlFavoriteManager: sqlFavoriteManager ?? live.sqlFavoriteManager, favoriteTablesStorage: live.favoriteTablesStorage, aiChatStorage: live.aiChatStorage, aiKeyStorage: live.aiKeyStorage, @@ -56,7 +58,7 @@ struct QuickSwitcherViewModelTests { syncMetadataStorage: live.syncMetadataStorage, favoritesExpansionState: live.favoritesExpansionState, linkedFolderWatcher: live.linkedFolderWatcher, - queryHistoryManager: live.queryHistoryManager, + queryHistoryManager: queryHistoryManager ?? live.queryHistoryManager, dateFormattingService: live.dateFormattingService, copilotService: live.copilotService, mcpServerManager: live.mcpServerManager, @@ -109,7 +111,7 @@ struct QuickSwitcherViewModelTests { let localConnectionId = UUID() let remoteConnectionId = UUID() let local = QuickSwitcherItem(id: "local", name: "users", kind: .table, subtitle: "") - let remoteTarget = QuickSwitcherObjectTarget( + let remoteTarget = QuickSwitcherTarget( connectionId: remoteConnectionId, connectionName: "Analytics", databaseName: "warehouse", @@ -120,7 +122,7 @@ struct QuickSwitcherViewModelTests { name: "events", kind: .table, subtitle: "Analytics / warehouse / public", - objectTarget: remoteTarget + target: remoteTarget ) let vm = makeViewModel(items: [local], connectionId: localConnectionId) vm.crossConnectionItems = [remote] @@ -131,6 +133,173 @@ struct QuickSwitcherViewModelTests { #expect(vm.groups.first?.header == "Analytics") } + @Test("Queries scope loads saved and recent queries from connected sessions") + func queriesScopeLoadsConnectedSessions() async throws { + let localConnection = TestFixtures.makeConnection(name: "Primary", database: "app") + let remoteConnection = TestFixtures.makeConnection(name: "Analytics", database: "warehouse") + let inactiveConnection = TestFixtures.makeConnection(name: "Offline", database: "archive") + let databaseManager = DatabaseManager() + let schemaService = SchemaService() + let schemaRefreshService = SchemaRefreshService( + schemaService: schemaService, + providerRegistry: SchemaProviderRegistry(), + metadataDriverProvider: databaseManager, + databaseManager: databaseManager + ) + let favoriteStorage = SQLFavoriteStorage( + databaseURL: FileManager.default.temporaryDirectory + .appendingPathComponent("quick-switcher-favorites-\(UUID().uuidString).db"), + removeDatabaseOnDeinit: true + ) + let historyStorage = QueryHistoryStorage( + databaseURL: FileManager.default.temporaryDirectory + .appendingPathComponent("quick-switcher-history-\(UUID().uuidString).db"), + removeDatabaseOnDeinit: true + ) + let favoriteManager = SQLFavoriteManager(storage: favoriteStorage) + let historyManager = QueryHistoryManager(storage: historyStorage) + let services = makeServices( + databaseManager: databaseManager, + schemaService: schemaService, + schemaRefreshService: schemaRefreshService, + sqlFavoriteManager: favoriteManager, + queryHistoryManager: historyManager + ) + + for connection in [localConnection, remoteConnection] { + var session = ConnectionSession(connection: connection, driver: MockDatabaseDriver(connection: connection)) + session.status = .connected + session.browseDatabase = connection.database + databaseManager.injectSession(session, for: connection.id) + } + defer { + databaseManager.removeSession(for: localConnection.id) + databaseManager.removeSession(for: remoteConnection.id) + } + + let globalFavorite = SQLFavorite(name: "Global health", query: "SELECT 1") + let remoteFavorite = SQLFavorite( + name: "Daily revenue", + query: "SELECT SUM(total) FROM orders", + keyword: "revenue", + connectionId: remoteConnection.id + ) + let inactiveFavorite = SQLFavorite( + name: "Archived users", + query: "SELECT * FROM users", + connectionId: inactiveConnection.id + ) + #expect(await favoriteStorage.addFavorite(globalFavorite)) + #expect(await favoriteStorage.addFavorite(remoteFavorite)) + #expect(await favoriteStorage.addFavorite(inactiveFavorite)) + + let remoteHistory = QueryHistoryEntry( + query: "SELECT * FROM events", + connectionId: remoteConnection.id, + databaseName: "reporting", + executionTime: 0.025, + rowCount: 12, + wasSuccessful: true + ) + let inactiveHistory = QueryHistoryEntry( + query: "DELETE FROM audit_log", + connectionId: inactiveConnection.id, + databaseName: "archive", + executionTime: 1, + rowCount: 1, + wasSuccessful: true + ) + #expect(await historyStorage.addHistory(remoteHistory)) + #expect(await historyStorage.addHistory(inactiveHistory)) + + let vm = makeViewModel( + items: [QuickSwitcherItem(id: "stale", name: "stale", kind: .queryHistory, subtitle: "")], + connectionId: localConnection.id, + services: services + ) + vm.scope = .queries + await vm.loadCrossConnectionQueryItems() + await vm.flushPendingFilter() + + #expect(Set(vm.flatItems.map(\.id)) == Set([ + "favorite_\(globalFavorite.id.uuidString)", + "favorite_\(remoteFavorite.id.uuidString)", + "history_\(remoteHistory.id.uuidString)" + ])) + #expect(vm.flatItems.contains { $0.id == "stale" } == false) + let globalItem = vm.flatItems.first { $0.id.contains(globalFavorite.id.uuidString) } + #expect(globalItem?.target?.connectionId == localConnection.id) + let historyItem = try #require(vm.flatItems.first { $0.id.contains(remoteHistory.id.uuidString) }) + #expect(historyItem.target?.connectionId == remoteConnection.id) + #expect(historyItem.target?.databaseName == "reporting") + #expect(historyItem.subtitle.contains("Analytics / reporting")) + #expect(historyItem.subtitle.contains("25 ms")) + + vm.searchText = "analytics" + await vm.flushPendingFilter() + #expect(Set(vm.flatItems.map(\.id)) == Set([ + "favorite_\(remoteFavorite.id.uuidString)", + "history_\(remoteHistory.id.uuidString)" + ])) + } + + @Test("Invalidating cross-connection queries reloads storage changes") + func invalidatingCrossConnectionQueriesReloadsChanges() async { + let connection = TestFixtures.makeConnection(name: "Primary", database: "app") + let databaseManager = DatabaseManager() + let schemaService = SchemaService() + let schemaRefreshService = SchemaRefreshService( + schemaService: schemaService, + providerRegistry: SchemaProviderRegistry(), + metadataDriverProvider: databaseManager, + databaseManager: databaseManager + ) + let favoriteStorage = SQLFavoriteStorage( + databaseURL: FileManager.default.temporaryDirectory + .appendingPathComponent("quick-switcher-refresh-\(UUID().uuidString).db"), + removeDatabaseOnDeinit: true + ) + let services = makeServices( + databaseManager: databaseManager, + schemaService: schemaService, + schemaRefreshService: schemaRefreshService, + sqlFavoriteManager: SQLFavoriteManager(storage: favoriteStorage) + ) + var session = ConnectionSession(connection: connection, driver: MockDatabaseDriver(connection: connection)) + session.status = .connected + databaseManager.injectSession(session, for: connection.id) + defer { databaseManager.removeSession(for: connection.id) } + + let vm = makeViewModel(items: [], connectionId: connection.id, services: services) + vm.scope = .queries + await vm.loadCrossConnectionQueryItems() + #expect(vm.crossConnectionQueryItems.isEmpty) + + let favorite = SQLFavorite(name: "Late arrival", query: "SELECT 2", connectionId: connection.id) + #expect(await favoriteStorage.addFavorite(favorite)) + vm.invalidateCrossConnectionQueryItems() + await vm.loadCrossConnectionQueryItems() + + #expect(vm.crossConnectionQueryItems.map(\.id) == ["favorite_\(favorite.id.uuidString)"]) + } + + @Test("Queries scope caps an oversized local catalog") + func queriesScopeCapsLargeCatalog() async { + let vm = makeViewModel(items: []) + vm.crossConnectionQueryItems = (0..<300).map { index in + QuickSwitcherItem( + id: "favorite_\(index)", + name: "Query \(index)", + kind: .savedQuery, + subtitle: "Primary / app" + ) + } + vm.scope = .queries + await vm.flushPendingFilter() + + #expect(vm.flatItems.count == 200) + } + @Test("Connections scope replaces tables from a previous browse scope") func connectionsScopeReplacesStaleScopeTables() async throws { let connection = TestFixtures.makeConnection(database: "primary", type: .pglite) @@ -173,7 +342,7 @@ struct QuickSwitcherViewModelTests { await vm.flushPendingFilter() #expect(vm.flatItems.map(\.name) == ["events"]) - #expect(vm.flatItems.first?.objectTarget?.databaseName == "analytics") + #expect(vm.flatItems.first?.target?.databaseName == "analytics") #expect(!vm.flatItems.contains { $0.name == "legacy_orders" }) } @@ -233,7 +402,7 @@ struct QuickSwitcherViewModelTests { @Test("Connections scope matches a connection name") func connectionsScopeMatchesConnectionName() async throws { - let target = QuickSwitcherObjectTarget( + let target = QuickSwitcherTarget( connectionId: UUID(), connectionName: "Analytics", databaseName: "warehouse", @@ -244,7 +413,7 @@ struct QuickSwitcherViewModelTests { name: "events", kind: .table, subtitle: "Analytics / warehouse", - objectTarget: target + target: target ) let vm = makeViewModel(items: []) vm.crossConnectionItems = [remote] @@ -253,12 +422,12 @@ struct QuickSwitcherViewModelTests { try await Task.sleep(nanoseconds: 200_000_000) #expect(vm.flatItems.first?.id == "remote") - #expect(vm.flatItems.first?.objectTarget == target) + #expect(vm.flatItems.first?.target == target) } @Test("Changing the query selects the best match") func changingQuerySelectsBestMatch() async throws { - let target = QuickSwitcherObjectTarget( + let target = QuickSwitcherTarget( connectionId: UUID(), connectionName: "Chinook", databaseName: "sample.sqlite", @@ -286,7 +455,7 @@ struct QuickSwitcherViewModelTests { @Test("Cross-connection catalog keeps object location") func crossConnectionCatalogKeepsLocation() { let connectionId = UUID() - let target = QuickSwitcherObjectTarget( + let target = QuickSwitcherTarget( connectionId: connectionId, connectionName: "Primary", databaseName: "app", @@ -301,8 +470,8 @@ struct QuickSwitcherViewModelTests { #expect(items.count == 2) #expect(items[0].id.contains(connectionId.uuidString)) - #expect(items[0].objectTarget?.schemaName == "public") - #expect(items[1].objectTarget?.schemaName == "fallback") + #expect(items[0].target?.schemaName == "public") + #expect(items[1].target?.schemaName == "fallback") #expect(items[1].kind == .view) #expect(items.allSatisfy { $0.subtitle.contains("Primary / app") }) } @@ -311,7 +480,7 @@ struct QuickSwitcherViewModelTests { func fileDatabasePathsAreAbbreviated() { let databasePath = NSHomeDirectory() + "/Databases/private.sqlite" let displayName = QuickSwitcherViewModel.databaseDisplayName(databasePath, pathFieldRole: .filePath) - let target = QuickSwitcherObjectTarget( + let target = QuickSwitcherTarget( connectionId: UUID(), connectionName: "Local", databaseName: databasePath, @@ -326,12 +495,12 @@ struct QuickSwitcherViewModelTests { #expect(displayName == "~/Databases/private.sqlite") #expect(!item.subtitle.contains(NSHomeDirectory())) - #expect(item.objectTarget?.databaseName == databasePath) + #expect(item.target?.databaseName == databasePath) } @Test("Identical object names in different schemas keep unique identities") func duplicateNamesAcrossSchemasStayUnique() { - let target = QuickSwitcherObjectTarget( + let target = QuickSwitcherTarget( connectionId: UUID(), connectionName: "Primary", databaseName: "app", @@ -345,12 +514,12 @@ struct QuickSwitcherViewModelTests { let items = QuickSwitcherViewModel.makeCrossConnectionItems(tables: tables, target: target) #expect(Set(items.map(\.id)).count == 2) - #expect(Set(items.compactMap(\.objectTarget?.schemaName)) == Set(["public", "audit"])) + #expect(Set(items.compactMap(\.target?.schemaName)) == Set(["public", "audit"])) } @Test("Connections scope caps a hostile catalog") func connectionsScopeCapsLargeCatalog() async { - let target = QuickSwitcherObjectTarget( + let target = QuickSwitcherTarget( connectionId: UUID(), connectionName: "Primary", databaseName: "app", @@ -384,13 +553,13 @@ struct QuickSwitcherViewModelTests { func structureActionStaysInCurrentConnection() { let connectionId = UUID() let vm = makeViewModel(items: [], connectionId: connectionId) - let currentTarget = QuickSwitcherObjectTarget( + let currentTarget = QuickSwitcherTarget( connectionId: connectionId, connectionName: "Primary", databaseName: nil, schemaName: nil ) - let remoteTarget = QuickSwitcherObjectTarget( + let remoteTarget = QuickSwitcherTarget( connectionId: UUID(), connectionName: "Analytics", databaseName: nil, @@ -402,14 +571,14 @@ struct QuickSwitcherViewModelTests { name: "users", kind: .table, subtitle: "", - objectTarget: currentTarget + target: currentTarget ))) #expect(!vm.canOpenStructure(QuickSwitcherItem( id: "remote", name: "events", kind: .table, subtitle: "", - objectTarget: remoteTarget + target: remoteTarget ))) } @@ -579,6 +748,7 @@ struct QuickSwitcherViewModelTests { payload: "SELECT SUM(total) FROM orders GROUP BY month;" )) let vm = makeViewModel(items: items) + vm.crossConnectionQueryItems = items vm.scope = .queries await vm.flushPendingFilter() let headers = vm.groups.compactMap(\.header) @@ -749,4 +919,112 @@ struct QuickSwitcherViewModelTests { let vm = QuickSwitcherViewModel(connectionId: UUID(), services: .live, defaults: makeDefaults()) #expect(vm.isLoading) } + + @Test("A large first section does not delete the sections after it") + func largeSectionKeepsLaterSections() async { + var items: [QuickSwitcherItem] = [] + for index in 0..<250 { + items.append(QuickSwitcherItem(id: "t\(index)", name: "table_\(index)", kind: .table, subtitle: "")) + } + for index in 0..<40 { + items.append(QuickSwitcherItem(id: "v\(index)", name: "view_\(index)", kind: .view, subtitle: "View")) + } + let vm = makeViewModel(items: items) + vm.scope = .tables + await vm.flushPendingFilter() + + let headers = vm.groups.compactMap(\.header) + #expect(headers.contains(String(localized: "Tables"))) + #expect(headers.contains(String(localized: "Views"))) + #expect(vm.groups.first { $0.header == String(localized: "Views") }?.items.count == 40) + } + + @Test("A busy connection does not crowd another out of the history list") + func historyInterleavesAcrossConnections() { + let busy = UUID() + let quiet = UUID() + let now = Date() + let busyEntries = (0..<200).map { index in + QueryHistoryEntry( + query: "SELECT \(index)", + connectionId: busy, + databaseName: "app", + executedAt: now.addingTimeInterval(-Double(index)), + executionTime: 0.01, + rowCount: 1, + wasSuccessful: true + ) + } + let quietEntries = [ + QueryHistoryEntry( + query: "SELECT yesterday", + connectionId: quiet, + databaseName: "warehouse", + executedAt: now.addingTimeInterval(-86_400), + executionTime: 0.01, + rowCount: 1, + wasSuccessful: true + ) + ] + + let merged = QuickSwitcherViewModel.interleaveByConnection( + [busyEntries, quietEntries], + limit: 200 + ) + + #expect(merged.count == 200) + #expect(merged.contains { $0.connectionId == quiet }) + } + + @Test("A keyword outranks a connection name in a saved query subtitle") + func keywordOutranksConnectionPath() async { + let keyworded = QuickSwitcherItem( + id: "favorite_keyworded", + name: "Daily totals", + kind: .savedQuery, + subtitle: "prod · Analytics / warehouse", + keyword: "prod", + payload: "SELECT 1" + ) + let pathOnly = QuickSwitcherItem( + id: "favorite_path", + name: "Customer churn", + kind: .savedQuery, + subtitle: "Production / app", + payload: "SELECT 2" + ) + let vm = makeViewModel(items: [pathOnly, keyworded]) + vm.searchText = "prod" + await vm.flushPendingFilter() + + #expect(vm.flatItems.first?.id == "favorite_keyworded") + } + + @Test("Selecting another connection's result records it against that connection") + func frecencyRecordsAgainstOwningConnection() { + let suite = makeDefaults() + let localConnectionId = UUID() + let remoteConnectionId = UUID() + let vm = makeViewModel(items: [], connectionId: localConnectionId, defaults: suite) + let remote = QuickSwitcherItem( + id: "history_remote", + name: "SELECT * FROM events", + kind: .queryHistory, + subtitle: "Analytics / warehouse", + payload: "SELECT * FROM events", + target: QuickSwitcherTarget( + connectionId: remoteConnectionId, + connectionName: "Analytics", + databaseName: "warehouse", + schemaName: nil + ) + ) + + vm.recordSelection(remote) + + let localStore = QuickSwitcherFrecencyStore(connectionId: localConnectionId, defaults: suite) + let remoteStore = QuickSwitcherFrecencyStore(connectionId: remoteConnectionId, defaults: suite) + #expect(localStore.recentItemIds(limit: 10).isEmpty) + #expect(remoteStore.recentItemIds(limit: 10) == ["history_remote"]) + } } diff --git a/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift b/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift index c7c1e41f1..5a48d0c2c 100644 --- a/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift +++ b/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift @@ -29,6 +29,7 @@ struct CoordinatorEditorLoadTests { changeManager: changeManager, toolbarState: toolbarState ) + coordinator.openTabInNewWindow = { _ in } return (coordinator, tabManager) } @@ -42,9 +43,10 @@ struct CoordinatorEditorLoadTests { tabManager.addTab(initialQuery: "SELECT 1") - coordinator.loadQueryIntoEditor("SELECT * FROM users") + let disposition = coordinator.loadQueryIntoEditor("SELECT * FROM users") #expect(tabManager.tabs[0].content.query == "SELECT * FROM users") + #expect(disposition == .currentCoordinator) } @Test("loadQueryIntoEditor sets hasUserInteraction to true") @@ -91,6 +93,81 @@ struct CoordinatorEditorLoadTests { #expect(tabManager.tabs.count == 1) #expect(tabManager.tabs[0].tabType == .query) #expect(tabManager.tabs[0].content.query == "SELECT 1") + #expect(tabManager.tabs[0].tableContext.databaseName == "testdb") + } + + @Test("loadQueryIntoEditor reuses a query tab in the target database") + @MainActor + func loadQueryReusesMatchingDatabase() { + let (coordinator, tabManager) = makeCoordinator() + defer { coordinator.teardown() } + + tabManager.addTab(initialQuery: "SELECT 1", databaseName: "analytics") + + let disposition = coordinator.loadQueryIntoEditor("SELECT 2", databaseName: "analytics") + + #expect(disposition == .currentCoordinator) + #expect(tabManager.tabs[0].content.query == "SELECT 2") + #expect(tabManager.tabs[0].tableContext.databaseName == "analytics") + } + + @Test("loadQueryIntoEditor does not overwrite a query tab from another database") + @MainActor + func loadQueryPreservesDifferentDatabase() { + let (coordinator, tabManager) = makeCoordinator() + defer { coordinator.teardown() } + var openedPayload: EditorTabPayload? + coordinator.openTabInNewWindow = { openedPayload = $0 } + + tabManager.addTab(initialQuery: "SELECT private_data", databaseName: "primary") + + let disposition = coordinator.loadQueryIntoEditor("SELECT public_data", databaseName: "analytics") + + #expect(disposition == .focusedElsewhere) + #expect(tabManager.tabs[0].content.query == "SELECT private_data") + #expect(tabManager.tabs[0].tableContext.databaseName == "primary") + #expect(openedPayload?.databaseName == "analytics") + #expect(openedPayload?.initialQuery == "SELECT public_data") + } + + @Test("loadQueryIntoEditor keeps the current tab when a new window tab is forced") + @MainActor + func loadQueryForcesNewWindowTab() { + let (coordinator, tabManager) = makeCoordinator() + defer { coordinator.teardown() } + var openedPayload: EditorTabPayload? + coordinator.openTabInNewWindow = { openedPayload = $0 } + + tabManager.addTab(initialQuery: "SELECT 1", databaseName: "testdb") + + let disposition = coordinator.loadQueryIntoEditor( + "SELECT 2", + databaseName: "testdb", + forceNewWindowTab: true + ) + + #expect(disposition == .focusedElsewhere) + #expect(tabManager.tabs[0].content.query == "SELECT 1") + #expect(openedPayload?.databaseName == "testdb") + #expect(openedPayload?.initialQuery == "SELECT 2") + } + + @Test("loadQueryIntoEditor reuses a tab opened without an explicit database") + @MainActor + func loadQueryReusesTabWithInheritedDatabase() { + let (coordinator, tabManager) = makeCoordinator() + defer { coordinator.teardown() } + var openedPayload: EditorTabPayload? + coordinator.openTabInNewWindow = { openedPayload = $0 } + + tabManager.addTab(initialQuery: "SELECT 1") + #expect(tabManager.tabs[0].tableContext.databaseName.isEmpty) + + let disposition = coordinator.loadQueryIntoEditor("SELECT 2", databaseName: "testdb") + + #expect(disposition == .currentCoordinator) + #expect(tabManager.tabs[0].content.query == "SELECT 2") + #expect(openedPayload == nil) } // MARK: - insertQueryFromAI diff --git a/TableProUITests/QuickSwitcherCrossConnectionUITests.swift b/TableProUITests/QuickSwitcherCrossConnectionUITests.swift index 1070e84b5..805953655 100644 --- a/TableProUITests/QuickSwitcherCrossConnectionUITests.swift +++ b/TableProUITests/QuickSwitcherCrossConnectionUITests.swift @@ -55,6 +55,42 @@ final class QuickSwitcherCrossConnectionUITests: XCTestCase { XCTAssertTrue(searchField.waitForNonExistence(timeout: 5)) } + func testQueriesScopeSearchesHistoryAndOpensANewWindowTab() throws { + let app = launchWithSampleDatabase() + XCTAssertTrue(editorTextView(in: app).waitForExistence(timeout: 15)) + + app.typeKey("t", modifierFlags: .command) + let queryEditor = editorTextView(in: app) + XCTAssertTrue(queryEditor.waitForExistence(timeout: 10)) + let query = "SELECT 42 AS cross_connection_probe;" + queryEditor.click() + app.typeText(query) + app.typeKey(.return, modifierFlags: .command) + XCTAssertTrue(app.windows.firstMatch.tables.firstMatch.waitForExistence(timeout: 15)) + queryEditor.click() + queryEditor.typeKey("a", modifierFlags: .command) + queryEditor.typeText("SELECT 0;") + + app.typeKey("o", modifierFlags: [.command, .shift]) + let searchField = app.textFields["quick-switcher-search-field"] + XCTAssertTrue(searchField.waitForExistence(timeout: 10)) + app.typeKey("4", modifierFlags: .command) + XCTAssertTrue(app.staticTexts["Queries"].waitForExistence(timeout: 5)) + + searchField.typeText("cross_connection_probe") + let historyResult = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS %@", "cross_connection_probe") + ).firstMatch + XCTAssertTrue(historyResult.waitForExistence(timeout: 10)) + + searchField.typeKey(.return, modifierFlags: .option) + XCTAssertTrue(searchField.waitForNonExistence(timeout: 5)) + let openedEditor = app.textViews.matching(identifier: "sql-editor-textview") + .matching(NSPredicate(format: "value == %@", query)) + .firstMatch + XCTAssertTrue(openedEditor.waitForExistence(timeout: 10)) + } + private func launchWithSampleDatabase() -> XCUIApplication { let app = XCUIApplication() app.launchEnvironment["TABLEPRO_UI_TESTING"] = "1" diff --git a/docs/features/quick-switcher.mdx b/docs/features/quick-switcher.mdx index c8452bdc6..4fc35396d 100644 --- a/docs/features/quick-switcher.mdx +++ b/docs/features/quick-switcher.mdx @@ -28,21 +28,34 @@ The opening shortcut is rebindable in **Settings > Keyboard**; see [Keyboard Sho Five scopes limit what the search covers: **All** (`Cmd+1`), **Tables** (`Cmd+2`, includes views and system tables), **Databases** (`Cmd+3`, includes schemas), **Queries** (`Cmd+4`, saved queries and recent queries), and **Connections** (`Cmd+5`). Round buttons beside the field set the scope; they show while the field is empty and nothing is listed. Once the list fills, the active scope moves to a badge in the bar. Click the badge to return to All. With an empty search in a scope other than All, everything in that scope is listed under section headers. The database list follows the sidebar's database filter. -Saved queries come from your [favorites](/features/favorites) and also match on their keyword. Recent queries are the last 50 entries from [query history](/features/query-history) for the connection. +Saved queries come from your [favorites](/features/favorites) and also match on their keyword. In the **All** scope, recent queries are the last 50 entries from [query history](/features/query-history) for the current connection. ## Search Across Connections +### Objects + The **Connections** scope searches tables, views, and system tables in every connected window. With an empty search the list is grouped under each connection's name. Once you type, results are ranked across all connections in one list, and every row shows the connection, database, and schema it belongs to. Search matches both the object name and that path, so typing a connection name limits the results to that connection. TablePro loads this catalog only when you open the Connections scope. It searches the database or schema each connection is currently browsing. Opening a result in another connection brings that connection's window forward and opens the table there. +### Queries + +The **Queries** scope searches saved queries and up to 200 recent query-history entries drawn from every connected window. Each connection contributes its own most recent entries, so a connection you ran hundreds of queries on today cannot push another one off the list. Each row shows its connection and database. Recent queries also show their execution time. Global saved queries open in the connection where you opened Quick Switcher. The connection the panel was opened from stays listed even while it is reconnecting. + + + Quick Switcher showing query history from Chinook and Analytics connections + + +Opening a query brings its connection forward and loads the SQL without running it. The window you opened Quick Switcher from is used whenever it is on the right connection and database, so a query never replaces the editor in a window you were not looking at. Query-history entries keep their recorded database context. If no open window uses that database, TablePro opens a new native window tab instead of loading the SQL into a tab for another database. `Option+Return` always opens a new native window tab. + ## Ranking With an empty search, the panel shows a **Recent** section: the last 10 items you opened through the switcher on this connection. With a query, each result's fuzzy match score is weighted by: - **Object kind**: tables rank above views, databases, schemas, and saved queries; query history ranks last. -- **Frecency**: each item's last 10 opens through the switcher, weighted by how recent they are, tracked per connection. +- **Frecency**: each item's last 10 opens through the switcher, weighted by how recent they are, tracked per connection. Opening another connection's result counts towards that connection. - **Open tab boost**: tables already open in a tab rank higher. +- **Where the match landed**: a match on the name ranks highest, then a saved query's keyword, then the connection and database path shown beside it. The list shows at most 200 results. @@ -57,7 +70,7 @@ What opening does depends on the item: tables and views open a table tab, databa | Action | Applies to | |--------|------------| | Open | All items | -| Open in New Tab | Tables, views, system tables | +| Open in New Tab | Tables, views, system tables, saved queries, recent queries | | Open Structure | Tables, views, system tables | | Copy Name | All items | | Copy Query | Saved and recent queries | diff --git a/docs/images/quick-switcher-cross-connection-queries.png b/docs/images/quick-switcher-cross-connection-queries.png new file mode 100644 index 000000000..4741f8bb8 Binary files /dev/null and b/docs/images/quick-switcher-cross-connection-queries.png differ