diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a7717d29..1d871bd8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) 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/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/Core/Storage/FilterSettingsStorage.swift b/TablePro/Core/Storage/FilterSettingsStorage.swift index 032b949da..03b242440 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]] = [:] @@ -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)") } } @@ -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( @@ -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)") } } @@ -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, 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..775d72503 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -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() @@ -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, 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/TablePro/Views/Editor/QueryContainerPicker.swift b/TablePro/Views/Editor/QueryContainerPicker.swift new file mode 100644 index 000000000..312429bda --- /dev/null +++ b/TablePro/Views/Editor/QueryContainerPicker.swift @@ -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) + } +} 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..196570300 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,51 @@ struct MainEditorContentView: View { .id(tab.id) } + // MARK: - Per-tab container picker + + private var containerSwitchTarget: ContainerSwitchTarget? { + PluginManager.shared.containerSwitchTarget(for: connection.type) + } + + private func containerDatabases(for tab: QueryTab) -> [DatabaseMetadata] { + guard containerSwitchTarget == .database else { return [] } + let all = treeService.databases(for: connectionId) + let selected = SharedSidebarState.forConnection(connectionId).databaseFilterSelected + 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 { + 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 + let previousBinding = tab.tableContext.databaseName + tabManager.mutate(tabId: tabId) { $0.tableContext.databaseName = 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 @ViewBuilder @@ -319,7 +366,12 @@ struct MainEditorContentView: View { guard !text.isEmpty else { return } coordinator.favoriteDialogQuery = FavoriteDialogQuery(query: text) }, - onClearResults: { coordinator.clearActiveQueryResults() } + onClearResults: { coordinator.clearActiveQueryResults() }, + availableContainers: containerDatabases(for: tab), + 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+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index a95df3aca..0fc95bfd1 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 ) } @@ -365,41 +367,21 @@ 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 = true) async { - if clearTabs { clearFilterState() } + /// 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) - - if clearTabs { - closeSiblingNativeWindows() - persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) - tabSessionRegistry.removeAll() - tabManager.tabs = [] - tabManager.selectedTabId = nil - } + try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId, persist: persist) + await SchemaService.shared.invalidate(connectionId: connectionId) - await refreshTables() + await refreshTables(currentDatabaseOnly: true) + return true } catch { toolbarState.currentDatabase = previousDatabase @@ -412,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 051ed3a64..ad1120f66 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -4,9 +4,25 @@ // 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, 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)" + ) + } + } + func resolveRowCap(sql: String, tabType: TabType) -> Int? { queryExecutionCoordinator.resolveRowCap(sql: sql, tabType: tabType) } 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) diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 3a020422b..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() } @@ -1166,6 +1167,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 +1192,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) } diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index 4575cf768..7c16a4df9 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,36 @@ 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) + if isKeyboardDrivenSelection { + pendingSingleClickWork?.cancel() + pendingSingleClickWork = nil + open(added, activateGridFocus: false) + } else { + scheduleSingleClickOpen(added) + } } lastSelection = refs } + private var isKeyboardDrivenSelection: Bool { + guard let outlineView, outlineView.window?.firstResponder === outlineView else { return false } + 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 + 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 diff --git a/TableProTests/Core/Storage/FilterSettingsStorageTests.swift b/TableProTests/Core/Storage/FilterSettingsStorageTests.swift index 621270320..8f6412b44 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,66 @@ 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 + ) + } + + @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 + ) + } } 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"]) + } }