Skip to content

Commit 634c3cb

Browse files
authored
feat(sidebar): show all databases on the server as a tree (#139) (#1473)
* feat(sidebar): show all databases on the server as a tree (#139) * fix(sidebar): metadata pool lifecycle, reconnect teardown, and observation cancellation (#139) * feat(sidebar): tree/flat layout option with native styling and large-list perf (#139) * fix(sidebar): tree context-menu targets the clicked database and pool clears canceled tasks (#139) * refactor(sidebar): database-qualified tree row identity and serialized metadata pool (#139) * fix(sidebar): reload stranded tree rows after switching active database (#139) * fix(sidebar): keep tree schema list stable when switching the active schema (#139) * fix(sidebar): load tree routines per schema and skip loads while reconnecting (#139) * fix(sidebar): retry failed tree loads after reconnect instead of blocking loads (#139) * fix(sidebar): mark tree rows loading before fetching routines to stop reconcile storm (#139) * fix(sidebar): distinguish overloaded routines by signature to avoid duplicate-id crash (#139) * fix(sidebar): defer active-db tree load until the driver reconnects to avoid Not Connected flash (#139) * fix(sidebar): qualify tree row ids by database to fix duplicate-id layout crash (#139) * chore(sidebar): add structured logging across the database tree load path (#139) * fix(sidebar): mark session connecting at switch start so tree loads wait for the new connection (#139) * refactor(sidebar): rebuild database tree on a connection-agnostic metadata cache (#139) * refactor(sidebar): simplify metadata pool and drop diagnostic logging (#139) * fix(sidebar): serialize metadata pool queries, debounce tree search, harden reconnect reset (#139) --------- Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
1 parent d8adced commit 634c3cb

34 files changed

Lines changed: 1665 additions & 341 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+
- The sidebar can show every database on the server as an expandable tree. Switch a connection between the flat list and the tree from the View menu (Sidebar Layout); right-click a database or schema to set it active. Set the default layout for new connections in Settings, General. Applies to MySQL, MariaDB, PostgreSQL, MSSQL, ClickHouse, Redshift; SQLite, Redis, MongoDB, BigQuery keep their existing sidebar. (#139)
1213
- A connection can read its password from a file, environment variable, or command at connect time instead of the Keychain, so scripts can provision a connection without entering the password by hand. (#1254)
1314

1415
### Fixed

TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<?xml version="1.0" encoding="UTF-8"?>
22
<Scheme
33
LastUpgradeVersion = "2640"
4-
version = "1.7">
4+
version = "1.8">
55
<BuildAction
66
parallelizeBuildables = "YES"
77
buildImplicitDependencies = "YES"

TablePro/Core/Database/DatabaseManager+Health.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ extension DatabaseManager {
5353
guard let self else { return false }
5454
guard let session = await self.activeSessions[connectionId] else { return false }
5555
await SchemaService.shared.invalidate(connectionId: connectionId)
56+
await DatabaseTreeMetadataService.shared.handleReconnect(connectionId: connectionId)
5657
do {
5758
let result = try await self.trackOperation(sessionId: connectionId) {
5859
try await self.reconnectDriver(for: session)
@@ -207,6 +208,7 @@ extension DatabaseManager {
207208
}
208209

209210
await SchemaService.shared.invalidate(connectionId: sessionId)
211+
await DatabaseTreeMetadataService.shared.handleReconnect(connectionId: sessionId)
210212

211213
// Stop existing health monitor
212214
await stopHealthMonitor(for: sessionId)

TablePro/Core/Database/DatabaseManager+Sessions.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ extension DatabaseManager {
260260
session.connection.database = database
261261
session.currentDatabase = database
262262
session.currentSchema = nil
263+
session.status = .connecting
263264
}
264265
appSettingsStorage.saveLastSchema(nil, for: connectionId)
265266
await SchemaService.shared.invalidate(connectionId: connectionId)
@@ -347,10 +348,12 @@ extension DatabaseManager {
347348
removeSessionEntry(for: sessionId)
348349

349350
await SchemaService.shared.invalidate(connectionId: sessionId)
351+
await DatabaseTreeMetadataService.shared.handleDisconnect(connectionId: sessionId)
350352

351353
SchemaProviderRegistry.shared.clear(for: sessionId)
352354

353355
SharedSidebarState.removeConnection(sessionId)
356+
SidebarViewModel.removeConnection(sessionId)
354357

355358
if currentSessionId == sessionId {
356359
if let nextSessionId = activeSessions.keys.first {

TablePro/Core/Plugins/PluginManager+Registration.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,15 @@ extension PluginManager {
446446
.schema.databaseGroupingStrategy ?? .byDatabase
447447
}
448448

449+
func supportsDatabaseTree(for databaseType: DatabaseType) -> Bool {
450+
guard connectionMode(for: databaseType) == .network,
451+
supportsDatabaseSwitching(for: databaseType) else {
452+
return false
453+
}
454+
let grouping = databaseGroupingStrategy(for: databaseType)
455+
return grouping == .byDatabase || grouping == .bySchema
456+
}
457+
449458
func defaultGroupName(for databaseType: DatabaseType) -> String {
450459
PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)?
451460
.schema.defaultGroupName ?? "main"

TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ internal final class SidebarContainerViewController: NSViewController {
1212
private var hostingController: NSHostingController<AnyView>
1313
private var sidebarState: SharedSidebarState?
1414
private var windowState: WindowSidebarState?
15-
private var observationGeneration = 0
15+
private var observationTask: Task<Void, Never>?
1616

1717
var rootView: AnyView {
1818
get { hostingController.rootView }
@@ -58,39 +58,45 @@ internal final class SidebarContainerViewController: NSViewController {
5858
}
5959

6060
func updateSidebarState(_ state: SharedSidebarState?, windowState: WindowSidebarState?) {
61-
observationGeneration += 1
61+
observationTask?.cancel()
6262
self.sidebarState = state
6363
self.windowState = windowState
6464
guard let state, let windowState else {
6565
searchField.isHidden = true
6666
return
6767
}
6868
searchField.isHidden = false
69-
syncFromState(state, windowState: windowState)
70-
startObserving(state, windowState: windowState, generation: observationGeneration)
69+
observationTask = Task { @MainActor [weak self] in
70+
guard let self else { return }
71+
while !Task.isCancelled {
72+
self.syncFromState(state, windowState: windowState)
73+
await Self.awaitChange(state: state, windowState: windowState)
74+
}
75+
}
7176
}
7277

73-
private func startObserving(
74-
_ state: SharedSidebarState,
75-
windowState: WindowSidebarState,
76-
generation: Int
77-
) {
78-
withObservationTracking {
79-
_ = state.selectedSidebarTab
80-
_ = windowState.searchText
81-
_ = windowState.favoritesSearchText
82-
} onChange: { [weak self] in
83-
Task { @MainActor [weak self] in
84-
guard let self,
85-
generation == self.observationGeneration,
86-
let sidebarState = self.sidebarState,
87-
let windowState = self.windowState else { return }
88-
self.syncFromState(sidebarState, windowState: windowState)
89-
self.startObserving(sidebarState, windowState: windowState, generation: generation)
78+
private static func awaitChange(state: SharedSidebarState, windowState: WindowSidebarState) async {
79+
let box = ObservationContinuationBox()
80+
await withTaskCancellationHandler {
81+
await withCheckedContinuation { continuation in
82+
box.attach(continuation)
83+
withObservationTracking {
84+
_ = state.selectedSidebarTab
85+
_ = windowState.searchText
86+
_ = windowState.favoritesSearchText
87+
} onChange: {
88+
box.resume()
89+
}
9090
}
91+
} onCancel: {
92+
box.resume()
9193
}
9294
}
9395

96+
deinit {
97+
observationTask?.cancel()
98+
}
99+
94100
private func syncFromState(_ state: SharedSidebarState, windowState: WindowSidebarState) {
95101
let activeText: String
96102
let placeholder: String
@@ -130,3 +136,28 @@ extension SidebarContainerViewController: NSSearchFieldDelegate {
130136
}
131137
}
132138
}
139+
140+
private final class ObservationContinuationBox: @unchecked Sendable {
141+
private let lock = NSLock()
142+
private var continuation: CheckedContinuation<Void, Never>?
143+
private var resumed = false
144+
145+
func attach(_ continuation: CheckedContinuation<Void, Never>) {
146+
lock.lock()
147+
defer { lock.unlock() }
148+
guard !resumed else {
149+
continuation.resume()
150+
return
151+
}
152+
self.continuation = continuation
153+
}
154+
155+
func resume() {
156+
lock.lock()
157+
defer { lock.unlock() }
158+
guard !resumed else { return }
159+
resumed = true
160+
continuation?.resume()
161+
continuation = nil
162+
}
163+
}

0 commit comments

Comments
 (0)