Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- New query tab (Cmd+T) no longer jumps focus back to the previous table tab on SQLite and other file-based databases (#1313)
- File-based databases (SQLite, DuckDB) no longer flash the sidebar table list every time a window becomes key; external file changes are picked up reactively via the file watcher instead of polling on focus
- PostgreSQL connections to AWS RDS, Cloud SQL, Azure, and other hosted Postgres now succeed out of the box instead of failing with "no pg_hba.conf entry for host" (#1298)
- Oracle: SSL/TCPS settings from the SSL pane are now respected; previously every Oracle connection was plain TCP regardless of SSL mode
- Cassandra: SSL settings from the SSL pane are now respected; previously every Cassandra connection was plain TCP because the plugin read from a non-existent "sslMode" field
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ final class DatabaseFileWatcher {

let fd = open(path, O_EVTONLY)
guard fd >= 0 else {
Self.logger.warning("Cannot open database file for watching: \(path, privacy: .public)")
Self.logger.error("Cannot open database file for watching: \(path, privacy: .public) errno=\(errno)")
return
}

Expand All @@ -85,9 +85,11 @@ final class DatabaseFileWatcher {

activeSources[connectionId] = source
source.resume()
Self.logger.info("watching connId=\(connectionId, privacy: .public) path=\(path, privacy: .public)")
}

private func handleEvent(connectionId: UUID) {
Self.logger.info("file event connId=\(connectionId, privacy: .public)")
// Re-create the watcher to get a fresh file descriptor.
// SQLite journaling (rename + recreate) can invalidate the old fd.
startSource(connectionId: connectionId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
) -> some View {
SidebarView(
sidebarState: SharedSidebarState.forConnection(currentSession.connection.id),
windowState: sessionState.coordinator.windowSidebarState,
onDoubleClick: { [weak self] table in
guard let coordinator = self?.sessionState?.coordinator else { return }
let connectionId = coordinator.connectionId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ internal final class SidebarContainerViewController: NSViewController {
generation: Int
) {
withObservationTracking {
_ = state.searchText
_ = state.selectedSidebarTab
_ = windowState.searchText
_ = windowState.favoritesSearchText
} onChange: { [weak self] in
Task { @MainActor [weak self] in
Expand All @@ -96,7 +96,7 @@ internal final class SidebarContainerViewController: NSViewController {
let placeholder: String
switch state.selectedSidebarTab {
case .tables:
activeText = state.searchText
activeText = windowState.searchText
placeholder = String(localized: "Filter")
case .favorites:
activeText = windowState.favoritesSearchText
Expand All @@ -121,12 +121,12 @@ extension SidebarContainerViewController: NSSearchFieldDelegate {
}

private func writeSearchText(_ text: String) {
guard let sidebarState else { return }
guard let sidebarState, let windowState else { return }
switch sidebarState.selectedSidebarTab {
case .tables:
sidebarState.searchText = text
windowState.searchText = text
case .favorites:
windowState?.favoritesSearchText = text
windowState.favoritesSearchText = text
}
}
}
17 changes: 0 additions & 17 deletions TablePro/Core/Services/Query/SchemaService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ final class SchemaService {
private(set) var functions: [UUID: [RoutineInfo]] = [:]
private(set) var schemasInOrder: [UUID: [String]] = [:]

@ObservationIgnored private var lastLoadDates: [UUID: Date] = [:]
@ObservationIgnored private let loadDedup = OnceTask<UUID, [TableInfo]>()
@ObservationIgnored private let procedureDedup = OnceTask<UUID, [RoutineInfo]>()
@ObservationIgnored private let functionDedup = OnceTask<UUID, [RoutineInfo]>()
Expand Down Expand Up @@ -74,20 +73,6 @@ final class SchemaService {
await runLoad(connectionId: connectionId, driver: driver, connection: connection)
}

func reloadIfStale(
connectionId: UUID,
driver: DatabaseDriver,
connection: DatabaseConnection,
staleness: TimeInterval
) async {
guard let lastLoad = lastLoadDates[connectionId] else {
await reload(connectionId: connectionId, driver: driver, connection: connection)
return
}
guard Date().timeIntervalSince(lastLoad) > staleness else { return }
await reload(connectionId: connectionId, driver: driver, connection: connection)
}

func reloadProcedures(connectionId: UUID, driver: DatabaseDriver) async {
do {
let routines = try await procedureDedup.execute(key: connectionId) {
Expand Down Expand Up @@ -127,7 +112,6 @@ final class SchemaService {
procedures.removeValue(forKey: connectionId)
functions.removeValue(forKey: connectionId)
schemasInOrder.removeValue(forKey: connectionId)
lastLoadDates.removeValue(forKey: connectionId)
}

func refresh(connectionId: UUID) async {
Expand Down Expand Up @@ -176,7 +160,6 @@ final class SchemaService {
states[connectionId] = .loaded(tables)
procedures[connectionId] = loadedProcedures
functions[connectionId] = loadedFunctions
lastLoadDates[connectionId] = Date()
} catch is CancellationError {
return
} catch {
Expand Down
7 changes: 3 additions & 4 deletions TablePro/Models/UI/SharedSidebarState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
// SharedSidebarState.swift
// TablePro
//
// Shared sidebar state (selection + search + tab) for cross-tab synchronization.
// One instance per connection, shared across all native macOS tabs.
// Connection-scoped sidebar state shared across all windows of the same
// connection. Window-scoped state (table selection) lives in
// `WindowSidebarState`.
//

import Foundation
Expand All @@ -16,8 +17,6 @@ internal enum SidebarTab: String, CaseIterable {

@MainActor @Observable
final class SharedSidebarState {
var selectedTables: Set<TableInfo> = []
var searchText: String = ""
var redisKeyTreeViewModel: RedisKeyTreeViewModel?

var selectedSidebarTab: SidebarTab {
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Models/UI/WindowSidebarState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@

import Foundation
import Observation
import TableProPluginKit

@MainActor
@Observable
internal final class WindowSidebarState {
var selectedTables: Set<TableInfo> = []
var searchText: String = ""
var favoritesSearchText: String = ""
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,23 @@ private let navigationLogger = Logger(subsystem: "com.TablePro", category: "Main
extension MainContentCoordinator {
// MARK: - Table Tab Opening

func openTableTab(_ table: TableInfo, showStructure: Bool = false) {
func openTableTab(_ table: TableInfo, showStructure: Bool = false, redirectToSibling: Bool = false) {
openTableTab(
table.name,
schema: table.schema,
showStructure: showStructure,
isView: table.type == .view
isView: table.type == .view,
redirectToSibling: redirectToSibling
)
}

func openTableTab(_ tableName: String, schema: String? = nil, showStructure: Bool = false, isView: Bool = false) {
func openTableTab(
_ tableName: String,
schema: String? = nil,
showStructure: Bool = false,
isView: Bool = false,
redirectToSibling: Bool = false
) {
let navigationModel = PluginMetadataRegistry.shared.snapshot(
forTypeId: connection.type.pluginTypeId
)?.navigationModel ?? .standard
Expand Down Expand Up @@ -71,20 +78,25 @@ extension MainContentCoordinator {
return
}

// Check if another native window tab already has this table open — switch to it
for sibling in MainContentCoordinator.allActiveCoordinators()
where sibling !== self && sibling.connectionId == connectionId {
let hasMatch = sibling.tabManager.tabs.contains { tab in
tab.tabType == .table
&& tab.tableContext.tableName == tableName
&& tab.tableContext.databaseName == currentDatabase
&& tab.tableContext.schemaName == resolvedSchema
// Opt-in cross-window navigation: if requested (e.g. quick switcher),
// and another window already shows this table, focus that window.
// Default-off so sidebar clicks and other window-local actions stay
// window-local instead of stealing focus to a sibling.
if redirectToSibling {
for sibling in MainContentCoordinator.allActiveCoordinators()
where sibling !== self && sibling.connectionId == connectionId {
let hasMatch = sibling.tabManager.tabs.contains { tab in
tab.tabType == .table
&& tab.tableContext.tableName == tableName
&& tab.tableContext.databaseName == currentDatabase
&& tab.tableContext.schemaName == resolvedSchema
}
guard hasMatch,
let windowId = sibling.windowId,
let window = WindowLifecycleMonitor.shared.window(for: windowId) else { continue }
window.makeKeyAndOrderFront(nil)
return
}
guard hasMatch,
let windowId = sibling.windowId,
let window = WindowLifecycleMonitor.shared.window(for: windowId) else { continue }
window.makeKeyAndOrderFront(nil)
return
}

// If no tabs exist (empty state), add a table tab directly.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ extension MainContentCoordinator {
func handleQuickSwitcherSelection(_ item: QuickSwitcherItem) {
switch item.kind {
case .table, .systemTable:
openTableTab(item.name)
openTableTab(item.name, redirectToSibling: true)

case .view:
openTableTab(item.name, isView: true)
openTableTab(item.name, isView: true, redirectToSibling: true)

case .database:
Task {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,6 @@ extension MainContentCoordinator {
evictionTask?.cancel()
evictionTask = nil

let isConnected =
DatabaseManager.shared.activeSessions[connectionId]?.isConnected ?? false
if PluginManager.shared.connectionMode(for: connection.type) == .fileBased && isConnected {
Task { await self.refreshTablesIfStale() }
}

syncSidebarToSelectedTab()

Self.lifecycleLogger.debug(
Expand Down Expand Up @@ -90,11 +84,10 @@ extension MainContentCoordinator {

// MARK: - Sidebar Sync

/// Update the connection-scoped sidebar selection so the active table tab
/// Update the window-scoped sidebar selection so the active table tab
/// is highlighted. Reads tables fresh from the DatabaseManager because the
/// schema load is async and may complete after focus changes.
func syncSidebarToSelectedTab() {
let sidebarState = SharedSidebarState.forConnection(connectionId)
let liveTables = DatabaseManager.shared
.session(for: connectionId)?.tables ?? []
let target: Set<TableInfo>
Expand All @@ -104,9 +97,9 @@ extension MainContentCoordinator {
} else {
target = []
}
if sidebarState.selectedTables != target {
if windowSidebarState.selectedTables != target {
if target.isEmpty && liveTables.isEmpty { return }
sidebarState.selectedTables = target
windowSidebarState.selectedTables = target
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,7 @@ extension MainContentView {
/// Only writes when the value actually changes, preventing spurious onChange triggers.
/// Navigation safety is guaranteed by `SidebarNavigationResult.resolve` returning `.skip`
/// when the selected table matches the current tab.
/// Reads from DatabaseManager (authoritative source) instead of the `tables` binding,
/// and skips background windows to avoid overwriting shared sidebar state.
/// Reads from DatabaseManager (authoritative source) instead of the `tables` binding.
func syncSidebarToCurrentTab() {
guard coordinator.isKeyWindow else { return }
let liveTables = DatabaseManager.shared.session(for: connection.id)?.tables ?? []
Expand All @@ -146,9 +145,9 @@ extension MainContentView {
} else {
target = []
}
if sidebarState.selectedTables != target {
if coordinator.windowSidebarState.selectedTables != target {
if target.isEmpty && liveTables.isEmpty { return }
sidebarState.selectedTables = target
coordinator.windowSidebarState.selectedTables = target
}
}

Expand Down
4 changes: 2 additions & 2 deletions TablePro/Views/Main/Extensions/MainContentView+Setup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,8 @@ extension MainContentView {
connection: connection,
selectionState: coordinator.selectionState,
selectedTables: Binding(
get: { sidebarState.selectedTables },
set: { sidebarState.selectedTables = $0 }
get: { coordinator.windowSidebarState.selectedTables },
set: { coordinator.windowSidebarState.selectedTables = $0 }
),
pendingTruncates: $pendingTruncates,
pendingDeletes: $pendingDeletes,
Expand Down
13 changes: 0 additions & 13 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -480,19 +480,6 @@ final class MainContentCoordinator {
fileWatcher = watcher
}

/// Refresh schema only if not recently refreshed (avoids redundant work
/// when both the file watcher and window focus trigger close together).
func refreshTablesIfStale() async {
guard let driver = services.databaseManager.driver(for: connectionId) else { return }
await services.schemaService.reloadIfStale(
connectionId: connectionId,
driver: driver,
connection: connection,
staleness: 2
)
await reconcilePostSchemaLoad()
}

func showAIChatPanel() {
inspectorProxy?.showInspector()
rightPanelState?.activeTab = .aiChat
Expand Down
10 changes: 5 additions & 5 deletions TablePro/Views/Main/MainContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ struct MainContentView: View {
mode: .tables(
connection: exportConnection,
preselectedTables: coordinator.exportPreselectedTableNames
?? Set(sidebarState.selectedTables.map(\.name))
?? Set(coordinator.windowSidebarState.selectedTables.map(\.name))
),
sidebarTables: tables
)
Expand Down Expand Up @@ -379,23 +379,23 @@ struct MainContentView: View {
handleConnectionStatusChange()
}

.onChange(of: sidebarState.selectedTables) { oldTables, newTables in
.onChange(of: coordinator.windowSidebarState.selectedTables) { oldTables, newTables in
guard !coordinator.isTearingDown else {
Self.lifecycleLogger.debug("[switch] sidebarState.selectedTables SKIPPED (tearingDown) windowId=\(windowId, privacy: .public)")
Self.lifecycleLogger.debug("[switch] windowSidebarState.selectedTables SKIPPED (tearingDown) windowId=\(windowId, privacy: .public)")
return
}
handleTableSelectionChange(from: oldTables, to: newTables)
}
.onChange(of: tables) { _, newTables in
let syncAction = SidebarSyncAction.resolveOnTablesLoad(
newTables: newTables,
selectedTables: sidebarState.selectedTables,
selectedTables: coordinator.windowSidebarState.selectedTables,
currentTabTableName: tabManager.selectedTab?.tableContext.tableName
)
if case .select(let tableName) = syncAction,
let match = newTables.first(where: { $0.name == tableName })
{
sidebarState.selectedTables = [match]
coordinator.windowSidebarState.selectedTables = [match]
}
}
}
Expand Down
Loading
Loading