diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b338421a..96763b413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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) - A connection can read its password from a file, environment variable, or command at connect time instead of the Keychain, so scripts can provision a connection without entering the password by hand. (#1254) ### Fixed 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"> 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/Infrastructure/SidebarContainerViewController.swift b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift index 9b43e6fee..82c810f88 100644 --- a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift +++ b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift @@ -12,7 +12,7 @@ internal final class SidebarContainerViewController: NSViewController { private var hostingController: NSHostingController 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,37 @@ 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 { + 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() } } + deinit { + observationTask?.cancel() + } + private func syncFromState(_ state: SharedSidebarState, windowState: WindowSidebarState) { let activeText: String let placeholder: String @@ -130,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/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift new file mode 100644 index 000000000..29513ffbb --- /dev/null +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -0,0 +1,250 @@ +// +// 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 ObjectsKey: Hashable, Sendable { + let connectionId: UUID + let database: String + let schema: String? + } + + struct SchemaObjects: Equatable, Sendable { + var tables: [TableInfo] + var routines: [RoutineInfo] + } + + private(set) var databaseList: [UUID: MetadataLoadState<[DatabaseMetadata]>] = [:] + private(set) var schemaList: [DatabaseKey: MetadataLoadState<[String]>] = [:] + private(set) var objects: [ObjectsKey: MetadataLoadState] = [:] + + @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" + ) + + private init() {} + + // MARK: - Reads + + func databaseListState(for connectionId: UUID) -> MetadataLoadState<[DatabaseMetadata]> { + databaseList[connectionId] ?? .idle + } + + func databases(for connectionId: UUID) -> [DatabaseMetadata] { + databaseList[connectionId]?.value ?? [] + } + + func schemaListState(connectionId: UUID, database: String) -> MetadataLoadState<[String]> { + schemaList[DatabaseKey(connectionId: connectionId, database: database)] ?? .idle + } + + func schemas(connectionId: UUID, database: String) -> [String] { + schemaList[DatabaseKey(connectionId: connectionId, database: database)]?.value ?? [] + } + + 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] { + objects[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)]?.value?.tables ?? [] + } + + func routines(connectionId: UUID, database: String, schema: String?) -> [RoutineInfo] { + objects[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)]?.value?.routines ?? [] + } + + // MARK: - Loads + + func loadDatabases(connectionId: UUID, databaseType: DatabaseType) async { + guard isConnected(connectionId) else { return } + 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 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)) + } + } + } + databaseList[connectionId] = .loaded(list) + } catch is CancellationError { + if case .loading = databaseList[connectionId] { databaseList[connectionId] = .idle } + } catch { + databaseList[connectionId] = .failed(error.localizedDescription) + 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 { return } + let key = DatabaseKey(connectionId: connectionId, database: database) + switch schemaList[key] ?? .idle { + case .loaded, .loading: return + case .idle, .failed: break + } + schemaList[key] = .loading + do { + let list = try await schemaDedup.execute(key: key) { [self] in + try await withDriver(connectionId: connectionId, database: database) { driver in + try await driver.fetchSchemas() + } + } + schemaList[key] = .loaded(list) + } catch is CancellationError { + if case .loading = schemaList[key] { schemaList[key] = .idle } + } catch { + schemaList[key] = .failed(error.localizedDescription) + 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 { 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 + do { + 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 + ) + } + } + objects[key] = .loaded(result) + } catch is CancellationError { + if case .loading = objects[key] { objects[key] = .idle } + } catch { + objects[key] = .failed(error.localizedDescription) + Self.logger.warning( + "objects load failed db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + } + } + + // MARK: - Refresh + + func refreshDatabases(connectionId: UUID, databaseType: DatabaseType) async { + await databaseDedup.cancel(key: connectionId) + databaseList.removeValue(forKey: connectionId) + await loadDatabases(connectionId: connectionId, databaseType: databaseType) + } + + 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 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) + } + + // MARK: - Lifecycle + + func handleReconnect(connectionId: UUID) async { + MetadataConnectionPool.shared.closeAll(connectionId: connectionId) + await resetPending(connectionId: connectionId) + } + + 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 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 } + } + + // 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) + } + for key in schemaKeys where isPending(schemaList[key]) { + await schemaDedup.cancel(key: key) + } + for key in objectKeys where isPending(objects[key]) { + await objectsDedup.cancel(key: key) + } + + 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 { + switch state { + case .loading, .failed: return true + case .idle, .loaded, .none: return false + } + } + + private func isConnected(_ connectionId: UUID) -> Bool { + DatabaseManager.shared.session(for: connectionId)?.status == .connected + } + + 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 objectsKey(connectionId: UUID, database: String, schema: String?) -> ObjectsKey { + let normalized: String? = (schema?.isEmpty == true) ? nil : schema + return ObjectsKey(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..905fb20cd --- /dev/null +++ b/TablePro/Core/Services/Query/MetadataConnectionPool.swift @@ -0,0 +1,172 @@ +// +// MetadataConnectionPool.swift +// TablePro +// + +import Foundation + +@MainActor +final class MetadataConnectionPool { + static let shared = MetadataConnectionPool() + + private struct Key: Hashable, Sendable { + let connectionId: UUID + 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 + self.lastUsed = Date() + 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] = [:] + private var pending: [Key: Task] = [:] + private let maxPerConnection = 4 + private let connectTimeoutSeconds: UInt64 = 15 + + private init() {} + + func withDriver( + connectionId: UUID, + database: String, + _ 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 entry.runSerially(body) + } + + func closeAll(connectionId: UUID) { + for key in pending.keys where key.connectionId == connectionId { + pending[key]?.cancel() + pending.removeValue(forKey: key) + } + for key in entries.keys where key.connectionId == connectionId { + closeOrDeferEntry(forKey: key) + } + } + + 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.removeValue(forKey: key) else { return } + 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 { + return entry + } + + 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 + } + + 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 + defer { if pending[key] == task { pending.removeValue(forKey: key) } } + try await task.value + + 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 + } + var connection = session.effectiveConnection ?? session.connection + connection.database = key.database + + let driver = try await DatabaseDriverFactory.createDriver( + for: connection, + passwordOverride: session.cachedPassword, + awaitPlugins: true + ) + do { + try await connectWithTimeout(driver: driver, database: key.database) + } catch { + driver.disconnect() + throw error + } + 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 Task.sleep(nanoseconds: timeoutNanos) + throw DatabaseError.connectionFailed( + String(format: String(localized: "Connecting to '%@' timed out."), database) + ) + } + try await group.next() + group.cancelAll() + } + } + + 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 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 new file mode 100644 index 000000000..bb1716924 --- /dev/null +++ b/TablePro/Core/Services/Query/MetadataLoadState.swift @@ -0,0 +1,20 @@ +// +// 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 + } +} + +extension MetadataLoadState: Equatable where Value: Equatable {} diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index ead0ed194..eac104d56 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 { @@ -296,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) + } } } 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/Models/UI/SharedSidebarState.swift b/TablePro/Models/UI/SharedSidebarState.swift index 4c4817143..1dbb65917 100644 --- a/TablePro/Models/UI/SharedSidebarState.swift +++ b/TablePro/Models/UI/SharedSidebarState.swift @@ -15,6 +15,11 @@ internal enum SidebarTab: String, CaseIterable { case favorites } +internal enum SidebarLayout: String, CaseIterable, Sendable { + case flat + case tree +} + @MainActor @Observable final class SharedSidebarState { var redisKeyTreeViewModel: RedisKeyTreeViewModel? @@ -28,6 +33,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 +66,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/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..a9fd9e4c8 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -11298,6 +11298,9 @@ } } } + }, + "Connecting to '%@' timed out." : { + }, "Connecting to %@" : { @@ -15066,6 +15069,9 @@ } } } + }, + "Default layout for new connections:" : { + }, "Default Operator" : { "localizations" : { @@ -26903,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", @@ -27447,6 +27456,9 @@ } } } + }, + "List" : { + }, "List all databases on the server" : { @@ -27778,6 +27790,9 @@ } } } + }, + "Loading schemas…" : { + }, "Loading tables..." : { "extractionState" : "stale", @@ -30713,6 +30728,9 @@ }, "No databases" : { + }, + "No Databases" : { + }, "No databases found" : { "extractionState" : "stale", @@ -30925,6 +30943,9 @@ } } } + }, + "No items" : { + }, "No keys" : { "localizations" : { @@ -31603,6 +31624,9 @@ } } } + }, + "No schemas" : { + }, "No schemas found" : { "extractionState" : "stale", @@ -43102,6 +43126,15 @@ } } } + }, + "Sidebar as List" : { + + }, + "Sidebar as Tree" : { + + }, + "Sidebar Layout" : { + }, "Sidebar Panel" : { "extractionState" : "stale", @@ -48446,6 +48479,9 @@ } } } + }, + "This server has no databases yet." : { + }, "This sets %lld loaded rows. Review and Save to apply." : { @@ -51189,6 +51225,12 @@ } } } + }, + "Use as Active Database" : { + + }, + "Use as Active Schema" : { + }, "Use clipboard URL" : { 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/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/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 575450b59..c257f08bd 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -259,6 +259,18 @@ final class MainContentCommandActions { PluginManager.shared.supportsDatabaseSwitching(for: connection.type) } + var canSwitchSidebarLayout: Bool { + PluginManager.shared.supportsDatabaseTree(for: connection.type) + } + + 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/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 6ea2a8e13..6317634dc 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -731,6 +731,10 @@ final class MainContentCoordinator { driver: driver, connection: connection ) + await DatabaseTreeMetadataService.shared.loadDatabases( + connectionId: connectionId, + databaseType: connection.type + ) await reconcilePostSchemaLoad() } 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/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 new file mode 100644 index 000000000..289174901 --- /dev/null +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -0,0 +1,488 @@ +// +// DatabaseTreeView.swift +// TablePro +// + +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 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 + + 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 = [] + @State private var searchText: String = "" + + 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 isConnected: Bool { + DatabaseManager.shared.session(for: connectionId)?.status == .connected + } + + private var connectionToken: String { + isConnected ? "connected" : "down" + } + + private var systemSchemas: Set { + Set(PluginManager.shared.systemSchemaNames(for: databaseType)) + } + + private var databases: [DatabaseMetadata] { + treeService.databases(for: connectionId) + } + + private var selectedTablesBinding: Binding> { + Binding( + get: { localSelection }, + set: { localSelection = $0 } + ) + } + + var body: some View { + Group { + switch treeService.databaseListState(for: connectionId) { + case .failed(let message): + errorState(message: message) + case .loaded where databases.isEmpty: + emptyDatabasesState + case .loaded: + treeList + case .idle, .loading: + loadingState + } + } + .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 + guard let ref = SelectionDelta.singleAddition(old: oldRefs, new: newRefs) else { return } + openTable(ref.table, in: ref.database, schema: ref.schema) + } + } + + private var activeContextKey: String { + "\(activeDatabase ?? "")|\(activeSchema ?? "")" + } + + 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: DatabaseTreeTableRef.self) { selection in + SidebarContextMenu( + 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 activate(selection.first) } + ) + } primaryAction: { selection in + guard let ref = selection.first else { return } + openTable(ref.table, in: ref.database, schema: ref.schema) + } + .onExitCommand { + localSelection.removeAll() + } + } + + @ViewBuilder + private func databaseBody(_ db: DatabaseMetadata) -> some View { + if supportsSchemaLevel { + schemasContent(for: db.name) + } else { + objectsContent(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(db.isSystemDatabase ? AnyShapeStyle(.secondary) : AnyShapeStyle(.tint)) + } + .contextMenu { + Button(String(localized: "Use as Active Database")) { + setActiveDatabase(db.name) + } + .disabled(isActive) + Button(String(localized: "Refresh")) { + Task { await treeService.refreshSchemas(connectionId: connectionId, database: 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(.tint) + } + .contextMenu { + Button(String(localized: "Use as Active Schema")) { + setActiveSchema(database: database, schema: schema) + } + .disabled(isActive) + Button(String(localized: "Refresh")) { + Task { + await treeService.refreshObjects(connectionId: connectionId, 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 { + switch treeService.schemaListState(connectionId: connectionId, database: database) { + case .idle, .loading: + loadingRow(String(localized: "Loading schemas\u{2026}")) + .task(id: "\(database)|\(connectionToken)") { + await treeService.loadSchemas(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.map { DatabaseTreeSchemaRef(database: database, schema: $0) }) { ref in + DisclosureGroup(isExpanded: schemaExpansionBinding(database: ref.database, schema: ref.schema)) { + objectsContent(database: ref.database, schema: ref.schema) + } label: { + schemaHeader(database: ref.database, schema: ref.schema) + } + } + } + } + } + + @ViewBuilder + 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 ?? "")|\(connectionToken)") { + await treeService.loadObjects(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.map { DatabaseTreeTableRef(database: database, schema: schema, table: $0) }) { ref in + TableRow( + table: ref.table, + isPendingTruncate: pendingTruncates.contains(ref.table.name), + isPendingDelete: pendingDeletes.contains(ref.table.name) + ) + .tag(ref) + } + ForEach(routines.map { DatabaseTreeRoutineRef(database: database, schema: schema, routine: $0) }) { ref in + RoutineRowView(routine: ref.routine) + .contextMenu { + RoutineContextMenu(routine: ref.routine) { selected in + coordinator?.showRoutineDDL(selected) + } + } + } + } + } + } + + 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) + } + + // MARK: - Selection actions + + @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 func setActiveDatabase(_ database: String) { + guard database != activeDatabase else { return } + Task { await coordinator?.switchDatabase(to: database) } + } + + 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) + } + } + } + + 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) + } + } + + 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) + ) + } + } + + // MARK: - Expansion + + private func databaseExpansionBinding(for database: String) -> Binding { + Binding( + get: { !searchText.isEmpty || windowState.expandedTreeDatabases.contains(database) }, + set: { isExpanded in + if isExpanded { + windowState.expandedTreeDatabases.insert(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) + } else { + windowState.expandedTreeDatabaseSchemas.remove(key) + } + } + ) + } + + // MARK: - Search filtering + + 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 } + 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 { + 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 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 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 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 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 } + } +} 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..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(.system(.callout, design: .monospaced)) .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/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..78676ed52 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 @@ -55,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) @@ -71,48 +68,65 @@ 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) Divider() - if isView { - Button("Edit View Definition") { - if let viewName = clickedTable?.name { - coordinator?.editViewDefinition(viewName) + if clickedTable != nil { + if isView { + Button("Edit View Definition") { + perform { + if let viewName = clickedTable?.name { + coordinator?.editViewDefinition(viewName) + } + } } + .disabled(isReadOnly) } - .disabled(isReadOnly) - } - Button("Show Structure") { - if let clickedTable { - coordinator?.openTableTab(clickedTable, showStructure: true) + Button("Show Structure") { + perform { + if let clickedTable { + coordinator?.openTableTab(clickedTable, showStructure: true) + } + } } } - .disabled(clickedTable == nil) - Button(String(localized: "View ER Diagram")) { - coordinator?.showERDiagram() + Button("View ER Diagram") { + perform { coordinator?.showERDiagram() } } - Button("Copy Name") { - ClipboardService.shared.writeText(effectiveTableNames.joined(separator: ",")) - } - .disabled(!hasSelection) + if hasSelection { + Button("Copy Name") { + ClipboardService.shared.writeText(effectiveTableNames.joined(separator: ",")) + } - Button("Export...") { - coordinator?.openExportDialog(preselectedTableNames: Set(effectiveTableNames)) + Button("Export...") { + perform { coordinator?.openExportDialog(preselectedTableNames: Set(effectiveTableNames)) } + } } - .disabled(!hasSelection) if SidebarContextMenuLogic.importVisible( clickedTable: clickedTable, @@ -121,17 +135,20 @@ struct SidebarContextMenu: View { ) ) { Button("Import...") { - coordinator?.openImportDialog() + perform { 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) { - if let table = clickedTable?.name { - coordinator?.showMaintenanceSheet(operation: op, tableName: table) + perform { + if let table = clickedTable?.name { + coordinator?.showMaintenanceSheet(operation: op, tableName: table) + } } } } @@ -139,21 +156,23 @@ struct SidebarContextMenu: View { .disabled(isReadOnly) } - Divider() + if hasSelection { + Divider() - if SidebarContextMenuLogic.truncateVisible(clickedTable: clickedTable) { - Button("Truncate") { - onBatchToggleTruncate(effectiveTableNames) + if SidebarContextMenuLogic.truncateVisible(clickedTable: clickedTable) { + Button("Truncate") { + perform { 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 + ) { + perform { 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/SidebarTreeView.swift b/TablePro/Views/Sidebar/SidebarTreeView.swift index 9cb90a4d1..babc46f98 100644 --- a/TablePro/Views/Sidebar/SidebarTreeView.swift +++ b/TablePro/Views/Sidebar/SidebarTreeView.swift @@ -12,7 +12,7 @@ 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 +49,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 +83,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 +97,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 +141,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 +153,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 +190,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..a273bb971 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,31 @@ struct SidebarView: View { private var tablesContent: some View { if groupingStrategy == .hierarchicalSchema { hierarchicalContent + } else if usesDatabaseTree { + databaseTreeContent } else { flatContent } } + private var usesDatabaseTree: Bool { + PluginManager.shared.supportsDatabaseTree(for: viewModel.databaseType) + && sidebarState.sidebarLayout == .tree + } + + @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,14 +272,21 @@ struct SidebarView: View { } ) } header: { - Text("Keys") + Text(String(localized: "Keys")) } } } .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) @@ -311,16 +338,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 6c23ef681..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,40 +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) + .foregroundStyle(.red) + } else if isPendingTruncate { + Image(systemName: "exclamationmark.circle.fill") + .font(.caption) + .foregroundStyle(.orange) + } } var body: some View { Label { Text(table.name) - .font(.system(.callout, design: .monospaced)) .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(Color.accentColor) + .frame(width: 16) + .overlay(alignment: .bottomTrailing) { + pendingStateBadge } - } } - .padding(.vertical, 4) .accessibilityElement(children: .combine) .accessibilityLabel(TableRowLogic.accessibilityLabel(table: table, isPendingDelete: isPendingDelete, isPendingTruncate: isPendingTruncate)) } 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/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") + } +} 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")