diff --git a/CHANGELOG.md b/CHANGELOG.md index 47d32c92a..3f29b7d6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Show schemas as collapsible sections in the Tables sidebar (#1296) +- Schema picker at the bottom of the Tables sidebar to switch the active schema (#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) - CSV/TSV inspector: open files from Finder or File > Open, edit cells, multi-condition filter (Cmd+F), multi-column sort (shift-click headers), add/remove/rename columns with type override, copy/paste rows as TSV, undo/redo, auto-reload on external changes. Large files stream from disk without loading into memory. (#1259) 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 1991f5d1d..7fe7c69a1 100644 --- a/TablePro/Core/Events/AppEvents.swift +++ b/TablePro/Core/Events/AppEvents.swift @@ -20,10 +20,10 @@ final class AppEvents { let editorSettingsChanged = PassthroughSubject() - let sidebarSettingsChanged = PassthroughSubject() - let dataGridSettingsChanged = PassthroughSubject() + let currentSchemaChanged = PassthroughSubject() + let aiSettingsChanged = PassthroughSubject() let terminalSettingsChanged = PassthroughSubject() diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index ee9accf48..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, schema: table.schema, isView: isView) + coordinator.openTableTab(table) } } else { coordinator.promotePreviewTab() - coordinator.openTableTab(table.name, schema: table.schema, isView: isView) + coordinator.openTableTab(table) } }, pendingTruncates: sessionPendingTruncatesBinding, diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index 4488e1476..2131fd3d2 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -22,16 +22,14 @@ final class SchemaService { @ObservationIgnored private let procedureDedup = OnceTask() @ObservationIgnored private let functionDedup = OnceTask() @ObservationIgnored private let schemasDedup = OnceTask() - @ObservationIgnored private var settingsCancellable: AnyCancellable? - @ObservationIgnored private var lastDisplaySchemas: Bool = false + @ObservationIgnored private var schemaChangeCancellable: AnyCancellable? @ObservationIgnored private static let logger = Logger(subsystem: "com.TablePro", category: "SchemaService") init() { - lastDisplaySchemas = AppSettingsManager.shared.sidebar.displaySchemas - settingsCancellable = AppEvents.shared.sidebarSettingsChanged - .sink { [weak self] in + schemaChangeCancellable = AppEvents.shared.currentSchemaChanged + .sink { [weak self] connectionId in Task { @MainActor [weak self] in - self?.handleSidebarSettingsChange() + await self?.handleSchemaSwitch(connectionId: connectionId) } } } @@ -91,13 +89,9 @@ final class SchemaService { } func reloadProcedures(connectionId: UUID, driver: DatabaseDriver) async { - let visibleSchemas = visibleSchemasForGroupedReload(connectionId: connectionId, driver: driver) do { let routines = try await procedureDedup.execute(key: connectionId) { - if let schemas = visibleSchemas { - return try await Self.fetchRoutinesAcrossSchemas(driver: driver, schemas: schemas, kind: .procedure) - } - return try await driver.fetchProcedures(schema: nil) + try await driver.fetchProcedures(schema: nil) } procedures[connectionId] = routines } catch is CancellationError { @@ -110,13 +104,9 @@ final class SchemaService { } func reloadFunctions(connectionId: UUID, driver: DatabaseDriver) async { - let visibleSchemas = visibleSchemasForGroupedReload(connectionId: connectionId, driver: driver) do { let routines = try await functionDedup.execute(key: connectionId) { - if let schemas = visibleSchemas { - return try await Self.fetchRoutinesAcrossSchemas(driver: driver, schemas: schemas, kind: .function) - } - return try await driver.fetchFunctions(schema: nil) + try await driver.fetchFunctions(schema: nil) } functions[connectionId] = routines } catch is CancellationError { @@ -140,6 +130,13 @@ final class SchemaService { 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, @@ -147,22 +144,10 @@ final class SchemaService { ) async { states[connectionId] = .loading - let wantsGrouping = AppSettingsManager.shared.sidebar.displaySchemas - && PluginManager.shared.supportsSchemaSwitching(for: connection.type) - - if wantsGrouping { - await runSchemaGroupedLoad(connectionId: connectionId, driver: driver, connection: connection) - } else { - await runFlatLoad(connectionId: connectionId, driver: driver, connection: connection) + let supportsSchemas = PluginManager.shared.supportsSchemaSwitching(for: connection.type) + if !supportsSchemas { + schemasInOrder.removeValue(forKey: connectionId) } - } - - private func runFlatLoad( - connectionId: UUID, - driver: DatabaseDriver, - connection: DatabaseConnection - ) async { - schemasInOrder.removeValue(forKey: connectionId) async let tablesTask: [TableInfo] = loadDedup.execute(key: connectionId) { try await driver.fetchTables() @@ -182,6 +167,9 @@ final class SchemaService { let loadedProcedures = await proceduresTask let loadedFunctions = await functionsTask + if supportsSchemas { + await loadSchemaList(connectionId: connectionId, driver: driver) + } do { let tables = try await tablesTask @@ -199,102 +187,19 @@ final class SchemaService { } } - private func runSchemaGroupedLoad( - connectionId: UUID, - driver: DatabaseDriver, - connection: DatabaseConnection - ) async { - let dbType = connection.type - let allSchemas: [String] + private func loadSchemaList(connectionId: UUID, driver: DatabaseDriver) async { do { - allSchemas = try await schemasDedup.execute(key: connectionId) { + 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); falling back to flat load" + "[schema] fetchSchemas failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" ) - await runFlatLoad(connectionId: connectionId, driver: driver, connection: connection) - return - } - - let systemSchemas = Set(PluginManager.shared.systemSchemaNames(for: dbType)) - let visibleSchemas = allSchemas.filter { !systemSchemas.contains($0) } - schemasInOrder[connectionId] = visibleSchemas - - async let tablesTask: [TableInfo] = loadDedup.execute(key: connectionId) { - try await Self.fetchTablesAcrossSchemas(driver: driver, schemas: visibleSchemas) } - async let proceduresTask: [RoutineInfo] = Self.fetchRoutinesSafely( - connectionId: connectionId, - kind: .procedure, - dedup: procedureDedup, - fetch: { try await Self.fetchRoutinesAcrossSchemas(driver: driver, schemas: visibleSchemas, kind: .procedure) } - ) - async let functionsTask: [RoutineInfo] = Self.fetchRoutinesSafely( - connectionId: connectionId, - kind: .function, - dedup: functionDedup, - fetch: { try await Self.fetchRoutinesAcrossSchemas(driver: driver, schemas: visibleSchemas, kind: .function) } - ) - - let loadedProcedures = await proceduresTask - let loadedFunctions = await functionsTask - - do { - let tables = try await tablesTask - states[connectionId] = .loaded(tables) - procedures[connectionId] = loadedProcedures - functions[connectionId] = loadedFunctions - lastLoadDates[connectionId] = Date() - } catch is CancellationError { - return - } catch { - Self.logger.warning( - "[schema] grouped load failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" - ) - states[connectionId] = .failed(error.localizedDescription) - } - } - - private func visibleSchemasForGroupedReload(connectionId: UUID, driver: DatabaseDriver) -> [String]? { - guard AppSettingsManager.shared.sidebar.displaySchemas else { return nil } - let schemas = schemasInOrder[connectionId] ?? [] - guard !schemas.isEmpty else { return nil } - return schemas - } - - private static func fetchTablesAcrossSchemas( - driver: DatabaseDriver, - schemas: [String] - ) async throws -> [TableInfo] { - var aggregated: [TableInfo] = [] - for schema in schemas { - try Task.checkCancellation() - let tables = try await driver.fetchTables(schema: schema) - aggregated.append(contentsOf: tables) - } - return aggregated - } - - private static func fetchRoutinesAcrossSchemas( - driver: DatabaseDriver, - schemas: [String], - kind: RoutineInfo.Kind - ) async throws -> [RoutineInfo] { - var aggregated: [RoutineInfo] = [] - for schema in schemas { - try Task.checkCancellation() - let routines: [RoutineInfo] - switch kind { - case .procedure: routines = try await driver.fetchProcedures(schema: schema) - case .function: routines = try await driver.fetchFunctions(schema: schema) - } - aggregated.append(contentsOf: routines) - } - return aggregated } private static func fetchRoutinesSafely( @@ -315,20 +220,10 @@ final class SchemaService { } } - private func handleSidebarSettingsChange() { - let now = AppSettingsManager.shared.sidebar.displaySchemas - guard now != lastDisplaySchemas else { return } - lastDisplaySchemas = now - - let sessions = DatabaseManager.shared.activeSessions - for (connectionId, session) in sessions { - guard let driver = session.driver else { continue } - let connection = session.connection - Task { [weak self] in - guard let self else { return } - await self.invalidate(connectionId: connectionId) - await self.reload(connectionId: connectionId, driver: driver, connection: connection) - } - } + 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/Core/Storage/AppSettingsManager.swift b/TablePro/Core/Storage/AppSettingsManager.swift index 772635f7a..34eb32690 100644 --- a/TablePro/Core/Storage/AppSettingsManager.swift +++ b/TablePro/Core/Storage/AppSettingsManager.swift @@ -96,14 +96,6 @@ final class AppSettingsManager { } } - var sidebar: SidebarSettings { - didSet { - storage.saveSidebar(sidebar) - appEvents.sidebarSettingsChanged.send(()) - syncTracker.markDirty(.settings, id: "sidebar") - } - } - var keyboard: KeyboardSettings { didSet { storage.saveKeyboard(keyboard) @@ -211,7 +203,6 @@ final class AppSettingsManager { self.dataGrid = storage.loadDataGrid() self.history = storage.loadHistory() self.tabs = storage.loadTabs() - self.sidebar = storage.loadSidebar() self.keyboard = storage.loadKeyboard() self.ai = Self.migrateAI(storage.loadAI()) self.sync = storage.loadSync() diff --git a/TablePro/Core/Storage/AppSettingsStorage.swift b/TablePro/Core/Storage/AppSettingsStorage.swift index 59be69885..1a705ac3c 100644 --- a/TablePro/Core/Storage/AppSettingsStorage.swift +++ b/TablePro/Core/Storage/AppSettingsStorage.swift @@ -27,7 +27,6 @@ final class AppSettingsStorage { static let dataGrid = "com.TablePro.settings.dataGrid" static let history = "com.TablePro.settings.history" static let tabs = "com.TablePro.settings.tabs" - static let sidebar = "com.TablePro.settings.sidebar" static let keyboard = "com.TablePro.settings.keyboard" static let ai = "com.TablePro.settings.ai" static let sync = "com.TablePro.settings.sync" @@ -101,16 +100,6 @@ final class AppSettingsStorage { save(settings, key: Keys.tabs) } - // MARK: - Sidebar Settings - - func loadSidebar() -> SidebarSettings { - load(key: Keys.sidebar, default: .default) - } - - func saveSidebar(_ settings: SidebarSettings) { - save(settings, key: Keys.sidebar) - } - // MARK: - Keyboard Settings func loadKeyboard() -> KeyboardSettings { diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 6264786ef..e8b9ea634 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -163,7 +163,7 @@ final class QueryTabManager { quoteIdentifier: quoteIdentifier ) var newTab = QueryTab( - title: tableName, + title: Self.tabTitle(name: tableName, schema: schemaName, databaseType: databaseType), query: query, tabType: .table, tableName: tableName @@ -175,6 +175,14 @@ final class QueryTabManager { 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) @@ -248,7 +256,7 @@ final class QueryTabManager { quoteIdentifier: quoteIdentifier ) var newTab = QueryTab( - title: tableName, + title: Self.tabTitle(name: tableName, schema: schemaName, databaseType: databaseType), query: query, tabType: .table, tableName: tableName @@ -287,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/Models/Settings/AppSettings.swift b/TablePro/Models/Settings/AppSettings.swift index 7be063985..28d3f35dc 100644 --- a/TablePro/Models/Settings/AppSettings.swift +++ b/TablePro/Models/Settings/AppSettings.swift @@ -281,23 +281,6 @@ struct DataGridSettings: Codable, Equatable { } } -// MARK: - Sidebar Settings - -struct SidebarSettings: Codable, Equatable { - var displaySchemas: Bool - - static let `default` = SidebarSettings(displaySchemas: false) - - init(displaySchemas: Bool = false) { - self.displaySchemas = displaySchemas - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - displaySchemas = try container.decodeIfPresent(Bool.self, forKey: .displaySchemas) ?? false - } -} - // MARK: - History Settings /// History settings diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 23eed13e5..6a24b55d3 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -16320,9 +16320,6 @@ } } } - }, - "Display schemas as collapsible sections" : { - }, "Distributed key-value store for service discovery" : { @@ -20718,6 +20715,7 @@ } }, "Failed to load schemas" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -27538,9 +27536,6 @@ } } } - }, - "Loading schemas…" : { - }, "Loading tables..." : { "extractionState" : "stale", @@ -31323,9 +31318,6 @@ } } } - }, - "No schemas" : { - }, "No schemas found" : { "extractionState" : "stale", @@ -31372,9 +31364,6 @@ } } } - }, - "No schemas match “%@”" : { - }, "No selection" : { "localizations" : { @@ -40804,9 +40793,6 @@ }, "Search saved query history. Returns matching entries with execution time, row count, and outcome." : { - }, - "Search schemas" : { - }, "Search schemas..." : { "extractionState" : "stale", @@ -41426,6 +41412,9 @@ } } } + }, + "Select schema" : { + }, "Select SQL File..." : { "extractionState" : "stale", @@ -42570,6 +42559,9 @@ } } } + }, + "Show System Schemas" : { + }, "Show Welcome Screen" : { "localizations" : { 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/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index bb99c83be..7719ec365 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -41,15 +41,6 @@ final class SidebarViewModel { var expanded: ExpansionState { didSet { persistExpansion(oldValue: oldValue) } } - var schemaExpanded: [String: Bool] = [:] { - didSet { - for (key, value) in schemaExpanded where oldValue[key] != value { - schemaExpansionResolveCache[key] = value - } - persistSchemaExpansion(oldValue: oldValue) - } - } - @ObservationIgnored private var schemaExpansionResolveCache: [String: Bool] = [:] var isRedisKeysExpanded: Bool { didSet { UserDefaults.standard.set( @@ -184,34 +175,6 @@ final class SidebarViewModel { } } - private func persistSchemaExpansion(oldValue: [String: Bool]) { - let defaults = UserDefaults.standard - for (schema, value) in schemaExpanded where oldValue[schema] != value { - defaults.set( - value, - forKey: SidebarPersistenceKey.schemaExpanded(connectionId: connectionId, schemaName: schema) - ) - } - } - - func effectiveSchemaExpanded(_ schemaName: String, hasMatches: Bool) -> Bool { - if !searchText.isEmpty && hasMatches { return true } - if let stored = schemaExpanded[schemaName] { return stored } - if let cached = schemaExpansionResolveCache[schemaName] { return cached } - let resolved = resolveStoredSchemaExpansion(schemaName) - schemaExpansionResolveCache[schemaName] = resolved - return resolved - } - - private func resolveStoredSchemaExpansion(_ schemaName: String) -> Bool { - let key = SidebarPersistenceKey.schemaExpanded(connectionId: connectionId, schemaName: schemaName) - if UserDefaults.standard.object(forKey: key) != nil { - return UserDefaults.standard.bool(forKey: key) - } - let current = (DatabaseManager.shared.driver(for: connectionId) as? SchemaSwitchable)?.currentSchema - return current == schemaName - } - // MARK: - Capability Gating func capabilities(for connectionId: UUID) -> PluginCapabilities { @@ -320,12 +283,6 @@ final class SidebarViewModel { @ObservationIgnored private var cachedFilteredRoutines: [SidebarObjectKind: [RoutineInfo]] = [:] @ObservationIgnored private var cachedFilteredRoutinesFingerprint: (count: Int, hash: Int, query: String)? - @ObservationIgnored private var cachedTablesPerSchema: [String: [TableInfo]] = [:] - @ObservationIgnored private var cachedTablesPerSchemaFingerprint: (count: Int, hash: Int, query: String)? - - @ObservationIgnored private var cachedRoutinesPerSchema: [String: [RoutineInfo]] = [:] - @ObservationIgnored private var cachedRoutinesPerSchemaFingerprint: (count: Int, hash: Int, query: String)? - func filteredTables(from tables: [TableInfo]) -> [TableInfo] { let query = searchText let fingerprint = (count: tables.count, hash: tables.hashValue, query: query) @@ -395,71 +352,6 @@ final class SidebarViewModel { return expanded[kind] } - func filteredTables(in schema: String, from tables: [TableInfo]) -> [TableInfo] { - rebuildSchemaCachesIfNeeded(tables: tables) - return cachedTablesPerSchema[schema] ?? [] - } - - func filteredRoutines(in schema: String, from routines: [RoutineInfo]) -> [RoutineInfo] { - rebuildRoutineSchemaCachesIfNeeded(routines: routines) - return cachedRoutinesPerSchema[schema] ?? [] - } - - private func rebuildSchemaCachesIfNeeded(tables: [TableInfo]) { - let query = searchText - let fingerprint = (count: tables.count, hash: tables.hashValue, query: query) - if cachedTablesPerSchemaFingerprint?.count == fingerprint.count - && cachedTablesPerSchemaFingerprint?.hash == fingerprint.hash - && cachedTablesPerSchemaFingerprint?.query == fingerprint.query { - return - } - var bySchema: [String: [SidebarObjectKind: [TableInfo]]] = [:] - for table in tables { - guard let schema = table.schema else { continue } - let kind = Self.sidebarObjectKind(for: table.type) - bySchema[schema, default: [:]][kind, default: []].append(table) - } - var result: [String: [TableInfo]] = [:] - for (schema, byKind) in bySchema { - var ordered: [TableInfo] = [] - for kind in SidebarObjectKind.allCases where !kind.isRoutine { - ordered.append(contentsOf: byKind[kind] ?? []) - } - result[schema] = applyQuery(query, to: ordered) - } - cachedTablesPerSchema = result - cachedTablesPerSchemaFingerprint = fingerprint - } - - private func rebuildRoutineSchemaCachesIfNeeded(routines: [RoutineInfo]) { - let query = searchText - let fingerprint = (count: routines.count, hash: routines.hashValue, query: query) - if cachedRoutinesPerSchemaFingerprint?.count == fingerprint.count - && cachedRoutinesPerSchemaFingerprint?.hash == fingerprint.hash - && cachedRoutinesPerSchemaFingerprint?.query == fingerprint.query { - return - } - var bySchemaProcs: [String: [RoutineInfo]] = [:] - var bySchemaFuncs: [String: [RoutineInfo]] = [:] - for routine in routines { - guard let schema = routine.schema else { continue } - switch routine.kind { - case .procedure: bySchemaProcs[schema, default: []].append(routine) - case .function: bySchemaFuncs[schema, default: []].append(routine) - } - } - let allSchemas = Set(bySchemaProcs.keys).union(bySchemaFuncs.keys) - var result: [String: [RoutineInfo]] = [:] - for schema in allSchemas { - var combined: [RoutineInfo] = [] - combined.append(contentsOf: bySchemaProcs[schema] ?? []) - combined.append(contentsOf: bySchemaFuncs[schema] ?? []) - result[schema] = applyRoutineQuery(query, to: combined) - } - cachedRoutinesPerSchema = result - cachedRoutinesPerSchemaFingerprint = fingerprint - } - private func applyQuery(_ query: String, to tables: [TableInfo]) -> [TableInfo] { guard !query.isEmpty else { return tables } return tables.filter { $0.name.localizedCaseInsensitiveContains(query) } @@ -498,9 +390,5 @@ final class SidebarViewModel { cachedFilteredByKindFingerprint = nil cachedFilteredRoutines = [:] cachedFilteredRoutinesFingerprint = nil - cachedTablesPerSchema = [:] - cachedTablesPerSchemaFingerprint = nil - cachedRoutinesPerSchema = [:] - cachedRoutinesPerSchemaFingerprint = nil } } 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 1b9677179..6fe48011e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -15,6 +15,15 @@ private let navigationLogger = Logger(subsystem: "com.TablePro", category: "Main extension MainContentCoordinator { // MARK: - Table Tab Opening + 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 @@ -30,8 +39,7 @@ extension MainContentCoordinator { currentDatabase = activeDatabaseName } - let connectionSchema = DatabaseManager.shared.session(for: connectionId)?.currentSchema - let resolvedSchema = schema ?? connectionSchema + 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, @@ -468,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/Settings/SettingsView.swift b/TablePro/Views/Settings/SettingsView.swift index d0a27346d..38c42f135 100644 --- a/TablePro/Views/Settings/SettingsView.swift +++ b/TablePro/Views/Settings/SettingsView.swift @@ -6,7 +6,7 @@ import SwiftUI enum SettingsTab: String { - case general, appearance, editor, sidebar, keyboard, ai, terminal, mcp, plugins, account + case general, appearance, editor, keyboard, ai, terminal, mcp, plugins, account } struct SettingsView: View { @@ -37,10 +37,6 @@ struct SettingsView: View { .tabItem { Label("Editor", systemImage: "doc.text") } .tag(SettingsTab.editor.rawValue) - SidebarSettingsView(settings: $settingsManager.sidebar) - .tabItem { Label("Sidebar", systemImage: "sidebar.left") } - .tag(SettingsTab.sidebar.rawValue) - KeyboardSettingsView(settings: $settingsManager.keyboard) .tabItem { Label("Keyboard", systemImage: "keyboard") } .tag(SettingsTab.keyboard.rawValue) diff --git a/TablePro/Views/Settings/SidebarSettingsView.swift b/TablePro/Views/Settings/SidebarSettingsView.swift deleted file mode 100644 index ebccc08c3..000000000 --- a/TablePro/Views/Settings/SidebarSettingsView.swift +++ /dev/null @@ -1,20 +0,0 @@ -import SwiftUI - -struct SidebarSettingsView: View { - @Binding var settings: SidebarSettings - - var body: some View { - Form { - Section("Schemas") { - Toggle("Display schemas as collapsible sections", isOn: $settings.displaySchemas) - } - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) - } -} - -#Preview { - SidebarSettingsView(settings: .constant(.default)) - .frame(width: 450, height: 200) -} diff --git a/TablePro/Views/Sidebar/SchemaPickerFooter.swift b/TablePro/Views/Sidebar/SchemaPickerFooter.swift new file mode 100644 index 000000000..dd23e094b --- /dev/null +++ b/TablePro/Views/Sidebar/SchemaPickerFooter.swift @@ -0,0 +1,193 @@ +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 + @State private var schemaVersion = 0 + + private var currentSchema: String? { + _ = schemaVersion + return 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) + } + .onReceive(AppEvents.shared.currentSchemaChanged) { changedId in + if changedId == connectionId { + schemaVersion &+= 1 + } + } + } + } + + 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 + + private var fingerprint: MenuFingerprint { + MenuFingerprint( + title: title, + userSchemas: userSchemas, + systemSchemas: systemSchemas, + showSystemSchemas: showSystemSchemas, + currentSchema: currentSchema + ) + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeNSView(context: Context) -> NSPopUpButton { + let button = NSPopUpButton(frame: .zero, pullsDown: true) + button.preferredEdge = .maxY + context.coordinator.lastFingerprint = fingerprint + rebuildMenu(button: button, context: context) + return button + } + + func updateNSView(_ button: NSPopUpButton, context: Context) { + context.coordinator.parent = self + let next = fingerprint + guard context.coordinator.lastFingerprint != next else { return } + context.coordinator.lastFingerprint = next + rebuildMenu(button: button, context: context) + } + + private func rebuildMenu(button: NSPopUpButton, context: Context) { + let menu = NSMenu() + menu.autoenablesItems = false + + menu.addItem(NSMenuItem(title: title, action: nil, keyEquivalent: "")) + + 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 + var lastFingerprint: MenuFingerprint? + + init(parent: SchemaPopUpButton) { + self.parent = parent + } + + @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() + } + } + + struct MenuFingerprint: Equatable { + let title: String + let userSchemas: [String] + let systemSchemas: [String] + let showSystemSchemas: Bool + let currentSchema: String? + } +} 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/SidebarPersistenceKey.swift b/TablePro/Views/Sidebar/SidebarPersistenceKey.swift index c0b68daf5..cfafd58c0 100644 --- a/TablePro/Views/Sidebar/SidebarPersistenceKey.swift +++ b/TablePro/Views/Sidebar/SidebarPersistenceKey.swift @@ -19,8 +19,4 @@ enum SidebarPersistenceKey { static func expanded(connectionId: UUID, kind: SidebarObjectKind) -> String { "sidebar.\(connectionId.uuidString).\(kind.rawValue).expanded" } - - static func schemaExpanded(connectionId: UUID, schemaName: String) -> String { - "sidebar.\(connectionId.uuidString).schema.\(schemaName).expanded" - } } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 46c13d62a..d4f31ef45 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -32,23 +32,16 @@ struct SidebarView: View { viewModel.capabilities(for: connectionId) } - private var isSchemaGrouped: Bool { - AppSettingsManager.shared.sidebar.displaySchemas - && !schemaService.schemas(for: connectionId).isEmpty - } - private var hasAnyMatch: Bool { - if isSchemaGrouped { - return schemaService.schemas(for: connectionId).contains { schema in - !viewModel.filteredTables(in: schema, from: tables).isEmpty - || !viewModel.filteredRoutines(in: schema, from: routines).isEmpty - } - } - return SidebarObjectKind.allCases.contains { kind in + SidebarObjectKind.allCases.contains { kind in countFor(kind: kind) > 0 } } + private var supportsSchemaFooter: Bool { + PluginManager.shared.supportsSchemaSwitching(for: viewModel.databaseType) + } + private var selectedTablesBinding: Binding> { Binding( get: { sidebarState.selectedTables }, @@ -97,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( @@ -196,16 +195,7 @@ struct SidebarView: View { // MARK: - Table List - @ViewBuilder private var tableList: some View { - if isSchemaGrouped { - schemaGroupedTableList - } else { - kindGroupedTableList - } - } - - private var kindGroupedTableList: some View { List(selection: selectedTablesBinding) { ForEach(SidebarObjectKind.allCases, id: \.self) { kind in sectionView(for: kind) @@ -235,92 +225,13 @@ struct SidebarView: View { EmptyView() } primaryAction: { selection in guard let table = selection.first else { return } - handleTableOpen(table) + onDoubleClick?(table) } .onExitCommand { sidebarState.selectedTables.removeAll() } } - private var schemaGroupedTableList: some View { - List(selection: selectedTablesBinding) { - ForEach(schemaService.schemas(for: connectionId), id: \.self) { schema in - schemaSection(for: schema) - } - } - .listStyle(.sidebar) - .scrollContentBackground(.hidden) - .contextMenu(forSelectionType: TableInfo.self) { _ in - EmptyView() - } primaryAction: { selection in - guard let table = selection.first else { return } - handleTableOpen(table) - } - .onExitCommand { - sidebarState.selectedTables.removeAll() - } - } - - @ViewBuilder - private func schemaSection(for schema: String) -> some View { - let schemaTables = viewModel.filteredTables(in: schema, from: tables) - let schemaRoutines = viewModel.filteredRoutines(in: schema, from: routines) - let hasContent = !schemaTables.isEmpty || !schemaRoutines.isEmpty - if hasContent { - Section(isExpanded: schemaExpandedBinding(for: schema, hasMatches: hasContent)) { - ForEach(schemaTables) { table in - TableRow( - table: table, - isPendingTruncate: pendingTruncates.contains(table.name), - isPendingDelete: pendingDeletes.contains(table.name) - ) - .tag(table) - .contextMenu { - SidebarContextMenu( - clickedTable: table, - selectedTables: sidebarState.selectedTables, - isReadOnly: coordinator?.safeModeLevel.blocksAllWrites ?? false, - onBatchToggleTruncate: { viewModel.batchToggleTruncate(tableNames: $0) }, - onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) }, - coordinator: coordinator - ) - } - } - ForEach(schemaRoutines) { routine in - RoutineRowView(routine: routine) - .tag(routine) - .contextMenu { - RoutineContextMenu(routine: routine) { selected in - coordinator?.showRoutineDDL(selected) - } - } - } - } header: { - Text(schema) - .contextMenu { - Button(String(localized: "Refresh")) { - Task { - await coordinator?.refreshTables() - await coordinator?.refreshProcedures() - await coordinator?.refreshFunctions() - } - } - } - } - } - } - - private func schemaExpandedBinding(for schema: String, hasMatches: Bool) -> Binding { - Binding( - get: { viewModel.effectiveSchemaExpanded(schema, hasMatches: hasMatches) }, - set: { viewModel.schemaExpanded[schema] = $0 } - ) - } - - private func handleTableOpen(_ table: TableInfo) { - onDoubleClick?(table) - } - // MARK: - Section View @ViewBuilder 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/Models/Query/QueryTabManagerTabTitleTests.swift b/TableProTests/Models/Query/QueryTabManagerTabTitleTests.swift new file mode 100644 index 000000000..0988f3a57 --- /dev/null +++ b/TableProTests/Models/Query/QueryTabManagerTabTitleTests.swift @@ -0,0 +1,42 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("QueryTabManager.tabTitle") +struct QueryTabManagerTabTitleTests { + @Test("Returns plain name when schema is nil") + func nilSchemaReturnsName() { + let title = QueryTabManager.tabTitle(name: "users", schema: nil, databaseType: .postgresql) + #expect(title == "users") + } + + @Test("Returns plain name when schema is empty string") + func emptySchemaReturnsName() { + let title = QueryTabManager.tabTitle(name: "users", schema: "", databaseType: .postgresql) + #expect(title == "users") + } + + @Test("Returns plain name when schema matches the database default") + func defaultSchemaReturnsName() { + let title = QueryTabManager.tabTitle(name: "users", schema: "public", databaseType: .postgresql) + #expect(title == "users") + } + + @Test("Qualifies with schema when schema differs from default") + func nonDefaultSchemaQualifies() { + let title = QueryTabManager.tabTitle(name: "audit_log_entries", schema: "auth", databaseType: .postgresql) + #expect(title == "auth.audit_log_entries") + } + + @Test("MSSQL default schema is dbo") + func mssqlDboReturnsName() { + let title = QueryTabManager.tabTitle(name: "Orders", schema: "dbo", databaseType: .mssql) + #expect(title == "Orders") + } + + @Test("MSSQL non-default schema qualifies") + func mssqlNonDboQualifies() { + let title = QueryTabManager.tabTitle(name: "Customers", schema: "sales", databaseType: .mssql) + #expect(title == "sales.Customers") + } +} diff --git a/TableProTests/Models/SidebarSettingsTests.swift b/TableProTests/Models/SidebarSettingsTests.swift deleted file mode 100644 index b0ead4d30..000000000 --- a/TableProTests/Models/SidebarSettingsTests.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation -@testable import TablePro -import Testing - -@Suite("SidebarSettings") -struct SidebarSettingsTests { - @Test("default has displaySchemas off") - func defaultIsOff() { - #expect(SidebarSettings.default.displaySchemas == false) - } - - @Test("Codable round-trip preserves displaySchemas") - func codableRoundTrip() throws { - let original = SidebarSettings(displaySchemas: true) - let data = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(SidebarSettings.self, from: data) - #expect(decoded.displaySchemas == true) - } - - @Test("decoding payload without displaySchemas defaults to false") - func legacyPayloadDecodesOff() throws { - let legacyJson = "{}".data(using: .utf8)! - let decoded = try JSONDecoder().decode(SidebarSettings.self, from: legacyJson) - #expect(decoded.displaySchemas == false) - } -} 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) diff --git a/docs/customization/sidebar-settings.mdx b/docs/customization/sidebar-settings.mdx deleted file mode 100644 index 8ba9ecf73..000000000 --- a/docs/customization/sidebar-settings.mdx +++ /dev/null @@ -1,16 +0,0 @@ ---- -title: Sidebar Settings -description: Configure how the Tables sidebar groups and displays database objects ---- - -# Sidebar Settings - -Configure the Tables sidebar in **Settings** > **Sidebar**. - -## Schemas - -| Setting | Options | Default | -|---------|---------|---------| -| Display schemas as collapsible sections | On, Off | Off | - -When on, the Tables tab groups every table, view, materialized view, foreign table, procedure, and function under its schema as a collapsible section. Multiple schema sections can stay expanded at once. System schemas (like `pg_catalog` and `information_schema`) are hidden. Databases without schemas (MySQL, SQLite, Redis) keep the flat layout.