Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The row detail panel no longer stays blank when a table is opened in a second tab. The panel now shows the selected row right away instead of only after the inspector is toggled.
- The sidebar filter now stays put when you open another tab. Before, opening a second table tab cleared the filter text and reset the table list to show everything.
- Fixed a crash when browsing the database tree on servers with many schemas, such as PostgreSQL. The sidebar tree is now a native outline view that loads each database and schema on demand.
- Reopening the app no longer shows a "Not connected to database" error when it restores a table tab on a connection that is still reconnecting, such as one over SSH. The tab waits for the connection and loads on its own.

## [0.51.1] - 2026-06-16

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -524,8 +524,11 @@ extension QueryExecutionCoordinator {
}

func restoreSchemaAndRunQuery(_ schema: String) async {
guard let driver = DatabaseManager.shared.driver(for: parent.connectionId),
let schemaDriver = driver as? SchemaSwitchable,
guard let driver = DatabaseManager.shared.driver(for: parent.connectionId) else {
parent.needsLazyLoad = true
return
}
guard let schemaDriver = driver as? SchemaSwitchable,
schemaDriver.currentSchema != nil else {
parent.runQuery()
return
Expand Down
15 changes: 13 additions & 2 deletions TablePro/Core/Database/DatabaseManager+Health.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ extension DatabaseManager {
session.driver = result.driver
session.effectiveConnection = result.effectiveConnection
session.status = .connected
if let schemaDriver = result.driver as? SchemaSwitchable {
session.currentSchema = schemaDriver.currentSchema
}
}
return true
} catch {
Expand Down Expand Up @@ -155,7 +158,7 @@ extension DatabaseManager {
await restoreSchemaAndDatabase(
on: driver,
savedSchema: session.currentSchema,
savedDatabase: session.currentDatabase
savedDatabase: databaseSwitchRequiresReconnect(session.connection) ? nil : session.currentDatabase
)

return ReconnectResult(driver: driver, effectiveConnection: connectionForDriver)
Expand All @@ -178,6 +181,11 @@ extension DatabaseManager {
await executeStartupCommands(startupCommands, on: driver, connectionName: connectionName)
}

private func databaseSwitchRequiresReconnect(_ connection: DatabaseConnection) -> Bool {
PluginMetadataRegistry.shared.snapshot(forTypeId: connection.type.pluginTypeId)?
.capabilities.requiresReconnectForDatabaseSwitch ?? false
}

func restoreSchemaAndDatabase(
on driver: DatabaseDriver,
savedSchema: String?,
Expand Down Expand Up @@ -272,14 +280,17 @@ extension DatabaseManager {
await restoreSchemaAndDatabase(
on: driver,
savedSchema: activeSessions[sessionId]?.currentSchema,
savedDatabase: activeSessions[sessionId]?.currentDatabase
savedDatabase: databaseSwitchRequiresReconnect(session.connection) ? nil : activeSessions[sessionId]?.currentDatabase
)

// Update session
updateSession(sessionId) { session in
session.driver = driver
session.status = .connected
session.effectiveConnection = effectiveConnection
if let schemaDriver = driver as? SchemaSwitchable {
session.currentSchema = schemaDriver.currentSchema
}
if let passwordOverride, !session.connection.usesAWSIAM {
session.cachedPassword = passwordOverride
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,19 +383,21 @@ extension MainContentCoordinator {
}

/// Switch to a different database (called from database switcher)
func switchDatabase(to database: String) async {
clearFilterState()
func switchDatabase(to database: String, clearTabs: Bool = true) async {
if clearTabs { clearFilterState() }
let previousDatabase = toolbarState.currentDatabase
toolbarState.currentDatabase = database

do {
try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId)

closeSiblingNativeWindows()
persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId)
tabSessionRegistry.removeAll()
tabManager.tabs = []
tabManager.selectedTabId = nil
if clearTabs {
closeSiblingNativeWindows()
persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId)
tabSessionRegistry.removeAll()
tabManager.tabs = []
tabManager.selectedTabId = nil
}
await SchemaService.shared.invalidate(connectionId: connectionId)

await refreshTables()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,10 @@ extension MainContentCoordinator {
)
changeManager.reloadVersion += 1
Task {
await switchDatabase(to: newTab.tableContext.databaseName)
await switchDatabase(to: newTab.tableContext.databaseName, clearTabs: false)
lazyLoadCurrentTabIfNeeded()
Comment on lines +99 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore schema before loading after DB switch

When switching to a table tab in another PostgreSQL database, this path now reconnects the database and immediately lazy-loads the tab, but switchDatabase clears session.currentSchema for plugins that require reconnects (DatabaseManager+Sessions.swift lines 267-272; PostgreSQL sets that capability in PostgreSQLPlugin.swift line 124). For restored/open tabs in a non-default schema, the selected tab's tableContext.schemaName is never restored before lazyLoadCurrentTabIfNeeded(), so schema-dependent metadata/FK/row-edit fetches run against the default schema even though the tab belongs to another schema. Restore newTab.tableContext.schemaName after the database switch and before auto-loading.

Useful? React with 👍 / 👎.

}
return // switchDatabase will re-execute the query
return
}
}

Expand Down
7 changes: 6 additions & 1 deletion TablePro/Views/Main/Extensions/MainContentView+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ extension MainContentView {
!selectedTab.tableContext.databaseName.isEmpty,
selectedTab.tableContext.databaseName != session.activeDatabase
{
Task { await coordinator.switchDatabase(to: selectedTab.tableContext.databaseName) }
Task {
await coordinator.switchDatabase(
to: selectedTab.tableContext.databaseName, clearTabs: false
)
coordinator.lazyLoadCurrentTabIfNeeded()
Comment on lines +35 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Defer loading until the database switch finishes

When needsLazyLoad is set and the selected restored tab belongs to a different database, this schedules the switch asynchronously but handleConnectionStatusChange still falls through to the unconditional lazyLoadCurrentTabIfNeeded() later in the same method while the session is on the old database. That early load can register the tab's load task and execute the table query before the switch completes, so the post-switch load here is skipped or the tab can show data/errors from the previous database. Return after queuing the switch, or otherwise prevent the lower lazy-load path from running until the context switch is complete.

Useful? React with 👍 / 👎.

}
} else if let selectedTab = tabManager.selectedTab,
let tabSchema = selectedTab.tableContext.schemaName,
!tabSchema.isEmpty,
Expand Down
7 changes: 2 additions & 5 deletions TablePro/Views/Main/Extensions/MainContentView+Setup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,16 +188,13 @@ extension MainContentView {
: activeDatabase.flatMap { $0.isEmpty ? nil : $0 }

Task {
var contextChanged = false
if let targetDatabase, targetDatabase != session.activeDatabase {
await coordinator.switchDatabase(to: targetDatabase)
contextChanged = true
await coordinator.switchDatabase(to: targetDatabase, clearTabs: false)
}
if let activeSchema, !activeSchema.isEmpty, activeSchema != session.currentSchema {
await coordinator.switchSchema(to: activeSchema)
contextChanged = true
}
if isTableTab, !contextChanged {
if isTableTab {
coordinator.lazyLoadCurrentTabIfNeeded()
}
}
Expand Down
26 changes: 23 additions & 3 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -963,13 +963,13 @@ final class MainContentCoordinator {
)
switch decision {
case .authorized:
executeQueryInternal(sql)
executeQueryInternal(sql, isAutoLoad: true)
case .denied(let reason):
tabManager.mutate(at: index) { $0.execution.errorMessage = reason }
}
}
} else {
executeQueryInternal(sql)
executeQueryInternal(sql, isAutoLoad: true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep explicit table runs out of auto-load retry mode

This path is used by runQuery() for table tabs, not only by restored/lazy loads. Passing isAutoLoad: true makes explicit table executions such as Run/Refresh enter the new reconnect-error suppression path, so if the driver is disconnected the catch block suppresses the error, sets needsLazyLoad, and may replay the query later without user feedback. Reserve isAutoLoad for actual automatic loads and let explicit table executions report connection failures.

Useful? React with 👍 / 👎.

}
}

Expand Down Expand Up @@ -1116,7 +1116,8 @@ final class MainContentCoordinator {
}

internal func executeQueryInternal(
_ sql: String
_ sql: String,
isAutoLoad: Bool = false
) {
guard let (selectedTab, index) = tabManager.selectedTabAndIndex,
!selectedTab.execution.isExecuting else { return }
Expand Down Expand Up @@ -1169,6 +1170,21 @@ final class MainContentCoordinator {
currentQueryTask = Task { [weak self] in
guard let self else { return }

if isAutoLoad {
do {
try await services.databaseManager.ensureConnected(conn)
} catch {
await MainActor.run { [weak self] in
guard let self else { return }
tabManager.mutate(tabId: tabId) { $0.execution.isExecuting = false }
currentQueryTask = nil
toolbarState.setExecuting(false)
needsLazyLoad = true
}
return
}
}

let schemaTask: Task<FetchedTableSchema, Error>?
if needsMetadataFetch, let tableName {
schemaTask = Task { try await QueryExecutor.fetchTableSchema(connectionId: connId, tableName: tableName) }
Expand Down Expand Up @@ -1262,6 +1278,10 @@ final class MainContentCoordinator {
toolbarState.setExecuting(false)
if error is CancellationError || Task.isCancelled { return }
guard capturedGeneration == queryGeneration else { return }
if isAutoLoad, services.databaseManager.driver(for: connectionId)?.status != .connected {
needsLazyLoad = true
return
}
handleQueryExecutionError(error, sql: sql, tabId: tabId, connection: conn)
}
}
Expand Down
14 changes: 14 additions & 0 deletions TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,20 @@ struct MainContentCoordinatorLazyLoadTests {
#expect(coordinator.needsLazyLoad == true)
}

@Test("restoreSchemaAndRunQuery defers via needsLazyLoad instead of running a query when the driver is not ready")
func restoreSchemaDefersWhenDriverNil() async {
let (coordinator, tabManager) = makeCoordinator()
let tabId = addTableTab(to: tabManager)
coordinator.needsLazyLoad = false

await coordinator.restoreSchemaAndRunQuery("public")

#expect(coordinator.needsLazyLoad == true)
if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) {
#expect(tabManager.tabs[idx].execution.isExecuting == false)
}
}

// MARK: - Idempotency

@Test("Idempotent: repeated calls with the same loaded state are no-ops")
Expand Down
Loading