diff --git a/CHANGELOG.md b/CHANGELOG.md index 711099064..2df5867e1 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 +- Schema picker at the bottom of the Tables sidebar to switch the active schema, matching the database switcher pattern (#1296) - Inline dropdown picker when editing ENUM and SET columns, covering MySQL, MariaDB, PostgreSQL, ClickHouse, DuckDB, and MongoDB JSON-schema enums (#1283) - Filter rows show an enum dropdown for `=` and `!=` operators on enum columns (#1283) diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 02cbb83f8..402cad0ed 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -64,6 +64,8 @@ protocol DatabaseDriver: AnyObject { /// Fetch all tables in the database func fetchTables() async throws -> [TableInfo] + func fetchTables(schema: String?) async throws -> [TableInfo] + /// Fetch columns for a specific table func fetchColumns(table: String) async throws -> [ColumnInfo] @@ -359,6 +361,10 @@ extension DatabaseDriver { /// Default: no schema support (MySQL/SQLite don't use schemas in the same way) func fetchSchemas() async throws -> [String] { [] } + func fetchTables(schema: String?) async throws -> [TableInfo] { + try await fetchTables() + } + func fetchProcedures(schema: String?) async throws -> [RoutineInfo] { [] } func fetchFunctions(schema: String?) async throws -> [RoutineInfo] { [] } diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 97d72fa24..ce54d5b6c 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -272,6 +272,7 @@ extension DatabaseManager { session.currentSchema = schema } appSettingsStorage.saveLastSchema(schema, for: connectionId) + AppEvents.shared.currentSchemaChanged.send(connectionId) } func switchToSession(_ sessionId: UUID) { diff --git a/TablePro/Core/Events/AppEvents.swift b/TablePro/Core/Events/AppEvents.swift index f2eeaf2dd..7fe7c69a1 100644 --- a/TablePro/Core/Events/AppEvents.swift +++ b/TablePro/Core/Events/AppEvents.swift @@ -22,6 +22,8 @@ final class AppEvents { let dataGridSettingsChanged = PassthroughSubject() + let currentSchemaChanged = PassthroughSubject() + let aiSettingsChanged = PassthroughSubject() let terminalSettingsChanged = PassthroughSubject() diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 6005f62c7..e031ed9f7 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -160,25 +160,38 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { func fetchTables() async throws -> [TableInfo] { let pluginTables = try await pluginDriver.fetchTables(schema: pluginDriver.currentSchema) - return pluginTables.map { table in - let tableType: TableInfo.TableType - switch table.type.lowercased() { - case "table", "base table", "prefix": - tableType = .table - case "view": - tableType = .view - case "materialized view", "materialized_view": - tableType = .materializedView - case "foreign table", "foreign_table": - tableType = .foreignTable - case "system table", "system base table", "system view": - tableType = .systemTable - default: - Self.logger.warning("Unknown plugin table type \"\(table.type, privacy: .public)\" for \"\(table.name, privacy: .public)\"; defaulting to .table") - tableType = .table - } - return TableInfo(name: table.name, type: tableType, rowCount: table.rowCount) + return pluginTables.map { mapPluginTable($0, schemaFallback: nil) } + } + + func fetchTables(schema: String?) async throws -> [TableInfo] { + let resolvedSchema = schema ?? pluginDriver.currentSchema + let pluginTables = try await pluginDriver.fetchTables(schema: resolvedSchema) + return pluginTables.map { mapPluginTable($0, schemaFallback: resolvedSchema) } + } + + private func mapPluginTable(_ table: PluginTableInfo, schemaFallback: String?) -> TableInfo { + let tableType: TableInfo.TableType + switch table.type.lowercased() { + case "table", "base table", "prefix": + tableType = .table + case "view": + tableType = .view + case "materialized view", "materialized_view": + tableType = .materializedView + case "foreign table", "foreign_table": + tableType = .foreignTable + case "system table", "system base table", "system view": + tableType = .systemTable + default: + Self.logger.warning("Unknown plugin table type \"\(table.type, privacy: .public)\" for \"\(table.name, privacy: .public)\"; defaulting to .table") + tableType = .table } + return TableInfo( + name: table.name, + type: tableType, + rowCount: table.rowCount, + schema: table.schema ?? schemaFallback + ) } func fetchColumns(table: String) async throws -> [ColumnInfo] { diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index adb9bbdb5..eac09422d 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -18,7 +18,7 @@ extension PluginManager { if let driver = instance as? any DriverPlugin { if !declared.contains(.databaseDriver) { - Self.logger.warning("Plugin '\(pluginId)' conforms to DriverPlugin but does not declare .databaseDriver capability — registering anyway") + Self.logger.warning("Plugin '\(pluginId)' conforms to DriverPlugin but does not declare .databaseDriver capability - registering anyway") } do { try validateDriverDescriptor(type(of: driver), pluginId: pluginId) @@ -57,7 +57,7 @@ extension PluginManager { if let exportPlugin = instance as? any ExportFormatPlugin { if !declared.contains(.exportFormat) { - Self.logger.warning("Plugin '\(pluginId)' conforms to ExportFormatPlugin but does not declare .exportFormat capability — registering anyway") + Self.logger.warning("Plugin '\(pluginId)' conforms to ExportFormatPlugin but does not declare .exportFormat capability - registering anyway") } let formatId = type(of: exportPlugin).formatId exportPlugins[formatId] = exportPlugin @@ -67,7 +67,7 @@ extension PluginManager { if let importPlugin = instance as? any ImportFormatPlugin { if !declared.contains(.importFormat) { - Self.logger.warning("Plugin '\(pluginId)' conforms to ImportFormatPlugin but does not declare .importFormat capability — registering anyway") + Self.logger.warning("Plugin '\(pluginId)' conforms to ImportFormatPlugin but does not declare .importFormat capability - registering anyway") } let formatId = type(of: importPlugin).formatId importPlugins[formatId] = importPlugin diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 47d853aa4..93b3b7c60 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -333,18 +333,17 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi onDoubleClick: { [weak self] table in guard let coordinator = self?.sessionState?.coordinator else { return } let connectionId = coordinator.connectionId - let isView = table.type == .view if let preview = WindowLifecycleMonitor.shared.previewWindow(for: connectionId), let previewCoordinator = MainContentCoordinator.coordinator(for: preview.windowId) { if previewCoordinator.tabManager.selectedTab?.tableContext.tableName == table.name { previewCoordinator.promotePreviewTab() } else { previewCoordinator.promotePreviewTab() - coordinator.openTableTab(table.name, isView: isView) + coordinator.openTableTab(table) } } else { coordinator.promotePreviewTab() - coordinator.openTableTab(table.name, isView: isView) + coordinator.openTableTab(table) } }, pendingTruncates: sessionPendingTruncatesBinding, diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index 638720fdc..f42bc9cbe 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -96,13 +96,15 @@ enum SessionStateFactory { try tabMgr.addPreviewTableTab( tableName: tableName, databaseType: connection.type, - databaseName: payload.databaseName ?? activeDatabaseName + databaseName: payload.databaseName ?? activeDatabaseName, + schemaName: payload.schemaName ) } else { try tabMgr.addTableTab( tableName: tableName, databaseType: connection.type, - databaseName: payload.databaseName ?? activeDatabaseName + databaseName: payload.databaseName ?? activeDatabaseName, + schemaName: payload.schemaName ) } } catch { diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index b54dc3788..756ab4e23 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -3,6 +3,7 @@ // TablePro // +import Combine import Foundation import os @@ -14,14 +15,24 @@ final class SchemaService { private(set) var states: [UUID: SchemaState] = [:] private(set) var procedures: [UUID: [RoutineInfo]] = [:] private(set) var functions: [UUID: [RoutineInfo]] = [:] + private(set) var schemasInOrder: [UUID: [String]] = [:] @ObservationIgnored private var lastLoadDates: [UUID: Date] = [:] @ObservationIgnored private let loadDedup = OnceTask() @ObservationIgnored private let procedureDedup = OnceTask() @ObservationIgnored private let functionDedup = OnceTask() + @ObservationIgnored private let schemasDedup = OnceTask() + @ObservationIgnored private var schemaChangeCancellable: AnyCancellable? @ObservationIgnored private static let logger = Logger(subsystem: "com.TablePro", category: "SchemaService") - init() {} + init() { + schemaChangeCancellable = AppEvents.shared.currentSchemaChanged + .sink { [weak self] connectionId in + Task { @MainActor [weak self] in + await self?.handleSchemaSwitch(connectionId: connectionId) + } + } + } func state(for connectionId: UUID) -> SchemaState { states[connectionId] ?? .idle @@ -46,6 +57,10 @@ final class SchemaService { procedures(for: connectionId) + functions(for: connectionId) } + func schemas(for connectionId: UUID) -> [String] { + schemasInOrder[connectionId] ?? [] + } + func load(connectionId: UUID, driver: DatabaseDriver, connection: DatabaseConnection) async { switch state(for: connectionId) { case .loaded: @@ -107,12 +122,21 @@ final class SchemaService { await loadDedup.cancel(key: connectionId) await procedureDedup.cancel(key: connectionId) await functionDedup.cancel(key: connectionId) + await schemasDedup.cancel(key: connectionId) states.removeValue(forKey: connectionId) procedures.removeValue(forKey: connectionId) functions.removeValue(forKey: connectionId) + schemasInOrder.removeValue(forKey: connectionId) lastLoadDates.removeValue(forKey: connectionId) } + func refresh(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) + } + private func runLoad( connectionId: UUID, driver: DatabaseDriver, @@ -120,6 +144,12 @@ final class SchemaService { ) async { states[connectionId] = .loading + if PluginManager.shared.supportsSchemaSwitching(for: connection.type) { + Task { [weak self] in await self?.loadSchemaList(connectionId: connectionId, driver: driver) } + } else { + schemasInOrder.removeValue(forKey: connectionId) + } + async let tablesTask: [TableInfo] = loadDedup.execute(key: connectionId) { try await driver.fetchTables() } @@ -155,6 +185,21 @@ final class SchemaService { } } + private func loadSchemaList(connectionId: UUID, driver: DatabaseDriver) async { + do { + let allSchemas = try await schemasDedup.execute(key: connectionId) { + try await driver.fetchSchemas() + } + schemasInOrder[connectionId] = allSchemas + } catch is CancellationError { + return + } catch { + Self.logger.warning( + "[schema] fetchSchemas failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + } + } + private static func fetchRoutinesSafely( connectionId: UUID, kind: RoutineInfo.Kind, @@ -172,4 +217,11 @@ final class SchemaService { return [] } } + + 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) + } } diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index 5bc1d5244..3e61da4db 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -73,10 +73,16 @@ enum DatabaseError: Error, LocalizedError { /// Information about a database table struct TableInfo: Identifiable, Hashable, Sendable { - var id: String { "\(name)_\(type.rawValue)" } + var id: String { + if let schema, !schema.isEmpty { + return "\(schema).\(name)_\(type.rawValue)" + } + return "\(name)_\(type.rawValue)" + } let name: String let type: TableType let rowCount: Int? + let schema: String? enum TableType: String, Sendable { case table = "TABLE" @@ -86,13 +92,21 @@ struct TableInfo: Identifiable, Hashable, Sendable { case systemTable = "SYSTEM TABLE" } + init(name: String, type: TableType, rowCount: Int?, schema: String? = nil) { + self.name = name + self.type = type + self.rowCount = rowCount + self.schema = schema + } + static func == (lhs: TableInfo, rhs: TableInfo) -> Bool { - lhs.name == rhs.name && lhs.type == rhs.type + lhs.name == rhs.name && lhs.type == rhs.type && lhs.schema == rhs.schema } func hash(into hasher: inout Hasher) { hasher.combine(name) hasher.combine(type) + hasher.combine(schema) } } diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index a757e8681..e8b9ea634 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -142,10 +142,14 @@ final class QueryTabManager { tableName: String, databaseType: DatabaseType = .mysql, databaseName: String = "", + schemaName: String? = nil, quoteIdentifier: ((String) -> String)? = nil ) throws { if let existingTab = tabs.first(where: { - $0.tabType == .table && $0.tableContext.tableName == tableName && $0.tableContext.databaseName == databaseName + $0.tabType == .table + && $0.tableContext.tableName == tableName + && $0.tableContext.databaseName == databaseName + && $0.tableContext.schemaName == schemaName }) { selectedTabId = existingTab.id return @@ -153,20 +157,32 @@ final class QueryTabManager { let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize let query = try QueryTab.buildBaseTableQuery( - tableName: tableName, databaseType: databaseType, quoteIdentifier: quoteIdentifier + tableName: tableName, + databaseType: databaseType, + schemaName: schemaName, + quoteIdentifier: quoteIdentifier ) var newTab = QueryTab( - title: tableName, + title: Self.tabTitle(name: tableName, schema: schemaName, databaseType: databaseType), query: query, tabType: .table, tableName: tableName ) newTab.pagination = PaginationState(pageSize: pageSize) newTab.tableContext.databaseName = databaseName + newTab.tableContext.schemaName = schemaName tabs.append(newTab) selectedTabId = newTab.id } + static func tabTitle(name: String, schema: String?, databaseType: DatabaseType) -> String { + guard let schema, !schema.isEmpty else { return name } + let defaultSchema = PluginMetadataRegistry.shared + .snapshot(forTypeId: databaseType.pluginTypeId)? + .schema.defaultSchemaName ?? "" + return schema == defaultSchema ? name : "\(schema).\(name)" + } + func addCreateTableTab(databaseName: String = "") { let tabTitle = String(localized: "Create Table") var newTab = QueryTab(title: tabTitle, tabType: .createTable) @@ -219,10 +235,14 @@ final class QueryTabManager { tableName: String, databaseType: DatabaseType = .mysql, databaseName: String = "", + schemaName: String? = nil, quoteIdentifier: ((String) -> String)? = nil ) throws { if let existing = tabs.first(where: { - $0.tabType == .table && $0.tableContext.tableName == tableName && $0.tableContext.databaseName == databaseName + $0.tabType == .table + && $0.tableContext.tableName == tableName + && $0.tableContext.databaseName == databaseName + && $0.tableContext.schemaName == schemaName }) { selectedTabId = existing.id return @@ -230,16 +250,20 @@ final class QueryTabManager { let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize let query = try QueryTab.buildBaseTableQuery( - tableName: tableName, databaseType: databaseType, quoteIdentifier: quoteIdentifier + tableName: tableName, + databaseType: databaseType, + schemaName: schemaName, + quoteIdentifier: quoteIdentifier ) var newTab = QueryTab( - title: tableName, + title: Self.tabTitle(name: tableName, schema: schemaName, databaseType: databaseType), query: query, tabType: .table, tableName: tableName ) newTab.pagination = PaginationState(pageSize: pageSize) newTab.tableContext.databaseName = databaseName + newTab.tableContext.schemaName = schemaName newTab.isPreview = true tabs.append(newTab) selectedTabId = newTab.id @@ -271,7 +295,7 @@ final class QueryTabManager { var tab = tabs[selectedIndex] tab.tabType = .table - tab.title = tableName + tab.title = Self.tabTitle(name: tableName, schema: schemaName, databaseType: databaseType) tab.tableContext.tableName = tableName tab.content.query = query tab.schemaVersion += 1 diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index f794ccf4c..83f38da6e 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -20692,6 +20692,7 @@ } }, "Failed to load schemas" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -27500,9 +27501,6 @@ } } } - }, - "Loading schemas…" : { - }, "Loading tables..." : { "extractionState" : "stale", @@ -31282,9 +31280,6 @@ } } } - }, - "No schemas" : { - }, "No schemas found" : { "extractionState" : "stale", @@ -31331,9 +31326,6 @@ } } } - }, - "No schemas match “%@”" : { - }, "No selection" : { "localizations" : { @@ -40741,9 +40733,6 @@ }, "Search saved query history. Returns matching entries with execution time, row count, and outcome." : { - }, - "Search schemas" : { - }, "Search schemas..." : { "extractionState" : "stale", @@ -41363,6 +41352,9 @@ } } } + }, + "Select schema" : { + }, "Select SQL File..." : { "extractionState" : "stale", @@ -42507,6 +42499,9 @@ } } } + }, + "Show System Schemas" : { + }, "Show Welcome Screen" : { "localizations" : { diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index b4bd87b5e..ed1af2718 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -244,7 +244,7 @@ struct AppMenuCommands: Commands { actions?.saveChanges() } .optionalKeyboardShortcut(shortcut(for: .saveChanges)) - // Match toolbar: also disable when no pending changes — avoids + // Match toolbar: also disable when no pending changes - avoids // a no-op Cmd+S when nothing has been edited. .disabled( !(actions?.isConnected ?? false) @@ -423,7 +423,7 @@ struct AppMenuCommands: Commands { // Check if first responder is a text view (SQL editor) if let firstResponder = NSApp.keyWindow?.firstResponder, firstResponder is NSTextView || firstResponder is TextView { - // Send undo: (with colon) through responder chain — + // Send undo: (with colon) through responder chain - // CodeEditTextView.TextView responds to undo: via @objc func undo(_:) NSApp.sendAction(#selector(TableProResponderActions.undo(_:)), to: nil, from: nil) } else { diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index f0d034ed0..638b1b381 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -2,9 +2,6 @@ // DatabaseSwitcherViewModel.swift // TablePro // -// ViewModel for DatabaseSwitcherSheet. -// Handles database fetching, metadata loading, recent tracking, and switching logic. -// import Foundation import Observation @@ -15,15 +12,6 @@ import SwiftUI final class DatabaseSwitcherViewModel { private static let logger = Logger(subsystem: "com.TablePro", category: "DatabaseSwitcherViewModel") - // MARK: - Mode - - enum Mode: Hashable { - case database - case schema - } - - // MARK: - Published State - var databases: [DatabaseMetadata] = [] var searchText = "" { didSet { selectedDatabase = filteredDatabases.first?.name } @@ -32,21 +20,12 @@ final class DatabaseSwitcherViewModel { var isLoading = false var errorMessage: String? var showPreview = false - var mode: Mode - - /// Whether we're switching schemas (Redshift or PostgreSQL in schema mode) - var isSchemaMode: Bool { mode == .schema } - - // MARK: - Dependencies private let connectionId: UUID private let currentDatabase: String? - private let currentSchema: String? private let databaseType: DatabaseType @ObservationIgnored private let services: AppServices - // MARK: - Computed Properties - var filteredDatabases: [DatabaseMetadata] { if searchText.isEmpty { return databases @@ -56,28 +35,18 @@ final class DatabaseSwitcherViewModel { } } - // MARK: - Initialization - init( - connectionId: UUID, currentDatabase: String?, currentSchema: String?, - databaseType: DatabaseType, services: AppServices = .live, - initialMode: Mode? = nil + connectionId: UUID, + currentDatabase: String?, + databaseType: DatabaseType, + services: AppServices = .live ) { self.connectionId = connectionId self.currentDatabase = currentDatabase - self.currentSchema = currentSchema self.databaseType = databaseType self.services = services - if let initialMode { - self.mode = initialMode - } else { - self.mode = services.pluginManager.supportsSchemaSwitching(for: databaseType) ? .schema : .database - } } - // MARK: - Public Methods - - /// Fetch databases (or schemas for Redshift) and their metadata func fetchDatabases() async { isLoading = true errorMessage = nil @@ -89,43 +58,20 @@ final class DatabaseSwitcherViewModel { return } - if isSchemaMode { - // Redshift: fetch schemas instead of databases - let schemaNames = try await driver.fetchSchemas() - databases = schemaNames.map { name in - DatabaseMetadata.minimal(name: name, isSystem: isSystemItem(name)) - } - } else { - // MySQL/MariaDB/PostgreSQL: fetch databases with metadata - // Show database names immediately, then load metadata - let dbNames = try await driver.fetchDatabases() - databases = dbNames.sorted().map { name in - DatabaseMetadata.minimal(name: name, isSystem: isSystemItem(name)) - } - - // Pre-select before metadata loads so the UI is interactive immediately - preselectDatabase() - - // Fetch all metadata in a single batched query - isLoading = false - do { - let metadataList = try await driver.fetchAllDatabaseMetadata() - databases = metadataList.sorted { $0.name < $1.name } - preselectDatabase() - } catch { - Self.logger.error("Failed to fetch database metadata: \(error)") - } - return + let dbNames = try await driver.fetchDatabases() + databases = dbNames.sorted().map { name in + DatabaseMetadata.minimal(name: name, isSystem: isSystemItem(name)) } - isLoading = false + preselectDatabase() - // Pre-select current database/schema or first item - let current = isSchemaMode ? currentSchema : currentDatabase - if let current, databases.contains(where: { $0.name == current }) { - selectedDatabase = current - } else { - selectedDatabase = databases.first?.name + isLoading = false + do { + let metadataList = try await driver.fetchAllDatabaseMetadata() + databases = metadataList.sorted { $0.name < $1.name } + preselectDatabase() + } catch { + Self.logger.error("Failed to fetch database metadata: \(error)") } } catch { errorMessage = error.localizedDescription @@ -133,7 +79,6 @@ final class DatabaseSwitcherViewModel { } } - /// Refresh database list func refreshDatabases() async { await fetchDatabases() } @@ -153,17 +98,13 @@ final class DatabaseSwitcherViewModel { try await driver.createDatabase(request) } - /// Drop a database func dropDatabase(name: String) async throws { guard let driver = services.databaseManager.driver(for: connectionId) else { throw DatabaseError.notConnected } - try await driver.dropDatabase(name: name) } - // MARK: - Keyboard Navigation - func moveUp() { let items = filteredDatabases guard !items.isEmpty else { return } @@ -187,8 +128,6 @@ final class DatabaseSwitcherViewModel { } } - // MARK: - Private Methods - private func preselectDatabase() { if let current = currentDatabase, databases.contains(where: { $0.name == current }) { selectedDatabase = current @@ -198,11 +137,6 @@ final class DatabaseSwitcherViewModel { } private func isSystemItem(_ name: String) -> Bool { - if isSchemaMode { - let schemaNames = services.pluginManager.systemSchemaNames(for: databaseType) - return schemaNames.contains(name) - } - let dbNames = services.pluginManager.systemDatabaseNames(for: databaseType) - return dbNames.contains(name) + services.pluginManager.systemDatabaseNames(for: databaseType).contains(name) } } diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift index 0060d5e43..028b80fda 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift @@ -2,9 +2,6 @@ import AppKit import SwiftUI import TableProPluginKit -/// Bridges the toolbar's weak coordinator reference to a concrete `DatabaseSwitcherPopover`. -/// Resolves the current database/schema at presentation time so the popover always reflects -/// the active session, even after the user switches between tabs. struct DatabaseSwitcherPopoverHost: View { weak var coordinator: MainContentCoordinator? @@ -13,21 +10,14 @@ struct DatabaseSwitcherPopoverHost: View { let connection = coordinator.connection let session = DatabaseManager.shared.session(for: connection.id) let activeDatabase = session?.currentDatabase ?? connection.database - let activeSchema = session?.currentSchema DatabaseSwitcherPopover( currentDatabase: activeDatabase, - currentSchema: activeSchema, databaseType: connection.type, connectionId: connection.id, onSelect: { [weak coordinator] database in Task { await coordinator?.switchDatabase(to: database) } }, - onSelectSchema: PluginManager.shared.supportsSchemaSwitching(for: connection.type) - ? { [weak coordinator] schema in - Task { await coordinator?.switchSchema(to: schema) } - } - : nil, onRequestCreate: { [weak coordinator] in coordinator?.activeSheet = .createDatabase }, @@ -43,11 +33,9 @@ struct DatabaseSwitcherPopoverHost: View { struct DatabaseSwitcherPopover: View { let currentDatabase: String? - let currentSchema: String? let databaseType: DatabaseType let connectionId: UUID let onSelect: (String) -> Void - let onSelectSchema: ((String) -> Void)? let onRequestCreate: () -> Void let onRequestDrop: (String) -> Void @@ -62,58 +50,40 @@ struct DatabaseSwitcherPopover: View { @FocusState private var focus: FocusField? - /// Fixed popover dimensions. Matches the native pattern of Emoji Picker, - /// Font Panel, and Color Picker — popovers with tabs and search keep stable - /// chrome and a stable frame so switching tabs doesn't reflow the surface. private static let popoverWidth: CGFloat = 320 private static let popoverHeight: CGFloat = 360 - private var isSchemaMode: Bool { viewModel.isSchemaMode } - private var activeName: String? { isSchemaMode ? currentSchema : currentDatabase } private var supportsDropDatabase: Bool { - !isSchemaMode && PluginManager.shared.supportsDropDatabase(for: databaseType) - } - private var supportsSchemaSwitching: Bool { - PluginManager.shared.supportsSchemaSwitching(for: databaseType) + PluginManager.shared.supportsDropDatabase(for: databaseType) } private var showsCreateRow: Bool { - !isSchemaMode && supportsCreateDatabase + supportsCreateDatabase } init( currentDatabase: String?, - currentSchema: String?, databaseType: DatabaseType, connectionId: UUID, onSelect: @escaping (String) -> Void, - onSelectSchema: ((String) -> Void)? = nil, onRequestCreate: @escaping () -> Void, onRequestDrop: @escaping (String) -> Void ) { self.currentDatabase = currentDatabase - self.currentSchema = currentSchema self.databaseType = databaseType self.connectionId = connectionId self.onSelect = onSelect - self.onSelectSchema = onSelectSchema self.onRequestCreate = onRequestCreate self.onRequestDrop = onRequestDrop self._viewModel = State( wrappedValue: DatabaseSwitcherViewModel( connectionId: connectionId, currentDatabase: currentDatabase, - currentSchema: currentSchema, databaseType: databaseType )) } var body: some View { VStack(spacing: 0) { - if supportsSchemaSwitching { - modePicker - Divider() - } - searchField Divider() @@ -143,9 +113,6 @@ struct DatabaseSwitcherPopover: View { } } - /// Hidden ⌘R binding. Native popovers (Mail mailbox switcher, Safari tab group - /// picker) don't show a visible refresh button — they auto-refresh on open via - /// `.task`. We keep the shortcut for power users. private var refreshShortcut: some View { Button("") { Task { await viewModel.refreshDatabases() } @@ -154,23 +121,6 @@ struct DatabaseSwitcherPopover: View { .hidden() } - private var modePicker: some View { - Picker("", selection: $viewModel.mode) { - Text(String(localized: "Databases")) - .tag(DatabaseSwitcherViewModel.Mode.database) - Text(String(localized: "Schemas")) - .tag(DatabaseSwitcherViewModel.Mode.schema) - } - .pickerStyle(.segmented) - .labelsHidden() - .controlSize(.small) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .onChange(of: viewModel.mode) { - Task { await viewModel.fetchDatabases() } - } - } - private var searchField: some View { HStack(spacing: 5) { Image(systemName: "magnifyingglass") @@ -181,7 +131,7 @@ struct DatabaseSwitcherPopover: View { TextField( "", text: $viewModel.searchText, - prompt: Text(searchPlaceholder) + prompt: Text(String(localized: "Search databases")) .foregroundStyle(.tertiary) ) .textFieldStyle(.plain) @@ -229,12 +179,6 @@ struct DatabaseSwitcherPopover: View { .onAppear { focus = .search } } - private var searchPlaceholder: String { - isSchemaMode - ? String(localized: "Search schemas") - : String(localized: "Search databases") - } - @ViewBuilder private var content: some View { if viewModel.isLoading { @@ -278,7 +222,7 @@ struct DatabaseSwitcherPopover: View { } private func row(for database: DatabaseMetadata) -> some View { - let isCurrent = database.name == activeName + let isCurrent = database.name == currentDatabase return HStack(spacing: 8) { Image(systemName: "checkmark") .font(.body.weight(.semibold)) @@ -286,7 +230,7 @@ struct DatabaseSwitcherPopover: View { .opacity(isCurrent ? 1 : 0) .frame(width: 14) - Image(systemName: rowIcon(for: database)) + Image(systemName: database.icon) .font(.body) .foregroundStyle(database.isSystemDatabase ? Color.secondary : Color.accentColor) .frame(width: 16) @@ -306,20 +250,13 @@ struct DatabaseSwitcherPopover: View { .tag(database.name) } - private func rowIcon(for database: DatabaseMetadata) -> String { - if isSchemaMode { - return "folder.fill" - } - return database.icon - } - @ViewBuilder private func contextMenuItems(for selection: Set) -> some View { if supportsDropDatabase, let name = selection.first, let database = viewModel.filteredDatabases.first(where: { $0.name == name }), !database.isSystemDatabase, - database.name != activeName { + database.name != currentDatabase { Button(role: .destructive) { dismiss() onRequestDrop(database.name) @@ -332,9 +269,7 @@ struct DatabaseSwitcherPopover: View { private var loadingView: some View { VStack(spacing: 10) { ProgressView().controlSize(.small) - Text(isSchemaMode - ? String(localized: "Loading schemas…") - : String(localized: "Loading databases…")) + Text(String(localized: "Loading databases…")) .font(.callout) .foregroundStyle(.secondary) } @@ -346,9 +281,7 @@ struct DatabaseSwitcherPopover: View { Image(systemName: "exclamationmark.triangle") .font(.title3) .foregroundStyle(Color(nsColor: .systemOrange)) - Text(isSchemaMode - ? String(localized: "Failed to load schemas") - : String(localized: "Failed to load databases")) + Text(String(localized: "Failed to load databases")) .font(.callout.weight(.medium)) Text(message) .font(.caption) @@ -387,14 +320,10 @@ struct DatabaseSwitcherPopover: View { .font(.title3) .foregroundStyle(.secondary) if viewModel.searchText.isEmpty { - Text(isSchemaMode - ? String(localized: "No schemas") - : String(localized: "No databases")) + Text(String(localized: "No databases")) .font(.callout.weight(.medium)) } else { - Text(isSchemaMode - ? String(format: String(localized: "No schemas match “%@”"), viewModel.searchText) - : String(format: String(localized: "No databases match “%@”"), viewModel.searchText)) + Text(String(format: String(localized: "No databases match “%@”"), viewModel.searchText)) .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -424,15 +353,11 @@ struct DatabaseSwitcherPopover: View { private func commitSelection() { guard let name = viewModel.selectedDatabase else { return } - if name == activeName { + if name == currentDatabase { dismiss() return } - if isSchemaMode, supportsSchemaSwitching, let onSelectSchema { - onSelectSchema(name) - } else { - onSelect(name) - } + onSelect(name) dismiss() } diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 1eaed3a6b..a6cc11332 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -43,9 +43,7 @@ struct DatabaseSwitcherSheet: View { wrappedValue: DatabaseSwitcherViewModel( connectionId: connectionId, currentDatabase: currentDatabase, - currentSchema: nil, - databaseType: databaseType, - initialMode: .database + databaseType: databaseType )) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 56b7af6d8..6fe48011e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -15,7 +15,16 @@ private let navigationLogger = Logger(subsystem: "com.TablePro", category: "Main extension MainContentCoordinator { // MARK: - Table Tab Opening - func openTableTab(_ tableName: String, showStructure: Bool = false, isView: Bool = false) { + func openTableTab(_ table: TableInfo, showStructure: Bool = false) { + openTableTab( + table.name, + schema: table.schema, + showStructure: showStructure, + isView: table.type == .view + ) + } + + func openTableTab(_ tableName: String, schema: String? = nil, showStructure: Bool = false, isView: Bool = false) { let navigationModel = PluginMetadataRegistry.shared.snapshot( forTypeId: connection.type.pluginTypeId )?.navigationModel ?? .standard @@ -30,13 +39,14 @@ extension MainContentCoordinator { currentDatabase = activeDatabaseName } - let currentSchema = DatabaseManager.shared.session(for: connectionId)?.currentSchema + let resolvedSchema = schema // Fast path: if this table is already the active tab in the same database, skip all work if let current = tabManager.selectedTab, current.tabType == .table, current.tableContext.tableName == tableName, - current.tableContext.databaseName == currentDatabase { + current.tableContext.databaseName == currentDatabase, + current.tableContext.schemaName == resolvedSchema { if showStructure, let (_, tabIndex) = tabManager.selectedTabAndIndex { tabManager.mutate(at: tabIndex) { $0.display.resultsViewMode = .structure } } @@ -51,7 +61,8 @@ extension MainContentCoordinator { try tabManager.addTableTab( tableName: tableName, databaseType: connection.type, - databaseName: currentDatabase + databaseName: currentDatabase, + schemaName: resolvedSchema ) } catch { navigationLogger.error("openTableTab addTableTab failed: \(error.localizedDescription, privacy: .public)") @@ -67,6 +78,7 @@ extension MainContentCoordinator { tab.tabType == .table && tab.tableContext.tableName == tableName && tab.tableContext.databaseName == currentDatabase + && tab.tableContext.schemaName == resolvedSchema } guard hasMatch, let windowId = sibling.windowId, @@ -83,17 +95,19 @@ extension MainContentCoordinator { try tabManager.addPreviewTableTab( tableName: tableName, databaseType: connection.type, - databaseName: currentDatabase + databaseName: currentDatabase, + schemaName: resolvedSchema ) if let wid = windowId { WindowLifecycleMonitor.shared.setPreview(true, for: wid) - WindowLifecycleMonitor.shared.window(for: wid)?.subtitle = "\(connection.name) — Preview" + WindowLifecycleMonitor.shared.window(for: wid)?.subtitle = "\(connection.name) - Preview" } } else { try tabManager.addTableTab( tableName: tableName, databaseType: connection.type, - databaseName: currentDatabase + databaseName: currentDatabase, + schemaName: resolvedSchema ) } } catch { @@ -104,7 +118,7 @@ extension MainContentCoordinator { tabManager.mutate(at: tabIndex) { tab in tab.tableContext.isView = isView tab.tableContext.isEditable = !isView - tab.tableContext.schemaName = currentSchema + tab.tableContext.schemaName = resolvedSchema tab.pagination.reset() } toolbarState.isTableTab = true @@ -132,7 +146,7 @@ extension MainContentCoordinator { tableName: tableName, databaseType: connection.type, databaseName: currentDatabase, - schemaName: currentSchema + schemaName: resolvedSchema ) if replaced { clearFilterState() @@ -163,7 +177,7 @@ extension MainContentCoordinator { tabType: .table, tableName: tableName, databaseName: currentDatabase, - schemaName: currentSchema, + schemaName: resolvedSchema, isView: isView, showStructure: showStructure ) @@ -173,7 +187,7 @@ extension MainContentCoordinator { // Preview tab mode: reuse or create a preview tab instead of a new native window if AppSettingsManager.shared.tabs.enablePreviewTabs { - openPreviewTab(tableName, isView: isView, databaseName: currentDatabase, schemaName: currentSchema, showStructure: showStructure) + openPreviewTab(tableName, isView: isView, databaseName: currentDatabase, schemaName: resolvedSchema, showStructure: showStructure) return } @@ -183,7 +197,7 @@ extension MainContentCoordinator { tabType: .table, tableName: tableName, databaseName: currentDatabase, - schemaName: currentSchema, + schemaName: resolvedSchema, isView: isView, showStructure: showStructure ) @@ -462,12 +476,8 @@ extension MainContentCoordinator { tabSessionRegistry.removeAll() tabManager.tabs = [] tabManager.selectedTabId = nil - await SchemaService.shared.invalidate(connectionId: connectionId) - - await refreshTables() } catch { toolbarState.currentSchema = previousSchema - await refreshTables() navigationLogger.error("Failed to switch schema: \(error.localizedDescription, privacy: .public)") AlertHelper.showErrorSheet( diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 12be58fa8..2f5801633 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -99,12 +99,10 @@ extension MainContentView { ) { let action = TableSelectionAction.resolve(oldTables: oldTables, newTables: newTables) - guard case .navigate(let tableName, let isView) = action else { + guard case .navigate(let table) = action else { return } - // Only navigate when this is the focused window. - // Prevents feedback loops when shared sidebar state syncs across native tabs. guard coordinator.isKeyWindow else { return } @@ -113,7 +111,7 @@ extension MainContentView { let hasPreview = WindowLifecycleMonitor.shared.previewWindow(for: connection.id) != nil let result = SidebarNavigationResult.resolve( - clickedTableName: tableName, + clickedTableName: table.name, currentTabTableName: tabManager.selectedTab?.tableContext.tableName, hasExistingTabs: !tabManager.tabs.isEmpty, isPreviewTabMode: isPreviewMode, @@ -125,11 +123,9 @@ extension MainContentView { return case .openInPlace: coordinator.selectionState.indices = [] - coordinator.openTableTab(tableName, isView: isView) - case .revertAndOpenNewWindow: - coordinator.openTableTab(tableName, isView: isView) - case .replacePreviewTab, .openNewPreviewTab: - coordinator.openTableTab(tableName, isView: isView) + coordinator.openTableTab(table) + case .revertAndOpenNewWindow, .replacePreviewTab, .openNewPreviewTab: + coordinator.openTableTab(table) } } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index a43280809..864542161 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -171,9 +171,7 @@ struct MainContentView: View { let viewModel = DatabaseSwitcherViewModel( connectionId: connection.id, currentDatabase: nil, - currentSchema: nil, - databaseType: connection.type, - initialMode: .database + databaseType: connection.type ) CreateDatabaseSheet( databaseType: connection.type, diff --git a/TablePro/Views/Main/TableSelectionAction.swift b/TablePro/Views/Main/TableSelectionAction.swift index 935f14faf..2328471d6 100644 --- a/TablePro/Views/Main/TableSelectionAction.swift +++ b/TablePro/Views/Main/TableSelectionAction.swift @@ -14,9 +14,8 @@ enum TableSelectionAction: Equatable { /// Covers: Cmd+A (multi-select), Shift+click range, deselection. case noNavigation /// Exactly one table was added — navigate to it. - case navigate(tableName: String, isView: Bool) + case navigate(table: TableInfo) - /// Pure function — determines the action from old/new selection sets. static func resolve( oldTables: Set, newTables: Set @@ -25,7 +24,7 @@ enum TableSelectionAction: Equatable { guard added.count == 1, let table = added.first else { return .noNavigation } - return .navigate(tableName: table.name, isView: table.type == .view) + return .navigate(table: table) } } diff --git a/TablePro/Views/Sidebar/SchemaPickerFooter.swift b/TablePro/Views/Sidebar/SchemaPickerFooter.swift new file mode 100644 index 000000000..2dd9705f8 --- /dev/null +++ b/TablePro/Views/Sidebar/SchemaPickerFooter.swift @@ -0,0 +1,168 @@ +import AppKit +import os +import SwiftUI +import TableProPluginKit + +struct SchemaPickerFooter: View { + let connectionId: UUID + let databaseType: DatabaseType + + @Bindable private var schemaService = SchemaService.shared + @State private var showSystemSchemas = false + + private var currentSchema: String? { + DatabaseManager.shared.session(for: connectionId)?.currentSchema + } + + private var allSchemas: [String] { + schemaService.schemas(for: connectionId) + } + + private var systemSchemas: Set { + Set(PluginManager.shared.systemSchemaNames(for: databaseType)) + } + + private var userSchemas: [String] { + allSchemas.filter { !systemSchemas.contains($0) } + } + + private var visibleSystemSchemas: [String] { + allSchemas.filter { systemSchemas.contains($0) } + } + + var body: some View { + if allSchemas.count > 1 { + VStack(spacing: 0) { + Divider() + SchemaPopUpButton( + title: currentSchema ?? String(localized: "Select schema"), + userSchemas: userSchemas, + systemSchemas: visibleSystemSchemas, + showSystemSchemas: $showSystemSchemas, + currentSchema: currentSchema, + onSelect: select(schema:), + onRefresh: { Task { await schemaService.refresh(connectionId: connectionId) } } + ) + .padding(8) + } + } + } + + private func select(schema: String) { + guard schema != currentSchema else { return } + Task { + do { + try await DatabaseManager.shared.switchSchema(to: schema, for: connectionId) + } catch { + schemaPickerLogger.error("Schema switch to \(schema, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") + } + } + } +} + +private let schemaPickerLogger = Logger(subsystem: "com.TablePro", category: "SchemaPicker") + +private struct SchemaPopUpButton: NSViewRepresentable { + let title: String + let userSchemas: [String] + let systemSchemas: [String] + @Binding var showSystemSchemas: Bool + let currentSchema: String? + let onSelect: (String) -> Void + let onRefresh: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeNSView(context: Context) -> NSPopUpButton { + let button = NSPopUpButton(frame: .zero, pullsDown: true) + button.preferredEdge = .maxY + button.target = context.coordinator + button.action = #selector(Coordinator.itemSelected(_:)) + rebuildMenu(button: button, context: context) + return button + } + + func updateNSView(_ button: NSPopUpButton, context: Context) { + context.coordinator.parent = self + rebuildMenu(button: button, context: context) + } + + private func rebuildMenu(button: NSPopUpButton, context: Context) { + let menu = NSMenu() + menu.autoenablesItems = false + + let titleItem = NSMenuItem(title: title, action: nil, keyEquivalent: "") + menu.addItem(titleItem) + + for schema in userSchemas { + menu.addItem(schemaItem(schema, coordinator: context.coordinator)) + } + + if !systemSchemas.isEmpty { + menu.addItem(.separator()) + let toggleItem = NSMenuItem( + title: String(localized: "Show System Schemas"), + action: #selector(Coordinator.toggleSystem(_:)), + keyEquivalent: "" + ) + toggleItem.target = context.coordinator + toggleItem.state = showSystemSchemas ? .on : .off + menu.addItem(toggleItem) + + if showSystemSchemas { + for schema in systemSchemas { + menu.addItem(schemaItem(schema, coordinator: context.coordinator)) + } + } + } + + menu.addItem(.separator()) + let refreshItem = NSMenuItem( + title: String(localized: "Refresh"), + action: #selector(Coordinator.refreshTriggered(_:)), + keyEquivalent: "" + ) + refreshItem.target = context.coordinator + menu.addItem(refreshItem) + + button.menu = menu + } + + private func schemaItem(_ schema: String, coordinator: Coordinator) -> NSMenuItem { + let item = NSMenuItem( + title: schema, + action: #selector(Coordinator.schemaSelected(_:)), + keyEquivalent: "" + ) + item.target = coordinator + item.representedObject = schema + item.state = schema == currentSchema ? .on : .off + return item + } + + @MainActor + final class Coordinator: NSObject { + var parent: SchemaPopUpButton + + init(parent: SchemaPopUpButton) { + self.parent = parent + } + + @objc func itemSelected(_ sender: Any?) {} + + @objc func schemaSelected(_ sender: NSMenuItem) { + guard let schema = sender.representedObject as? String else { return } + parent.onSelect(schema) + } + + @objc func toggleSystem(_ sender: NSMenuItem) { + parent.showSystemSchemas.toggle() + } + + @objc func refreshTriggered(_ sender: NSMenuItem) { + parent.onRefresh() + } + } +} diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index 7feab60ab..ccd6ab942 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -94,8 +94,8 @@ struct SidebarContextMenu: View { } Button("Show Structure") { - if let tableName = clickedTable?.name { - coordinator?.openTableTab(tableName, showStructure: true) + if let clickedTable { + coordinator?.openTableTab(clickedTable, showStructure: true) } } .disabled(clickedTable == nil) diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 17b325db9..d4f31ef45 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -8,9 +8,6 @@ import SwiftUI import TableProPluginKit -// MARK: - SidebarView - -/// Sidebar view with segmented tab picker for Tables and Favorites struct SidebarView: View { @State private var viewModel: SidebarViewModel @Bindable private var schemaService = SchemaService.shared @@ -41,6 +38,10 @@ struct SidebarView: View { } } + private var supportsSchemaFooter: Bool { + PluginManager.shared.supportsSchemaSwitching(for: viewModel.databaseType) + } + private var selectedTablesBinding: Binding> { Binding( get: { sidebarState.selectedTables }, @@ -89,7 +90,13 @@ struct SidebarView: View { Group { switch sidebarState.selectedSidebarTab { case .tables: - tablesContent + VStack(spacing: 0) { + tablesContent + if supportsSchemaFooter { + Divider() + SchemaPickerFooter(connectionId: connectionId, databaseType: viewModel.databaseType) + } + } case .favorites: if let coordinator { FavoritesTabView( diff --git a/TablePro/Views/Structure/StructureGridDelegate.swift b/TablePro/Views/Structure/StructureGridDelegate.swift index 6f53a4c2e..4cf64ff52 100644 --- a/TablePro/Views/Structure/StructureGridDelegate.swift +++ b/TablePro/Views/Structure/StructureGridDelegate.swift @@ -627,6 +627,6 @@ final class StructureGridDelegate: DataGridViewDelegate { private func handleNavigateToFK(_ row: Int) { guard row < structureChangeManager.workingForeignKeys.count else { return } let fk = structureChangeManager.workingForeignKeys[row] - coordinator?.openTableTab(fk.referencedTable, showStructure: false, isView: false) + coordinator?.openTableTab(fk.referencedTable, schema: fk.referencedSchema) } } diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift index 0221fff34..4d82a20f6 100644 --- a/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift +++ b/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift @@ -154,4 +154,36 @@ struct PluginDriverAdapterTableTypeMappingTests { #expect(TableInfo.TableType(rawValue: "MATERIALIZED VIEW") == .materializedView) #expect(TableInfo.TableType(rawValue: "FOREIGN TABLE") == .foreignTable) } + + @Test("Plugin schema propagates to TableInfo when set on PluginTableInfo") + func pluginSchemaPropagates() async throws { + let driver = StubTableTypeDriver() + driver.stubbedTables = [ + PluginTableInfo(name: "users", type: "TABLE", schema: "analytics"), + PluginTableInfo(name: "orders", type: "TABLE", schema: "public") + ] + let adapter = makeAdapter(driver: driver) + let tables = try await adapter.fetchTables() + let bySchema = Dictionary(grouping: tables, by: { $0.schema ?? "" }) + #expect(bySchema["analytics"]?.first?.name == "users") + #expect(bySchema["public"]?.first?.name == "orders") + } + + @Test("fetchTables(schema:) resolves missing PluginTableInfo schema to requested schema") + func explicitSchemaFallback() async throws { + let driver = StubTableTypeDriver() + driver.stubbedTables = [PluginTableInfo(name: "logs", type: "TABLE")] + let adapter = makeAdapter(driver: driver) + let tables = try await adapter.fetchTables(schema: "audit") + #expect(tables.first?.schema == "audit") + } + + @Test("fetchTables() preserves nil schema (no fallback to currentSchema)") + func defaultFetchPreservesNilSchema() async throws { + let driver = StubTableTypeDriver() + driver.stubbedTables = [PluginTableInfo(name: "users", type: "TABLE")] + let adapter = makeAdapter(driver: driver) + let tables = try await adapter.fetchTables() + #expect(tables.first?.schema == nil) + } } diff --git a/TableProTests/Views/Main/SharedSidebarSyncTests.swift b/TableProTests/Views/Main/SharedSidebarSyncTests.swift index 883ea8f1d..afdeff294 100644 --- a/TableProTests/Views/Main/SharedSidebarSyncTests.swift +++ b/TableProTests/Views/Main/SharedSidebarSyncTests.swift @@ -35,7 +35,7 @@ struct SharedSidebarSyncTests { oldTables: previousSelectedTables, newTables: newSelectedTables ) - #expect(action == .navigate(tableName: "users", isView: false)) + #expect(action == .navigate(table: TableInfo(name: "users", type: .table, rowCount: nil))) // But SidebarNavigationResult.resolve skips because clicked == current tab let result = SidebarNavigationResult.resolve( @@ -75,7 +75,7 @@ struct SharedSidebarSyncTests { oldTables: [makeTable("orders")], newTables: [makeTable("users")] ) - #expect(action == .navigate(tableName: "users", isView: false)) + #expect(action == .navigate(table: TableInfo(name: "users", type: .table, rowCount: nil))) // But isKeyWindow guard blocks it. We test the invariant: // handleTableSelectionChange should early-return when isKeyWindow=false. @@ -106,7 +106,7 @@ struct SharedSidebarSyncTests { newTables: [makeTable("users")] ) // This produces .navigate — but SidebarNavigationResult catches it - #expect(action == .navigate(tableName: "users", isView: false)) + #expect(action == .navigate(table: TableInfo(name: "users", type: .table, rowCount: nil))) let result = SidebarNavigationResult.resolve( clickedTableName: "users", @@ -135,7 +135,7 @@ struct SharedSidebarSyncTests { oldTables: [makeTable("users")], newTables: [makeTable("orders")] ) - #expect(action == .navigate(tableName: "orders", isView: false)) + #expect(action == .navigate(table: TableInfo(name: "orders", type: .table, rowCount: nil))) let result = SidebarNavigationResult.resolve( clickedTableName: "orders", @@ -151,7 +151,7 @@ struct SharedSidebarSyncTests { oldTables: [], newTables: [makeTable("users")] ) - #expect(action == .navigate(tableName: "users", isView: false)) + #expect(action == .navigate(table: TableInfo(name: "users", type: .table, rowCount: nil))) let result = SidebarNavigationResult.resolve( clickedTableName: "users", @@ -168,7 +168,7 @@ struct SharedSidebarSyncTests { oldTables: [], newTables: [makeTable("users")] ) - #expect(action == .navigate(tableName: "users", isView: false)) + #expect(action == .navigate(table: TableInfo(name: "users", type: .table, rowCount: nil))) let result = SidebarNavigationResult.resolve( clickedTableName: "users", @@ -188,7 +188,7 @@ struct SharedSidebarSyncTests { oldTables: [makeTable("orders")], newTables: [makeTable("users")] ) - #expect(action == .navigate(tableName: "users", isView: false)) + #expect(action == .navigate(table: TableInfo(name: "users", type: .table, rowCount: nil))) // Window B's isKeyWindow = false → handleTableSelectionChange returns early // This is enforced by the guard, not by these pure functions } diff --git a/TableProTests/Views/Main/TableSelectionChangeTests.swift b/TableProTests/Views/Main/TableSelectionChangeTests.swift index 0d1095b45..bd871ae1e 100644 --- a/TableProTests/Views/Main/TableSelectionChangeTests.swift +++ b/TableProTests/Views/Main/TableSelectionChangeTests.swift @@ -21,7 +21,7 @@ struct TableSelectionChangeTests { let old: Set = [] let new: Set = [TestFixtures.makeTableInfo(name: "orders")] let action = TableSelectionAction.resolve(oldTables: old, newTables: new) - #expect(action == .navigate(tableName: "orders", isView: false)) + #expect(action == .navigate(table: TableInfo(name: "orders", type: .table, rowCount: nil))) } @Test("Single click on a view — navigate with isView true") @@ -30,7 +30,7 @@ struct TableSelectionChangeTests { let view = TableInfo(name: "my_view", type: .view, rowCount: nil) let new: Set = [view] let action = TableSelectionAction.resolve(oldTables: old, newTables: new) - #expect(action == .navigate(tableName: "my_view", isView: true)) + #expect(action == .navigate(table: TableInfo(name: "my_view", type: .view, rowCount: nil))) } @Test("Cmd+click adds exactly one more table — navigate to it") @@ -40,7 +40,7 @@ struct TableSelectionChangeTests { let old: Set = [existing] let new: Set = [existing, added] let action = TableSelectionAction.resolve(oldTables: old, newTables: new) - #expect(action == .navigate(tableName: "orders", isView: false)) + #expect(action == .navigate(table: TableInfo(name: "orders", type: .table, rowCount: nil))) } // MARK: - Multi-selection (Cmd+A, Shift+click)