Skip to content

Commit 1ecff2d

Browse files
feat(sidebar): show schemas as collapsible sections in the Tables sidebar (#1300)
* feat(sidebar): show schemas as collapsible sections in the Tables sidebar * refactor(sidebar): address PR review feedback - sidebar settings group, schema serialisation, comment cleanup * fix(sidebar): qualify SELECT with table schema instead of switching connection context * fix(sidebar): pass payload.schemaName when SessionStateFactory builds the initial tab --------- Signed-off-by: Luiz Vergennes <luizvergennes@lgvm.dev> Co-authored-by: Ngo Quoc Dat <datlechin@gmail.com>
1 parent 01de284 commit 1ecff2d

22 files changed

Lines changed: 618 additions & 49 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Show schemas as collapsible sections in the Tables sidebar (#1296)
1213
- Inline dropdown picker when editing ENUM and SET columns, covering MySQL, MariaDB, PostgreSQL, ClickHouse, DuckDB, and MongoDB JSON-schema enums (#1283)
1314
- Filter rows show an enum dropdown for `=` and `!=` operators on enum columns (#1283)
1415
- 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)

TablePro/Core/Database/DatabaseDriver.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ protocol DatabaseDriver: AnyObject {
6464
/// Fetch all tables in the database
6565
func fetchTables() async throws -> [TableInfo]
6666

67+
func fetchTables(schema: String?) async throws -> [TableInfo]
68+
6769
/// Fetch columns for a specific table
6870
func fetchColumns(table: String) async throws -> [ColumnInfo]
6971

@@ -359,6 +361,10 @@ extension DatabaseDriver {
359361
/// Default: no schema support (MySQL/SQLite don't use schemas in the same way)
360362
func fetchSchemas() async throws -> [String] { [] }
361363

364+
func fetchTables(schema: String?) async throws -> [TableInfo] {
365+
try await fetchTables()
366+
}
367+
362368
func fetchProcedures(schema: String?) async throws -> [RoutineInfo] { [] }
363369

364370
func fetchFunctions(schema: String?) async throws -> [RoutineInfo] { [] }

TablePro/Core/Events/AppEvents.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ final class AppEvents {
2020

2121
let editorSettingsChanged = PassthroughSubject<Void, Never>()
2222

23+
let sidebarSettingsChanged = PassthroughSubject<Void, Never>()
24+
2325
let dataGridSettingsChanged = PassthroughSubject<Void, Never>()
2426

2527
let aiSettingsChanged = PassthroughSubject<Void, Never>()

TablePro/Core/Plugins/PluginDriverAdapter.swift

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -160,25 +160,38 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable {
160160

161161
func fetchTables() async throws -> [TableInfo] {
162162
let pluginTables = try await pluginDriver.fetchTables(schema: pluginDriver.currentSchema)
163-
return pluginTables.map { table in
164-
let tableType: TableInfo.TableType
165-
switch table.type.lowercased() {
166-
case "table", "base table", "prefix":
167-
tableType = .table
168-
case "view":
169-
tableType = .view
170-
case "materialized view", "materialized_view":
171-
tableType = .materializedView
172-
case "foreign table", "foreign_table":
173-
tableType = .foreignTable
174-
case "system table", "system base table", "system view":
175-
tableType = .systemTable
176-
default:
177-
Self.logger.warning("Unknown plugin table type \"\(table.type, privacy: .public)\" for \"\(table.name, privacy: .public)\"; defaulting to .table")
178-
tableType = .table
179-
}
180-
return TableInfo(name: table.name, type: tableType, rowCount: table.rowCount)
163+
return pluginTables.map { mapPluginTable($0, schemaFallback: nil) }
164+
}
165+
166+
func fetchTables(schema: String?) async throws -> [TableInfo] {
167+
let resolvedSchema = schema ?? pluginDriver.currentSchema
168+
let pluginTables = try await pluginDriver.fetchTables(schema: resolvedSchema)
169+
return pluginTables.map { mapPluginTable($0, schemaFallback: resolvedSchema) }
170+
}
171+
172+
private func mapPluginTable(_ table: PluginTableInfo, schemaFallback: String?) -> TableInfo {
173+
let tableType: TableInfo.TableType
174+
switch table.type.lowercased() {
175+
case "table", "base table", "prefix":
176+
tableType = .table
177+
case "view":
178+
tableType = .view
179+
case "materialized view", "materialized_view":
180+
tableType = .materializedView
181+
case "foreign table", "foreign_table":
182+
tableType = .foreignTable
183+
case "system table", "system base table", "system view":
184+
tableType = .systemTable
185+
default:
186+
Self.logger.warning("Unknown plugin table type \"\(table.type, privacy: .public)\" for \"\(table.name, privacy: .public)\"; defaulting to .table")
187+
tableType = .table
181188
}
189+
return TableInfo(
190+
name: table.name,
191+
type: tableType,
192+
rowCount: table.rowCount,
193+
schema: table.schema ?? schemaFallback
194+
)
182195
}
183196

184197
func fetchColumns(table: String) async throws -> [ColumnInfo] {

TablePro/Core/Services/Infrastructure/MainSplitViewController.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -340,11 +340,11 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
340340
previewCoordinator.promotePreviewTab()
341341
} else {
342342
previewCoordinator.promotePreviewTab()
343-
coordinator.openTableTab(table.name, isView: isView)
343+
coordinator.openTableTab(table.name, schema: table.schema, isView: isView)
344344
}
345345
} else {
346346
coordinator.promotePreviewTab()
347-
coordinator.openTableTab(table.name, isView: isView)
347+
coordinator.openTableTab(table.name, schema: table.schema, isView: isView)
348348
}
349349
},
350350
pendingTruncates: sessionPendingTruncatesBinding,

TablePro/Core/Services/Infrastructure/SessionStateFactory.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,15 @@ enum SessionStateFactory {
9696
try tabMgr.addPreviewTableTab(
9797
tableName: tableName,
9898
databaseType: connection.type,
99-
databaseName: payload.databaseName ?? activeDatabaseName
99+
databaseName: payload.databaseName ?? activeDatabaseName,
100+
schemaName: payload.schemaName
100101
)
101102
} else {
102103
try tabMgr.addTableTab(
103104
tableName: tableName,
104105
databaseType: connection.type,
105-
databaseName: payload.databaseName ?? activeDatabaseName
106+
databaseName: payload.databaseName ?? activeDatabaseName,
107+
schemaName: payload.schemaName
106108
)
107109
}
108110
} catch {

TablePro/Core/Services/Query/SchemaService.swift

Lines changed: 162 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// TablePro
44
//
55

6+
import Combine
67
import Foundation
78
import os
89

@@ -14,14 +15,26 @@ final class SchemaService {
1415
private(set) var states: [UUID: SchemaState] = [:]
1516
private(set) var procedures: [UUID: [RoutineInfo]] = [:]
1617
private(set) var functions: [UUID: [RoutineInfo]] = [:]
18+
private(set) var schemasInOrder: [UUID: [String]] = [:]
1719

1820
@ObservationIgnored private var lastLoadDates: [UUID: Date] = [:]
1921
@ObservationIgnored private let loadDedup = OnceTask<UUID, [TableInfo]>()
2022
@ObservationIgnored private let procedureDedup = OnceTask<UUID, [RoutineInfo]>()
2123
@ObservationIgnored private let functionDedup = OnceTask<UUID, [RoutineInfo]>()
24+
@ObservationIgnored private let schemasDedup = OnceTask<UUID, [String]>()
25+
@ObservationIgnored private var settingsCancellable: AnyCancellable?
26+
@ObservationIgnored private var lastDisplaySchemas: Bool = false
2227
@ObservationIgnored private static let logger = Logger(subsystem: "com.TablePro", category: "SchemaService")
2328

24-
init() {}
29+
init() {
30+
lastDisplaySchemas = AppSettingsManager.shared.sidebar.displaySchemas
31+
settingsCancellable = AppEvents.shared.sidebarSettingsChanged
32+
.sink { [weak self] in
33+
Task { @MainActor [weak self] in
34+
self?.handleSidebarSettingsChange()
35+
}
36+
}
37+
}
2538

2639
func state(for connectionId: UUID) -> SchemaState {
2740
states[connectionId] ?? .idle
@@ -46,6 +59,10 @@ final class SchemaService {
4659
procedures(for: connectionId) + functions(for: connectionId)
4760
}
4861

62+
func schemas(for connectionId: UUID) -> [String] {
63+
schemasInOrder[connectionId] ?? []
64+
}
65+
4966
func load(connectionId: UUID, driver: DatabaseDriver, connection: DatabaseConnection) async {
5067
switch state(for: connectionId) {
5168
case .loaded:
@@ -74,9 +91,13 @@ final class SchemaService {
7491
}
7592

7693
func reloadProcedures(connectionId: UUID, driver: DatabaseDriver) async {
94+
let visibleSchemas = visibleSchemasForGroupedReload(connectionId: connectionId, driver: driver)
7795
do {
7896
let routines = try await procedureDedup.execute(key: connectionId) {
79-
try await driver.fetchProcedures(schema: nil)
97+
if let schemas = visibleSchemas {
98+
return try await Self.fetchRoutinesAcrossSchemas(driver: driver, schemas: schemas, kind: .procedure)
99+
}
100+
return try await driver.fetchProcedures(schema: nil)
80101
}
81102
procedures[connectionId] = routines
82103
} catch is CancellationError {
@@ -89,9 +110,13 @@ final class SchemaService {
89110
}
90111

91112
func reloadFunctions(connectionId: UUID, driver: DatabaseDriver) async {
113+
let visibleSchemas = visibleSchemasForGroupedReload(connectionId: connectionId, driver: driver)
92114
do {
93115
let routines = try await functionDedup.execute(key: connectionId) {
94-
try await driver.fetchFunctions(schema: nil)
116+
if let schemas = visibleSchemas {
117+
return try await Self.fetchRoutinesAcrossSchemas(driver: driver, schemas: schemas, kind: .function)
118+
}
119+
return try await driver.fetchFunctions(schema: nil)
95120
}
96121
functions[connectionId] = routines
97122
} catch is CancellationError {
@@ -107,9 +132,11 @@ final class SchemaService {
107132
await loadDedup.cancel(key: connectionId)
108133
await procedureDedup.cancel(key: connectionId)
109134
await functionDedup.cancel(key: connectionId)
135+
await schemasDedup.cancel(key: connectionId)
110136
states.removeValue(forKey: connectionId)
111137
procedures.removeValue(forKey: connectionId)
112138
functions.removeValue(forKey: connectionId)
139+
schemasInOrder.removeValue(forKey: connectionId)
113140
lastLoadDates.removeValue(forKey: connectionId)
114141
}
115142

@@ -120,6 +147,23 @@ final class SchemaService {
120147
) async {
121148
states[connectionId] = .loading
122149

150+
let wantsGrouping = AppSettingsManager.shared.sidebar.displaySchemas
151+
&& PluginManager.shared.supportsSchemaSwitching(for: connection.type)
152+
153+
if wantsGrouping {
154+
await runSchemaGroupedLoad(connectionId: connectionId, driver: driver, connection: connection)
155+
} else {
156+
await runFlatLoad(connectionId: connectionId, driver: driver, connection: connection)
157+
}
158+
}
159+
160+
private func runFlatLoad(
161+
connectionId: UUID,
162+
driver: DatabaseDriver,
163+
connection: DatabaseConnection
164+
) async {
165+
schemasInOrder.removeValue(forKey: connectionId)
166+
123167
async let tablesTask: [TableInfo] = loadDedup.execute(key: connectionId) {
124168
try await driver.fetchTables()
125169
}
@@ -155,6 +199,104 @@ final class SchemaService {
155199
}
156200
}
157201

202+
private func runSchemaGroupedLoad(
203+
connectionId: UUID,
204+
driver: DatabaseDriver,
205+
connection: DatabaseConnection
206+
) async {
207+
let dbType = connection.type
208+
let allSchemas: [String]
209+
do {
210+
allSchemas = try await schemasDedup.execute(key: connectionId) {
211+
try await driver.fetchSchemas()
212+
}
213+
} catch is CancellationError {
214+
return
215+
} catch {
216+
Self.logger.warning(
217+
"[schema] fetchSchemas failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public); falling back to flat load"
218+
)
219+
await runFlatLoad(connectionId: connectionId, driver: driver, connection: connection)
220+
return
221+
}
222+
223+
let systemSchemas = Set(PluginManager.shared.systemSchemaNames(for: dbType))
224+
let visibleSchemas = allSchemas.filter { !systemSchemas.contains($0) }
225+
schemasInOrder[connectionId] = visibleSchemas
226+
227+
async let tablesTask: [TableInfo] = loadDedup.execute(key: connectionId) {
228+
try await Self.fetchTablesAcrossSchemas(driver: driver, schemas: visibleSchemas)
229+
}
230+
async let proceduresTask: [RoutineInfo] = Self.fetchRoutinesSafely(
231+
connectionId: connectionId,
232+
kind: .procedure,
233+
dedup: procedureDedup,
234+
fetch: { try await Self.fetchRoutinesAcrossSchemas(driver: driver, schemas: visibleSchemas, kind: .procedure) }
235+
)
236+
async let functionsTask: [RoutineInfo] = Self.fetchRoutinesSafely(
237+
connectionId: connectionId,
238+
kind: .function,
239+
dedup: functionDedup,
240+
fetch: { try await Self.fetchRoutinesAcrossSchemas(driver: driver, schemas: visibleSchemas, kind: .function) }
241+
)
242+
243+
let loadedProcedures = await proceduresTask
244+
let loadedFunctions = await functionsTask
245+
246+
do {
247+
let tables = try await tablesTask
248+
states[connectionId] = .loaded(tables)
249+
procedures[connectionId] = loadedProcedures
250+
functions[connectionId] = loadedFunctions
251+
lastLoadDates[connectionId] = Date()
252+
} catch is CancellationError {
253+
return
254+
} catch {
255+
Self.logger.warning(
256+
"[schema] grouped load failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)"
257+
)
258+
states[connectionId] = .failed(error.localizedDescription)
259+
}
260+
}
261+
262+
private func visibleSchemasForGroupedReload(connectionId: UUID, driver: DatabaseDriver) -> [String]? {
263+
guard AppSettingsManager.shared.sidebar.displaySchemas else { return nil }
264+
let schemas = schemasInOrder[connectionId] ?? []
265+
guard !schemas.isEmpty else { return nil }
266+
return schemas
267+
}
268+
269+
private static func fetchTablesAcrossSchemas(
270+
driver: DatabaseDriver,
271+
schemas: [String]
272+
) async throws -> [TableInfo] {
273+
var aggregated: [TableInfo] = []
274+
for schema in schemas {
275+
try Task.checkCancellation()
276+
let tables = try await driver.fetchTables(schema: schema)
277+
aggregated.append(contentsOf: tables)
278+
}
279+
return aggregated
280+
}
281+
282+
private static func fetchRoutinesAcrossSchemas(
283+
driver: DatabaseDriver,
284+
schemas: [String],
285+
kind: RoutineInfo.Kind
286+
) async throws -> [RoutineInfo] {
287+
var aggregated: [RoutineInfo] = []
288+
for schema in schemas {
289+
try Task.checkCancellation()
290+
let routines: [RoutineInfo]
291+
switch kind {
292+
case .procedure: routines = try await driver.fetchProcedures(schema: schema)
293+
case .function: routines = try await driver.fetchFunctions(schema: schema)
294+
}
295+
aggregated.append(contentsOf: routines)
296+
}
297+
return aggregated
298+
}
299+
158300
private static func fetchRoutinesSafely(
159301
connectionId: UUID,
160302
kind: RoutineInfo.Kind,
@@ -172,4 +314,21 @@ final class SchemaService {
172314
return []
173315
}
174316
}
317+
318+
private func handleSidebarSettingsChange() {
319+
let now = AppSettingsManager.shared.sidebar.displaySchemas
320+
guard now != lastDisplaySchemas else { return }
321+
lastDisplaySchemas = now
322+
323+
let sessions = DatabaseManager.shared.activeSessions
324+
for (connectionId, session) in sessions {
325+
guard let driver = session.driver else { continue }
326+
let connection = session.connection
327+
Task { [weak self] in
328+
guard let self else { return }
329+
await self.invalidate(connectionId: connectionId)
330+
await self.reload(connectionId: connectionId, driver: driver, connection: connection)
331+
}
332+
}
333+
}
175334
}

TablePro/Core/Storage/AppSettingsManager.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ final class AppSettingsManager {
9696
}
9797
}
9898

99+
var sidebar: SidebarSettings {
100+
didSet {
101+
storage.saveSidebar(sidebar)
102+
appEvents.sidebarSettingsChanged.send(())
103+
syncTracker.markDirty(.settings, id: "sidebar")
104+
}
105+
}
106+
99107
var keyboard: KeyboardSettings {
100108
didSet {
101109
storage.saveKeyboard(keyboard)
@@ -203,6 +211,7 @@ final class AppSettingsManager {
203211
self.dataGrid = storage.loadDataGrid()
204212
self.history = storage.loadHistory()
205213
self.tabs = storage.loadTabs()
214+
self.sidebar = storage.loadSidebar()
206215
self.keyboard = storage.loadKeyboard()
207216
self.ai = Self.migrateAI(storage.loadAI())
208217
self.sync = storage.loadSync()

0 commit comments

Comments
 (0)