diff --git a/CHANGELOG.md b/CHANGELOG.md index 792018fa9..1935ca938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Select several databases or schemas in the sidebar tree and act on them at once: drop, refresh, copy names, or export. Shift-click and Cmd-click extend the selection. - 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. - 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) @@ -47,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Option+Return in the Quick Switcher opens the selected table in a new window tab. The shortcut was documented but never fired. - Right-clicking a table that is not part of the current selection now acts on that table. It used to act on the selected tables instead, including for Delete. - Database icons and the current-database checkmark stay visible on a selected row in the database switcher. They were drawn in the accent colour, which vanished against the accent-coloured selection. - The sidebar drops a database from the tree as soon as you drop it on the server, instead of listing it until you reconnect. diff --git a/TablePro/Core/Concurrency/OnceTask.swift b/TablePro/Core/Concurrency/OnceTask.swift index 69b0c2ed9..d54e54fcd 100644 --- a/TablePro/Core/Concurrency/OnceTask.swift +++ b/TablePro/Core/Concurrency/OnceTask.swift @@ -43,6 +43,13 @@ actor OnceTask { inFlight.removeValue(forKey: key) } + func cancel(where predicate: (Key) -> Bool) { + for key in inFlight.keys where predicate(key) { + inFlight[key]?.task.cancel() + inFlight.removeValue(forKey: key) + } + } + func cancelAll() { for entry in inFlight.values { entry.task.cancel() diff --git a/TablePro/Core/Services/Query/SchemaRefreshService.swift b/TablePro/Core/Services/Query/SchemaRefreshService.swift index 8301de797..b261722dc 100644 --- a/TablePro/Core/Services/Query/SchemaRefreshService.swift +++ b/TablePro/Core/Services/Query/SchemaRefreshService.swift @@ -69,6 +69,77 @@ final class SchemaRefreshService { inFlight.removeValue(forKey: key) } + func waitForRefresh(connectionId: UUID) async { + let tasks = inFlight.compactMap { key, task in + key.connectionId == connectionId ? task : nil + } + for task in tasks { + await task.value + } + } + + /// Brings every listed connection's catalog up to the scope that connection is browsing. + /// Connections are independent, so they load concurrently rather than one after another. + /// Returns the connections whose catalog ended up matching their browse scope. + func loadBrowseCatalogs(connectionIds: [UUID]) async -> Set { + await withTaskGroup(of: (UUID, Bool).self) { group in + for connectionId in connectionIds { + group.addTask { @MainActor in + (connectionId, await self.loadBrowseCatalog(connectionId: connectionId)) + } + } + var loaded: Set = [] + for await (connectionId, didLoad) in group where didLoad { + loaded.insert(connectionId) + } + return loaded + } + } + + private func loadBrowseCatalog(connectionId: UUID) async -> Bool { + await waitForRefresh(connectionId: connectionId) + await schemaService.waitForRefresh(connectionId: connectionId) + guard !Task.isCancelled, + let session = databaseManager?.session(for: connectionId), + session.isConnected, + session.driver != nil, + let scope = metadataDriverProvider.browseScope(for: connectionId) else { return false } + + if schemaService.loadedScope(for: connectionId) != scope { + await refresh(connection: session.connection) + await waitForRefresh(connectionId: connectionId) + await schemaService.waitForRefresh(connectionId: connectionId) + } + + guard !Task.isCancelled, schemaService.loadedScope(for: connectionId) == scope else { return false } + guard pluginManager.databaseGroupingStrategy(for: session.connection.type) == .hierarchicalSchema else { + return true + } + return await loadBrowsedSchemaTables(connectionId: connectionId, scope: scope) + } + + /// A hierarchicalSchema plugin loads objects one schema at a time, so a loaded scope on its + /// own means the schema list arrived, not that any schema holds objects. Without this, a + /// connection whose browsed schema was never expanded reports a full catalog of nothing. + private func loadBrowsedSchemaTables(connectionId: UUID, scope: DatabaseScope) async -> Bool { + guard let schema = scope.schema else { return false } + if schemaService.hasLoadedContent(for: connectionId, schema: schema) { return true } + do { + try await metadataDriverProvider.withMetadataDriver( + scope: scope, + workload: .bulk + ) { [schemaService] driver in + await schemaService.loadSchemaTables(connectionId: connectionId, schema: schema, driver: driver) + } + } catch { + Self.logger.warning( + "[schema] browsed schema load failed connId=\(connectionId, privacy: .public) schema=\(schema, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + return false + } + return schemaService.hasLoadedContent(for: connectionId, schema: schema) + } + /// Push the loaded table list into the autocomplete provider. /// /// The provider caches the driver it is handed and fetches columns from it later, so it @@ -120,14 +191,18 @@ final class SchemaRefreshService { } do { - try await metadataDriverProvider.withBrowseMetadataDriver( - connectionId: connectionId, + guard let scope = metadataDriverProvider.browseScope(for: connectionId) else { + throw DatabaseError.notConnected + } + try await metadataDriverProvider.withMetadataDriver( + scope: scope, workload: .bulk ) { [schemaService] driver in await schemaService.reload( connectionId: connectionId, driver: driver, - connection: connection + connection: connection, + scope: scope ) await schemaService.refreshLoadedSchemaTables( connectionId: connectionId, diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index 614f4f12e..4de108376 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -19,6 +19,7 @@ final class SchemaService { private(set) var perSchemaStates: [UUID: [String: SchemaState]] = [:] private(set) var generations: [UUID: Int] = [:] private(set) var refreshingConnections: Set = [] + private(set) var loadedScopes: [UUID: DatabaseScope] = [:] func generationToken(for connectionId: UUID) -> Int { generations[connectionId] ?? 0 @@ -28,7 +29,7 @@ final class SchemaService { generations[connectionId, default: 0] &+= 1 } - @ObservationIgnored private let loadDedup = OnceTask() + @ObservationIgnored private let loadDedup = OnceTask() @ObservationIgnored private let procedureDedup = OnceTask() @ObservationIgnored private let functionDedup = OnceTask() @ObservationIgnored private let schemasDedup = OnceTask() @@ -38,7 +39,21 @@ final class SchemaService { let connectionId: UUID let schema: String } + + /// Two windows browsing the same scope share one fetch; two windows browsing different + /// scopes must not, or the second stamps the first's tables with its own scope. + struct LoadKey: Hashable, Sendable { + let connectionId: UUID + let scope: DatabaseScope? + } + + private struct RefreshWaiter { + let id: UUID + let continuation: CheckedContinuation + } + @ObservationIgnored private var loadGenerations: [UUID: Int] = [:] + @ObservationIgnored private var refreshWaiters: [UUID: [RefreshWaiter]] = [:] @ObservationIgnored private var nextLoadGeneration = 0 @ObservationIgnored private static let logger = Logger(subsystem: "com.TablePro", category: "SchemaService") @@ -50,6 +65,30 @@ final class SchemaService { refreshingConnections.contains(connectionId) } + func loadedScope(for connectionId: UUID) -> DatabaseScope? { + loadedScopes[connectionId] + } + + func waitForRefresh(connectionId: UUID) async { + while refreshingConnections.contains(connectionId), !Task.isCancelled { + let waiterId = UUID() + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + guard refreshingConnections.contains(connectionId), !Task.isCancelled else { + continuation.resume() + return + } + refreshWaiters[connectionId, default: []] + .append(RefreshWaiter(id: waiterId, continuation: continuation)) + } + } onCancel: { + Task { @MainActor [weak self] in + self?.resumeRefreshWaiter(connectionId, id: waiterId) + } + } + } + } + func hasLoadedContent(for connectionId: UUID) -> Bool { if case .loaded = state(for: connectionId) { return true } return false @@ -158,17 +197,27 @@ final class SchemaService { bumpGeneration(connectionId) } - func load(connectionId: UUID, driver: DatabaseDriver, connection: DatabaseConnection) async { + func load( + connectionId: UUID, + driver: DatabaseDriver, + connection: DatabaseConnection, + scope: DatabaseScope? = nil + ) async { switch state(for: connectionId) { - case .loaded: + case .loaded where scope == nil || loadedScopes[connectionId] == scope: return - case .idle, .loading, .failed: - await runLoad(connectionId: connectionId, driver: driver, connection: connection) + case .idle, .loading, .failed, .loaded: + await runLoad(connectionId: connectionId, driver: driver, connection: connection, scope: scope) } } - func reload(connectionId: UUID, driver: DatabaseDriver, connection: DatabaseConnection) async { - await runLoad(connectionId: connectionId, driver: driver, connection: connection) + func reload( + connectionId: UUID, + driver: DatabaseDriver, + connection: DatabaseConnection, + scope: DatabaseScope? = nil + ) async { + await runLoad(connectionId: connectionId, driver: driver, connection: connection, scope: scope) } func reloadProcedures(connectionId: UUID, driver: DatabaseDriver) async { @@ -210,15 +259,11 @@ final class SchemaService { } private func cancelInFlightLoads(connectionId: UUID) async { - await loadDedup.cancel(key: connectionId) + await loadDedup.cancel { $0.connectionId == connectionId } await procedureDedup.cancel(key: connectionId) await functionDedup.cancel(key: connectionId) await schemasDedup.cancel(key: connectionId) - if let schemas = perSchemaStates[connectionId]?.keys { - for schema in schemas { - await perSchemaDedup.cancel(key: SchemaKey(connectionId: connectionId, schema: schema)) - } - } + await perSchemaDedup.cancel { $0.connectionId == connectionId } } func invalidate(connectionId: UUID) async { @@ -231,6 +276,8 @@ final class SchemaService { schemasInOrder.removeValue(forKey: connectionId) perSchemaStates.removeValue(forKey: connectionId) generations.removeValue(forKey: connectionId) + loadedScopes.removeValue(forKey: connectionId) + resumeRefreshWaiters(connectionId) } func refresh(connectionId: UUID) async { @@ -243,7 +290,12 @@ final class SchemaService { return } await prepareForReload(connectionId: connectionId) - await reload(connectionId: connectionId, driver: driver, connection: session.connection) + await reload( + connectionId: connectionId, + driver: driver, + connection: session.connection, + scope: DatabaseManager.shared.browseScope(for: connectionId) + ) } func markLoadFailed(connectionId: UUID, message: String) { @@ -255,7 +307,8 @@ final class SchemaService { private func runLoad( connectionId: UUID, driver: DatabaseDriver, - connection: DatabaseConnection + connection: DatabaseConnection, + scope: DatabaseScope? ) async { let generation = beginLoadGeneration(for: connectionId) beginRefresh(connectionId) @@ -272,11 +325,18 @@ final class SchemaService { let grouping = PluginManager.shared.databaseGroupingStrategy(for: connection.type) if grouping == .hierarchicalSchema { - await runHierarchicalLoad(connectionId: connectionId, driver: driver, generation: generation) + await runHierarchicalLoad( + connectionId: connectionId, + driver: driver, + generation: generation, + scope: scope + ) return } - async let tablesTask: [TableInfo] = loadDedup.execute(key: connectionId) { + async let tablesTask: [TableInfo] = loadDedup.execute( + key: LoadKey(connectionId: connectionId, scope: scope) + ) { try await driver.fetchTables() } async let proceduresTask: [RoutineInfo] = Self.fetchRoutinesSafely( @@ -324,6 +384,9 @@ final class SchemaService { } schemasInOrder[connectionId] = loadedSchemas } + if let scope { + loadedScopes[connectionId] = scope + } bumpGeneration(connectionId) } catch is CancellationError { return @@ -341,7 +404,12 @@ final class SchemaService { } } - private func runHierarchicalLoad(connectionId: UUID, driver: DatabaseDriver, generation: Int) async { + private func runHierarchicalLoad( + connectionId: UUID, + driver: DatabaseDriver, + generation: Int, + scope: DatabaseScope? + ) async { async let proceduresTask: [RoutineInfo] = Self.fetchRoutinesSafely( connectionId: connectionId, kind: .procedure, @@ -383,6 +451,9 @@ final class SchemaService { procedures[connectionId] = loadedProcedures functions[connectionId] = loadedFunctions states[connectionId] = .loaded([]) + if let scope { + loadedScopes[connectionId] = scope + } bumpGeneration(connectionId) } @@ -393,6 +464,22 @@ final class SchemaService { private func endRefresh(_ connectionId: UUID, generation: Int) { guard loadGenerations[connectionId] == generation else { return } refreshingConnections.remove(connectionId) + resumeRefreshWaiters(connectionId) + } + + private func resumeRefreshWaiters(_ connectionId: UUID) { + let waiters = refreshWaiters.removeValue(forKey: connectionId) ?? [] + for waiter in waiters { + waiter.continuation.resume() + } + } + + private func resumeRefreshWaiter(_ connectionId: UUID, id: UUID) { + guard var waiters = refreshWaiters[connectionId], + let index = waiters.firstIndex(where: { $0.id == id }) else { return } + let waiter = waiters.remove(at: index) + refreshWaiters[connectionId] = waiters.isEmpty ? nil : waiters + waiter.continuation.resume() } private func beginLoadGeneration(for connectionId: UUID) -> Int { diff --git a/TablePro/Models/UI/QuickSwitcherItem.swift b/TablePro/Models/UI/QuickSwitcherItem.swift index 6045441e2..295f80308 100644 --- a/TablePro/Models/UI/QuickSwitcherItem.swift +++ b/TablePro/Models/UI/QuickSwitcherItem.swift @@ -25,21 +25,49 @@ internal enum QuickSwitcherCommitIntent: Sendable { case openStructure } +internal struct QuickSwitcherObjectTarget: Hashable, Sendable { + let connectionId: UUID + let connectionName: String + let databaseName: String? + let schemaName: String? + let databaseDisplayName: String? + + init( + connectionId: UUID, + connectionName: String, + databaseName: String?, + schemaName: String?, + databaseDisplayName: String? = nil + ) { + self.connectionId = connectionId + self.connectionName = connectionName + self.databaseName = databaseName + self.schemaName = schemaName + self.databaseDisplayName = databaseDisplayName + } +} + /// A search scope limiting which kinds of objects the quick switcher shows internal enum QuickSwitcherScope: String, CaseIterable, Identifiable, Sendable { case all case tables case containers case queries + case connections var id: String { rawValue } + /// Whether the scope draws from the catalog of every connected window rather than + /// from the objects of the connection that opened the panel. + var usesCrossConnectionCatalog: Bool { self == .connections } + var includedKinds: Set? { switch self { case .all: return nil case .tables: return [.table, .view, .systemTable] case .containers: return [.database, .schema] case .queries: return [.savedQuery, .queryHistory] + case .connections: return [.table, .view, .systemTable] } } @@ -49,6 +77,7 @@ internal enum QuickSwitcherScope: String, CaseIterable, Identifiable, Sendable { case .tables: return String(localized: "Tables") case .containers: return String(localized: "Databases") case .queries: return String(localized: "Queries") + case .connections: return String(localized: "Connections") } } @@ -58,6 +87,7 @@ internal enum QuickSwitcherScope: String, CaseIterable, Identifiable, Sendable { case .tables: return "tablecells" case .containers: return "cylinder" case .queries: return "doc.text" + case .connections: return "rectangle.3.group" } } } @@ -72,6 +102,7 @@ internal struct QuickSwitcherItem: Identifiable, Hashable, Sendable { var payload: String? var isOpenInTab: Bool = false var isReadOnly: Bool = false + var objectTarget: QuickSwitcherObjectTarget? 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 0a512bf41..2aa37460c 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -24366,6 +24366,34 @@ } } }, + "Could Not Open Table" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tablo Açılamadı" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể mở bảng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法打开表" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法開啟資料表" + } + } + } + }, "Could not parse database URL: %@" : { "localizations" : { "tr" : { @@ -100701,4 +100729,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 4b96b43d4..942ce7326 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -6,6 +6,7 @@ import Foundation import Observation import os +import TableProPluginKit private enum QuickSwitcherRanking { static let maxResults = 200 @@ -18,6 +19,19 @@ private enum QuickSwitcherRanking { @MainActor @Observable internal final class QuickSwitcherViewModel { + struct CrossConnectionCatalogVersion: Hashable { + struct Entry: Hashable { + let connectionId: UUID + let browseScope: DatabaseScope + let loadedScope: DatabaseScope? + let schemaGeneration: Int + let isRefreshing: Bool + } + + let connectionStatusVersion: Int + let entries: [Entry] + } + struct Group: Identifiable, Sendable { let id: String let header: String? @@ -35,11 +49,17 @@ internal final class QuickSwitcherViewModel { @ObservationIgnored internal var allItems: [QuickSwitcherItem] = [] { didSet { scheduleFilter(debounced: false) } } + @ObservationIgnored internal var crossConnectionItems: [QuickSwitcherItem] = [] { + didSet { scheduleFilter(debounced: false) } + } @ObservationIgnored private var filterTask: Task? @ObservationIgnored private var activeLoadId = UUID() + @ObservationIgnored private var activeCrossConnectionLoadId = UUID() + @ObservationIgnored private var loadedCrossConnectionVersion: CrossConnectionCatalogVersion? private(set) var groups: [Group] = [] private(set) var isLoading = true + private(set) var isLoadingCrossConnections = false var selectedItemId: String? var searchText = "" { @@ -60,6 +80,17 @@ internal final class QuickSwitcherViewModel { groups.flatMap(\.items) } + var isLoadingResults: Bool { + scope.usesCrossConnectionCatalog && isLoadingCrossConnections + } + + /// Nil outside the cross-connection scope, so a panel showing one connection's objects + /// never observes every session's schema state and never reloads on their activity. + var crossConnectionLoadVersion: CrossConnectionCatalogVersion? { + guard scope.usesCrossConnectionCatalog else { return nil } + return crossConnectionCatalogVersion + } + 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 @@ -97,36 +128,12 @@ internal final class QuickSwitcherViewModel { let tables = await schemaProvider.getTables() for table in tables { - let kind: QuickSwitcherItemKind - let subtitle: String - switch table.type { - case .table: - kind = .table - subtitle = "" - case .view: - kind = .view - subtitle = String(localized: "View") - case .materializedView: - kind = .view - subtitle = String(localized: "Materialized View") - case .foreignTable: - kind = .table - subtitle = String(localized: "Foreign Table") - case .systemTable: - kind = .systemTable - subtitle = String(localized: "System") - case .partitionedTable: - kind = .table - subtitle = String(localized: "Partitioned Table") - case .externalTable: - kind = .table - subtitle = String(localized: "External Table") - } + let presentation = Self.tablePresentation(for: table.type) items.append(QuickSwitcherItem( id: "table_\(table.name)_\(table.type.rawValue)", name: table.name, - kind: kind, - subtitle: subtitle, + kind: presentation.kind, + subtitle: presentation.subtitle, isOpenInTab: openTableNames.contains(table.name), isReadOnly: !table.type.allowsRowEditing )) @@ -223,6 +230,123 @@ internal final class QuickSwitcherViewModel { allItems = items } + /// Loading is keyed on a version of the world, so it must always record the version it + /// settled on. Leaving the version unrecorded because one connection failed re-arms the + /// task that drives this, and the refresh it just ran has already moved the version, so + /// the panel refreshes that connection forever. + func loadCrossConnectionItems() async { + guard scope.usesCrossConnectionCatalog else { return } + guard loadedCrossConnectionVersion != crossConnectionCatalogVersion else { return } + + let loadId = UUID() + activeCrossConnectionLoadId = loadId + isLoadingCrossConnections = true + defer { + if activeCrossConnectionLoadId == loadId { + isLoadingCrossConnections = false + } + } + + let loadedConnectionIds = await services.schemaRefreshService.loadBrowseCatalogs( + connectionIds: connectedSessions().map(\.id) + ) + guard activeCrossConnectionLoadId == loadId, !Task.isCancelled else { return } + + let sessions = connectedSessions() + let unavailableCount = sessions.count - loadedConnectionIds.count + if unavailableCount > 0 { + Self.logger.warning( + "[quickswitcher] cross-connection catalog omits \(unavailableCount, privacy: .public) of \(sessions.count, privacy: .public) connections" + ) + } + + loadedCrossConnectionVersion = crossConnectionCatalogVersion + crossConnectionItems = crossConnectionItems(for: sessions, loaded: loadedConnectionIds) + } + + private func connectedSessions() -> [ConnectionSession] { + services.databaseManager.activeSessions.values + .filter { $0.isConnected && $0.driver != nil } + .sorted { lhs, rhs in + lhs.connection.name.localizedStandardCompare(rhs.connection.name) == .orderedAscending + } + } + + private func crossConnectionItems( + for sessions: [ConnectionSession], + loaded loadedConnectionIds: Set + ) -> [QuickSwitcherItem] { + sessions + .filter { loadedConnectionIds.contains($0.id) } + .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( + connectionId: session.id, + connectionName: session.connection.name, + databaseName: databaseName, + schemaName: scope.schema, + databaseDisplayName: Self.databaseDisplayName( + databaseName, + pathFieldRole: session.connection.type.pathFieldRole + ) + ) + return Self.makeCrossConnectionItems( + tables: services.schemaService.allLoadedTables(for: session.id), + target: target + ) + } + } + + private var crossConnectionCatalogVersion: CrossConnectionCatalogVersion { + let entries = services.databaseManager.activeSessions.values + .filter { $0.isConnected && $0.driver != nil } + .compactMap { session -> CrossConnectionCatalogVersion.Entry? in + guard let scope = services.databaseManager.browseScope(for: session.id) else { return nil } + return CrossConnectionCatalogVersion.Entry( + connectionId: session.id, + browseScope: scope, + loadedScope: services.schemaService.loadedScope(for: session.id), + schemaGeneration: services.schemaService.generationToken(for: session.id), + isRefreshing: services.schemaService.isRefreshing(connectionId: session.id) + ) + } + .sorted { $0.connectionId.uuidString < $1.connectionId.uuidString } + return CrossConnectionCatalogVersion( + connectionStatusVersion: services.databaseManager.connectionStatusVersion, + entries: entries + ) + } + + nonisolated static func makeCrossConnectionItems( + tables: [TableInfo], + target: QuickSwitcherObjectTarget + ) -> [QuickSwitcherItem] { + tables.map { table in + let presentation = tablePresentation(for: table.type) + let resolvedTarget = QuickSwitcherObjectTarget( + connectionId: target.connectionId, + connectionName: target.connectionName, + databaseName: target.databaseName, + schemaName: table.schema ?? target.schemaName, + databaseDisplayName: target.databaseDisplayName + ) + return QuickSwitcherItem( + id: "connection_\(target.connectionId.uuidString)_\(table.id)", + name: table.name, + kind: presentation.kind, + subtitle: objectPath(for: resolvedTarget), + isReadOnly: !table.type.allowsRowEditing, + objectTarget: resolvedTarget + ) + } + } + + func canOpenStructure(_ item: QuickSwitcherItem) -> Bool { + guard let target = item.objectTarget else { return true } + return target.connectionId == connectionId + } + func selectedItem() -> QuickSwitcherItem? { guard let id = selectedItemId else { return nil } return flatItems.first { $0.id == id } @@ -246,29 +370,39 @@ internal final class QuickSwitcherViewModel { frecencyStore.recordAccess(itemId: item.id, at: date) } + /// Grouping sorts the whole scoped catalog, which across every open connection runs to + /// tens of thousands of localized comparisons. Both the empty-query and the query path + /// build off the main actor so neither can stall the panel while it is being typed into. private func scheduleFilter(debounced: Bool) { filterTask?.cancel() let query = searchText.trimmingCharacters(in: .whitespaces) - guard !query.isEmpty else { - filterTask = nil - groups = buildEmptyQueryGroups() - reconcileSelection() - return - } let items = scopedItems() + let scope = scope let frecencyScores = frecencyStore.scores() + let recentIds = frecencyStore.recentItemIds(limit: Self.recentLimit) filterTask = Task { @MainActor [weak self] in if debounced { try? await Task.sleep(nanoseconds: Self.filterDebounceNanoseconds) guard !Task.isCancelled else { return } } - let groups = await Self.filteredGroups(items: items, query: query, frecencyScores: frecencyScores) + let groups = query.isEmpty + ? await Self.emptyQueryGroups(items: items, scope: scope, recentIds: recentIds) + : await Self.filteredGroups(items: items, query: query, frecencyScores: frecencyScores) guard !Task.isCancelled, let self else { return } self.groups = groups self.reconcileSelection() } } + /// Commits the pending refilter now instead of waiting out its debounce, so a Return typed + /// straight after the last keystroke commits the result for what was typed rather than + /// finding no selection and doing nothing. + func flushPendingFilter() async { + guard filterTask != nil else { return } + scheduleFilter(debounced: false) + await filterTask?.value + } + private func reconcileSelection() { let items = flatItems if let current = selectedItemId, items.contains(where: { $0.id == current }) { @@ -278,41 +412,83 @@ internal final class QuickSwitcherViewModel { } private func scopedItems() -> [QuickSwitcherItem] { - guard let includedKinds = scope.includedKinds else { return allItems } - return allItems.filter { includedKinds.contains($0.kind) } + let source = scope.usesCrossConnectionCatalog ? crossConnectionItems : allItems + guard let includedKinds = scope.includedKinds else { return source } + return source.filter { includedKinds.contains($0.kind) } } - private func buildEmptyQueryGroups() -> [Group] { - let scoped = scopedItems() - let recentIds = frecencyStore.recentItemIds(limit: Self.recentLimit) + nonisolated private static func emptyQueryGroups( + items: [QuickSwitcherItem], + scope: QuickSwitcherScope, + recentIds: [String] + ) async -> [Group] { let recentIdSet = Set(recentIds) let recentOrder = Dictionary(uniqueKeysWithValues: recentIds.enumerated().map { ($1, $0) }) var result: [Group] = [] - let recent = scoped + 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) + } + guard scope != .all else { return result } for kind in QuickSwitcherItemKind.displayOrder { - let items = scoped + let kindItems = items .filter { $0.kind == kind && !recentIdSet.contains($0.id) } .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } - guard !items.isEmpty else { continue } + guard !kindItems.isEmpty else { continue } result.append(Group( id: "kind-\(kind.rawValue)", header: kind.sectionTitle, - items: Array(items.prefix(QuickSwitcherRanking.maxResults)) + items: Array(kindItems.prefix(QuickSwitcherRanking.maxResults)) )) } return result } + nonisolated private static func connectionGroups( + items: [QuickSwitcherItem], + excluding excludedIds: Set + ) -> [Group] { + let excludedCount = items.lazy.filter { excludedIds.contains($0.id) }.count + let availableCount = max(0, QuickSwitcherRanking.maxResults - excludedCount) + let sortedItems = items + .filter { !excludedIds.contains($0.id) } + .sorted { lhs, rhs in + let lhsConnection = lhs.objectTarget?.connectionName ?? "" + let rhsConnection = rhs.objectTarget?.connectionName ?? "" + let connectionOrder = lhsConnection.localizedStandardCompare(rhsConnection) + if connectionOrder != .orderedSame { return connectionOrder == .orderedAscending } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + .prefix(availableCount) + + var order: [UUID] = [] + var grouped: [UUID: [QuickSwitcherItem]] = [:] + var names: [UUID: String] = [:] + for item in sortedItems { + guard let target = item.objectTarget else { continue } + if grouped[target.connectionId] == nil { + order.append(target.connectionId) + names[target.connectionId] = target.connectionName + } + grouped[target.connectionId, default: []].append(item) + } + + return order.compactMap { connectionId in + guard let items = grouped[connectionId], let name = names[connectionId] else { return nil } + return Group(id: "connection-\(connectionId.uuidString)", header: name, items: items) + } + } + nonisolated private static func filteredGroups( items: [QuickSwitcherItem], query: String, @@ -365,6 +541,50 @@ internal final class QuickSwitcherViewModel { return nil } } + + nonisolated private static func tablePresentation( + for type: TableInfo.TableType + ) -> (kind: QuickSwitcherItemKind, subtitle: String) { + switch type { + case .table: + return (.table, "") + case .view: + return (.view, String(localized: "View")) + case .materializedView: + return (.view, String(localized: "Materialized View")) + case .foreignTable: + return (.table, String(localized: "Foreign Table")) + case .systemTable: + return (.systemTable, String(localized: "System")) + case .partitionedTable: + return (.table, String(localized: "Partitioned Table")) + case .externalTable: + return (.table, String(localized: "External Table")) + } + } + + nonisolated private static func objectPath(for target: QuickSwitcherObjectTarget) -> String { + var components = [target.connectionName] + if let databaseDisplayName = target.databaseDisplayName ?? target.databaseName, + !databaseDisplayName.isEmpty { + components.append(databaseDisplayName) + } + if let schemaName = target.schemaName, + !schemaName.isEmpty, + schemaName != target.databaseName { + components.append(schemaName) + } + return components.joined(separator: " / ") + } + + nonisolated static func databaseDisplayName( + _ databaseName: String?, + pathFieldRole: PathFieldRole + ) -> String? { + guard let databaseName, !databaseName.isEmpty else { return nil } + guard pathFieldRole == .filePath else { return databaseName } + return (databaseName as NSString).abbreviatingWithTildeInPath + } } private extension QuickSwitcherItemKind { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 885a28c76..186f0694f 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -12,9 +12,15 @@ import TableProPluginKit private let navigationLogger = Logger(subsystem: "com.TablePro", category: "MainContentCoordinator+Navigation") +internal enum TableTabOpenDisposition: Equatable { + case currentCoordinator + case focusedElsewhere +} + extension MainContentCoordinator { // MARK: - Table Tab Opening + @discardableResult func openTableTab( _ table: TableInfo, schema: String? = nil, @@ -22,7 +28,7 @@ extension MainContentCoordinator { forceNonPreview: Bool = false, activateGridFocus: Bool = false, forceNewWindowTab: Bool = false - ) { + ) -> TableTabOpenDisposition? { openTableTab( table.name, schema: schema ?? table.schema, @@ -34,6 +40,7 @@ extension MainContentCoordinator { ) } + @discardableResult func openTableTab( _ tableName: String, schema: String? = nil, @@ -42,7 +49,7 @@ extension MainContentCoordinator { forceNonPreview: Bool = false, activateGridFocus: Bool = false, forceNewWindowTab: Bool = false - ) { + ) -> TableTabOpenDisposition? { let navigationModel = PluginMetadataRegistry.shared.snapshot( forTypeId: connection.type.pluginTypeId )?.navigationModel ?? .standard @@ -50,7 +57,7 @@ extension MainContentCoordinator { let currentDatabase: String if navigationModel == .inPlace { guard tableName.hasPrefix("db"), Int(String(tableName.dropFirst(2))) != nil else { - return + return nil } currentDatabase = String(tableName.dropFirst(2)) } else { @@ -61,7 +68,7 @@ extension MainContentCoordinator { let createAsPreview = !forceNonPreview && !forceNewWindowTab && AppSettingsManager.shared.tabs.enablePreviewTabs - if !forceNewWindowTab, activateIfAlreadyOpen( + if !forceNewWindowTab, let disposition = activateIfAlreadyOpen( tableName: tableName, databaseName: currentDatabase, schemaName: resolvedSchema, @@ -72,7 +79,7 @@ extension MainContentCoordinator { navigationLogger.debug( "[tableload] activateExistingTab table=\(tableName, privacy: .public)" ) - return + return disposition } if activateGridFocus { @@ -94,17 +101,18 @@ extension MainContentCoordinator { schemaName: resolvedSchema, isView: isView ) + return .currentCoordinator } catch { navigationLogger.error("openTableTab addTableTab failed: \(error.localizedDescription, privacy: .public)") } } else { pendingGridFocusOnOpen = false } - return + return nil } if tabManager.tabs.isEmpty { - addFirstTableTab( + let didOpen = addFirstTableTab( tableName: tableName, currentDatabase: currentDatabase, resolvedSchema: resolvedSchema, @@ -112,7 +120,7 @@ extension MainContentCoordinator { createAsPreview: createAsPreview, isInPlace: navigationModel == .inPlace ) - return + return didOpen ? .currentCoordinator : nil } // In-place navigation: replace current tab content rather than @@ -145,14 +153,15 @@ extension MainContentCoordinator { selectRedisDatabaseAndQuery(dbIndex) } } + return replaced ? .currentCoordinator : nil } catch { navigationLogger.error("openTableTab replaceTabContent failed: \(error.localizedDescription, privacy: .public)") + return nil } - return } if isActiveTabReusable, !forceNewWindowTab { - reuseActiveTab( + let didOpen = reuseActiveTab( for: tableName, currentDatabase: currentDatabase, resolvedSchema: resolvedSchema, @@ -160,7 +169,7 @@ extension MainContentCoordinator { showStructure: showStructure, createAsPreview: createAsPreview ) - return + return didOpen ? .currentCoordinator : nil } promotePreviewTab() @@ -179,6 +188,7 @@ extension MainContentCoordinator { isPreview: createAsPreview ) WindowManager.shared.openTab(payload: payload) + return .focusedElsewhere } func activateIfAlreadyOpen( @@ -188,7 +198,7 @@ extension MainContentCoordinator { showStructure: Bool, activateGridFocus: Bool, includeSiblings: Bool - ) -> Bool { + ) -> TableTabOpenDisposition? { func matches(_ tab: QueryTab) -> Bool { tab.tabType == .table && tab.tableContext.tableName == tableName @@ -204,10 +214,10 @@ extension MainContentCoordinator { if activateGridFocus { requestGridFocus() } - return true + return .currentCoordinator } - guard includeSiblings else { return false } + guard includeSiblings else { return nil } for sibling in MainContentCoordinator.allActiveCoordinators() where sibling !== self && sibling.connectionId == connectionId { @@ -215,9 +225,9 @@ extension MainContentCoordinator { sibling.pendingGridFocusOnOpen = activateGridFocus applyStructureMode(showStructure, toTab: match.id, in: sibling.tabManager) sibling.selectTabAndFocusWindow(match.id) - return true + return .focusedElsewhere } - return false + return nil } private func applyStructureMode(_ showStructure: Bool, toTab tabId: UUID, in tabManager: QueryTabManager) { @@ -232,7 +242,7 @@ extension MainContentCoordinator { isView: Bool, createAsPreview: Bool, isInPlace: Bool - ) { + ) -> Bool { do { if createAsPreview { try tabManager.addPreviewTableTab( @@ -253,7 +263,7 @@ extension MainContentCoordinator { } } catch { navigationLogger.error("openTableTab tab creation failed: \(error.localizedDescription, privacy: .public)") - return + return false } if let (tab, tabIndex) = tabManager.selectedTabAndIndex { let token = TableLoadTracer.shared.begin(tabId: tab.id, table: tableName, origin: .sidebar) @@ -274,6 +284,7 @@ extension MainContentCoordinator { } else { lazyLoadCurrentTabIfNeeded() } + return true } private func reuseActiveTab( @@ -283,7 +294,7 @@ extension MainContentCoordinator { isView: Bool, showStructure: Bool, createAsPreview: Bool - ) { + ) -> Bool { let previousTableName = tabManager.selectedTab?.tableContext.tableName if let previousTableName { saveLastFilters(for: previousTableName) @@ -316,7 +327,7 @@ extension MainContentCoordinator { } catch { navigationLogger.error("openTableTab replaceTabContent failed: \(error.localizedDescription, privacy: .public)") if let token { TableLoadTracer.shared.finish(token: token, outcome: "replaceFailed") } - return + return false } if let token { TableLoadTracer.shared.stage(.replaceTabContent, token: token) } clearFilterState() @@ -335,6 +346,7 @@ extension MainContentCoordinator { cancelTableLoad(for: tabId) } lazyLoadCurrentTabIfNeeded() + return true } // MARK: - Preview Tabs diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index 8f5c0dd00..d1443d0dc 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -31,10 +31,17 @@ extension MainContentCoordinator { } func handleQuickSwitcherSelection(_ item: QuickSwitcherItem, intent: QuickSwitcherCommitIntent = .open) { + if let target = item.objectTarget, target.connectionId != connectionId { + openQuickSwitcherObject(item, target: target, intent: intent) + return + } + + let schemaName = item.objectTarget?.schemaName switch item.kind { case .table, .systemTable: openTableTab( item.name, + schema: schemaName, showStructure: intent == .openStructure, isView: item.isReadOnly, activateGridFocus: true, @@ -44,6 +51,7 @@ extension MainContentCoordinator { case .view: openTableTab( item.name, + schema: schemaName, showStructure: intent == .openStructure, isView: true, activateGridFocus: true, @@ -67,4 +75,64 @@ extension MainContentCoordinator { loadQueryIntoEditor(item.payload ?? item.name) } } + + private func openQuickSwitcherObject( + _ item: QuickSwitcherItem, + target: QuickSwitcherObjectTarget, + intent: QuickSwitcherCommitIntent + ) { + if let coordinator = Self.coordinator(browsing: target) { + let disposition = coordinator.openTableTab( + item.name, + schema: target.schemaName, + isView: item.kind == .view || item.isReadOnly, + activateGridFocus: true, + forceNewWindowTab: intent == .openInNewWindowTab + ) + switch disposition { + case .currentCoordinator: + if let tabId = coordinator.tabManager.selectedTabId { + coordinator.selectTabAndFocusWindow(tabId) + } + case .focusedElsewhere: + break + case nil: + coordinator.focusWindow() + } + return + } + + Task { [weak self] in + do { + try await TabRouter.shared.route(.openTable( + connectionId: target.connectionId, + database: target.databaseName, + schema: target.schemaName, + table: item.name, + isView: item.kind == .view || item.isReadOnly + )) + } catch { + guard let self else { return } + AlertHelper.showErrorSheet( + title: String(localized: "Could Not Open Table"), + message: error.localizedDescription, + window: contentWindow + ) + } + } + } + + /// 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 + } + } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SchemaLoading.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SchemaLoading.swift index c132f25a6..614d4a9e0 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SchemaLoading.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SchemaLoading.swift @@ -39,12 +39,17 @@ extension MainContentCoordinator { func loadSchema() async { let connection = connection + guard let scope = services.databaseManager.browseScope(for: connectionId) else { + armPostConnectSchemaLoad() + return + } do { - try await services.databaseManager.withBrowseMetadataDriver(connectionId: connectionId) { [services, connectionId] driver in + try await services.databaseManager.withMetadataDriver(scope: scope) { [services, connectionId] driver in await services.schemaService.load( connectionId: connectionId, driver: driver, - connection: connection + connection: connection, + scope: scope ) } } catch { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index 8cf95fe1a..935f0e64c 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -103,6 +103,10 @@ extension MainContentCoordinator { func selectTabAndFocusWindow(_ tabId: UUID) { tabManager.selectedTabId = tabId + focusWindow() + } + + func focusWindow() { guard let windowId, let window = WindowLifecycleMonitor.shared.window(for: windowId) else { return } window.makeKeyAndOrderFront(nil) diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index d0858a128..398a17d58 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -66,6 +66,9 @@ struct QuickSwitcherPanelView: View { openTableNames: openTableNames ) } + .task(id: viewModel.crossConnectionLoadVersion) { + await viewModel.loadCrossConnectionItems() + } } } @@ -96,7 +99,7 @@ struct QuickSwitcherPanelContent: View { } private var showsResultSurface: Bool { - !viewModel.flatItems.isEmpty || !trimmedQuery.isEmpty + !viewModel.flatItems.isEmpty || !trimmedQuery.isEmpty || viewModel.isLoadingResults } private var trimmedQuery: String { @@ -160,6 +163,7 @@ struct QuickSwitcherPanelContent: View { } .buttonStyle(.plain) .help(scope.title) + .accessibilityIdentifier("quick-switcher-scope-\(scope.rawValue)") } private var barStrokeColor: Color { @@ -322,19 +326,28 @@ struct QuickSwitcherPanelContent: View { .background(Capsule().fill(Color(nsColor: .quaternarySystemFill))) } + if showsSubtitle(for: item, isSelected: isSelected) { + Text(item.subtitle) + .font(.system(size: 12)) + .foregroundStyle(secondaryColor) + .lineLimit(1) + } + if isSelected { Text(commitHint(for: item)) .font(.system(size: 12)) .foregroundStyle(secondaryColor) keycap("↩", isEmphasized: isEmphasized) - } else if !item.subtitle.isEmpty { - Text(item.subtitle) - .font(.system(size: 12)) - .foregroundStyle(secondaryColor) - .lineLimit(1) } } + /// A cross-connection result keeps its path while selected, because it names the connection + /// 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 + } + private func keycap(_ label: String, isEmphasized: Bool) -> some View { Text(label) .font(.system(size: 11, weight: .medium)) @@ -357,13 +370,27 @@ struct QuickSwitcherPanelContent: View { } } + @ViewBuilder private var noResultsRow: some View { - Text(String(format: String(localized: "No results for \"%@\""), viewModel.searchText)) + if viewModel.isLoadingResults { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Loading...") + } .font(.system(size: 15)) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 24) .frame(height: PanelMetrics.rowHeight + PanelMetrics.listVerticalPadding * 2) + } else { + Text(String(format: String(localized: "No results for \"%@\""), viewModel.searchText)) + .font(.system(size: 15)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .frame(height: PanelMetrics.rowHeight + PanelMetrics.listVerticalPadding * 2) + } } @ViewBuilder @@ -377,9 +404,11 @@ struct QuickSwitcherPanelContent: View { viewModel.selectedItemId = item.id onCommit(item, .openInNewWindowTab) } - Button(String(localized: "Open Structure")) { - viewModel.selectedItemId = item.id - onCommit(item, .openStructure) + if viewModel.canOpenStructure(item) { + Button(String(localized: "Open Structure")) { + viewModel.selectedItemId = item.id + onCommit(item, .openStructure) + } } } Divider() @@ -449,11 +478,14 @@ struct QuickSwitcherPanelContent: View { } private func openSelectedItem() { - guard let item = viewModel.selectedItem() else { return } let intent: QuickSwitcherCommitIntent = NSEvent.modifierFlags.contains(.option) ? .openInNewWindowTab : .open - onCommit(item, intent) + Task { @MainActor in + await viewModel.flushPendingFilter() + guard let item = viewModel.selectedItem() else { return } + onCommit(item, intent) + } } } diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherSearchField.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherSearchField.swift index a30890acb..a22defbe9 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherSearchField.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherSearchField.swift @@ -27,6 +27,7 @@ internal struct QuickSwitcherSearchField: NSViewRepresentable { field.cell?.wraps = false field.delegate = context.coordinator field.setContentHuggingPriority(.defaultLow, for: .horizontal) + field.setAccessibilityIdentifier("quick-switcher-search-field") return field } @@ -62,7 +63,9 @@ internal struct QuickSwitcherSearchField: NSViewRepresentable { case #selector(NSResponder.moveDown(_:)): parent.onMoveDown() return true - case #selector(NSResponder.insertNewline(_:)): + case #selector(NSResponder.insertNewline(_:)), + #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:)), + #selector(NSResponder.insertLineBreak(_:)): parent.onSubmit() return true case #selector(NSResponder.cancelOperation(_:)): diff --git a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift index 13becc075..2ab0579ad 100644 --- a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift @@ -37,6 +37,7 @@ final class MockDatabaseDriver: DatabaseDriver, SchemaSwitchable, @unchecked Sen var fetchSchemasError: Error? var databasesToReturn: [String] = [] var fetchDatabasesError: Error? + var fetchTablesError: Error? private var hangContinuation: CheckedContinuation? init(connection: DatabaseConnection = TestFixtures.makeConnection()) { @@ -84,6 +85,9 @@ final class MockDatabaseDriver: DatabaseDriver, SchemaSwitchable, @unchecked Sen func fetchTables() async throws -> [TableInfo] { fetchTablesCallCount += 1 + if let fetchTablesError { + throw fetchTablesError + } return tablesToReturn } diff --git a/TableProTests/Services/SchemaServiceRefreshTests.swift b/TableProTests/Services/SchemaServiceRefreshTests.swift index 045e8336d..26c3123d5 100644 --- a/TableProTests/Services/SchemaServiceRefreshTests.swift +++ b/TableProTests/Services/SchemaServiceRefreshTests.swift @@ -150,6 +150,104 @@ struct SchemaServiceRefreshTests { #expect(!service.isRefreshing(connectionId: connectionId)) } + @Test("A load for a new scope does not adopt the in-flight load for the old scope") + func loadsForDifferentScopesDoNotJoin() async { + let connectionId = UUID() + let connection = TestFixtures.makeConnection(id: connectionId, type: .postgresql) + let driver = RefreshMockDriver(connection: connection) + let service = SchemaService() + let primary = DatabaseScope(connectionId: connectionId, database: "primary", schema: "public") + let analytics = DatabaseScope(connectionId: connectionId, database: "analytics", schema: "reporting") + + let suspended = AsyncStream.makeStream() + driver.onFetchTablesSuspended = { suspended.continuation.yield() } + driver.pausesFetchTables = true + driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "legacy_orders")] + + let first = Task { + await service.reload( + connectionId: connectionId, + driver: driver, + connection: connection, + scope: primary + ) + } + var iterator = suspended.stream.makeAsyncIterator() + await iterator.next() + + driver.pausesFetchTables = false + driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "events")] + let second = Task { + await service.reload( + connectionId: connectionId, + driver: driver, + connection: connection, + scope: analytics + ) + } + await Task.yield() + driver.resumeFetchTables() + await first.value + await second.value + + #expect(driver.tablesCallCount == 2, "Each browse scope must run its own fetch") + #expect(service.loadedScope(for: connectionId) == analytics) + #expect( + service.tables(for: connectionId).map(\.name) == ["events"], + "The new scope must not be stamped onto the previous scope's tables" + ) + } + + @Test("refresh waiters receive tables for the requested scope") + func refreshWaitersReceiveRequestedScope() async { + let connectionId = UUID() + let connection = TestFixtures.makeConnection(id: connectionId, type: .postgresql) + let driver = RefreshMockDriver(connection: connection) + let service = SchemaService() + let firstScope = DatabaseScope(connectionId: connectionId, database: "primary", schema: "public") + let secondScope = DatabaseScope(connectionId: connectionId, database: "analytics", schema: "reporting") + + driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "orders")] + await service.load( + connectionId: connectionId, + driver: driver, + connection: connection, + scope: firstScope + ) + + let suspended = AsyncStream.makeStream() + driver.onFetchTablesSuspended = { suspended.continuation.yield() } + driver.pausesFetchTables = true + driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "events")] + + let reload = Task { + await service.reload( + connectionId: connectionId, + driver: driver, + connection: connection, + scope: secondScope + ) + } + var iterator = suspended.stream.makeAsyncIterator() + await iterator.next() + + #expect(service.loadedScope(for: connectionId) == firstScope) + var waiterFinished = false + let waiter = Task { + await service.waitForRefresh(connectionId: connectionId) + waiterFinished = true + } + await Task.yield() + #expect(!waiterFinished) + + driver.resumeFetchTables() + await waiter.value + await reload.value + + #expect(service.loadedScope(for: connectionId) == secondScope) + #expect(service.tables(for: connectionId).map(\.name) == ["events"]) + } + @Test("a cold load still reports loading so the sidebar can show its spinner") func coldLoadReportsLoading() async { let connectionId = UUID() diff --git a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift index 4abf15914..de61a00db 100644 --- a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift @@ -20,14 +20,52 @@ struct QuickSwitcherViewModelTests { private func makeViewModel( items: [QuickSwitcherItem], connectionId: UUID = UUID(), - defaults: UserDefaults? = nil + defaults: UserDefaults? = nil, + services: AppServices? = nil ) -> QuickSwitcherViewModel { let suite = defaults ?? makeDefaults() - let vm = QuickSwitcherViewModel(connectionId: connectionId, services: .live, defaults: suite) + let vm = QuickSwitcherViewModel(connectionId: connectionId, services: services ?? .live, defaults: suite) vm.allItems = items return vm } + private func makeServices( + databaseManager: DatabaseManager, + schemaService: SchemaService, + schemaRefreshService: SchemaRefreshService + ) -> AppServices { + let live = AppServices.live + return AppServices( + appEvents: live.appEvents, + appSettings: live.appSettings, + appSettingsStorage: live.appSettingsStorage, + connectionStorage: live.connectionStorage, + databaseManager: databaseManager, + pluginManager: live.pluginManager, + schemaService: schemaService, + schemaRefreshService: schemaRefreshService, + schemaProviderRegistry: SchemaProviderRegistry(), + sqlFavoriteManager: live.sqlFavoriteManager, + favoriteTablesStorage: live.favoriteTablesStorage, + aiChatStorage: live.aiChatStorage, + aiKeyStorage: live.aiKeyStorage, + groupStorage: live.groupStorage, + tagStorage: live.tagStorage, + sshProfileStorage: live.sshProfileStorage, + licenseManager: live.licenseManager, + syncMetadataStorage: live.syncMetadataStorage, + favoritesExpansionState: live.favoritesExpansionState, + linkedFolderWatcher: live.linkedFolderWatcher, + queryHistoryManager: live.queryHistoryManager, + dateFormattingService: live.dateFormattingService, + copilotService: live.copilotService, + mcpServerManager: live.mcpServerManager, + syncTracker: live.syncTracker, + themeEngine: live.themeEngine, + welcomeRouter: live.welcomeRouter + ) + } + private func sampleItems() -> [QuickSwitcherItem] { [ QuickSwitcherItem(id: "t1", name: "users", kind: .table, subtitle: ""), @@ -39,30 +77,342 @@ struct QuickSwitcherViewModelTests { } @Test("Empty search with the All scope shows only recents") - func emptySearchShowsOnlyRecents() { + func emptySearchShowsOnlyRecents() async { let suite = makeDefaults() let connectionId = UUID() let items = sampleItems() let vm = makeViewModel(items: items, connectionId: connectionId, defaults: suite) + await vm.flushPendingFilter() #expect(vm.groups.isEmpty) vm.recordSelection(items[0]) let vm2 = QuickSwitcherViewModel(connectionId: connectionId, services: .live, defaults: suite) vm2.allItems = items + await vm2.flushPendingFilter() #expect(vm2.groups.count == 1) #expect(vm2.groups.first?.header == String(localized: "Recent")) } @Test("A browse scope lists every kind it covers") - func browseScopeListsKinds() { + func browseScopeListsKinds() async { let vm = makeViewModel(items: sampleItems()) vm.scope = .tables + await vm.flushPendingFilter() let kinds = vm.groups.compactMap { $0.header } #expect(kinds.contains(String(localized: "Tables"))) #expect(kinds.contains(String(localized: "Views"))) #expect(!kinds.contains(String(localized: "Databases"))) } + @Test("Connections scope lists objects from every connection") + func connectionsScopeUsesCrossConnectionItems() async { + let localConnectionId = UUID() + let remoteConnectionId = UUID() + let local = QuickSwitcherItem(id: "local", name: "users", kind: .table, subtitle: "") + let remoteTarget = QuickSwitcherObjectTarget( + connectionId: remoteConnectionId, + connectionName: "Analytics", + databaseName: "warehouse", + schemaName: "public" + ) + let remote = QuickSwitcherItem( + id: "remote", + name: "events", + kind: .table, + subtitle: "Analytics / warehouse / public", + objectTarget: remoteTarget + ) + let vm = makeViewModel(items: [local], connectionId: localConnectionId) + vm.crossConnectionItems = [remote] + vm.scope = .connections + await vm.flushPendingFilter() + + #expect(vm.flatItems.map(\.id) == ["remote"]) + #expect(vm.groups.first?.header == "Analytics") + } + + @Test("Connections scope replaces tables from a previous browse scope") + func connectionsScopeReplacesStaleScopeTables() async throws { + let connection = TestFixtures.makeConnection(database: "primary", type: .pglite) + let driver = MockDatabaseDriver(connection: connection) + let databaseManager = DatabaseManager() + let schemaService = SchemaService() + let schemaRefreshService = SchemaRefreshService( + schemaService: schemaService, + providerRegistry: SchemaProviderRegistry(), + metadataDriverProvider: databaseManager, + databaseManager: databaseManager + ) + let services = makeServices( + databaseManager: databaseManager, + schemaService: schemaService, + schemaRefreshService: schemaRefreshService + ) + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + session.browseDatabase = "primary" + databaseManager.injectSession(session, for: connection.id) + defer { databaseManager.removeSession(for: connection.id) } + + let firstScope = try #require(databaseManager.browseScope(for: connection.id)) + driver.tablesToReturn = [TableInfo(name: "legacy_orders", type: .table, rowCount: nil)] + await schemaService.load( + connectionId: connection.id, + driver: driver, + connection: connection, + scope: firstScope + ) + + session.browseDatabase = "analytics" + databaseManager.injectSession(session, for: connection.id) + driver.tablesToReturn = [TableInfo(name: "events", type: .table, rowCount: nil)] + + let vm = makeViewModel(items: [], connectionId: connection.id, services: services) + vm.scope = .connections + await vm.loadCrossConnectionItems() + await vm.flushPendingFilter() + + #expect(vm.flatItems.map(\.name) == ["events"]) + #expect(vm.flatItems.first?.objectTarget?.databaseName == "analytics") + #expect(!vm.flatItems.contains { $0.name == "legacy_orders" }) + } + + @Test("A connection that cannot load its catalog is not refreshed again for the same state") + func failedConnectionSettlesInsteadOfReloading() async throws { + let connection = TestFixtures.makeConnection(database: "primary", type: .pglite) + let driver = MockDatabaseDriver(connection: connection) + driver.fetchTablesError = DatabaseError.connectionFailed("permission denied") + let databaseManager = DatabaseManager() + let schemaService = SchemaService() + let schemaRefreshService = SchemaRefreshService( + schemaService: schemaService, + providerRegistry: SchemaProviderRegistry(), + metadataDriverProvider: databaseManager, + databaseManager: databaseManager + ) + let services = makeServices( + databaseManager: databaseManager, + schemaService: schemaService, + schemaRefreshService: schemaRefreshService + ) + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + session.browseDatabase = "primary" + databaseManager.injectSession(session, for: connection.id) + defer { databaseManager.removeSession(for: connection.id) } + + let vm = makeViewModel(items: [], connectionId: connection.id, services: services) + vm.scope = .connections + + await vm.loadCrossConnectionItems() + let callsAfterFirstLoad = driver.fetchTablesCallCount + #expect(callsAfterFirstLoad > 0, "The first load must try the connection") + + await vm.loadCrossConnectionItems() + + #expect( + driver.fetchTablesCallCount == callsAfterFirstLoad, + "A connection that failed must not be refreshed again until something about it changes" + ) + #expect(vm.flatItems.isEmpty) + } + + @Test("A commit straight after typing uses the query that was typed") + func pendingFilterCommitsBeforeSelectionIsRead() async { + let vm = makeViewModel(items: sampleItems()) + await vm.flushPendingFilter() + + vm.searchText = "orders" + await vm.flushPendingFilter() + + #expect( + vm.selectedItem()?.name == "orders", + "Return inside the refilter debounce must still commit the typed query" + ) + } + + @Test("Connections scope matches a connection name") + func connectionsScopeMatchesConnectionName() async throws { + let target = QuickSwitcherObjectTarget( + connectionId: UUID(), + connectionName: "Analytics", + databaseName: "warehouse", + schemaName: nil + ) + let remote = QuickSwitcherItem( + id: "remote", + name: "events", + kind: .table, + subtitle: "Analytics / warehouse", + objectTarget: target + ) + let vm = makeViewModel(items: []) + vm.crossConnectionItems = [remote] + vm.scope = .connections + vm.searchText = "analytics" + try await Task.sleep(nanoseconds: 200_000_000) + + #expect(vm.flatItems.first?.id == "remote") + #expect(vm.flatItems.first?.objectTarget == target) + } + + @Test("Changing the query selects the best match") + func changingQuerySelectsBestMatch() async throws { + let target = QuickSwitcherObjectTarget( + connectionId: UUID(), + connectionName: "Chinook", + databaseName: "sample.sqlite", + schemaName: nil + ) + let items = QuickSwitcherViewModel.makeCrossConnectionItems( + tables: [ + TableInfo(name: "Album", type: .table, rowCount: nil), + TableInfo(name: "Track", type: .table, rowCount: nil) + ], + target: target + ) + let vm = makeViewModel(items: []) + vm.crossConnectionItems = items + vm.scope = .connections + vm.selectedItemId = items[0].id + + vm.searchText = "track" + try await Task.sleep(nanoseconds: 200_000_000) + + #expect(vm.flatItems.first?.name == "Track") + #expect(vm.selectedItem()?.name == "Track") + } + + @Test("Cross-connection catalog keeps object location") + func crossConnectionCatalogKeepsLocation() { + let connectionId = UUID() + let target = QuickSwitcherObjectTarget( + connectionId: connectionId, + connectionName: "Primary", + databaseName: "app", + schemaName: "fallback" + ) + let tables = [ + TableInfo(name: "users", type: .table, rowCount: nil, schema: "public"), + TableInfo(name: "active_users", type: .view, rowCount: nil) + ] + + let items = QuickSwitcherViewModel.makeCrossConnectionItems(tables: tables, target: target) + + #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[1].kind == .view) + #expect(items.allSatisfy { $0.subtitle.contains("Primary / app") }) + } + + @Test("File database paths are abbreviated in search results") + func fileDatabasePathsAreAbbreviated() { + let databasePath = NSHomeDirectory() + "/Databases/private.sqlite" + let displayName = QuickSwitcherViewModel.databaseDisplayName(databasePath, pathFieldRole: .filePath) + let target = QuickSwitcherObjectTarget( + connectionId: UUID(), + connectionName: "Local", + databaseName: databasePath, + schemaName: nil, + databaseDisplayName: displayName + ) + + let item = QuickSwitcherViewModel.makeCrossConnectionItems( + tables: [TableInfo(name: "users", type: .table, rowCount: nil)], + target: target + )[0] + + #expect(displayName == "~/Databases/private.sqlite") + #expect(!item.subtitle.contains(NSHomeDirectory())) + #expect(item.objectTarget?.databaseName == databasePath) + } + + @Test("Identical object names in different schemas keep unique identities") + func duplicateNamesAcrossSchemasStayUnique() { + let target = QuickSwitcherObjectTarget( + connectionId: UUID(), + connectionName: "Primary", + databaseName: "app", + schemaName: nil + ) + let tables = [ + TableInfo(name: "events", type: .table, rowCount: nil, schema: "public"), + TableInfo(name: "events", type: .table, rowCount: nil, schema: "audit") + ] + + let items = QuickSwitcherViewModel.makeCrossConnectionItems(tables: tables, target: target) + + #expect(Set(items.map(\.id)).count == 2) + #expect(Set(items.compactMap(\.objectTarget?.schemaName)) == Set(["public", "audit"])) + } + + @Test("Connections scope caps a hostile catalog") + func connectionsScopeCapsLargeCatalog() async { + let target = QuickSwitcherObjectTarget( + connectionId: UUID(), + connectionName: "Primary", + databaseName: "app", + schemaName: nil + ) + let tables = (0..<300).map { index in + TableInfo(name: "table_\(index)", type: .table, rowCount: nil) + } + let vm = makeViewModel(items: []) + vm.crossConnectionItems = QuickSwitcherViewModel.makeCrossConnectionItems(tables: tables, target: target) + vm.scope = .connections + await vm.flushPendingFilter() + + #expect(vm.flatItems.count == 200) + } + + @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") + ]) + + vm.searchText = "users'; DROP TABLE audit; --" + try await Task.sleep(nanoseconds: 200_000_000) + + #expect(vm.flatItems.isEmpty) + #expect(vm.allItems.map(\.name) == ["users"]) + } + + @Test("Structure action stays in the current connection") + func structureActionStaysInCurrentConnection() { + let connectionId = UUID() + let vm = makeViewModel(items: [], connectionId: connectionId) + let currentTarget = QuickSwitcherObjectTarget( + connectionId: connectionId, + connectionName: "Primary", + databaseName: nil, + schemaName: nil + ) + let remoteTarget = QuickSwitcherObjectTarget( + connectionId: UUID(), + connectionName: "Analytics", + databaseName: nil, + schemaName: nil + ) + + #expect(vm.canOpenStructure(QuickSwitcherItem( + id: "current", + name: "users", + kind: .table, + subtitle: "", + objectTarget: currentTarget + ))) + #expect(!vm.canOpenStructure(QuickSwitcherItem( + id: "remote", + name: "events", + kind: .table, + subtitle: "", + objectTarget: remoteTarget + ))) + } + @Test("Filtered search returns one headerless group of best matches") func filteredGroupHasNoHeader() async throws { let vm = makeViewModel(items: sampleItems()) @@ -90,20 +440,22 @@ struct QuickSwitcherViewModelTests { } @Test("Browse scope caps at maxResults") - func filterCaps() { + func filterCaps() async { var items: [QuickSwitcherItem] = [] for index in 0..<300 { items.append(QuickSwitcherItem(id: "t\(index)", name: "table_\(index)", kind: .table, subtitle: "")) } let vm = makeViewModel(items: items) vm.scope = .tables + await vm.flushPendingFilter() #expect(vm.flatItems.count == 200) } @Test("moveSelection by 1 advances to next item") - func moveDownAdvances() { + func moveDownAdvances() async { let vm = makeViewModel(items: sampleItems()) vm.scope = .tables + await vm.flushPendingFilter() let first = vm.flatItems.first?.id #expect(vm.selectedItemId == first) vm.moveSelection(by: 1) @@ -111,9 +463,10 @@ struct QuickSwitcherViewModelTests { } @Test("moveSelection clamps at the bounds") - func moveSelectionClamps() { + func moveSelectionClamps() async { let vm = makeViewModel(items: sampleItems()) vm.scope = .tables + await vm.flushPendingFilter() vm.selectedItemId = vm.flatItems.first?.id vm.moveSelection(by: -1) #expect(vm.selectedItemId == vm.flatItems.first?.id) @@ -130,24 +483,26 @@ struct QuickSwitcherViewModelTests { } @Test("selectedItem returns the current selection") - func selectedItemReturnsCurrent() { + func selectedItemReturnsCurrent() async { let vm = makeViewModel(items: sampleItems()) vm.scope = .tables + await vm.flushPendingFilter() let target = vm.flatItems[2] vm.selectedItemId = target.id #expect(vm.selectedItem()?.id == target.id) } @Test("selectedItem is nil when no selection") - func selectedItemNilWhenNone() { + func selectedItemNilWhenNone() async { let vm = makeViewModel(items: sampleItems()) vm.scope = .tables + await vm.flushPendingFilter() vm.selectedItemId = nil #expect(vm.selectedItem() == nil) } @Test("recordSelection inserts MRU and Recent group appears next time") - func recordSelectionAddsRecent() { + func recordSelectionAddsRecent() async { let suite = makeDefaults() let connectionId = UUID() let items = sampleItems() @@ -157,12 +512,13 @@ struct QuickSwitcherViewModelTests { let vm2 = QuickSwitcherViewModel(connectionId: connectionId, services: .live, defaults: suite) vm2.allItems = items + await vm2.flushPendingFilter() let recentGroup = vm2.groups.first { $0.header == String(localized: "Recent") } #expect(recentGroup?.items.first?.id == chosen.id) } @Test("Recent group caps at 10 entries, newest first") - func recentGroupCapsAtLimit() { + func recentGroupCapsAtLimit() async { let suite = makeDefaults() let connectionId = UUID() var items: [QuickSwitcherItem] = [] @@ -176,6 +532,7 @@ struct QuickSwitcherViewModelTests { let vm2 = QuickSwitcherViewModel(connectionId: connectionId, services: .live, defaults: suite) vm2.allItems = items + await vm2.flushPendingFilter() let recentGroup = vm2.groups.first { $0.header == String(localized: "Recent") } #expect(recentGroup?.items.count == 10) #expect(recentGroup?.items.first?.id == items.last?.id) @@ -212,7 +569,7 @@ struct QuickSwitcherViewModelTests { } @Test("Saved queries get their own section in the queries browse scope") - func savedQueriesGetOwnSection() { + func savedQueriesGetOwnSection() async { var items = sampleItems() items.append(QuickSwitcherItem( id: "f1", @@ -223,6 +580,7 @@ struct QuickSwitcherViewModelTests { )) let vm = makeViewModel(items: items) vm.scope = .queries + await vm.flushPendingFilter() let headers = vm.groups.compactMap(\.header) #expect(headers.contains(String(localized: "Saved Queries"))) } @@ -243,11 +601,13 @@ struct QuickSwitcherViewModelTests { } @Test("Scope limits the empty-query view to its kinds") - func scopeLimitsEmptyQueryView() { + func scopeLimitsEmptyQueryView() async { let vm = makeViewModel(items: sampleItems()) vm.scope = .tables + await vm.flushPendingFilter() #expect(vm.flatItems.allSatisfy { [.table, .view, .systemTable].contains($0.kind) }) vm.scope = .queries + await vm.flushPendingFilter() #expect(vm.flatItems.allSatisfy { [.savedQuery, .queryHistory].contains($0.kind) }) } @@ -303,8 +663,9 @@ struct QuickSwitcherViewModelTests { } @Test("listHeight is zero when there are no items") - func listHeightZeroWhenEmpty() { + func listHeightZeroWhenEmpty() async { let vm = makeViewModel(items: []) + await vm.flushPendingFilter() #expect(vm.listHeight(rowHeight: 30, headerHeight: 28, maxVisibleRows: 9) == 0) } @@ -344,30 +705,33 @@ struct QuickSwitcherViewModelTests { } @Test("listHeight for a browse scope counts section headers") - func listHeightCountsSectionHeaders() { + func listHeightCountsSectionHeaders() async { let vm = makeViewModel(items: sampleItems()) vm.scope = .tables + await vm.flushPendingFilter() #expect(vm.groups.filter { $0.header != nil }.count == 2) #expect(vm.flatItems.count == 3) #expect(vm.listHeight(rowHeight: 30, headerHeight: 28, maxVisibleRows: 9) == 146) } @Test("A recorded selection adds a Recent header and row to the empty-query view") - func listHeightIncludesRecentHeader() { + func listHeightIncludesRecentHeader() async { let suite = makeDefaults() let connectionId = UUID() let items = sampleItems() let vm = makeViewModel(items: items, connectionId: connectionId, defaults: suite) + await vm.flushPendingFilter() #expect(vm.listHeight(rowHeight: 30, headerHeight: 28, maxVisibleRows: 100) == 0) vm.recordSelection(items[0]) let vm2 = QuickSwitcherViewModel(connectionId: connectionId, services: .live, defaults: suite) vm2.allItems = items + await vm2.flushPendingFilter() #expect(vm2.listHeight(rowHeight: 30, headerHeight: 28, maxVisibleRows: 100) == 58) } @Test("listHeight clamps to the cap when sections and rows overflow") - func listHeightClampsWithHeaders() { + func listHeightClampsWithHeaders() async { var items: [QuickSwitcherItem] = [] for index in 0..<30 { items.append(QuickSwitcherItem(id: "t\(index)", name: "table_\(index)", kind: .table, subtitle: "")) @@ -375,6 +739,7 @@ struct QuickSwitcherViewModelTests { } let vm = makeViewModel(items: items) vm.scope = .tables + await vm.flushPendingFilter() #expect(vm.groups.filter { $0.header != nil }.count >= 2) #expect(vm.listHeight(rowHeight: 30, headerHeight: 28, maxVisibleRows: 9) == 270) } diff --git a/TableProTests/Views/Main/MultiConnectionNavigationTests.swift b/TableProTests/Views/Main/MultiConnectionNavigationTests.swift index 0973f4ce2..0de4b84a1 100644 --- a/TableProTests/Views/Main/MultiConnectionNavigationTests.swift +++ b/TableProTests/Views/Main/MultiConnectionNavigationTests.swift @@ -269,8 +269,9 @@ struct MultiConnectionNavigationTests { #expect(tabManagerA.selectedTab?.tableContext.tableName == "accounts") try tabManagerB.addTableTab(tableName: "orders", databaseType: .mysql, databaseName: "db_a") - coordinatorB.openTableTab("users") + let disposition = coordinatorB.openTableTab("users") + #expect(disposition == .focusedElsewhere) #expect(tabManagerB.tabs.count == 1) #expect(tabManagerB.tabs.first?.tableContext.tableName == "orders") #expect(tabManagerA.selectedTab?.tableContext.tableName == "users") @@ -299,7 +300,7 @@ struct MultiConnectionNavigationTests { includeSiblings: true ) - #expect(activated == false) + #expect(activated == nil) #expect(tabManagerB.tabs.isEmpty) } } diff --git a/TableProTests/Views/Main/OpenTableTabTests.swift b/TableProTests/Views/Main/OpenTableTabTests.swift index 557f3440c..5d3847af0 100644 --- a/TableProTests/Views/Main/OpenTableTabTests.swift +++ b/TableProTests/Views/Main/OpenTableTabTests.swift @@ -354,7 +354,7 @@ struct OpenTableTabTests { includeSiblings: true ) - #expect(activated == false) + #expect(activated == nil) #expect(tabManager.selectedTab?.tableContext.tableName == "orders") } @@ -383,7 +383,7 @@ struct OpenTableTabTests { includeSiblings: true ) - #expect(activated == true) + #expect(activated == .currentCoordinator) #expect(tabManager.selectedTab?.tableContext.tableName == "users") #expect(tabManager.selectedTab?.display.resultsViewMode == .structure) } diff --git a/TableProTests/Views/QuickSwitcherSearchFieldTests.swift b/TableProTests/Views/QuickSwitcherSearchFieldTests.swift new file mode 100644 index 000000000..c1c9103f2 --- /dev/null +++ b/TableProTests/Views/QuickSwitcherSearchFieldTests.swift @@ -0,0 +1,81 @@ +import AppKit +import SwiftUI +@testable import TablePro +import Testing + +@MainActor +struct QuickSwitcherSearchFieldTests { + @Test("Every Return command submits") + func returnCommandsSubmit() { + var text = "invoice" + var submitCount = 0 + let field = QuickSwitcherSearchField( + text: Binding(get: { text }, set: { text = $0 }), + placeholder: "", + onMoveUp: {}, + onMoveDown: {}, + onSubmit: { submitCount += 1 } + ) + let coordinator = field.makeCoordinator() + let control = NSTextField() + let editor = NSTextView() + let selectors = [ + #selector(NSResponder.insertNewline(_:)), + #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:)), + #selector(NSResponder.insertLineBreak(_:)) + ] + + for selector in selectors { + #expect(coordinator.control(control, textView: editor, doCommandBy: selector)) + } + + #expect(submitCount == selectors.count) + } + + @Test("Escape clears a typed query instead of dismissing") + func escapeClearsTypedQuery() { + var text = "invoice" + let field = QuickSwitcherSearchField( + text: Binding(get: { text }, set: { text = $0 }), + placeholder: "", + onMoveUp: {}, + onMoveDown: {}, + onSubmit: {} + ) + let coordinator = field.makeCoordinator() + let control = NSTextField() + control.stringValue = "invoice" + + let handled = coordinator.control( + control, + textView: NSTextView(), + doCommandBy: #selector(NSResponder.cancelOperation(_:)) + ) + + #expect(handled, "The field owns the cancel while it has a query to cancel") + #expect(text.isEmpty) + #expect(control.stringValue.isEmpty) + } + + @Test("Escape on an empty field passes through to the panel") + func escapePassesThroughWhenEmpty() { + var text = "" + let field = QuickSwitcherSearchField( + text: Binding(get: { text }, set: { text = $0 }), + placeholder: "", + onMoveUp: {}, + onMoveDown: {}, + onSubmit: {} + ) + let coordinator = field.makeCoordinator() + + let handled = coordinator.control( + NSTextField(), + textView: NSTextView(), + doCommandBy: #selector(NSResponder.cancelOperation(_:)) + ) + + #expect(!handled, "With nothing to cancel the panel must get the cancel and close") + #expect(text.isEmpty) + } +} diff --git a/TableProUITests/QuickSwitcherCrossConnectionUITests.swift b/TableProUITests/QuickSwitcherCrossConnectionUITests.swift new file mode 100644 index 000000000..1070e84b5 --- /dev/null +++ b/TableProUITests/QuickSwitcherCrossConnectionUITests.swift @@ -0,0 +1,80 @@ +import XCTest + +final class QuickSwitcherCrossConnectionUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().terminate() + } + + func testConnectionsScopeSearchesTheOpenSampleDatabase() throws { + let app = launchWithSampleDatabase() + XCTAssertTrue(editorTextView(in: app).waitForExistence(timeout: 15)) + + app.typeKey("o", modifierFlags: [.command, .shift]) + let searchField = app.textFields["quick-switcher-search-field"] + XCTAssertTrue(searchField.waitForExistence(timeout: 10)) + + app.typeKey("5", modifierFlags: .command) + XCTAssertTrue(app.staticTexts["Connections"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["Chinook (Sample)"].waitForExistence(timeout: 15)) + XCTAssertTrue(app.staticTexts["Track"].waitForExistence(timeout: 5)) + + searchField.typeText("track") + XCTAssertTrue(app.staticTexts["Track"].waitForExistence(timeout: 5)) + + searchField.typeKey("a", modifierFlags: .command) + searchField.typeText("missing-object-name") + XCTAssertTrue(app.staticTexts["No results for \"missing-object-name\""].waitForExistence(timeout: 5)) + + searchField.typeKey("a", modifierFlags: .command) + searchField.typeText("track") + searchField.typeKey(.return, modifierFlags: []) + XCTAssertTrue(searchField.waitForNonExistence(timeout: 5)) + let trackWindow = app.windows.matching(NSPredicate(format: "title BEGINSWITH %@", "Track")).firstMatch + XCTAssertTrue(trackWindow.waitForExistence(timeout: 10)) + + app.typeKey("o", modifierFlags: [.command, .shift]) + XCTAssertTrue(searchField.waitForExistence(timeout: 5)) + app.typeKey("5", modifierFlags: .command) + searchField.typeText("invoice") + searchField.typeKey(.return, modifierFlags: .option) + XCTAssertTrue(searchField.waitForNonExistence(timeout: 5)) + let invoiceWindow = app.windows.matching(NSPredicate(format: "title BEGINSWITH %@", "Invoice")).firstMatch + XCTAssertTrue(invoiceWindow.waitForExistence(timeout: 10)) + + app.typeKey("o", modifierFlags: [.command, .shift]) + XCTAssertTrue(searchField.waitForExistence(timeout: 5)) + searchField.typeText("dismiss-me") + searchField.typeKey(.escape, modifierFlags: []) + XCTAssertTrue(searchField.waitForExistence(timeout: 5), "The first Escape clears the query, it does not dismiss") + XCTAssertEqual(searchField.value as? String, "") + searchField.typeKey(.escape, modifierFlags: []) + XCTAssertTrue(searchField.waitForNonExistence(timeout: 5)) + } + + private func launchWithSampleDatabase() -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["TABLEPRO_UI_TESTING"] = "1" + app.launch() + + let menuBar = app.menuBars.firstMatch + XCTAssertTrue(menuBar.waitForExistence(timeout: 10)) + menuBar.menuBarItems["Help"].click() + let openSample = menuBar.menuItems["Open Sample Database"] + XCTAssertTrue(openSample.waitForExistence(timeout: 5)) + openSample.click() + return app + } + + private func editorTextView(in app: XCUIApplication) -> XCUIElement { + let window = app.windows.firstMatch + let identified = window.textViews.matching(identifier: "sql-editor-textview").firstMatch + if identified.exists { + return identified + } + return window.textViews.firstMatch + } +} diff --git a/docs/features/quick-switcher.mdx b/docs/features/quick-switcher.mdx index 59eee41d2..c8452bdc6 100644 --- a/docs/features/quick-switcher.mdx +++ b/docs/features/quick-switcher.mdx @@ -1,6 +1,6 @@ --- title: Quick Switcher -description: Search and jump to any table, view, database, schema, saved query, or recent query +description: Search objects and queries in one connection or across every open connection --- Press `Cmd+Shift+O` (or **Query** > **Quick Switcher**) in a connection window to open a floating search panel. Type a few characters and press `Return` to jump to any table, view, system table, database, schema, saved query, or recent query. Matching is fuzzy: `usr` finds `users` and `user_settings`, and matched characters are shown in bold. @@ -18,17 +18,24 @@ Press `Cmd+Shift+O` (or **Query** > **Quick Switcher**) in a connection window t | Move selection | `Up` / `Down`, or `Ctrl+J`/`Ctrl+N` and `Ctrl+K`/`Ctrl+P` | | Open selected item | `Return` (double-click also works) | | Open in a new window tab | `Option+Return` | -| Switch scope | `Cmd+1` to `Cmd+4` | -| Dismiss | `Escape`, `Cmd+Shift+O` again, or click outside | +| Switch scope | `Cmd+1` to `Cmd+5` | +| Clear the search text | `Escape` | +| Dismiss | `Escape` on an empty field, `Cmd+Shift+O` again, or click outside | The opening shortcut is rebindable in **Settings > Keyboard**; see [Keyboard Shortcuts](/features/keyboard-shortcuts). ## Scopes -Four scopes limit what the search covers: **All** (`Cmd+1`), **Tables** (`Cmd+2`, includes views and system tables), **Databases** (`Cmd+3`, includes schemas), and **Queries** (`Cmd+4`, saved queries and recent queries). 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. +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. +## Search Across Connections + +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. + ## 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: @@ -54,3 +61,5 @@ What opening does depends on the item: tables and views open a table tab, databa | Open Structure | Tables, views, system tables | | Copy Name | All items | | Copy Query | Saved and recent queries | + +Open Structure is available only for objects in the current connection.