diff --git a/CHANGELOG.md b/CHANGELOG.md index 80256521b..47d32c92a 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 +- Show schemas as collapsible sections in the Tables sidebar (#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/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/Events/AppEvents.swift b/TablePro/Core/Events/AppEvents.swift index f2eeaf2dd..1991f5d1d 100644 --- a/TablePro/Core/Events/AppEvents.swift +++ b/TablePro/Core/Events/AppEvents.swift @@ -20,6 +20,8 @@ final class AppEvents { let editorSettingsChanged = PassthroughSubject() + let sidebarSettingsChanged = PassthroughSubject() + let dataGridSettingsChanged = PassthroughSubject() let aiSettingsChanged = 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/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 47d853aa4..ee9accf48 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -340,11 +340,11 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi previewCoordinator.promotePreviewTab() } else { previewCoordinator.promotePreviewTab() - coordinator.openTableTab(table.name, isView: isView) + coordinator.openTableTab(table.name, schema: table.schema, isView: isView) } } else { coordinator.promotePreviewTab() - coordinator.openTableTab(table.name, isView: isView) + coordinator.openTableTab(table.name, schema: table.schema, isView: isView) } }, 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..4488e1476 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,26 @@ 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 settingsCancellable: AnyCancellable? + @ObservationIgnored private var lastDisplaySchemas: Bool = false @ObservationIgnored private static let logger = Logger(subsystem: "com.TablePro", category: "SchemaService") - init() {} + init() { + lastDisplaySchemas = AppSettingsManager.shared.sidebar.displaySchemas + settingsCancellable = AppEvents.shared.sidebarSettingsChanged + .sink { [weak self] in + Task { @MainActor [weak self] in + self?.handleSidebarSettingsChange() + } + } + } func state(for connectionId: UUID) -> SchemaState { states[connectionId] ?? .idle @@ -46,6 +59,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: @@ -74,9 +91,13 @@ 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) { - try await driver.fetchProcedures(schema: nil) + if let schemas = visibleSchemas { + return try await Self.fetchRoutinesAcrossSchemas(driver: driver, schemas: schemas, kind: .procedure) + } + return try await driver.fetchProcedures(schema: nil) } procedures[connectionId] = routines } catch is CancellationError { @@ -89,9 +110,13 @@ 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) { - try await driver.fetchFunctions(schema: nil) + if let schemas = visibleSchemas { + return try await Self.fetchRoutinesAcrossSchemas(driver: driver, schemas: schemas, kind: .function) + } + return try await driver.fetchFunctions(schema: nil) } functions[connectionId] = routines } catch is CancellationError { @@ -107,9 +132,11 @@ 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) } @@ -120,6 +147,23 @@ 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) + } + } + + 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() } @@ -155,6 +199,104 @@ final class SchemaService { } } + private func runSchemaGroupedLoad( + connectionId: UUID, + driver: DatabaseDriver, + connection: DatabaseConnection + ) async { + let dbType = connection.type + let allSchemas: [String] + do { + allSchemas = try await schemasDedup.execute(key: connectionId) { + try await driver.fetchSchemas() + } + } 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" + ) + 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( connectionId: UUID, kind: RoutineInfo.Kind, @@ -172,4 +314,21 @@ final class SchemaService { return [] } } + + 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) + } + } + } } diff --git a/TablePro/Core/Storage/AppSettingsManager.swift b/TablePro/Core/Storage/AppSettingsManager.swift index 34eb32690..772635f7a 100644 --- a/TablePro/Core/Storage/AppSettingsManager.swift +++ b/TablePro/Core/Storage/AppSettingsManager.swift @@ -96,6 +96,14 @@ 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) @@ -203,6 +211,7 @@ 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 1a705ac3c..59be69885 100644 --- a/TablePro/Core/Storage/AppSettingsStorage.swift +++ b/TablePro/Core/Storage/AppSettingsStorage.swift @@ -27,6 +27,7 @@ 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" @@ -100,6 +101,16 @@ 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/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..6264786ef 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,7 +157,10 @@ 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, @@ -163,6 +170,7 @@ final class QueryTabManager { ) newTab.pagination = PaginationState(pageSize: pageSize) newTab.tableContext.databaseName = databaseName + newTab.tableContext.schemaName = schemaName tabs.append(newTab) selectedTabId = newTab.id } @@ -219,10 +227,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,7 +242,10 @@ 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, @@ -240,6 +255,7 @@ final class QueryTabManager { ) newTab.pagination = PaginationState(pageSize: pageSize) newTab.tableContext.databaseName = databaseName + newTab.tableContext.schemaName = schemaName newTab.isPreview = true tabs.append(newTab) selectedTabId = newTab.id diff --git a/TablePro/Models/Settings/AppSettings.swift b/TablePro/Models/Settings/AppSettings.swift index 28d3f35dc..7be063985 100644 --- a/TablePro/Models/Settings/AppSettings.swift +++ b/TablePro/Models/Settings/AppSettings.swift @@ -281,6 +281,23 @@ 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 9a9b3df1f..23eed13e5 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -16320,6 +16320,9 @@ } } } + }, + "Display schemas as collapsible sections" : { + }, "Distributed key-value store for service discovery" : { diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index 7719ec365..bb99c83be 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -41,6 +41,15 @@ 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( @@ -175,6 +184,34 @@ 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 { @@ -283,6 +320,12 @@ 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) @@ -352,6 +395,71 @@ 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) } @@ -390,5 +498,9 @@ final class SidebarViewModel { cachedFilteredByKindFingerprint = nil cachedFilteredRoutines = [:] cachedFilteredRoutinesFingerprint = nil + cachedTablesPerSchema = [:] + cachedTablesPerSchemaFingerprint = nil + cachedRoutinesPerSchema = [:] + cachedRoutinesPerSchemaFingerprint = nil } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 56b7af6d8..1b9677179 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -15,7 +15,7 @@ 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(_ tableName: String, schema: String? = nil, showStructure: Bool = false, isView: Bool = false) { let navigationModel = PluginMetadataRegistry.shared.snapshot( forTypeId: connection.type.pluginTypeId )?.navigationModel ?? .standard @@ -30,13 +30,15 @@ extension MainContentCoordinator { currentDatabase = activeDatabaseName } - let currentSchema = DatabaseManager.shared.session(for: connectionId)?.currentSchema + let connectionSchema = DatabaseManager.shared.session(for: connectionId)?.currentSchema + let resolvedSchema = schema ?? connectionSchema // 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 +53,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 +70,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 +87,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 +110,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 +138,7 @@ extension MainContentCoordinator { tableName: tableName, databaseType: connection.type, databaseName: currentDatabase, - schemaName: currentSchema + schemaName: resolvedSchema ) if replaced { clearFilterState() @@ -163,7 +169,7 @@ extension MainContentCoordinator { tabType: .table, tableName: tableName, databaseName: currentDatabase, - schemaName: currentSchema, + schemaName: resolvedSchema, isView: isView, showStructure: showStructure ) @@ -173,7 +179,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 +189,7 @@ extension MainContentCoordinator { tabType: .table, tableName: tableName, databaseName: currentDatabase, - schemaName: currentSchema, + schemaName: resolvedSchema, isView: isView, showStructure: showStructure ) diff --git a/TablePro/Views/Settings/SettingsView.swift b/TablePro/Views/Settings/SettingsView.swift index 38c42f135..d0a27346d 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, keyboard, ai, terminal, mcp, plugins, account + case general, appearance, editor, sidebar, keyboard, ai, terminal, mcp, plugins, account } struct SettingsView: View { @@ -37,6 +37,10 @@ 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 new file mode 100644 index 000000000..ebccc08c3 --- /dev/null +++ b/TablePro/Views/Settings/SidebarSettingsView.swift @@ -0,0 +1,20 @@ +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/SidebarPersistenceKey.swift b/TablePro/Views/Sidebar/SidebarPersistenceKey.swift index cfafd58c0..c0b68daf5 100644 --- a/TablePro/Views/Sidebar/SidebarPersistenceKey.swift +++ b/TablePro/Views/Sidebar/SidebarPersistenceKey.swift @@ -19,4 +19,8 @@ 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 17b325db9..46c13d62a 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 @@ -35,8 +32,19 @@ struct SidebarView: View { viewModel.capabilities(for: connectionId) } + private var isSchemaGrouped: Bool { + AppSettingsManager.shared.sidebar.displaySchemas + && !schemaService.schemas(for: connectionId).isEmpty + } + private var hasAnyMatch: Bool { - SidebarObjectKind.allCases.contains { kind in + 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 countFor(kind: kind) > 0 } } @@ -188,7 +196,16 @@ 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) @@ -218,13 +235,92 @@ struct SidebarView: View { EmptyView() } primaryAction: { selection in guard let table = selection.first else { return } - onDoubleClick?(table) + handleTableOpen(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/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/Models/SidebarSettingsTests.swift b/TableProTests/Models/SidebarSettingsTests.swift new file mode 100644 index 000000000..b0ead4d30 --- /dev/null +++ b/TableProTests/Models/SidebarSettingsTests.swift @@ -0,0 +1,26 @@ +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/docs/customization/sidebar-settings.mdx b/docs/customization/sidebar-settings.mdx new file mode 100644 index 000000000..8ba9ecf73 --- /dev/null +++ b/docs/customization/sidebar-settings.mdx @@ -0,0 +1,16 @@ +--- +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.