From 42aba620d28f5194f8f92b7169d07b4563f75cbd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 23 Sep 2026 18:31:43 +0700 Subject: [PATCH] fix(sidebar): give every Open Quickly item one Recent identity across runs, scopes and databases --- CHANGELOG.md | 4 + .../Infrastructure/SessionStateFactory.swift | 4 +- .../UI/QuickSwitcherFrecencyStore.swift | 3 +- TablePro/Models/Database/IdentityPath.swift | 2 +- .../Models/UI/QuickSwitcherFrecencyKey.swift | 63 ++++ TablePro/Models/UI/QuickSwitcherItem.swift | 30 +- TablePro/Models/UI/SharedSidebarState.swift | 40 ++- .../QuickSwitcherViewModel+QueryItems.swift | 130 +++++++ .../ViewModels/QuickSwitcherViewModel.swift | 260 ++++++-------- .../QuickSwitcherPanelView.swift | 12 +- .../QuickSwitcherItemIdentityTests.swift | 187 ++++++++--- .../QuickSwitcherCatalogStoreTests.swift | 6 +- .../QuickSwitcherFrecencyStoreTests.swift | 18 +- .../QuickSwitcherCrossSchemaTests.swift | 37 +- .../QuickSwitcherHistoryItemTests.swift | 12 +- .../QuickSwitcherRecentIdentityTests.swift | 317 ++++++++++++++++++ .../QuickSwitcherViewModelTests.swift | 115 ++++--- docs/features/open-quickly.mdx | 2 +- 18 files changed, 942 insertions(+), 300 deletions(-) create mode 100644 TablePro/Models/UI/QuickSwitcherFrecencyKey.swift create mode 100644 TablePro/ViewModels/QuickSwitcherViewModel+QueryItems.swift create mode 100644 TableProTests/ViewModels/QuickSwitcherRecentIdentityTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 334f85a8da..b9232b52ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Safe Mode list offering only the levels a connection allows, with the reason under it and in the toolbar tooltip. - **Show Previous Window Tab** and **Show Next Window Tab** for window tabs, with no default shortcut. - SQLite 3.53.4 built into the SQLite and libSQL drivers in place of the macOS copy. +- One-time reset of Open Quickly's Recent query history, and of its objects on connections that switch databases. ### Removed @@ -339,6 +340,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Closing a background tab with unsaved work landing on its neighbour instead of the tab you were on. - Show Previous Tab, Show Next Tab and Select Tab 1 to 9 enabled in Agent mode and with no tab to go to. - Row data of a window's first connection kept in memory after switching to another connection. +- Query picked from Open Quickly's Recent list dropping out of it once the query ran again. +- Table opened in one database shown in Open Quickly's Recent in every other database, and opened there. +- Open Quickly's Recent split between the Connections scope and the other scopes, each showing about half. ### Security diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index fa794da2f8..860037356d 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -62,10 +62,12 @@ enum SessionStateFactory { }, tabSessionRegistry: tabSessionRegistry ) + let databaseType = connection.type tabMgr.onTableOpened = { tableName, schemaName, databaseName, isView, objectType, isPreview in SharedSidebarState.forConnection(connectionId).recordTableOpen( database: databaseName, schema: schemaName, name: tableName, - isView: isView, objectType: objectType, isPreview: isPreview + isView: isView, objectType: objectType, isPreview: isPreview, + connectionSwitchesDatabases: PluginManager.shared.supportsDatabaseSwitching(for: databaseType) ) } tabMgr.onTableSchemaResolved = { tableName, databaseName, schemaName in diff --git a/TablePro/Core/Utilities/UI/QuickSwitcherFrecencyStore.swift b/TablePro/Core/Utilities/UI/QuickSwitcherFrecencyStore.swift index 72b3587d81..525978d691 100644 --- a/TablePro/Core/Utilities/UI/QuickSwitcherFrecencyStore.swift +++ b/TablePro/Core/Utilities/UI/QuickSwitcherFrecencyStore.swift @@ -53,11 +53,10 @@ internal struct QuickSwitcherFrecencyStore { loadAccesses().mapValues { score(for: $0, now: now) } } - func recentItemIds(limit: Int) -> [String] { + func recentItemIds() -> [String] { loadAccesses() .compactMap { itemId, samples in samples.max().map { (itemId, $0) } } .sorted { $0.1 > $1.1 } - .prefix(limit) .map(\.0) } diff --git a/TablePro/Models/Database/IdentityPath.swift b/TablePro/Models/Database/IdentityPath.swift index d9212fe685..37b9e2c925 100644 --- a/TablePro/Models/Database/IdentityPath.swift +++ b/TablePro/Models/Database/IdentityPath.swift @@ -12,7 +12,7 @@ internal enum IdentityPath { return joined([schema, name], separator: ".") } - private static func escaped(_ component: String, separator: Unicode.Scalar) -> String { + internal static func escaped(_ component: String, separator: Unicode.Scalar) -> String { guard component.unicodeScalars.contains(where: { $0 == separator || $0 == "\\" }) else { return component } diff --git a/TablePro/Models/UI/QuickSwitcherFrecencyKey.swift b/TablePro/Models/UI/QuickSwitcherFrecencyKey.swift new file mode 100644 index 0000000000..52ac7ed089 --- /dev/null +++ b/TablePro/Models/UI/QuickSwitcherFrecencyKey.swift @@ -0,0 +1,63 @@ +// +// QuickSwitcherFrecencyKey.swift +// TablePro +// + +import CryptoKit +import Foundation + +internal enum QuickSwitcherFrecencyKey { + internal struct DatabaseQualifier: Hashable, Sendable { + let database: String? + + init(database: String?, connectionSwitchesDatabases: Bool) { + guard connectionSwitchesDatabases, let database, !database.isEmpty else { + self.database = nil + return + } + self.database = database + } + } + + static func table(name: String, schema: String?, in qualifier: DatabaseQualifier) -> String { + qualified("table_\(IdentityPath.qualified(name: name, schema: schema))", by: qualifier) + } + + static func schema(_ name: String, in qualifier: DatabaseQualifier) -> String { + qualified("schema_\(name)", by: qualifier) + } + + static func routine(_ routineId: String, in qualifier: DatabaseQualifier) -> String { + qualified("routine_\(routineId)", by: qualifier) + } + + static func trigger(_ triggerId: String, in qualifier: DatabaseQualifier) -> String { + qualified("trigger_\(triggerId)", by: qualifier) + } + + static func userType(_ typeId: String, in qualifier: DatabaseQualifier) -> String { + qualified("usertype_\(typeId)", by: qualifier) + } + + static func database(_ name: String) -> String { + "db_\(name)" + } + + static func savedQuery(_ favoriteId: UUID) -> String { + "favorite_\(favoriteId.uuidString)" + } + + static func queryHistory(_ query: String) -> String { + let digest = SHA256.hash(data: Data(normalizedQuery(query).utf8)) + return "history_" + digest.map { String(format: "%02x", $0) }.joined() + } + + static func normalizedQuery(_ query: String) -> String { + query.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func qualified(_ key: String, by qualifier: DatabaseQualifier) -> String { + guard let database = qualifier.database else { return key } + return "@\(IdentityPath.escaped(database, separator: "/"))/\(key)" + } +} diff --git a/TablePro/Models/UI/QuickSwitcherItem.swift b/TablePro/Models/UI/QuickSwitcherItem.swift index 03891a2e56..35afefa9b4 100644 --- a/TablePro/Models/UI/QuickSwitcherItem.swift +++ b/TablePro/Models/UI/QuickSwitcherItem.swift @@ -94,7 +94,7 @@ internal enum QuickSwitcherScope: String, CaseIterable, Identifiable, Sendable { /// A single item in the quick switcher results list internal struct QuickSwitcherItem: Identifiable, Hashable, Sendable { - let id: String + let frecencyKey: String let name: String let kind: QuickSwitcherItemKind let subtitle: String @@ -132,31 +132,13 @@ internal struct QuickSwitcherItem: Identifiable, Hashable, Sendable { return QualifiedSearchQuery.location(database: target.databaseName, schema: target.schemaName) } - /// The frecency identity of a table, produced identically by the two places that record one: - /// the quick switcher, which knows the object's `TableInfo.TableType`, and the tab open - /// chokepoint, which only ever learns a Bool. - /// - /// The type used to be part of this. It cannot be, because the two sides spell it differently - /// and one of them cannot spell it at all: the switcher used the full `TableType` raw value - /// while the tab derived `isView` from `allowsRowEditing`, so a materialized view was recorded - /// as `TABLE` and looked up as `MATERIALIZED VIEW`. Five of the seven table types disagreed, - /// and those objects could never reach the Recent section or earn a frecency boost no matter - /// how often they were opened. A name and a schema identify one object in a database whatever - /// its type, so the type buys nothing here. - /// - /// A dot or backslash inside a name is escaped, or schema `a` with table `b.c` and schema `a.b` - /// with table `c` would share one id, one row selection and one Recent entry. A name with - /// neither keeps the id it always had, so no Recent history is lost. - static func tableItemId(name: String, schema: String?) -> String { - guard let schema, !schema.isEmpty else { return "table_\(escapedIdComponent(name))" } - return "table_\(escapedIdComponent(schema)).\(escapedIdComponent(name))" + var id: String { + guard let target else { return frecencyKey } + return "\(target.connectionId.uuidString)/\(frecencyKey)" } - private static func escapedIdComponent(_ component: String) -> String { - guard component.contains(where: { $0 == "." || $0 == "\\" }) else { return component } - return component - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: ".", with: "\\.") + func belongs(to connectionId: UUID) -> Bool { + target.map { $0.connectionId == connectionId } ?? true } /// SF Symbol name for this item's icon diff --git a/TablePro/Models/UI/SharedSidebarState.swift b/TablePro/Models/UI/SharedSidebarState.swift index 8dfd1437ea..9eab3faf81 100644 --- a/TablePro/Models/UI/SharedSidebarState.swift +++ b/TablePro/Models/UI/SharedSidebarState.swift @@ -44,32 +44,58 @@ final class SharedSidebarState: ObservableObject { name: String, isView: Bool, objectType: TableInfo.TableType?, - isPreview: Bool + isPreview: Bool, + connectionSwitchesDatabases: Bool ) { + let frecencyKey = Self.tableFrecencyKey( + database: database, schema: schema, name: name, + connectionSwitchesDatabases: connectionSwitchesDatabases + ) guard isPreview else { pendingRecordTask?.cancel() pendingRecordTask = nil - commitTableOpen(database: database, schema: schema, name: name, isView: isView, objectType: objectType) + commitTableOpen( + database: database, schema: schema, name: name, + isView: isView, objectType: objectType, frecencyKey: frecencyKey + ) return } pendingRecordTask?.cancel() pendingRecordTask = Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: 250_000_000) guard let self, !Task.isCancelled else { return } - self.commitTableOpen(database: database, schema: schema, name: name, isView: isView, objectType: objectType) + self.commitTableOpen( + database: database, schema: schema, name: name, + isView: isView, objectType: objectType, frecencyKey: frecencyKey + ) } } + nonisolated static func tableFrecencyKey( + database: String?, + schema: String?, + name: String, + connectionSwitchesDatabases: Bool + ) -> String { + QuickSwitcherFrecencyKey.table( + name: name, + schema: schema, + in: QuickSwitcherFrecencyKey.DatabaseQualifier( + database: database, + connectionSwitchesDatabases: connectionSwitchesDatabases + ) + ) + } + private func commitTableOpen( database: String?, schema: String?, name: String, isView: Bool, - objectType: TableInfo.TableType? + objectType: TableInfo.TableType?, + frecencyKey: String ) { - QuickSwitcherFrecencyStore(connectionId: connectionId).recordAccess( - itemId: QuickSwitcherItem.tableItemId(name: name, schema: schema) - ) + QuickSwitcherFrecencyStore(connectionId: connectionId).recordAccess(itemId: frecencyKey) guard AppSettingsManager.shared.general.showRecentTables else { return } recentTables = RecentTablesStore.shared.record( connectionId: connectionId, database: normalizedDatabase(database), diff --git a/TablePro/ViewModels/QuickSwitcherViewModel+QueryItems.swift b/TablePro/ViewModels/QuickSwitcherViewModel+QueryItems.swift new file mode 100644 index 0000000000..d89446b04a --- /dev/null +++ b/TablePro/ViewModels/QuickSwitcherViewModel+QueryItems.swift @@ -0,0 +1,130 @@ +// +// QuickSwitcherViewModel+QueryItems.swift +// TablePro +// + +import Foundation + +internal extension QuickSwitcherViewModel { + nonisolated static func makeHistoryItems(_ entries: [QueryHistoryEntry]) -> [QuickSwitcherItem] { + distinctByQuery(entries).prefix(QuickSwitcherRanking.localHistoryLimit).map { entry in + QuickSwitcherItem( + frecencyKey: QuickSwitcherFrecencyKey.queryHistory(entry.query), + name: entry.queryPreview, + kind: .queryHistory, + subtitle: entry.databaseDisplayName, + payload: entry.query + ) + } + } + + 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( + frecencyKey: QuickSwitcherFrecencyKey.savedQuery(favorite.id), + name: favorite.name, + kind: .savedQuery, + subtitle: subtitle, + keyword: favorite.keyword, + payload: favorite.query, + target: target + ) + } + + let historyItems = distinctByQuery(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( + frecencyKey: QuickSwitcherFrecencyKey.queryHistory(entry.query), + name: entry.queryPreview, + kind: .queryHistory, + subtitle: [ + connectionPath(for: target), + entry.hasMeasuredDuration ? entry.formattedExecutionTime : "" + ] + .filter { !$0.isEmpty } + .joined(separator: " · "), + payload: entry.query, + target: target + ) + } + + return interleaveToCap(favoriteItems, historyItems, cap: QuickSwitcherRanking.maxResults) + } + + /// The switcher is a recall list, so one statement run twenty times is one thing to recall. + /// Every execution stays in history; only the list collapses them, keeping the most recent. + nonisolated static func distinctByQuery(_ entries: [QueryHistoryEntry]) -> [QueryHistoryEntry] { + var seen: Set = [] + var distinct: [QueryHistoryEntry] = [] + for entry in entries { + let query = QuickSwitcherFrecencyKey.normalizedQuery(entry.query) + guard !query.isEmpty, + seen.insert(HistoryStatement(connectionId: entry.connectionId, query: query)).inserted else { + continue + } + distinct.append(entry) + } + return distinct + } + + /// Concatenating and truncating let a long favourites list push recent queries out of the + /// panel entirely. Each source keeps its own half of the cap and only lends what it does + /// not use. + nonisolated static func interleaveToCap( + _ favorites: [QuickSwitcherItem], + _ history: [QuickSwitcherItem], + cap: Int + ) -> [QuickSwitcherItem] { + guard favorites.count + history.count > cap else { return favorites + history } + + let share = cap / 2 + let favoriteCount = min(favorites.count, max(share, cap - history.count)) + let historyCount = min(history.count, cap - favoriteCount) + return Array(favorites.prefix(favoriteCount)) + Array(history.prefix(historyCount)) + } + + 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 struct HistoryStatement: Hashable { + let connectionId: UUID + let query: String +} diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 9b2c2e882b..4482e22ccf 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -8,8 +8,10 @@ import Foundation import os import TableProPluginKit -private enum QuickSwitcherRanking { +internal enum QuickSwitcherRanking { static let maxResults = 200 + static let recentLimit = 10 + static let localHistoryLimit = 50 static let subtitleMatchPenalty = 0.6 static let keywordMatchWeight = 1.0 static let frecencyBoost = 0.5 @@ -51,6 +53,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { /// What the panel's own connection is browsing, which is what its table rows are built for. struct TableSource { let database: String? + let connectionSwitchesDatabases: Bool let browseSchema: String? let openTables: Set let grouping: GroupingStrategy @@ -61,7 +64,6 @@ internal final class QuickSwitcherViewModel: ObservableObject { } nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "QuickSwitcherViewModel") - private static let recentLimit = 10 private static let filterDebounceNanoseconds: UInt64 = 40_000_000 private let services: AppServices @@ -210,6 +212,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { let tableSource = TableSource( database: services.databaseManager.browseScope(for: connectionId)?.database, + connectionSwitchesDatabases: services.pluginManager.supportsDatabaseSwitching(for: databaseType), browseSchema: browseSchema, openTables: openTables, grouping: services.pluginManager.databaseGroupingStrategy(for: databaseType) @@ -251,6 +254,10 @@ internal final class QuickSwitcherViewModel: ObservableObject { let switchTarget = services.pluginManager.containerSwitchTarget(for: databaseType) let activeDatabase = services.databaseManager.session(for: connectionId) .map { services.databaseManager.browseDatabaseName(for: $0.connection) } + let qualifier = QuickSwitcherFrecencyKey.DatabaseQualifier( + database: activeDatabase, + connectionSwitchesDatabases: services.pluginManager.supportsDatabaseSwitching(for: databaseType) + ) /// A schema-only engine has no database to switch to, and its driver answers /// `fetchDatabases()` with its schema list, so listing them here showed every schema /// twice and the copy labelled "Database" failed with the driver's own error (#2262). @@ -272,7 +279,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { : databases for db in listed { items.append(QuickSwitcherItem( - id: "db_\(db)", + frecencyKey: QuickSwitcherFrecencyKey.database(db), name: db, kind: .database, subtitle: databaseSubtitle @@ -294,7 +301,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { : String(localized: "Schema") for schema in schemas { items.append(QuickSwitcherItem( - id: "schema_\(schema)", + frecencyKey: QuickSwitcherFrecencyKey.schema(schema, in: qualifier), name: schema, kind: .schema, subtitle: schemaSubtitle @@ -306,14 +313,14 @@ internal final class QuickSwitcherViewModel: ObservableObject { } } - items += routineItems(connectionId: connectionId, database: activeDatabase) - items += triggerItems(connectionId: connectionId, database: activeDatabase) - items += userTypeItems(connectionId: connectionId, database: activeDatabase) + items += routineItems(connectionId: connectionId, database: activeDatabase, qualifier: qualifier) + items += triggerItems(connectionId: connectionId, database: activeDatabase, qualifier: qualifier) + items += userTypeItems(connectionId: connectionId, database: activeDatabase, qualifier: qualifier) let favorites = await services.sqlFavoriteManager.fetchFavorites(connectionId: connectionId) for favorite in favorites { items.append(QuickSwitcherItem( - id: "favorite_\(favorite.id.uuidString)", + frecencyKey: QuickSwitcherFrecencyKey.savedQuery(favorite.id), name: favorite.name, kind: .savedQuery, subtitle: favorite.keyword ?? "", @@ -326,15 +333,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { QueryHistoryFilter(scope: .connection(connectionId), sources: QueryHistorySource.userAuthored), limit: 200 ).entries - for entry in Self.distinctByQuery(historyEntries).prefix(50) { - items.append(QuickSwitcherItem( - id: "history_\(entry.id.uuidString)", - name: entry.queryPreview, - kind: .queryHistory, - subtitle: entry.databaseDisplayName, - payload: entry.query - )) - } + items += Self.makeHistoryItems(historyEntries) return (items, isComplete) } @@ -435,6 +434,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { return Self.makeTableItems( tables, database: tableSource.database, + connectionSwitchesDatabases: tableSource.connectionSwitchesDatabases, browseSchema: tableSource.browseSchema, openTables: tableSource.openTables ) @@ -559,25 +559,6 @@ internal final class QuickSwitcherViewModel: ObservableObject { 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 } @@ -626,7 +607,10 @@ internal final class QuickSwitcherViewModel: ObservableObject { ) return Self.makeCrossConnectionItems( tables: services.schemaService.allLoadedTables(for: session.id), - target: target + target: target, + connectionSwitchesDatabases: services.pluginManager.supportsDatabaseSwitching( + for: session.connection.type + ) ) } } @@ -707,17 +691,27 @@ internal final class QuickSwitcherViewModel: ObservableObject { nonisolated static func makeTableItems( _ tables: [TableInfo], database: String?, + connectionSwitchesDatabases: Bool, browseSchema: String?, openTables: Set ) -> [QuickSwitcherItem] { - tables.map { table in + let qualifier = QuickSwitcherFrecencyKey.DatabaseQualifier( + database: database, + connectionSwitchesDatabases: connectionSwitchesDatabases + ) + var listedKeys: Set = [] + return tables.compactMap { table in + let frecencyKey = QuickSwitcherFrecencyKey.table( + name: table.name, schema: table.schema ?? browseSchema, in: qualifier + ) + guard listedKeys.insert(frecencyKey).inserted else { return nil } let presentation = tablePresentation(for: table.type) let otherSchema = SchemaQualifiedName.explicitSchema(table.schema, implicitSchemaName: browseSchema) let subtitle = [otherSchema, presentation.subtitle] .compactMap { $0?.isEmpty == false ? $0 : nil } .joined(separator: " · ") return QuickSwitcherItem( - id: QuickSwitcherItem.tableItemId(name: table.name, schema: table.schema), + frecencyKey: frecencyKey, name: table.name, kind: presentation.kind, subtitle: subtitle, @@ -735,10 +729,15 @@ internal final class QuickSwitcherViewModel: ObservableObject { nonisolated static func makeCrossConnectionItems( tables: [TableInfo], - target: QuickSwitcherTarget + target: QuickSwitcherTarget, + connectionSwitchesDatabases: Bool ) -> [QuickSwitcherItem] { - tables.map { table in - let presentation = tablePresentation(for: table.type) + let qualifier = QuickSwitcherFrecencyKey.DatabaseQualifier( + database: target.databaseName, + connectionSwitchesDatabases: connectionSwitchesDatabases + ) + var listedKeys: Set = [] + return tables.compactMap { table in let resolvedTarget = QuickSwitcherTarget( connectionId: target.connectionId, connectionName: target.connectionName, @@ -747,8 +746,13 @@ internal final class QuickSwitcherViewModel: ObservableObject { databaseDisplayName: target.databaseDisplayName, pathFieldRole: target.pathFieldRole ) + let frecencyKey = QuickSwitcherFrecencyKey.table( + name: table.name, schema: resolvedTarget.schemaName, in: qualifier + ) + guard listedKeys.insert(frecencyKey).inserted else { return nil } + let presentation = tablePresentation(for: table.type) return QuickSwitcherItem( - id: "connection_\(target.connectionId.uuidString)_\(table.id)", + frecencyKey: frecencyKey, name: table.name, kind: presentation.kind, subtitle: connectionPath(for: resolvedTarget), @@ -759,89 +763,6 @@ internal final class QuickSwitcherViewModel: ObservableObject { } } - 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 = distinctByQuery(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.hasMeasuredDuration ? entry.formattedExecutionTime : "" - ] - .filter { !$0.isEmpty } - .joined(separator: " · "), - payload: entry.query, - target: target - ) - } - - return interleaveToCap(favoriteItems, historyItems, cap: QuickSwitcherRanking.maxResults) - } - - /// The switcher is a recall list, so one statement run twenty times is one thing to recall. - /// Every execution stays in history; only the list collapses them, keeping the most recent. - nonisolated static func distinctByQuery(_ entries: [QueryHistoryEntry]) -> [QueryHistoryEntry] { - var seen: Set = [] - var distinct: [QueryHistoryEntry] = [] - for entry in entries { - let key = entry.query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty, seen.insert(key).inserted else { continue } - distinct.append(entry) - } - return distinct - } - - /// Concatenating and truncating let a long favourites list push recent queries out of the - /// panel entirely. Each source keeps its own half of the cap and only lends what it does - /// not use. - nonisolated static func interleaveToCap( - _ favorites: [QuickSwitcherItem], - _ history: [QuickSwitcherItem], - cap: Int - ) -> [QuickSwitcherItem] { - guard favorites.count + history.count > cap else { return favorites + history } - - let share = cap / 2 - let favoriteCount = min(favorites.count, max(share, cap - history.count)) - let historyCount = min(history.count, cap - favoriteCount) - return Array(favorites.prefix(favoriteCount)) + Array(history.prefix(historyCount)) - } - func canOpenStructure(_ item: QuickSwitcherItem) -> Bool { guard let target = item.target else { return true } return target.connectionId == connectionId @@ -867,7 +788,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { } func recordSelection(_ item: QuickSwitcherItem, at date: Date = Date()) { - frecencyStore(for: item).recordAccess(itemId: item.id, at: date) + frecencyStore(for: item).recordAccess(itemId: item.frecencyKey, at: date) } /// A result from another connection is recorded against that connection. The store is keyed per @@ -886,8 +807,9 @@ internal final class QuickSwitcherViewModel: ObservableObject { let query = searchText.trimmingCharacters(in: .whitespaces) let items = scopedItems() let scope = scope + let connectionId = connectionId let frecencyScores = frecencyStore.scores() - let recentIds = frecencyStore.recentItemIds(limit: Self.recentLimit) + let recentKeys = frecencyStore.recentItemIds() isFiltering = true filterTask = Task { @MainActor [weak self] in if debounced { @@ -895,8 +817,12 @@ internal final class QuickSwitcherViewModel: ObservableObject { guard !Task.isCancelled else { return } } let groups = query.isEmpty - ? await Self.emptyQueryGroups(items: items, scope: scope, recentIds: recentIds) - : await Self.filteredGroups(items: items, query: query, frecencyScores: frecencyScores) + ? await Self.emptyQueryGroups( + items: items, scope: scope, recentKeys: recentKeys, connectionId: connectionId + ) + : await Self.filteredGroups( + items: items, query: query, frecencyScores: frecencyScores, connectionId: connectionId + ) guard !Task.isCancelled, let self else { return } self.groups = groups self.isFiltering = false @@ -946,29 +872,30 @@ internal final class QuickSwitcherViewModel: ObservableObject { nonisolated private static func emptyQueryGroups( items: [QuickSwitcherItem], scope: QuickSwitcherScope, - recentIds: [String] + recentKeys: [String], + connectionId: UUID ) async -> [Group] { - let recentIdSet = Set(recentIds) - let recentOrder = Dictionary(uniqueKeysWithValues: recentIds.enumerated().map { ($1, $0) }) + let recent = recentItems(in: items, keys: recentKeys, ownedBy: connectionId) + let recentKeySet = Set(recent.map(\.frecencyKey)) + let isRecent: (QuickSwitcherItem) -> Bool = { item in + item.belongs(to: connectionId) && recentKeySet.contains(item.frecencyKey) + } var result: [Group] = [] - let recent = items - .filter { recentIdSet.contains($0.id) } - .sorted { (recentOrder[$0.id] ?? 0) < (recentOrder[$1.id] ?? 0) } if !recent.isEmpty { result.append(Group(id: "recent", header: String(localized: "Recent"), items: recent)) } if scope.usesCrossConnectionCatalog { - return result + connectionGroups(items: items, excluding: recentIdSet) + return result + connectionGroups(items: items, excluding: isRecent) } guard scope != .all else { return result } for kind in QuickSwitcherItemKind.displayOrder { let kindItems = items - .filter { $0.kind == kind && !recentIdSet.contains($0.id) } + .filter { $0.kind == kind && !isRecent($0) } .sorted { lhs, rhs in if lhs.isOutsideBrowsedSchema != rhs.isOutsideBrowsedSchema { return rhs.isOutsideBrowsedSchema } return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending @@ -983,14 +910,33 @@ internal final class QuickSwitcherViewModel: ObservableObject { return result } + nonisolated private static func recentItems( + in items: [QuickSwitcherItem], + keys recentKeys: [String], + ownedBy connectionId: UUID + ) -> [QuickSwitcherItem] { + let rank = Dictionary(recentKeys.enumerated().map { ($1, $0) }, uniquingKeysWith: { first, _ in first }) + var rankedItems: [Int: QuickSwitcherItem] = [:] + for item in items where item.belongs(to: connectionId) { + guard let position = rank[item.frecencyKey], rankedItems[position] == nil else { continue } + rankedItems[position] = item + } + return rankedItems + .sorted { $0.key < $1.key } + .prefix(QuickSwitcherRanking.recentLimit) + .map(\.value) + } + nonisolated private static func connectionGroups( items: [QuickSwitcherItem], - excluding excludedIds: Set + excluding isExcluded: (QuickSwitcherItem) -> Bool ) -> [Group] { - let excludedCount = items.lazy.filter { excludedIds.contains($0.id) }.count + let excludedCount = items.reduce(into: 0) { count, item in + if isExcluded(item) { count += 1 } + } let availableCount = max(0, QuickSwitcherRanking.maxResults - excludedCount) let sortedItems = items - .filter { !excludedIds.contains($0.id) } + .filter { !isExcluded($0) } .sorted { lhs, rhs in let lhsConnection = lhs.target?.connectionName ?? "" let rhsConnection = rhs.target?.connectionName ?? "" @@ -1021,7 +967,8 @@ internal final class QuickSwitcherViewModel: ObservableObject { nonisolated private static func filteredGroups( items: [QuickSwitcherItem], query: String, - frecencyScores: [String: Double] + frecencyScores: [String: Double], + connectionId: UUID ) async -> [Group] { let qualified = QualifiedSearchQuery(query) let prefersShorterNames = qualified.map { !$0.name.isEmpty } ?? true @@ -1031,7 +978,8 @@ internal final class QuickSwitcherViewModel: ObservableObject { } var matched = item matched.matchedIndices = matchedIndices - let frecency = 1 + (frecencyScores[item.id] ?? 0) * QuickSwitcherRanking.frecencyBoost + let recalled = item.belongs(to: connectionId) ? frecencyScores[item.frecencyKey] ?? 0 : 0 + let frecency = 1 + recalled * QuickSwitcherRanking.frecencyBoost let openBoost = item.isOpenInTab ? QuickSwitcherRanking.openTabBoost : 1 let location = item.isOutsideBrowsedSchema ? QuickSwitcherRanking.otherSchemaWeight : 1 return (matched, matchScore * item.kind.rankWeight * frecency * openBoost * location) @@ -1134,7 +1082,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { } } - nonisolated private static func connectionPath(for target: QuickSwitcherTarget) -> String { + nonisolated static func connectionPath(for target: QuickSwitcherTarget) -> String { var components = [target.connectionName] if let databaseDisplayName = target.databaseDisplayName ?? target.databaseName, !databaseDisplayName.isEmpty { @@ -1158,12 +1106,16 @@ internal final class QuickSwitcherViewModel: ObservableObject { /// Reads the sidebar's own cache rather than querying. The switcher opens over a connection /// whose objects the tree has already loaded, and a fresh catalog read per keystroke session /// would make opening the panel wait on the server. - private func routineItems(connectionId: UUID, database: String?) -> [QuickSwitcherItem] { + private func routineItems( + connectionId: UUID, + database: String?, + qualifier: QuickSwitcherFrecencyKey.DatabaseQualifier + ) -> [QuickSwitcherItem] { let routines = SchemaService.shared.routines(for: connectionId) let labels = RoutineDisplayLabel.labels(for: routines) return routines.map { routine in QuickSwitcherItem( - id: "routine_\(routine.id)", + frecencyKey: QuickSwitcherFrecencyKey.routine(routine.id, in: qualifier), name: labels[routine.id] ?? routine.name, kind: routine.kind == .procedure ? .procedure : .function, subtitle: routine.schema ?? database ?? "", @@ -1174,10 +1126,14 @@ internal final class QuickSwitcherViewModel: ObservableObject { } } - private func triggerItems(connectionId: UUID, database: String?) -> [QuickSwitcherItem] { + private func triggerItems( + connectionId: UUID, + database: String?, + qualifier: QuickSwitcherFrecencyKey.DatabaseQualifier + ) -> [QuickSwitcherItem] { SchemaService.shared.triggers(for: connectionId).map { trigger in QuickSwitcherItem( - id: "trigger_\(trigger.id)", + frecencyKey: QuickSwitcherFrecencyKey.trigger(trigger.id, in: qualifier), name: trigger.name, kind: .trigger, subtitle: trigger.table ?? trigger.schema ?? database ?? "", @@ -1188,10 +1144,14 @@ internal final class QuickSwitcherViewModel: ObservableObject { } } - private func userTypeItems(connectionId: UUID, database: String?) -> [QuickSwitcherItem] { + private func userTypeItems( + connectionId: UUID, + database: String?, + qualifier: QuickSwitcherFrecencyKey.DatabaseQualifier + ) -> [QuickSwitcherItem] { SchemaService.shared.userDefinedTypes(for: connectionId).map { type in QuickSwitcherItem( - id: "usertype_\(type.id)", + frecencyKey: QuickSwitcherFrecencyKey.userType(type.id, in: qualifier), name: type.name, kind: .userType, subtitle: type.schema ?? database ?? "", diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index 86156a88dc..a127b57533 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -494,12 +494,12 @@ struct QuickSwitcherPanelContent: View { #Preview("Browse tables") { let viewModel = QuickSwitcherViewModel(connectionId: UUID()) viewModel.allItems = [ - QuickSwitcherItem(id: "t1", name: "users", kind: .table, subtitle: "", isOpenInTab: true), - QuickSwitcherItem(id: "t2", name: "user_profiles", kind: .table, subtitle: ""), - QuickSwitcherItem(id: "t3", name: "orders", kind: .table, subtitle: ""), - QuickSwitcherItem(id: "v1", name: "active_users", kind: .view, subtitle: "View"), - QuickSwitcherItem(id: "d1", name: "analytics", kind: .database, subtitle: "Database"), - QuickSwitcherItem(id: "f1", name: "Monthly revenue", kind: .savedQuery, subtitle: "rev") + QuickSwitcherItem(frecencyKey: "t1", name: "users", kind: .table, subtitle: "", isOpenInTab: true), + QuickSwitcherItem(frecencyKey: "t2", name: "user_profiles", kind: .table, subtitle: ""), + QuickSwitcherItem(frecencyKey: "t3", name: "orders", kind: .table, subtitle: ""), + QuickSwitcherItem(frecencyKey: "v1", name: "active_users", kind: .view, subtitle: "View"), + QuickSwitcherItem(frecencyKey: "d1", name: "analytics", kind: .database, subtitle: "Database"), + QuickSwitcherItem(frecencyKey: "f1", name: "Monthly revenue", kind: .savedQuery, subtitle: "rev") ] viewModel.scope = .tables return QuickSwitcherPanelContent(viewModel: viewModel) { _, _ in } diff --git a/TableProTests/Models/QuickSwitcherItemIdentityTests.swift b/TableProTests/Models/QuickSwitcherItemIdentityTests.swift index 9359d201d5..6e67639ce2 100644 --- a/TableProTests/Models/QuickSwitcherItemIdentityTests.swift +++ b/TableProTests/Models/QuickSwitcherItemIdentityTests.swift @@ -3,28 +3,70 @@ // TableProTests // +import Foundation @testable import TablePro import Testing -/// Two places record that a table was opened: the quick switcher, which knows the object's -/// `TableInfo.TableType`, and the tab open chokepoint, which only learns a Bool. They have to -/// produce the same id or the Recent section and the frecency boost silently skip the object. +/// Three places produce the key a table is remembered under: the tab open chokepoint, which only +/// learns a Bool for the kind, the All and Tables scopes, and the Connections scope. They have to +/// agree or the Recent section and the frecency boost silently skip the object. struct QuickSwitcherItemIdentityTests { - /// What `SharedSidebarState.commitTableOpen` records when a table is opened from anywhere - /// other than the switcher. - private func recordedByTabOpen(name: String, schema: String?) -> String { - QuickSwitcherItem.tableItemId(name: name, schema: schema) + private let connectionId = UUID() + + private func recordedByTabOpen( + _ table: TableInfo, + database: String, + switchesDatabases: Bool = true + ) -> String { + SharedSidebarState.tableFrecencyKey( + database: database, + schema: table.schema, + name: table.name, + connectionSwitchesDatabases: switchesDatabases + ) } - /// What the switcher lists the same object under. - private func listedBySwitcher(name: String, schema: String?) -> String { - QuickSwitcherItem.tableItemId(name: name, schema: schema) + private func listedInTablesScope( + _ table: TableInfo, + database: String, + switchesDatabases: Bool = true + ) -> String? { + QuickSwitcherViewModel.makeTableItems( + [table], + database: database, + connectionSwitchesDatabases: switchesDatabases, + browseSchema: "public", + openTables: [] + ).first?.frecencyKey + } + + private func listedInConnectionsScope( + _ table: TableInfo, + database: String, + switchesDatabases: Bool = true + ) -> String? { + let target = QuickSwitcherTarget( + connectionId: connectionId, + connectionName: "Primary", + databaseName: database, + schemaName: "public" + ) + return QuickSwitcherViewModel.makeCrossConnectionItems( + tables: [table], + target: target, + connectionSwitchesDatabases: switchesDatabases + ).first?.frecencyKey } - /// Every type used to be spelled into the id, from two sides that spelled it differently. - /// The tab derives `isView` from `allowsRowEditing`, so a materialized view was recorded as - /// `TABLE` while the switcher listed it as `MATERIALIZED VIEW`. - @Test("Every table type records and lists under the same id", arguments: [ + private func key(_ name: String, schema: String?, database: String?, switchesDatabases: Bool = true) -> String { + QuickSwitcherFrecencyKey.table( + name: name, + schema: schema, + in: .init(database: database, connectionSwitchesDatabases: switchesDatabases) + ) + } + + @Test("Every table type records and lists under the same key in every scope", arguments: [ TableInfo.TableType.table, .view, .materializedView, @@ -36,41 +78,110 @@ struct QuickSwitcherItemIdentityTests { ]) func everyTypeAgrees(type: TableInfo.TableType) { let table = TableInfo(name: "sales", type: type, rowCount: nil, schema: "public") - #expect( - listedBySwitcher(name: table.name, schema: table.schema) - == recordedByTabOpen(name: table.name, schema: table.schema) - ) + let recorded = recordedByTabOpen(table, database: "app") + + #expect(listedInTablesScope(table, database: "app") == recorded) + #expect(listedInConnectionsScope(table, database: "app") == recorded) } - @Test("A schema qualifies the id") - func schemaQualifiesTheId() { - #expect( - QuickSwitcherItem.tableItemId(name: "users", schema: "public") - != QuickSwitcherItem.tableItemId(name: "users", schema: "analytics") + @Test("A table listed without a schema is keyed under the schema its tab resolves to") + func schemalessListingUsesTheResolvedSchema() { + let table = TableInfo(name: "orders", type: .table, rowCount: nil, schema: nil) + let recorded = SharedSidebarState.tableFrecencyKey( + database: "app", schema: "public", name: "orders", connectionSwitchesDatabases: true ) + + #expect(listedInTablesScope(table, database: "app") == recorded) + #expect(listedInConnectionsScope(table, database: "app") == recorded) } - @Test("A driver that reports no schema keeps a stable id") - func noSchemaKeepsStableId() { - #expect( - QuickSwitcherItem.tableItemId(name: "users", schema: nil) - == QuickSwitcherItem.tableItemId(name: "users", schema: "") + @Test("One schema and name listed under two kinds is one row in every scope") + func duplicateListingIsOneRow() { + let tables = [ + TableInfo(name: "orders", type: .table, rowCount: nil, schema: "public"), + TableInfo(name: "orders", type: .view, rowCount: nil, schema: "public") + ] + let listed = QuickSwitcherViewModel.makeTableItems( + tables, database: "app", connectionSwitchesDatabases: true, browseSchema: "public", openTables: [] + ) + let connected = QuickSwitcherViewModel.makeCrossConnectionItems( + tables: tables, + target: QuickSwitcherTarget( + connectionId: connectionId, connectionName: "Primary", databaseName: "app", schemaName: "public" + ), + connectionSwitchesDatabases: true ) + + #expect(listed.map(\.tableType) == [.table]) + #expect(connected.map(\.tableType) == [.table]) + } + + @Test("A connection that reaches one database records and lists under the same key") + func singleDatabaseConnectionAgrees() { + let table = TableInfo(name: "users", type: .table, rowCount: nil, schema: "main") + let recorded = recordedByTabOpen(table, database: "/Users/me/app.sqlite", switchesDatabases: false) + + #expect(listedInTablesScope(table, database: "/Users/me/app.sqlite", switchesDatabases: false) == recorded) + #expect(listedInConnectionsScope(table, database: "/Users/me/app.sqlite", switchesDatabases: false) == recorded) + } + + @Test("The database qualifies the key on a connection that switches databases") + func databaseQualifiesTheKey() { + #expect(key("users", schema: "public", database: "app_prod") != key("users", schema: "public", database: "app_staging")) + } + + @Test("A connection that reaches one database keeps the key it always had") + func singleDatabaseKeyIsUnchanged() { + #expect(key("users", schema: "public", database: "/Users/me/app.sqlite", switchesDatabases: false) == "table_public.users") + #expect(key("users", schema: nil, database: "/Users/me/app.sqlite", switchesDatabases: false) == "table_users") + } + + @Test("No database selected leaves the key unqualified") + func emptyDatabaseIsUnqualified() { + #expect(key("users", schema: "public", database: "") == key("users", schema: "public", database: nil)) + } + + @Test("A qualified key can never equal a key recorded before it had a database") + func qualifiedKeysAreDisjointFromUnqualifiedOnes() { + let qualified = key("c", schema: "b", database: "a") + + #expect(qualified != "table_a.b.c") + #expect(qualified != key("c", schema: "a.b", database: nil)) + #expect(!qualified.hasPrefix("table_")) + } + + @Test("A slash in a database name cannot move a component into another") + func slashInDatabaseStaysInsideIt() { + #expect(key("c", schema: nil, database: "a/b") != key("b/c", schema: nil, database: "a")) + } + + @Test("A schema qualifies the key") + func schemaQualifiesTheKey() { + #expect(key("users", schema: "public", database: "app") != key("users", schema: "analytics", database: "app")) + } + + @Test("A driver that reports no schema keeps a stable key") + func noSchemaKeepsStableKey() { + #expect(key("users", schema: nil, database: "app") == key("users", schema: "", database: "app")) } @Test("Different names never collide") func differentNamesDoNotCollide() { - #expect( - QuickSwitcherItem.tableItemId(name: "users", schema: "public") - != QuickSwitcherItem.tableItemId(name: "orders", schema: "public") - ) + #expect(key("users", schema: "public", database: "app") != key("orders", schema: "public", database: "app")) + } + + @Test("A statement keeps one key however many times it runs and however it is padded") + func statementKeyIgnoresExecutionAndPadding() { + #expect(QuickSwitcherFrecencyKey.queryHistory("SELECT 1") == QuickSwitcherFrecencyKey.queryHistory(" SELECT 1\n")) + #expect(QuickSwitcherFrecencyKey.queryHistory("SELECT 1") != QuickSwitcherFrecencyKey.queryHistory("SELECT 2")) + #expect(QuickSwitcherFrecencyKey.queryHistory("SELECT 1").hasPrefix("history_")) } - /// The id is a key in a per-connection store shared with database, schema and query items, so - /// it has to stay inside the table namespace. - @Test("The id stays in the table namespace") - func idStaysInTableNamespace() { - #expect(QuickSwitcherItem.tableItemId(name: "users", schema: nil).hasPrefix("table_")) - #expect(QuickSwitcherItem.tableItemId(name: "users", schema: "public").hasPrefix("table_")) + @Test("A statement key has a fixed length whatever the statement's size") + func statementKeyLengthIsFixed() { + let short = QuickSwitcherFrecencyKey.queryHistory("SELECT 1") + let long = QuickSwitcherFrecencyKey.queryHistory(String(repeating: "SELECT * FROM events; ", count: 10_000)) + + #expect((short as NSString).length == (long as NSString).length) } } diff --git a/TableProTests/Services/QuickSwitcherCatalogStoreTests.swift b/TableProTests/Services/QuickSwitcherCatalogStoreTests.swift index 367c2ded68..4b284a3121 100644 --- a/TableProTests/Services/QuickSwitcherCatalogStoreTests.swift +++ b/TableProTests/Services/QuickSwitcherCatalogStoreTests.swift @@ -31,7 +31,11 @@ struct QuickSwitcherCatalogStoreTests { private func table(_ name: String, schema: String? = "public", isOpen: Bool = false) -> QuickSwitcherItem { QuickSwitcherItem( - id: QuickSwitcherItem.tableItemId(name: name, schema: schema), + frecencyKey: QuickSwitcherFrecencyKey.table( + name: name, + schema: schema, + in: .init(database: "app", connectionSwitchesDatabases: true) + ), name: name, kind: .table, subtitle: "", diff --git a/TableProTests/Utilities/QuickSwitcherFrecencyStoreTests.swift b/TableProTests/Utilities/QuickSwitcherFrecencyStoreTests.swift index 796f4962f8..ac5ecde42d 100644 --- a/TableProTests/Utilities/QuickSwitcherFrecencyStoreTests.swift +++ b/TableProTests/Utilities/QuickSwitcherFrecencyStoreTests.swift @@ -94,14 +94,24 @@ struct QuickSwitcherFrecencyStoreTests { #expect(scores["item_0"] == nil) } - @Test("recentItemIds orders by last access, newest first") + @Test("recentItemIds orders every tracked item by last access, newest first") func recentItemIdsOrdered() { let (store, _, _) = makeStore() let now = Date() store.recordAccess(itemId: "first", at: now.addingTimeInterval(-300)) store.recordAccess(itemId: "second", at: now.addingTimeInterval(-200)) store.recordAccess(itemId: "third", at: now.addingTimeInterval(-100)) - #expect(store.recentItemIds(limit: 2) == ["third", "second"]) + #expect(store.recentItemIds() == ["third", "second", "first"]) + } + + @Test("recentItemIds is not cut to the Recent section's length") + func recentItemIdsReturnsEveryTrackedItem() { + let (store, _, _) = makeStore() + let now = Date() + for index in 0..<25 { + store.recordAccess(itemId: "item_\(index)", at: now.addingTimeInterval(TimeInterval(index))) + } + #expect(store.recentItemIds().count == 25) } @Test("Legacy MRU list migrates preserving order and removes the old key") @@ -112,7 +122,7 @@ struct QuickSwitcherFrecencyStoreTests { suite.set(["newest", "middle", "oldest"], forKey: legacyKey) let store = QuickSwitcherFrecencyStore(connectionId: connectionId, defaults: suite) - #expect(store.recentItemIds(limit: 10) == ["newest", "middle", "oldest"]) + #expect(store.recentItemIds() == ["newest", "middle", "oldest"]) #expect(suite.stringArray(forKey: legacyKey) == nil) } @@ -122,7 +132,7 @@ struct QuickSwitcherFrecencyStoreTests { store.recordAccess(itemId: "table_users") store.clearHistory() #expect(store.scores().isEmpty) - #expect(store.recentItemIds(limit: 10).isEmpty) + #expect(store.recentItemIds().isEmpty) } @Test("Stores for different connections are isolated") diff --git a/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift b/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift index 606aea9cb3..df157b77e5 100644 --- a/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift @@ -21,7 +21,11 @@ struct QuickSwitcherCrossSchemaTests { openTables: Set = [] ) -> [QuickSwitcherItem] { QuickSwitcherViewModel.makeTableItems( - tables, database: "shop", browseSchema: browseSchema, openTables: openTables + tables, + database: "shop", + connectionSwitchesDatabases: true, + browseSchema: browseSchema, + openTables: openTables ) } @@ -271,7 +275,8 @@ struct QuickSwitcherCrossSchemaTests { ) let remote = QuickSwitcherViewModel.makeCrossConnectionItems( tables: [table("timesheet", "attendance"), table("timesheet", "public")], - target: target + target: target, + connectionSwitchesDatabases: true ) guard let defaults = UserDefaults(suiteName: "QuickSwitcherCrossSchemaTests.\(UUID().uuidString)") else { Issue.record("no defaults suite") @@ -298,18 +303,26 @@ struct QuickSwitcherCrossSchemaTests { // MARK: - Identity - @Test("A name without a dot keeps the id it always had") - func idUnchangedWithoutDots() { - #expect(QuickSwitcherItem.tableItemId(name: "users", schema: "public") == "table_public.users") - #expect(QuickSwitcherItem.tableItemId(name: "users", schema: nil) == "table_users") + private func key(_ name: String, schema: String?, switchesDatabases: Bool) -> String { + QuickSwitcherFrecencyKey.table( + name: name, + schema: schema, + in: .init(database: "shop", connectionSwitchesDatabases: switchesDatabases) + ) + } + + @Test("A name without a dot keeps the key it always had on a connection that reaches one database") + func keyUnchangedWithoutDots() { + #expect(key("users", schema: "public", switchesDatabases: false) == "table_public.users") + #expect(key("users", schema: nil, switchesDatabases: false) == "table_users") } - @Test("Dotted names in different schemas never share an id") - func dottedNamesStayDistinct() { - let first = QuickSwitcherItem.tableItemId(name: "b.c", schema: "a") - let second = QuickSwitcherItem.tableItemId(name: "c", schema: "a.b") - let unqualified = QuickSwitcherItem.tableItemId(name: "a.b", schema: nil) - let qualified = QuickSwitcherItem.tableItemId(name: "b", schema: "a") + @Test("Dotted names in different schemas never share a key", arguments: [false, true]) + func dottedNamesStayDistinct(switchesDatabases: Bool) { + let first = key("b.c", schema: "a", switchesDatabases: switchesDatabases) + let second = key("c", schema: "a.b", switchesDatabases: switchesDatabases) + let unqualified = key("a.b", schema: nil, switchesDatabases: switchesDatabases) + let qualified = key("b", schema: "a", switchesDatabases: switchesDatabases) #expect(first != second) #expect(unqualified != qualified) diff --git a/TableProTests/ViewModels/QuickSwitcherHistoryItemTests.swift b/TableProTests/ViewModels/QuickSwitcherHistoryItemTests.swift index d75c1aa4a3..a175842e5b 100644 --- a/TableProTests/ViewModels/QuickSwitcherHistoryItemTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherHistoryItemTests.swift @@ -26,8 +26,8 @@ struct QuickSwitcherHistoryItemTests { ) } - private func makeItem(_ id: String, kind: QuickSwitcherItemKind) -> QuickSwitcherItem { - QuickSwitcherItem(id: id, name: id, kind: kind, subtitle: "") + private func makeItem(_ key: String, kind: QuickSwitcherItemKind) -> QuickSwitcherItem { + QuickSwitcherItem(frecencyKey: key, name: key, kind: kind, subtitle: "") } @Test("repeated executions collapse to one entry") @@ -69,6 +69,14 @@ struct QuickSwitcherHistoryItemTests { #expect(QuickSwitcherViewModel.distinctByQuery([plain, different]).count == 2) } + @Test("one statement run on two connections stays one entry per connection") + func sameStatementOnTwoConnectionsStaysTwoEntries() { + let first = makeEntry(query: "SELECT a", connectionId: UUID()) + let second = makeEntry(query: "SELECT a", connectionId: UUID()) + + #expect(QuickSwitcherViewModel.distinctByQuery([first, second]).map(\.id) == [first.id, second.id]) + } + @Test("blank statements are dropped rather than shown as an empty row") func blankStatementsAreDropped() { #expect(QuickSwitcherViewModel.distinctByQuery([makeEntry(query: " ")]).isEmpty) diff --git a/TableProTests/ViewModels/QuickSwitcherRecentIdentityTests.swift b/TableProTests/ViewModels/QuickSwitcherRecentIdentityTests.swift new file mode 100644 index 0000000000..433bbb5df1 --- /dev/null +++ b/TableProTests/ViewModels/QuickSwitcherRecentIdentityTests.swift @@ -0,0 +1,317 @@ +// +// QuickSwitcherRecentIdentityTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Open Quickly Recent identity") +@MainActor +struct QuickSwitcherRecentIdentityTests { + private let connectionId = UUID() + private let start = Date() + + private func makeDefaults() -> UserDefaults? { + UserDefaults(suiteName: "QuickSwitcherRecentIdentityTests.\(UUID().uuidString)") + } + + private func table(_ name: String) -> TableInfo { + TableInfo(name: name, type: .table, rowCount: nil, schema: "public") + } + + private func tables(_ names: [String], in database: String) -> [QuickSwitcherItem] { + QuickSwitcherViewModel.makeTableItems( + names.map { table($0) }, + database: database, + connectionSwitchesDatabases: true, + browseSchema: "public", + openTables: [] + ) + } + + private func connectionTables( + _ names: [String], + in database: String, + on connection: UUID? = nil + ) -> [QuickSwitcherItem] { + QuickSwitcherViewModel.makeCrossConnectionItems( + tables: names.map { table($0) }, + target: QuickSwitcherTarget( + connectionId: connection ?? connectionId, + connectionName: connection == nil ? "Primary" : "Replica", + databaseName: database, + schemaName: "public" + ), + connectionSwitchesDatabases: true + ) + } + + private func execution(_ query: String, on connection: UUID? = nil, at offset: TimeInterval) -> QueryHistoryEntry { + QueryHistoryEntry( + query: query, + connectionId: connection ?? connectionId, + databaseName: "app", + databaseType: .postgresql, + source: .editor, + executedAt: start.addingTimeInterval(offset), + executionTime: 0.01, + rowCount: 1, + wasSuccessful: true + ) + } + + private func queryTarget(for connection: UUID, named name: String) -> QuickSwitcherTarget { + QuickSwitcherTarget(connectionId: connection, connectionName: name, databaseName: "app", schemaName: nil) + } + + private func queryItems( + _ entries: [QueryHistoryEntry], + targets: [UUID: QuickSwitcherTarget]? = nil + ) -> [QuickSwitcherItem] { + QuickSwitcherViewModel.makeCrossConnectionQueryItems( + favorites: [], + historyEntries: entries, + targets: targets ?? [connectionId: queryTarget(for: connectionId, named: "Primary")], + currentConnectionId: connectionId + ) + } + + private func pick(_ item: QuickSwitcherItem, at offset: TimeInterval = 0, defaults: UserDefaults) { + QuickSwitcherViewModel(connectionId: connectionId, services: .live, defaults: defaults) + .recordSelection(item, at: start.addingTimeInterval(offset)) + } + + private func makeViewModel( + scope: QuickSwitcherScope, + defaults: UserDefaults, + allItems: [QuickSwitcherItem] = [], + connectionItems: [QuickSwitcherItem] = [], + queryItems: [QuickSwitcherItem] = [], + searchText: String = "" + ) async -> QuickSwitcherViewModel { + let viewModel = QuickSwitcherViewModel(connectionId: connectionId, services: .live, defaults: defaults) + viewModel.allItems = allItems + viewModel.crossConnectionItems = connectionItems + viewModel.crossConnectionQueryItems = queryItems + viewModel.scope = scope + viewModel.searchText = searchText + await viewModel.flushPendingFilter() + return viewModel + } + + private func recent( + in scope: QuickSwitcherScope, + defaults: UserDefaults, + allItems: [QuickSwitcherItem] = [], + connectionItems: [QuickSwitcherItem] = [], + queryItems: [QuickSwitcherItem] = [] + ) async -> [QuickSwitcherItem] { + let viewModel = await makeViewModel( + scope: scope, + defaults: defaults, + allItems: allItems, + connectionItems: connectionItems, + queryItems: queryItems + ) + return viewModel.groups.first { $0.id == "recent" }?.items ?? [] + } + + // MARK: - A statement that runs again + + @Test("A query picked from the Queries scope stays recent after it runs again") + func queryStaysRecentAfterRerun() async throws { + let defaults = try #require(makeDefaults()) + let firstRun = execution("SELECT * FROM users", at: 0) + let picked = try #require(queryItems([firstRun]).first) + pick(picked, defaults: defaults) + + let rerun = execution("SELECT * FROM users", at: 60) + let listed = await recent(in: .queries, defaults: defaults, queryItems: queryItems([rerun, firstRun])) + + #expect(listed.map(\.name) == [firstRun.queryPreview]) + } + + @Test("Queries picked from the All scope stay recent after each one runs again") + func allScopeQueriesStayRecentAfterReruns() async throws { + let defaults = try #require(makeDefaults()) + let statements = ["SELECT 1", "SELECT 2", "SELECT 3"] + let firstRuns = statements.enumerated().map { execution($1, at: TimeInterval($0)) } + let picked = QuickSwitcherViewModel.makeHistoryItems(firstRuns.reversed()) + for (offset, item) in picked.enumerated() { + pick(item, at: TimeInterval(10 + offset), defaults: defaults) + } + + let reruns = statements.enumerated().map { execution($1, at: TimeInterval(100 + $0)) } + let listed = await recent( + in: .all, + defaults: defaults, + allItems: QuickSwitcherViewModel.makeHistoryItems(reruns.reversed() + firstRuns.reversed()) + ) + let payloads = Set(listed.compactMap { $0.payload }) + + #expect(payloads == Set(statements)) + } + + @Test("One statement run on two connections keeps a row, and a Recent entry, for each") + func statementOnTwoConnectionsKeepsBothRows() async throws { + let defaults = try #require(makeDefaults()) + let other = UUID() + let targets = [ + connectionId: queryTarget(for: connectionId, named: "Primary"), + other: queryTarget(for: other, named: "Analytics") + ] + let here = execution("SELECT count(*) FROM events", at: 0) + let picked = try #require(queryItems([here], targets: targets).first) + pick(picked, defaults: defaults) + + let there = execution("SELECT count(*) FROM events", on: other, at: 60) + let items = queryItems([there, here], targets: targets) + let listed = await recent(in: .queries, defaults: defaults, queryItems: items) + + #expect(items.count == 2) + #expect(Set(items.map(\.id)).count == 2) + #expect(listed.map { $0.target?.connectionId } == [connectionId]) + } + + // MARK: - The database a table lives in + + @Test("A table picked in one database is not recent in another") + func tableIsRecentOnlyInItsDatabase() async throws { + let defaults = try #require(makeDefaults()) + let picked = try #require(tables(["users"], in: "app_prod").first) + pick(picked, defaults: defaults) + + let inStaging = await recent(in: .tables, defaults: defaults, allItems: tables(["users"], in: "app_staging")) + let inProduction = await recent(in: .tables, defaults: defaults, allItems: tables(["users"], in: "app_prod")) + + #expect(inStaging.isEmpty) + #expect(inProduction.map(\.name) == ["users"]) + } + + @Test("A table opened from a tab is recent in its own database only") + func tabOpenIsRecentOnlyInItsDatabase() async throws { + let defaults = try #require(makeDefaults()) + QuickSwitcherFrecencyStore(connectionId: connectionId, defaults: defaults).recordAccess( + itemId: SharedSidebarState.tableFrecencyKey( + database: "app_prod", schema: "public", name: "users", connectionSwitchesDatabases: true + ) + ) + + let inStaging = await recent(in: .all, defaults: defaults, allItems: tables(["users"], in: "app_staging")) + let inProduction = await recent(in: .all, defaults: defaults, allItems: tables(["users"], in: "app_prod")) + + #expect(inStaging.isEmpty) + #expect(inProduction.map(\.name) == ["users"]) + } + + @Test("A table earns its frecency boost only in its own database") + func frecencyBoostStaysInItsDatabase() async throws { + let defaults = try #require(makeDefaults()) + let picked = try #require(tables(["users_b"], in: "app_prod").first) + pick(picked, defaults: defaults) + + let staging = await makeViewModel( + scope: .tables, defaults: defaults, allItems: tables(["users_a", "users_b"], in: "app_staging"), searchText: "users" + ) + let production = await makeViewModel( + scope: .tables, defaults: defaults, allItems: tables(["users_a", "users_b"], in: "app_prod"), searchText: "users" + ) + + #expect(staging.flatItems.first?.name == "users_a") + #expect(production.flatItems.first?.name == "users_b") + } + + @Test("Recent in one database is not crowded out by tables opened in another") + func recentIsNotCrowdedOutByAnotherDatabase() async throws { + let defaults = try #require(makeDefaults()) + let orders = try #require(tables(["orders"], in: "app_staging").first) + pick(orders, at: 0, defaults: defaults) + for (offset, item) in tables((0..<12).map { "prod_\($0)" }, in: "app_prod").enumerated() { + pick(item, at: TimeInterval(1 + offset), defaults: defaults) + } + + let inStaging = await recent(in: .tables, defaults: defaults, allItems: tables(["orders"], in: "app_staging")) + + #expect(inStaging.map(\.name) == ["orders"]) + } + + @Test("A connection that reaches one database still finds what it recorded before databases qualified a key") + func singleDatabaseConnectionKeepsEarlierEntries() async throws { + let defaults = try #require(makeDefaults()) + QuickSwitcherFrecencyStore(connectionId: connectionId, defaults: defaults).recordAccess(itemId: "table_public.users") + let items = QuickSwitcherViewModel.makeTableItems( + [table("users")], + database: "/Users/me/app.sqlite", + connectionSwitchesDatabases: false, + browseSchema: "public", + openTables: [] + ) + + let listed = await recent(in: .tables, defaults: defaults, allItems: items) + + #expect(listed.map(\.name) == ["users"]) + } + + // MARK: - One entry across scopes + + @Test("A table picked in the Connections scope is recent in the Tables scope") + func connectionsPickIsRecentInTablesScope() async throws { + let defaults = try #require(makeDefaults()) + let picked = try #require(connectionTables(["users"], in: "app").first) + pick(picked, defaults: defaults) + + let listed = await recent(in: .tables, defaults: defaults, allItems: tables(["users"], in: "app")) + + #expect(listed.map(\.name) == ["users"]) + } + + @Test("Ten tables picked across two scopes fill Recent in each") + func tenPicksAcrossScopesFillBothRecents() async throws { + let defaults = try #require(makeDefaults()) + let names = (0..<10).map { "table_\($0)" } + let listed = tables(names, in: "app") + let connected = connectionTables(names, in: "app") + for index in names.indices { + let item = index.isMultiple(of: 2) ? connected[index] : listed[index] + pick(item, at: TimeInterval(index), defaults: defaults) + } + + let inTables = await recent(in: .tables, defaults: defaults, allItems: listed) + let inConnections = await recent(in: .connections, defaults: defaults, connectionItems: connected) + + let newestFirst = Array(names.reversed()) + #expect(inTables.map(\.name) == newestFirst) + #expect(inConnections.map(\.name) == newestFirst) + } + + @Test("Another connection's table of the same name is never this connection's Recent") + func anotherConnectionsTableIsNotRecent() async throws { + let defaults = try #require(makeDefaults()) + let picked = try #require(connectionTables(["users"], in: "app").first) + pick(picked, defaults: defaults) + + let items = connectionTables(["users"], in: "app", on: UUID()) + connectionTables(["users"], in: "app") + let listed = await recent(in: .connections, defaults: defaults, connectionItems: items) + + #expect(Set(items.map(\.id)).count == 2) + #expect(listed.map { $0.target?.connectionId } == [connectionId]) + } + + @Test("Another connection's table of the same name earns no frecency boost here") + func anotherConnectionsTableEarnsNoBoost() async throws { + let defaults = try #require(makeDefaults()) + let picked = try #require(connectionTables(["users_b"], in: "app").first) + pick(picked, defaults: defaults) + + let replica = await makeViewModel( + scope: .connections, + defaults: defaults, + connectionItems: connectionTables(["users_a", "users_b"], in: "app", on: UUID()), + searchText: "users" + ) + + #expect(replica.flatItems.first?.name == "users_a") + } +} diff --git a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift index 77cd6bfeef..c167ffa422 100644 --- a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift @@ -73,11 +73,11 @@ struct QuickSwitcherViewModelTests { private func sampleItems() -> [QuickSwitcherItem] { [ - QuickSwitcherItem(id: "t1", name: "users", kind: .table, subtitle: ""), - QuickSwitcherItem(id: "t2", name: "orders", kind: .table, subtitle: ""), - QuickSwitcherItem(id: "v1", name: "active_users", kind: .view, subtitle: "View"), - QuickSwitcherItem(id: "d1", name: "production", kind: .database, subtitle: "Database"), - QuickSwitcherItem(id: "h1", name: "SELECT * FROM users;", kind: .queryHistory, subtitle: "mydb") + QuickSwitcherItem(frecencyKey: "t1", name: "users", kind: .table, subtitle: ""), + QuickSwitcherItem(frecencyKey: "t2", name: "orders", kind: .table, subtitle: ""), + QuickSwitcherItem(frecencyKey: "v1", name: "active_users", kind: .view, subtitle: "View"), + QuickSwitcherItem(frecencyKey: "d1", name: "production", kind: .database, subtitle: "Database"), + QuickSwitcherItem(frecencyKey: "h1", name: "SELECT * FROM users;", kind: .queryHistory, subtitle: "mydb") ] } @@ -113,7 +113,7 @@ struct QuickSwitcherViewModelTests { func connectionsScopeUsesCrossConnectionItems() async { let localConnectionId = UUID() let remoteConnectionId = UUID() - let local = QuickSwitcherItem(id: "local", name: "users", kind: .table, subtitle: "") + let local = QuickSwitcherItem(frecencyKey: "local", name: "users", kind: .table, subtitle: "") let remoteTarget = QuickSwitcherTarget( connectionId: remoteConnectionId, connectionName: "Analytics", @@ -121,7 +121,7 @@ struct QuickSwitcherViewModelTests { schemaName: "public" ) let remote = QuickSwitcherItem( - id: "remote", + frecencyKey: "remote", name: "events", kind: .table, subtitle: "Analytics / warehouse / public", @@ -132,7 +132,7 @@ struct QuickSwitcherViewModelTests { vm.scope = .connections await vm.flushPendingFilter() - #expect(vm.flatItems.map(\.id) == ["remote"]) + #expect(vm.flatItems.map(\.frecencyKey) == ["remote"]) #expect(vm.groups.first?.header == "Analytics") } @@ -220,7 +220,7 @@ struct QuickSwitcherViewModelTests { #expect(await historyStorage.record(inactiveHistory)) let vm = makeViewModel( - items: [QuickSwitcherItem(id: "stale", name: "stale", kind: .queryHistory, subtitle: "")], + items: [QuickSwitcherItem(frecencyKey: "stale", name: "stale", kind: .queryHistory, subtitle: "")], connectionId: localConnection.id, services: services ) @@ -228,15 +228,15 @@ struct QuickSwitcherViewModelTests { await vm.loadCrossConnectionQueryItems() await vm.flushPendingFilter() - #expect(Set(vm.flatItems.map(\.id)) == Set([ + #expect(Set(vm.flatItems.map(\.frecencyKey)) == Set([ "favorite_\(globalFavorite.id.uuidString)", "favorite_\(remoteFavorite.id.uuidString)", - "history_\(remoteHistory.id.uuidString)" + QuickSwitcherFrecencyKey.queryHistory(remoteHistory.query) ])) - #expect(vm.flatItems.contains { $0.id == "stale" } == false) + #expect(vm.flatItems.contains { $0.frecencyKey == "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) }) + let historyItem = try #require(vm.flatItems.first { $0.kind == .queryHistory }) #expect(historyItem.target?.connectionId == remoteConnection.id) #expect(historyItem.target?.databaseName == "reporting") #expect(historyItem.subtitle.contains("Analytics / reporting")) @@ -244,9 +244,9 @@ struct QuickSwitcherViewModelTests { vm.searchText = "analytics" await vm.flushPendingFilter() - #expect(Set(vm.flatItems.map(\.id)) == Set([ + #expect(Set(vm.flatItems.map(\.frecencyKey)) == Set([ "favorite_\(remoteFavorite.id.uuidString)", - "history_\(remoteHistory.id.uuidString)" + QuickSwitcherFrecencyKey.queryHistory(remoteHistory.query) ])) } @@ -287,7 +287,7 @@ struct QuickSwitcherViewModelTests { vm.invalidateCrossConnectionQueryItems() await vm.loadCrossConnectionQueryItems() - #expect(vm.crossConnectionQueryItems.map(\.id) == ["favorite_\(favorite.id.uuidString)"]) + #expect(vm.crossConnectionQueryItems.map(\.frecencyKey) == ["favorite_\(favorite.id.uuidString)"]) } @Test("Queries scope caps an oversized local catalog") @@ -295,7 +295,7 @@ struct QuickSwitcherViewModelTests { let vm = makeViewModel(items: []) vm.crossConnectionQueryItems = (0..<300).map { index in QuickSwitcherItem( - id: "favorite_\(index)", + frecencyKey: "favorite_\(index)", name: "Query \(index)", kind: .savedQuery, subtitle: "Primary / app" @@ -416,7 +416,7 @@ struct QuickSwitcherViewModelTests { schemaName: nil ) let remote = QuickSwitcherItem( - id: "remote", + frecencyKey: "remote", name: "events", kind: .table, subtitle: "Analytics / warehouse", @@ -428,7 +428,7 @@ struct QuickSwitcherViewModelTests { vm.searchText = "analytics" try await Task.sleep(nanoseconds: 200_000_000) - #expect(vm.flatItems.first?.id == "remote") + #expect(vm.flatItems.first?.frecencyKey == "remote") #expect(vm.flatItems.first?.target == target) } @@ -445,7 +445,8 @@ struct QuickSwitcherViewModelTests { TableInfo(name: "Album", type: .table, rowCount: nil), TableInfo(name: "Track", type: .table, rowCount: nil) ], - target: target + target: target, + connectionSwitchesDatabases: false ) let vm = makeViewModel(items: []) vm.crossConnectionItems = items @@ -476,7 +477,8 @@ struct QuickSwitcherViewModelTests { TableInfo(name: "Invoice", type: .table, rowCount: nil), TableInfo(name: "InvoiceLine", type: .table, rowCount: nil) ], - target: target + target: target, + connectionSwitchesDatabases: false ) let vm = makeViewModel(items: []) vm.crossConnectionItems = items @@ -508,14 +510,18 @@ struct QuickSwitcherViewModelTests { TableInfo(name: "InvoiceLine", type: .table, rowCount: nil) ] let vm = makeViewModel(items: []) - vm.crossConnectionItems = QuickSwitcherViewModel.makeCrossConnectionItems(tables: tables, target: target) + vm.crossConnectionItems = QuickSwitcherViewModel.makeCrossConnectionItems( + tables: tables, target: target, connectionSwitchesDatabases: true + ) vm.scope = .connections vm.searchText = "invoice" try await Task.sleep(nanoseconds: 200_000_000) let chosen = try #require(vm.flatItems.first { $0.name == "InvoiceLine" }) vm.selectedItemId = chosen.id - vm.crossConnectionItems = QuickSwitcherViewModel.makeCrossConnectionItems(tables: tables, target: target) + vm.crossConnectionItems = QuickSwitcherViewModel.makeCrossConnectionItems( + tables: tables, target: target, connectionSwitchesDatabases: true + ) try await Task.sleep(nanoseconds: 200_000_000) #expect(vm.selectedItem()?.name == "InvoiceLine") @@ -535,7 +541,9 @@ struct QuickSwitcherViewModelTests { TableInfo(name: "active_users", type: .view, rowCount: nil) ] - let items = QuickSwitcherViewModel.makeCrossConnectionItems(tables: tables, target: target) + let items = QuickSwitcherViewModel.makeCrossConnectionItems( + tables: tables, target: target, connectionSwitchesDatabases: true + ) #expect(items.count == 2) #expect(items[0].id.contains(connectionId.uuidString)) @@ -559,7 +567,8 @@ struct QuickSwitcherViewModelTests { let item = QuickSwitcherViewModel.makeCrossConnectionItems( tables: [TableInfo(name: "users", type: .table, rowCount: nil)], - target: target + target: target, + connectionSwitchesDatabases: false )[0] #expect(displayName == "~/Databases/private.sqlite") @@ -580,7 +589,9 @@ struct QuickSwitcherViewModelTests { TableInfo(name: "events", type: .table, rowCount: nil, schema: "audit") ] - let items = QuickSwitcherViewModel.makeCrossConnectionItems(tables: tables, target: target) + let items = QuickSwitcherViewModel.makeCrossConnectionItems( + tables: tables, target: target, connectionSwitchesDatabases: true + ) #expect(Set(items.map(\.id)).count == 2) #expect(Set(items.compactMap(\.target?.schemaName)) == Set(["public", "audit"])) @@ -598,7 +609,9 @@ struct QuickSwitcherViewModelTests { TableInfo(name: "table_\(index)", type: .table, rowCount: nil) } let vm = makeViewModel(items: []) - vm.crossConnectionItems = QuickSwitcherViewModel.makeCrossConnectionItems(tables: tables, target: target) + vm.crossConnectionItems = QuickSwitcherViewModel.makeCrossConnectionItems( + tables: tables, target: target, connectionSwitchesDatabases: true + ) vm.scope = .connections await vm.flushPendingFilter() @@ -608,7 +621,7 @@ struct QuickSwitcherViewModelTests { @Test("Query-like input stays plain text") func queryLikeInputStaysPlainText() async throws { let vm = makeViewModel(items: [ - QuickSwitcherItem(id: "users", name: "users", kind: .table, subtitle: "Primary / app") + QuickSwitcherItem(frecencyKey: "users", name: "users", kind: .table, subtitle: "Primary / app") ]) vm.searchText = "users'; DROP TABLE audit; --" @@ -636,14 +649,14 @@ struct QuickSwitcherViewModelTests { ) #expect(vm.canOpenStructure(QuickSwitcherItem( - id: "current", + frecencyKey: "current", name: "users", kind: .table, subtitle: "", target: currentTarget ))) #expect(!vm.canOpenStructure(QuickSwitcherItem( - id: "remote", + frecencyKey: "remote", name: "events", kind: .table, subtitle: "", @@ -665,7 +678,7 @@ struct QuickSwitcherViewModelTests { func savedQueryFoundByKeyword() async throws { var items = sampleItems() items.append(QuickSwitcherItem( - id: "favorite_1", + frecencyKey: "favorite_1", name: "Daily Report", kind: .savedQuery, subtitle: "rpt", @@ -681,7 +694,7 @@ struct QuickSwitcherViewModelTests { func filterCaps() async { var items: [QuickSwitcherItem] = [] for index in 0..<300 { - items.append(QuickSwitcherItem(id: "t\(index)", name: "table_\(index)", kind: .table, subtitle: "")) + items.append(QuickSwitcherItem(frecencyKey: "t\(index)", name: "table_\(index)", kind: .table, subtitle: "")) } let vm = makeViewModel(items: items) vm.scope = .tables @@ -761,7 +774,7 @@ struct QuickSwitcherViewModelTests { let connectionId = UUID() var items: [QuickSwitcherItem] = [] for index in 0..<15 { - items.append(QuickSwitcherItem(id: "t\(index)", name: "table_\(index)", kind: .table, subtitle: "")) + items.append(QuickSwitcherItem(frecencyKey: "t\(index)", name: "table_\(index)", kind: .table, subtitle: "")) } let vm = makeViewModel(items: items, connectionId: connectionId, defaults: suite) for (index, item) in items.enumerated() { @@ -790,8 +803,8 @@ struct QuickSwitcherViewModelTests { let suite = makeDefaults() let connectionId = UUID() let items = [ - QuickSwitcherItem(id: "ta", name: "users_a", kind: .table, subtitle: ""), - QuickSwitcherItem(id: "tb", name: "users_b", kind: .table, subtitle: "") + QuickSwitcherItem(frecencyKey: "ta", name: "users_a", kind: .table, subtitle: ""), + QuickSwitcherItem(frecencyKey: "tb", name: "users_b", kind: .table, subtitle: "") ] let vm = makeViewModel(items: items, connectionId: connectionId, defaults: suite) vm.searchText = "users" @@ -810,7 +823,7 @@ struct QuickSwitcherViewModelTests { func savedQueriesGetOwnSection() async { var items = sampleItems() items.append(QuickSwitcherItem( - id: "f1", + frecencyKey: "f1", name: "Monthly revenue", kind: .savedQuery, subtitle: "rev", @@ -827,7 +840,7 @@ struct QuickSwitcherViewModelTests { @Test("Payload survives filtering") func payloadSurvivesFiltering() async throws { let items = [QuickSwitcherItem( - id: "f1", + frecencyKey: "f1", name: "Monthly revenue", kind: .savedQuery, subtitle: "", @@ -863,8 +876,8 @@ struct QuickSwitcherViewModelTests { @Test("A table already open in a tab outranks an equal match") func openTabOutranksEqualMatch() async throws { let items = [ - QuickSwitcherItem(id: "ta", name: "users_a", kind: .table, subtitle: ""), - QuickSwitcherItem(id: "tb", name: "users_b", kind: .table, subtitle: "", isOpenInTab: true) + QuickSwitcherItem(frecencyKey: "ta", name: "users_a", kind: .table, subtitle: ""), + QuickSwitcherItem(frecencyKey: "tb", name: "users_b", kind: .table, subtitle: "", isOpenInTab: true) ] let vm = makeViewModel(items: items) vm.searchText = "users" @@ -910,7 +923,7 @@ struct QuickSwitcherViewModelTests { @Test("listHeight for a single filtered result is one row") func listHeightSingleFilteredRow() async throws { - let vm = makeViewModel(items: [QuickSwitcherItem(id: "t1", name: "users", kind: .table, subtitle: "")]) + let vm = makeViewModel(items: [QuickSwitcherItem(frecencyKey: "t1", name: "users", kind: .table, subtitle: "")]) vm.searchText = "users" try await Task.sleep(nanoseconds: 200_000_000) #expect(vm.groups.first?.header == nil) @@ -921,7 +934,7 @@ struct QuickSwitcherViewModelTests { func listHeightAtCap() async throws { var items: [QuickSwitcherItem] = [] for index in 0..<9 { - items.append(QuickSwitcherItem(id: "t\(index)", name: "tbl_\(index)", kind: .table, subtitle: "")) + items.append(QuickSwitcherItem(frecencyKey: "t\(index)", name: "tbl_\(index)", kind: .table, subtitle: "")) } let vm = makeViewModel(items: items) vm.searchText = "tbl" @@ -934,7 +947,7 @@ struct QuickSwitcherViewModelTests { func listHeightCapsWhenOverflowing() async throws { var items: [QuickSwitcherItem] = [] for index in 0..<20 { - items.append(QuickSwitcherItem(id: "t\(index)", name: "tbl_\(index)", kind: .table, subtitle: "")) + items.append(QuickSwitcherItem(frecencyKey: "t\(index)", name: "tbl_\(index)", kind: .table, subtitle: "")) } let vm = makeViewModel(items: items) vm.searchText = "tbl" @@ -973,8 +986,8 @@ struct QuickSwitcherViewModelTests { func listHeightClampsWithHeaders() async { var items: [QuickSwitcherItem] = [] for index in 0..<30 { - items.append(QuickSwitcherItem(id: "t\(index)", name: "table_\(index)", kind: .table, subtitle: "")) - items.append(QuickSwitcherItem(id: "v\(index)", name: "view_\(index)", kind: .view, subtitle: "View")) + items.append(QuickSwitcherItem(frecencyKey: "t\(index)", name: "table_\(index)", kind: .table, subtitle: "")) + items.append(QuickSwitcherItem(frecencyKey: "v\(index)", name: "view_\(index)", kind: .view, subtitle: "View")) } let vm = makeViewModel(items: items) vm.scope = .tables @@ -1060,10 +1073,10 @@ struct QuickSwitcherViewModelTests { func largeSectionKeepsLaterSections() async { var items: [QuickSwitcherItem] = [] for index in 0..<250 { - items.append(QuickSwitcherItem(id: "t\(index)", name: "table_\(index)", kind: .table, subtitle: "")) + items.append(QuickSwitcherItem(frecencyKey: "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")) + items.append(QuickSwitcherItem(frecencyKey: "v\(index)", name: "view_\(index)", kind: .view, subtitle: "View")) } let vm = makeViewModel(items: items) vm.scope = .tables @@ -1119,7 +1132,7 @@ struct QuickSwitcherViewModelTests { @Test("A keyword outranks a connection name in a saved query subtitle") func keywordOutranksConnectionPath() async { let keyworded = QuickSwitcherItem( - id: "favorite_keyworded", + frecencyKey: "favorite_keyworded", name: "Daily totals", kind: .savedQuery, subtitle: "prod · Analytics / warehouse", @@ -1127,7 +1140,7 @@ struct QuickSwitcherViewModelTests { payload: "SELECT 1" ) let pathOnly = QuickSwitcherItem( - id: "favorite_path", + frecencyKey: "favorite_path", name: "Customer churn", kind: .savedQuery, subtitle: "Production / app", @@ -1147,7 +1160,7 @@ struct QuickSwitcherViewModelTests { let remoteConnectionId = UUID() let vm = makeViewModel(items: [], connectionId: localConnectionId, defaults: suite) let remote = QuickSwitcherItem( - id: "history_remote", + frecencyKey: "history_remote", name: "SELECT * FROM events", kind: .queryHistory, subtitle: "Analytics / warehouse", @@ -1164,7 +1177,7 @@ struct QuickSwitcherViewModelTests { 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"]) + #expect(localStore.recentItemIds().isEmpty) + #expect(remoteStore.recentItemIds() == ["history_remote"]) } } diff --git a/docs/features/open-quickly.mdx b/docs/features/open-quickly.mdx index cd04a3e8b2..e1f4cc424e 100644 --- a/docs/features/open-quickly.mdx +++ b/docs/features/open-quickly.mdx @@ -81,7 +81,7 @@ A table already open in a tab shows an **Open** badge, and the hint on the selec ## Ranking -An empty search shows **Recent**: the last 10 items you opened through the panel on this connection. Once you type, a match on the name beats a match on the connection and database path beside it, tables rank above other kinds, and anything you open often through the panel or already have open in a tab moves up. Between two equal matches, the table in the browsed schema comes first. The list stops at 200 results. +An empty search shows **Recent**: the last 10 things opened on this connection that the scope lists, including tables opened from the sidebar or a link. A table, schema, or routine is recent only in the database you opened it in, never in another database that holds one with the same name. Once you type, a match on the name beats a match on the connection and database path beside it, tables rank above other kinds, and anything you open often or already have open in a tab moves up. Between two equal matches, the table in the browsed schema comes first. The list stops at 200 results. ## When a table is not listed