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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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 ce752abfe22602b3ff291579fea9d0fd3ee95177 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 29 May 2026 18:45:13 +0700 Subject: [PATCH 10/18] fix(sidebar): mark tree rows loading before fetching routines to stop reconcile storm (#139) --- .../Services/Query/DatabaseTreeMetadataService.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index dfeb536f1..c8731f453 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -173,7 +173,6 @@ final class DatabaseTreeMetadataService { } func loadTables(connectionId: UUID, database: String, schema: String?) async { - 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 } @@ -186,10 +185,14 @@ 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) - if case .loaded = tableStates[key] { return } + if case .loaded = tableStates[key] { + await loadRoutines(connectionId: connectionId, database: database, schema: schema) + return + } tableStates[key] = .loading do { let normalizedSchema = key.schema @@ -208,7 +211,9 @@ final class DatabaseTreeMetadataService { "[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) + return } + await loadRoutines(connectionId: connectionId, database: database, schema: schema) } func loadRoutines(connectionId: UUID, database: String, schema: String?) async { From c951b7643641677694a94fe46a0c3e34d913ee81 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 29 May 2026 18:51:04 +0700 Subject: [PATCH 11/18] fix(sidebar): distinguish overloaded routines by signature to avoid duplicate-id crash (#139) --- TablePro/Models/Query/RoutineInfo.swift | 7 +++- TablePro/Views/Sidebar/DatabaseTreeView.swift | 7 +++- TableProTests/Models/RoutineInfoTests.swift | 41 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 TableProTests/Models/RoutineInfoTests.swift diff --git a/TablePro/Models/Query/RoutineInfo.swift b/TablePro/Models/Query/RoutineInfo.swift index 88e68b94d..aaeb878f5 100644 --- a/TablePro/Models/Query/RoutineInfo.swift +++ b/TablePro/Models/Query/RoutineInfo.swift @@ -1,7 +1,12 @@ import Foundation struct RoutineInfo: Identifiable, Hashable, Sendable { - var id: String { "\(kind.rawValue)_\(qualifiedName)" } + var id: String { + guard let signature, !signature.isEmpty else { + return "\(kind.rawValue)_\(qualifiedName)" + } + return "\(kind.rawValue)_\(qualifiedName)_\(signature)" + } let name: String let schema: String? let kind: Kind diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index 28820e69e..7f1c395eb 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -416,8 +416,11 @@ struct DatabaseTreeView: View { 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) } + let matched = searchText.isEmpty + ? all + : all.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + var seen = Set() + return matched.filter { seen.insert($0.id).inserted } } private func databaseExpansionBinding(for database: String) -> Binding { diff --git a/TableProTests/Models/RoutineInfoTests.swift b/TableProTests/Models/RoutineInfoTests.swift new file mode 100644 index 000000000..2e0fef25f --- /dev/null +++ b/TableProTests/Models/RoutineInfoTests.swift @@ -0,0 +1,41 @@ +// +// RoutineInfoTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("RoutineInfo Identity") +struct RoutineInfoTests { + @Test("Overloaded functions with different signatures get distinct ids") + func overloadsAreDistinct() { + let a = RoutineInfo(name: "st_distance", schema: "public", kind: .function, signature: "(geometry, geometry)") + let b = RoutineInfo(name: "st_distance", schema: "public", kind: .function, signature: "(geography, geography)") + + #expect(a.id != b.id) + #expect(Set([a.id, b.id]).count == 2) + } + + @Test("Same routine yields a stable id") + func sameRoutineStableId() { + let a = RoutineInfo(name: "f", schema: "public", kind: .function, signature: "(int)") + let b = RoutineInfo(name: "f", schema: "public", kind: .function, signature: "(int)") + #expect(a.id == b.id) + } + + @Test("Procedure and function with the same name get distinct ids") + func procedureAndFunctionDistinct() { + let proc = RoutineInfo(name: "sync", schema: "public", kind: .procedure, signature: nil) + let fn = RoutineInfo(name: "sync", schema: "public", kind: .function, signature: nil) + #expect(proc.id != fn.id) + } + + @Test("Signatureless routine falls back to name-based id") + func signaturelessFallback() { + let routine = RoutineInfo(name: "do_thing", schema: "app", kind: .procedure, signature: nil) + #expect(routine.id == "PROCEDURE_app.do_thing") + } +} From b28d91d4efed314c5187828204d36ccc6b2d240a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 29 May 2026 19:01:08 +0700 Subject: [PATCH 12/18] fix(sidebar): defer active-db tree load until the driver reconnects to avoid Not Connected flash (#139) --- TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index c8731f453..43ea892e4 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -175,7 +175,8 @@ final class DatabaseTreeMetadataService { 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 } + let driver = session.driver, + driver.status == .connected else { return } if let schema { await SchemaService.shared.loadSchemaTables( connectionId: connectionId, schema: schema, driver: driver From fba22726d0120e0e1a992c6b946022aec0b23289 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 29 May 2026 19:01:08 +0700 Subject: [PATCH 13/18] fix(sidebar): qualify tree row ids by database to fix duplicate-id layout crash (#139) --- TablePro/Views/Sidebar/DatabaseTreeView.swift | 71 +++++++++++++------ 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index 7f1c395eb..5f5d32135 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -24,6 +24,25 @@ struct DatabaseTreeTableRef: Hashable, Identifiable { } } +struct DatabaseTreeRoutineRef: Identifiable { + let database: String + let schema: String? + let routine: RoutineInfo + + var id: String { + "\(database)|\(schema ?? "")|\(routine.id)" + } +} + +struct DatabaseTreeSchemaRef: Identifiable { + let database: String + let schema: String + + var id: String { + "\(database)|\(schema)" + } +} + struct DatabaseTreeView: View { @Bindable private var treeService = DatabaseTreeMetadataService.shared @@ -257,13 +276,13 @@ struct DatabaseTreeView: View { if visible.isEmpty { emptyRow(String(localized: "No schemas")) } else { - ForEach(visible, id: \.self) { schema in + ForEach(visible.map { DatabaseTreeSchemaRef(database: database, schema: $0) }) { ref in DisclosureGroup( - isExpanded: schemaExpansionBinding(database: database, schema: schema) + isExpanded: schemaExpansionBinding(database: ref.database, schema: ref.schema) ) { - tablesContent(database: database, schema: schema) + tablesContent(database: ref.database, schema: ref.schema) } label: { - schemaHeader(database: database, schema: schema) + schemaHeader(database: ref.database, schema: ref.schema) } } } @@ -290,19 +309,18 @@ struct DatabaseTreeView: View { if tables.isEmpty && routines.isEmpty { emptyRow(String(localized: "No items")) } else { - ForEach(tables) { table in + ForEach(tables.map { DatabaseTreeTableRef(database: database, schema: schema, table: $0) }) { ref in TableRow( - table: table, - isPendingTruncate: pendingTruncates.contains(table.name), - isPendingDelete: pendingDeletes.contains(table.name) + table: ref.table, + isPendingTruncate: pendingTruncates.contains(ref.table.name), + isPendingDelete: pendingDeletes.contains(ref.table.name) ) - .tag(DatabaseTreeTableRef(database: database, schema: schema, table: table)) + .tag(ref) } - ForEach(routines) { routine in - RoutineRowView(routine: routine) - .tag(routine) + ForEach(routines.map { DatabaseTreeRoutineRef(database: database, schema: schema, routine: $0) }) { ref in + RoutineRowView(routine: ref.routine) .contextMenu { - RoutineContextMenu(routine: routine) { selected in + RoutineContextMenu(routine: ref.routine) { selected in coordinator?.showRoutineDDL(selected) } } @@ -372,8 +390,9 @@ struct DatabaseTreeView: View { private var visibleDatabases: [DatabaseMetadata] { let nonSystem = databases.filter { !$0.isSystemDatabase } - guard !searchText.isEmpty else { return nonSystem } - return nonSystem.filter { databaseMatchesSearch($0) } + let matched = searchText.isEmpty ? nonSystem : nonSystem.filter { databaseMatchesSearch($0) } + var seen = Set() + return matched.filter { seen.insert($0.id).inserted } } private func databaseMatchesSearch(_ db: DatabaseMetadata) -> Bool { @@ -400,18 +419,24 @@ struct DatabaseTreeView: View { } 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) - } + let nonSystem = all.filter { !systemSchemas.contains($0) } + let matched = searchText.isEmpty + ? nonSystem + : nonSystem.filter { schema in + schema.localizedCaseInsensitiveContains(searchText) + || schemaContentMatchesSearch(database: database, schema: schema) + } + var seen = Set() + return matched.filter { seen.insert($0).inserted } } 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) } + let matched = searchText.isEmpty + ? all + : all.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + var seen = Set() + return matched.filter { seen.insert($0.id).inserted } } private func filteredRoutines(database: String, schema: String?) -> [RoutineInfo] { From 740f45569392c00a2cc35cd3a76ef74e175c076a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 29 May 2026 19:08:48 +0700 Subject: [PATCH 14/18] chore(sidebar): add structured logging across the database tree load path (#139) --- .../Query/DatabaseTreeMetadataService.swift | 150 +++++++++++++++--- .../Query/MetadataConnectionPool.swift | 21 ++- .../Core/Services/Query/SchemaService.swift | 32 +++- TablePro/Views/Sidebar/DatabaseTreeView.swift | 69 +++++++- 4 files changed, 244 insertions(+), 28 deletions(-) diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index 43ea892e4..5b82be648 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -48,7 +48,7 @@ final class DatabaseTreeMetadataService { @ObservationIgnored private let routineDedup = OnceTask() @ObservationIgnored private static let logger = Logger( - subsystem: "com.TablePro", category: "DatabaseTreeMetadataService" + subsystem: "com.TablePro", category: "SidebarTree" ) private init() {} @@ -119,7 +119,13 @@ final class DatabaseTreeMetadataService { } func loadDatabaseList(connectionId: UUID, driver: DatabaseDriver, databaseType: DatabaseType) async { - if case .loaded = databaseListState(for: connectionId) { return } + Self.logger.debug( + "loadDatabaseList enter connId=\(connectionId, privacy: .public) type=\(databaseType.rawValue, privacy: .public) state=\(Self.label(self.databaseListState(for: connectionId)), privacy: .public) driver=\(driver.status.label, privacy: .public)" + ) + if case .loaded = databaseListState(for: connectionId) { + Self.logger.debug("loadDatabaseList skip-loaded connId=\(connectionId, privacy: .public)") + return + } databaseListStates[connectionId] = .loading let systemNames = Set(PluginManager.shared.systemDatabaseNames(for: databaseType)) do { @@ -130,17 +136,22 @@ final class DatabaseTreeMetadataService { } } databaseListStates[connectionId] = .loaded(list) + Self.logger.debug( + "loadDatabaseList loaded connId=\(connectionId, privacy: .public) count=\(list.count, privacy: .public)" + ) } catch is CancellationError { + Self.logger.debug("loadDatabaseList cancelled connId=\(connectionId, privacy: .public)") return } catch { Self.logger.warning( - "[tree] database list load failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + "loadDatabaseList failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" ) databaseListStates[connectionId] = .failed(error.localizedDescription) } } func reloadDatabaseList(connectionId: UUID, driver: DatabaseDriver, databaseType: DatabaseType) async { + Self.logger.debug("reloadDatabaseList connId=\(connectionId, privacy: .public)") await databaseListDedup.cancel(key: connectionId) databaseListStates.removeValue(forKey: connectionId) await loadDatabaseList( @@ -149,9 +160,19 @@ final class DatabaseTreeMetadataService { } func loadSchemaList(connectionId: UUID, database: String) async { - if database == activeDatabase(for: connectionId) { return } + let active = activeDatabase(for: connectionId) + Self.logger.debug( + "loadSchemaList enter connId=\(connectionId, privacy: .public) db=\(database, privacy: .public) active=\(active ?? "nil", privacy: .public)" + ) + if database == active { + Self.logger.debug("loadSchemaList skip-active db=\(database, privacy: .public)") + return + } let key = DatabaseKey(connectionId: connectionId, database: database) - if case .loaded = schemaListStates[key] { return } + if case .loaded = schemaListStates[key] { + Self.logger.debug("loadSchemaList skip-loaded db=\(database, privacy: .public)") + return + } schemaListStates[key] = .loading do { let list = try await schemaListDedup.execute(key: key) { @@ -162,26 +183,47 @@ final class DatabaseTreeMetadataService { } } schemaListStates[key] = .loaded(list) + Self.logger.debug( + "loadSchemaList loaded db=\(database, privacy: .public) count=\(list.count, privacy: .public)" + ) } catch is CancellationError { + Self.logger.debug("loadSchemaList cancelled db=\(database, privacy: .public)") return } catch { Self.logger.warning( - "[tree] schema list load failed connId=\(connectionId, privacy: .public) db=\(database, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + "loadSchemaList 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) { + let active = activeDatabase(for: connectionId) + let isActive = database == active + Self.logger.debug( + "loadTables enter connId=\(connectionId, privacy: .public) db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) active=\(active ?? "nil", privacy: .public) isActive=\(isActive, privacy: .public) driver=\(self.driverStatusLabel(connectionId), privacy: .public)" + ) + if isActive { guard let session = DatabaseManager.shared.session(for: connectionId), let driver = session.driver, - driver.status == .connected else { return } + driver.status == .connected else { + Self.logger.debug( + "loadTables active-skip-not-connected db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) driver=\(self.driverStatusLabel(connectionId), privacy: .public)" + ) + return + } if let schema { + Self.logger.debug( + "loadTables active->loadSchemaTables schema=\(schema, privacy: .public) before=\(SchemaService.shared.schemaState(for: connectionId, schema: schema).label, privacy: .public)" + ) await SchemaService.shared.loadSchemaTables( connectionId: connectionId, schema: schema, driver: driver ) + Self.logger.debug( + "loadTables active->loadSchemaTables done schema=\(schema, privacy: .public) after=\(SchemaService.shared.schemaState(for: connectionId, schema: schema).label, privacy: .public)" + ) } else if case .idle = SchemaService.shared.state(for: connectionId) { + Self.logger.debug("loadTables active->SchemaService.load db=\(database, privacy: .public)") await SchemaService.shared.load( connectionId: connectionId, driver: driver, connection: session.connection ) @@ -191,6 +233,9 @@ final class DatabaseTreeMetadataService { } let key = Self.tableKey(connectionId: connectionId, database: database, schema: schema) if case .loaded = tableStates[key] { + Self.logger.debug( + "loadTables nonactive-skip-loaded db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)" + ) await loadRoutines(connectionId: connectionId, database: database, schema: schema) return } @@ -205,11 +250,17 @@ final class DatabaseTreeMetadataService { } } tableStates[key] = .loaded(list) + Self.logger.debug( + "loadTables nonactive-loaded db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) count=\(list.count, privacy: .public)" + ) } catch is CancellationError { + Self.logger.debug( + "loadTables nonactive-cancelled db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)" + ) 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)" + "loadTables nonactive-failed connId=\(connectionId, privacy: .public) db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) error=\(error.localizedDescription, privacy: .public)" ) tableStates[key] = .failed(error.localizedDescription) return @@ -219,12 +270,21 @@ final class DatabaseTreeMetadataService { func loadRoutines(connectionId: UUID, database: String, schema: String?) async { let key = Self.tableKey(connectionId: connectionId, database: database, schema: schema) - if routineLists[key] != nil { return } + if routineLists[key] != nil { + Self.logger.debug( + "loadRoutines skip-loaded db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)" + ) + 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 } + let isActive = database == activeDatabase(for: connectionId) + let activeSessionDriver = isActive ? DatabaseManager.shared.session(for: connectionId)?.driver : nil + if isActive, activeSessionDriver == nil { + Self.logger.debug( + "loadRoutines active-skip-no-driver db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)" + ) + return + } do { let list = try await routineDedup.execute(key: key) { if let activeSessionDriver { @@ -237,11 +297,17 @@ final class DatabaseTreeMetadataService { } } routineLists[key] = list + Self.logger.debug( + "loadRoutines loaded db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) count=\(list.count, privacy: .public)" + ) } catch is CancellationError { + Self.logger.debug( + "loadRoutines cancelled db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)" + ) 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)" + "loadRoutines failed connId=\(connectionId, privacy: .public) db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) error=\(error.localizedDescription, privacy: .public)" ) routineLists[key] = [] } @@ -254,6 +320,9 @@ final class DatabaseTreeMetadataService { } func reloadTables(connectionId: UUID, database: String, schema: String?) async { + Self.logger.debug( + "reloadTables connId=\(connectionId, privacy: .public) db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)" + ) let key = Self.tableKey(connectionId: connectionId, database: database, schema: schema) await routineDedup.cancel(key: key) routineLists.removeValue(forKey: key) @@ -278,6 +347,9 @@ final class DatabaseTreeMetadataService { } func refreshDatabase(connectionId: UUID, database: String) async { + Self.logger.debug( + "refreshDatabase connId=\(connectionId, privacy: .public) db=\(database, privacy: .public)" + ) if database == activeDatabase(for: connectionId) { await SchemaService.shared.refresh(connectionId: connectionId) return @@ -286,29 +358,32 @@ final class DatabaseTreeMetadataService { } func invalidate(connectionId: UUID) async { + Self.logger.debug("invalidate connId=\(connectionId, privacy: .public)") await databaseListDedup.cancel(key: connectionId) databaseListStates.removeValue(forKey: connectionId) await invalidatePerDatabaseCaches(connectionId: connectionId) } func invalidateForReconnect(connectionId: UUID) async { + Self.logger.debug("invalidateForReconnect connId=\(connectionId, privacy: .public)") await invalidatePerDatabaseCaches(connectionId: connectionId) } private func invalidatePerDatabaseCaches(connectionId: UUID) async { let dbKeys = schemaListStates.keys.filter { $0.connectionId == connectionId } + let tableKeys = tableStates.keys.filter { $0.connectionId == connectionId } + let routineKeys = routineLists.keys.filter { $0.connectionId == connectionId } + Self.logger.debug( + "invalidatePerDatabaseCaches connId=\(connectionId, privacy: .public) schemaLists=\(dbKeys.count, privacy: .public) tableStates=\(tableKeys.count, privacy: .public) routineLists=\(routineKeys.count, privacy: .public)" + ) 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) } - - let routineKeys = routineLists.keys.filter { $0.connectionId == connectionId } for key in routineKeys { await routineDedup.cancel(key: key) routineLists.removeValue(forKey: key) @@ -318,6 +393,9 @@ final class DatabaseTreeMetadataService { } func invalidateDatabase(connectionId: UUID, database: String) async { + Self.logger.debug( + "invalidateDatabase connId=\(connectionId, privacy: .public) db=\(database, privacy: .public)" + ) let dbKey = DatabaseKey(connectionId: connectionId, database: database) await schemaListDedup.cancel(key: dbKey) schemaListStates.removeValue(forKey: dbKey) @@ -347,9 +425,43 @@ final class DatabaseTreeMetadataService { return value.isEmpty ? nil : value } + private func driverStatusLabel(_ connectionId: UUID) -> String { + DatabaseManager.shared.session(for: connectionId)?.driver?.status.label ?? "noDriver" + } + + private static func label(_ state: DatabaseListState) -> String { + switch state { + case .idle: return "idle" + case .loading: return "loading" + case .loaded(let list): return "loaded(\(list.count))" + case .failed: return "failed" + } + } 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) } } + +extension SchemaState { + var label: String { + switch self { + case .idle: return "idle" + case .loading: return "loading" + case .loaded(let tables): return "loaded(\(tables.count))" + case .failed: return "failed" + } + } +} + +extension ConnectionStatus { + var label: String { + switch self { + case .disconnected: return "disconnected" + case .connecting: return "connecting" + case .connected: return "connected" + case .error: return "error" + } + } +} diff --git a/TablePro/Core/Services/Query/MetadataConnectionPool.swift b/TablePro/Core/Services/Query/MetadataConnectionPool.swift index 2f0034701..171cf4d53 100644 --- a/TablePro/Core/Services/Query/MetadataConnectionPool.swift +++ b/TablePro/Core/Services/Query/MetadataConnectionPool.swift @@ -44,6 +44,9 @@ final class MetadataConnectionPool { database: String, _ body: @Sendable @escaping (DatabaseDriver) async throws -> T ) async throws -> T { + Self.logger.debug( + "[metadata-pool] withDriver acquire connId=\(connectionId, privacy: .public) db=\(database, privacy: .public)" + ) let entry = try await acquireEntry(connectionId: connectionId, database: database) entry.inFlightCount += 1 entry.lastUsed = Date() @@ -56,7 +59,18 @@ final class MetadataConnectionPool { return try await body(driver) } entry.tail = Task { @MainActor in _ = try? await work.value } - return try await work.value + do { + let result = try await work.value + Self.logger.debug( + "[metadata-pool] withDriver done connId=\(connectionId, privacy: .public) db=\(database, privacy: .public)" + ) + return result + } catch { + Self.logger.debug( + "[metadata-pool] withDriver threw connId=\(connectionId, privacy: .public) db=\(database, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + throw error + } } private func releaseEntry(_ entry: Entry) { @@ -101,16 +115,21 @@ final class MetadataConnectionPool { 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 { + Self.logger.debug("[metadata-pool] reuse connId=\(connectionId, privacy: .public) db=\(database, privacy: .public)") return entry } if let inFlight = pending[key] { + Self.logger.debug("[metadata-pool] await-pending connId=\(connectionId, privacy: .public) db=\(database, privacy: .public)") try await inFlight.value guard let entry = entries[key] else { throw DatabaseError.notConnected } return entry } guard DatabaseManager.shared.session(for: connectionId) != nil else { + Self.logger.debug( + "[metadata-pool] acquire-no-session connId=\(connectionId, privacy: .public) db=\(database, privacy: .public)" + ) throw DatabaseError.notConnected } diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index eac104d56..50fa7bc40 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -89,14 +89,28 @@ final class SchemaService { } func loadSchemaTables(connectionId: UUID, schema: String, driver: DatabaseDriver) async { - if case .loaded = schemaState(for: connectionId, schema: schema) { return } + if case .loaded = schemaState(for: connectionId, schema: schema) { + Self.logger.debug( + "[schema] loadSchemaTables skip-loaded connId=\(connectionId, privacy: .public) schema=\(schema, privacy: .public)" + ) + return + } + Self.logger.debug( + "[schema] loadSchemaTables begin connId=\(connectionId, privacy: .public) schema=\(schema, privacy: .public) driver=\(driver.status.label, privacy: .public)" + ) setPerSchemaState(.loading, connectionId: connectionId, schema: schema) do { let tables = try await perSchemaDedup.execute(key: SchemaKey(connectionId: connectionId, schema: schema)) { try await driver.fetchTables(schema: schema) } setPerSchemaState(.loaded(tables), connectionId: connectionId, schema: schema) + Self.logger.debug( + "[schema] loadSchemaTables loaded connId=\(connectionId, privacy: .public) schema=\(schema, privacy: .public) count=\(tables.count, privacy: .public)" + ) } catch is CancellationError { + Self.logger.debug( + "[schema] loadSchemaTables cancelled connId=\(connectionId, privacy: .public) schema=\(schema, privacy: .public)" + ) return } catch { Self.logger.warning( @@ -172,6 +186,9 @@ final class SchemaService { } func invalidate(connectionId: UUID) async { + Self.logger.debug( + "[schema] invalidate connId=\(connectionId, privacy: .public) perSchema=\(self.perSchemaStates[connectionId]?.count ?? 0, privacy: .public)" + ) await loadDedup.cancel(key: connectionId) await procedureDedup.cancel(key: connectionId) await functionDedup.cancel(key: connectionId) @@ -201,6 +218,9 @@ final class SchemaService { driver: DatabaseDriver, connection: DatabaseConnection ) async { + Self.logger.debug( + "[schema] runLoad begin connId=\(connectionId, privacy: .public) db=\(connection.database, privacy: .public) driver=\(driver.status.label, privacy: .public)" + ) states[connectionId] = .loading bumpGeneration(connectionId) @@ -243,7 +263,11 @@ final class SchemaService { procedures[connectionId] = loadedProcedures functions[connectionId] = loadedFunctions bumpGeneration(connectionId) + Self.logger.debug( + "[schema] runLoad loaded connId=\(connectionId, privacy: .public) tables=\(tables.count, privacy: .public) schemas=\(self.schemasInOrder[connectionId]?.count ?? 0, privacy: .public)" + ) } catch is CancellationError { + Self.logger.debug("[schema] runLoad cancelled connId=\(connectionId, privacy: .public)") return } catch { Self.logger.warning( @@ -316,6 +340,9 @@ final class SchemaService { guard let session = DatabaseManager.shared.activeSessions[connectionId], let driver = session.driver else { return } let connection = session.connection + Self.logger.debug( + "[schema] handleSchemaSwitch connId=\(connectionId, privacy: .public) schema=\(session.currentSchema ?? "nil", privacy: .public) grouping=\(connection.type.rawValue, privacy: .public)" + ) if PluginManager.shared.databaseGroupingStrategy(for: connection.type) == .hierarchicalSchema { await invalidate(connectionId: connectionId) await reload(connectionId: connectionId, driver: driver, connection: connection) @@ -325,6 +352,9 @@ final class SchemaService { } private func reloadCurrentSchemaContent(connectionId: UUID, driver: DatabaseDriver) async { + Self.logger.debug( + "[schema] reloadCurrentSchemaContent connId=\(connectionId, privacy: .public) driver=\(driver.status.label, privacy: .public)" + ) await loadDedup.cancel(key: connectionId) await procedureDedup.cancel(key: connectionId) await functionDedup.cancel(key: connectionId) diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index 5f5d32135..f1b62626c 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -3,6 +3,7 @@ // TablePro // +import os import SwiftUI import TableProPluginKit @@ -46,6 +47,8 @@ struct DatabaseTreeSchemaRef: Identifiable { struct DatabaseTreeView: View { @Bindable private var treeService = DatabaseTreeMetadataService.shared + private static let logger = Logger(subsystem: "com.TablePro", category: "SidebarTreeView") + let connectionId: UUID let databaseType: DatabaseType let viewModel: SidebarViewModel @@ -135,21 +138,30 @@ struct DatabaseTreeView: View { } } .onAppear { + Self.logger.debug( + "onAppear connId=\(connectionId, privacy: .public) active=\(committedActiveDatabase ?? "nil", privacy: .public) toolbar=\(activeDatabase ?? "nil", privacy: .public)" + ) loadDatabasesIfNeeded() expandActive() reconcileLoads(retryFailed: false) } - .onChange(of: activeContextKey) { _, _ in + .onChange(of: activeContextKey) { old, new in + Self.logger.debug("trigger activeContextKey '\(old, privacy: .public)' -> '\(new, privacy: .public)'") expandActive() } - .onChange(of: reconcileKey) { _, _ in + .onChange(of: reconcileKey) { old, new in + Self.logger.debug("trigger reconcileKey '\(old, privacy: .public)' -> '\(new, privacy: .public)'") reconcileLoads(retryFailed: true) } - .onChange(of: schemaGenerationToken) { _, _ in + .onChange(of: schemaGenerationToken) { old, new in + Self.logger.debug("trigger schemaGeneration \(old, privacy: .public) -> \(new, privacy: .public)") reconcileLoads(retryFailed: false) } .onChange(of: localSelection) { oldRefs, newRefs in guard let ref = SelectionDelta.singleAddition(old: oldRefs, new: newRefs) else { return } + Self.logger.debug( + "selection-navigate db=\(ref.database, privacy: .public) schema=\(ref.schema ?? "nil", privacy: .public) table=\(ref.table.name, privacy: .public)" + ) openTable(ref.table, in: ref.database, schema: ref.schema) } } @@ -267,6 +279,7 @@ struct DatabaseTreeView: View { case .idle, .loading: loadingRow(String(localized: "Loading schemas\u{2026}")) .task(id: database) { + Self.logger.debug("task->loadSchemaList db=\(database, privacy: .public)") await treeService.loadSchemaList(connectionId: connectionId, database: database) } case .failed(let message): @@ -297,6 +310,9 @@ struct DatabaseTreeView: View { case .idle, .loading: loadingRow(String(localized: "Loading tables\u{2026}")) .task(id: "\(database)|\(schema ?? "")") { + Self.logger.debug( + "task->loadTables db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)" + ) await treeService.loadTables( connectionId: connectionId, database: database, schema: schema ) @@ -452,6 +468,7 @@ struct DatabaseTreeView: View { Binding( get: { !searchText.isEmpty || windowState.expandedTreeDatabases.contains(database) }, set: { isExpanded in + Self.logger.debug("expand-database db=\(database, privacy: .public) expanded=\(isExpanded, privacy: .public)") if isExpanded { windowState.expandedTreeDatabases.insert(database) loadDatabaseContentIfNeeded(database) @@ -467,6 +484,9 @@ struct DatabaseTreeView: View { return Binding( get: { !searchText.isEmpty || windowState.expandedTreeDatabaseSchemas.contains(key) }, set: { isExpanded in + Self.logger.debug( + "expand-schema db=\(database, privacy: .public) schema=\(schema, privacy: .public) expanded=\(isExpanded, privacy: .public)" + ) if isExpanded { windowState.expandedTreeDatabaseSchemas.insert(key) loadTablesIfNeeded(database: database, schema: schema) @@ -478,7 +498,10 @@ struct DatabaseTreeView: View { } private func loadDatabasesIfNeeded() { - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + Self.logger.debug("loadDatabasesIfNeeded skip-no-driver connId=\(connectionId, privacy: .public)") + return + } Task { await treeService.loadDatabaseList( connectionId: connectionId, @@ -489,7 +512,14 @@ struct DatabaseTreeView: View { } private func reconcileLoads(retryFailed: Bool) { - guard case .loaded = treeService.databaseListState(for: connectionId) else { return } + guard case .loaded = treeService.databaseListState(for: connectionId) else { + Self.logger.debug("reconcile skip (db list not loaded) retryFailed=\(retryFailed, privacy: .public)") + return + } + let expandedSchemaCount = windowState.expandedTreeDatabaseSchemas.count + Self.logger.debug( + "reconcile begin retryFailed=\(retryFailed, privacy: .public) active=\(committedActiveDatabase ?? "nil", privacy: .public) activeSchema=\(committedActiveSchema ?? "nil", privacy: .public) expandedDbs=\(windowState.expandedTreeDatabases.count, privacy: .public) expandedSchemas=\(expandedSchemaCount, privacy: .public)" + ) if let active = committedActiveDatabase { ensureContentLoaded( @@ -514,10 +544,13 @@ struct DatabaseTreeView: View { } private func ensureSchemaListLoaded(database: String, retryFailed: Bool) { - switch treeService.schemaListState(connectionId: connectionId, database: database) { + let state = treeService.schemaListState(connectionId: connectionId, database: database) + switch state { case .idle: + Self.logger.debug("reconcile kick schemaList db=\(database, privacy: .public) reason=idle") loadDatabaseContentIfNeeded(database) case .failed where retryFailed: + Self.logger.debug("reconcile kick schemaList db=\(database, privacy: .public) reason=retryFailed") loadDatabaseContentIfNeeded(database) case .loading, .loaded, .failed: return @@ -525,10 +558,17 @@ struct DatabaseTreeView: View { } private func ensureContentLoaded(database: String, schema: String?, retryFailed: Bool) { - switch treeService.tableState(connectionId: connectionId, database: database, schema: schema) { + let state = treeService.tableState(connectionId: connectionId, database: database, schema: schema) + switch state { case .idle: + Self.logger.debug( + "reconcile kick tables db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) reason=idle" + ) loadTablesIfNeeded(database: database, schema: schema) case .failed where retryFailed: + Self.logger.debug( + "reconcile kick tables db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) reason=retryFailed" + ) loadTablesIfNeeded(database: database, schema: schema) case .loading, .loaded, .failed: return @@ -576,12 +616,19 @@ struct DatabaseTreeView: View { private func setActiveDatabase(_ database: String) { guard database != activeDatabase else { return } + Self.logger.debug("setActiveDatabase db=\(database, privacy: .public)") Task { @MainActor in await coordinator?.switchDatabase(to: database) + Self.logger.debug( + "setActiveDatabase done db=\(database, privacy: .public) committed=\(committedActiveDatabase ?? "nil", privacy: .public)" + ) } } private func setActiveSchema(database: String, schema: String) { + Self.logger.debug( + "setActiveSchema db=\(database, privacy: .public) schema=\(schema, privacy: .public)" + ) Task { @MainActor in if database != activeDatabase { await coordinator?.switchDatabase(to: database) @@ -594,14 +641,22 @@ struct DatabaseTreeView: View { private func openTable(_ table: TableInfo, in database: String, schema: String?) { Task { @MainActor in + Self.logger.debug( + "openTable begin table=\(table.name, privacy: .public) db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) committed=\(committedActiveDatabase ?? "nil", privacy: .public)" + ) if database != committedActiveDatabase { + Self.logger.debug("openTable switchDatabase -> \(database, privacy: .public)") await coordinator?.switchDatabase(to: database) } if let schema, schema != coordinator?.toolbarState.currentSchema, PluginManager.shared.supportsSchemaSwitching(for: databaseType) { + Self.logger.debug("openTable switchSchema -> \(schema, privacy: .public)") await coordinator?.switchSchema(to: schema) } + Self.logger.debug( + "openTable openTableTab table=\(table.name, privacy: .public) committed=\(committedActiveDatabase ?? "nil", privacy: .public) currentSchema=\(coordinator?.toolbarState.currentSchema ?? "nil", privacy: .public)" + ) coordinator?.openTableTab(table) } } From e95400d525fcf16b1576d2d76dee03ec8baa908b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 29 May 2026 19:16:04 +0700 Subject: [PATCH 15/18] fix(sidebar): mark session connecting at switch start so tree loads wait for the new connection (#139) --- .../Database/DatabaseManager+Sessions.swift | 1 + .../Query/DatabaseTreeMetadataService.swift | 25 ++++++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 947ee8a20..a1f8fd342 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -260,6 +260,7 @@ extension DatabaseManager { session.connection.database = database session.currentDatabase = database session.currentSchema = nil + session.status = .connecting } appSettingsStorage.saveLastSchema(nil, for: connectionId) await SchemaService.shared.invalidate(connectionId: connectionId) diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index 5b82be648..b622359a6 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -168,6 +168,10 @@ final class DatabaseTreeMetadataService { Self.logger.debug("loadSchemaList skip-active db=\(database, privacy: .public)") return } + if isConnecting(connectionId) { + Self.logger.debug("loadSchemaList skip-connecting db=\(database, privacy: .public)") + return + } let key = DatabaseKey(connectionId: connectionId, database: database) if case .loaded = schemaListStates[key] { Self.logger.debug("loadSchemaList skip-loaded db=\(database, privacy: .public)") @@ -203,12 +207,17 @@ final class DatabaseTreeMetadataService { Self.logger.debug( "loadTables enter connId=\(connectionId, privacy: .public) db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) active=\(active ?? "nil", privacy: .public) isActive=\(isActive, privacy: .public) driver=\(self.driverStatusLabel(connectionId), privacy: .public)" ) + if isConnecting(connectionId) { + Self.logger.debug( + "loadTables skip-connecting db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)" + ) + return + } if isActive { guard let session = DatabaseManager.shared.session(for: connectionId), - let driver = session.driver, - driver.status == .connected else { + let driver = session.driver else { Self.logger.debug( - "loadTables active-skip-not-connected db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) driver=\(self.driverStatusLabel(connectionId), privacy: .public)" + "loadTables active-skip-no-driver db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)" ) return } @@ -276,6 +285,12 @@ final class DatabaseTreeMetadataService { ) return } + if isConnecting(connectionId) { + Self.logger.debug( + "loadRoutines skip-connecting db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)" + ) + return + } let normalizedSchema = key.schema let isActive = database == activeDatabase(for: connectionId) let activeSessionDriver = isActive ? DatabaseManager.shared.session(for: connectionId)?.driver : nil @@ -425,6 +440,10 @@ final class DatabaseTreeMetadataService { return value.isEmpty ? nil : value } + private func isConnecting(_ connectionId: UUID) -> Bool { + DatabaseManager.shared.session(for: connectionId)?.status == .connecting + } + private func driverStatusLabel(_ connectionId: UUID) -> String { DatabaseManager.shared.session(for: connectionId)?.driver?.status.label ?? "noDriver" } From 4071b5f06d760f501fdd0ae72772113e327d9884 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 29 May 2026 19:35:51 +0700 Subject: [PATCH 16/18] refactor(sidebar): rebuild database tree on a connection-agnostic metadata cache (#139) --- .../Database/DatabaseManager+Health.swift | 4 +- .../Database/DatabaseManager+Sessions.swift | 2 +- .../Query/DatabaseTreeMetadataService.swift | 510 +++++------------- .../Services/Query/MetadataLoadState.swift | 51 ++ .../Views/Main/MainContentCoordinator.swift | 3 +- TablePro/Views/Sidebar/DatabaseTreeView.swift | 415 ++++---------- 6 files changed, 319 insertions(+), 666 deletions(-) create mode 100644 TablePro/Core/Services/Query/MetadataLoadState.swift diff --git a/TablePro/Core/Database/DatabaseManager+Health.swift b/TablePro/Core/Database/DatabaseManager+Health.swift index d019aa56e..9ac79854a 100644 --- a/TablePro/Core/Database/DatabaseManager+Health.swift +++ b/TablePro/Core/Database/DatabaseManager+Health.swift @@ -53,7 +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) + await DatabaseTreeMetadataService.shared.handleReconnect(connectionId: connectionId) do { let result = try await self.trackOperation(sessionId: connectionId) { try await self.reconnectDriver(for: session) @@ -208,7 +208,7 @@ extension DatabaseManager { } await SchemaService.shared.invalidate(connectionId: sessionId) - await DatabaseTreeMetadataService.shared.invalidateForReconnect(connectionId: sessionId) + await DatabaseTreeMetadataService.shared.handleReconnect(connectionId: sessionId) // Stop existing health monitor await stopHealthMonitor(for: sessionId) diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index a1f8fd342..2fc001e29 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -348,7 +348,7 @@ extension DatabaseManager { removeSessionEntry(for: sessionId) await SchemaService.shared.invalidate(connectionId: sessionId) - await DatabaseTreeMetadataService.shared.invalidate(connectionId: sessionId) + await DatabaseTreeMetadataService.shared.handleDisconnect(connectionId: sessionId) SchemaProviderRegistry.shared.clear(for: sessionId) diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index b622359a6..87fa925fd 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -17,35 +17,24 @@ final class DatabaseTreeMetadataService { let database: String } - struct TableKey: Hashable, Sendable { + struct ObjectsKey: 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) + struct SchemaObjects: Equatable, Sendable { + var tables: [TableInfo] + var routines: [RoutineInfo] } - 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] = [:] - private(set) var routineLists: [TableKey: [RoutineInfo]] = [:] + private(set) var databaseList: [UUID: MetadataLoadState<[DatabaseMetadata]>] = [:] + private(set) var schemaList: [DatabaseKey: MetadataLoadState<[String]>] = [:] + private(set) var objects: [ObjectsKey: MetadataLoadState] = [:] - @ObservationIgnored private let databaseListDedup = OnceTask() - @ObservationIgnored private let schemaListDedup = OnceTask() - @ObservationIgnored private let tableDedup = OnceTask() - @ObservationIgnored private let routineDedup = OnceTask() + @ObservationIgnored private let databaseDedup = OnceTask() + @ObservationIgnored private let schemaDedup = OnceTask() + @ObservationIgnored private let objectsDedup = OnceTask() @ObservationIgnored private static let logger = Logger( subsystem: "com.TablePro", category: "SidebarTree" @@ -53,434 +42,227 @@ final class DatabaseTreeMetadataService { private init() {} - func databaseListState(for connectionId: UUID) -> DatabaseListState { - databaseListStates[connectionId] ?? .idle + // MARK: - Reads + + func databaseListState(for connectionId: UUID) -> MetadataLoadState<[DatabaseMetadata]> { + databaseList[connectionId] ?? .idle } func databases(for connectionId: UUID) -> [DatabaseMetadata] { - if case .loaded(let list) = databaseListState(for: connectionId) { - return list - } - return [] + databaseList[connectionId]?.value ?? [] } - 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(schemas) - } - } - return schemaListStates[DatabaseKey(connectionId: connectionId, database: database)] ?? .idle + func schemaListState(connectionId: UUID, database: String) -> MetadataLoadState<[String]> { + schemaList[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 [] + schemaList[DatabaseKey(connectionId: connectionId, database: database)]?.value ?? [] } - 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 objectsState(connectionId: UUID, database: String, schema: String?) -> MetadataLoadState { + objects[Self.objectsKey(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 [] + objects[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)]?.value?.tables ?? [] } func routines(connectionId: UUID, database: String, schema: String?) -> [RoutineInfo] { - routineLists[Self.tableKey(connectionId: connectionId, database: database, schema: schema)] ?? [] + objects[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)]?.value?.routines ?? [] } - func loadDatabaseList(connectionId: UUID, driver: DatabaseDriver, databaseType: DatabaseType) async { - Self.logger.debug( - "loadDatabaseList enter connId=\(connectionId, privacy: .public) type=\(databaseType.rawValue, privacy: .public) state=\(Self.label(self.databaseListState(for: connectionId)), privacy: .public) driver=\(driver.status.label, privacy: .public)" - ) - if case .loaded = databaseListState(for: connectionId) { - Self.logger.debug("loadDatabaseList skip-loaded connId=\(connectionId, privacy: .public)") + // MARK: - Loads + + func loadDatabases(connectionId: UUID, databaseType: DatabaseType) async { + guard isConnected(connectionId) else { + Self.logger.debug("loadDatabases skip-not-connected connId=\(connectionId, privacy: .public)") return } - databaseListStates[connectionId] = .loading + switch databaseListState(for: connectionId) { + case .loaded, .loading: return + case .idle, .failed: break + } + databaseList[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)) + let list = try await databaseDedup.execute(key: connectionId) { [self] in + try await withDriver(connectionId: connectionId, database: nil) { driver in + try await driver.fetchDatabases().sorted().map { + DatabaseMetadata.minimal(name: $0, isSystem: systemNames.contains($0)) + } } } - databaseListStates[connectionId] = .loaded(list) - Self.logger.debug( - "loadDatabaseList loaded connId=\(connectionId, privacy: .public) count=\(list.count, privacy: .public)" - ) + databaseList[connectionId] = .loaded(list) + Self.logger.debug("loadDatabases loaded connId=\(connectionId, privacy: .public) count=\(list.count, privacy: .public)") } catch is CancellationError { - Self.logger.debug("loadDatabaseList cancelled connId=\(connectionId, privacy: .public)") - return + resetIfLoading(databaseConnectionId: connectionId) } catch { - Self.logger.warning( - "loadDatabaseList failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" - ) - databaseListStates[connectionId] = .failed(error.localizedDescription) + databaseList[connectionId] = .failed(error.localizedDescription) + Self.logger.warning("loadDatabases failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)") } } - func reloadDatabaseList(connectionId: UUID, driver: DatabaseDriver, databaseType: DatabaseType) async { - Self.logger.debug("reloadDatabaseList connId=\(connectionId, privacy: .public)") - await databaseListDedup.cancel(key: connectionId) - databaseListStates.removeValue(forKey: connectionId) - await loadDatabaseList( - connectionId: connectionId, driver: driver, databaseType: databaseType - ) - } - - func loadSchemaList(connectionId: UUID, database: String) async { - let active = activeDatabase(for: connectionId) - Self.logger.debug( - "loadSchemaList enter connId=\(connectionId, privacy: .public) db=\(database, privacy: .public) active=\(active ?? "nil", privacy: .public)" - ) - if database == active { - Self.logger.debug("loadSchemaList skip-active db=\(database, privacy: .public)") - return - } - if isConnecting(connectionId) { - Self.logger.debug("loadSchemaList skip-connecting db=\(database, privacy: .public)") + func loadSchemas(connectionId: UUID, database: String) async { + guard isConnected(connectionId) else { + Self.logger.debug("loadSchemas skip-not-connected db=\(database, privacy: .public)") return } let key = DatabaseKey(connectionId: connectionId, database: database) - if case .loaded = schemaListStates[key] { - Self.logger.debug("loadSchemaList skip-loaded db=\(database, privacy: .public)") - return + switch schemaList[key] ?? .idle { + case .loaded, .loading: return + case .idle, .failed: break } - schemaListStates[key] = .loading + schemaList[key] = .loading do { - let list = try await schemaListDedup.execute(key: key) { - try await MetadataConnectionPool.shared.withDriver( - connectionId: connectionId, database: database - ) { driver in + let list = try await schemaDedup.execute(key: key) { [self] in + try await withDriver(connectionId: connectionId, database: database) { driver in try await driver.fetchSchemas() } } - schemaListStates[key] = .loaded(list) - Self.logger.debug( - "loadSchemaList loaded db=\(database, privacy: .public) count=\(list.count, privacy: .public)" - ) - } catch is CancellationError { - Self.logger.debug("loadSchemaList cancelled db=\(database, privacy: .public)") - return - } catch { - Self.logger.warning( - "loadSchemaList 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 { - let active = activeDatabase(for: connectionId) - let isActive = database == active - Self.logger.debug( - "loadTables enter connId=\(connectionId, privacy: .public) db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) active=\(active ?? "nil", privacy: .public) isActive=\(isActive, privacy: .public) driver=\(self.driverStatusLabel(connectionId), privacy: .public)" - ) - if isConnecting(connectionId) { - Self.logger.debug( - "loadTables skip-connecting db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)" - ) - return - } - if isActive { - guard let session = DatabaseManager.shared.session(for: connectionId), - let driver = session.driver else { - Self.logger.debug( - "loadTables active-skip-no-driver db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)" - ) - return - } - if let schema { - Self.logger.debug( - "loadTables active->loadSchemaTables schema=\(schema, privacy: .public) before=\(SchemaService.shared.schemaState(for: connectionId, schema: schema).label, privacy: .public)" - ) - await SchemaService.shared.loadSchemaTables( - connectionId: connectionId, schema: schema, driver: driver - ) - Self.logger.debug( - "loadTables active->loadSchemaTables done schema=\(schema, privacy: .public) after=\(SchemaService.shared.schemaState(for: connectionId, schema: schema).label, privacy: .public)" - ) - } else if case .idle = SchemaService.shared.state(for: connectionId) { - Self.logger.debug("loadTables active->SchemaService.load db=\(database, privacy: .public)") - await SchemaService.shared.load( - 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) - if case .loaded = tableStates[key] { - Self.logger.debug( - "loadTables nonactive-skip-loaded db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)" - ) - await loadRoutines(connectionId: connectionId, database: database, schema: schema) - 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) - Self.logger.debug( - "loadTables nonactive-loaded db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) count=\(list.count, privacy: .public)" - ) + schemaList[key] = .loaded(list) + Self.logger.debug("loadSchemas loaded db=\(database, privacy: .public) count=\(list.count, privacy: .public)") } catch is CancellationError { - Self.logger.debug( - "loadTables nonactive-cancelled db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)" - ) - return + if case .loading = schemaList[key] { schemaList[key] = .idle } } catch { - Self.logger.warning( - "loadTables nonactive-failed connId=\(connectionId, privacy: .public) db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) error=\(error.localizedDescription, privacy: .public)" - ) - tableStates[key] = .failed(error.localizedDescription) - return + schemaList[key] = .failed(error.localizedDescription) + Self.logger.warning("loadSchemas failed db=\(database, privacy: .public) error=\(error.localizedDescription, privacy: .public)") } - await loadRoutines(connectionId: connectionId, database: database, schema: schema) } - func loadRoutines(connectionId: UUID, database: String, schema: String?) async { - let key = Self.tableKey(connectionId: connectionId, database: database, schema: schema) - if routineLists[key] != nil { - Self.logger.debug( - "loadRoutines skip-loaded db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)" - ) + func loadObjects(connectionId: UUID, database: String, schema: String?) async { + guard isConnected(connectionId) else { + Self.logger.debug("loadObjects skip-not-connected db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)") return } - if isConnecting(connectionId) { - Self.logger.debug( - "loadRoutines skip-connecting db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)" - ) - return + let key = Self.objectsKey(connectionId: connectionId, database: database, schema: schema) + switch objects[key] ?? .idle { + case .loaded, .loading: return + case .idle, .failed: break } + objects[key] = .loading let normalizedSchema = key.schema - let isActive = database == activeDatabase(for: connectionId) - let activeSessionDriver = isActive ? DatabaseManager.shared.session(for: connectionId)?.driver : nil - if isActive, activeSessionDriver == nil { - Self.logger.debug( - "loadRoutines active-skip-no-driver db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)" - ) - 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) + let result = try await objectsDedup.execute(key: key) { [self] in + try await withDriver(connectionId: connectionId, database: database) { driver in + async let tables = driver.fetchTables(schema: normalizedSchema) + async let procedures = driver.fetchProcedures(schema: normalizedSchema) + async let functions = driver.fetchFunctions(schema: normalizedSchema) + return SchemaObjects( + tables: try await tables, + routines: try await procedures + functions + ) } } - routineLists[key] = list + objects[key] = .loaded(result) Self.logger.debug( - "loadRoutines loaded db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) count=\(list.count, privacy: .public)" + "loadObjects loaded db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) tables=\(result.tables.count, privacy: .public) routines=\(result.routines.count, privacy: .public)" ) } catch is CancellationError { - Self.logger.debug( - "loadRoutines cancelled db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)" - ) - return + if case .loading = objects[key] { objects[key] = .idle } } catch { + objects[key] = .failed(error.localizedDescription) Self.logger.warning( - "loadRoutines failed connId=\(connectionId, privacy: .public) db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) error=\(error.localizedDescription, privacy: .public)" + "loadObjects failed db=\(database, privacy: .public) schema=\(schema ?? "nil", 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 - } + // MARK: - Refresh - func reloadTables(connectionId: UUID, database: String, schema: String?) async { - Self.logger.debug( - "reloadTables connId=\(connectionId, privacy: .public) db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)" - ) - 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 } - 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 - ) - } - await loadRoutines(connectionId: connectionId, database: database, schema: schema) - return - } - await tableDedup.cancel(key: key) - tableStates.removeValue(forKey: key) - await loadTables(connectionId: connectionId, database: database, schema: schema) + func refreshDatabases(connectionId: UUID, databaseType: DatabaseType) async { + await databaseDedup.cancel(key: connectionId) + databaseList.removeValue(forKey: connectionId) + await loadDatabases(connectionId: connectionId, databaseType: databaseType) } - func refreshDatabase(connectionId: UUID, database: String) async { - Self.logger.debug( - "refreshDatabase connId=\(connectionId, privacy: .public) db=\(database, privacy: .public)" - ) - if database == activeDatabase(for: connectionId) { - await SchemaService.shared.refresh(connectionId: connectionId) - return - } - await invalidateDatabase(connectionId: connectionId, database: database) + func refreshSchemas(connectionId: UUID, database: String) async { + let key = DatabaseKey(connectionId: connectionId, database: database) + await schemaDedup.cancel(key: key) + schemaList.removeValue(forKey: key) + await loadSchemas(connectionId: connectionId, database: database) } - func invalidate(connectionId: UUID) async { - Self.logger.debug("invalidate connId=\(connectionId, privacy: .public)") - await databaseListDedup.cancel(key: connectionId) - databaseListStates.removeValue(forKey: connectionId) - await invalidatePerDatabaseCaches(connectionId: connectionId) + func refreshObjects(connectionId: UUID, database: String, schema: String?) async { + let key = Self.objectsKey(connectionId: connectionId, database: database, schema: schema) + await objectsDedup.cancel(key: key) + objects.removeValue(forKey: key) + await loadObjects(connectionId: connectionId, database: database, schema: schema) } - func invalidateForReconnect(connectionId: UUID) async { - Self.logger.debug("invalidateForReconnect connId=\(connectionId, privacy: .public)") - await invalidatePerDatabaseCaches(connectionId: connectionId) + // MARK: - Lifecycle + + func handleReconnect(connectionId: UUID) async { + Self.logger.debug("handleReconnect connId=\(connectionId, privacy: .public)") + MetadataConnectionPool.shared.closeAll(connectionId: connectionId) + await resetPending(connectionId: connectionId) } - private func invalidatePerDatabaseCaches(connectionId: UUID) async { - let dbKeys = schemaListStates.keys.filter { $0.connectionId == connectionId } - let tableKeys = tableStates.keys.filter { $0.connectionId == connectionId } - let routineKeys = routineLists.keys.filter { $0.connectionId == connectionId } - Self.logger.debug( - "invalidatePerDatabaseCaches connId=\(connectionId, privacy: .public) schemaLists=\(dbKeys.count, privacy: .public) tableStates=\(tableKeys.count, privacy: .public) routineLists=\(routineKeys.count, privacy: .public)" - ) - for key in dbKeys { - await schemaListDedup.cancel(key: key) - schemaListStates.removeValue(forKey: key) - } - for key in tableKeys { - await tableDedup.cancel(key: key) - tableStates.removeValue(forKey: key) + func handleDisconnect(connectionId: UUID) async { + Self.logger.debug("handleDisconnect connId=\(connectionId, privacy: .public)") + MetadataConnectionPool.shared.closeAll(connectionId: connectionId) + await databaseDedup.cancel(key: connectionId) + for key in schemaList.keys where key.connectionId == connectionId { + await schemaDedup.cancel(key: key) } - for key in routineKeys { - await routineDedup.cancel(key: key) - routineLists.removeValue(forKey: key) + for key in objects.keys where key.connectionId == connectionId { + await objectsDedup.cancel(key: key) } - - MetadataConnectionPool.shared.closeAll(connectionId: connectionId) + databaseList.removeValue(forKey: connectionId) + schemaList = schemaList.filter { $0.key.connectionId != connectionId } + objects = objects.filter { $0.key.connectionId != connectionId } } - func invalidateDatabase(connectionId: UUID, database: String) async { - Self.logger.debug( - "invalidateDatabase connId=\(connectionId, privacy: .public) db=\(database, privacy: .public)" - ) - let dbKey = DatabaseKey(connectionId: connectionId, database: database) - await schemaListDedup.cancel(key: dbKey) - schemaListStates.removeValue(forKey: dbKey) + // MARK: - Private - let tableKeys = tableStates.keys.filter { - $0.connectionId == connectionId && $0.database == database + private func resetPending(connectionId: UUID) async { + if isPending(databaseList[connectionId]) { + await databaseDedup.cancel(key: connectionId) + databaseList[connectionId] = .idle } - for key in tableKeys { - await tableDedup.cancel(key: key) - tableStates.removeValue(forKey: key) + for (key, state) in schemaList where key.connectionId == connectionId && isPending(state) { + await schemaDedup.cancel(key: key) + schemaList[key] = .idle } - - let routineKeys = routineLists.keys.filter { - $0.connectionId == connectionId && $0.database == database - } - for key in routineKeys { - await routineDedup.cancel(key: key) - routineLists.removeValue(forKey: key) + for (key, state) in objects where key.connectionId == connectionId && isPending(state) { + await objectsDedup.cancel(key: key) + objects[key] = .idle } - - 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 func isPending(_ state: MetadataLoadState?) -> Bool { + switch state { + case .loading, .failed: return true + case .idle, .loaded, .none: return false + } } - private func isConnecting(_ connectionId: UUID) -> Bool { - DatabaseManager.shared.session(for: connectionId)?.status == .connecting + private func resetIfLoading(databaseConnectionId connectionId: UUID) { + if case .loading = databaseList[connectionId] { databaseList[connectionId] = .idle } } - private func driverStatusLabel(_ connectionId: UUID) -> String { - DatabaseManager.shared.session(for: connectionId)?.driver?.status.label ?? "noDriver" + private func isConnected(_ connectionId: UUID) -> Bool { + DatabaseManager.shared.session(for: connectionId)?.status == .connected } - private static func label(_ state: DatabaseListState) -> String { - switch state { - case .idle: return "idle" - case .loading: return "loading" - case .loaded(let list): return "loaded(\(list.count))" - case .failed: return "failed" - } + private func withDriver( + connectionId: UUID, + database: String?, + _ body: @Sendable @escaping (DatabaseDriver) async throws -> T + ) async throws -> T { + let session = DatabaseManager.shared.session(for: connectionId) + let usesPrimary = database == nil || database == session?.activeDatabase + if usesPrimary, let driver = session?.driver, driver.status == .connected { + return try await body(driver) + } + guard let database else { throw DatabaseError.notConnected } + return try await MetadataConnectionPool.shared.withDriver( + connectionId: connectionId, database: database, body + ) } - private static func tableKey(connectionId: UUID, database: String, schema: String?) -> TableKey { + private static func objectsKey(connectionId: UUID, database: String, schema: String?) -> ObjectsKey { let normalized: String? = (schema?.isEmpty == true) ? nil : schema - return TableKey(connectionId: connectionId, database: database, schema: normalized) - } -} - -extension SchemaState { - var label: String { - switch self { - case .idle: return "idle" - case .loading: return "loading" - case .loaded(let tables): return "loaded(\(tables.count))" - case .failed: return "failed" - } - } -} - -extension ConnectionStatus { - var label: String { - switch self { - case .disconnected: return "disconnected" - case .connecting: return "connecting" - case .connected: return "connected" - case .error: return "error" - } + return ObjectsKey(connectionId: connectionId, database: database, schema: normalized) } } diff --git a/TablePro/Core/Services/Query/MetadataLoadState.swift b/TablePro/Core/Services/Query/MetadataLoadState.swift new file mode 100644 index 000000000..2bd394184 --- /dev/null +++ b/TablePro/Core/Services/Query/MetadataLoadState.swift @@ -0,0 +1,51 @@ +// +// MetadataLoadState.swift +// TablePro +// + +import Foundation + +enum MetadataLoadState: Sendable { + case idle + case loading + case loaded(Value) + case failed(String) + + var value: Value? { + if case .loaded(let value) = self { return value } + return nil + } + + var label: String { + switch self { + case .idle: return "idle" + case .loading: return "loading" + case .loaded: return "loaded" + case .failed: return "failed" + } + } +} + +extension MetadataLoadState: Equatable where Value: Equatable {} + +extension SchemaState { + var label: String { + switch self { + case .idle: return "idle" + case .loading: return "loading" + case .loaded(let tables): return "loaded(\(tables.count))" + case .failed: return "failed" + } + } +} + +extension ConnectionStatus { + var label: String { + switch self { + case .disconnected: return "disconnected" + case .connecting: return "connecting" + case .connected: return "connected" + case .error: return "error" + } + } +} diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index a5d2da8a6..6317634dc 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -731,9 +731,8 @@ final class MainContentCoordinator { driver: driver, connection: connection ) - await DatabaseTreeMetadataService.shared.loadDatabaseList( + await DatabaseTreeMetadataService.shared.loadDatabases( connectionId: connectionId, - driver: driver, databaseType: connection.type ) await reconcilePostSchemaLoad() diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index f1b62626c..b81d32ac4 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -3,7 +3,6 @@ // TablePro // -import os import SwiftUI import TableProPluginKit @@ -47,8 +46,6 @@ struct DatabaseTreeSchemaRef: Identifiable { struct DatabaseTreeView: View { @Bindable private var treeService = DatabaseTreeMetadataService.shared - private static let logger = Logger(subsystem: "com.TablePro", category: "SidebarTreeView") - let connectionId: UUID let databaseType: DatabaseType let viewModel: SidebarViewModel @@ -76,31 +73,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 committedActiveSchema: String? { - DatabaseManager.shared.session(for: connectionId)?.currentSchema + private var isConnected: Bool { + DatabaseManager.shared.session(for: connectionId)?.status == .connected } - private var schemaGenerationToken: Int { - SchemaService.shared.generationToken(for: connectionId) - } - - @MainActor - private func activate(_ ref: DatabaseTreeTableRef?) async { - guard let ref else { return } - if ref.database != committedActiveDatabase { - await coordinator?.switchDatabase(to: ref.database) - } - if let schema = ref.schema, - schema != coordinator?.toolbarState.currentSchema, - PluginManager.shared.supportsSchemaSwitching(for: databaseType) { - await coordinator?.switchSchema(to: schema) - } + private var connectionToken: String { + isConnected ? "connected" : "down" } private var systemSchemas: Set { @@ -124,44 +102,24 @@ struct DatabaseTreeView: View { var body: some View { Group { - let state = treeService.databaseListState(for: connectionId) - if case .failed(let message) = state { + switch treeService.databaseListState(for: connectionId) { + case .failed(let message): errorState(message: message) - } else if databases.isEmpty { - if case .loaded = state { - emptyDatabasesState - } else { - loadingState - } - } else { + case .loaded where databases.isEmpty: + emptyDatabasesState + case .loaded: treeList + case .idle, .loading: + loadingState } } - .onAppear { - Self.logger.debug( - "onAppear connId=\(connectionId, privacy: .public) active=\(committedActiveDatabase ?? "nil", privacy: .public) toolbar=\(activeDatabase ?? "nil", privacy: .public)" - ) - loadDatabasesIfNeeded() - expandActive() - reconcileLoads(retryFailed: false) - } - .onChange(of: activeContextKey) { old, new in - Self.logger.debug("trigger activeContextKey '\(old, privacy: .public)' -> '\(new, privacy: .public)'") - expandActive() - } - .onChange(of: reconcileKey) { old, new in - Self.logger.debug("trigger reconcileKey '\(old, privacy: .public)' -> '\(new, privacy: .public)'") - reconcileLoads(retryFailed: true) - } - .onChange(of: schemaGenerationToken) { old, new in - Self.logger.debug("trigger schemaGeneration \(old, privacy: .public) -> \(new, privacy: .public)") - reconcileLoads(retryFailed: false) + .task(id: connectionToken) { + await treeService.loadDatabases(connectionId: connectionId, databaseType: databaseType) } + .onAppear { expandActive() } + .onChange(of: activeContextKey) { _, _ in expandActive() } .onChange(of: localSelection) { oldRefs, newRefs in guard let ref = SelectionDelta.singleAddition(old: oldRefs, new: newRefs) else { return } - Self.logger.debug( - "selection-navigate db=\(ref.database, privacy: .public) schema=\(ref.schema ?? "nil", privacy: .public) table=\(ref.table.name, privacy: .public)" - ) openTable(ref.table, in: ref.database, schema: ref.schema) } } @@ -170,19 +128,6 @@ struct DatabaseTreeView: View { "\(activeDatabase ?? "")|\(activeSchema ?? "")" } - private var reconcileKey: String { - "\(committedActiveDatabase ?? "")|\(committedActiveSchema ?? "")|\(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 { List(selection: selectedTablesBinding) { ForEach(visibleDatabases, id: \.id) { db in @@ -219,7 +164,7 @@ struct DatabaseTreeView: View { if supportsSchemaLevel { schemasContent(for: db.name) } else { - tablesContent(database: db.name, schema: nil) + objectsContent(database: db.name, schema: nil) } } @@ -239,7 +184,7 @@ struct DatabaseTreeView: View { } .disabled(isActive) Button(String(localized: "Refresh")) { - refreshDatabase(db.name) + Task { await treeService.refreshSchemas(connectionId: connectionId, database: db.name) } } } } @@ -261,7 +206,9 @@ struct DatabaseTreeView: View { } .disabled(isActive) Button(String(localized: "Refresh")) { - refreshSchema(database: database, schema: schema) + Task { + await treeService.refreshObjects(connectionId: connectionId, database: database, schema: schema) + } } } } @@ -274,13 +221,11 @@ struct DatabaseTreeView: View { @ViewBuilder private func schemasContent(for database: String) -> some View { - let state = treeService.schemaListState(connectionId: connectionId, database: database) - switch state { + switch treeService.schemaListState(connectionId: connectionId, database: database) { case .idle, .loading: loadingRow(String(localized: "Loading schemas\u{2026}")) - .task(id: database) { - Self.logger.debug("task->loadSchemaList db=\(database, privacy: .public)") - await treeService.loadSchemaList(connectionId: connectionId, database: database) + .task(id: "\(database)|\(connectionToken)") { + await treeService.loadSchemas(connectionId: connectionId, database: database) } case .failed(let message): errorRow(message) @@ -290,10 +235,8 @@ struct DatabaseTreeView: View { emptyRow(String(localized: "No schemas")) } else { ForEach(visible.map { DatabaseTreeSchemaRef(database: database, schema: $0) }) { ref in - DisclosureGroup( - isExpanded: schemaExpansionBinding(database: ref.database, schema: ref.schema) - ) { - tablesContent(database: ref.database, schema: ref.schema) + DisclosureGroup(isExpanded: schemaExpansionBinding(database: ref.database, schema: ref.schema)) { + objectsContent(database: ref.database, schema: ref.schema) } label: { schemaHeader(database: ref.database, schema: ref.schema) } @@ -303,19 +246,12 @@ struct DatabaseTreeView: View { } @ViewBuilder - private func tablesContent(database: String, schema: String?) -> some View { - switch treeService.tableState( - connectionId: connectionId, database: database, schema: schema - ) { + private func objectsContent(database: String, schema: String?) -> some View { + switch treeService.objectsState(connectionId: connectionId, database: database, schema: schema) { case .idle, .loading: loadingRow(String(localized: "Loading tables\u{2026}")) - .task(id: "\(database)|\(schema ?? "")") { - Self.logger.debug( - "task->loadTables db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)" - ) - await treeService.loadTables( - connectionId: connectionId, database: database, schema: schema - ) + .task(id: "\(database)|\(schema ?? "")|\(connectionToken)") { + await treeService.loadObjects(connectionId: connectionId, database: database, schema: schema) } case .failed(let message): errorRow(message) @@ -396,82 +332,69 @@ struct DatabaseTreeView: View { .foregroundStyle(.secondary) } - private func tables(database: String, schema: String?) -> [TableInfo] { - treeService.tables(connectionId: connectionId, database: database, schema: schema) - } + // MARK: - Selection actions - private func routines(database: String, schema: String?) -> [RoutineInfo] { - treeService.routines(connectionId: connectionId, database: database, schema: schema) + @MainActor + private func activate(_ ref: DatabaseTreeTableRef?) async { + guard let ref else { return } + if ref.database != activeDatabase { + await coordinator?.switchDatabase(to: ref.database) + } + if let schema = ref.schema, + schema != coordinator?.toolbarState.currentSchema, + PluginManager.shared.supportsSchemaSwitching(for: databaseType) { + await coordinator?.switchSchema(to: schema) + } } - private var visibleDatabases: [DatabaseMetadata] { - let nonSystem = databases.filter { !$0.isSystemDatabase } - let matched = searchText.isEmpty ? nonSystem : nonSystem.filter { databaseMatchesSearch($0) } - var seen = Set() - return matched.filter { seen.insert($0.id).inserted } + private func setActiveDatabase(_ database: String) { + guard database != activeDatabase else { return } + Task { await coordinator?.switchDatabase(to: database) } } - 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 + private func setActiveSchema(database: String, schema: String) { + Task { + if database != activeDatabase { + await coordinator?.switchDatabase(to: database) + } + if schema != coordinator?.toolbarState.currentSchema { + await coordinator?.switchSchema(to: schema) } } - 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 nonSystem = all.filter { !systemSchemas.contains($0) } - let matched = searchText.isEmpty - ? nonSystem - : nonSystem.filter { schema in - schema.localizedCaseInsensitiveContains(searchText) - || schemaContentMatchesSearch(database: database, schema: schema) + private func openTable(_ table: TableInfo, in database: String, schema: String?) { + Task { @MainActor in + if database != activeDatabase { + await coordinator?.switchDatabase(to: database) } - var seen = Set() - return matched.filter { seen.insert($0).inserted } + if let schema, + schema != coordinator?.toolbarState.currentSchema, + PluginManager.shared.supportsSchemaSwitching(for: databaseType) { + await coordinator?.switchSchema(to: schema) + } + coordinator?.openTableTab(table) + } } - private func filteredTables(database: String, schema: String?) -> [TableInfo] { - let all = tables(database: database, schema: schema) - let matched = searchText.isEmpty - ? all - : all.filter { $0.name.localizedCaseInsensitiveContains(searchText) } - var seen = Set() - return matched.filter { seen.insert($0.id).inserted } + 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 filteredRoutines(database: String, schema: String?) -> [RoutineInfo] { - let all = routines(database: database, schema: schema) - let matched = searchText.isEmpty - ? all - : all.filter { $0.name.localizedCaseInsensitiveContains(searchText) } - var seen = Set() - return matched.filter { seen.insert($0.id).inserted } - } + // MARK: - Expansion private func databaseExpansionBinding(for database: String) -> Binding { Binding( get: { !searchText.isEmpty || windowState.expandedTreeDatabases.contains(database) }, set: { isExpanded in - Self.logger.debug("expand-database db=\(database, privacy: .public) expanded=\(isExpanded, privacy: .public)") if isExpanded { windowState.expandedTreeDatabases.insert(database) - loadDatabaseContentIfNeeded(database) } else { windowState.expandedTreeDatabases.remove(database) } @@ -484,12 +407,8 @@ struct DatabaseTreeView: View { return Binding( get: { !searchText.isEmpty || windowState.expandedTreeDatabaseSchemas.contains(key) }, set: { isExpanded in - Self.logger.debug( - "expand-schema db=\(database, privacy: .public) schema=\(schema, privacy: .public) expanded=\(isExpanded, privacy: .public)" - ) if isExpanded { windowState.expandedTreeDatabaseSchemas.insert(key) - loadTablesIfNeeded(database: database, schema: schema) } else { windowState.expandedTreeDatabaseSchemas.remove(key) } @@ -497,167 +416,69 @@ struct DatabaseTreeView: View { ) } - private func loadDatabasesIfNeeded() { - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { - Self.logger.debug("loadDatabasesIfNeeded skip-no-driver connId=\(connectionId, privacy: .public)") - return - } - Task { - await treeService.loadDatabaseList( - connectionId: connectionId, - driver: driver, - databaseType: databaseType - ) - } - } - - private func reconcileLoads(retryFailed: Bool) { - guard case .loaded = treeService.databaseListState(for: connectionId) else { - Self.logger.debug("reconcile skip (db list not loaded) retryFailed=\(retryFailed, privacy: .public)") - return - } - let expandedSchemaCount = windowState.expandedTreeDatabaseSchemas.count - Self.logger.debug( - "reconcile begin retryFailed=\(retryFailed, privacy: .public) active=\(committedActiveDatabase ?? "nil", privacy: .public) activeSchema=\(committedActiveSchema ?? "nil", privacy: .public) expandedDbs=\(windowState.expandedTreeDatabases.count, privacy: .public) expandedSchemas=\(expandedSchemaCount, privacy: .public)" - ) - - if let active = committedActiveDatabase { - ensureContentLoaded( - database: active, - schema: supportsSchemaLevel ? committedActiveSchema : nil, - retryFailed: retryFailed - ) - } - - for database in windowState.expandedTreeDatabases { - guard !supportsSchemaLevel else { - ensureSchemaListLoaded(database: database, retryFailed: retryFailed) - let expandedSchemas = windowState.expandedTreeDatabaseSchemas - .filter { $0.database == database } - for key in expandedSchemas { - ensureContentLoaded(database: database, schema: key.schema, retryFailed: retryFailed) - } - continue - } - ensureContentLoaded(database: database, schema: nil, retryFailed: retryFailed) - } - } - - private func ensureSchemaListLoaded(database: String, retryFailed: Bool) { - let state = treeService.schemaListState(connectionId: connectionId, database: database) - switch state { - case .idle: - Self.logger.debug("reconcile kick schemaList db=\(database, privacy: .public) reason=idle") - loadDatabaseContentIfNeeded(database) - case .failed where retryFailed: - Self.logger.debug("reconcile kick schemaList db=\(database, privacy: .public) reason=retryFailed") - loadDatabaseContentIfNeeded(database) - case .loading, .loaded, .failed: - return - } - } - - private func ensureContentLoaded(database: String, schema: String?, retryFailed: Bool) { - let state = treeService.tableState(connectionId: connectionId, database: database, schema: schema) - switch state { - case .idle: - Self.logger.debug( - "reconcile kick tables db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) reason=idle" - ) - loadTablesIfNeeded(database: database, schema: schema) - case .failed where retryFailed: - Self.logger.debug( - "reconcile kick tables db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) reason=retryFailed" - ) - loadTablesIfNeeded(database: database, schema: schema) - case .loading, .loaded, .failed: - return - } - } + // MARK: - Search filtering - private func loadDatabaseContentIfNeeded(_ database: String) { - if supportsSchemaLevel { - Task { await treeService.loadSchemaList(connectionId: connectionId, database: database) } - } else { - loadTablesIfNeeded(database: database, schema: nil) - } + private func tables(database: String, schema: String?) -> [TableInfo] { + treeService.tables(connectionId: connectionId, database: database, schema: schema) } - private func loadTablesIfNeeded(database: String, schema: String?) { - Task { - await treeService.loadTables(connectionId: connectionId, database: database, schema: schema) - } + private func routines(database: String, schema: String?) -> [RoutineInfo] { + treeService.routines(connectionId: connectionId, database: database, schema: schema) } - private func refreshDatabase(_ database: String) { - Task { - await treeService.refreshDatabase(connectionId: connectionId, database: database) - loadDatabaseContentIfNeeded(database) - } + private var visibleDatabases: [DatabaseMetadata] { + let nonSystem = databases.filter { !$0.isSystemDatabase } + let matched = searchText.isEmpty ? nonSystem : nonSystem.filter { databaseMatchesSearch($0) } + var seen = Set() + return matched.filter { seen.insert($0.id).inserted } } - private func refreshSchema(database: String, schema: String) { - Task { - await treeService.reloadTables( - connectionId: connectionId, database: database, schema: schema - ) + private func databaseMatchesSearch(_ db: DatabaseMetadata) -> Bool { + if db.name.localizedCaseInsensitiveContains(searchText) { return true } + if case .loaded(let list) = treeService.schemaListState(connectionId: connectionId, database: db.name) { + if list.contains(where: { $0.localizedCaseInsensitiveContains(searchText) }) { return true } + for schema in list where schemaContentMatchesSearch(database: db.name, schema: schema) { + return true + } } + return schemaContentMatchesSearch(database: db.name, schema: nil) } - 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 schemaContentMatchesSearch(database: String, schema: String?) -> Bool { + if let schema, schema.localizedCaseInsensitiveContains(searchText) { return true } + if tables(database: database, schema: schema).contains(where: { $0.name.localizedCaseInsensitiveContains(searchText) }) { + return true } + return routines(database: database, schema: schema).contains { $0.name.localizedCaseInsensitiveContains(searchText) } } - private func setActiveDatabase(_ database: String) { - guard database != activeDatabase else { return } - Self.logger.debug("setActiveDatabase db=\(database, privacy: .public)") - Task { @MainActor in - await coordinator?.switchDatabase(to: database) - Self.logger.debug( - "setActiveDatabase done db=\(database, privacy: .public) committed=\(committedActiveDatabase ?? "nil", privacy: .public)" - ) - } + private func visibleSchemas(database: String, all: [String]) -> [String] { + let nonSystem = all.filter { !systemSchemas.contains($0) } + let matched = searchText.isEmpty + ? nonSystem + : nonSystem.filter { schema in + schema.localizedCaseInsensitiveContains(searchText) + || schemaContentMatchesSearch(database: database, schema: schema) + } + var seen = Set() + return matched.filter { seen.insert($0).inserted } } - private func setActiveSchema(database: String, schema: String) { - Self.logger.debug( - "setActiveSchema db=\(database, privacy: .public) schema=\(schema, privacy: .public)" - ) - Task { @MainActor in - if database != activeDatabase { - await coordinator?.switchDatabase(to: database) - } - if schema != coordinator?.toolbarState.currentSchema { - await coordinator?.switchSchema(to: schema) - } - } + private func filteredTables(database: String, schema: String?) -> [TableInfo] { + let all = tables(database: database, schema: schema) + let matched = searchText.isEmpty + ? all + : all.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + var seen = Set() + return matched.filter { seen.insert($0.id).inserted } } - private func openTable(_ table: TableInfo, in database: String, schema: String?) { - Task { @MainActor in - Self.logger.debug( - "openTable begin table=\(table.name, privacy: .public) db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) committed=\(committedActiveDatabase ?? "nil", privacy: .public)" - ) - if database != committedActiveDatabase { - Self.logger.debug("openTable switchDatabase -> \(database, privacy: .public)") - await coordinator?.switchDatabase(to: database) - } - if let schema, - schema != coordinator?.toolbarState.currentSchema, - PluginManager.shared.supportsSchemaSwitching(for: databaseType) { - Self.logger.debug("openTable switchSchema -> \(schema, privacy: .public)") - await coordinator?.switchSchema(to: schema) - } - Self.logger.debug( - "openTable openTableTab table=\(table.name, privacy: .public) committed=\(committedActiveDatabase ?? "nil", privacy: .public) currentSchema=\(coordinator?.toolbarState.currentSchema ?? "nil", privacy: .public)" - ) - coordinator?.openTableTab(table) - } + private func filteredRoutines(database: String, schema: String?) -> [RoutineInfo] { + let all = routines(database: database, schema: schema) + let matched = searchText.isEmpty + ? all + : all.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + var seen = Set() + return matched.filter { seen.insert($0.id).inserted } } } From 78c33bc145302d1020a880e151b7d34a465cc0c0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 29 May 2026 19:45:42 +0700 Subject: [PATCH 17/18] refactor(sidebar): simplify metadata pool and drop diagnostic logging (#139) --- .../Query/DatabaseTreeMetadataService.swift | 34 ++---- .../Query/MetadataConnectionPool.swift | 110 ++++-------------- .../Services/Query/MetadataLoadState.swift | 31 ----- .../Core/Services/Query/SchemaService.swift | 32 +---- 4 files changed, 33 insertions(+), 174 deletions(-) diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index 87fa925fd..6d9960131 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -75,10 +75,7 @@ final class DatabaseTreeMetadataService { // MARK: - Loads func loadDatabases(connectionId: UUID, databaseType: DatabaseType) async { - guard isConnected(connectionId) else { - Self.logger.debug("loadDatabases skip-not-connected connId=\(connectionId, privacy: .public)") - return - } + guard isConnected(connectionId) else { return } switch databaseListState(for: connectionId) { case .loaded, .loading: return case .idle, .failed: break @@ -94,20 +91,16 @@ final class DatabaseTreeMetadataService { } } databaseList[connectionId] = .loaded(list) - Self.logger.debug("loadDatabases loaded connId=\(connectionId, privacy: .public) count=\(list.count, privacy: .public)") } catch is CancellationError { - resetIfLoading(databaseConnectionId: connectionId) + if case .loading = databaseList[connectionId] { databaseList[connectionId] = .idle } } catch { databaseList[connectionId] = .failed(error.localizedDescription) - Self.logger.warning("loadDatabases failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)") + Self.logger.warning("databases load failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)") } } func loadSchemas(connectionId: UUID, database: String) async { - guard isConnected(connectionId) else { - Self.logger.debug("loadSchemas skip-not-connected db=\(database, privacy: .public)") - return - } + guard isConnected(connectionId) else { return } let key = DatabaseKey(connectionId: connectionId, database: database) switch schemaList[key] ?? .idle { case .loaded, .loading: return @@ -121,20 +114,16 @@ final class DatabaseTreeMetadataService { } } schemaList[key] = .loaded(list) - Self.logger.debug("loadSchemas loaded db=\(database, privacy: .public) count=\(list.count, privacy: .public)") } catch is CancellationError { if case .loading = schemaList[key] { schemaList[key] = .idle } } catch { schemaList[key] = .failed(error.localizedDescription) - Self.logger.warning("loadSchemas failed db=\(database, privacy: .public) error=\(error.localizedDescription, privacy: .public)") + Self.logger.warning("schemas load failed db=\(database, privacy: .public) error=\(error.localizedDescription, privacy: .public)") } } func loadObjects(connectionId: UUID, database: String, schema: String?) async { - guard isConnected(connectionId) else { - Self.logger.debug("loadObjects skip-not-connected db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public)") - return - } + guard isConnected(connectionId) else { return } let key = Self.objectsKey(connectionId: connectionId, database: database, schema: schema) switch objects[key] ?? .idle { case .loaded, .loading: return @@ -155,15 +144,12 @@ final class DatabaseTreeMetadataService { } } objects[key] = .loaded(result) - Self.logger.debug( - "loadObjects loaded db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) tables=\(result.tables.count, privacy: .public) routines=\(result.routines.count, privacy: .public)" - ) } catch is CancellationError { if case .loading = objects[key] { objects[key] = .idle } } catch { objects[key] = .failed(error.localizedDescription) Self.logger.warning( - "loadObjects failed db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) error=\(error.localizedDescription, privacy: .public)" + "objects load failed db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) error=\(error.localizedDescription, privacy: .public)" ) } } @@ -193,13 +179,11 @@ final class DatabaseTreeMetadataService { // MARK: - Lifecycle func handleReconnect(connectionId: UUID) async { - Self.logger.debug("handleReconnect connId=\(connectionId, privacy: .public)") MetadataConnectionPool.shared.closeAll(connectionId: connectionId) await resetPending(connectionId: connectionId) } func handleDisconnect(connectionId: UUID) async { - Self.logger.debug("handleDisconnect connId=\(connectionId, privacy: .public)") MetadataConnectionPool.shared.closeAll(connectionId: connectionId) await databaseDedup.cancel(key: connectionId) for key in schemaList.keys where key.connectionId == connectionId { @@ -237,10 +221,6 @@ final class DatabaseTreeMetadataService { } } - private func resetIfLoading(databaseConnectionId connectionId: UUID) { - if case .loading = databaseList[connectionId] { databaseList[connectionId] = .idle } - } - private func isConnected(_ connectionId: UUID) -> Bool { DatabaseManager.shared.session(for: connectionId)?.status == .connected } diff --git a/TablePro/Core/Services/Query/MetadataConnectionPool.swift b/TablePro/Core/Services/Query/MetadataConnectionPool.swift index 171cf4d53..1e5c0ed1d 100644 --- a/TablePro/Core/Services/Query/MetadataConnectionPool.swift +++ b/TablePro/Core/Services/Query/MetadataConnectionPool.swift @@ -4,7 +4,6 @@ // import Foundation -import os @MainActor final class MetadataConnectionPool { @@ -20,14 +19,12 @@ final class MetadataConnectionPool { 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 {} } } @@ -35,76 +32,40 @@ final class MetadataConnectionPool { private var pending: [Key: Task] = [:] 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 @escaping (DatabaseDriver) async throws -> T + _ body: @Sendable (DatabaseDriver) async throws -> T ) async throws -> T { - Self.logger.debug( - "[metadata-pool] withDriver acquire connId=\(connectionId, privacy: .public) db=\(database, privacy: .public)" - ) let entry = try await acquireEntry(connectionId: connectionId, database: database) entry.inFlightCount += 1 entry.lastUsed = Date() 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 } - do { - let result = try await work.value - Self.logger.debug( - "[metadata-pool] withDriver done connId=\(connectionId, privacy: .public) db=\(database, privacy: .public)" - ) - return result - } catch { - Self.logger.debug( - "[metadata-pool] withDriver threw connId=\(connectionId, privacy: .public) db=\(database, privacy: .public) error=\(error.localizedDescription, privacy: .public)" - ) - throw error - } - } - - 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) - closeOrDeferEntry(forKey: key) + return try await body(entry.driver) } func closeAll(connectionId: UUID) { - for key in pending.keys.filter({ $0.connectionId == connectionId }) { + for key in pending.keys where key.connectionId == connectionId { pending[key]?.cancel() pending.removeValue(forKey: key) } - let keys = entries.keys.filter { $0.connectionId == connectionId } - for key in keys { + for key in entries.keys where key.connectionId == connectionId { closeOrDeferEntry(forKey: key) } - if !keys.isEmpty { - Self.logger.info( - "[metadata-pool] closed all connId=\(connectionId, privacy: .public) count=\(keys.count, privacy: .public)" - ) + } + + private func releaseEntry(_ entry: Entry) { + entry.inFlightCount -= 1 + if entry.inFlightCount == 0, entry.closeWhenIdle { + entry.driver.disconnect() } } private func closeOrDeferEntry(forKey key: Key) { - guard let entry = entries[key] else { return } - entries.removeValue(forKey: key) + guard let entry = entries.removeValue(forKey: key) else { return } if entry.inFlightCount == 0 { entry.driver.disconnect() } else { @@ -115,21 +76,16 @@ final class MetadataConnectionPool { 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 { - Self.logger.debug("[metadata-pool] reuse connId=\(connectionId, privacy: .public) db=\(database, privacy: .public)") return entry } if let inFlight = pending[key] { - Self.logger.debug("[metadata-pool] await-pending connId=\(connectionId, privacy: .public) db=\(database, privacy: .public)") try await inFlight.value guard let entry = entries[key] else { throw DatabaseError.notConnected } return entry } guard DatabaseManager.shared.session(for: connectionId) != nil else { - Self.logger.debug( - "[metadata-pool] acquire-no-session connId=\(connectionId, privacy: .public) db=\(database, privacy: .public)" - ) throw DatabaseError.notConnected } @@ -144,13 +100,8 @@ final class MetadataConnectionPool { entries[key] = entry } pending[key] = task - do { - try await task.value - } catch { - if pending[key] == task { pending.removeValue(forKey: key) } - throw error - } - if pending[key] == task { pending.removeValue(forKey: key) } + defer { if pending[key] == task { pending.removeValue(forKey: key) } } + try await task.value guard let entry = entries[key] else { throw DatabaseError.notConnected } return entry @@ -160,12 +111,11 @@ final class MetadataConnectionPool { guard let session = DatabaseManager.shared.session(for: key.connectionId) else { throw DatabaseError.notConnected } - let baseConnection = session.effectiveConnection ?? session.connection - var cloned = baseConnection - cloned.database = key.database + var connection = session.effectiveConnection ?? session.connection + connection.database = key.database let driver = try await DatabaseDriverFactory.createDriver( - for: cloned, + for: connection, passwordOverride: session.cachedPassword, awaitPlugins: true ) @@ -175,26 +125,17 @@ final class MetadataConnectionPool { driver.disconnect() throw error } - Self.logger.info( - "[metadata-pool] opened connId=\(key.connectionId, privacy: .public) db=\(key.database, privacy: .public)" - ) return Entry(driver: driver) } 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 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 - )] + throw DatabaseError.connectionFailed( + String(format: String(localized: "Connecting to '%@' timed out."), database) ) } try await group.next() @@ -206,12 +147,11 @@ final class MetadataConnectionPool { 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( - "[metadata-pool] evicted connId=\(connectionId, privacy: .public) db=\(oldest.key.database, privacy: .public)" - ) + let oldestIdle = live + .filter { $0.value.inFlightCount == 0 } + .min { $0.value.lastUsed < $1.value.lastUsed } + guard let oldestIdle else { return } + oldestIdle.value.driver.disconnect() + entries.removeValue(forKey: oldestIdle.key) } } diff --git a/TablePro/Core/Services/Query/MetadataLoadState.swift b/TablePro/Core/Services/Query/MetadataLoadState.swift index 2bd394184..bb1716924 100644 --- a/TablePro/Core/Services/Query/MetadataLoadState.swift +++ b/TablePro/Core/Services/Query/MetadataLoadState.swift @@ -15,37 +15,6 @@ enum MetadataLoadState: Sendable { if case .loaded(let value) = self { return value } return nil } - - var label: String { - switch self { - case .idle: return "idle" - case .loading: return "loading" - case .loaded: return "loaded" - case .failed: return "failed" - } - } } extension MetadataLoadState: Equatable where Value: Equatable {} - -extension SchemaState { - var label: String { - switch self { - case .idle: return "idle" - case .loading: return "loading" - case .loaded(let tables): return "loaded(\(tables.count))" - case .failed: return "failed" - } - } -} - -extension ConnectionStatus { - var label: String { - switch self { - case .disconnected: return "disconnected" - case .connecting: return "connecting" - case .connected: return "connected" - case .error: return "error" - } - } -} diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index 50fa7bc40..eac104d56 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -89,28 +89,14 @@ final class SchemaService { } func loadSchemaTables(connectionId: UUID, schema: String, driver: DatabaseDriver) async { - if case .loaded = schemaState(for: connectionId, schema: schema) { - Self.logger.debug( - "[schema] loadSchemaTables skip-loaded connId=\(connectionId, privacy: .public) schema=\(schema, privacy: .public)" - ) - return - } - Self.logger.debug( - "[schema] loadSchemaTables begin connId=\(connectionId, privacy: .public) schema=\(schema, privacy: .public) driver=\(driver.status.label, privacy: .public)" - ) + if case .loaded = schemaState(for: connectionId, schema: schema) { return } setPerSchemaState(.loading, connectionId: connectionId, schema: schema) do { let tables = try await perSchemaDedup.execute(key: SchemaKey(connectionId: connectionId, schema: schema)) { try await driver.fetchTables(schema: schema) } setPerSchemaState(.loaded(tables), connectionId: connectionId, schema: schema) - Self.logger.debug( - "[schema] loadSchemaTables loaded connId=\(connectionId, privacy: .public) schema=\(schema, privacy: .public) count=\(tables.count, privacy: .public)" - ) } catch is CancellationError { - Self.logger.debug( - "[schema] loadSchemaTables cancelled connId=\(connectionId, privacy: .public) schema=\(schema, privacy: .public)" - ) return } catch { Self.logger.warning( @@ -186,9 +172,6 @@ final class SchemaService { } func invalidate(connectionId: UUID) async { - Self.logger.debug( - "[schema] invalidate connId=\(connectionId, privacy: .public) perSchema=\(self.perSchemaStates[connectionId]?.count ?? 0, privacy: .public)" - ) await loadDedup.cancel(key: connectionId) await procedureDedup.cancel(key: connectionId) await functionDedup.cancel(key: connectionId) @@ -218,9 +201,6 @@ final class SchemaService { driver: DatabaseDriver, connection: DatabaseConnection ) async { - Self.logger.debug( - "[schema] runLoad begin connId=\(connectionId, privacy: .public) db=\(connection.database, privacy: .public) driver=\(driver.status.label, privacy: .public)" - ) states[connectionId] = .loading bumpGeneration(connectionId) @@ -263,11 +243,7 @@ final class SchemaService { procedures[connectionId] = loadedProcedures functions[connectionId] = loadedFunctions bumpGeneration(connectionId) - Self.logger.debug( - "[schema] runLoad loaded connId=\(connectionId, privacy: .public) tables=\(tables.count, privacy: .public) schemas=\(self.schemasInOrder[connectionId]?.count ?? 0, privacy: .public)" - ) } catch is CancellationError { - Self.logger.debug("[schema] runLoad cancelled connId=\(connectionId, privacy: .public)") return } catch { Self.logger.warning( @@ -340,9 +316,6 @@ final class SchemaService { guard let session = DatabaseManager.shared.activeSessions[connectionId], let driver = session.driver else { return } let connection = session.connection - Self.logger.debug( - "[schema] handleSchemaSwitch connId=\(connectionId, privacy: .public) schema=\(session.currentSchema ?? "nil", privacy: .public) grouping=\(connection.type.rawValue, privacy: .public)" - ) if PluginManager.shared.databaseGroupingStrategy(for: connection.type) == .hierarchicalSchema { await invalidate(connectionId: connectionId) await reload(connectionId: connectionId, driver: driver, connection: connection) @@ -352,9 +325,6 @@ final class SchemaService { } private func reloadCurrentSchemaContent(connectionId: UUID, driver: DatabaseDriver) async { - Self.logger.debug( - "[schema] reloadCurrentSchemaContent connId=\(connectionId, privacy: .public) driver=\(driver.status.label, privacy: .public)" - ) await loadDedup.cancel(key: connectionId) await procedureDedup.cancel(key: connectionId) await functionDedup.cancel(key: connectionId) From 569aacfdc74969962ca54d4275e7cb6c82da178e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 29 May 2026 19:56:21 +0700 Subject: [PATCH 18/18] fix(sidebar): serialize metadata pool queries, debounce tree search, harden reconnect reset (#139) --- .../Query/DatabaseTreeMetadataService.swift | 24 ++++++++++--------- .../Query/MetadataConnectionPool.swift | 19 +++++++++++++-- TablePro/Views/Sidebar/DatabaseTreeView.swift | 12 ++++++---- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index 6d9960131..29513ffbb 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -185,13 +185,11 @@ final class DatabaseTreeMetadataService { func handleDisconnect(connectionId: UUID) async { MetadataConnectionPool.shared.closeAll(connectionId: connectionId) + let schemaKeys = schemaList.keys.filter { $0.connectionId == connectionId } + let objectKeys = objects.keys.filter { $0.connectionId == connectionId } await databaseDedup.cancel(key: connectionId) - for key in schemaList.keys where key.connectionId == connectionId { - await schemaDedup.cancel(key: key) - } - for key in objects.keys where key.connectionId == connectionId { - await objectsDedup.cancel(key: key) - } + for key in schemaKeys { await schemaDedup.cancel(key: key) } + for key in objectKeys { await objectsDedup.cancel(key: key) } databaseList.removeValue(forKey: connectionId) schemaList = schemaList.filter { $0.key.connectionId != connectionId } objects = objects.filter { $0.key.connectionId != connectionId } @@ -200,18 +198,22 @@ final class DatabaseTreeMetadataService { // MARK: - Private private func resetPending(connectionId: UUID) async { + let schemaKeys = schemaList.keys.filter { $0.connectionId == connectionId } + let objectKeys = objects.keys.filter { $0.connectionId == connectionId } + if isPending(databaseList[connectionId]) { await databaseDedup.cancel(key: connectionId) - databaseList[connectionId] = .idle } - for (key, state) in schemaList where key.connectionId == connectionId && isPending(state) { + for key in schemaKeys where isPending(schemaList[key]) { await schemaDedup.cancel(key: key) - schemaList[key] = .idle } - for (key, state) in objects where key.connectionId == connectionId && isPending(state) { + for key in objectKeys where isPending(objects[key]) { await objectsDedup.cancel(key: key) - objects[key] = .idle } + + if isPending(databaseList[connectionId]) { databaseList[connectionId] = .idle } + for key in schemaKeys where isPending(schemaList[key]) { schemaList[key] = .idle } + for key in objectKeys where isPending(objects[key]) { objects[key] = .idle } } private func isPending(_ state: MetadataLoadState?) -> Bool { diff --git a/TablePro/Core/Services/Query/MetadataConnectionPool.swift b/TablePro/Core/Services/Query/MetadataConnectionPool.swift index 1e5c0ed1d..905fb20cd 100644 --- a/TablePro/Core/Services/Query/MetadataConnectionPool.swift +++ b/TablePro/Core/Services/Query/MetadataConnectionPool.swift @@ -14,11 +14,13 @@ final class MetadataConnectionPool { let database: String } + @MainActor private final class Entry { let driver: DatabaseDriver var lastUsed: Date var inFlightCount: Int var closeWhenIdle: Bool + private var tail: Task = Task {} init(driver: DatabaseDriver) { self.driver = driver @@ -26,6 +28,19 @@ final class MetadataConnectionPool { self.inFlightCount = 0 self.closeWhenIdle = false } + + func runSerially( + _ body: @Sendable @escaping (DatabaseDriver) async throws -> T + ) async throws -> T { + let previous = tail + let driver = self.driver + let work = Task { @MainActor () async throws -> T in + await previous.value + return try await body(driver) + } + tail = Task { @MainActor in _ = try? await work.value } + return try await work.value + } } private var entries: [Key: Entry] = [:] @@ -38,13 +53,13 @@ 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 { releaseEntry(entry) } - return try await body(entry.driver) + return try await entry.runSerially(body) } func closeAll(connectionId: UUID) { diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index b81d32ac4..289174901 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -55,6 +55,7 @@ struct DatabaseTreeView: View { let coordinator: MainContentCoordinator? @State private var localSelection: Set = [] + @State private var searchText: String = "" private var groupingStrategy: GroupingStrategy { PluginManager.shared.databaseGroupingStrategy(for: databaseType) @@ -89,10 +90,6 @@ struct DatabaseTreeView: View { treeService.databases(for: connectionId) } - private var searchText: String { - viewModel.searchText - } - private var selectedTablesBinding: Binding> { Binding( get: { localSelection }, @@ -116,6 +113,13 @@ struct DatabaseTreeView: View { .task(id: connectionToken) { await treeService.loadDatabases(connectionId: connectionId, databaseType: databaseType) } + .task(id: viewModel.searchText) { + let live = viewModel.searchText + guard !live.isEmpty else { searchText = ""; return } + try? await Task.sleep(nanoseconds: 250_000_000) + guard !Task.isCancelled else { return } + searchText = live + } .onAppear { expandActive() } .onChange(of: activeContextKey) { _, _ in expandActive() } .onChange(of: localSelection) { oldRefs, newRefs in