Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
04b5f3e
feat(sidebar): show all databases on the server as a tree (#139)
datlechin May 29, 2026
cb0c442
fix(sidebar): metadata pool lifecycle, reconnect teardown, and observ…
datlechin May 29, 2026
e7f293c
feat(sidebar): tree/flat layout option with native styling and large-…
datlechin May 29, 2026
c88cff5
fix(sidebar): tree context-menu targets the clicked database and pool…
datlechin May 29, 2026
50e90a8
Merge branch 'main' into feat/139-sidebar-database-tree
datlechin May 29, 2026
25cee6a
refactor(sidebar): database-qualified tree row identity and serialize…
datlechin May 29, 2026
82323b7
fix(sidebar): reload stranded tree rows after switching active databa…
datlechin May 29, 2026
635b620
fix(sidebar): keep tree schema list stable when switching the active …
datlechin May 29, 2026
397f380
fix(sidebar): load tree routines per schema and skip loads while reco…
datlechin May 29, 2026
186f2cc
fix(sidebar): retry failed tree loads after reconnect instead of bloc…
datlechin May 29, 2026
ce752ab
fix(sidebar): mark tree rows loading before fetching routines to stop…
datlechin May 29, 2026
c951b76
fix(sidebar): distinguish overloaded routines by signature to avoid d…
datlechin May 29, 2026
b28d91d
fix(sidebar): defer active-db tree load until the driver reconnects t…
datlechin May 29, 2026
fba2272
fix(sidebar): qualify tree row ids by database to fix duplicate-id la…
datlechin May 29, 2026
740f455
chore(sidebar): add structured logging across the database tree load …
datlechin May 29, 2026
e95400d
fix(sidebar): mark session connecting at switch start so tree loads w…
datlechin May 29, 2026
4071b5f
refactor(sidebar): rebuild database tree on a connection-agnostic met…
datlechin May 29, 2026
78c33bc
refactor(sidebar): simplify metadata pool and drop diagnostic logging…
datlechin May 29, 2026
569aacf
fix(sidebar): serialize metadata pool queries, debounce tree search, …
datlechin May 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- 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)
- 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)

### Fixed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2640"
version = "1.7">
version = "1.8">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Core/Database/DatabaseManager+Health.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ extension DatabaseManager {
guard let self else { return false }
guard let session = await self.activeSessions[connectionId] else { return false }
await SchemaService.shared.invalidate(connectionId: connectionId)
await DatabaseTreeMetadataService.shared.handleReconnect(connectionId: connectionId)
do {
let result = try await self.trackOperation(sessionId: connectionId) {
try await self.reconnectDriver(for: session)
Expand Down Expand Up @@ -207,6 +208,7 @@ extension DatabaseManager {
}

await SchemaService.shared.invalidate(connectionId: sessionId)
await DatabaseTreeMetadataService.shared.handleReconnect(connectionId: sessionId)

// Stop existing health monitor
await stopHealthMonitor(for: sessionId)
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Core/Database/DatabaseManager+Sessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ extension DatabaseManager {
session.connection.database = database
session.currentDatabase = database
session.currentSchema = nil
session.status = .connecting
}
appSettingsStorage.saveLastSchema(nil, for: connectionId)
await SchemaService.shared.invalidate(connectionId: connectionId)
Expand Down Expand Up @@ -347,10 +348,12 @@ extension DatabaseManager {
removeSessionEntry(for: sessionId)

await SchemaService.shared.invalidate(connectionId: sessionId)
await DatabaseTreeMetadataService.shared.handleDisconnect(connectionId: sessionId)

SchemaProviderRegistry.shared.clear(for: sessionId)

SharedSidebarState.removeConnection(sessionId)
SidebarViewModel.removeConnection(sessionId)

if currentSessionId == sessionId {
if let nextSessionId = activeSessions.keys.first {
Expand Down
9 changes: 9 additions & 0 deletions TablePro/Core/Plugins/PluginManager+Registration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,15 @@ extension PluginManager {
.schema.databaseGroupingStrategy ?? .byDatabase
}

func supportsDatabaseTree(for databaseType: DatabaseType) -> Bool {
guard connectionMode(for: databaseType) == .network,
supportsDatabaseSwitching(for: databaseType) else {
return false
}
let grouping = databaseGroupingStrategy(for: databaseType)
return grouping == .byDatabase || grouping == .bySchema
}

func defaultGroupName(for databaseType: DatabaseType) -> String {
PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)?
.schema.defaultGroupName ?? "main"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ internal final class SidebarContainerViewController: NSViewController {
private var hostingController: NSHostingController<AnyView>
private var sidebarState: SharedSidebarState?
private var windowState: WindowSidebarState?
private var observationGeneration = 0
private var observationTask: Task<Void, Never>?

var rootView: AnyView {
get { hostingController.rootView }
Expand Down Expand Up @@ -58,39 +58,45 @@ internal final class SidebarContainerViewController: NSViewController {
}

func updateSidebarState(_ state: SharedSidebarState?, windowState: WindowSidebarState?) {
observationGeneration += 1
observationTask?.cancel()
self.sidebarState = state
self.windowState = windowState
guard let state, let windowState else {
searchField.isHidden = true
return
}
searchField.isHidden = false
syncFromState(state, windowState: windowState)
startObserving(state, windowState: windowState, generation: observationGeneration)
observationTask = Task { @MainActor [weak self] in
guard let self else { return }
while !Task.isCancelled {
self.syncFromState(state, windowState: windowState)
await Self.awaitChange(state: state, windowState: windowState)
}
}
}

private func startObserving(
_ state: SharedSidebarState,
windowState: WindowSidebarState,
generation: Int
) {
withObservationTracking {
_ = state.selectedSidebarTab
_ = windowState.searchText
_ = windowState.favoritesSearchText
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self,
generation == self.observationGeneration,
let sidebarState = self.sidebarState,
let windowState = self.windowState else { return }
self.syncFromState(sidebarState, windowState: windowState)
self.startObserving(sidebarState, windowState: windowState, generation: generation)
private static func awaitChange(state: SharedSidebarState, windowState: WindowSidebarState) async {
let box = ObservationContinuationBox()
await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
box.attach(continuation)
withObservationTracking {
_ = state.selectedSidebarTab
_ = windowState.searchText
_ = windowState.favoritesSearchText
} onChange: {
box.resume()
}
}
} onCancel: {
box.resume()
}
}

deinit {
observationTask?.cancel()
}

private func syncFromState(_ state: SharedSidebarState, windowState: WindowSidebarState) {
let activeText: String
let placeholder: String
Expand Down Expand Up @@ -130,3 +136,28 @@ extension SidebarContainerViewController: NSSearchFieldDelegate {
}
}
}

private final class ObservationContinuationBox: @unchecked Sendable {
private let lock = NSLock()
private var continuation: CheckedContinuation<Void, Never>?
private var resumed = false

func attach(_ continuation: CheckedContinuation<Void, Never>) {
lock.lock()
defer { lock.unlock() }
guard !resumed else {
continuation.resume()
return
}
self.continuation = continuation
}

func resume() {
lock.lock()
defer { lock.unlock() }
guard !resumed else { return }
resumed = true
continuation?.resume()
continuation = nil
}
}
Loading
Loading