Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d580d2d
feat(editor): add per-tab database picker to query toolbar
Jun 27, 2026
4587597
feat(tabs): preserve tabs on database switch, single-click opens tabl…
Jun 27, 2026
1bef397
refactor(database-switcher): apply sidebar tree filter to all databas…
Jun 27, 2026
d585165
perf(tabs): persist last filters off the main thread when switching t…
Jun 27, 2026
ce4308b
build: add scripts/run-local.sh to patch and launch local debug builds
Jun 27, 2026
80d8916
docs(changelog): record per-tab picker, tab behavior, and filter perf…
Jun 27, 2026
da961f8
perf(tabs): refresh only the active database's table list when switch…
Jun 27, 2026
1fe379d
refactor(tabs): drop dead clearTabs branch from switchDatabase
Jun 27, 2026
6785bb5
fix(datagrid): serialize filter-settings disk writes to fix ordering …
datlechin Jun 27, 2026
c88380b
fix(connections): hide system databases in the quick switcher
datlechin Jun 27, 2026
7e8b11f
fix(sidebar): keep keyboard tree navigation instant, debounce only mouse
datlechin Jun 27, 2026
a99732d
fix(datagrid): serialize all filter-file deletes through the io queue
datlechin Jun 27, 2026
483d15c
fix(editor): make per-tab database switches transient and keep the bo…
datlechin Jun 27, 2026
0e2b7ad
fix(connections): guard quick switcher database filter by container s…
datlechin Jun 27, 2026
aa841ab
fix(datagrid): serialize browse-search disk writes through the io queue
datlechin Jun 27, 2026
45e0d6a
fix(sidebar): require tree focus before treating a selection as keybo…
datlechin Jun 27, 2026
88f2455
chore: drop scripts/run-local.sh from the per-tab picker branch
datlechin Jun 27, 2026
cdf303c
docs(changelog): tighten per-tab picker entries
datlechin Jun 27, 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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ 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.
- 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

- 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)
Expand Down
6 changes: 4 additions & 2 deletions TablePro/Core/Database/DatabaseManager+Sessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 57 additions & 35 deletions TablePro/Core/Storage/FilterSettingsStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = [:]
Expand Down Expand Up @@ -195,17 +196,25 @@ final class FilterSettingsStorage {
let fileURL = fileURL(forKey: key)

guard !filters.isEmpty else {
removeFile(at: fileURL, label: tableName)
lastFiltersCache.removeValue(forKey: key)
ioQueue.async {
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
ioQueue.async {
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)")
}
}

Expand All @@ -222,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(
Expand Down Expand Up @@ -276,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)")
}
}

Expand Down Expand Up @@ -319,46 +342,45 @@ 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 {
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,
Expand Down
15 changes: 12 additions & 3 deletions TablePro/ViewModels/DatabaseSwitcherViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}

Expand Down
16 changes: 16 additions & 0 deletions TablePro/ViewModels/QuickSwitcherViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,15 @@ internal final class QuickSwitcherViewModel {
}

let switchTarget = services.pluginManager.containerSwitchTarget(for: databaseType)
let databaseFilter = SharedSidebarState.forConnection(connectionId).databaseFilterSelected
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()
Expand All @@ -134,6 +143,13 @@ internal final class QuickSwitcherViewModel {
? services.pluginManager.containerEntityName(for: databaseType)
: String(localized: "Database")
for db in databases {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ struct DatabaseSwitcherPopover: View {
wrappedValue: DatabaseSwitcherViewModel(
connectionId: connectionId,
currentDatabase: currentDatabase,
databaseType: databaseType
databaseType: databaseType,
sidebarState: SharedSidebarState.forConnection(connectionId)
))
}

Expand Down
3 changes: 2 additions & 1 deletion TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ struct DatabaseSwitcherSheet: View {
wrappedValue: DatabaseSwitcherViewModel(
connectionId: connectionId,
currentDatabase: currentDatabase,
databaseType: databaseType
databaseType: databaseType,
sidebarState: SharedSidebarState.forConnection(connectionId)
))
}

Expand Down
87 changes: 87 additions & 0 deletions TablePro/Views/Editor/QueryContainerPicker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//
// 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 if !selectedName.isEmpty {
indicatorLabel
} else {
EmptyView()
}
}

private var selectedIcon: String {
containers.first(where: { $0.name == selectedName })?.icon ?? "cylinder"
}

private var menu: some View {
Menu {
ForEach(containers) { 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))
}

private var indicatorLabel: some View {
HStack(spacing: 4) {
Image(systemName: selectedIcon)
.font(.body)
Text(selectedName)
.font(.callout)
.lineLimit(1)
}
.foregroundStyle(.secondary)
.accessibilityLabel(entityName)
}
}
Loading
Loading