From d580d2dc5c73fc58df916dc6f48fba9be8a8afac Mon Sep 17 00:00:00 2001 From: xuhengyu Date: Sat, 27 Jun 2026 11:25:57 +0800 Subject: [PATCH 01/18] feat(editor): add per-tab database picker to query toolbar --- .../Views/Editor/QueryContainerPicker.swift | 73 +++++++++++++++++++ TablePro/Views/Editor/QueryEditorView.swift | 13 ++++ .../Main/Child/MainEditorContentView.swift | 41 ++++++++++- .../MainContentCoordinator+QueryHelpers.swift | 12 +++ .../Views/Main/MainContentCoordinator.swift | 10 +++ 5 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 TablePro/Views/Editor/QueryContainerPicker.swift diff --git a/TablePro/Views/Editor/QueryContainerPicker.swift b/TablePro/Views/Editor/QueryContainerPicker.swift new file mode 100644 index 000000000..b8f30f437 --- /dev/null +++ b/TablePro/Views/Editor/QueryContainerPicker.swift @@ -0,0 +1,73 @@ +// +// QueryContainerPicker.swift +// TablePro +// +// Per-tab container (database/schema) selector shown in the query editor +// toolbar. Binds a query tab to the container its SQL runs in, so each tab +// can target a different database without clearing the others. +// + +import SwiftUI + +struct QueryContainerPicker: View { + let containers: [DatabaseMetadata] + let selectedName: String + let entityName: String + let isReadOnly: Bool + let onChange: (String) -> Void + + var body: some View { + if isReadOnly { + readOnlyLabel + } else if containers.count > 1 { + menu + } else { + EmptyView() + } + } + + private var selectedIcon: String { + containers.first(where: { $0.name == selectedName })?.icon ?? "cylinder" + } + + private var menu: some View { + Menu { + ForEach(containers, id: \.name) { container in + Button { + if container.name != selectedName { onChange(container.name) } + } label: { + Label(container.name, systemImage: container.name == selectedName ? "checkmark" : container.icon) + } + } + } label: { + HStack(spacing: 4) { + Image(systemName: selectedIcon) + .font(.body) + Text(selectedName.isEmpty ? entityName : selectedName) + .font(.callout) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .foregroundStyle(.secondary) + } + .menuStyle(.borderlessButton) + .fixedSize() + .accessibilityLabel(entityName) + } + + private var readOnlyLabel: some View { + HStack(spacing: 4) { + Image(systemName: selectedIcon) + .font(.body) + Text(selectedName) + .font(.callout) + .lineLimit(1) + Image(systemName: "lock.fill") + .font(.caption2) + } + .foregroundStyle(.secondary) + .help(String(format: String(localized: "%@ switches reconnect the session"), entityName)) + } +} diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index 088787532..785555ae4 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -31,6 +31,11 @@ struct QueryEditorView: View { var onAIOptimize: ((String) -> Void)? var onSaveAsFavorite: ((String) -> Void)? var onClearResults: (() -> Void)? + var availableContainers: [DatabaseMetadata] = [] + var selectedContainerName: String = "" + var containerEntityName: String = "" + var isContainerSwitchReadOnly: Bool = false + var onContainerChanged: ((String) -> Void)? @State private var vimMode: VimMode = .normal @@ -86,6 +91,14 @@ struct QueryEditorView: View { VimModeIndicatorView(mode: vimMode) } + QueryContainerPicker( + containers: availableContainers, + selectedName: selectedContainerName, + entityName: containerEntityName, + isReadOnly: isContainerSwitchReadOnly, + onChange: { name in onContainerChanged?(name) } + ) + Spacer() Button(action: { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index d6119cf52..c29fb7d9f 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -58,6 +58,8 @@ struct MainEditorContentView: View { @State private var serverDashboardViewModels: [UUID: ServerDashboardViewModel] = [:] @State private var dataTabDelegate = DataTabGridDelegate() + @Bindable private var treeService = DatabaseTreeMetadataService.shared + // Native macOS window tabs — no LRU tracking needed (single tab per window) // MARK: - Environment @@ -261,6 +263,38 @@ struct MainEditorContentView: View { .id(tab.id) } + // MARK: - Per-tab container picker + + private var containerSwitchTarget: ContainerSwitchTarget? { + PluginManager.shared.containerSwitchTarget(for: connection.type) + } + + private var filteredContainerDatabases: [DatabaseMetadata] { + guard containerSwitchTarget == .database else { return [] } + let selected = SharedSidebarState.forConnection(connectionId).databaseFilterSelected + return DatabaseTreeVisibility.visible(databases: treeService.databases(for: connectionId), selected: selected) + } + + private var isContainerSwitchReadOnly: Bool { + guard containerSwitchTarget == .database else { return false } + return PluginManager.shared.requiresReconnectForDatabaseSwitch(for: connection.type) + } + + private var containerEntityName: String { + PluginManager.shared.containerEntityName(for: connection.type) + } + + private func containerName(for tab: QueryTab) -> String { + let bound = tab.tableContext.databaseName + return bound.isEmpty ? coordinator.activeDatabaseName : bound + } + + private func changeContainer(for tab: QueryTab, to name: String) { + let tabId = tab.id + tabManager.mutate(tabId: tabId) { $0.tableContext.databaseName = name } + Task { await coordinator.switchDatabase(to: name, clearTabs: false) } + } + // MARK: - Query Tab Content @ViewBuilder @@ -319,7 +353,12 @@ struct MainEditorContentView: View { guard !text.isEmpty else { return } coordinator.favoriteDialogQuery = FavoriteDialogQuery(query: text) }, - onClearResults: { coordinator.clearActiveQueryResults() } + onClearResults: { coordinator.clearActiveQueryResults() }, + availableContainers: filteredContainerDatabases, + selectedContainerName: containerName(for: tab), + containerEntityName: containerEntityName, + isContainerSwitchReadOnly: isContainerSwitchReadOnly, + onContainerChanged: { name in changeContainer(for: tab, to: name) } ) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 051ed3a64..8bf74f745 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -4,9 +4,21 @@ // import Foundation +import os import TableProPluginKit extension MainContentCoordinator { + func switchDatabaseBeforeExecution(to database: String, connectionId: UUID) async { + do { + try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId) + await MainActor.run { toolbarState.currentDatabase = database } + } catch { + Self.logger.warning( + "Pre-execute switch to \(database, privacy: .public) failed: \(error.localizedDescription, privacy: .public)" + ) + } + } + func resolveRowCap(sql: String, tabType: TabType) -> Int? { queryExecutionCoordinator.resolveRowCap(sql: sql, tabType: tabType) } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 3a020422b..18133fe3c 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1166,6 +1166,12 @@ final class MainContentCoordinator { ) } let connId = connectionId + let currentDatabase = activeDatabaseName + let targetDatabase = tab.tableContext.databaseName.isEmpty + ? currentDatabase + : tab.tableContext.databaseName + let perTabSwitchAllowed = !services.pluginManager.requiresReconnectForDatabaseSwitch(for: connection.type) + let needsDatabaseSwitch = perTabSwitchAllowed && !targetDatabase.isEmpty && targetDatabase != currentDatabase currentQueryTask = Task { [weak self] in guard let self else { return } @@ -1185,6 +1191,10 @@ final class MainContentCoordinator { } } + if needsDatabaseSwitch { + await switchDatabaseBeforeExecution(to: targetDatabase, connectionId: connId) + } + let schemaTask: Task? if needsMetadataFetch, let tableName { schemaTask = Task { try await QueryExecutor.fetchTableSchema(connectionId: connId, tableName: tableName) } From 458759768a2e5f87911fc87a9eb6e5565fe46a6d Mon Sep 17 00:00:00 2001 From: xuhengyu Date: Sat, 27 Jun 2026 11:25:58 +0800 Subject: [PATCH 02/18] feat(tabs): preserve tabs on database switch, single-click opens table in current tab and double-click in new tab --- .../MainContentCoordinator+Navigation.swift | 8 +++++--- .../DatabaseTreeOutlineCoordinator.swift | 20 +++++++++++++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index a95df3aca..2407b1f72 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -19,7 +19,8 @@ extension MainContentCoordinator { _ table: TableInfo, showStructure: Bool = false, forceNonPreview: Bool = false, - activateGridFocus: Bool = false + activateGridFocus: Bool = false, + forceNewWindowTab: Bool = false ) { openTableTab( table.name, @@ -27,7 +28,8 @@ extension MainContentCoordinator { showStructure: showStructure, isView: table.type == .view, forceNonPreview: forceNonPreview, - activateGridFocus: activateGridFocus + activateGridFocus: activateGridFocus, + forceNewWindowTab: forceNewWindowTab ) } @@ -382,7 +384,7 @@ extension MainContentCoordinator { } /// Switch to a different database (called from database switcher) - func switchDatabase(to database: String, clearTabs: Bool = true) async { + func switchDatabase(to database: String, clearTabs: Bool = false) async { if clearTabs { clearFilterState() } let previousDatabase = toolbarState.currentDatabase toolbarState.currentDatabase = database diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index 4575cf768..5ea0180a6 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -29,6 +29,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private var nodeCache: [String: DatabaseTreeNode] = [:] private var childrenCache: [String: [DatabaseTreeNode]] = [:] private var lastSelection: Set = [] + private var pendingSingleClickWork: DispatchWorkItem? private var isApplyingExpansion = false private var isSyncingSelection = false private var isReloading = false @@ -395,10 +396,10 @@ final class DatabaseTreeOutlineCoordinator: NSObject { isSyncingSelection = false } - private func open(_ ref: DatabaseTreeTableRef, activateGridFocus: Bool) { + private func open(_ ref: DatabaseTreeTableRef, activateGridFocus: Bool, forceNewWindowTab: Bool = false) { Task { @MainActor in await activate(ref) - mainCoordinator?.openTableTab(ref.table, activateGridFocus: activateGridFocus) + mainCoordinator?.openTableTab(ref.table, activateGridFocus: activateGridFocus, forceNewWindowTab: forceNewWindowTab) } } @@ -472,7 +473,9 @@ final class DatabaseTreeOutlineCoordinator: NSObject { guard let outlineView, outlineView.clickedRow >= 0, let node = outlineView.item(atRow: outlineView.clickedRow) as? DatabaseTreeNode else { return } if let ref = node.tableRef { - open(ref, activateGridFocus: true) + pendingSingleClickWork?.cancel() + pendingSingleClickWork = nil + open(ref, activateGridFocus: true, forceNewWindowTab: true) return } guard node.isExpandable else { return } @@ -526,11 +529,20 @@ extension DatabaseTreeOutlineCoordinator: NSOutlineViewDelegate { guard !isSyncingSelection, !isReloading else { return } let refs = Set(selectedRefs()) if let added = SelectionDelta.singleAddition(old: lastSelection, new: refs) { - open(added, activateGridFocus: false) + scheduleSingleClickOpen(added) } lastSelection = refs } + private func scheduleSingleClickOpen(_ ref: DatabaseTreeTableRef) { + pendingSingleClickWork?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.open(ref, activateGridFocus: false) + } + pendingSingleClickWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + NSEvent.doubleClickInterval, execute: work) + } + private func makeCell() -> DatabaseTreeCellView { let cell = DatabaseTreeCellView() cell.identifier = Self.cellIdentifier From 1bef397003344d59317733a34d7c12ec9c05bba4 Mon Sep 17 00:00:00 2001 From: xuhengyu Date: Sat, 27 Jun 2026 11:25:58 +0800 Subject: [PATCH 03/18] refactor(database-switcher): apply sidebar tree filter to all database pickers --- .../DatabaseSwitcherViewModel.swift | 15 +++++-- .../ViewModels/QuickSwitcherViewModel.swift | 2 + .../DatabaseSwitcherPopover.swift | 3 +- .../DatabaseSwitcherSheet.swift | 3 +- .../DatabaseSwitcherFilterTests.swift | 43 +++++++++++++++++-- 5 files changed, 58 insertions(+), 8 deletions(-) diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index c4f71deb8..75f97c677 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -27,11 +27,18 @@ final class DatabaseSwitcherViewModel { private let currentDatabase: String? private let databaseType: DatabaseType @ObservationIgnored private let services: AppServices + private let sidebarState: SharedSidebarState? + + private var treeVisibleDatabases: [DatabaseMetadata] { + guard switchTarget == .database, let sidebarState else { return databases } + return DatabaseTreeVisibility.visible(databases: databases, selected: sidebarState.databaseFilterSelected) + } var filteredDatabases: [DatabaseMetadata] { + let visible = treeVisibleDatabases let trimmed = searchText.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty else { return databases } - return databases + guard !trimmed.isEmpty else { return visible } + return visible .compactMap { database -> (DatabaseMetadata, Int)? in guard let match = FuzzyMatcher.match(query: trimmed, candidate: database.name) else { return nil } return (database, match.score) @@ -47,12 +54,14 @@ final class DatabaseSwitcherViewModel { connectionId: UUID, currentDatabase: String?, databaseType: DatabaseType, - services: AppServices = .live + services: AppServices = .live, + sidebarState: SharedSidebarState? = nil ) { self.connectionId = connectionId self.currentDatabase = currentDatabase self.databaseType = databaseType self.services = services + self.sidebarState = sidebarState self.switchTarget = services.pluginManager.containerSwitchTarget(for: databaseType) ?? .database } diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index ef6c0c35e..841b226df 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -126,6 +126,7 @@ internal final class QuickSwitcherViewModel { } let switchTarget = services.pluginManager.containerSwitchTarget(for: databaseType) + let databaseFilter = SharedSidebarState.forConnection(connectionId).databaseFilterSelected do { let databases = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in try await driver.fetchDatabases() @@ -134,6 +135,7 @@ internal final class QuickSwitcherViewModel { ? services.pluginManager.containerEntityName(for: databaseType) : String(localized: "Database") for db in databases { + if !databaseFilter.isEmpty && !databaseFilter.contains(db) { continue } items.append(QuickSwitcherItem( id: "db_\(db)", name: db, diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift index 8a2e6748e..e6eaacff1 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift @@ -81,7 +81,8 @@ struct DatabaseSwitcherPopover: View { wrappedValue: DatabaseSwitcherViewModel( connectionId: connectionId, currentDatabase: currentDatabase, - databaseType: databaseType + databaseType: databaseType, + sidebarState: SharedSidebarState.forConnection(connectionId) )) } diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index c08e73108..9dd71225a 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -43,7 +43,8 @@ struct DatabaseSwitcherSheet: View { wrappedValue: DatabaseSwitcherViewModel( connectionId: connectionId, currentDatabase: currentDatabase, - databaseType: databaseType + databaseType: databaseType, + sidebarState: SharedSidebarState.forConnection(connectionId) )) } diff --git a/TableProTests/ViewModels/DatabaseSwitcherFilterTests.swift b/TableProTests/ViewModels/DatabaseSwitcherFilterTests.swift index 6d77811e1..cdd2c06e5 100644 --- a/TableProTests/ViewModels/DatabaseSwitcherFilterTests.swift +++ b/TableProTests/ViewModels/DatabaseSwitcherFilterTests.swift @@ -9,13 +9,22 @@ import Testing @MainActor struct DatabaseSwitcherFilterTests { - private func makeViewModel(databaseNames: [String]) -> DatabaseSwitcherViewModel { + private func makeViewModel( + databaseNames: [String], + filter: Set = [], + systemNames: [String] = [] + ) -> DatabaseSwitcherViewModel { + let sidebarState = SharedSidebarState() + sidebarState.databaseFilterSelected = filter let vm = DatabaseSwitcherViewModel( connectionId: UUID(), currentDatabase: nil, - databaseType: .mysql + databaseType: .mysql, + sidebarState: sidebarState ) - vm.databases = databaseNames.map { DatabaseMetadata.minimal(name: $0) } + vm.databases = databaseNames.map { name in + DatabaseMetadata.minimal(name: name, isSystem: systemNames.contains(name)) + } return vm } @@ -45,4 +54,32 @@ struct DatabaseSwitcherFilterTests { vm.searchText = "zzz" #expect(vm.filteredDatabases.isEmpty) } + + @Test("Sidebar filter narrows the database list to the selected set") + func sidebarFilterNarrowsList() { + let vm = makeViewModel( + databaseNames: ["app", "analytics", "staging", "logs"], + filter: ["app", "staging"] + ) + #expect(Set(vm.filteredDatabases.map(\.name)) == ["app", "staging"]) + } + + @Test("Empty sidebar filter still hides system databases") + func emptySidebarFilterHidesSystemDatabases() { + let vm = makeViewModel( + databaseNames: ["app", "analytics", "staging"], + systemNames: ["analytics"] + ) + #expect(vm.filteredDatabases.map(\.name) == ["app", "staging"]) + } + + @Test("Sidebar filter keeps hiding system databases even when selected") + func sidebarFilterHidesSystemDatabasesWhenSelected() { + let vm = makeViewModel( + databaseNames: ["app", "mysql", "sys"], + filter: ["app", "mysql", "sys"], + systemNames: ["mysql", "sys"] + ) + #expect(vm.filteredDatabases.map(\.name) == ["app"]) + } } From d58516544f851a3e05feec96f58523963f58933c Mon Sep 17 00:00:00 2001 From: xuhengyu Date: Sat, 27 Jun 2026 11:26:31 +0800 Subject: [PATCH 04/18] perf(tabs): persist last filters off the main thread when switching table tabs --- .../Core/Storage/FilterSettingsStorage.swift | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Storage/FilterSettingsStorage.swift b/TablePro/Core/Storage/FilterSettingsStorage.swift index 032b949da..27fccde52 100644 --- a/TablePro/Core/Storage/FilterSettingsStorage.swift +++ b/TablePro/Core/Storage/FilterSettingsStorage.swift @@ -195,17 +195,27 @@ final class FilterSettingsStorage { let fileURL = fileURL(forKey: key) guard !filters.isEmpty else { - removeFile(at: fileURL, label: tableName) lastFiltersCache.removeValue(forKey: key) + Task.detached(priority: .utility) { + if FileManager.default.fileExists(atPath: fileURL.path) { + try? FileManager.default.removeItem(at: fileURL) + } + } return } + lastFiltersCache[key] = filters do { let data = try encoder.encode(filters) - try data.write(to: fileURL, options: .atomic) - lastFiltersCache[key] = filters + Task.detached(priority: .utility) { + do { + try data.write(to: fileURL, options: .atomic) + } catch { + Self.logger.error("Failed to persist last filters for \(tableName): \(error.localizedDescription)") + } + } } catch { - Self.logger.error("Failed to save last filters for \(tableName): \(error)") + Self.logger.error("Failed to encode last filters for \(tableName): \(error)") } } From ce4308b7d460f19f45522c52b3f8b0ae4a3f3228 Mon Sep 17 00:00:00 2001 From: xuhengyu Date: Sat, 27 Jun 2026 11:26:31 +0800 Subject: [PATCH 05/18] build: add scripts/run-local.sh to patch and launch local debug builds --- scripts/run-local.sh | 50 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100755 scripts/run-local.sh diff --git a/scripts/run-local.sh b/scripts/run-local.sh new file mode 100755 index 000000000..b40e12638 --- /dev/null +++ b/scripts/run-local.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Launch the locally built Debug app for manual testing. +# +# A command-line `xcodebuild build` with CODE_SIGNING_ALLOWED=NO leaves the +# bundle unsigned and without OpenSSL dylibs embedded, so the app dies on launch +# (TablePro.debug.dylib has no signature) and the MySQL plugin fails to load +# (libssl.3/libcrypto.3 missing from rpath). This script patches both so a CLI +# build runs locally: +# 1. copy libssl.3/libcrypto.3 into Contents/Frameworks +# 2. ad-hoc sign the whole bundle +# 3. (re)launch +# +# Usage: scripts/run-local.sh +# Build the project first (xcodebuild build); this only patches the product. +# A clean build fixes the plugin rpath outright, so step 1 is a fallback. + +CONFIGURATION="${CONFIGURATION:-Debug}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(dirname "$SCRIPT_DIR")" + +APP_PATH="${APP_PATH:-$(ls -dt "$HOME/Library/Developer/Xcode/DerivedData"/TablePro*/Build/Products/"$CONFIGURATION"/TablePro.app 2>/dev/null | head -1)}" + +if [[ -z "${APP_PATH:-}" || ! -d "$APP_PATH" ]]; then + echo "error: TablePro.app ($CONFIGURATION) not found. Build it first." >&2 + exit 1 +fi + +echo "App: $APP_PATH" + +mkdir -p "$APP_PATH/Contents/Frameworks" +for dylib in libssl.3.dylib libcrypto.3.dylib; do + src="$REPO_ROOT/Libs/dylibs/$dylib" + dest="$APP_PATH/Contents/Frameworks/$dylib" + if [[ -f "$src" ]]; then + cp "$src" "$dest" + echo "copied $dylib" + else + echo "warning: $src missing, skipped" >&2 + fi +done + +codesign --force --deep --sign - "$APP_PATH" +echo "ad-hoc signed" + +pkill -f "$APP_PATH/Contents/MacOS/TablePro" 2>/dev/null || true +sleep 1 +open "$APP_PATH" +echo "launched" From 80d8916877eaed9c1d55a62f0539f33050bdf3c8 Mon Sep 17 00:00:00 2001 From: xuhengyu Date: Sat, 27 Jun 2026 11:26:31 +0800 Subject: [PATCH 06/18] docs(changelog): record per-tab picker, tab behavior, and filter perf changes --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a7717d29..8feb9ad5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Per-tab database picker in the query editor toolbar. Each SQL tab can target its own database without clearing other tabs. +- Single-clicking a table in the sidebar tree opens it in the current tab; double-clicking opens it in a new tab. + +### Changed + +- Switching the active database keeps existing tabs open instead of closing them, so tabs for different databases can stay open together. +- Database pickers in the toolbar switcher, quick switcher, and query editor now follow the sidebar database filter and only list enabled databases. +- The per-tab database picker is read-only for connections that must reconnect to switch databases (PostgreSQL, Redshift, CockroachDB). +- Switching table tabs no longer blocks on writing filter settings to disk, so tab changes feel smoother. + ### Fixed - SSH tunnels no longer pin a CPU core after the connection drops. A dropped tunnel is now detected and torn down instead of spinning in its relay loop. (#1769) From da961f81e9e45045648a2c55aeeaac6c728309a3 Mon Sep 17 00:00:00 2001 From: xuhengyu Date: Sat, 27 Jun 2026 11:30:50 +0800 Subject: [PATCH 07/18] perf(tabs): refresh only the active database's table list when switching databases --- CHANGELOG.md | 1 + .../Services/Query/DatabaseTreeMetadataService.swift | 6 ++++-- .../Extensions/MainContentCoordinator+Navigation.swift | 2 +- TablePro/Views/Main/MainContentCoordinator.swift | 9 +++++---- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8feb9ad5e..162073c56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Database pickers in the toolbar switcher, quick switcher, and query editor now follow the sidebar database filter and only list enabled databases. - The per-tab database picker is read-only for connections that must reconnect to switch databases (PostgreSQL, Redshift, CockroachDB). - Switching table tabs no longer blocks on writing filter settings to disk, so tab changes feel smoother. +- Switching to a table tab in a different database refreshes only the active database's table list instead of every expanded database, so cross-database tab switches are faster. ### Fixed diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index 6806db7b3..a2c73c39e 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -208,8 +208,10 @@ final class DatabaseTreeMetadataService { _ = await (tables, routines) } - func refreshLoadedTables(connectionId: UUID) async { - let keys = tablesState.keys.filter { $0.connectionId == connectionId } + func refreshLoadedTables(connectionId: UUID, database: String? = nil) async { + let keys = tablesState.keys.filter { key in + key.connectionId == connectionId && (database == nil || key.database == database) + } await withTaskGroup(of: Void.self) { group in for key in keys { group.addTask { @MainActor in diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 2407b1f72..f895bd0f6 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -401,7 +401,7 @@ extension MainContentCoordinator { } await SchemaService.shared.invalidate(connectionId: connectionId) - await refreshTables() + await refreshTables(currentDatabaseOnly: true) } catch { toolbarState.currentDatabase = previousDatabase diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 18133fe3c..173f37891 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -582,21 +582,21 @@ final class MainContentCoordinator { _teardownScheduled.withLock { $0 = false } } - func refreshTables() async { + func refreshTables(currentDatabaseOnly: Bool = false) async { if let existing = schemaReloadTask { await existing.value return } let task = Task { [weak self] in guard let self else { return } - await self.reloadSchema() + await self.reloadSchema(currentDatabaseOnly: currentDatabaseOnly) } schemaReloadTask = task await task.value schemaReloadTask = nil } - private func reloadSchema() async { + private func reloadSchema(currentDatabaseOnly: Bool = false) async { schemaColumns.removeAll() let schemaService = services.schemaService let connectionId = connectionId @@ -615,7 +615,8 @@ final class MainContentCoordinator { } catch { Self.logger.warning("Schema refresh failed: \(error.localizedDescription, privacy: .public)") } - await DatabaseTreeMetadataService.shared.refreshLoadedTables(connectionId: connectionId) + let database = currentDatabaseOnly ? activeDatabaseName : nil + await DatabaseTreeMetadataService.shared.refreshLoadedTables(connectionId: connectionId, database: database) await reconcilePostSchemaLoad() } From 1fe379d9edb2051848b6316b4969fa69d5336066 Mon Sep 17 00:00:00 2001 From: xuhengyu Date: Sat, 27 Jun 2026 13:57:33 +0800 Subject: [PATCH 08/18] refactor(tabs): drop dead clearTabs branch from switchDatabase --- .../Views/Editor/QueryContainerPicker.swift | 2 +- .../Main/Child/MainEditorContentView.swift | 2 +- .../MainContentCoordinator+Navigation.swift | 26 +------------------ .../MainContentCoordinator+TabSwitch.swift | 2 +- .../Extensions/MainContentView+Helpers.swift | 4 +-- .../Extensions/MainContentView+Setup.swift | 2 +- 6 files changed, 6 insertions(+), 32 deletions(-) diff --git a/TablePro/Views/Editor/QueryContainerPicker.swift b/TablePro/Views/Editor/QueryContainerPicker.swift index b8f30f437..ebe761c0b 100644 --- a/TablePro/Views/Editor/QueryContainerPicker.swift +++ b/TablePro/Views/Editor/QueryContainerPicker.swift @@ -32,7 +32,7 @@ struct QueryContainerPicker: View { private var menu: some View { Menu { - ForEach(containers, id: \.name) { container in + ForEach(containers) { container in Button { if container.name != selectedName { onChange(container.name) } } label: { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index c29fb7d9f..daed37887 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -292,7 +292,7 @@ struct MainEditorContentView: View { private func changeContainer(for tab: QueryTab, to name: String) { let tabId = tab.id tabManager.mutate(tabId: tabId) { $0.tableContext.databaseName = name } - Task { await coordinator.switchDatabase(to: name, clearTabs: false) } + Task { await coordinator.switchDatabase(to: name) } } // MARK: - Query Tab Content diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index f895bd0f6..fa0a3eee2 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -367,38 +367,14 @@ extension MainContentCoordinator { // MARK: - Database Switching - /// Close all sibling native window-tabs except the current key window. - /// Each table opened via WindowOpener creates a separate NSWindow in the same - /// tab group. Clearing `tabManager.tabs` only affects the in-app state of the - /// *current* window — other NSWindows remain open with stale content. - private func closeSiblingNativeWindows() { - guard let keyWindow = NSApp.keyWindow else { return } - let siblings = keyWindow.tabbedWindows ?? [] - let ownWindows = Set(WindowLifecycleMonitor.shared.windows(for: connectionId).map { ObjectIdentifier($0) }) - for sibling in siblings where sibling !== keyWindow { - // Only close windows belonging to this connection to avoid - // destroying tabs from other connections when groupAllConnectionTabs is ON - guard ownWindows.contains(ObjectIdentifier(sibling)) else { continue } - sibling.close() - } - } - /// Switch to a different database (called from database switcher) - func switchDatabase(to database: String, clearTabs: Bool = false) async { - if clearTabs { clearFilterState() } + func switchDatabase(to database: String) async { let previousDatabase = toolbarState.currentDatabase toolbarState.currentDatabase = database do { try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId) - 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(currentDatabaseOnly: true) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 0c8612f94..7414239a4 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -102,7 +102,7 @@ extension MainContentCoordinator { ) changeManager.reloadVersion += 1 Task { - await switchDatabase(to: newTab.tableContext.databaseName, clearTabs: false) + await switchDatabase(to: newTab.tableContext.databaseName) lazyLoadCurrentTabIfNeeded() } return diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index efbe40ef3..3b26b1729 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -33,9 +33,7 @@ extension MainContentView { selectedTab.tableContext.databaseName != session.activeDatabase { Task { - await coordinator.switchDatabase( - to: selectedTab.tableContext.databaseName, clearTabs: false - ) + await coordinator.switchDatabase(to: selectedTab.tableContext.databaseName) coordinator.lazyLoadCurrentTabIfNeeded() } } else if let selectedTab = tabManager.selectedTab, diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index d8719b1a8..98627f68f 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -189,7 +189,7 @@ extension MainContentView { Task { if let targetDatabase, targetDatabase != session.activeDatabase { - await coordinator.switchDatabase(to: targetDatabase, clearTabs: false) + await coordinator.switchDatabase(to: targetDatabase) } if let activeSchema, !activeSchema.isEmpty, activeSchema != session.currentSchema { await coordinator.switchSchema(to: activeSchema) From 6785bb50768b1763fec9233dfc82fb195b5187d4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 27 Jun 2026 19:02:01 +0700 Subject: [PATCH 09/18] fix(datagrid): serialize filter-settings disk writes to fix ordering race --- TablePro/Core/Storage/FilterSettingsStorage.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/TablePro/Core/Storage/FilterSettingsStorage.swift b/TablePro/Core/Storage/FilterSettingsStorage.swift index 27fccde52..aab358cfa 100644 --- a/TablePro/Core/Storage/FilterSettingsStorage.swift +++ b/TablePro/Core/Storage/FilterSettingsStorage.swift @@ -90,6 +90,7 @@ final class FilterSettingsStorage { private let filterStateDirectory: URL private let encoder = JSONEncoder() private let decoder = JSONDecoder() + private let ioQueue = DispatchQueue(label: "com.TablePro.FilterSettingsStorage.io", qos: .utility) private var cachedSettings: FilterSettings? private var lastFiltersCache: [String: [TableFilter]] = [:] @@ -196,10 +197,8 @@ final class FilterSettingsStorage { guard !filters.isEmpty else { lastFiltersCache.removeValue(forKey: key) - Task.detached(priority: .utility) { - if FileManager.default.fileExists(atPath: fileURL.path) { - try? FileManager.default.removeItem(at: fileURL) - } + ioQueue.async { + try? FileManager.default.removeItem(at: fileURL) } return } @@ -207,7 +206,7 @@ final class FilterSettingsStorage { lastFiltersCache[key] = filters do { let data = try encoder.encode(filters) - Task.detached(priority: .utility) { + ioQueue.async { do { try data.write(to: fileURL, options: .atomic) } catch { From c88380bfeae9c4ce00e7f4221d6cee8a3e396b21 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 27 Jun 2026 19:02:02 +0700 Subject: [PATCH 10/18] fix(connections): hide system databases in the quick switcher --- TablePro/ViewModels/QuickSwitcherViewModel.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 841b226df..5bb3636f0 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -127,6 +127,12 @@ internal final class QuickSwitcherViewModel { let switchTarget = services.pluginManager.containerSwitchTarget(for: databaseType) let databaseFilter = SharedSidebarState.forConnection(connectionId).databaseFilterSelected + let visibleDatabaseNames = Set( + DatabaseTreeVisibility.visible( + databases: DatabaseTreeMetadataService.shared.databases(for: connectionId), + selected: databaseFilter + ).map(\.name) + ) do { let databases = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in try await driver.fetchDatabases() @@ -135,7 +141,7 @@ internal final class QuickSwitcherViewModel { ? services.pluginManager.containerEntityName(for: databaseType) : String(localized: "Database") for db in databases { - if !databaseFilter.isEmpty && !databaseFilter.contains(db) { continue } + if !visibleDatabaseNames.isEmpty, !visibleDatabaseNames.contains(db) { continue } items.append(QuickSwitcherItem( id: "db_\(db)", name: db, From 7e8b11fc3dbaa8a331856f30bec0ad3f18468e1b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 27 Jun 2026 19:02:02 +0700 Subject: [PATCH 11/18] fix(sidebar): keep keyboard tree navigation instant, debounce only mouse --- .../DatabaseTreeOutlineCoordinator.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index 5ea0180a6..d124cc605 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -529,11 +529,26 @@ extension DatabaseTreeOutlineCoordinator: NSOutlineViewDelegate { guard !isSyncingSelection, !isReloading else { return } let refs = Set(selectedRefs()) if let added = SelectionDelta.singleAddition(old: lastSelection, new: refs) { - scheduleSingleClickOpen(added) + if isKeyboardDrivenSelection { + pendingSingleClickWork?.cancel() + pendingSingleClickWork = nil + open(added, activateGridFocus: false) + } else { + scheduleSingleClickOpen(added) + } } lastSelection = refs } + private var isKeyboardDrivenSelection: Bool { + switch NSApp.currentEvent?.type { + case .keyDown, .keyUp: + return true + default: + return false + } + } + private func scheduleSingleClickOpen(_ ref: DatabaseTreeTableRef) { pendingSingleClickWork?.cancel() let work = DispatchWorkItem { [weak self] in From a99732d1179dd93ce7fc5f5eb10a9d41d0a89f3d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 27 Jun 2026 20:17:07 +0700 Subject: [PATCH 12/18] fix(datagrid): serialize all filter-file deletes through the io queue --- .../Core/Storage/FilterSettingsStorage.swift | 52 ++++++++++++------- .../Storage/FilterSettingsStorageTests.swift | 29 +++++++++++ 2 files changed, 62 insertions(+), 19 deletions(-) diff --git a/TablePro/Core/Storage/FilterSettingsStorage.swift b/TablePro/Core/Storage/FilterSettingsStorage.swift index aab358cfa..f252b2eb9 100644 --- a/TablePro/Core/Storage/FilterSettingsStorage.swift +++ b/TablePro/Core/Storage/FilterSettingsStorage.swift @@ -231,8 +231,14 @@ final class FilterSettingsStorage { schemaName: schemaName ) let fileURL = fileURL(forKey: key) - removeFile(at: fileURL, label: tableName) lastFiltersCache.removeValue(forKey: key) + ioQueue.async { + try? FileManager.default.removeItem(at: fileURL) + } + } + + func waitForPendingDiskWrites() { + ioQueue.sync {} } func loadBrowseSearch( @@ -328,31 +334,39 @@ final class FilterSettingsStorage { encodedPrefixes.contains { name.hasPrefix($0) } } - let fm = FileManager.default - do { - let files = try fm.contentsOfDirectory(at: filterStateDirectory, includingPropertiesForKeys: nil) - for file in files where matchesConnection(file.lastPathComponent) { - try? fm.removeItem(at: file) - } - } catch { - Self.logger.error("Failed to enumerate filter state directory: \(error.localizedDescription)") - } lastFiltersCache = lastFiltersCache.filter { !matchesConnection($0.key) } browseSearchCache = browseSearchCache.filter { !matchesConnection($0.key) } - } - func clearAllLastFilters() { - let fm = FileManager.default - do { - let files = try fm.contentsOfDirectory(at: filterStateDirectory, includingPropertiesForKeys: nil) - for file in files where file.pathExtension == "json" { - try? fm.removeItem(at: file) + let directory = filterStateDirectory + ioQueue.async { + let fm = FileManager.default + do { + let files = try fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + for file in files where encodedPrefixes.contains(where: { file.lastPathComponent.hasPrefix($0) }) { + try? fm.removeItem(at: file) + } + } catch { + Self.logger.error("Failed to enumerate filter state directory: \(error.localizedDescription)") } - } catch { - Self.logger.error("Failed to enumerate filter state directory: \(error.localizedDescription)") } + } + + func clearAllLastFilters() { lastFiltersCache.removeAll() browseSearchCache.removeAll() + + let directory = filterStateDirectory + ioQueue.async { + let fm = FileManager.default + do { + let files = try fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + for file in files where file.pathExtension == "json" { + try? fm.removeItem(at: file) + } + } catch { + Self.logger.error("Failed to enumerate filter state directory: \(error.localizedDescription)") + } + } } private func fileURL(forKey key: String) -> URL { diff --git a/TableProTests/Core/Storage/FilterSettingsStorageTests.swift b/TableProTests/Core/Storage/FilterSettingsStorageTests.swift index 621270320..76c5e93f0 100644 --- a/TableProTests/Core/Storage/FilterSettingsStorageTests.swift +++ b/TableProTests/Core/Storage/FilterSettingsStorageTests.swift @@ -109,6 +109,7 @@ struct FilterSettingsStorageTests { ) storage.removeFilters(for: deletedConnection) + storage.waitForPendingDiskWrites() #expect( storage.loadLastFilters(for: "users", connectionId: deletedConnection, databaseName: "db", schemaName: nil) @@ -135,6 +136,7 @@ struct FilterSettingsStorageTests { } storage.removeFilters(for: [first, second]) + storage.waitForPendingDiskWrites() #expect(storage.loadLastFilters(for: "users", connectionId: first, databaseName: "db", schemaName: nil).isEmpty) #expect(storage.loadLastFilters(for: "users", connectionId: second, databaseName: "db", schemaName: nil).isEmpty) @@ -160,6 +162,7 @@ struct FilterSettingsStorageTests { ) storage.removeFilters(for: connectionId) + storage.waitForPendingDiskWrites() let fresh = FilterSettingsStorage(filterStateDirectory: directory, defaults: defaults) #expect( @@ -176,6 +179,7 @@ struct FilterSettingsStorageTests { [TestFixtures.makeTableFilter()], for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil ) storage.saveLastFilters([], for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil) + storage.waitForPendingDiskWrites() #expect( storage.loadLastFilters(for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil).isEmpty @@ -219,6 +223,7 @@ struct FilterSettingsStorageTests { let writer = FilterSettingsStorage(filterStateDirectory: directory, defaults: defaults) writer.saveLastFilters(filters, for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil) + writer.waitForPendingDiskWrites() let reader = FilterSettingsStorage(filterStateDirectory: directory, defaults: defaults) #expect( @@ -235,9 +240,33 @@ struct FilterSettingsStorageTests { storage.saveLastFilters(filters, for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil) storage.clearLastFilters(for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil) + storage.waitForPendingDiskWrites() #expect( storage.loadLastFilters(for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil).isEmpty ) } + + @Test("A save followed by an immediate clear leaves no file on disk") + func clearAfterSaveLeavesNothingOnDisk() { + let suiteName = "FilterSettingsStorageTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create UserDefaults suite for tests") + } + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("FilterSettingsStorageTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let connectionId = UUID() + let filters = [TestFixtures.makeTableFilter(column: "email", value: "a@b.com")] + + let writer = FilterSettingsStorage(filterStateDirectory: directory, defaults: defaults) + writer.saveLastFilters(filters, for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil) + writer.clearLastFilters(for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil) + writer.waitForPendingDiskWrites() + + let reader = FilterSettingsStorage(filterStateDirectory: directory, defaults: defaults) + #expect( + reader.loadLastFilters(for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil).isEmpty + ) + } } From 483d15c9febae4bdc448d60ce49b86167efeaa54 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 27 Jun 2026 21:01:21 +0700 Subject: [PATCH 13/18] fix(editor): make per-tab database switches transient and keep the bound database visible --- .../Database/DatabaseManager+Sessions.swift | 6 ++++-- .../Views/Editor/QueryContainerPicker.swift | 14 +++++++++++++ .../Main/Child/MainEditorContentView.swift | 21 +++++++++++++++---- .../MainContentCoordinator+Navigation.swift | 11 +++++++--- .../MainContentCoordinator+QueryHelpers.swift | 6 +++++- 5 files changed, 48 insertions(+), 10 deletions(-) diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index c320205f8..42dafdd6a 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -255,7 +255,7 @@ extension DatabaseManager { // MARK: - Database / Schema Switching - func switchDatabase(to database: String, for connectionId: UUID) async throws { + func switchDatabase(to database: String, for connectionId: UUID, persist: Bool = true) async throws { guard let driver = driver(for: connectionId) else { throw DatabaseError.notConnected } @@ -285,7 +285,9 @@ extension DatabaseManager { } } - appSettingsStorage.saveLastDatabase(database, for: connectionId) + if persist { + appSettingsStorage.saveLastDatabase(database, for: connectionId) + } } func switchSchema(to schema: String, for connectionId: UUID) async throws { diff --git a/TablePro/Views/Editor/QueryContainerPicker.swift b/TablePro/Views/Editor/QueryContainerPicker.swift index ebe761c0b..312429bda 100644 --- a/TablePro/Views/Editor/QueryContainerPicker.swift +++ b/TablePro/Views/Editor/QueryContainerPicker.swift @@ -21,6 +21,8 @@ struct QueryContainerPicker: View { readOnlyLabel } else if containers.count > 1 { menu + } else if !selectedName.isEmpty { + indicatorLabel } else { EmptyView() } @@ -70,4 +72,16 @@ struct QueryContainerPicker: View { .foregroundStyle(.secondary) .help(String(format: String(localized: "%@ switches reconnect the session"), entityName)) } + + private var indicatorLabel: some View { + HStack(spacing: 4) { + Image(systemName: selectedIcon) + .font(.body) + Text(selectedName) + .font(.callout) + .lineLimit(1) + } + .foregroundStyle(.secondary) + .accessibilityLabel(entityName) + } } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index daed37887..196570300 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -269,10 +269,17 @@ struct MainEditorContentView: View { PluginManager.shared.containerSwitchTarget(for: connection.type) } - private var filteredContainerDatabases: [DatabaseMetadata] { + private func containerDatabases(for tab: QueryTab) -> [DatabaseMetadata] { guard containerSwitchTarget == .database else { return [] } + let all = treeService.databases(for: connectionId) let selected = SharedSidebarState.forConnection(connectionId).databaseFilterSelected - return DatabaseTreeVisibility.visible(databases: treeService.databases(for: connectionId), selected: selected) + var visible = DatabaseTreeVisibility.visible(databases: all, selected: selected) + let bound = containerName(for: tab) + if !bound.isEmpty, !visible.contains(where: { $0.name == bound }), + let boundDatabase = all.first(where: { $0.name == bound }) { + visible.insert(boundDatabase, at: 0) + } + return visible } private var isContainerSwitchReadOnly: Bool { @@ -291,8 +298,14 @@ struct MainEditorContentView: View { private func changeContainer(for tab: QueryTab, to name: String) { let tabId = tab.id + let previousBinding = tab.tableContext.databaseName tabManager.mutate(tabId: tabId) { $0.tableContext.databaseName = name } - Task { await coordinator.switchDatabase(to: name) } + Task { + let switched = await coordinator.switchDatabase(to: name, persist: false) + if !switched { + tabManager.mutate(tabId: tabId) { $0.tableContext.databaseName = previousBinding } + } + } } // MARK: - Query Tab Content @@ -354,7 +367,7 @@ struct MainEditorContentView: View { coordinator.favoriteDialogQuery = FavoriteDialogQuery(query: text) }, onClearResults: { coordinator.clearActiveQueryResults() }, - availableContainers: filteredContainerDatabases, + availableContainers: containerDatabases(for: tab), selectedContainerName: containerName(for: tab), containerEntityName: containerEntityName, isContainerSwitchReadOnly: isContainerSwitchReadOnly, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index fa0a3eee2..0fc95bfd1 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -367,17 +367,21 @@ extension MainContentCoordinator { // MARK: - Database Switching - /// Switch to a different database (called from database switcher) - func switchDatabase(to database: String) async { + /// Switch to a different database (called from database switcher). + /// `persist` records the database as the connection's saved default; pass `false` + /// for transient per-tab switches that must not change the connection default. + @discardableResult + func switchDatabase(to database: String, persist: Bool = true) async -> Bool { let previousDatabase = toolbarState.currentDatabase toolbarState.currentDatabase = database do { - try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId) + try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId, persist: persist) await SchemaService.shared.invalidate(connectionId: connectionId) await refreshTables(currentDatabaseOnly: true) + return true } catch { toolbarState.currentDatabase = previousDatabase @@ -390,6 +394,7 @@ extension MainContentCoordinator { message: error.localizedDescription, window: contentWindow ) + return false } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 8bf74f745..ad1120f66 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -10,8 +10,12 @@ import TableProPluginKit extension MainContentCoordinator { func switchDatabaseBeforeExecution(to database: String, connectionId: UUID) async { do { - try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId) + try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId, persist: false) await MainActor.run { toolbarState.currentDatabase = database } + Task { [weak self] in + await SchemaService.shared.invalidate(connectionId: connectionId) + await self?.refreshTables(currentDatabaseOnly: true) + } } catch { Self.logger.warning( "Pre-execute switch to \(database, privacy: .public) failed: \(error.localizedDescription, privacy: .public)" From 0e2b7ad92c145f1024d03a901c303f49b1eadd34 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 27 Jun 2026 21:01:22 +0700 Subject: [PATCH 14/18] fix(connections): guard quick switcher database filter by container switch target --- .../ViewModels/QuickSwitcherViewModel.swift | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 5bb3636f0..775d72503 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -127,12 +127,14 @@ internal final class QuickSwitcherViewModel { let switchTarget = services.pluginManager.containerSwitchTarget(for: databaseType) let databaseFilter = SharedSidebarState.forConnection(connectionId).databaseFilterSelected - let visibleDatabaseNames = Set( - DatabaseTreeVisibility.visible( - databases: DatabaseTreeMetadataService.shared.databases(for: connectionId), - selected: databaseFilter - ).map(\.name) - ) + let visibleDatabaseNames = switchTarget == .database + ? Set( + DatabaseTreeVisibility.visible( + databases: DatabaseTreeMetadataService.shared.databases(for: connectionId), + selected: databaseFilter + ).map(\.name) + ) + : [] do { let databases = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in try await driver.fetchDatabases() @@ -141,7 +143,13 @@ internal final class QuickSwitcherViewModel { ? services.pluginManager.containerEntityName(for: databaseType) : String(localized: "Database") for db in databases { - if !visibleDatabaseNames.isEmpty, !visibleDatabaseNames.contains(db) { continue } + if switchTarget == .database { + if !visibleDatabaseNames.isEmpty { + if !visibleDatabaseNames.contains(db) { continue } + } else if !databaseFilter.isEmpty, !databaseFilter.contains(db) { + continue + } + } items.append(QuickSwitcherItem( id: "db_\(db)", name: db, From aa841abfdd2e40341dbdd9b201d0751615c4af5d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 27 Jun 2026 21:01:22 +0700 Subject: [PATCH 15/18] fix(datagrid): serialize browse-search disk writes through the io queue --- .../Core/Storage/FilterSettingsStorage.swift | 23 +++++++------ .../Storage/FilterSettingsStorageTests.swift | 33 +++++++++++++++++++ 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/TablePro/Core/Storage/FilterSettingsStorage.swift b/TablePro/Core/Storage/FilterSettingsStorage.swift index f252b2eb9..03b242440 100644 --- a/TablePro/Core/Storage/FilterSettingsStorage.swift +++ b/TablePro/Core/Storage/FilterSettingsStorage.swift @@ -291,17 +291,25 @@ final class FilterSettingsStorage { let fileURL = fileURL(forKey: key) guard state.isActive else { - removeFile(at: fileURL, label: tableName) browseSearchCache.removeValue(forKey: key) + ioQueue.async { + try? FileManager.default.removeItem(at: fileURL) + } return } do { let data = try encoder.encode(state) - try data.write(to: fileURL, options: .atomic) browseSearchCache[key] = state + ioQueue.async { + do { + try data.write(to: fileURL, options: .atomic) + } catch { + Self.logger.error("Failed to persist browse search for \(tableName): \(error.localizedDescription)") + } + } } catch { - Self.logger.error("Failed to save browse search for \(tableName): \(error)") + Self.logger.error("Failed to encode browse search for \(tableName): \(error)") } } @@ -373,15 +381,6 @@ final class FilterSettingsStorage { filterStateDirectory.appendingPathComponent("\(key).json") } - private func removeFile(at fileURL: URL, label: String) { - guard FileManager.default.fileExists(atPath: fileURL.path) else { return } - do { - try FileManager.default.removeItem(at: fileURL) - } catch { - Self.logger.error("Failed to remove last filters file for \(label): \(error.localizedDescription)") - } - } - private func compositeKey( tableName: String, connectionId: UUID, diff --git a/TableProTests/Core/Storage/FilterSettingsStorageTests.swift b/TableProTests/Core/Storage/FilterSettingsStorageTests.swift index 76c5e93f0..8f6412b44 100644 --- a/TableProTests/Core/Storage/FilterSettingsStorageTests.swift +++ b/TableProTests/Core/Storage/FilterSettingsStorageTests.swift @@ -269,4 +269,37 @@ struct FilterSettingsStorageTests { reader.loadLastFilters(for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil).isEmpty ) } + + @Test("Browse search persists to disk and clearing it leaves nothing") + func browseSearchPersistsAndClears() { + let suiteName = "FilterSettingsStorageTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create UserDefaults suite for tests") + } + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("FilterSettingsStorageTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let connectionId = UUID() + let state = BrowseSearchState(pattern: "user:*", typeScope: "hash") + + let writer = FilterSettingsStorage(filterStateDirectory: directory, defaults: defaults) + writer.saveBrowseSearch(state, for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil) + writer.waitForPendingDiskWrites() + + let reader = FilterSettingsStorage(filterStateDirectory: directory, defaults: defaults) + #expect( + reader.loadBrowseSearch(for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil) == state + ) + + writer.saveBrowseSearch( + BrowseSearchState(), for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil + ) + writer.waitForPendingDiskWrites() + + let afterClear = FilterSettingsStorage(filterStateDirectory: directory, defaults: defaults) + #expect( + !afterClear.loadBrowseSearch(for: "users", connectionId: connectionId, databaseName: "db", schemaName: nil) + .isActive + ) + } } From 45e0d6a6009aacba79818d95f9f74bd75d9328d9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 27 Jun 2026 21:01:23 +0700 Subject: [PATCH 16/18] fix(sidebar): require tree focus before treating a selection as keyboard-driven --- TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index d124cc605..7c16a4df9 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -541,6 +541,7 @@ extension DatabaseTreeOutlineCoordinator: NSOutlineViewDelegate { } private var isKeyboardDrivenSelection: Bool { + guard let outlineView, outlineView.window?.firstResponder === outlineView else { return false } switch NSApp.currentEvent?.type { case .keyDown, .keyUp: return true From 88f245599e810826c2bb43de1163b84cbb11540d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 27 Jun 2026 21:03:32 +0700 Subject: [PATCH 17/18] chore: drop scripts/run-local.sh from the per-tab picker branch --- scripts/run-local.sh | 50 -------------------------------------------- 1 file changed, 50 deletions(-) delete mode 100755 scripts/run-local.sh diff --git a/scripts/run-local.sh b/scripts/run-local.sh deleted file mode 100755 index b40e12638..000000000 --- a/scripts/run-local.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Launch the locally built Debug app for manual testing. -# -# A command-line `xcodebuild build` with CODE_SIGNING_ALLOWED=NO leaves the -# bundle unsigned and without OpenSSL dylibs embedded, so the app dies on launch -# (TablePro.debug.dylib has no signature) and the MySQL plugin fails to load -# (libssl.3/libcrypto.3 missing from rpath). This script patches both so a CLI -# build runs locally: -# 1. copy libssl.3/libcrypto.3 into Contents/Frameworks -# 2. ad-hoc sign the whole bundle -# 3. (re)launch -# -# Usage: scripts/run-local.sh -# Build the project first (xcodebuild build); this only patches the product. -# A clean build fixes the plugin rpath outright, so step 1 is a fallback. - -CONFIGURATION="${CONFIGURATION:-Debug}" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(dirname "$SCRIPT_DIR")" - -APP_PATH="${APP_PATH:-$(ls -dt "$HOME/Library/Developer/Xcode/DerivedData"/TablePro*/Build/Products/"$CONFIGURATION"/TablePro.app 2>/dev/null | head -1)}" - -if [[ -z "${APP_PATH:-}" || ! -d "$APP_PATH" ]]; then - echo "error: TablePro.app ($CONFIGURATION) not found. Build it first." >&2 - exit 1 -fi - -echo "App: $APP_PATH" - -mkdir -p "$APP_PATH/Contents/Frameworks" -for dylib in libssl.3.dylib libcrypto.3.dylib; do - src="$REPO_ROOT/Libs/dylibs/$dylib" - dest="$APP_PATH/Contents/Frameworks/$dylib" - if [[ -f "$src" ]]; then - cp "$src" "$dest" - echo "copied $dylib" - else - echo "warning: $src missing, skipped" >&2 - fi -done - -codesign --force --deep --sign - "$APP_PATH" -echo "ad-hoc signed" - -pkill -f "$APP_PATH/Contents/MacOS/TablePro" 2>/dev/null || true -sleep 1 -open "$APP_PATH" -echo "launched" From cdf303c13307c5399d9ec84907da088f24f74171 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 27 Jun 2026 21:04:36 +0700 Subject: [PATCH 18/18] docs(changelog): tighten per-tab picker entries --- CHANGELOG.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 162073c56..1d871bd8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Switching the active database keeps existing tabs open instead of closing them, so tabs for different databases can stay open together. -- Database pickers in the toolbar switcher, quick switcher, and query editor now follow the sidebar database filter and only list enabled databases. -- The per-tab database picker is read-only for connections that must reconnect to switch databases (PostgreSQL, Redshift, CockroachDB). -- Switching table tabs no longer blocks on writing filter settings to disk, so tab changes feel smoother. -- Switching to a table tab in a different database refreshes only the active database's table list instead of every expanded database, so cross-database tab switches are faster. +- Switching the active database keeps existing tabs open instead of closing them. +- The toolbar, quick switcher, and query editor database pickers follow the sidebar database filter. +- Switching table tabs is faster: filter settings persist off the main thread, and only the active database's table list refreshes. ### Fixed