From 8d8783513daf0183e28024c7b3738da5d3dc6252 Mon Sep 17 00:00:00 2001 From: Luiz Vergennes Date: Sat, 16 May 2026 14:00:12 -0300 Subject: [PATCH 1/4] feat(sidebar): show schemas as collapsible sections in the Tables sidebar --- CHANGELOG.md | 4 + TablePro/Core/Database/DatabaseDriver.swift | 8 + .../Core/Plugins/PluginDriverAdapter.swift | 14 +- .../Core/Services/Query/SchemaService.swift | 174 +++++++++++++++++- TablePro/Models/Query/QueryResult.swift | 18 +- TablePro/Models/Settings/EditorSettings.swift | 9 +- TablePro/ViewModels/SidebarViewModel.swift | 99 ++++++++++ .../Views/Settings/EditorSettingsView.swift | 4 + .../Views/Sidebar/SidebarPersistenceKey.swift | 4 + TablePro/Views/Sidebar/SidebarView.swift | 113 +++++++++++- ...inDriverAdapterTableTypeMappingTests.swift | 23 +++ docs/customization/editor-settings.mdx | 8 + 12 files changed, 467 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f041f7bf4..82a9a0320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Show schemas as collapsible sections in the Tables sidebar (off by default, enable in Settings > Editor > Sidebar) + ## [0.42.0] - 2026-05-16 ### Added diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 02cbb83f8..364bf83b9 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -64,6 +64,9 @@ protocol DatabaseDriver: AnyObject { /// Fetch all tables in the database func fetchTables() async throws -> [TableInfo] + /// Fetch tables for a specific schema (nil = current schema) + func fetchTables(schema: String?) async throws -> [TableInfo] + /// Fetch columns for a specific table func fetchColumns(table: String) async throws -> [ColumnInfo] @@ -359,6 +362,11 @@ extension DatabaseDriver { /// Default: no schema support (MySQL/SQLite don't use schemas in the same way) func fetchSchemas() async throws -> [String] { [] } + /// Default: ignore schema and call existing fetchTables(). Plugin-backed drivers override. + 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/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 85ab45176..bd3ac90b3 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -159,7 +159,12 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { // MARK: - Schema Operations func fetchTables() async throws -> [TableInfo] { - let pluginTables = try await pluginDriver.fetchTables(schema: pluginDriver.currentSchema) + try await fetchTables(schema: pluginDriver.currentSchema) + } + + func fetchTables(schema: String?) async throws -> [TableInfo] { + let resolvedSchema = schema ?? pluginDriver.currentSchema + let pluginTables = try await pluginDriver.fetchTables(schema: resolvedSchema) return pluginTables.map { table in let tableType: TableInfo.TableType switch table.type.lowercased() { @@ -177,7 +182,12 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { 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 TableInfo( + name: table.name, + type: tableType, + rowCount: table.rowCount, + schema: table.schema ?? resolvedSchema + ) } } diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index b54dc3788..e4712523d 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 lastDisplaySchemasInSidebar: Bool = false @ObservationIgnored private static let logger = Logger(subsystem: "com.TablePro", category: "SchemaService") - init() {} + init() { + lastDisplaySchemasInSidebar = AppSettingsManager.shared.editor.displaySchemasInSidebar + settingsCancellable = AppEvents.shared.editorSettingsChanged + .sink { [weak self] in + Task { @MainActor [weak self] in + self?.handleEditorSettingsChange() + } + } + } 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.editor.displaySchemasInSidebar + && 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,113 @@ 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.editor.displaySchemasInSidebar 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] { + try await withThrowingTaskGroup(of: [TableInfo].self) { group in + for schema in schemas { + group.addTask { + try await driver.fetchTables(schema: schema) + } + } + var aggregated: [TableInfo] = [] + for try await tables in group { + aggregated.append(contentsOf: tables) + } + return aggregated + } + } + + private static func fetchRoutinesAcrossSchemas( + driver: DatabaseDriver, + schemas: [String], + kind: RoutineInfo.Kind + ) async throws -> [RoutineInfo] { + try await withThrowingTaskGroup(of: [RoutineInfo].self) { group in + for schema in schemas { + group.addTask { + switch kind { + case .procedure: return try await driver.fetchProcedures(schema: schema) + case .function: return try await driver.fetchFunctions(schema: schema) + } + } + } + var aggregated: [RoutineInfo] = [] + for try await routines in group { + aggregated.append(contentsOf: routines) + } + return aggregated + } + } + private static func fetchRoutinesSafely( connectionId: UUID, kind: RoutineInfo.Kind, @@ -172,4 +323,21 @@ final class SchemaService { return [] } } + + private func handleEditorSettingsChange() { + let now = AppSettingsManager.shared.editor.displaySchemasInSidebar + guard now != lastDisplaySchemasInSidebar else { return } + lastDisplaySchemasInSidebar = 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/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index 310e913fb..f4053b1b7 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/Settings/EditorSettings.swift b/TablePro/Models/Settings/EditorSettings.swift index c55295943..9d36bf885 100644 --- a/TablePro/Models/Settings/EditorSettings.swift +++ b/TablePro/Models/Settings/EditorSettings.swift @@ -76,6 +76,7 @@ struct EditorSettings: Codable, Equatable { var uppercaseKeywords: Bool var queryParametersEnabled: Bool var jsonViewerPreferredMode: JSONViewMode + var displaySchemasInSidebar: Bool static let `default` = EditorSettings( showLineNumbers: true, @@ -85,7 +86,8 @@ struct EditorSettings: Codable, Equatable { vimModeEnabled: false, uppercaseKeywords: false, queryParametersEnabled: true, - jsonViewerPreferredMode: .text + jsonViewerPreferredMode: .text, + displaySchemasInSidebar: false ) init( @@ -96,7 +98,8 @@ struct EditorSettings: Codable, Equatable { vimModeEnabled: Bool = false, uppercaseKeywords: Bool = false, queryParametersEnabled: Bool = true, - jsonViewerPreferredMode: JSONViewMode = .text + jsonViewerPreferredMode: JSONViewMode = .text, + displaySchemasInSidebar: Bool = false ) { self.showLineNumbers = showLineNumbers self.highlightCurrentLine = highlightCurrentLine @@ -106,6 +109,7 @@ struct EditorSettings: Codable, Equatable { self.uppercaseKeywords = uppercaseKeywords self.queryParametersEnabled = queryParametersEnabled self.jsonViewerPreferredMode = jsonViewerPreferredMode + self.displaySchemasInSidebar = displaySchemasInSidebar } init(from decoder: Decoder) throws { @@ -118,6 +122,7 @@ struct EditorSettings: Codable, Equatable { uppercaseKeywords = try container.decodeIfPresent(Bool.self, forKey: .uppercaseKeywords) ?? false queryParametersEnabled = try container.decodeIfPresent(Bool.self, forKey: .queryParametersEnabled) ?? true jsonViewerPreferredMode = try container.decodeIfPresent(JSONViewMode.self, forKey: .jsonViewerPreferredMode) ?? .text + displaySchemasInSidebar = try container.decodeIfPresent(Bool.self, forKey: .displaySchemasInSidebar) ?? false } /// Clamped tab width (1-16) diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index 7719ec365..772d5ef2a 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -41,6 +41,9 @@ final class SidebarViewModel { var expanded: ExpansionState { didSet { persistExpansion(oldValue: oldValue) } } + var schemaExpanded: [String: Bool] = [:] { + didSet { persistSchemaExpansion(oldValue: oldValue) } + } var isRedisKeysExpanded: Bool { didSet { UserDefaults.standard.set( @@ -175,6 +178,27 @@ 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 } + 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 +307,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 +382,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 +485,9 @@ final class SidebarViewModel { cachedFilteredByKindFingerprint = nil cachedFilteredRoutines = [:] cachedFilteredRoutinesFingerprint = nil + cachedTablesPerSchema = [:] + cachedTablesPerSchemaFingerprint = nil + cachedRoutinesPerSchema = [:] + cachedRoutinesPerSchemaFingerprint = nil } } diff --git a/TablePro/Views/Settings/EditorSettingsView.swift b/TablePro/Views/Settings/EditorSettingsView.swift index 249fbd7ec..6af13f6e2 100644 --- a/TablePro/Views/Settings/EditorSettingsView.swift +++ b/TablePro/Views/Settings/EditorSettingsView.swift @@ -32,6 +32,10 @@ struct EditorSettingsView: View { } } + Section("Sidebar") { + Toggle("Display schemas on sidebar", isOn: $settings.displaySchemasInSidebar) + } + DataGridSection(settings: $dataGridSettings) } .formStyle(.grouped) 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..3452c9586 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -35,8 +35,19 @@ struct SidebarView: View { viewModel.capabilities(for: connectionId) } + private var isSchemaGrouped: Bool { + AppSettingsManager.shared.editor.displaySchemasInSidebar + && !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 +199,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 +238,102 @@ 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() } + } + } + } + } + } + + 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) { + guard let targetSchema = table.schema, + let switchable = DatabaseManager.shared.driver(for: connectionId) as? SchemaSwitchable, + let currentSchema = switchable.currentSchema, + targetSchema != currentSchema else { + onDoubleClick?(table) + return + } + Task { + do { + try await switchable.switchSchema(to: targetSchema) + } catch { + // Schema switch failed; still attempt to open with current schema + } + onDoubleClick?(table) + } + } + // MARK: - Section View @ViewBuilder diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift index 0221fff34..092b6ee2e 100644 --- a/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift +++ b/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift @@ -154,4 +154,27 @@ 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") + } } diff --git a/docs/customization/editor-settings.mdx b/docs/customization/editor-settings.mdx index 7f3b6f622..a2f850cc1 100644 --- a/docs/customization/editor-settings.mdx +++ b/docs/customization/editor-settings.mdx @@ -190,6 +190,14 @@ Autocomplete is always on. Dismiss with `Escape`. See [Autocomplete](/features/a Pick which mode the JSON viewer starts in. You can switch anytime with the toggle in the viewer. Your last choice is remembered. +## Sidebar + +| Setting | Options | Default | +|---------|---------|---------| +| Display schemas on sidebar | 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 accordion. Multiple schema accordions 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. + ## Keyboard Shortcuts | Action | Shortcut | From 2d0cbddfef3fb248491bc0367cc08e0167c14a27 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 17 May 2026 18:20:54 +0700 Subject: [PATCH 2/4] refactor(sidebar): address PR review feedback - sidebar settings group, schema serialisation, comment cleanup --- CHANGELOG.md | 2 +- TablePro/Core/Database/DatabaseDriver.swift | 2 - TablePro/Core/Events/AppEvents.swift | 2 + .../Core/Plugins/PluginDriverAdapter.swift | 51 ++++++++-------- .../Plugins/PluginManager+Registration.swift | 6 +- .../Core/Services/Query/SchemaService.swift | 59 ++++++++----------- .../Core/Storage/AppSettingsManager.swift | 9 +++ .../Core/Storage/AppSettingsStorage.swift | 11 ++++ TablePro/Models/Settings/AppSettings.swift | 17 ++++++ TablePro/Models/Settings/EditorSettings.swift | 9 +-- TablePro/Resources/Localizable.xcstrings | 3 + TablePro/TableProApp.swift | 4 +- TablePro/ViewModels/SidebarViewModel.swift | 15 ++++- .../Views/Settings/EditorSettingsView.swift | 4 -- TablePro/Views/Settings/SettingsView.swift | 6 +- .../Views/Settings/SidebarSettingsView.swift | 20 +++++++ TablePro/Views/Sidebar/SidebarView.swift | 14 +++-- ...inDriverAdapterTableTypeMappingTests.swift | 9 +++ .../Models/SidebarSettingsTests.swift | 26 ++++++++ docs/customization/editor-settings.mdx | 8 --- docs/customization/sidebar-settings.mdx | 16 +++++ 21 files changed, 201 insertions(+), 92 deletions(-) create mode 100644 TablePro/Views/Settings/SidebarSettingsView.swift create mode 100644 TableProTests/Models/SidebarSettingsTests.swift create mode 100644 docs/customization/sidebar-settings.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index 5285af7c2..39c90d667 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 (off by default, enable in Settings > Editor > Sidebar) +- 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) diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 364bf83b9..402cad0ed 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -64,7 +64,6 @@ protocol DatabaseDriver: AnyObject { /// Fetch all tables in the database func fetchTables() async throws -> [TableInfo] - /// Fetch tables for a specific schema (nil = current schema) func fetchTables(schema: String?) async throws -> [TableInfo] /// Fetch columns for a specific table @@ -362,7 +361,6 @@ extension DatabaseDriver { /// Default: no schema support (MySQL/SQLite don't use schemas in the same way) func fetchSchemas() async throws -> [String] { [] } - /// Default: ignore schema and call existing fetchTables(). Plugin-backed drivers override. func fetchTables(schema: String?) async throws -> [TableInfo] { try await fetchTables() } 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 ed4019751..e031ed9f7 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -159,36 +159,39 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { // MARK: - Schema Operations func fetchTables() async throws -> [TableInfo] { - try await fetchTables(schema: pluginDriver.currentSchema) + let pluginTables = try await pluginDriver.fetchTables(schema: pluginDriver.currentSchema) + 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 { 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, - schema: table.schema ?? resolvedSchema - ) + return pluginTables.map { mapPluginTable($0, schemaFallback: resolvedSchema) } + } + + private func mapPluginTable(_ table: PluginTableInfo, schemaFallback: String?) -> TableInfo { + let tableType: TableInfo.TableType + switch table.type.lowercased() { + case "table", "base table", "prefix": + tableType = .table + case "view": + tableType = .view + case "materialized view", "materialized_view": + tableType = .materializedView + case "foreign table", "foreign_table": + tableType = .foreignTable + case "system table", "system base table", "system view": + tableType = .systemTable + default: + Self.logger.warning("Unknown plugin table type \"\(table.type, privacy: .public)\" for \"\(table.name, privacy: .public)\"; defaulting to .table") + tableType = .table } + return TableInfo( + name: table.name, + type: tableType, + rowCount: table.rowCount, + schema: table.schema ?? schemaFallback + ) } func fetchColumns(table: String) async throws -> [ColumnInfo] { diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index adb9bbdb5..eac09422d 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -18,7 +18,7 @@ extension PluginManager { if let driver = instance as? any DriverPlugin { if !declared.contains(.databaseDriver) { - Self.logger.warning("Plugin '\(pluginId)' conforms to DriverPlugin but does not declare .databaseDriver capability — registering anyway") + Self.logger.warning("Plugin '\(pluginId)' conforms to DriverPlugin but does not declare .databaseDriver capability - registering anyway") } do { try validateDriverDescriptor(type(of: driver), pluginId: pluginId) @@ -57,7 +57,7 @@ extension PluginManager { if let exportPlugin = instance as? any ExportFormatPlugin { if !declared.contains(.exportFormat) { - Self.logger.warning("Plugin '\(pluginId)' conforms to ExportFormatPlugin but does not declare .exportFormat capability — registering anyway") + Self.logger.warning("Plugin '\(pluginId)' conforms to ExportFormatPlugin but does not declare .exportFormat capability - registering anyway") } let formatId = type(of: exportPlugin).formatId exportPlugins[formatId] = exportPlugin @@ -67,7 +67,7 @@ extension PluginManager { if let importPlugin = instance as? any ImportFormatPlugin { if !declared.contains(.importFormat) { - Self.logger.warning("Plugin '\(pluginId)' conforms to ImportFormatPlugin but does not declare .importFormat capability — registering anyway") + Self.logger.warning("Plugin '\(pluginId)' conforms to ImportFormatPlugin but does not declare .importFormat capability - registering anyway") } let formatId = type(of: importPlugin).formatId importPlugins[formatId] = importPlugin diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index e4712523d..4488e1476 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -23,15 +23,15 @@ final class SchemaService { @ObservationIgnored private let functionDedup = OnceTask() @ObservationIgnored private let schemasDedup = OnceTask() @ObservationIgnored private var settingsCancellable: AnyCancellable? - @ObservationIgnored private var lastDisplaySchemasInSidebar: Bool = false + @ObservationIgnored private var lastDisplaySchemas: Bool = false @ObservationIgnored private static let logger = Logger(subsystem: "com.TablePro", category: "SchemaService") init() { - lastDisplaySchemasInSidebar = AppSettingsManager.shared.editor.displaySchemasInSidebar - settingsCancellable = AppEvents.shared.editorSettingsChanged + lastDisplaySchemas = AppSettingsManager.shared.sidebar.displaySchemas + settingsCancellable = AppEvents.shared.sidebarSettingsChanged .sink { [weak self] in Task { @MainActor [weak self] in - self?.handleEditorSettingsChange() + self?.handleSidebarSettingsChange() } } } @@ -147,7 +147,7 @@ final class SchemaService { ) async { states[connectionId] = .loading - let wantsGrouping = AppSettingsManager.shared.editor.displaySchemasInSidebar + let wantsGrouping = AppSettingsManager.shared.sidebar.displaySchemas && PluginManager.shared.supportsSchemaSwitching(for: connection.type) if wantsGrouping { @@ -260,7 +260,7 @@ final class SchemaService { } private func visibleSchemasForGroupedReload(connectionId: UUID, driver: DatabaseDriver) -> [String]? { - guard AppSettingsManager.shared.editor.displaySchemasInSidebar else { return nil } + guard AppSettingsManager.shared.sidebar.displaySchemas else { return nil } let schemas = schemasInOrder[connectionId] ?? [] guard !schemas.isEmpty else { return nil } return schemas @@ -270,18 +270,13 @@ final class SchemaService { driver: DatabaseDriver, schemas: [String] ) async throws -> [TableInfo] { - try await withThrowingTaskGroup(of: [TableInfo].self) { group in - for schema in schemas { - group.addTask { - try await driver.fetchTables(schema: schema) - } - } - var aggregated: [TableInfo] = [] - for try await tables in group { - aggregated.append(contentsOf: tables) - } - return aggregated + 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( @@ -289,21 +284,17 @@ final class SchemaService { schemas: [String], kind: RoutineInfo.Kind ) async throws -> [RoutineInfo] { - try await withThrowingTaskGroup(of: [RoutineInfo].self) { group in - for schema in schemas { - group.addTask { - switch kind { - case .procedure: return try await driver.fetchProcedures(schema: schema) - case .function: return try await driver.fetchFunctions(schema: schema) - } - } - } - var aggregated: [RoutineInfo] = [] - for try await routines in group { - aggregated.append(contentsOf: routines) + 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) } - return aggregated + aggregated.append(contentsOf: routines) } + return aggregated } private static func fetchRoutinesSafely( @@ -324,10 +315,10 @@ final class SchemaService { } } - private func handleEditorSettingsChange() { - let now = AppSettingsManager.shared.editor.displaySchemasInSidebar - guard now != lastDisplaySchemasInSidebar else { return } - lastDisplaySchemasInSidebar = now + 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 { 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/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/Models/Settings/EditorSettings.swift b/TablePro/Models/Settings/EditorSettings.swift index 9d36bf885..c55295943 100644 --- a/TablePro/Models/Settings/EditorSettings.swift +++ b/TablePro/Models/Settings/EditorSettings.swift @@ -76,7 +76,6 @@ struct EditorSettings: Codable, Equatable { var uppercaseKeywords: Bool var queryParametersEnabled: Bool var jsonViewerPreferredMode: JSONViewMode - var displaySchemasInSidebar: Bool static let `default` = EditorSettings( showLineNumbers: true, @@ -86,8 +85,7 @@ struct EditorSettings: Codable, Equatable { vimModeEnabled: false, uppercaseKeywords: false, queryParametersEnabled: true, - jsonViewerPreferredMode: .text, - displaySchemasInSidebar: false + jsonViewerPreferredMode: .text ) init( @@ -98,8 +96,7 @@ struct EditorSettings: Codable, Equatable { vimModeEnabled: Bool = false, uppercaseKeywords: Bool = false, queryParametersEnabled: Bool = true, - jsonViewerPreferredMode: JSONViewMode = .text, - displaySchemasInSidebar: Bool = false + jsonViewerPreferredMode: JSONViewMode = .text ) { self.showLineNumbers = showLineNumbers self.highlightCurrentLine = highlightCurrentLine @@ -109,7 +106,6 @@ struct EditorSettings: Codable, Equatable { self.uppercaseKeywords = uppercaseKeywords self.queryParametersEnabled = queryParametersEnabled self.jsonViewerPreferredMode = jsonViewerPreferredMode - self.displaySchemasInSidebar = displaySchemasInSidebar } init(from decoder: Decoder) throws { @@ -122,7 +118,6 @@ struct EditorSettings: Codable, Equatable { uppercaseKeywords = try container.decodeIfPresent(Bool.self, forKey: .uppercaseKeywords) ?? false queryParametersEnabled = try container.decodeIfPresent(Bool.self, forKey: .queryParametersEnabled) ?? true jsonViewerPreferredMode = try container.decodeIfPresent(JSONViewMode.self, forKey: .jsonViewerPreferredMode) ?? .text - displaySchemasInSidebar = try container.decodeIfPresent(Bool.self, forKey: .displaySchemasInSidebar) ?? false } /// Clamped tab width (1-16) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index f794ccf4c..8b4a206a4 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -16303,6 +16303,9 @@ } } } + }, + "Display schemas as collapsible sections" : { + }, "Distributed key-value store for service discovery" : { diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index b4bd87b5e..ed1af2718 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -244,7 +244,7 @@ struct AppMenuCommands: Commands { actions?.saveChanges() } .optionalKeyboardShortcut(shortcut(for: .saveChanges)) - // Match toolbar: also disable when no pending changes — avoids + // Match toolbar: also disable when no pending changes - avoids // a no-op Cmd+S when nothing has been edited. .disabled( !(actions?.isConnected ?? false) @@ -423,7 +423,7 @@ struct AppMenuCommands: Commands { // Check if first responder is a text view (SQL editor) if let firstResponder = NSApp.keyWindow?.firstResponder, firstResponder is NSTextView || firstResponder is TextView { - // Send undo: (with colon) through responder chain — + // Send undo: (with colon) through responder chain - // CodeEditTextView.TextView responds to undo: via @objc func undo(_:) NSApp.sendAction(#selector(TableProResponderActions.undo(_:)), to: nil, from: nil) } else { diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index 772d5ef2a..bb99c83be 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -42,8 +42,14 @@ final class SidebarViewModel { didSet { persistExpansion(oldValue: oldValue) } } var schemaExpanded: [String: Bool] = [:] { - didSet { persistSchemaExpansion(oldValue: oldValue) } + 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( @@ -191,6 +197,13 @@ final class SidebarViewModel { 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) diff --git a/TablePro/Views/Settings/EditorSettingsView.swift b/TablePro/Views/Settings/EditorSettingsView.swift index 6af13f6e2..249fbd7ec 100644 --- a/TablePro/Views/Settings/EditorSettingsView.swift +++ b/TablePro/Views/Settings/EditorSettingsView.swift @@ -32,10 +32,6 @@ struct EditorSettingsView: View { } } - Section("Sidebar") { - Toggle("Display schemas on sidebar", isOn: $settings.displaySchemasInSidebar) - } - DataGridSection(settings: $dataGridSettings) } .formStyle(.grouped) 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/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 3452c9586..4b0f28a6a 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -5,12 +5,12 @@ // Created by Ngo Quoc Dat on 16/12/25. // +import os import SwiftUI import TableProPluginKit -// MARK: - SidebarView +private let logger = Logger(subsystem: "com.TablePro", category: "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 @@ -36,7 +36,7 @@ struct SidebarView: View { } private var isSchemaGrouped: Bool { - AppSettingsManager.shared.editor.displaySchemasInSidebar + AppSettingsManager.shared.sidebar.displaySchemas && !schemaService.schemas(for: connectionId).isEmpty } @@ -302,7 +302,11 @@ struct SidebarView: View { Text(schema) .contextMenu { Button(String(localized: "Refresh")) { - Task { await coordinator?.refreshTables() } + Task { + await coordinator?.refreshTables() + await coordinator?.refreshProcedures() + await coordinator?.refreshFunctions() + } } } } @@ -328,7 +332,7 @@ struct SidebarView: View { do { try await switchable.switchSchema(to: targetSchema) } catch { - // Schema switch failed; still attempt to open with current schema + logger.warning("Schema switch to \(targetSchema, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") } onDoubleClick?(table) } diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift index 092b6ee2e..4d82a20f6 100644 --- a/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift +++ b/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift @@ -177,4 +177,13 @@ struct PluginDriverAdapterTableTypeMappingTests { 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/editor-settings.mdx b/docs/customization/editor-settings.mdx index a2f850cc1..7f3b6f622 100644 --- a/docs/customization/editor-settings.mdx +++ b/docs/customization/editor-settings.mdx @@ -190,14 +190,6 @@ Autocomplete is always on. Dismiss with `Escape`. See [Autocomplete](/features/a Pick which mode the JSON viewer starts in. You can switch anytime with the toggle in the viewer. Your last choice is remembered. -## Sidebar - -| Setting | Options | Default | -|---------|---------|---------| -| Display schemas on sidebar | 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 accordion. Multiple schema accordions 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. - ## Keyboard Shortcuts | Action | Shortcut | 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. From 12b20b8892c55e0447fb2f090ddc228dac0db12c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 17 May 2026 18:27:51 +0700 Subject: [PATCH 3/4] fix(sidebar): qualify SELECT with table schema instead of switching connection context --- .../MainSplitViewController.swift | 4 +-- TablePro/Models/Query/QueryTabManager.swift | 24 ++++++++++++--- .../MainContentCoordinator+Navigation.swift | 30 +++++++++++-------- TablePro/Views/Sidebar/SidebarView.swift | 19 +----------- 4 files changed, 41 insertions(+), 36 deletions(-) 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/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/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/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 4b0f28a6a..46c13d62a 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -5,12 +5,9 @@ // Created by Ngo Quoc Dat on 16/12/25. // -import os import SwiftUI import TableProPluginKit -private let logger = Logger(subsystem: "com.TablePro", category: "SidebarView") - struct SidebarView: View { @State private var viewModel: SidebarViewModel @Bindable private var schemaService = SchemaService.shared @@ -321,21 +318,7 @@ struct SidebarView: View { } private func handleTableOpen(_ table: TableInfo) { - guard let targetSchema = table.schema, - let switchable = DatabaseManager.shared.driver(for: connectionId) as? SchemaSwitchable, - let currentSchema = switchable.currentSchema, - targetSchema != currentSchema else { - onDoubleClick?(table) - return - } - Task { - do { - try await switchable.switchSchema(to: targetSchema) - } catch { - logger.warning("Schema switch to \(targetSchema, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") - } - onDoubleClick?(table) - } + onDoubleClick?(table) } // MARK: - Section View From fc2cd142cc5185d4fa7cf6a5133d91a0af8dc278 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 17 May 2026 18:35:08 +0700 Subject: [PATCH 4/4] fix(sidebar): pass payload.schemaName when SessionStateFactory builds the initial tab --- .../Core/Services/Infrastructure/SessionStateFactory.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 {