From 04b5f3e72753c8a1e5860b7ada974f057648c704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 08:05:44 +0700 Subject: [PATCH 01/12] feat(sidebar): show all databases on the server as a tree (#139) --- CHANGELOG.md | 4 + .../xcshareddata/xcschemes/TablePro.xcscheme | 2 +- .../Database/DatabaseManager+Health.swift | 1 + .../Database/DatabaseManager+Sessions.swift | 2 + .../SidebarContainerViewController.swift | 45 +- .../Query/DatabaseTreeMetadataService.swift | 289 +++++++++++ .../Query/MetadataConnectionPool.swift | 136 +++++ .../Core/Services/Query/SchemaService.swift | 19 + TablePro/Models/UI/WindowSidebarState.swift | 8 + TablePro/Resources/Localizable.xcstrings | 24 + TablePro/ViewModels/SidebarViewModel.swift | 79 ++- .../Views/Main/MainContentCoordinator.swift | 5 + TablePro/Views/Sidebar/DatabaseTreeView.swift | 473 ++++++++++++++++++ TablePro/Views/Sidebar/FavoritesTabView.swift | 202 +++++--- TablePro/Views/Sidebar/RedisKeyTreeView.swift | 6 +- TablePro/Views/Sidebar/RoutineRowView.swift | 2 +- .../Views/Sidebar/SchemaPickerFooter.swift | 10 +- .../Views/Sidebar/SidebarContextMenu.swift | 20 +- TablePro/Views/Sidebar/SidebarTreeView.swift | 36 +- TablePro/Views/Sidebar/SidebarView.swift | 35 +- TablePro/Views/Sidebar/TableRowView.swift | 36 +- 21 files changed, 1256 insertions(+), 178 deletions(-) create mode 100644 TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift create mode 100644 TablePro/Core/Services/Query/MetadataConnectionPool.swift create mode 100644 TablePro/Views/Sidebar/DatabaseTreeView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d643cac0e..2bee610be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- The sidebar lists every database on the server as a tree; right-click a database or schema to set it as active. Applies to MySQL, MariaDB, PostgreSQL, MSSQL, ClickHouse, Redshift; SQLite, Redis, MongoDB, BigQuery keep their existing sidebar. (#139) + ## [0.46.0] - 2026-05-28 ### Added diff --git a/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme b/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme index f99c67cbd..a2d8da8aa 100644 --- a/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme +++ b/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme @@ -1,7 +1,7 @@ + version = "1.8"> private var sidebarState: SharedSidebarState? private var windowState: WindowSidebarState? - private var observationGeneration = 0 + private var observationTask: Task? var rootView: AnyView { get { hostingController.rootView } @@ -58,7 +58,7 @@ internal final class SidebarContainerViewController: NSViewController { } func updateSidebarState(_ state: SharedSidebarState?, windowState: WindowSidebarState?) { - observationGeneration += 1 + observationTask?.cancel() self.sidebarState = state self.windowState = windowState guard let state, let windowState else { @@ -66,31 +66,34 @@ internal final class SidebarContainerViewController: NSViewController { return } searchField.isHidden = false - syncFromState(state, windowState: windowState) - startObserving(state, windowState: windowState, generation: observationGeneration) + observationTask = Task { @MainActor [weak self] in + guard let self else { return } + while !Task.isCancelled { + self.syncFromState(state, windowState: windowState) + await Self.awaitChange(state: state, windowState: windowState) + } + } } - private func startObserving( - _ state: SharedSidebarState, - windowState: WindowSidebarState, - generation: Int - ) { - withObservationTracking { - _ = state.selectedSidebarTab - _ = windowState.searchText - _ = windowState.favoritesSearchText - } onChange: { [weak self] in - Task { @MainActor [weak self] in - guard let self, - generation == self.observationGeneration, - let sidebarState = self.sidebarState, - let windowState = self.windowState else { return } - self.syncFromState(sidebarState, windowState: windowState) - self.startObserving(sidebarState, windowState: windowState, generation: generation) + private static func awaitChange(state: SharedSidebarState, windowState: WindowSidebarState) async { + await withCheckedContinuation { continuation in + var resumed = false + withObservationTracking { + _ = state.selectedSidebarTab + _ = windowState.searchText + _ = windowState.favoritesSearchText + } onChange: { + guard !resumed else { return } + resumed = true + continuation.resume() } } } + deinit { + observationTask?.cancel() + } + private func syncFromState(_ state: SharedSidebarState, windowState: WindowSidebarState) { let activeText: String let placeholder: String diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift new file mode 100644 index 000000000..06592bcd5 --- /dev/null +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -0,0 +1,289 @@ +// +// DatabaseTreeMetadataService.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit + +@MainActor +@Observable +final class DatabaseTreeMetadataService { + static let shared = DatabaseTreeMetadataService() + + struct DatabaseKey: Hashable, Sendable { + let connectionId: UUID + let database: String + } + + struct TableKey: Hashable, Sendable { + let connectionId: UUID + let database: String + let schema: String? + } + + enum DatabaseListState: Equatable, Sendable { + case idle + case loading + case loaded([DatabaseMetadata]) + case failed(String) + } + + enum SchemaListState: Equatable, Sendable { + case idle + case loading + case loaded([String]) + case failed(String) + } + + private(set) var databaseListStates: [UUID: DatabaseListState] = [:] + private(set) var schemaListStates: [DatabaseKey: SchemaListState] = [:] + private(set) var tableStates: [TableKey: SchemaState] = [:] + + @ObservationIgnored private let databaseListDedup = OnceTask() + @ObservationIgnored private let schemaListDedup = OnceTask() + @ObservationIgnored private let tableDedup = OnceTask() + + @ObservationIgnored private static let logger = Logger( + subsystem: "com.TablePro", category: "DatabaseTreeMetadataService" + ) + + private init() {} + + func databaseListState(for connectionId: UUID) -> DatabaseListState { + databaseListStates[connectionId] ?? .idle + } + + func databases(for connectionId: UUID) -> [DatabaseMetadata] { + if case .loaded(let list) = databaseListState(for: connectionId) { + return list + } + return [] + } + + func schemaListState(connectionId: UUID, database: String) -> SchemaListState { + if database == activeDatabase(for: connectionId) { + switch SchemaService.shared.state(for: connectionId) { + case .idle: return .idle + case .loading: return .loading + case .failed(let message): return .failed(message) + case .loaded: return .loaded(SchemaService.shared.schemas(for: connectionId)) + } + } + return schemaListStates[DatabaseKey(connectionId: connectionId, database: database)] ?? .idle + } + + func schemas(connectionId: UUID, database: String) -> [String] { + if case .loaded(let list) = schemaListState(connectionId: connectionId, database: database) { + return list + } + return [] + } + + func tableState(connectionId: UUID, database: String, schema: String?) -> SchemaState { + if database == activeDatabase(for: connectionId) { + if let schema { + return SchemaService.shared.schemaState(for: connectionId, schema: schema) + } + return SchemaService.shared.state(for: connectionId) + } + return tableStates[Self.tableKey( + connectionId: connectionId, database: database, schema: schema + )] ?? .idle + } + + func tables(connectionId: UUID, database: String, schema: String?) -> [TableInfo] { + if database == activeDatabase(for: connectionId) { + if let schema { + return SchemaService.shared.tables(for: connectionId, schema: schema) + } + return SchemaService.shared.tables(for: connectionId) + } + if case .loaded(let list) = tableState( + connectionId: connectionId, database: database, schema: schema + ) { + return list + } + return [] + } + + func routines(connectionId: UUID, database: String, schema: String?) -> [RoutineInfo] { + guard database == activeDatabase(for: connectionId) else { return [] } + return SchemaService.shared.routines(for: connectionId) + } + + func loadDatabaseList(connectionId: UUID, driver: DatabaseDriver, databaseType: DatabaseType) async { + if case .loaded = databaseListState(for: connectionId) { return } + databaseListStates[connectionId] = .loading + let systemNames = Set(PluginManager.shared.systemDatabaseNames(for: databaseType)) + do { + let list = try await databaseListDedup.execute(key: connectionId) { + let names = try await driver.fetchDatabases() + return names.sorted().map { name in + DatabaseMetadata.minimal(name: name, isSystem: systemNames.contains(name)) + } + } + databaseListStates[connectionId] = .loaded(list) + } catch is CancellationError { + return + } catch { + Self.logger.warning( + "[tree] database list load failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + databaseListStates[connectionId] = .failed(error.localizedDescription) + } + } + + func reloadDatabaseList(connectionId: UUID, driver: DatabaseDriver, databaseType: DatabaseType) async { + await databaseListDedup.cancel(key: connectionId) + databaseListStates.removeValue(forKey: connectionId) + await loadDatabaseList( + connectionId: connectionId, driver: driver, databaseType: databaseType + ) + } + + func loadSchemaList(connectionId: UUID, database: String) async { + if database == activeDatabase(for: connectionId) { return } + let key = DatabaseKey(connectionId: connectionId, database: database) + if case .loaded = schemaListStates[key] { return } + schemaListStates[key] = .loading + do { + let list = try await schemaListDedup.execute(key: key) { + try await MetadataConnectionPool.shared.withDriver( + connectionId: connectionId, database: database + ) { driver in + try await driver.fetchSchemas() + } + } + schemaListStates[key] = .loaded(list) + } catch is CancellationError { + return + } catch { + Self.logger.warning( + "[tree] schema list load failed connId=\(connectionId, privacy: .public) db=\(database, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + schemaListStates[key] = .failed(error.localizedDescription) + } + } + + func loadTables(connectionId: UUID, database: String, schema: String?) async { + if database == activeDatabase(for: connectionId) { + guard let session = DatabaseManager.shared.session(for: connectionId), + let driver = session.driver else { return } + if let schema { + await SchemaService.shared.loadSchemaTables( + connectionId: connectionId, schema: schema, driver: driver + ) + } else if case .idle = SchemaService.shared.state(for: connectionId) { + await SchemaService.shared.load( + connectionId: connectionId, driver: driver, connection: session.connection + ) + } + return + } + let key = Self.tableKey(connectionId: connectionId, database: database, schema: schema) + if case .loaded = tableStates[key] { return } + tableStates[key] = .loading + do { + let normalizedSchema = key.schema + let list = try await tableDedup.execute(key: key) { + try await MetadataConnectionPool.shared.withDriver( + connectionId: connectionId, database: database + ) { driver in + try await driver.fetchTables(schema: normalizedSchema) + } + } + tableStates[key] = .loaded(list) + } catch is CancellationError { + return + } catch { + Self.logger.warning( + "[tree] table load failed connId=\(connectionId, privacy: .public) db=\(database, privacy: .public) schema=\(schema ?? "", privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + tableStates[key] = .failed(error.localizedDescription) + } + } + + func reloadTables(connectionId: UUID, database: String, schema: String?) async { + if database == activeDatabase(for: connectionId) { + guard let session = DatabaseManager.shared.session(for: connectionId), + let driver = session.driver else { return } + if let schema { + await SchemaService.shared.reloadSchemaTables( + connectionId: connectionId, schema: schema, driver: driver + ) + } else { + await SchemaService.shared.reload( + connectionId: connectionId, driver: driver, connection: session.connection + ) + } + return + } + let key = Self.tableKey(connectionId: connectionId, database: database, schema: schema) + await tableDedup.cancel(key: key) + tableStates.removeValue(forKey: key) + await loadTables(connectionId: connectionId, database: database, schema: schema) + } + + func refreshDatabase(connectionId: UUID, database: String) async { + if database == activeDatabase(for: connectionId) { + await SchemaService.shared.refresh(connectionId: connectionId) + return + } + await invalidateDatabase(connectionId: connectionId, database: database) + } + + func invalidate(connectionId: UUID) async { + await databaseListDedup.cancel(key: connectionId) + databaseListStates.removeValue(forKey: connectionId) + await invalidatePerDatabaseCaches(connectionId: connectionId) + } + + func invalidateForReconnect(connectionId: UUID) async { + await invalidatePerDatabaseCaches(connectionId: connectionId) + } + + private func invalidatePerDatabaseCaches(connectionId: UUID) async { + let dbKeys = schemaListStates.keys.filter { $0.connectionId == connectionId } + for key in dbKeys { + await schemaListDedup.cancel(key: key) + schemaListStates.removeValue(forKey: key) + } + + let tableKeys = tableStates.keys.filter { $0.connectionId == connectionId } + for key in tableKeys { + await tableDedup.cancel(key: key) + tableStates.removeValue(forKey: key) + } + + MetadataConnectionPool.shared.closeAll(connectionId: connectionId) + } + + func invalidateDatabase(connectionId: UUID, database: String) async { + let dbKey = DatabaseKey(connectionId: connectionId, database: database) + await schemaListDedup.cancel(key: dbKey) + schemaListStates.removeValue(forKey: dbKey) + + let tableKeys = tableStates.keys.filter { + $0.connectionId == connectionId && $0.database == database + } + for key in tableKeys { + await tableDedup.cancel(key: key) + tableStates.removeValue(forKey: key) + } + + MetadataConnectionPool.shared.invalidate(connectionId: connectionId, database: database) + } + + private func activeDatabase(for connectionId: UUID) -> String? { + guard let session = DatabaseManager.shared.session(for: connectionId) else { return nil } + let value = session.activeDatabase + return value.isEmpty ? nil : value + } + + private static func tableKey(connectionId: UUID, database: String, schema: String?) -> TableKey { + let normalized: String? = (schema?.isEmpty == true) ? nil : schema + return TableKey(connectionId: connectionId, database: database, schema: normalized) + } +} diff --git a/TablePro/Core/Services/Query/MetadataConnectionPool.swift b/TablePro/Core/Services/Query/MetadataConnectionPool.swift new file mode 100644 index 000000000..c8a4f3532 --- /dev/null +++ b/TablePro/Core/Services/Query/MetadataConnectionPool.swift @@ -0,0 +1,136 @@ +// +// MetadataConnectionPool.swift +// TablePro +// + +import Foundation +import os + +@MainActor +final class MetadataConnectionPool { + static let shared = MetadataConnectionPool() + + private struct Key: Hashable { + let connectionId: UUID + let database: String + } + + private final class Entry { + let driver: DatabaseDriver + var lastUsed: Date + var inFlightCount: Int + + init(driver: DatabaseDriver) { + self.driver = driver + self.lastUsed = Date() + self.inFlightCount = 0 + } + } + + private var entries: [Key: Entry] = [:] + private let maxPerConnection = 4 + private let connectTimeoutSeconds: UInt64 = 15 + private static let logger = Logger(subsystem: "com.TablePro", category: "MetadataConnectionPool") + + private init() {} + + func withDriver( + connectionId: UUID, + database: String, + _ body: @Sendable (DatabaseDriver) async throws -> T + ) async throws -> T { + let entry = try await acquireEntry(connectionId: connectionId, database: database) + entry.inFlightCount += 1 + entry.lastUsed = Date() + defer { entry.inFlightCount -= 1 } + return try await body(entry.driver) + } + + func invalidate(connectionId: UUID, database: String) { + let key = Key(connectionId: connectionId, database: database) + entries[key]?.driver.disconnect() + entries.removeValue(forKey: key) + } + + func closeAll(connectionId: UUID) { + let keys = entries.keys.filter { $0.connectionId == connectionId } + for key in keys { + entries[key]?.driver.disconnect() + entries.removeValue(forKey: key) + } + if !keys.isEmpty { + Self.logger.info( + "[metadata-pool] closed all connId=\(connectionId, privacy: .public) count=\(keys.count, privacy: .public)" + ) + } + } + + private func acquireEntry(connectionId: UUID, database: String) async throws -> Entry { + let key = Key(connectionId: connectionId, database: database) + if let entry = entries[key], entry.driver.status == .connected { + return entry + } + + guard let session = DatabaseManager.shared.session(for: connectionId) else { + throw DatabaseError.notConnected + } + + evictIfNeeded(for: connectionId) + + let baseConnection = session.effectiveConnection ?? session.connection + var cloned = baseConnection + cloned.database = database + + let driver = try await DatabaseDriverFactory.createDriver( + for: cloned, + passwordOverride: session.cachedPassword, + awaitPlugins: true + ) + try await connectWithTimeout(driver: driver, database: database) + let entry = Entry(driver: driver) + entries[key] = entry + Self.logger.info( + "[metadata-pool] opened connId=\(connectionId, privacy: .public) db=\(database, privacy: .public)" + ) + return entry + } + + private func connectWithTimeout(driver: DatabaseDriver, database: String) async throws { + let timeoutNanos = connectTimeoutSeconds * 1_000_000_000 + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await driver.connect() + } + group.addTask { + try await Task.sleep(nanoseconds: timeoutNanos) + throw NSError( + domain: "MetadataConnectionPool", + code: NSURLErrorTimedOut, + userInfo: [NSLocalizedDescriptionKey: String( + format: String(localized: "Connecting to '%@' timed out."), database + )] + ) + } + try await group.next() + group.cancelAll() + } + } + + private func evictIfNeeded(for connectionId: UUID) { + let mine = entries.filter { $0.key.connectionId == connectionId } + guard mine.count >= maxPerConnection else { return } + let idleEntries = mine.filter { $0.value.inFlightCount == 0 } + let pool = idleEntries.isEmpty ? mine : idleEntries + guard let oldest = pool.min(by: { $0.value.lastUsed < $1.value.lastUsed }) else { return } + if idleEntries.isEmpty { + Self.logger.warning( + "[metadata-pool] cap reached but all in-flight; evicting busy connId=\(connectionId, privacy: .public) db=\(oldest.key.database, privacy: .public)" + ) + } + entries[oldest.key]?.driver.disconnect() + entries.removeValue(forKey: oldest.key) + Self.logger.info( + "[metadata-pool] evicted connId=\(connectionId, privacy: .public) db=\(oldest.key.database, privacy: .public)" + ) + } +} diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index ead0ed194..1ef043f17 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -18,6 +18,15 @@ final class SchemaService { private(set) var functions: [UUID: [RoutineInfo]] = [:] private(set) var schemasInOrder: [UUID: [String]] = [:] private(set) var perSchemaStates: [UUID: [String: SchemaState]] = [:] + private(set) var generations: [UUID: Int] = [:] + + func generationToken(for connectionId: UUID) -> Int { + generations[connectionId] ?? 0 + } + + private func bumpGeneration(_ connectionId: UUID) { + generations[connectionId, default: 0] &+= 1 + } @ObservationIgnored private let loadDedup = OnceTask() @ObservationIgnored private let procedureDedup = OnceTask() @@ -107,12 +116,14 @@ final class SchemaService { var inner = perSchemaStates[connectionId] ?? [:] inner[schema] = state perSchemaStates[connectionId] = inner + bumpGeneration(connectionId) } private func clearPerSchemaState(connectionId: UUID, schema: String) { guard var inner = perSchemaStates[connectionId] else { return } inner.removeValue(forKey: schema) perSchemaStates[connectionId] = inner + bumpGeneration(connectionId) } func load(connectionId: UUID, driver: DatabaseDriver, connection: DatabaseConnection) async { @@ -134,6 +145,7 @@ final class SchemaService { try await driver.fetchProcedures(schema: nil) } procedures[connectionId] = routines + bumpGeneration(connectionId) } catch is CancellationError { return } catch { @@ -149,6 +161,7 @@ final class SchemaService { try await driver.fetchFunctions(schema: nil) } functions[connectionId] = routines + bumpGeneration(connectionId) } catch is CancellationError { return } catch { @@ -173,6 +186,7 @@ final class SchemaService { functions.removeValue(forKey: connectionId) schemasInOrder.removeValue(forKey: connectionId) perSchemaStates.removeValue(forKey: connectionId) + generations.removeValue(forKey: connectionId) } func refresh(connectionId: UUID) async { @@ -188,6 +202,7 @@ final class SchemaService { connection: DatabaseConnection ) async { states[connectionId] = .loading + bumpGeneration(connectionId) let supportsSchemas = PluginManager.shared.supportsSchemaSwitching(for: connection.type) if !supportsSchemas { @@ -227,6 +242,7 @@ final class SchemaService { states[connectionId] = .loaded(tables) procedures[connectionId] = loadedProcedures functions[connectionId] = loadedFunctions + bumpGeneration(connectionId) } catch is CancellationError { return } catch { @@ -234,6 +250,7 @@ final class SchemaService { "[schema] load failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" ) states[connectionId] = .failed(error.localizedDescription) + bumpGeneration(connectionId) } } @@ -258,6 +275,7 @@ final class SchemaService { procedures[connectionId] = loadedProcedures functions[connectionId] = loadedFunctions states[connectionId] = .loaded([]) + bumpGeneration(connectionId) } private func loadSchemaList(connectionId: UUID, driver: DatabaseDriver) async { @@ -266,6 +284,7 @@ final class SchemaService { try await driver.fetchSchemas() } schemasInOrder[connectionId] = allSchemas + bumpGeneration(connectionId) } catch is CancellationError { return } catch { diff --git a/TablePro/Models/UI/WindowSidebarState.swift b/TablePro/Models/UI/WindowSidebarState.swift index e09da4b3f..a7672ae89 100644 --- a/TablePro/Models/UI/WindowSidebarState.swift +++ b/TablePro/Models/UI/WindowSidebarState.swift @@ -7,10 +7,18 @@ import Foundation import Observation import TableProPluginKit +struct DatabaseSchemaKey: Hashable, Sendable { + let database: String + let schema: String +} + @MainActor @Observable internal final class WindowSidebarState { var selectedTables: Set = [] var searchText: String = "" var favoritesSearchText: String = "" + var expandedTreeSchemas: Set = [] + var expandedTreeDatabases: Set = [] + var expandedTreeDatabaseSchemas: Set = [] } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 6158570e6..77748f6e1 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -11298,6 +11298,9 @@ } } } + }, + "Connecting to '%@' timed out." : { + }, "Connecting to %@" : { @@ -27778,6 +27781,9 @@ } } } + }, + "Loading schemas…" : { + }, "Loading tables..." : { "extractionState" : "stale", @@ -30713,6 +30719,9 @@ }, "No databases" : { + }, + "No Databases" : { + }, "No databases found" : { "extractionState" : "stale", @@ -30925,6 +30934,9 @@ } } } + }, + "No items" : { + }, "No keys" : { "localizations" : { @@ -31603,6 +31615,9 @@ } } } + }, + "No schemas" : { + }, "No schemas found" : { "extractionState" : "stale", @@ -48446,6 +48461,9 @@ } } } + }, + "This server has no databases yet." : { + }, "This sets %lld loaded rows. Review and Save to apply." : { @@ -51189,6 +51207,12 @@ } } } + }, + "Use as Active Database" : { + + }, + "Use as Active Schema" : { + }, "Use clipboard URL" : { diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index 7719ec365..5c2e21e5e 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -2,18 +2,60 @@ // SidebarViewModel.swift // TablePro // -// ViewModel for SidebarView. -// Handles search filtering and batch operations. -// import Observation import SwiftUI import TableProPluginKit -// MARK: - SidebarViewModel - @MainActor @Observable final class SidebarViewModel { + private static var registry: [UUID: SidebarViewModel] = [:] + + static func shared( + connectionId: UUID, + databaseType: DatabaseType, + selectedTables: Binding>, + pendingTruncates: Binding>, + pendingDeletes: Binding>, + tableOperationOptions: Binding<[String: TableOperationOptions]> + ) -> SidebarViewModel { + if let existing = registry[connectionId] { + existing.updateBindings( + selectedTables: selectedTables, + pendingTruncates: pendingTruncates, + pendingDeletes: pendingDeletes, + tableOperationOptions: tableOperationOptions + ) + return existing + } + let viewModel = SidebarViewModel( + selectedTables: selectedTables, + pendingTruncates: pendingTruncates, + pendingDeletes: pendingDeletes, + tableOperationOptions: tableOperationOptions, + databaseType: databaseType, + connectionId: connectionId + ) + registry[connectionId] = viewModel + return viewModel + } + + static func removeConnection(_ connectionId: UUID) { + registry.removeValue(forKey: connectionId) + } + + func updateBindings( + selectedTables: Binding>, + pendingTruncates: Binding>, + pendingDeletes: Binding>, + tableOperationOptions: Binding<[String: TableOperationOptions]> + ) { + selectedTablesBinding = selectedTables + pendingTruncatesBinding = pendingTruncates + pendingDeletesBinding = pendingDeletes + tableOperationOptionsBinding = tableOperationOptions + } + // MARK: - Expansion State struct ExpansionState: Sendable { @@ -88,7 +130,6 @@ final class SidebarViewModel { set { tableOperationOptionsBinding.wrappedValue = newValue } } - // Maintained for backwards compatibility with call sites that read/write a single boolean. var isTablesExpanded: Bool { get { expanded[.table] } set { expanded[.table] = newValue } @@ -272,20 +313,24 @@ final class SidebarViewModel { // MARK: - Filtering @ObservationIgnored private var cachedFilteredTables: [TableInfo]? - @ObservationIgnored private var cachedFilterInputs: (count: Int, hash: Int, query: String)? + @ObservationIgnored private var cachedFilterInputs: (count: Int, generation: Int, query: String)? @ObservationIgnored private var cachedKindBuckets: [SidebarObjectKind: [TableInfo]] = [:] - @ObservationIgnored private var cachedKindFingerprint: (count: Int, hash: Int)? + @ObservationIgnored private var cachedKindFingerprint: (count: Int, generation: Int)? @ObservationIgnored private var cachedFilteredByKind: [SidebarObjectKind: [TableInfo]] = [:] - @ObservationIgnored private var cachedFilteredByKindFingerprint: (count: Int, hash: Int, query: String)? + @ObservationIgnored private var cachedFilteredByKindFingerprint: (count: Int, generation: Int, query: String)? @ObservationIgnored private var cachedFilteredRoutines: [SidebarObjectKind: [RoutineInfo]] = [:] - @ObservationIgnored private var cachedFilteredRoutinesFingerprint: (count: Int, hash: Int, query: String)? + @ObservationIgnored private var cachedFilteredRoutinesFingerprint: (count: Int, generation: Int, query: String)? + + private var schemaGeneration: Int { + SchemaService.shared.generationToken(for: connectionId) + } func filteredTables(from tables: [TableInfo]) -> [TableInfo] { let query = searchText - let fingerprint = (count: tables.count, hash: tables.hashValue, query: query) + let fingerprint = (count: tables.count, generation: schemaGeneration, query: query) if let cache = cachedFilteredTables, let inputs = cachedFilterInputs, inputs == fingerprint { @@ -304,9 +349,9 @@ final class SidebarViewModel { func tables(of kind: SidebarObjectKind, from tables: [TableInfo]) -> [TableInfo] { guard !kind.isRoutine else { return [] } - let fingerprint = (count: tables.count, hash: tables.hashValue) + let fingerprint = (count: tables.count, generation: schemaGeneration) if cachedKindFingerprint?.count != fingerprint.count - || cachedKindFingerprint?.hash != fingerprint.hash { + || cachedKindFingerprint?.generation != fingerprint.generation { rebuildKindBuckets(from: tables) cachedKindFingerprint = fingerprint } @@ -315,9 +360,9 @@ final class SidebarViewModel { func filteredTables(of kind: SidebarObjectKind, from tables: [TableInfo]) -> [TableInfo] { let query = searchText - let fingerprint = (count: tables.count, hash: tables.hashValue, query: query) + let fingerprint = (count: tables.count, generation: schemaGeneration, query: query) if cachedFilteredByKindFingerprint?.count != fingerprint.count - || cachedFilteredByKindFingerprint?.hash != fingerprint.hash + || cachedFilteredByKindFingerprint?.generation != fingerprint.generation || cachedFilteredByKindFingerprint?.query != fingerprint.query { let bucket = self.tables(of: .table, from: tables) let bucketView = self.tables(of: .view, from: tables) @@ -334,9 +379,9 @@ final class SidebarViewModel { func filteredRoutines(of kind: SidebarObjectKind, from routines: [RoutineInfo]) -> [RoutineInfo] { let query = searchText - let fingerprint = (count: routines.count, hash: routines.hashValue, query: query) + let fingerprint = (count: routines.count, generation: schemaGeneration, query: query) if cachedFilteredRoutinesFingerprint?.count != fingerprint.count - || cachedFilteredRoutinesFingerprint?.hash != fingerprint.hash + || cachedFilteredRoutinesFingerprint?.generation != fingerprint.generation || cachedFilteredRoutinesFingerprint?.query != fingerprint.query { let procs = routines.filter { $0.kind == .procedure } let funcs = routines.filter { $0.kind == .function } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 6ea2a8e13..a5d2da8a6 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -731,6 +731,11 @@ final class MainContentCoordinator { driver: driver, connection: connection ) + await DatabaseTreeMetadataService.shared.loadDatabaseList( + connectionId: connectionId, + driver: driver, + databaseType: connection.type + ) await reconcilePostSchemaLoad() } diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift new file mode 100644 index 000000000..9f6db477c --- /dev/null +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -0,0 +1,473 @@ +// +// DatabaseTreeView.swift +// TablePro +// + +import SwiftUI +import TableProPluginKit + +struct DatabaseTreeView: View { + @Bindable private var treeService = DatabaseTreeMetadataService.shared + + let connectionId: UUID + let databaseType: DatabaseType + let viewModel: SidebarViewModel + let windowState: WindowSidebarState + @Binding var pendingTruncates: Set + @Binding var pendingDeletes: Set + let coordinator: MainContentCoordinator? + + @State private var localSelection: Set = [] + + private var groupingStrategy: GroupingStrategy { + PluginManager.shared.databaseGroupingStrategy(for: databaseType) + } + + private var supportsSchemaLevel: Bool { + groupingStrategy == .bySchema + } + + private var activeDatabase: String? { + let name = coordinator?.toolbarState.currentDatabase ?? "" + return name.isEmpty ? nil : name + } + + private var activeSchema: String? { + coordinator?.toolbarState.currentSchema + } + + private var systemSchemas: Set { + Set(PluginManager.shared.systemSchemaNames(for: databaseType)) + } + + private var databases: [DatabaseMetadata] { + treeService.databases(for: connectionId) + } + + private var searchText: String { + viewModel.searchText + } + + private var selectedTablesBinding: Binding> { + Binding( + get: { localSelection }, + set: { localSelection = $0 } + ) + } + + var body: some View { + Group { + let state = treeService.databaseListState(for: connectionId) + if case .failed(let message) = state { + errorState(message: message) + } else if databases.isEmpty { + if case .loaded = state { + emptyDatabasesState + } else { + loadingState + } + } else { + treeList + } + } + .onAppear { + loadDatabasesIfNeeded() + expandActive() + } + .onChange(of: activeDatabase ?? "") { _, _ in + expandActive() + } + .onChange(of: activeSchema ?? "") { _, _ in + expandActive() + } + } + + private var treeList: some View { + List(selection: selectedTablesBinding) { + ForEach(visibleDatabases, id: \.id) { db in + DisclosureGroup(isExpanded: databaseExpansionBinding(for: db.name)) { + databaseBody(db) + } label: { + databaseHeader(db) + } + } + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + .contextMenu(forSelectionType: TableInfo.self) { _ in + EmptyView() + } primaryAction: { selection in + guard let table = selection.first, + let origin = locateTable(table) else { return } + openTable(table, in: origin.database, schema: origin.schema) + } + .onExitCommand { + localSelection.removeAll() + } + } + + private func locateTable(_ target: TableInfo) -> (database: String, schema: String?)? { + for db in visibleDatabases { + if supportsSchemaLevel { + let schemaState = treeService.schemaListState(connectionId: connectionId, database: db.name) + if case .loaded(let schemas) = schemaState { + for schema in schemas { + let candidates = tables(database: db.name, schema: schema) + if candidates.contains(where: { $0.id == target.id }) { + return (db.name, schema) + } + } + } + } else { + let candidates = tables(database: db.name, schema: nil) + if candidates.contains(where: { $0.id == target.id }) { + return (db.name, nil) + } + } + } + return nil + } + + @ViewBuilder + private func databaseBody(_ db: DatabaseMetadata) -> some View { + if supportsSchemaLevel { + schemasContent(for: db.name) + } else { + tablesContent(database: db.name, schema: nil) + } + } + + private func databaseHeader(_ db: DatabaseMetadata) -> some View { + let isActive = db.name == activeDatabase + return Label { + Text(db.name) + .fontWeight(isActive ? .bold : .regular) + .foregroundStyle(rowForeground(isActive: isActive, isSystem: db.isSystemDatabase)) + } icon: { + Image(systemName: db.isSystemDatabase ? "gearshape" : "cylinder") + .foregroundStyle(isActive ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) + } + .contextMenu { + Button(String(localized: "Use as Active Database")) { + setActiveDatabase(db.name) + } + .disabled(isActive) + Button(String(localized: "Refresh")) { + refreshDatabase(db.name) + } + } + } + + private func schemaHeader(database: String, schema: String) -> some View { + let isActive = (database == activeDatabase) && (schema == activeSchema) + let isSystem = systemSchemas.contains(schema) + return Label { + Text(schema) + .fontWeight(isActive ? .bold : .regular) + .foregroundStyle(rowForeground(isActive: isActive, isSystem: isSystem)) + } icon: { + Image(systemName: "folder") + .foregroundStyle(isActive ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) + } + .contextMenu { + Button(String(localized: "Use as Active Schema")) { + setActiveSchema(database: database, schema: schema) + } + .disabled(isActive) + Button(String(localized: "Refresh")) { + refreshSchema(database: database, schema: schema) + } + } + } + + private func rowForeground(isActive: Bool, isSystem: Bool) -> AnyShapeStyle { + if isActive { return AnyShapeStyle(.tint) } + if isSystem { return AnyShapeStyle(.secondary) } + return AnyShapeStyle(.primary) + } + + @ViewBuilder + private func schemasContent(for database: String) -> some View { + let state = treeService.schemaListState(connectionId: connectionId, database: database) + switch state { + case .idle, .loading: + loadingRow(String(localized: "Loading schemas\u{2026}")) + .task(id: database) { + await treeService.loadSchemaList(connectionId: connectionId, database: database) + } + case .failed(let message): + errorRow(message) + case .loaded(let schemas): + let visible = visibleSchemas(database: database, all: schemas) + if visible.isEmpty { + emptyRow(String(localized: "No schemas")) + } else { + ForEach(visible, id: \.self) { schema in + DisclosureGroup( + isExpanded: schemaExpansionBinding(database: database, schema: schema) + ) { + tablesContent(database: database, schema: schema) + } label: { + schemaHeader(database: database, schema: schema) + } + } + } + } + } + + @ViewBuilder + private func tablesContent(database: String, schema: String?) -> some View { + switch treeService.tableState( + connectionId: connectionId, database: database, schema: schema + ) { + case .idle, .loading: + loadingRow(String(localized: "Loading tables\u{2026}")) + .task(id: "\(database)|\(schema ?? "")") { + await treeService.loadTables( + connectionId: connectionId, database: database, schema: schema + ) + } + case .failed(let message): + errorRow(message) + case .loaded: + let tables = filteredTables(database: database, schema: schema) + let routines = filteredRoutines(database: database, schema: schema) + if tables.isEmpty && routines.isEmpty { + emptyRow(String(localized: "No items")) + } else { + ForEach(tables) { table in + TableRow( + table: table, + isPendingTruncate: pendingTruncates.contains(table.name), + isPendingDelete: pendingDeletes.contains(table.name) + ) + .tag(table) + } + ForEach(routines) { routine in + RoutineRowView(routine: routine) + .tag(routine) + } + } + } + } + + private var loadingState: some View { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func errorState(message: String) -> some View { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.title) + .foregroundStyle(.orange) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + + private var emptyDatabasesState: some View { + ContentUnavailableView( + String(localized: "No Databases"), + systemImage: "cylinder", + description: Text("This server has no databases yet.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func loadingRow(_ text: String) -> some View { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text(text) + .font(.callout) + .foregroundStyle(.secondary) + } + } + + private func errorRow(_ message: String) -> some View { + Label(message, systemImage: "exclamationmark.triangle") + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + private func emptyRow(_ text: String) -> some View { + Text(text) + .font(.callout) + .foregroundStyle(.secondary) + } + + private func tables(database: String, schema: String?) -> [TableInfo] { + treeService.tables(connectionId: connectionId, database: database, schema: schema) + } + + private func routines(database: String, schema: String?) -> [RoutineInfo] { + treeService.routines(connectionId: connectionId, database: database, schema: schema) + } + + private var visibleDatabases: [DatabaseMetadata] { + let nonSystem = databases.filter { !$0.isSystemDatabase } + guard !searchText.isEmpty else { return nonSystem } + return nonSystem.filter { databaseMatchesSearch($0) } + } + + private func databaseMatchesSearch(_ db: DatabaseMetadata) -> Bool { + if db.name.localizedCaseInsensitiveContains(searchText) { return true } + let schemas = treeService.schemaListState(connectionId: connectionId, database: db.name) + if case .loaded(let list) = schemas { + if list.contains(where: { $0.localizedCaseInsensitiveContains(searchText) }) { return true } + for schema in list where schemaContentMatchesSearch(database: db.name, schema: schema) { + return true + } + } + if schemaContentMatchesSearch(database: db.name, schema: nil) { return true } + return false + } + + private func schemaContentMatchesSearch(database: String, schema: String?) -> Bool { + if let schema, schema.localizedCaseInsensitiveContains(searchText) { return true } + let tableMatches = tables(database: database, schema: schema) + .contains { $0.name.localizedCaseInsensitiveContains(searchText) } + if tableMatches { return true } + let routineMatches = routines(database: database, schema: schema) + .contains { $0.name.localizedCaseInsensitiveContains(searchText) } + return routineMatches + } + + private func visibleSchemas(database: String, all: [String]) -> [String] { + let filtered = all.filter { !systemSchemas.contains($0) } + guard !searchText.isEmpty else { return filtered } + return filtered.filter { schema in + schema.localizedCaseInsensitiveContains(searchText) + || schemaContentMatchesSearch(database: database, schema: schema) + } + } + + private func filteredTables(database: String, schema: String?) -> [TableInfo] { + let all = tables(database: database, schema: schema) + guard !searchText.isEmpty else { return all } + return all.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + } + + private func filteredRoutines(database: String, schema: String?) -> [RoutineInfo] { + let all = routines(database: database, schema: schema) + guard !searchText.isEmpty else { return all } + return all.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + } + + private func databaseExpansionBinding(for database: String) -> Binding { + Binding( + get: { !searchText.isEmpty || windowState.expandedTreeDatabases.contains(database) }, + set: { isExpanded in + if isExpanded { + windowState.expandedTreeDatabases.insert(database) + loadDatabaseContentIfNeeded(database) + } else { + windowState.expandedTreeDatabases.remove(database) + } + } + ) + } + + private func schemaExpansionBinding(database: String, schema: String) -> Binding { + let key = DatabaseSchemaKey(database: database, schema: schema) + return Binding( + get: { !searchText.isEmpty || windowState.expandedTreeDatabaseSchemas.contains(key) }, + set: { isExpanded in + if isExpanded { + windowState.expandedTreeDatabaseSchemas.insert(key) + loadTablesIfNeeded(database: database, schema: schema) + } else { + windowState.expandedTreeDatabaseSchemas.remove(key) + } + } + ) + } + + private func loadDatabasesIfNeeded() { + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } + Task { + await treeService.loadDatabaseList( + connectionId: connectionId, + driver: driver, + databaseType: databaseType + ) + } + } + + private func loadDatabaseContentIfNeeded(_ database: String) { + if supportsSchemaLevel { + Task { await treeService.loadSchemaList(connectionId: connectionId, database: database) } + } else { + loadTablesIfNeeded(database: database, schema: nil) + } + } + + private func loadTablesIfNeeded(database: String, schema: String?) { + Task { + await treeService.loadTables(connectionId: connectionId, database: database, schema: schema) + } + } + + private func refreshDatabase(_ database: String) { + Task { + await treeService.refreshDatabase(connectionId: connectionId, database: database) + loadDatabaseContentIfNeeded(database) + } + } + + private func refreshSchema(database: String, schema: String) { + Task { + await treeService.reloadTables( + connectionId: connectionId, database: database, schema: schema + ) + } + } + + private func expandActive() { + guard let active = activeDatabase else { return } + windowState.expandedTreeDatabases.insert(active) + if let schema = activeSchema { + windowState.expandedTreeDatabaseSchemas.insert( + DatabaseSchemaKey(database: active, schema: schema) + ) + } + } + + private func setActiveDatabase(_ database: String) { + guard database != activeDatabase else { return } + Task { @MainActor in + await coordinator?.switchDatabase(to: database) + } + } + + private func setActiveSchema(database: String, schema: String) { + Task { @MainActor in + if database != activeDatabase { + await coordinator?.switchDatabase(to: database) + } + if schema != coordinator?.toolbarState.currentSchema { + await coordinator?.switchSchema(to: schema) + } + } + } + + private func openTable(_ table: TableInfo, in database: String, schema: String?) { + Task { @MainActor in + if database != activeDatabase { + await coordinator?.switchDatabase(to: database) + } + if let schema, + schema != coordinator?.toolbarState.currentSchema, + PluginManager.shared.supportsSchemaSwitching(for: databaseType) { + await coordinator?.switchSchema(to: schema) + } + coordinator?.openTableTab(table) + } + } +} diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 30e9fc5ba..c1f5f02f4 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -175,89 +175,16 @@ internal struct FavoritesTabView: View { } } - private func nodeRows(_ items: [FavoriteNode]) -> AnyView { - AnyView(ForEach(items) { node in - switch node.content { - case .favorite(let favorite): - FavoriteRowView(favorite: favorite) - .tag(node.id) - case .folder(let folder): - DisclosureGroup(isExpanded: Binding( - get: { FavoritesExpansionState.shared.isFolderExpanded(folder.id, for: connectionId) }, - set: { expanded in - FavoritesExpansionState.shared.setFolderExpanded(folder.id, expanded: expanded, for: connectionId) - } - )) { - if let children = node.children { - nodeRows(children) - } - } label: { - folderLabel(folder) - } - .tag(node.id) - case .linkedFolder(let linkedFolder): - DisclosureGroup(isExpanded: linkedSubtreeBinding(node.id)) { - if let children = node.children { - nodeRows(children) - } - } label: { - LinkedFolderRowLabel(folder: linkedFolder) - } - .tag(node.id) - case .linkedSubfolder(_, let displayName, _): - DisclosureGroup(isExpanded: linkedSubtreeBinding(node.id)) { - if let children = node.children { - nodeRows(children) - } - } label: { - LinkedSubfolderRowLabel(displayName: displayName) - } - .tag(node.id) - case .linkedFavorite(let linked): - LinkedFavoriteRowView(favorite: linked) - .tag(node.id) - } - }) - } - - private func linkedSubtreeBinding(_ nodeId: String) -> Binding { - Binding( - get: { FavoritesExpansionState.shared.isLinkedNodeExpanded(nodeId, for: connectionId) }, - set: { expanded in - FavoritesExpansionState.shared.setLinkedNodeExpanded(nodeId, expanded: expanded, for: connectionId) - } + @ViewBuilder + private func nodeRows(_ items: [FavoriteNode]) -> some View { + FavoriteNodeRowsView( + items: items, + connectionId: connectionId, + viewModel: viewModel, + renameFocus: $isRenameFocused ) } - @ViewBuilder - private func folderLabel(_ folder: SQLFavoriteFolder) -> some View { - if viewModel.renamingFolderId == folder.id { - HStack(spacing: 4) { - Image(systemName: "folder") - TextField( - "", - text: Binding( - get: { viewModel.renamingFolderName }, - set: { viewModel.renamingFolderName = $0 } - ) - ) - .textFieldStyle(.roundedBorder) - .accessibilityLabel(String(localized: "Folder name")) - .focused($isRenameFocused) - .onSubmit { - viewModel.commitRenameFolder(folder) - } - .onExitCommand { - viewModel.renamingFolderId = nil - } - .onAppear { - isRenameFocused = true - } - } - } else { - Label(folder.name, systemImage: "folder") - } - } private func deleteSelectedNode() { guard let nodeId = sidebarState.selectedFavoriteNodeId else { return } @@ -501,3 +428,118 @@ internal struct FavoritesTabView: View { } } } + +private struct FavoriteNodeRowsView: View { + let items: [FavoriteNode] + let connectionId: UUID + let viewModel: FavoritesSidebarViewModel + let renameFocus: FocusState.Binding + + var body: some View { + ForEach(items) { node in + content(for: node) + } + } + + @ViewBuilder + private func content(for node: FavoriteNode) -> some View { + switch node.content { + case .favorite(let favorite): + FavoriteRowView(favorite: favorite) + .tag(node.id) + case .folder(let folder): + DisclosureGroup(isExpanded: folderExpansionBinding(folder)) { + if let children = node.children { + FavoriteNodeRowsView( + items: children, + connectionId: connectionId, + viewModel: viewModel, + renameFocus: renameFocus + ) + } + } label: { + folderLabel(folder) + } + .tag(node.id) + case .linkedFolder(let linkedFolder): + DisclosureGroup(isExpanded: linkedSubtreeBinding(node.id)) { + if let children = node.children { + FavoriteNodeRowsView( + items: children, + connectionId: connectionId, + viewModel: viewModel, + renameFocus: renameFocus + ) + } + } label: { + LinkedFolderRowLabel(folder: linkedFolder) + } + .tag(node.id) + case .linkedSubfolder(_, let displayName, _): + DisclosureGroup(isExpanded: linkedSubtreeBinding(node.id)) { + if let children = node.children { + FavoriteNodeRowsView( + items: children, + connectionId: connectionId, + viewModel: viewModel, + renameFocus: renameFocus + ) + } + } label: { + LinkedSubfolderRowLabel(displayName: displayName) + } + .tag(node.id) + case .linkedFavorite(let linked): + LinkedFavoriteRowView(favorite: linked) + .tag(node.id) + } + } + + private func folderExpansionBinding(_ folder: SQLFavoriteFolder) -> Binding { + Binding( + get: { FavoritesExpansionState.shared.isFolderExpanded(folder.id, for: connectionId) }, + set: { expanded in + FavoritesExpansionState.shared.setFolderExpanded(folder.id, expanded: expanded, for: connectionId) + } + ) + } + + private func linkedSubtreeBinding(_ nodeId: String) -> Binding { + Binding( + get: { FavoritesExpansionState.shared.isLinkedNodeExpanded(nodeId, for: connectionId) }, + set: { expanded in + FavoritesExpansionState.shared.setLinkedNodeExpanded(nodeId, expanded: expanded, for: connectionId) + } + ) + } + + @ViewBuilder + private func folderLabel(_ folder: SQLFavoriteFolder) -> some View { + if viewModel.renamingFolderId == folder.id { + HStack(spacing: 4) { + Image(systemName: "folder") + TextField( + "", + text: Binding( + get: { viewModel.renamingFolderName }, + set: { viewModel.renamingFolderName = $0 } + ) + ) + .textFieldStyle(.roundedBorder) + .accessibilityLabel(String(localized: "Folder name")) + .focused(renameFocus) + .onSubmit { + viewModel.commitRenameFolder(folder) + } + .onExitCommand { + viewModel.renamingFolderId = nil + } + .onAppear { + renameFocus.wrappedValue = true + } + } + } else { + Label(folder.name, systemImage: "folder") + } + } +} diff --git a/TablePro/Views/Sidebar/RedisKeyTreeView.swift b/TablePro/Views/Sidebar/RedisKeyTreeView.swift index ae7487813..4c9be9418 100644 --- a/TablePro/Views/Sidebar/RedisKeyTreeView.swift +++ b/TablePro/Views/Sidebar/RedisKeyTreeView.swift @@ -17,13 +17,13 @@ internal struct RedisKeyTreeView: View { HStack(spacing: 6) { ProgressView() .controlSize(.small) - Text("Loading keys\u{2026}") + Text(String(localized: "Loading keys\u{2026}")) .foregroundStyle(.secondary) .font(.caption) } .padding(.vertical, 4) } else if nodes.isEmpty { - Text("No keys") + Text(String(localized: "No keys")) .foregroundStyle(.secondary) .font(.caption) .padding(.vertical, 4) @@ -32,7 +32,7 @@ internal struct RedisKeyTreeView: View { row(for: node) } if isTruncated { - Text("Showing first 50,000 keys") + Text(String(localized: "Showing first 50,000 keys")) .foregroundStyle(.secondary) .font(.caption2) .padding(.vertical, 2) diff --git a/TablePro/Views/Sidebar/RoutineRowView.swift b/TablePro/Views/Sidebar/RoutineRowView.swift index 9f342166d..4ed41605d 100644 --- a/TablePro/Views/Sidebar/RoutineRowView.swift +++ b/TablePro/Views/Sidebar/RoutineRowView.swift @@ -43,7 +43,7 @@ struct RoutineRowView: View { var body: some View { Label { Text(routine.name) - .font(.system(.callout, design: .monospaced)) + .font(.callout) .lineLimit(1) .truncationMode(.tail) .sidebarTint(.primary) diff --git a/TablePro/Views/Sidebar/SchemaPickerFooter.swift b/TablePro/Views/Sidebar/SchemaPickerFooter.swift index dd23e094b..78608386a 100644 --- a/TablePro/Views/Sidebar/SchemaPickerFooter.swift +++ b/TablePro/Views/Sidebar/SchemaPickerFooter.swift @@ -8,12 +8,11 @@ struct SchemaPickerFooter: View { let databaseType: DatabaseType @Bindable private var schemaService = SchemaService.shared + @Bindable private var databaseManager = DatabaseManager.shared @State private var showSystemSchemas = false - @State private var schemaVersion = 0 private var currentSchema: String? { - _ = schemaVersion - return DatabaseManager.shared.session(for: connectionId)?.currentSchema + databaseManager.session(for: connectionId)?.currentSchema } private var allSchemas: [String] { @@ -47,11 +46,6 @@ struct SchemaPickerFooter: View { ) .padding(8) } - .onReceive(AppEvents.shared.currentSchemaChanged) { changedId in - if changedId == connectionId { - schemaVersion &+= 1 - } - } } } diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index ccd6ab942..c1cd966d3 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -2,8 +2,6 @@ // SidebarContextMenu.swift // TablePro // -// Context menu for sidebar table rows and empty space. -// import SwiftUI import TableProPluginKit @@ -17,7 +15,6 @@ enum SidebarContextMenuLogic { clickedTable?.type == .view } - /// True when the object cannot be modified via DML (INSERT/UPDATE/DELETE). static func isReadOnlyKind(_ type: TableInfo.TableType?) -> Bool { switch type { case .view, .materializedView, .foreignTable, .systemTable: @@ -47,7 +44,6 @@ enum SidebarContextMenuLogic { } } -/// Unified context menu for sidebar — used for both table rows and empty space struct SidebarContextMenu: View { let clickedTable: TableInfo? let selectedTables: Set @@ -72,12 +68,12 @@ struct SidebarContextMenu: View { } var body: some View { - Button("Create New Table...") { + Button(String(localized: "Create New Table...")) { coordinator?.createNewTable() } .disabled(isReadOnly) - Button("Create New View...") { + Button(String(localized: "Create New View...")) { coordinator?.createView() } .disabled(isReadOnly) @@ -85,7 +81,7 @@ struct SidebarContextMenu: View { Divider() if isView { - Button("Edit View Definition") { + Button(String(localized: "Edit View Definition")) { if let viewName = clickedTable?.name { coordinator?.editViewDefinition(viewName) } @@ -93,7 +89,7 @@ struct SidebarContextMenu: View { .disabled(isReadOnly) } - Button("Show Structure") { + Button(String(localized: "Show Structure")) { if let clickedTable { coordinator?.openTableTab(clickedTable, showStructure: true) } @@ -104,12 +100,12 @@ struct SidebarContextMenu: View { coordinator?.showERDiagram() } - Button("Copy Name") { + Button(String(localized: "Copy Name")) { ClipboardService.shared.writeText(effectiveTableNames.joined(separator: ",")) } .disabled(!hasSelection) - Button("Export...") { + Button(String(localized: "Export...")) { coordinator?.openExportDialog(preselectedTableNames: Set(effectiveTableNames)) } .disabled(!hasSelection) @@ -120,7 +116,7 @@ struct SidebarContextMenu: View { for: coordinator?.connection.type ?? .mysql ) ) { - Button("Import...") { + Button(String(localized: "Import...")) { coordinator?.openImportDialog() } .disabled(isReadOnly) @@ -142,7 +138,7 @@ struct SidebarContextMenu: View { Divider() if SidebarContextMenuLogic.truncateVisible(clickedTable: clickedTable) { - Button("Truncate") { + Button(String(localized: "Truncate")) { onBatchToggleTruncate(effectiveTableNames) } .disabled(!hasSelection || isReadOnly) diff --git a/TablePro/Views/Sidebar/SidebarTreeView.swift b/TablePro/Views/Sidebar/SidebarTreeView.swift index 9cb90a4d1..97dc439bb 100644 --- a/TablePro/Views/Sidebar/SidebarTreeView.swift +++ b/TablePro/Views/Sidebar/SidebarTreeView.swift @@ -12,7 +12,8 @@ struct SidebarTreeView: View { var onDoubleClick: ((TableInfo) -> Void)? weak var coordinator: MainContentCoordinator? - @State private var expandedSchemas: Set = [] + @State private var searchLoadTask: Task? + private var systemSchemas: Set { Set(PluginManager.shared.systemSchemaNames(for: viewModel.databaseType)) @@ -49,10 +50,7 @@ struct SidebarTreeView: View { } } .onChange(of: searchText) { _, newValue in - guard !newValue.isEmpty else { return } - for schema in schemas { - loadTables(for: schema) - } + scheduleSearchLoad(searchText: newValue) } } @@ -86,7 +84,7 @@ struct SidebarTreeView: View { HStack(spacing: 6) { ProgressView() .controlSize(.small) - Text("Loading tables\u{2026}") + Text(String(localized: "Loading tables\u{2026}")) .font(.caption) .foregroundStyle(.secondary) } @@ -100,7 +98,7 @@ struct SidebarTreeView: View { case .loaded: let tables = tablesToShow(for: schema) if tables.isEmpty { - Text("No tables") + Text(String(localized: "No tables")) .font(.caption) .foregroundStyle(.secondary) .padding(.vertical, 4) @@ -144,7 +142,7 @@ struct SidebarTreeView: View { ContentUnavailableView( String(localized: "No Datasets"), systemImage: "tablecells", - description: Text("This project has no datasets yet.") + description: Text(String(localized: "This project has no datasets yet.")) ) .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -156,13 +154,13 @@ struct SidebarTreeView: View { private func expansionBinding(for schema: String) -> Binding { Binding( - get: { !searchText.isEmpty || expandedSchemas.contains(schema) }, + get: { !searchText.isEmpty || windowState.expandedTreeSchemas.contains(schema) }, set: { isExpanded in if isExpanded { - expandedSchemas.insert(schema) + windowState.expandedTreeSchemas.insert(schema) loadTables(for: schema) } else { - expandedSchemas.remove(schema) + windowState.expandedTreeSchemas.remove(schema) } } ) @@ -193,6 +191,22 @@ struct SidebarTreeView: View { } } + private func scheduleSearchLoad(searchText: String) { + searchLoadTask?.cancel() + guard !searchText.isEmpty else { return } + let schemasSnapshot = schemas + searchLoadTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + for schema in schemasSnapshot { + if case .loaded = schemaService.schemaState(for: connectionId, schema: schema) { + continue + } + loadTables(for: schema) + } + } + } + private func reloadTables(for schema: String) { guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } Task { diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index cc072cdbf..04465c1a0 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -10,7 +10,7 @@ import TableProPluginKit struct SidebarView: View { @State private var viewModel: SidebarViewModel - @Bindable private var schemaService = SchemaService.shared + private var schemaService: SchemaService { SchemaService.shared } var sidebarState: SharedSidebarState var windowState: WindowSidebarState @@ -45,7 +45,7 @@ struct SidebarView: View { private var supportsSchemaFooter: Bool { guard PluginManager.shared.supportsSchemaSwitching(for: viewModel.databaseType) else { return false } - return groupingStrategy != .hierarchicalSchema + return groupingStrategy != .hierarchicalSchema && !usesDatabaseTree } private var selectedTablesBinding: Binding> { @@ -75,13 +75,13 @@ struct SidebarView: View { get: { windowState.selectedTables }, set: { windowState.selectedTables = $0 } ) - let vm = SidebarViewModel( + let vm = SidebarViewModel.shared( + connectionId: connectionId, + databaseType: databaseType, selectedTables: selectedBinding, pendingTruncates: pendingTruncates, pendingDeletes: pendingDeletes, - tableOperationOptions: tableOperationOptions, - databaseType: databaseType, - connectionId: connectionId + tableOperationOptions: tableOperationOptions ) vm.searchText = windowState.searchText if databaseType == .redis, let existingVM = sidebarState.redisKeyTreeViewModel { @@ -151,11 +151,32 @@ struct SidebarView: View { private var tablesContent: some View { if groupingStrategy == .hierarchicalSchema { hierarchicalContent + } else if usesDatabaseTree { + databaseTreeContent } else { flatContent } } + private var usesDatabaseTree: Bool { + PluginManager.shared.connectionMode(for: viewModel.databaseType) == .network + && PluginManager.shared.supportsDatabaseSwitching(for: viewModel.databaseType) + && (groupingStrategy == .byDatabase || groupingStrategy == .bySchema) + } + + @ViewBuilder + private var databaseTreeContent: some View { + DatabaseTreeView( + connectionId: connectionId, + databaseType: viewModel.databaseType, + viewModel: viewModel, + windowState: windowState, + pendingTruncates: $pendingTruncates, + pendingDeletes: $pendingDeletes, + coordinator: coordinator + ) + } + @ViewBuilder private var hierarchicalContent: some View { switch schemaService.state(for: connectionId) { @@ -252,7 +273,7 @@ struct SidebarView: View { } ) } header: { - Text("Keys") + Text(String(localized: "Keys")) } } } diff --git a/TablePro/Views/Sidebar/TableRowView.swift b/TablePro/Views/Sidebar/TableRowView.swift index 6c23ef681..69dea9044 100644 --- a/TablePro/Views/Sidebar/TableRowView.swift +++ b/TablePro/Views/Sidebar/TableRowView.swift @@ -69,30 +69,32 @@ struct TableRow: View { TableRowLogic.textColor(isPendingDelete: isPendingDelete, isPendingTruncate: isPendingTruncate) } + @ViewBuilder + private var pendingStateBadge: some View { + if isPendingDelete { + Image(systemName: "minus.circle.fill") + .font(.caption) + .sidebarTint(.red) + } else if isPendingTruncate { + Image(systemName: "exclamationmark.circle.fill") + .font(.caption) + .sidebarTint(.orange) + } + } + var body: some View { Label { Text(table.name) - .font(.system(.callout, design: .monospaced)) + .font(.callout) .lineLimit(1) .sidebarTint(textColor) } icon: { - ZStack(alignment: .bottomTrailing) { - Image(systemName: TableRowLogic.iconName(for: table.type)) - .sidebarTint(iconColor) - .frame(width: 14) - - if isPendingDelete { - Image(systemName: "minus.circle.fill") - .font(.caption) - .sidebarTint(.red) - .offset(x: 4, y: 4) - } else if isPendingTruncate { - Image(systemName: "exclamationmark.circle.fill") - .font(.caption) - .sidebarTint(.orange) - .offset(x: 4, y: 4) + Image(systemName: TableRowLogic.iconName(for: table.type)) + .sidebarTint(iconColor) + .frame(width: 14) + .overlay(alignment: .bottomTrailing) { + pendingStateBadge } - } } .padding(.vertical, 4) .accessibilityElement(children: .combine) From cb0c4425cb39dd2206ec833c81c83a2c0264fa16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 11:00:04 +0700 Subject: [PATCH 02/12] fix(sidebar): metadata pool lifecycle, reconnect teardown, and observation cancellation (#139) --- .../Database/DatabaseManager+Health.swift | 1 + .../SidebarContainerViewController.swift | 48 +++++++++--- .../Query/MetadataConnectionPool.swift | 74 ++++++++++++++----- 3 files changed, 93 insertions(+), 30 deletions(-) diff --git a/TablePro/Core/Database/DatabaseManager+Health.swift b/TablePro/Core/Database/DatabaseManager+Health.swift index 18136f1d9..d019aa56e 100644 --- a/TablePro/Core/Database/DatabaseManager+Health.swift +++ b/TablePro/Core/Database/DatabaseManager+Health.swift @@ -53,6 +53,7 @@ extension DatabaseManager { guard let self else { return false } guard let session = await self.activeSessions[connectionId] else { return false } await SchemaService.shared.invalidate(connectionId: connectionId) + await DatabaseTreeMetadataService.shared.invalidateForReconnect(connectionId: connectionId) do { let result = try await self.trackOperation(sessionId: connectionId) { try await self.reconnectDriver(for: session) diff --git a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift index 76d058b5c..82c810f88 100644 --- a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift +++ b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift @@ -76,17 +76,20 @@ internal final class SidebarContainerViewController: NSViewController { } private static func awaitChange(state: SharedSidebarState, windowState: WindowSidebarState) async { - await withCheckedContinuation { continuation in - var resumed = false - withObservationTracking { - _ = state.selectedSidebarTab - _ = windowState.searchText - _ = windowState.favoritesSearchText - } onChange: { - guard !resumed else { return } - resumed = true - continuation.resume() + let box = ObservationContinuationBox() + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + box.attach(continuation) + withObservationTracking { + _ = state.selectedSidebarTab + _ = windowState.searchText + _ = windowState.favoritesSearchText + } onChange: { + box.resume() + } } + } onCancel: { + box.resume() } } @@ -133,3 +136,28 @@ extension SidebarContainerViewController: NSSearchFieldDelegate { } } } + +private final class ObservationContinuationBox: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var resumed = false + + func attach(_ continuation: CheckedContinuation) { + lock.lock() + defer { lock.unlock() } + guard !resumed else { + continuation.resume() + return + } + self.continuation = continuation + } + + func resume() { + lock.lock() + defer { lock.unlock() } + guard !resumed else { return } + resumed = true + continuation?.resume() + continuation = nil + } +} diff --git a/TablePro/Core/Services/Query/MetadataConnectionPool.swift b/TablePro/Core/Services/Query/MetadataConnectionPool.swift index c8a4f3532..5eb213dd1 100644 --- a/TablePro/Core/Services/Query/MetadataConnectionPool.swift +++ b/TablePro/Core/Services/Query/MetadataConnectionPool.swift @@ -10,7 +10,7 @@ import os final class MetadataConnectionPool { static let shared = MetadataConnectionPool() - private struct Key: Hashable { + private struct Key: Hashable, Sendable { let connectionId: UUID let database: String } @@ -28,6 +28,7 @@ final class MetadataConnectionPool { } private var entries: [Key: Entry] = [:] + private var pending: [Key: Task] = [:] private let maxPerConnection = 4 private let connectTimeoutSeconds: UInt64 = 15 private static let logger = Logger(subsystem: "com.TablePro", category: "MetadataConnectionPool") @@ -48,11 +49,15 @@ final class MetadataConnectionPool { func invalidate(connectionId: UUID, database: String) { let key = Key(connectionId: connectionId, database: database) + pending[key]?.cancel() entries[key]?.driver.disconnect() entries.removeValue(forKey: key) } func closeAll(connectionId: UUID) { + for key in pending.keys where key.connectionId == connectionId { + pending[key]?.cancel() + } let keys = entries.keys.filter { $0.connectionId == connectionId } for key in keys { entries[key]?.driver.disconnect() @@ -71,28 +76,62 @@ final class MetadataConnectionPool { return entry } - guard let session = DatabaseManager.shared.session(for: connectionId) else { + if let inFlight = pending[key] { + try await inFlight.value + guard let entry = entries[key] else { throw DatabaseError.notConnected } + return entry + } + + guard DatabaseManager.shared.session(for: connectionId) != nil else { throw DatabaseError.notConnected } - evictIfNeeded(for: connectionId) + evictIdleIfNeeded(for: connectionId) + let task = Task { [self] in + let entry = try await openEntry(key: key) + if Task.isCancelled { + entry.driver.disconnect() + return + } + entries[key] = entry + } + pending[key] = task + do { + try await task.value + } catch { + pending.removeValue(forKey: key) + throw error + } + pending.removeValue(forKey: key) + + guard let entry = entries[key] else { throw DatabaseError.notConnected } + return entry + } + + private func openEntry(key: Key) async throws -> Entry { + guard let session = DatabaseManager.shared.session(for: key.connectionId) else { + throw DatabaseError.notConnected + } let baseConnection = session.effectiveConnection ?? session.connection var cloned = baseConnection - cloned.database = database + cloned.database = key.database let driver = try await DatabaseDriverFactory.createDriver( for: cloned, passwordOverride: session.cachedPassword, awaitPlugins: true ) - try await connectWithTimeout(driver: driver, database: database) - let entry = Entry(driver: driver) - entries[key] = entry + do { + try await connectWithTimeout(driver: driver, database: key.database) + } catch { + driver.disconnect() + throw error + } Self.logger.info( - "[metadata-pool] opened connId=\(connectionId, privacy: .public) db=\(database, privacy: .public)" + "[metadata-pool] opened connId=\(key.connectionId, privacy: .public) db=\(key.database, privacy: .public)" ) - return entry + return Entry(driver: driver) } private func connectWithTimeout(driver: DatabaseDriver, database: String) async throws { @@ -116,17 +155,12 @@ final class MetadataConnectionPool { } } - private func evictIfNeeded(for connectionId: UUID) { - let mine = entries.filter { $0.key.connectionId == connectionId } - guard mine.count >= maxPerConnection else { return } - let idleEntries = mine.filter { $0.value.inFlightCount == 0 } - let pool = idleEntries.isEmpty ? mine : idleEntries - guard let oldest = pool.min(by: { $0.value.lastUsed < $1.value.lastUsed }) else { return } - if idleEntries.isEmpty { - Self.logger.warning( - "[metadata-pool] cap reached but all in-flight; evicting busy connId=\(connectionId, privacy: .public) db=\(oldest.key.database, privacy: .public)" - ) - } + private func evictIdleIfNeeded(for connectionId: UUID) { + let live = entries.filter { $0.key.connectionId == connectionId } + let pendingCount = pending.keys.filter { $0.connectionId == connectionId }.count + guard live.count + pendingCount >= maxPerConnection else { return } + let idle = live.filter { $0.value.inFlightCount == 0 } + guard let oldest = idle.min(by: { $0.value.lastUsed < $1.value.lastUsed }) else { return } entries[oldest.key]?.driver.disconnect() entries.removeValue(forKey: oldest.key) Self.logger.info( From e7f293cc532e6de136cbb7b684c0934db132ff25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 11:00:12 +0700 Subject: [PATCH 03/12] feat(sidebar): tree/flat layout option with native styling and large-list perf (#139) --- CHANGELOG.md | 4 +- TablePro/Models/UI/SharedSidebarState.swift | 36 ++++++++++ TablePro/Resources/Localizable.xcstrings | 18 +++++ TablePro/TableProApp.swift | 18 +++++ .../Main/MainContentCommandActions.swift | 17 +++++ .../Views/Settings/GeneralSettingsView.swift | 9 +++ TablePro/Views/Sidebar/DatabaseTreeView.swift | 34 +++++++-- TablePro/Views/Sidebar/RoutineRowView.swift | 14 +--- .../Views/Sidebar/SidebarContextMenu.swift | 72 ++++++++++--------- .../Views/Sidebar/SidebarPersistenceKey.swift | 6 ++ TablePro/Views/Sidebar/SidebarView.swift | 28 ++++---- TablePro/Views/Sidebar/TableRowView.swift | 37 ++-------- 12 files changed, 194 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bee610be..e95e93697 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed +### Added -- The sidebar lists every database on the server as a tree; right-click a database or schema to set it as active. Applies to MySQL, MariaDB, PostgreSQL, MSSQL, ClickHouse, Redshift; SQLite, Redis, MongoDB, BigQuery keep their existing sidebar. (#139) +- The sidebar can show every database on the server as an expandable tree. Switch a connection between the flat list and the tree from the View menu (Sidebar Layout); right-click a database or schema to set it active. Set the default layout for new connections in Settings, General. Applies to MySQL, MariaDB, PostgreSQL, MSSQL, ClickHouse, Redshift; SQLite, Redis, MongoDB, BigQuery keep their existing sidebar. (#139) ## [0.46.0] - 2026-05-28 diff --git a/TablePro/Models/UI/SharedSidebarState.swift b/TablePro/Models/UI/SharedSidebarState.swift index 4c4817143..b763ab59e 100644 --- a/TablePro/Models/UI/SharedSidebarState.swift +++ b/TablePro/Models/UI/SharedSidebarState.swift @@ -15,6 +15,12 @@ internal enum SidebarTab: String, CaseIterable { case favorites } +/// How the tables tab presents objects for engines that support a database tree. +internal enum SidebarLayout: String, CaseIterable, Sendable { + case flat + case tree +} + @MainActor @Observable final class SharedSidebarState { var redisKeyTreeViewModel: RedisKeyTreeViewModel? @@ -28,6 +34,28 @@ final class SharedSidebarState { } } + var sidebarLayout: SidebarLayout { + didSet { + UserDefaults.standard.set( + sidebarLayout.rawValue, + forKey: SidebarPersistenceKey.layout(connectionId: connectionId) + ) + } + } + + static var defaultLayout: SidebarLayout { + get { + guard let raw = UserDefaults.standard.string(forKey: SidebarPersistenceKey.defaultLayout), + let layout = SidebarLayout(rawValue: raw) else { + return .flat + } + return layout + } + set { + UserDefaults.standard.set(newValue.rawValue, forKey: SidebarPersistenceKey.defaultLayout) + } + } + let connectionId: UUID private init(connectionId: UUID) { @@ -39,12 +67,20 @@ final class SharedSidebarState { } else { self.selectedSidebarTab = .tables } + let layoutKey = SidebarPersistenceKey.layout(connectionId: connectionId) + if let raw = UserDefaults.standard.string(forKey: layoutKey), + let layout = SidebarLayout(rawValue: raw) { + self.sidebarLayout = layout + } else { + self.sidebarLayout = SharedSidebarState.defaultLayout + } } /// Default init for previews and tests init() { self.connectionId = UUID() self.selectedSidebarTab = .tables + self.sidebarLayout = .flat } private static var registry: [UUID: SharedSidebarState] = [:] diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 77748f6e1..a9fd9e4c8 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -15069,6 +15069,9 @@ } } } + }, + "Default layout for new connections:" : { + }, "Default Operator" : { "localizations" : { @@ -26906,6 +26909,9 @@ } } } + }, + "Layout for new connections on servers that support a database tree. Switch the current connection from the View menu." : { + }, "Length" : { "extractionState" : "stale", @@ -27450,6 +27456,9 @@ } } } + }, + "List" : { + }, "List all databases on the server" : { @@ -43117,6 +43126,15 @@ } } } + }, + "Sidebar as List" : { + + }, + "Sidebar as Tree" : { + + }, + "Sidebar Layout" : { + }, "Sidebar Panel" : { "extractionState" : "stale", diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index f45c53f8b..2db1ebd38 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -143,6 +143,13 @@ struct AppMenuCommands: Commands { focusedActions ?? commandRegistry.current } + private var sidebarLayoutBinding: Binding { + Binding( + get: { actions?.sidebarLayout ?? .flat }, + set: { actions?.setSidebarLayout($0) } + ) + } + private func shortcut(for action: ShortcutAction) -> KeyboardShortcut? { settingsManager.keyboard.keyboardShortcut(for: action) } @@ -540,6 +547,17 @@ struct AppMenuCommands: Commands { Divider() + Picker(selection: sidebarLayoutBinding) { + Text("Sidebar as List").tag(SidebarLayout.flat) + Text("Sidebar as Tree").tag(SidebarLayout.tree) + } label: { + Text("Sidebar Layout") + } + .pickerStyle(.inline) + .disabled(!(actions?.canSwitchSidebarLayout ?? false)) + + Divider() + Button("Toggle Filters") { actions?.toggleFilterPanel() } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 575450b59..4c051924b 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -259,6 +259,23 @@ final class MainContentCommandActions { PluginManager.shared.supportsDatabaseSwitching(for: connection.type) } + var canSwitchSidebarLayout: Bool { + guard PluginManager.shared.connectionMode(for: connection.type) == .network, + PluginManager.shared.supportsDatabaseSwitching(for: connection.type) else { + return false + } + let grouping = PluginManager.shared.databaseGroupingStrategy(for: connection.type) + return grouping == .byDatabase || grouping == .bySchema + } + + var sidebarLayout: SidebarLayout { + SharedSidebarState.forConnection(connection.id).sidebarLayout + } + + func setSidebarLayout(_ layout: SidebarLayout) { + SharedSidebarState.forConnection(connection.id).sidebarLayout = layout + } + var isCurrentTabEditable: Bool { coordinator?.tabManager.selectedTab?.tableContext.isEditable == true } diff --git a/TablePro/Views/Settings/GeneralSettingsView.swift b/TablePro/Views/Settings/GeneralSettingsView.swift index 81c88bd00..ca91c6c82 100644 --- a/TablePro/Views/Settings/GeneralSettingsView.swift +++ b/TablePro/Views/Settings/GeneralSettingsView.swift @@ -15,6 +15,7 @@ struct GeneralSettingsView: View { @State private var initialLanguage: AppLanguage? @State private var showResetConfirmation = false + @AppStorage(SidebarPersistenceKey.defaultLayout) private var defaultSidebarLayout: SidebarLayout = .flat private static let standardTimeouts = [10, 20, 30, 40, 50, 60, 90, 120, 180, 300, 600] @@ -54,6 +55,14 @@ struct GeneralSettingsView: View { .help("When enabled, tabs from different connections share the same window instead of opening separate windows.") } + Section("Sidebar") { + Picker("Default layout for new connections:", selection: $defaultSidebarLayout) { + Text("List").tag(SidebarLayout.flat) + Text("Tree").tag(SidebarLayout.tree) + } + .help(String(localized: "Layout for new connections on servers that support a database tree. Switch the current connection from the View menu.")) + } + Section("Query Execution") { Picker("Query timeout:", selection: $settings.queryTimeoutSeconds) { Text("No limit").tag(0) diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index 9f6db477c..db548088c 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -36,6 +36,12 @@ struct DatabaseTreeView: View { coordinator?.toolbarState.currentSchema } + private var committedActiveDatabase: String? { + guard let session = DatabaseManager.shared.session(for: connectionId) else { return nil } + let value = session.activeDatabase + return value.isEmpty ? nil : value + } + private var systemSchemas: Set { Set(PluginManager.shared.systemSchemaNames(for: databaseType)) } @@ -80,6 +86,12 @@ struct DatabaseTreeView: View { .onChange(of: activeSchema ?? "") { _, _ in expandActive() } + .onChange(of: localSelection) { oldTables, newTables in + let action = TableSelectionAction.resolve(oldTables: oldTables, newTables: newTables) + guard case .navigate(let table) = action, + let origin = locateTable(table) else { return } + openTable(table, in: origin.database, schema: origin.schema) + } } private var treeList: some View { @@ -94,8 +106,15 @@ struct DatabaseTreeView: View { } .listStyle(.sidebar) .scrollContentBackground(.hidden) - .contextMenu(forSelectionType: TableInfo.self) { _ in - EmptyView() + .contextMenu(forSelectionType: TableInfo.self) { selection in + SidebarContextMenu( + clickedTable: selection.first, + selectedTables: selection, + isReadOnly: coordinator?.safeModeLevel.blocksAllWrites ?? false, + onBatchToggleTruncate: { viewModel.batchToggleTruncate(tableNames: $0) }, + onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) }, + coordinator: coordinator + ) } primaryAction: { selection in guard let table = selection.first, let origin = locateTable(table) else { return } @@ -145,7 +164,7 @@ struct DatabaseTreeView: View { .foregroundStyle(rowForeground(isActive: isActive, isSystem: db.isSystemDatabase)) } icon: { Image(systemName: db.isSystemDatabase ? "gearshape" : "cylinder") - .foregroundStyle(isActive ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) + .foregroundStyle(db.isSystemDatabase ? AnyShapeStyle(.secondary) : AnyShapeStyle(.tint)) } .contextMenu { Button(String(localized: "Use as Active Database")) { @@ -167,7 +186,7 @@ struct DatabaseTreeView: View { .foregroundStyle(rowForeground(isActive: isActive, isSystem: isSystem)) } icon: { Image(systemName: "folder") - .foregroundStyle(isActive ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) + .foregroundStyle(.tint) } .contextMenu { Button(String(localized: "Use as Active Schema")) { @@ -246,6 +265,11 @@ struct DatabaseTreeView: View { ForEach(routines) { routine in RoutineRowView(routine: routine) .tag(routine) + .contextMenu { + RoutineContextMenu(routine: routine) { selected in + coordinator?.showRoutineDDL(selected) + } + } } } } @@ -459,7 +483,7 @@ struct DatabaseTreeView: View { private func openTable(_ table: TableInfo, in database: String, schema: String?) { Task { @MainActor in - if database != activeDatabase { + if database != committedActiveDatabase { await coordinator?.switchDatabase(to: database) } if let schema, diff --git a/TablePro/Views/Sidebar/RoutineRowView.swift b/TablePro/Views/Sidebar/RoutineRowView.swift index 4ed41605d..c5e8853e5 100644 --- a/TablePro/Views/Sidebar/RoutineRowView.swift +++ b/TablePro/Views/Sidebar/RoutineRowView.swift @@ -24,13 +24,6 @@ enum RoutineRowLogic { } } - static func iconColor(for kind: RoutineInfo.Kind) -> Color { - switch kind { - case .procedure: return Color(nsColor: .systemTeal) - case .function: return Color(nsColor: .systemCyan) - } - } - static func tooltip(for routine: RoutineInfo) -> String? { guard let signature = routine.signature, !signature.isEmpty else { return nil } return signature @@ -43,16 +36,13 @@ struct RoutineRowView: View { var body: some View { Label { Text(routine.name) - .font(.callout) .lineLimit(1) .truncationMode(.tail) - .sidebarTint(.primary) } icon: { Image(systemName: RoutineRowLogic.iconName(for: routine.kind)) - .sidebarTint(RoutineRowLogic.iconColor(for: routine.kind)) - .frame(width: 14) + .sidebarTint(Color.accentColor) + .frame(width: 16) } - .padding(.vertical, 2) .accessibilityElement(children: .combine) .accessibilityLabel(RoutineRowLogic.accessibilityLabel(for: routine)) .help(RoutineRowLogic.tooltip(for: routine) ?? routine.name) diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index c1cd966d3..02322de5a 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -68,47 +68,48 @@ struct SidebarContextMenu: View { } var body: some View { - Button(String(localized: "Create New Table...")) { + Button("Create New Table...") { coordinator?.createNewTable() } .disabled(isReadOnly) - Button(String(localized: "Create New View...")) { + Button("Create New View...") { coordinator?.createView() } .disabled(isReadOnly) Divider() - if isView { - Button(String(localized: "Edit View Definition")) { - if let viewName = clickedTable?.name { - coordinator?.editViewDefinition(viewName) + if clickedTable != nil { + if isView { + Button("Edit View Definition") { + if let viewName = clickedTable?.name { + coordinator?.editViewDefinition(viewName) + } } + .disabled(isReadOnly) } - .disabled(isReadOnly) - } - Button(String(localized: "Show Structure")) { - if let clickedTable { - coordinator?.openTableTab(clickedTable, showStructure: true) + Button("Show Structure") { + if let clickedTable { + coordinator?.openTableTab(clickedTable, showStructure: true) + } } } - .disabled(clickedTable == nil) - Button(String(localized: "View ER Diagram")) { + Button("View ER Diagram") { coordinator?.showERDiagram() } - Button(String(localized: "Copy Name")) { - ClipboardService.shared.writeText(effectiveTableNames.joined(separator: ",")) - } - .disabled(!hasSelection) + if hasSelection { + Button("Copy Name") { + ClipboardService.shared.writeText(effectiveTableNames.joined(separator: ",")) + } - Button(String(localized: "Export...")) { - coordinator?.openExportDialog(preselectedTableNames: Set(effectiveTableNames)) + Button("Export...") { + coordinator?.openExportDialog(preselectedTableNames: Set(effectiveTableNames)) + } } - .disabled(!hasSelection) if SidebarContextMenuLogic.importVisible( clickedTable: clickedTable, @@ -116,13 +117,14 @@ struct SidebarContextMenu: View { for: coordinator?.connection.type ?? .mysql ) ) { - Button(String(localized: "Import...")) { + Button("Import...") { coordinator?.openImportDialog() } .disabled(isReadOnly) } - if let ops = coordinator?.supportedMaintenanceOperations(), !ops.isEmpty, hasSelection { + if hasSelection, + let ops = coordinator?.supportedMaintenanceOperations(), !ops.isEmpty { Menu(String(localized: "Maintenance")) { ForEach(ops, id: \.self) { op in Button(op) { @@ -135,21 +137,23 @@ struct SidebarContextMenu: View { .disabled(isReadOnly) } - Divider() + if hasSelection { + Divider() - if SidebarContextMenuLogic.truncateVisible(clickedTable: clickedTable) { - Button(String(localized: "Truncate")) { - onBatchToggleTruncate(effectiveTableNames) + if SidebarContextMenuLogic.truncateVisible(clickedTable: clickedTable) { + Button("Truncate") { + onBatchToggleTruncate(effectiveTableNames) + } + .disabled(isReadOnly) } - .disabled(!hasSelection || isReadOnly) - } - Button( - SidebarContextMenuLogic.deleteLabel(for: clickedTable?.type), - role: .destructive - ) { - onBatchToggleDelete(effectiveTableNames) + Button( + SidebarContextMenuLogic.deleteLabel(for: clickedTable?.type), + role: .destructive + ) { + onBatchToggleDelete(effectiveTableNames) + } + .disabled(isReadOnly) } - .disabled(!hasSelection || isReadOnly) } } diff --git a/TablePro/Views/Sidebar/SidebarPersistenceKey.swift b/TablePro/Views/Sidebar/SidebarPersistenceKey.swift index cfafd58c0..f2c690ac6 100644 --- a/TablePro/Views/Sidebar/SidebarPersistenceKey.swift +++ b/TablePro/Views/Sidebar/SidebarPersistenceKey.swift @@ -16,6 +16,12 @@ enum SidebarPersistenceKey { "sidebar.selectedTab.\(connectionId.uuidString)" } + static let defaultLayout = "sidebar.defaultLayout" + + static func layout(connectionId: UUID) -> String { + "sidebar.layout.\(connectionId.uuidString)" + } + static func expanded(connectionId: UUID, kind: SidebarObjectKind) -> String { "sidebar.\(connectionId.uuidString).\(kind.rawValue).expanded" } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 04465c1a0..a274e3c5c 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -158,12 +158,16 @@ struct SidebarView: View { } } - private var usesDatabaseTree: Bool { + private var canUseDatabaseTree: Bool { PluginManager.shared.connectionMode(for: viewModel.databaseType) == .network && PluginManager.shared.supportsDatabaseSwitching(for: viewModel.databaseType) && (groupingStrategy == .byDatabase || groupingStrategy == .bySchema) } + private var usesDatabaseTree: Bool { + canUseDatabaseTree && sidebarState.sidebarLayout == .tree + } + @ViewBuilder private var databaseTreeContent: some View { DatabaseTreeView( @@ -177,6 +181,7 @@ struct SidebarView: View { ) } + @ViewBuilder private var hierarchicalContent: some View { switch schemaService.state(for: connectionId) { @@ -279,8 +284,15 @@ struct SidebarView: View { } .listStyle(.sidebar) .scrollContentBackground(.hidden) - .contextMenu(forSelectionType: TableInfo.self) { _ in - EmptyView() + .contextMenu(forSelectionType: TableInfo.self) { selection in + SidebarContextMenu( + clickedTable: selection.first, + selectedTables: selection, + isReadOnly: coordinator?.safeModeLevel.blocksAllWrites ?? false, + onBatchToggleTruncate: { viewModel.batchToggleTruncate(tableNames: $0) }, + onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) }, + coordinator: coordinator + ) } primaryAction: { selection in guard let table = selection.first else { return } onDoubleClick?(table) @@ -332,16 +344,6 @@ struct SidebarView: View { isPendingDelete: pendingDeletes.contains(table.name) ) .tag(table) - .contextMenu { - SidebarContextMenu( - clickedTable: table, - selectedTables: windowState.selectedTables, - isReadOnly: coordinator?.safeModeLevel.blocksAllWrites ?? false, - onBatchToggleTruncate: { viewModel.batchToggleTruncate(tableNames: $0) }, - onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) }, - coordinator: coordinator - ) - } } } } diff --git a/TablePro/Views/Sidebar/TableRowView.swift b/TablePro/Views/Sidebar/TableRowView.swift index 69dea9044..667e7c63f 100644 --- a/TablePro/Views/Sidebar/TableRowView.swift +++ b/TablePro/Views/Sidebar/TableRowView.swift @@ -36,24 +36,6 @@ enum TableRowLogic { } return label } - - static func iconColor(table: TableInfo, isPendingDelete: Bool, isPendingTruncate: Bool) -> Color { - if isPendingDelete { return .red } - if isPendingTruncate { return .orange } - switch table.type { - case .table: return .blue - case .view: return .purple - case .materializedView: return Color(nsColor: .systemTeal) - case .foreignTable: return Color(nsColor: .systemIndigo) - case .systemTable: return .gray - } - } - - static func textColor(isPendingDelete: Bool, isPendingTruncate: Bool) -> Color { - if isPendingDelete { return .red } - if isPendingTruncate { return .orange } - return .primary - } } struct TableRow: View { @@ -61,42 +43,31 @@ struct TableRow: View { let isPendingTruncate: Bool let isPendingDelete: Bool - private var iconColor: Color { - TableRowLogic.iconColor(table: table, isPendingDelete: isPendingDelete, isPendingTruncate: isPendingTruncate) - } - - private var textColor: Color { - TableRowLogic.textColor(isPendingDelete: isPendingDelete, isPendingTruncate: isPendingTruncate) - } - @ViewBuilder private var pendingStateBadge: some View { if isPendingDelete { Image(systemName: "minus.circle.fill") .font(.caption) - .sidebarTint(.red) + .foregroundStyle(.red) } else if isPendingTruncate { Image(systemName: "exclamationmark.circle.fill") .font(.caption) - .sidebarTint(.orange) + .foregroundStyle(.orange) } } var body: some View { Label { Text(table.name) - .font(.callout) .lineLimit(1) - .sidebarTint(textColor) } icon: { Image(systemName: TableRowLogic.iconName(for: table.type)) - .sidebarTint(iconColor) - .frame(width: 14) + .sidebarTint(Color.accentColor) + .frame(width: 16) .overlay(alignment: .bottomTrailing) { pendingStateBadge } } - .padding(.vertical, 4) .accessibilityElement(children: .combine) .accessibilityLabel(TableRowLogic.accessibilityLabel(table: table, isPendingDelete: isPendingDelete, isPendingTruncate: isPendingTruncate)) } From c88cff546aafe4709e0b00cd8e8aa2539e60a6cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 15:52:39 +0700 Subject: [PATCH 04/12] fix(sidebar): tree context-menu targets the clicked database and pool clears canceled tasks (#139) --- .../Query/MetadataConnectionPool.swift | 8 ++-- TablePro/Views/Sidebar/DatabaseTreeView.swift | 16 ++++++- .../Views/Sidebar/SidebarContextMenu.swift | 45 +++++++++++++------ 3 files changed, 52 insertions(+), 17 deletions(-) diff --git a/TablePro/Core/Services/Query/MetadataConnectionPool.swift b/TablePro/Core/Services/Query/MetadataConnectionPool.swift index 5eb213dd1..a7613bc0c 100644 --- a/TablePro/Core/Services/Query/MetadataConnectionPool.swift +++ b/TablePro/Core/Services/Query/MetadataConnectionPool.swift @@ -50,13 +50,15 @@ final class MetadataConnectionPool { func invalidate(connectionId: UUID, database: String) { let key = Key(connectionId: connectionId, database: database) pending[key]?.cancel() + pending.removeValue(forKey: key) entries[key]?.driver.disconnect() entries.removeValue(forKey: key) } func closeAll(connectionId: UUID) { - for key in pending.keys where key.connectionId == connectionId { + for key in pending.keys.filter({ $0.connectionId == connectionId }) { pending[key]?.cancel() + pending.removeValue(forKey: key) } let keys = entries.keys.filter { $0.connectionId == connectionId } for key in keys { @@ -100,10 +102,10 @@ final class MetadataConnectionPool { do { try await task.value } catch { - pending.removeValue(forKey: key) + if pending[key] == task { pending.removeValue(forKey: key) } throw error } - pending.removeValue(forKey: key) + if pending[key] == task { pending.removeValue(forKey: key) } guard let entry = entries[key] else { throw DatabaseError.notConnected } return entry diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index db548088c..a3173f1eb 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -42,6 +42,19 @@ struct DatabaseTreeView: View { return value.isEmpty ? nil : value } + @MainActor + private func activateClickedDatabase(_ table: TableInfo?) async { + guard let table, let origin = locateTable(table) else { return } + if origin.database != committedActiveDatabase { + await coordinator?.switchDatabase(to: origin.database) + } + if let schema = origin.schema, + schema != coordinator?.toolbarState.currentSchema, + PluginManager.shared.supportsSchemaSwitching(for: databaseType) { + await coordinator?.switchSchema(to: schema) + } + } + private var systemSchemas: Set { Set(PluginManager.shared.systemSchemaNames(for: databaseType)) } @@ -113,7 +126,8 @@ struct DatabaseTreeView: View { isReadOnly: coordinator?.safeModeLevel.blocksAllWrites ?? false, onBatchToggleTruncate: { viewModel.batchToggleTruncate(tableNames: $0) }, onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) }, - coordinator: coordinator + coordinator: coordinator, + activateBeforeAction: { await activateClickedDatabase(selection.first) } ) } primaryAction: { selection in guard let table = selection.first, diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index 02322de5a..78676ed52 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -51,6 +51,7 @@ struct SidebarContextMenu: View { let onBatchToggleTruncate: ([String]) -> Void let onBatchToggleDelete: ([String]) -> Void let coordinator: MainContentCoordinator? + var activateBeforeAction: (@MainActor () async -> Void)? private var hasSelection: Bool { SidebarContextMenuLogic.hasSelection(selectedTables: selectedTables, clickedTable: clickedTable) @@ -67,14 +68,26 @@ struct SidebarContextMenu: View { return selectedTables.map(\.name).sorted() } + @MainActor + private func perform(_ action: @MainActor @escaping () -> Void) { + guard let activate = activateBeforeAction else { + action() + return + } + Task { @MainActor in + await activate() + action() + } + } + var body: some View { Button("Create New Table...") { - coordinator?.createNewTable() + perform { coordinator?.createNewTable() } } .disabled(isReadOnly) Button("Create New View...") { - coordinator?.createView() + perform { coordinator?.createView() } } .disabled(isReadOnly) @@ -83,22 +96,26 @@ struct SidebarContextMenu: View { if clickedTable != nil { if isView { Button("Edit View Definition") { - if let viewName = clickedTable?.name { - coordinator?.editViewDefinition(viewName) + perform { + if let viewName = clickedTable?.name { + coordinator?.editViewDefinition(viewName) + } } } .disabled(isReadOnly) } Button("Show Structure") { - if let clickedTable { - coordinator?.openTableTab(clickedTable, showStructure: true) + perform { + if let clickedTable { + coordinator?.openTableTab(clickedTable, showStructure: true) + } } } } Button("View ER Diagram") { - coordinator?.showERDiagram() + perform { coordinator?.showERDiagram() } } if hasSelection { @@ -107,7 +124,7 @@ struct SidebarContextMenu: View { } Button("Export...") { - coordinator?.openExportDialog(preselectedTableNames: Set(effectiveTableNames)) + perform { coordinator?.openExportDialog(preselectedTableNames: Set(effectiveTableNames)) } } } @@ -118,7 +135,7 @@ struct SidebarContextMenu: View { ) ) { Button("Import...") { - coordinator?.openImportDialog() + perform { coordinator?.openImportDialog() } } .disabled(isReadOnly) } @@ -128,8 +145,10 @@ struct SidebarContextMenu: View { Menu(String(localized: "Maintenance")) { ForEach(ops, id: \.self) { op in Button(op) { - if let table = clickedTable?.name { - coordinator?.showMaintenanceSheet(operation: op, tableName: table) + perform { + if let table = clickedTable?.name { + coordinator?.showMaintenanceSheet(operation: op, tableName: table) + } } } } @@ -142,7 +161,7 @@ struct SidebarContextMenu: View { if SidebarContextMenuLogic.truncateVisible(clickedTable: clickedTable) { Button("Truncate") { - onBatchToggleTruncate(effectiveTableNames) + perform { onBatchToggleTruncate(effectiveTableNames) } } .disabled(isReadOnly) } @@ -151,7 +170,7 @@ struct SidebarContextMenu: View { SidebarContextMenuLogic.deleteLabel(for: clickedTable?.type), role: .destructive ) { - onBatchToggleDelete(effectiveTableNames) + perform { onBatchToggleDelete(effectiveTableNames) } } .disabled(isReadOnly) } From 25cee6a451540ad55f1b2924cedf3311c99cbf5a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 29 May 2026 17:43:42 +0700 Subject: [PATCH 05/12] refactor(sidebar): database-qualified tree row identity and serialized metadata pool (#139) --- .../Plugins/PluginManager+Registration.swift | 9 +++ .../Query/MetadataConnectionPool.swift | 40 ++++++++-- TablePro/Models/UI/SharedSidebarState.swift | 1 - .../Main/MainContentCommandActions.swift | 7 +- .../Views/Main/TableSelectionAction.swift | 14 +++- TablePro/Views/Sidebar/DatabaseTreeView.swift | 77 ++++++++---------- TablePro/Views/Sidebar/SidebarTreeView.swift | 1 - TablePro/Views/Sidebar/SidebarView.swift | 10 +-- .../Core/Storage/GroupStorageTests.swift | 2 +- .../Sidebar/DatabaseTreeSelectionTests.swift | 77 ++++++++++++++++++ TableProTests/Views/TableRowLogicTests.swift | 79 ------------------- 11 files changed, 170 insertions(+), 147 deletions(-) create mode 100644 TableProTests/Views/Sidebar/DatabaseTreeSelectionTests.swift diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index 888a93439..19ceb3dc2 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -446,6 +446,15 @@ extension PluginManager { .schema.databaseGroupingStrategy ?? .byDatabase } + func supportsDatabaseTree(for databaseType: DatabaseType) -> Bool { + guard connectionMode(for: databaseType) == .network, + supportsDatabaseSwitching(for: databaseType) else { + return false + } + let grouping = databaseGroupingStrategy(for: databaseType) + return grouping == .byDatabase || grouping == .bySchema + } + func defaultGroupName(for databaseType: DatabaseType) -> String { PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)? .schema.defaultGroupName ?? "main" diff --git a/TablePro/Core/Services/Query/MetadataConnectionPool.swift b/TablePro/Core/Services/Query/MetadataConnectionPool.swift index a7613bc0c..2f0034701 100644 --- a/TablePro/Core/Services/Query/MetadataConnectionPool.swift +++ b/TablePro/Core/Services/Query/MetadataConnectionPool.swift @@ -19,11 +19,15 @@ final class MetadataConnectionPool { let driver: DatabaseDriver var lastUsed: Date var inFlightCount: Int + var closeWhenIdle: Bool + var tail: Task init(driver: DatabaseDriver) { self.driver = driver self.lastUsed = Date() self.inFlightCount = 0 + self.closeWhenIdle = false + self.tail = Task {} } } @@ -38,21 +42,34 @@ final class MetadataConnectionPool { func withDriver( connectionId: UUID, database: String, - _ body: @Sendable (DatabaseDriver) async throws -> T + _ body: @Sendable @escaping (DatabaseDriver) async throws -> T ) async throws -> T { let entry = try await acquireEntry(connectionId: connectionId, database: database) entry.inFlightCount += 1 entry.lastUsed = Date() - defer { entry.inFlightCount -= 1 } - return try await body(entry.driver) + defer { releaseEntry(entry) } + + let previous = entry.tail + let driver = entry.driver + let work = Task { @MainActor () async throws -> T in + await previous.value + return try await body(driver) + } + entry.tail = Task { @MainActor in _ = try? await work.value } + return try await work.value + } + + private func releaseEntry(_ entry: Entry) { + entry.inFlightCount -= 1 + guard entry.inFlightCount == 0, entry.closeWhenIdle else { return } + entry.driver.disconnect() } func invalidate(connectionId: UUID, database: String) { let key = Key(connectionId: connectionId, database: database) pending[key]?.cancel() pending.removeValue(forKey: key) - entries[key]?.driver.disconnect() - entries.removeValue(forKey: key) + closeOrDeferEntry(forKey: key) } func closeAll(connectionId: UUID) { @@ -62,8 +79,7 @@ final class MetadataConnectionPool { } let keys = entries.keys.filter { $0.connectionId == connectionId } for key in keys { - entries[key]?.driver.disconnect() - entries.removeValue(forKey: key) + closeOrDeferEntry(forKey: key) } if !keys.isEmpty { Self.logger.info( @@ -72,6 +88,16 @@ final class MetadataConnectionPool { } } + private func closeOrDeferEntry(forKey key: Key) { + guard let entry = entries[key] else { return } + entries.removeValue(forKey: key) + if entry.inFlightCount == 0 { + entry.driver.disconnect() + } else { + entry.closeWhenIdle = true + } + } + private func acquireEntry(connectionId: UUID, database: String) async throws -> Entry { let key = Key(connectionId: connectionId, database: database) if let entry = entries[key], entry.driver.status == .connected { diff --git a/TablePro/Models/UI/SharedSidebarState.swift b/TablePro/Models/UI/SharedSidebarState.swift index b763ab59e..1dbb65917 100644 --- a/TablePro/Models/UI/SharedSidebarState.swift +++ b/TablePro/Models/UI/SharedSidebarState.swift @@ -15,7 +15,6 @@ internal enum SidebarTab: String, CaseIterable { case favorites } -/// How the tables tab presents objects for engines that support a database tree. internal enum SidebarLayout: String, CaseIterable, Sendable { case flat case tree diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 4c051924b..c257f08bd 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -260,12 +260,7 @@ final class MainContentCommandActions { } var canSwitchSidebarLayout: Bool { - guard PluginManager.shared.connectionMode(for: connection.type) == .network, - PluginManager.shared.supportsDatabaseSwitching(for: connection.type) else { - return false - } - let grouping = PluginManager.shared.databaseGroupingStrategy(for: connection.type) - return grouping == .byDatabase || grouping == .bySchema + PluginManager.shared.supportsDatabaseTree(for: connection.type) } var sidebarLayout: SidebarLayout { diff --git a/TablePro/Views/Main/TableSelectionAction.swift b/TablePro/Views/Main/TableSelectionAction.swift index 2328471d6..2e61c101d 100644 --- a/TablePro/Views/Main/TableSelectionAction.swift +++ b/TablePro/Views/Main/TableSelectionAction.swift @@ -20,14 +20,24 @@ enum TableSelectionAction: Equatable { oldTables: Set, newTables: Set ) -> TableSelectionAction { - let added = newTables.subtracting(oldTables) - guard added.count == 1, let table = added.first else { + guard let table = SelectionDelta.singleAddition(old: oldTables, new: newTables) else { return .noNavigation } return .navigate(table: table) } } +enum SelectionDelta { + static func singleAddition( + old: Set, + new: Set + ) -> Element? { + let added = new.subtracting(old) + guard added.count == 1 else { return nil } + return added.first + } +} + /// Determines which table (if any) to select when the table list loads in a new window. enum SidebarSyncAction: Equatable { case noSync diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index a3173f1eb..c8b75958c 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -6,6 +6,24 @@ import SwiftUI import TableProPluginKit +struct DatabaseTreeTableRef: Hashable, Identifiable { + let database: String + let schema: String? + let table: TableInfo + + var id: String { + "\(database)|\(schema ?? "")|\(table.id)" + } + + static func == (lhs: DatabaseTreeTableRef, rhs: DatabaseTreeTableRef) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + struct DatabaseTreeView: View { @Bindable private var treeService = DatabaseTreeMetadataService.shared @@ -17,7 +35,7 @@ struct DatabaseTreeView: View { @Binding var pendingDeletes: Set let coordinator: MainContentCoordinator? - @State private var localSelection: Set = [] + @State private var localSelection: Set = [] private var groupingStrategy: GroupingStrategy { PluginManager.shared.databaseGroupingStrategy(for: databaseType) @@ -43,12 +61,12 @@ struct DatabaseTreeView: View { } @MainActor - private func activateClickedDatabase(_ table: TableInfo?) async { - guard let table, let origin = locateTable(table) else { return } - if origin.database != committedActiveDatabase { - await coordinator?.switchDatabase(to: origin.database) + private func activate(_ ref: DatabaseTreeTableRef?) async { + guard let ref else { return } + if ref.database != committedActiveDatabase { + await coordinator?.switchDatabase(to: ref.database) } - if let schema = origin.schema, + if let schema = ref.schema, schema != coordinator?.toolbarState.currentSchema, PluginManager.shared.supportsSchemaSwitching(for: databaseType) { await coordinator?.switchSchema(to: schema) @@ -67,7 +85,7 @@ struct DatabaseTreeView: View { viewModel.searchText } - private var selectedTablesBinding: Binding> { + private var selectedTablesBinding: Binding> { Binding( get: { localSelection }, set: { localSelection = $0 } @@ -99,11 +117,9 @@ struct DatabaseTreeView: View { .onChange(of: activeSchema ?? "") { _, _ in expandActive() } - .onChange(of: localSelection) { oldTables, newTables in - let action = TableSelectionAction.resolve(oldTables: oldTables, newTables: newTables) - guard case .navigate(let table) = action, - let origin = locateTable(table) else { return } - openTable(table, in: origin.database, schema: origin.schema) + .onChange(of: localSelection) { oldRefs, newRefs in + guard let ref = SelectionDelta.singleAddition(old: oldRefs, new: newRefs) else { return } + openTable(ref.table, in: ref.database, schema: ref.schema) } } @@ -119,48 +135,25 @@ struct DatabaseTreeView: View { } .listStyle(.sidebar) .scrollContentBackground(.hidden) - .contextMenu(forSelectionType: TableInfo.self) { selection in + .contextMenu(forSelectionType: DatabaseTreeTableRef.self) { selection in SidebarContextMenu( - clickedTable: selection.first, - selectedTables: selection, + clickedTable: selection.first?.table, + selectedTables: Set(selection.map(\.table)), isReadOnly: coordinator?.safeModeLevel.blocksAllWrites ?? false, onBatchToggleTruncate: { viewModel.batchToggleTruncate(tableNames: $0) }, onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) }, coordinator: coordinator, - activateBeforeAction: { await activateClickedDatabase(selection.first) } + activateBeforeAction: { await activate(selection.first) } ) } primaryAction: { selection in - guard let table = selection.first, - let origin = locateTable(table) else { return } - openTable(table, in: origin.database, schema: origin.schema) + guard let ref = selection.first else { return } + openTable(ref.table, in: ref.database, schema: ref.schema) } .onExitCommand { localSelection.removeAll() } } - private func locateTable(_ target: TableInfo) -> (database: String, schema: String?)? { - for db in visibleDatabases { - if supportsSchemaLevel { - let schemaState = treeService.schemaListState(connectionId: connectionId, database: db.name) - if case .loaded(let schemas) = schemaState { - for schema in schemas { - let candidates = tables(database: db.name, schema: schema) - if candidates.contains(where: { $0.id == target.id }) { - return (db.name, schema) - } - } - } - } else { - let candidates = tables(database: db.name, schema: nil) - if candidates.contains(where: { $0.id == target.id }) { - return (db.name, nil) - } - } - } - return nil - } - @ViewBuilder private func databaseBody(_ db: DatabaseMetadata) -> some View { if supportsSchemaLevel { @@ -274,7 +267,7 @@ struct DatabaseTreeView: View { isPendingTruncate: pendingTruncates.contains(table.name), isPendingDelete: pendingDeletes.contains(table.name) ) - .tag(table) + .tag(DatabaseTreeTableRef(database: database, schema: schema, table: table)) } ForEach(routines) { routine in RoutineRowView(routine: routine) diff --git a/TablePro/Views/Sidebar/SidebarTreeView.swift b/TablePro/Views/Sidebar/SidebarTreeView.swift index 97dc439bb..babc46f98 100644 --- a/TablePro/Views/Sidebar/SidebarTreeView.swift +++ b/TablePro/Views/Sidebar/SidebarTreeView.swift @@ -14,7 +14,6 @@ struct SidebarTreeView: View { @State private var searchLoadTask: Task? - private var systemSchemas: Set { Set(PluginManager.shared.systemSchemaNames(for: viewModel.databaseType)) } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index a274e3c5c..a273bb971 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -158,14 +158,9 @@ struct SidebarView: View { } } - private var canUseDatabaseTree: Bool { - PluginManager.shared.connectionMode(for: viewModel.databaseType) == .network - && PluginManager.shared.supportsDatabaseSwitching(for: viewModel.databaseType) - && (groupingStrategy == .byDatabase || groupingStrategy == .bySchema) - } - private var usesDatabaseTree: Bool { - canUseDatabaseTree && sidebarState.sidebarLayout == .tree + PluginManager.shared.supportsDatabaseTree(for: viewModel.databaseType) + && sidebarState.sidebarLayout == .tree } @ViewBuilder @@ -181,7 +176,6 @@ struct SidebarView: View { ) } - @ViewBuilder private var hierarchicalContent: some View { switch schemaService.state(for: connectionId) { diff --git a/TableProTests/Core/Storage/GroupStorageTests.swift b/TableProTests/Core/Storage/GroupStorageTests.swift index 1581bf01c..b7141d15f 100644 --- a/TableProTests/Core/Storage/GroupStorageTests.swift +++ b/TableProTests/Core/Storage/GroupStorageTests.swift @@ -42,7 +42,7 @@ final class GroupStorageTests: XCTestCase { storage = GroupStorage( userDefaults: defaults, syncTracker: tracker, - connectionStorage: connectionStorage + connectionStorage: self.connectionStorage ) } diff --git a/TableProTests/Views/Sidebar/DatabaseTreeSelectionTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeSelectionTests.swift new file mode 100644 index 000000000..eabb69c32 --- /dev/null +++ b/TableProTests/Views/Sidebar/DatabaseTreeSelectionTests.swift @@ -0,0 +1,77 @@ +// +// DatabaseTreeSelectionTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("Database Tree Selection Identity") +struct DatabaseTreeSelectionTests { + private func makeTable(_ name: String, schema: String? = nil) -> TableInfo { + TableInfo(name: name, type: .table, rowCount: nil, schema: schema) + } + + @Test("Same table name in different databases produces distinct refs") + func sameNameDifferentDatabaseIsDistinct() { + let table = makeTable("users") + let inDb1 = DatabaseTreeTableRef(database: "db1", schema: nil, table: table) + let inDb2 = DatabaseTreeTableRef(database: "db2", schema: nil, table: table) + + #expect(inDb1 != inDb2) + #expect(inDb1.id != inDb2.id) + #expect(Set([inDb1, inDb2]).count == 2) + } + + @Test("Same public schema in different databases produces distinct refs") + func samePublicSchemaDifferentDatabaseIsDistinct() { + let table = makeTable("users", schema: "public") + let inDb1 = DatabaseTreeTableRef(database: "db1", schema: "public", table: table) + let inDb2 = DatabaseTreeTableRef(database: "db2", schema: "public", table: table) + + #expect(inDb1 != inDb2) + #expect(Set([inDb1, inDb2]).count == 2) + } + + @Test("Identical database, schema, and table are equal") + func identicalRefsAreEqual() { + let lhs = DatabaseTreeTableRef(database: "db1", schema: "public", table: makeTable("users", schema: "public")) + let rhs = DatabaseTreeTableRef(database: "db1", schema: "public", table: makeTable("users", schema: "public")) + + #expect(lhs == rhs) + #expect(lhs.hashValue == rhs.hashValue) + } +} + +@Suite("Selection Delta") +struct SelectionDeltaTests { + @Test("Single addition is detected") + func singleAdditionDetected() { + let old: Set = [1, 2] + let new: Set = [1, 2, 3] + #expect(SelectionDelta.singleAddition(old: old, new: new) == 3) + } + + @Test("No addition returns nil") + func noAdditionReturnsNil() { + let set: Set = [1, 2] + #expect(SelectionDelta.singleAddition(old: set, new: set) == nil) + } + + @Test("Removal returns nil") + func removalReturnsNil() { + let old: Set = [1, 2, 3] + let new: Set = [1, 2] + #expect(SelectionDelta.singleAddition(old: old, new: new) == nil) + } + + @Test("Multiple additions return nil") + func multipleAdditionsReturnNil() { + let old: Set = [1] + let new: Set = [1, 2, 3] + #expect(SelectionDelta.singleAddition(old: old, new: new) == nil) + } +} diff --git a/TableProTests/Views/TableRowLogicTests.swift b/TableProTests/Views/TableRowLogicTests.swift index 0d219b594..1ff1fc192 100644 --- a/TableProTests/Views/TableRowLogicTests.swift +++ b/TableProTests/Views/TableRowLogicTests.swift @@ -5,7 +5,6 @@ // Tests for TableRow computed property logic extracted into TableRowLogic. // -import SwiftUI import TableProPluginKit import Testing @testable import TablePro @@ -57,84 +56,6 @@ struct TableRowLogicTests { #expect(label == "View: my_view, pending delete") } - // MARK: - Icon Color - - @Test("Normal table icon color is system blue") - func iconColorNormalTable() { - let table = TestFixtures.makeTableInfo(name: "users", type: .table) - #expect(TableRowLogic.iconColor(table: table, isPendingDelete: false, isPendingTruncate: false) == Color(nsColor: .systemBlue)) - } - - @Test("Normal view icon color is system purple") - func iconColorNormalView() { - let table = TestFixtures.makeTableInfo(name: "v", type: .view) - #expect(TableRowLogic.iconColor(table: table, isPendingDelete: false, isPendingTruncate: false) == Color(nsColor: .systemPurple)) - } - - @Test("Materialized view icon color is system teal") - func iconColorMaterializedView() { - let table = TestFixtures.makeTableInfo(name: "mv", type: .materializedView) - #expect(TableRowLogic.iconColor(table: table, isPendingDelete: false, isPendingTruncate: false) == Color(nsColor: .systemTeal)) - } - - @Test("Foreign table icon color is system indigo") - func iconColorForeignTable() { - let table = TestFixtures.makeTableInfo(name: "ft", type: .foreignTable) - #expect(TableRowLogic.iconColor(table: table, isPendingDelete: false, isPendingTruncate: false) == Color(nsColor: .systemIndigo)) - } - - @Test("System table icon color is system gray") - func iconColorSystemTable() { - let table = TestFixtures.makeTableInfo(name: "s", type: .systemTable) - #expect(TableRowLogic.iconColor(table: table, isPendingDelete: false, isPendingTruncate: false) == Color(nsColor: .systemGray)) - } - - @Test("Pending delete table icon color is system red") - func iconColorPendingDeleteTable() { - let table = TestFixtures.makeTableInfo(name: "users", type: .table) - #expect(TableRowLogic.iconColor(table: table, isPendingDelete: true, isPendingTruncate: false) == Color(nsColor: .systemRed)) - } - - @Test("Pending truncate table icon color is system orange") - func iconColorPendingTruncateTable() { - let table = TestFixtures.makeTableInfo(name: "users", type: .table) - #expect(TableRowLogic.iconColor(table: table, isPendingDelete: false, isPendingTruncate: true) == Color(nsColor: .systemOrange)) - } - - @Test("Pending delete view icon color is system red") - func iconColorPendingDeleteView() { - let table = TestFixtures.makeTableInfo(name: "v", type: .view) - #expect(TableRowLogic.iconColor(table: table, isPendingDelete: true, isPendingTruncate: false) == Color(nsColor: .systemRed)) - } - - @Test("Both pending — delete wins for icon color") - func iconColorBothPendingDeleteWins() { - let table = TestFixtures.makeTableInfo(name: "users", type: .table) - #expect(TableRowLogic.iconColor(table: table, isPendingDelete: true, isPendingTruncate: true) == Color(nsColor: .systemRed)) - } - - // MARK: - Text Color - - @Test("Normal text color is primary") - func textColorNormal() { - #expect(TableRowLogic.textColor(isPendingDelete: false, isPendingTruncate: false) == .primary) - } - - @Test("Pending delete text color is system red") - func textColorPendingDelete() { - #expect(TableRowLogic.textColor(isPendingDelete: true, isPendingTruncate: false) == Color(nsColor: .systemRed)) - } - - @Test("Pending truncate text color is system orange") - func textColorPendingTruncate() { - #expect(TableRowLogic.textColor(isPendingDelete: false, isPendingTruncate: true) == Color(nsColor: .systemOrange)) - } - - @Test("Both pending — delete wins for text color") - func textColorBothPendingDeleteWins() { - #expect(TableRowLogic.textColor(isPendingDelete: true, isPendingTruncate: true) == Color(nsColor: .systemRed)) - } - // MARK: - Icon Name per Kind @Test("Icon name per table kind") From 82323b797eaafc287e2b0b386e43e33071004cf3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 29 May 2026 18:04:36 +0700 Subject: [PATCH 06/12] fix(sidebar): reload stranded tree rows after switching active database (#139) --- TablePro/Views/Sidebar/DatabaseTreeView.swift | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index c8b75958c..ca21056ee 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -60,6 +60,14 @@ struct DatabaseTreeView: View { return value.isEmpty ? nil : value } + private var committedActiveSchema: String? { + DatabaseManager.shared.session(for: connectionId)?.currentSchema + } + + private var schemaGenerationToken: Int { + SchemaService.shared.generationToken(for: connectionId) + } + @MainActor private func activate(_ ref: DatabaseTreeTableRef?) async { guard let ref else { return } @@ -110,12 +118,13 @@ struct DatabaseTreeView: View { .onAppear { loadDatabasesIfNeeded() expandActive() + reconcileLoads() } - .onChange(of: activeDatabase ?? "") { _, _ in + .onChange(of: activeContextKey) { _, _ in expandActive() } - .onChange(of: activeSchema ?? "") { _, _ in - expandActive() + .onChange(of: reconcileKey) { _, _ in + reconcileLoads() } .onChange(of: localSelection) { oldRefs, newRefs in guard let ref = SelectionDelta.singleAddition(old: oldRefs, new: newRefs) else { return } @@ -123,6 +132,14 @@ struct DatabaseTreeView: View { } } + private var activeContextKey: String { + "\(activeDatabase ?? "")|\(activeSchema ?? "")" + } + + private var reconcileKey: String { + "\(committedActiveDatabase ?? "")|\(committedActiveSchema ?? "")|\(schemaGenerationToken)" + } + private var treeList: some View { List(selection: selectedTablesBinding) { ForEach(visibleDatabases, id: \.id) { db in @@ -431,6 +448,39 @@ struct DatabaseTreeView: View { } } + private func reconcileLoads() { + guard case .loaded = treeService.databaseListState(for: connectionId) else { return } + + if let active = committedActiveDatabase { + ensureContentLoaded(database: active, schema: supportsSchemaLevel ? committedActiveSchema : nil) + } + + for database in windowState.expandedTreeDatabases { + guard !supportsSchemaLevel else { + ensureSchemaListLoaded(database: database) + let expandedSchemas = windowState.expandedTreeDatabaseSchemas + .filter { $0.database == database } + for key in expandedSchemas { + ensureContentLoaded(database: database, schema: key.schema) + } + continue + } + ensureContentLoaded(database: database, schema: nil) + } + } + + private func ensureSchemaListLoaded(database: String) { + guard case .idle = treeService.schemaListState(connectionId: connectionId, database: database) else { return } + loadDatabaseContentIfNeeded(database) + } + + private func ensureContentLoaded(database: String, schema: String?) { + guard case .idle = treeService.tableState( + connectionId: connectionId, database: database, schema: schema + ) else { return } + loadTablesIfNeeded(database: database, schema: schema) + } + private func loadDatabaseContentIfNeeded(_ database: String) { if supportsSchemaLevel { Task { await treeService.loadSchemaList(connectionId: connectionId, database: database) } From 635b620f7906d9a2c90bdc4959f825e632496868 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 29 May 2026 18:12:47 +0700 Subject: [PATCH 07/12] fix(sidebar): keep tree schema list stable when switching the active schema (#139) --- .../Query/DatabaseTreeMetadataService.swift | 6 ++- .../Core/Services/Query/SchemaService.swift | 52 ++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index 06592bcd5..4b002af6e 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -64,11 +64,15 @@ final class DatabaseTreeMetadataService { func schemaListState(connectionId: UUID, database: String) -> SchemaListState { if database == activeDatabase(for: connectionId) { + let schemas = SchemaService.shared.schemas(for: connectionId) + if !schemas.isEmpty { + return .loaded(schemas) + } switch SchemaService.shared.state(for: connectionId) { case .idle: return .idle case .loading: return .loading case .failed(let message): return .failed(message) - case .loaded: return .loaded(SchemaService.shared.schemas(for: connectionId)) + case .loaded: return .loaded(schemas) } } return schemaListStates[DatabaseKey(connectionId: connectionId, database: database)] ?? .idle diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index 1ef043f17..eac104d56 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -315,7 +315,55 @@ final class SchemaService { private func handleSchemaSwitch(connectionId: UUID) async { guard let session = DatabaseManager.shared.activeSessions[connectionId], let driver = session.driver else { return } - await invalidate(connectionId: connectionId) - await reload(connectionId: connectionId, driver: driver, connection: session.connection) + let connection = session.connection + if PluginManager.shared.databaseGroupingStrategy(for: connection.type) == .hierarchicalSchema { + await invalidate(connectionId: connectionId) + await reload(connectionId: connectionId, driver: driver, connection: connection) + return + } + await reloadCurrentSchemaContent(connectionId: connectionId, driver: driver) + } + + private func reloadCurrentSchemaContent(connectionId: UUID, driver: DatabaseDriver) async { + await loadDedup.cancel(key: connectionId) + await procedureDedup.cancel(key: connectionId) + await functionDedup.cancel(key: connectionId) + + states[connectionId] = .loading + bumpGeneration(connectionId) + + async let proceduresTask: [RoutineInfo] = Self.fetchRoutinesSafely( + connectionId: connectionId, + kind: .procedure, + dedup: procedureDedup, + fetch: { try await driver.fetchProcedures(schema: nil) } + ) + async let functionsTask: [RoutineInfo] = Self.fetchRoutinesSafely( + connectionId: connectionId, + kind: .function, + dedup: functionDedup, + fetch: { try await driver.fetchFunctions(schema: nil) } + ) + + let loadedProcedures = await proceduresTask + let loadedFunctions = await functionsTask + + do { + let tables = try await loadDedup.execute(key: connectionId) { + try await driver.fetchTables() + } + states[connectionId] = .loaded(tables) + procedures[connectionId] = loadedProcedures + functions[connectionId] = loadedFunctions + bumpGeneration(connectionId) + } catch is CancellationError { + return + } catch { + Self.logger.warning( + "[schema] current-schema reload failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + states[connectionId] = .failed(error.localizedDescription) + bumpGeneration(connectionId) + } } } From 397f380a88273bc8f91b9707efe1ddf5b24bae8b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 29 May 2026 18:29:29 +0700 Subject: [PATCH 08/12] fix(sidebar): load tree routines per schema and skip loads while reconnecting (#139) --- .../Query/DatabaseTreeMetadataService.swift | 69 ++++++++++++++++++- TablePro/Views/Sidebar/DatabaseTreeView.swift | 11 ++- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index 4b002af6e..e07eb7ef9 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -40,10 +40,12 @@ final class DatabaseTreeMetadataService { private(set) var databaseListStates: [UUID: DatabaseListState] = [:] private(set) var schemaListStates: [DatabaseKey: SchemaListState] = [:] private(set) var tableStates: [TableKey: SchemaState] = [:] + private(set) var routineLists: [TableKey: [RoutineInfo]] = [:] @ObservationIgnored private let databaseListDedup = OnceTask() @ObservationIgnored private let schemaListDedup = OnceTask() @ObservationIgnored private let tableDedup = OnceTask() + @ObservationIgnored private let routineDedup = OnceTask() @ObservationIgnored private static let logger = Logger( subsystem: "com.TablePro", category: "DatabaseTreeMetadataService" @@ -113,8 +115,7 @@ final class DatabaseTreeMetadataService { } func routines(connectionId: UUID, database: String, schema: String?) -> [RoutineInfo] { - guard database == activeDatabase(for: connectionId) else { return [] } - return SchemaService.shared.routines(for: connectionId) + routineLists[Self.tableKey(connectionId: connectionId, database: database, schema: schema)] ?? [] } func loadDatabaseList(connectionId: UUID, driver: DatabaseDriver, databaseType: DatabaseType) async { @@ -149,6 +150,7 @@ final class DatabaseTreeMetadataService { func loadSchemaList(connectionId: UUID, database: String) async { if database == activeDatabase(for: connectionId) { return } + guard !isReconnecting(connectionId) else { return } let key = DatabaseKey(connectionId: connectionId, database: database) if case .loaded = schemaListStates[key] { return } schemaListStates[key] = .loading @@ -172,6 +174,8 @@ final class DatabaseTreeMetadataService { } func loadTables(connectionId: UUID, database: String, schema: String?) async { + guard !isReconnecting(connectionId) else { return } + await loadRoutines(connectionId: connectionId, database: database, schema: schema) if database == activeDatabase(for: connectionId) { guard let session = DatabaseManager.shared.session(for: connectionId), let driver = session.driver else { return } @@ -209,7 +213,47 @@ final class DatabaseTreeMetadataService { } } + func loadRoutines(connectionId: UUID, database: String, schema: String?) async { + guard !isReconnecting(connectionId) else { return } + let key = Self.tableKey(connectionId: connectionId, database: database, schema: schema) + if routineLists[key] != nil { return } + let normalizedSchema = key.schema + let activeSessionDriver = database == activeDatabase(for: connectionId) + ? DatabaseManager.shared.session(for: connectionId)?.driver + : nil + if database == activeDatabase(for: connectionId), activeSessionDriver == nil { return } + do { + let list = try await routineDedup.execute(key: key) { + if let activeSessionDriver { + return try await Self.fetchRoutines(driver: activeSessionDriver, schema: normalizedSchema) + } + return try await MetadataConnectionPool.shared.withDriver( + connectionId: connectionId, database: database + ) { driver in + try await Self.fetchRoutines(driver: driver, schema: normalizedSchema) + } + } + routineLists[key] = list + } catch is CancellationError { + return + } catch { + Self.logger.warning( + "[tree] routine load failed connId=\(connectionId, privacy: .public) db=\(database, privacy: .public) schema=\(schema ?? "", privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + routineLists[key] = [] + } + } + + private static func fetchRoutines(driver: DatabaseDriver, schema: String?) async throws -> [RoutineInfo] { + async let procedures = driver.fetchProcedures(schema: schema) + async let functions = driver.fetchFunctions(schema: schema) + return try await procedures + functions + } + func reloadTables(connectionId: UUID, database: String, schema: String?) async { + let key = Self.tableKey(connectionId: connectionId, database: database, schema: schema) + await routineDedup.cancel(key: key) + routineLists.removeValue(forKey: key) if database == activeDatabase(for: connectionId) { guard let session = DatabaseManager.shared.session(for: connectionId), let driver = session.driver else { return } @@ -222,9 +266,9 @@ final class DatabaseTreeMetadataService { connectionId: connectionId, driver: driver, connection: session.connection ) } + await loadRoutines(connectionId: connectionId, database: database, schema: schema) return } - let key = Self.tableKey(connectionId: connectionId, database: database, schema: schema) await tableDedup.cancel(key: key) tableStates.removeValue(forKey: key) await loadTables(connectionId: connectionId, database: database, schema: schema) @@ -261,6 +305,12 @@ final class DatabaseTreeMetadataService { tableStates.removeValue(forKey: key) } + let routineKeys = routineLists.keys.filter { $0.connectionId == connectionId } + for key in routineKeys { + await routineDedup.cancel(key: key) + routineLists.removeValue(forKey: key) + } + MetadataConnectionPool.shared.closeAll(connectionId: connectionId) } @@ -277,6 +327,14 @@ final class DatabaseTreeMetadataService { tableStates.removeValue(forKey: key) } + let routineKeys = routineLists.keys.filter { + $0.connectionId == connectionId && $0.database == database + } + for key in routineKeys { + await routineDedup.cancel(key: key) + routineLists.removeValue(forKey: key) + } + MetadataConnectionPool.shared.invalidate(connectionId: connectionId, database: database) } @@ -286,6 +344,11 @@ final class DatabaseTreeMetadataService { return value.isEmpty ? nil : value } + private func isReconnecting(_ connectionId: UUID) -> Bool { + if case .connecting = DatabaseManager.shared.session(for: connectionId)?.status { return true } + return false + } + private static func tableKey(connectionId: UUID, database: String, schema: String?) -> TableKey { let normalized: String? = (schema?.isEmpty == true) ? nil : schema return TableKey(connectionId: connectionId, database: database, schema: normalized) diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index ca21056ee..01e7751d1 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -137,7 +137,16 @@ struct DatabaseTreeView: View { } private var reconcileKey: String { - "\(committedActiveDatabase ?? "")|\(committedActiveSchema ?? "")|\(schemaGenerationToken)" + "\(committedActiveDatabase ?? "")|\(committedActiveSchema ?? "")|\(schemaGenerationToken)|\(connectionStatusToken)" + } + + private var connectionStatusToken: String { + switch DatabaseManager.shared.session(for: connectionId)?.status { + case .connected: return "connected" + case .connecting: return "connecting" + case .error: return "error" + case .disconnected, .none: return "disconnected" + } } private var treeList: some View { From 186f2cc51f10cc49baa536a34e7b6452de86475b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 29 May 2026 18:34:16 +0700 Subject: [PATCH 09/12] fix(sidebar): retry failed tree loads after reconnect instead of blocking loads (#139) --- .../Query/DatabaseTreeMetadataService.swift | 7 --- TablePro/Views/Sidebar/DatabaseTreeView.swift | 49 +++++++++++++------ 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index e07eb7ef9..dfeb536f1 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -150,7 +150,6 @@ final class DatabaseTreeMetadataService { func loadSchemaList(connectionId: UUID, database: String) async { if database == activeDatabase(for: connectionId) { return } - guard !isReconnecting(connectionId) else { return } let key = DatabaseKey(connectionId: connectionId, database: database) if case .loaded = schemaListStates[key] { return } schemaListStates[key] = .loading @@ -174,7 +173,6 @@ final class DatabaseTreeMetadataService { } func loadTables(connectionId: UUID, database: String, schema: String?) async { - guard !isReconnecting(connectionId) else { return } await loadRoutines(connectionId: connectionId, database: database, schema: schema) if database == activeDatabase(for: connectionId) { guard let session = DatabaseManager.shared.session(for: connectionId), @@ -214,7 +212,6 @@ final class DatabaseTreeMetadataService { } func loadRoutines(connectionId: UUID, database: String, schema: String?) async { - guard !isReconnecting(connectionId) else { return } let key = Self.tableKey(connectionId: connectionId, database: database, schema: schema) if routineLists[key] != nil { return } let normalizedSchema = key.schema @@ -344,10 +341,6 @@ final class DatabaseTreeMetadataService { return value.isEmpty ? nil : value } - private func isReconnecting(_ connectionId: UUID) -> Bool { - if case .connecting = DatabaseManager.shared.session(for: connectionId)?.status { return true } - return false - } private static func tableKey(connectionId: UUID, database: String, schema: String?) -> TableKey { let normalized: String? = (schema?.isEmpty == true) ? nil : schema diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index 01e7751d1..28820e69e 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -118,13 +118,16 @@ struct DatabaseTreeView: View { .onAppear { loadDatabasesIfNeeded() expandActive() - reconcileLoads() + reconcileLoads(retryFailed: false) } .onChange(of: activeContextKey) { _, _ in expandActive() } .onChange(of: reconcileKey) { _, _ in - reconcileLoads() + reconcileLoads(retryFailed: true) + } + .onChange(of: schemaGenerationToken) { _, _ in + reconcileLoads(retryFailed: false) } .onChange(of: localSelection) { oldRefs, newRefs in guard let ref = SelectionDelta.singleAddition(old: oldRefs, new: newRefs) else { return } @@ -137,7 +140,7 @@ struct DatabaseTreeView: View { } private var reconcileKey: String { - "\(committedActiveDatabase ?? "")|\(committedActiveSchema ?? "")|\(schemaGenerationToken)|\(connectionStatusToken)" + "\(committedActiveDatabase ?? "")|\(committedActiveSchema ?? "")|\(connectionStatusToken)" } private var connectionStatusToken: String { @@ -457,37 +460,51 @@ struct DatabaseTreeView: View { } } - private func reconcileLoads() { + private func reconcileLoads(retryFailed: Bool) { guard case .loaded = treeService.databaseListState(for: connectionId) else { return } if let active = committedActiveDatabase { - ensureContentLoaded(database: active, schema: supportsSchemaLevel ? committedActiveSchema : nil) + ensureContentLoaded( + database: active, + schema: supportsSchemaLevel ? committedActiveSchema : nil, + retryFailed: retryFailed + ) } for database in windowState.expandedTreeDatabases { guard !supportsSchemaLevel else { - ensureSchemaListLoaded(database: database) + ensureSchemaListLoaded(database: database, retryFailed: retryFailed) let expandedSchemas = windowState.expandedTreeDatabaseSchemas .filter { $0.database == database } for key in expandedSchemas { - ensureContentLoaded(database: database, schema: key.schema) + ensureContentLoaded(database: database, schema: key.schema, retryFailed: retryFailed) } continue } - ensureContentLoaded(database: database, schema: nil) + ensureContentLoaded(database: database, schema: nil, retryFailed: retryFailed) } } - private func ensureSchemaListLoaded(database: String) { - guard case .idle = treeService.schemaListState(connectionId: connectionId, database: database) else { return } - loadDatabaseContentIfNeeded(database) + private func ensureSchemaListLoaded(database: String, retryFailed: Bool) { + switch treeService.schemaListState(connectionId: connectionId, database: database) { + case .idle: + loadDatabaseContentIfNeeded(database) + case .failed where retryFailed: + loadDatabaseContentIfNeeded(database) + case .loading, .loaded, .failed: + return + } } - private func ensureContentLoaded(database: String, schema: String?) { - guard case .idle = treeService.tableState( - connectionId: connectionId, database: database, schema: schema - ) else { return } - loadTablesIfNeeded(database: database, schema: schema) + private func ensureContentLoaded(database: String, schema: String?, retryFailed: Bool) { + switch treeService.tableState(connectionId: connectionId, database: database, schema: schema) { + case .idle: + loadTablesIfNeeded(database: database, schema: schema) + case .failed where retryFailed: + loadTablesIfNeeded(database: database, schema: schema) + case .loading, .loaded, .failed: + return + } } private func loadDatabaseContentIfNeeded(_ database: String) { From 3056d9c2fc9d075dd5aaca40e99971d2d3d26213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 17:09:58 +0700 Subject: [PATCH 10/12] fix(perf): open tables without waiting behind background schema introspection --- CHANGELOG.md | 1 + .../Core/Autocomplete/SQLSchemaProvider.swift | 42 ++++-- .../QueryExecutionCoordinator+Helpers.swift | 34 ++--- .../Database/DatabaseManager+Metadata.swift | 25 ++++ .../Query/MetadataConnectionPool.swift | 52 ++++++-- .../Core/Services/Query/QueryExecutor.swift | 28 ++-- .../Query/SchemaProviderRegistry.swift | 14 +- .../AIChatViewModel+SchemaContext.swift | 24 ++-- .../DatabaseSwitcherViewModel.swift | 12 +- TablePro/ViewModels/ERDiagramViewModel.swift | 12 +- .../ViewModels/QuickSwitcherViewModel.swift | 50 +++---- TablePro/Views/Export/ExportDialog.swift | 126 +++++++++--------- ...nContentCoordinator+ColumnFetchScope.swift | 8 +- ...ainContentCoordinator+SidebarActions.swift | 5 +- .../Views/Main/MainContentCoordinator.swift | 12 +- .../TableStructureView+DataLoading.swift | 39 +++--- .../Structure/TableStructureView+Schema.swift | 13 +- .../Autocomplete/SQLSchemaProviderTests.swift | 36 ++++- 18 files changed, 335 insertions(+), 198 deletions(-) create mode 100644 TablePro/Core/Database/DatabaseManager+Metadata.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 96763b413..33ead989d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Moving a connection into or out of a group now syncs across devices, instead of leaving it ungrouped on your other Macs. +- Opening a table on a connection with many tables no longer stalls for several seconds while autocomplete and table metadata load. Background schema introspection now runs on separate connections instead of waiting behind, or blocking, the query that fills the grid. (#1483) ## [0.46.0] - 2026-05-28 diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index 9bb028bf6..b17bafb84 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -25,12 +25,19 @@ actor SQLSchemaProvider { private var loadTask: Task? private var eagerColumnTask: Task? - // Store a weak driver reference to avoid retaining it after disconnect (MEM-9) - private weak var cachedDriver: (any DatabaseDriver)? + struct ColumnMetadataSource: Sendable { + let fetchColumns: @Sendable (_ table: String) async throws -> [ColumnInfo] + let fetchAllColumns: @Sendable () async throws -> [String: [ColumnInfo]] + } - // Store connection info for reference + private weak var cachedDriver: (any DatabaseDriver)? + private let metadataSource: ColumnMetadataSource? private var connectionInfo: DatabaseConnection? + init(metadataSource: ColumnMetadataSource? = nil) { + self.metadataSource = metadataSource + } + // MARK: - Public API /// Load schema from the database (driver should already be connected). @@ -97,12 +104,15 @@ actor SQLSchemaProvider { return cached } - guard let driver = cachedDriver else { - return [] - } - do { - let columns = try await driver.fetchColumns(table: tableName) + let columns: [ColumnInfo] + if let metadataSource { + columns = try await metadataSource.fetchColumns(tableName) + } else if let driver = cachedDriver { + columns = try await driver.fetchColumns(table: tableName) + } else { + return [] + } columnCache[key] = columns columnAccessOrder.append(key) evictIfNeeded() @@ -168,13 +178,23 @@ actor SQLSchemaProvider { // MARK: - Eager Column Loading private func startEagerColumnLoad() { - guard !tables.isEmpty, let driver = cachedDriver else { return } + guard !tables.isEmpty else { return } + let source = metadataSource + let driver = cachedDriver + guard source != nil || driver != nil else { return } eagerColumnTask?.cancel() let tableCount = tables.count - eagerColumnTask = Task { + eagerColumnTask = Task(priority: .utility) { Self.logger.info("[schema] eager column load starting tableCount=\(tableCount)") do { - let allColumns = try await driver.fetchAllColumns() + let allColumns: [String: [ColumnInfo]] + if let source { + allColumns = try await source.fetchAllColumns() + } else if let driver { + allColumns = try await driver.fetchAllColumns() + } else { + return + } guard !Task.isCancelled else { return } self.populateColumnCache(allColumns) Self.logger.info("[schema] eager column load complete cachedCount=\(self.columnCache.count)") diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index cb510ec27..50e9ccb4a 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -261,8 +261,7 @@ extension QueryExecutionCoordinator { let isNonSQL = PluginManager.shared.editorLanguage(for: connectionType) != .sql guard !isNonSQL else { return } - guard let enumDriver = DatabaseManager.shared.driver(for: parent.connectionId) else { return } - Task(priority: .background) { [weak self, parent] in + Task(priority: .utility) { [weak self, parent] in guard let self else { return } guard !parent.isTearingDown else { return } @@ -270,17 +269,14 @@ extension QueryExecutionCoordinator { if let schema = schemaResult { columnInfo = schema.columnInfo } else { - do { - columnInfo = try await enumDriver.fetchColumns(table: tableName) - } catch { - columnInfo = [] - } + columnInfo = (try? await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId) { driver in + try await driver.fetchColumns(table: tableName) + }) ?? [] } let columnEnumValues = await parent.fetchEnumValues( columnInfo: columnInfo, tableName: tableName, - driver: enumDriver, connectionType: connectionType ) @@ -336,10 +332,9 @@ extension QueryExecutionCoordinator { ) { let isNonSQL = PluginManager.shared.editorLanguage(for: connectionType) != .sql - Task(priority: .background) { [weak self, parent] in + Task(priority: .utility) { [weak self, parent] in guard let self else { return } guard !parent.isTearingDown else { return } - guard let driver = DatabaseManager.shared.driver(for: parent.connectionId) else { return } let prepared: (plan: RowCountPlan, sql: String?) = await MainActor.run { guard let tab = parent.tabManager.tabs.first(where: { $0.id == tabId }) else { return (.skip, nil) } @@ -366,24 +361,33 @@ extension QueryExecutionCoordinator { case .clear: outcome = .clear case .approximate: - guard let count = try? await driver.fetchApproximateRowCount(table: tableName) else { return } + guard let count = try? await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId, { driver in + try await driver.fetchApproximateRowCount(table: tableName) + }) else { return } outcome = .count(count, isApproximate: true) case let .filteredNonSQL(filters, logicMode): - if let count = try? await driver.fetchFilteredRowCount(table: tableName, filters: filters, logicMode: logicMode) { + if let count = try? await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId, { driver in + try await driver.fetchFilteredRowCount(table: tableName, filters: filters, logicMode: logicMode) + }) { outcome = .count(count, isApproximate: false) } else { outcome = .clear } case .exactCount: guard let sql = prepared.sql else { return } + let count: Int? do { - let result = try await driver.execute(query: sql) - guard let countStr = result.rows.first?.first?.asText, let count = Int(countStr) else { return } - outcome = .count(count, isApproximate: false) + count = try await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId) { driver in + let result = try await driver.execute(query: sql) + guard let countStr = result.rows.first?.first?.asText else { return Int?.none } + return Int(countStr) + } } catch { helpersLogger.warning("COUNT query failed for \(tableName): \(error.localizedDescription)") return } + guard let count else { return } + outcome = .count(count, isApproximate: false) } await MainActor.run { diff --git a/TablePro/Core/Database/DatabaseManager+Metadata.swift b/TablePro/Core/Database/DatabaseManager+Metadata.swift new file mode 100644 index 000000000..31ae6b95f --- /dev/null +++ b/TablePro/Core/Database/DatabaseManager+Metadata.swift @@ -0,0 +1,25 @@ +// +// DatabaseManager+Metadata.swift +// TablePro +// + +import Foundation + +extension DatabaseManager { + func withMetadataDriver( + connectionId: UUID, + workload: MetadataConnectionPool.Workload = .interactive, + _ body: @Sendable @escaping (DatabaseDriver) async throws -> T + ) async throws -> T { + guard let session = session(for: connectionId) else { + throw DatabaseError.notConnected + } + return try await MetadataConnectionPool.shared.withDriver( + connectionId: connectionId, + database: session.activeDatabase, + schema: session.currentSchema, + workload: workload, + body + ) + } +} diff --git a/TablePro/Core/Services/Query/MetadataConnectionPool.swift b/TablePro/Core/Services/Query/MetadataConnectionPool.swift index 2f0034701..716b78801 100644 --- a/TablePro/Core/Services/Query/MetadataConnectionPool.swift +++ b/TablePro/Core/Services/Query/MetadataConnectionPool.swift @@ -10,9 +10,16 @@ import os final class MetadataConnectionPool { static let shared = MetadataConnectionPool() + enum Workload: Hashable, Sendable { + case interactive + case bulk + } + private struct Key: Hashable, Sendable { let connectionId: UUID let database: String + let schema: String? + let workload: Workload } private final class Entry { @@ -33,7 +40,7 @@ final class MetadataConnectionPool { private var entries: [Key: Entry] = [:] private var pending: [Key: Task] = [:] - private let maxPerConnection = 4 + private let maxPerConnection = 6 private let connectTimeoutSeconds: UInt64 = 15 private static let logger = Logger(subsystem: "com.TablePro", category: "MetadataConnectionPool") @@ -42,9 +49,13 @@ final class MetadataConnectionPool { func withDriver( connectionId: UUID, database: String, + schema: String? = nil, + workload: Workload = .interactive, _ body: @Sendable @escaping (DatabaseDriver) async throws -> T ) async throws -> T { - let entry = try await acquireEntry(connectionId: connectionId, database: database) + let entry = try await acquireEntry( + connectionId: connectionId, database: database, schema: schema, workload: workload + ) entry.inFlightCount += 1 entry.lastUsed = Date() defer { releaseEntry(entry) } @@ -66,10 +77,13 @@ final class MetadataConnectionPool { } func invalidate(connectionId: UUID, database: String) { - let key = Key(connectionId: connectionId, database: database) - pending[key]?.cancel() - pending.removeValue(forKey: key) - closeOrDeferEntry(forKey: key) + let matching = Set(entries.keys.filter { $0.connectionId == connectionId && $0.database == database }) + .union(pending.keys.filter { $0.connectionId == connectionId && $0.database == database }) + for key in matching { + pending[key]?.cancel() + pending.removeValue(forKey: key) + closeOrDeferEntry(forKey: key) + } } func closeAll(connectionId: UUID) { @@ -98,8 +112,10 @@ final class MetadataConnectionPool { } } - private func acquireEntry(connectionId: UUID, database: String) async throws -> Entry { - let key = Key(connectionId: connectionId, database: database) + private func acquireEntry( + connectionId: UUID, database: String, schema: String?, workload: Workload + ) async throws -> Entry { + let key = Key(connectionId: connectionId, database: database, schema: schema, workload: workload) if let entry = entries[key], entry.driver.status == .connected { return entry } @@ -152,6 +168,26 @@ final class MetadataConnectionPool { ) do { try await connectWithTimeout(driver: driver, database: key.database) + do { + try await driver.applyQueryTimeout(AppSettingsManager.shared.general.queryTimeoutSeconds) + } catch { + Self.logger.warning( + "[metadata-pool] query timeout not applied connId=\(key.connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + } + await DatabaseManager.shared.executeStartupCommands( + session.connection.startupCommands, on: driver, connectionName: session.connection.name + ) + if let schema = key.schema, let switchable = driver as? SchemaSwitchable { + do { + try await switchable.switchSchema(to: schema) + } catch { + Self.logger.warning( + "[metadata-pool] schema switch failed, discarding connection connId=\(key.connectionId, privacy: .public) schema=\(schema, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + throw error + } + } } catch { driver.disconnect() throw error diff --git a/TablePro/Core/Services/Query/QueryExecutor.swift b/TablePro/Core/Services/Query/QueryExecutor.swift index b0af3e761..7b20bfa8f 100644 --- a/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/TablePro/Core/Services/Query/QueryExecutor.swift @@ -63,18 +63,12 @@ final class QueryExecutor { var parallelSchemaTask: Task? if fetchSchemaForTable, let tableName, !tableName.isEmpty { parallelSchemaTask = Task { - guard let driver = DatabaseManager.shared.driver(for: connId) else { - throw DatabaseError.notConnected + try await DatabaseManager.shared.withMetadataDriver(connectionId: connId) { driver in + let cols = try await driver.fetchColumns(table: tableName) + let fks = try await driver.fetchForeignKeys(table: tableName) + let approxCount = try? await driver.fetchApproximateRowCount(table: tableName) + return (columnInfo: cols, fkInfo: fks, approximateRowCount: approxCount) } - async let cols = driver.fetchColumns(table: tableName) - async let fks = driver.fetchForeignKeys(table: tableName) - let result = try await (columnInfo: cols, fkInfo: fks) - let approxCount = try? await driver.fetchApproximateRowCount(table: tableName) - return ( - columnInfo: result.columnInfo, - fkInfo: result.fkInfo, - approximateRowCount: approxCount - ) } } @@ -174,13 +168,13 @@ final class QueryExecutor { if let parallelTask { return try? await parallelTask.value } - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return nil } do { - async let cols = driver.fetchColumns(table: tableName) - async let fks = driver.fetchForeignKeys(table: tableName) - let (c, f) = try await (cols, fks) - let approxCount = try? await driver.fetchApproximateRowCount(table: tableName) - return (columnInfo: c, fkInfo: f, approximateRowCount: approxCount) + return try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId) { driver in + let cols = try await driver.fetchColumns(table: tableName) + let fks = try await driver.fetchForeignKeys(table: tableName) + let approxCount = try? await driver.fetchApproximateRowCount(table: tableName) + return (columnInfo: cols, fkInfo: fks, approximateRowCount: approxCount) + } } catch { queryExecutorLog.error("Phase 2 schema fetch failed: \(error.localizedDescription, privacy: .public)") return nil diff --git a/TablePro/Core/Services/Query/SchemaProviderRegistry.swift b/TablePro/Core/Services/Query/SchemaProviderRegistry.swift index a44136b16..bcc5ab13b 100644 --- a/TablePro/Core/Services/Query/SchemaProviderRegistry.swift +++ b/TablePro/Core/Services/Query/SchemaProviderRegistry.swift @@ -63,7 +63,19 @@ final class SchemaProviderRegistry { if let existing = providers[connectionId] { return existing } - let provider = SQLSchemaProvider() + let source = SQLSchemaProvider.ColumnMetadataSource( + fetchColumns: { table in + try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId) { driver in + try await driver.fetchColumns(table: table) + } + }, + fetchAllColumns: { + try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId, workload: .bulk) { driver in + try await driver.fetchAllColumns() + } + } + ) + let provider = SQLSchemaProvider(metadataSource: source) providers[connectionId] = provider return provider } diff --git a/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift b/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift index e3883d73d..8906ad01b 100644 --- a/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift +++ b/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift @@ -29,19 +29,23 @@ extension AIChatViewModel { await inFlight.value return } - guard let connection, - let driver = services.databaseManager.driver(for: connection.id) else { return } + guard let connection else { return } + let connId = connection.id let task: Task = Task { [weak self] in let columns: [ColumnInfo] do { - columns = try await driver.fetchColumns(table: tableName) + columns = try await DatabaseManager.shared.withMetadataDriver(connectionId: connId) { driver in + try await driver.fetchColumns(table: tableName) + } } catch { Self.logger.warning("Column fetch failed for \(tableName, privacy: .public): \(error.localizedDescription, privacy: .public)") columns = [] } let fkMap: [String: [ForeignKeyInfo]] do { - fkMap = try await driver.fetchForeignKeys(forTables: [tableName]) + fkMap = try await DatabaseManager.shared.withMetadataDriver(connectionId: connId) { driver in + try await driver.fetchForeignKeys(forTables: [tableName]) + } } catch { Self.logger.warning("Foreign key fetch failed for \(tableName, privacy: .public): \(error.localizedDescription, privacy: .public)") fkMap = [:] @@ -92,8 +96,8 @@ extension AIChatViewModel { } private func runSchemaLoad() async { - guard let connection, - let driver = services.databaseManager.driver(for: connection.id) else { return } + guard let connection else { return } + let connId = connection.id let settings = services.appSettings.ai let tablesToFetch = Array(tables.prefix(settings.maxSchemaTables)) guard !tablesToFetch.isEmpty else { return } @@ -103,7 +107,9 @@ extension AIChatViewModel { let name = table.name group.addTask { do { - let cols = try await driver.fetchColumns(table: name) + let cols = try await DatabaseManager.shared.withMetadataDriver(connectionId: connId, workload: .bulk) { driver in + try await driver.fetchColumns(table: name) + } return (name, cols) } catch { Self.logger.warning("Schema column fetch failed for \(name, privacy: .public): \(error.localizedDescription, privacy: .public)") @@ -121,7 +127,9 @@ extension AIChatViewModel { let needsFKFetch = tablesToFetch.contains { foreignKeysByTable[$0.name] == nil } guard needsFKFetch else { return } do { - let fkMap = try await driver.fetchForeignKeys(forTables: tablesToFetch.map(\.name)) + let fkMap = try await DatabaseManager.shared.withMetadataDriver(connectionId: connId, workload: .bulk) { driver in + try await driver.fetchForeignKeys(forTables: tablesToFetch.map(\.name)) + } for (name, fks) in fkMap { foreignKeysByTable[name] = fks } diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index 638b1b381..9849990bf 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -52,13 +52,9 @@ final class DatabaseSwitcherViewModel { errorMessage = nil do { - guard let driver = services.databaseManager.driver(for: connectionId) else { - errorMessage = String(localized: "No active connection") - isLoading = false - return + let dbNames = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in + try await driver.fetchDatabases() } - - let dbNames = try await driver.fetchDatabases() databases = dbNames.sorted().map { name in DatabaseMetadata.minimal(name: name, isSystem: isSystemItem(name)) } @@ -67,7 +63,9 @@ final class DatabaseSwitcherViewModel { isLoading = false do { - let metadataList = try await driver.fetchAllDatabaseMetadata() + let metadataList = try await services.databaseManager.withMetadataDriver(connectionId: connectionId, workload: .bulk) { driver in + try await driver.fetchAllDatabaseMetadata() + } databases = metadataList.sorted { $0.name < $1.name } preselectDatabase() } catch { diff --git a/TablePro/ViewModels/ERDiagramViewModel.swift b/TablePro/ViewModels/ERDiagramViewModel.swift index c978fe8ac..db3d86440 100644 --- a/TablePro/ViewModels/ERDiagramViewModel.swift +++ b/TablePro/ViewModels/ERDiagramViewModel.swift @@ -95,15 +95,19 @@ final class ERDiagramViewModel { await waitForConnection() } - guard let driver = services.databaseManager.driver(for: connectionId) else { + guard services.databaseManager.driver(for: connectionId) != nil else { loadState = .failed(String(localized: "No database connection")) return } do { - async let columnsResult = driver.fetchAllColumns() - async let fksResult = driver.fetchAllForeignKeys() - let (allColumns, allFKs) = try await (columnsResult, fksResult) + let (allColumns, allFKs) = try await services.databaseManager.withMetadataDriver( + connectionId: connectionId, workload: .bulk + ) { driver in + let cols = try await driver.fetchAllColumns() + let fks = try await driver.fetchAllForeignKeys() + return (cols, fks) + } let builtGraph = ERDiagramGraphBuilder.build( allColumns: allColumns, diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 4e84b93c4..da6c16107 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -104,35 +104,37 @@ internal final class QuickSwitcherViewModel { )) } - if let driver = services.databaseManager.driver(for: connectionId) { + do { + let databases = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in + try await driver.fetchDatabases() + } + for db in databases { + items.append(QuickSwitcherItem( + id: "db_\(db)", + name: db, + kind: .database, + subtitle: String(localized: "Database") + )) + } + } catch { + Self.logger.warning("Failed to fetch databases: \(error.localizedDescription, privacy: .public)") + } + + if services.pluginManager.supportsSchemaSwitching(for: databaseType) { do { - let databases = try await driver.fetchDatabases() - for db in databases { + let schemas = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in + try await driver.fetchSchemas() + } + for schema in schemas { items.append(QuickSwitcherItem( - id: "db_\(db)", - name: db, - kind: .database, - subtitle: String(localized: "Database") + id: "schema_\(schema)", + name: schema, + kind: .schema, + subtitle: String(localized: "Schema") )) } } catch { - Self.logger.warning("Failed to fetch databases: \(error.localizedDescription, privacy: .public)") - } - - if services.pluginManager.supportsSchemaSwitching(for: databaseType) { - do { - let schemas = try await driver.fetchSchemas() - for schema in schemas { - items.append(QuickSwitcherItem( - id: "schema_\(schema)", - name: schema, - kind: .schema, - subtitle: String(localized: "Schema") - )) - } - } catch { - Self.logger.warning("Failed to fetch schemas: \(error.localizedDescription, privacy: .public)") - } + Self.logger.warning("Failed to fetch schemas: \(error.localizedDescription, privacy: .public)") } } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 5937681f9..d826ec29f 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -550,16 +550,6 @@ struct ExportDialog: View { @MainActor private func loadDatabaseItems() async { - guard let driver = DatabaseManager.shared.driver(for: connection.id) else { - isLoading = false - AlertHelper.showErrorSheet( - title: String(localized: "Export Error"), - message: String(localized: "Not connected to database"), - window: nil - ) - return - } - // Snapshot user-toggled selections before replacing items let priorSelections = currentSelectionState() @@ -570,10 +560,12 @@ struct ExportDialog: View { let grouping = PluginManager.shared.databaseGroupingStrategy(for: dbType) switch grouping { case .bySchema, .hierarchicalSchema: - let schemas = try await driver.fetchSchemas() + let schemas = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in + try await driver.fetchSchemas() + } let defaultSchema = PluginManager.shared.defaultSchemaName(for: dbType) for schema in schemas { - let tables = try await fetchTablesForSchema(schema, driver: driver) + let tables = try await fetchTablesForSchema(schema) let isDefaultSchema = schema.caseInsensitiveCompare(defaultSchema) == .orderedSame let tableItems = tables.map { table in let key = "\(schema).\(table.name)" @@ -602,15 +594,16 @@ struct ExportDialog: View { case .flat: let fallbackName = PluginManager.shared.defaultGroupName(for: dbType) let dbItem = try await buildFlatDatabaseItem( - driver: driver, name: connection.database.isEmpty ? fallbackName : connection.database, priorSelections: priorSelections ) if let dbItem { items.append(dbItem) } case .byDatabase: - let databases = try await driver.fetchDatabases() + let databases = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in + try await driver.fetchDatabases() + } for dbName in databases { - let tables = try await fetchTablesForDatabase(dbName, driver: driver) + let tables = try await fetchTablesForDatabase(dbName) let isCurrentDB = dbName == connection.database let tableItems = tables.map { table in let key = "\(dbName).\(table.name)" @@ -658,11 +651,12 @@ struct ExportDialog: View { } private func buildFlatDatabaseItem( - driver: DatabaseDriver, name: String, priorSelections: [String: Bool] = [:] ) async throws -> ExportDatabaseItem? { - let tables = try await driver.fetchTables() + let tables = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in + try await driver.fetchTables() + } let tableItems = tables.map { table in let key = "\(name).\(table.name)" let selected = priorSelections[key] ?? preselectedTables.contains(table.name) @@ -677,64 +671,66 @@ struct ExportDialog: View { return ExportDatabaseItem(name: name, tables: tableItems, isExpanded: true) } - private func fetchTablesForSchema(_ schema: String, driver: DatabaseDriver) async throws -> [TableInfo] { - // Oracle does not have information_schema — use ALL_TABLES/ALL_VIEWS - if connection.type.pluginTypeId == "Oracle" { - let escapedSchema = schema.replacingOccurrences(of: "'", with: "''") + private func fetchTablesForSchema(_ schema: String) async throws -> [TableInfo] { + let isOracle = connection.type.pluginTypeId == "Oracle" + return try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in + if isOracle { + let escapedSchema = schema.replacingOccurrences(of: "'", with: "''") + let query = """ + SELECT TABLE_NAME, 'BASE TABLE' AS TABLE_TYPE FROM ALL_TABLES WHERE OWNER = '\(escapedSchema)' + UNION ALL + SELECT VIEW_NAME, 'VIEW' FROM ALL_VIEWS WHERE OWNER = '\(escapedSchema)' + ORDER BY 1 + """ + let result = try await driver.execute(query: query) + return result.rows.compactMap { row -> TableInfo? in + guard let name = row[safe: 0]?.asText else { return nil } + let typeStr = row[safe: 1]?.asText ?? "BASE TABLE" + let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table + return TableInfo(name: name, type: type, rowCount: nil) + } + } + let query = """ - SELECT TABLE_NAME, 'BASE TABLE' AS TABLE_TYPE FROM ALL_TABLES WHERE OWNER = '\(escapedSchema)' - UNION ALL - SELECT VIEW_NAME, 'VIEW' FROM ALL_VIEWS WHERE OWNER = '\(escapedSchema)' - ORDER BY 1 + SELECT table_schema, table_name, table_type + FROM information_schema.tables + ORDER BY table_name """ let result = try await driver.execute(query: query) return result.rows.compactMap { row -> TableInfo? in - guard let name = row[safe: 0]?.asText else { return nil } - let typeStr = row[safe: 1]?.asText ?? "BASE TABLE" + guard row.count >= 2, + let rowSchema = row[0].asText, + rowSchema == schema, + let name = row[1].asText else { + return nil + } + let typeStr = row.count > 2 ? (row[2].asText ?? "BASE TABLE") : "BASE TABLE" let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table return TableInfo(name: name, type: type, rowCount: nil) } } + } - let query = """ - SELECT table_schema, table_name, table_type - FROM information_schema.tables - ORDER BY table_name - """ - let result = try await driver.execute(query: query) - return result.rows.compactMap { row -> TableInfo? in - guard row.count >= 2, - let rowSchema = row[0].asText, - rowSchema == schema, - let name = row[1].asText else { - return nil - } - let typeStr = row.count > 2 ? (row[2].asText ?? "BASE TABLE") : "BASE TABLE" - let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table - return TableInfo(name: name, type: type, rowCount: nil) - } - } - - private func fetchTablesForDatabase(_ database: String, driver: DatabaseDriver) async throws -> [TableInfo] { - // Fetch tables from information_schema and filter by database in Swift to avoid SQL interpolation. - // MySQL/MariaDB: information_schema.TABLES contains TABLE_SCHEMA, TABLE_NAME, and TABLE_TYPE. - let query = """ - SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE - FROM information_schema.TABLES - ORDER BY TABLE_NAME - """ - let result = try await driver.execute(query: query) - - return result.rows.compactMap { row -> TableInfo? in - guard row.count >= 2, - let rowSchema = row[0].asText, - rowSchema == database, - let name = row[1].asText else { - return nil + private func fetchTablesForDatabase(_ database: String) async throws -> [TableInfo] { + try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in + let query = """ + SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE + FROM information_schema.TABLES + ORDER BY TABLE_NAME + """ + let result = try await driver.execute(query: query) + + return result.rows.compactMap { row -> TableInfo? in + guard row.count >= 2, + let rowSchema = row[0].asText, + rowSchema == database, + let name = row[1].asText else { + return nil + } + let typeStr = row.count > 2 ? (row[2].asText ?? "BASE TABLE") : "BASE TABLE" + let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table + return TableInfo(name: name, type: type, rowCount: nil) } - let typeStr = row.count > 2 ? (row[2].asText ?? "BASE TABLE") : "BASE TABLE" - let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table - return TableInfo(name: name, type: type, rowCount: nil) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift index c7b0b8b54..2c52cc719 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift @@ -43,12 +43,10 @@ extension MainContentCoordinator { func loadSchemaColumns(for tableName: String, schema: String?) async { let key = schemaColumnsKey(tableName, schema: schema) guard schemaColumnsCache[key] == nil else { return } - guard let driver = services.databaseManager.driver(for: connectionId) else { - columnScopeLog.error("loadSchemaColumns: no driver for connection; cannot scope columns for table=\(tableName, privacy: .public)") - return - } do { - let columns = try await driver.fetchColumns(table: tableName, schema: schema) + let columns = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in + try await driver.fetchColumns(table: tableName, schema: schema) + } guard !columns.isEmpty else { columnScopeLog.error("loadSchemaColumns: 0 columns for table=\(tableName, privacy: .public); cannot scope") return diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index 4c3818f6a..074390a7a 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -98,8 +98,9 @@ extension MainContentCoordinator { func editViewDefinition(_ viewName: String) { Task { do { - guard let driver = DatabaseManager.shared.driver(for: self.connection.id) else { return } - let definition = try await driver.fetchViewDefinition(view: viewName) + let definition = try await DatabaseManager.shared.withMetadataDriver(connectionId: self.connection.id) { driver in + try await driver.fetchViewDefinition(view: viewName) + } let payload = EditorTabPayload( connectionId: connection.id, diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index a5d2da8a6..40c9ca36a 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -740,10 +740,10 @@ final class MainContentCoordinator { } func loadTableMetadata(tableName: String) async { - guard let driver = services.databaseManager.driver(for: connectionId) else { return } - do { - let metadata = try await driver.fetchTableMetadata(tableName: tableName) + let metadata = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in + try await driver.fetchTableMetadata(tableName: tableName) + } self.tableMetadata = metadata } catch { Self.logger.error("Failed to load table metadata: \(error.localizedDescription, privacy: .public)") @@ -1162,7 +1162,6 @@ final class MainContentCoordinator { func fetchEnumValues( columnInfo: [ColumnInfo], tableName: String, - driver: DatabaseDriver, connectionType: DatabaseType ) async -> [String: [String]] { var result: [String: [String]] = [:] @@ -1173,7 +1172,10 @@ final class MainContentCoordinator { } } - if result.isEmpty, let createSQL = try? await driver.fetchTableDDL(table: tableName) { + if result.isEmpty, + let createSQL = try? await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId, { driver in + try await driver.fetchTableDDL(table: tableName) + }) { for col in columnInfo { if let values = QuerySqlParser.parseSQLiteCheckConstraintValues( createSQL: createSQL, columnName: col.name diff --git a/TablePro/Views/Structure/TableStructureView+DataLoading.swift b/TablePro/Views/Structure/TableStructureView+DataLoading.swift index 028ced7ce..920ac4470 100644 --- a/TablePro/Views/Structure/TableStructureView+DataLoading.swift +++ b/TablePro/Views/Structure/TableStructureView+DataLoading.swift @@ -28,14 +28,10 @@ extension TableStructureView { isLoading = true errorMessage = nil - guard let driver = DatabaseManager.shared.driver(for: connection.id) else { - errorMessage = String(localized: "Not connected") - isLoading = false - return - } - do { - columns = try await driver.fetchColumns(table: tableName) + columns = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id) { driver in + try await driver.fetchColumns(table: tableName) + } loadedTabs.insert(.columns) } catch { errorMessage = error.localizedDescription @@ -50,23 +46,28 @@ extension TableStructureView { } func fetchTabData(_ tab: StructureTab) async { - guard let driver = DatabaseManager.shared.driver(for: connection.id) else { return } - do { switch tab { case .columns: - columns = try await driver.fetchColumns(table: tableName) + columns = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id) { driver in + try await driver.fetchColumns(table: tableName) + } case .indexes: - indexes = try await driver.fetchIndexes(table: tableName) + indexes = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id) { driver in + try await driver.fetchIndexes(table: tableName) + } case .foreignKeys: - foreignKeys = try await driver.fetchForeignKeys(table: tableName) + foreignKeys = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id) { driver in + try await driver.fetchForeignKeys(table: tableName) + } case .ddl: - let sequences = try await driver.fetchDependentSequences(forTable: tableName) - let enumTypes = try await driver.fetchDependentTypes(forTable: tableName) - let baseDDL = try await driver.fetchTableDDL(table: tableName) - if sequences.isEmpty && enumTypes.isEmpty { - ddlStatement = baseDDL - } else { + ddlStatement = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id) { driver in + let sequences = try await driver.fetchDependentSequences(forTable: tableName) + let enumTypes = try await driver.fetchDependentTypes(forTable: tableName) + let baseDDL = try await driver.fetchTableDDL(table: tableName) + if sequences.isEmpty && enumTypes.isEmpty { + return baseDDL + } var preamble = "" for seq in sequences { preamble += seq.ddl + "\n\n" @@ -76,7 +77,7 @@ extension TableStructureView { let quotedLabels = enumType.labels.map { "'\(SQLEscaping.escapeStringLiteral($0))'" } preamble += "CREATE TYPE \(quotedName) AS ENUM (\(quotedLabels.joined(separator: ", ")));\n" } - ddlStatement = preamble + "\n" + baseDDL + return preamble + "\n" + baseDDL } case .parts: return diff --git a/TablePro/Views/Structure/TableStructureView+Schema.swift b/TablePro/Views/Structure/TableStructureView+Schema.swift index 46f341114..177b18b4b 100644 --- a/TablePro/Views/Structure/TableStructureView+Schema.swift +++ b/TablePro/Views/Structure/TableStructureView+Schema.swift @@ -102,14 +102,15 @@ extension TableStructureView { await loadColumns() // Load indexes and foreign keys (needed for complete schema state) - guard let driver = DatabaseManager.shared.driver(for: connection.id) else { - isReloadingAfterSave = false - return - } do { - indexes = try await driver.fetchIndexes(table: tableName) + let (reloadedIndexes, reloadedFKs) = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id) { driver in + let reloadedIndexes = try await driver.fetchIndexes(table: tableName) + let reloadedFKs = try await driver.fetchForeignKeys(table: tableName) + return (reloadedIndexes, reloadedFKs) + } + indexes = reloadedIndexes loadedTabs.insert(.indexes) - foreignKeys = try await driver.fetchForeignKeys(table: tableName) + foreignKeys = reloadedFKs loadedTabs.insert(.foreignKeys) } catch { Self.logger.error("Failed to reload indexes/FKs: \(error.localizedDescription, privacy: .public)") diff --git a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift index f170f88f3..a27d1cf9a 100644 --- a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift @@ -6,9 +6,9 @@ // import Foundation +@testable import TablePro import TableProPluginKit import Testing -@testable import TablePro // MARK: - Mock Driver @@ -333,4 +333,38 @@ struct SQLSchemaProviderTests { #expect(items[0].label == "users.id") #expect(items[1].label == "orders.id") } + + @Test("getColumns uses injected metadata source instead of cached driver") + func getColumnsUsesMetadataSource() async { + let driver = MockDatabaseDriver() + driver.columnsToReturn = ["users": [TestFixtures.makeColumnInfo(name: "from_driver")]] + let source = SQLSchemaProvider.ColumnMetadataSource( + fetchColumns: { _ in [TestFixtures.makeColumnInfo(name: "from_source")] }, + fetchAllColumns: { [:] } + ) + let provider = SQLSchemaProvider(metadataSource: source) + await provider.resetForDatabase("db", tables: [TestFixtures.makeTableInfo(name: "users")], driver: driver) + + let columns = await provider.getColumns(for: "users") + #expect(columns.first?.name == "from_source") + #expect(driver.fetchColumnsCallCount == 0) + } + + @Test("eager column load uses injected metadata source instead of cached driver") + func eagerLoadUsesMetadataSource() async throws { + let driver = MockDatabaseDriver() + driver.columnsToReturn = ["users": [TestFixtures.makeColumnInfo(name: "from_driver")]] + let source = SQLSchemaProvider.ColumnMetadataSource( + fetchColumns: { _ in [TestFixtures.makeColumnInfo(name: "lazy_source")] }, + fetchAllColumns: { ["users": [TestFixtures.makeColumnInfo(name: "eager_source")]] } + ) + let provider = SQLSchemaProvider(metadataSource: source) + await provider.resetForDatabase("db", tables: [TestFixtures.makeTableInfo(name: "users")], driver: driver) + + try await Task.sleep(nanoseconds: 300_000_000) + + let columns = await provider.getColumns(for: "users") + #expect(columns.first?.name == "eager_source") + #expect(driver.fetchColumnsCallCount == 0) + } } From c037a0c15958a4db549466fd631864865a49474e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 29 May 2026 20:44:42 +0700 Subject: [PATCH 11/12] perf(datagrid): run exact and filtered row counts on the bulk metadata lane (#1483) --- .../Core/Coordinators/QueryExecutionCoordinator+Helpers.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 50e9ccb4a..e84d46022 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -366,7 +366,7 @@ extension QueryExecutionCoordinator { }) else { return } outcome = .count(count, isApproximate: true) case let .filteredNonSQL(filters, logicMode): - if let count = try? await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId, { driver in + if let count = try? await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId, workload: .bulk, { driver in try await driver.fetchFilteredRowCount(table: tableName, filters: filters, logicMode: logicMode) }) { outcome = .count(count, isApproximate: false) @@ -377,7 +377,7 @@ extension QueryExecutionCoordinator { guard let sql = prepared.sql else { return } let count: Int? do { - count = try await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId) { driver in + count = try await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId, workload: .bulk) { driver in let result = try await driver.execute(query: sql) guard let countStr = result.rows.first?.first?.asText else { return Int?.none } return Int(countStr) From 46726a6f63ca59fa7b89bc4984768e72bb8ee1ea Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 29 May 2026 20:51:16 +0700 Subject: [PATCH 12/12] refactor(query): extract duplicated table-schema sidecar fetch into one helper (#1483) --- .../Core/Services/Query/QueryExecutor.swift | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/TablePro/Core/Services/Query/QueryExecutor.swift b/TablePro/Core/Services/Query/QueryExecutor.swift index 7b20bfa8f..27c1086d2 100644 --- a/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/TablePro/Core/Services/Query/QueryExecutor.swift @@ -63,12 +63,7 @@ final class QueryExecutor { var parallelSchemaTask: Task? if fetchSchemaForTable, let tableName, !tableName.isEmpty { parallelSchemaTask = Task { - try await DatabaseManager.shared.withMetadataDriver(connectionId: connId) { driver in - let cols = try await driver.fetchColumns(table: tableName) - let fks = try await driver.fetchForeignKeys(table: tableName) - let approxCount = try? await driver.fetchApproximateRowCount(table: tableName) - return (columnInfo: cols, fkInfo: fks, approximateRowCount: approxCount) - } + try await Self.fetchTableSchema(connectionId: connId, tableName: tableName) } } @@ -169,18 +164,22 @@ final class QueryExecutor { return try? await parallelTask.value } do { - return try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId) { driver in - let cols = try await driver.fetchColumns(table: tableName) - let fks = try await driver.fetchForeignKeys(table: tableName) - let approxCount = try? await driver.fetchApproximateRowCount(table: tableName) - return (columnInfo: cols, fkInfo: fks, approximateRowCount: approxCount) - } + return try await fetchTableSchema(connectionId: connectionId, tableName: tableName) } catch { queryExecutorLog.error("Phase 2 schema fetch failed: \(error.localizedDescription, privacy: .public)") return nil } } + static func fetchTableSchema(connectionId: UUID, tableName: String) async throws -> SchemaResult { + try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId) { driver in + let columns = try await driver.fetchColumns(table: tableName) + let foreignKeys = try await driver.fetchForeignKeys(table: tableName) + let approximateRowCount = try? await driver.fetchApproximateRowCount(table: tableName) + return (columnInfo: columns, fkInfo: foreignKeys, approximateRowCount: approximateRowCount) + } + } + static func parseSchemaMetadata(_ schema: SchemaResult) -> ParsedSchemaMetadata { var defaults: [String: String?] = [:] var fks: [String: ForeignKeyInfo] = [:]