From 8b56babed7a1a71ef058f42f4ba84e1e7709f7b2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 18 Jun 2026 15:45:28 +0700 Subject: [PATCH 1/2] fix(sidebar): keep the table filter when opening another tab --- CHANGELOG.md | 1 + .../MainSplitViewController.swift | 12 +++--- .../SidebarContainerViewController.swift | 28 +++++++------ TablePro/Models/UI/SharedSidebarState.swift | 3 ++ TablePro/Models/UI/WindowSidebarState.swift | 2 - TablePro/Views/Sidebar/FavoritesTabView.swift | 8 ++-- TablePro/Views/Sidebar/SidebarView.swift | 6 +-- .../Models/SharedSidebarStateTests.swift | 39 ++++++++++++++++++- .../ViewModels/WindowSidebarStateTests.swift | 27 ++----------- 9 files changed, 70 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d0d095ed..f6ed3f6ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The SQL formatter keeps nested indentation for UNION, UNION ALL, INTERSECT, and EXCEPT inside a derived table or CTE, and puts the closing parenthesis of a subquery on its own line instead of collapsing it onto the last SELECT. (#1698) - Toolbar button tooltips now show each action's real keyboard shortcut and follow your custom bindings, instead of a fixed value. The Switch Connection tooltip showed the wrong shortcut. (#1694) - The row detail panel no longer stays blank when a table is opened in a second tab. The panel now shows the selected row right away instead of only after the inspector is toggled. +- The sidebar filter now stays put when you open another tab. Before, opening a second table tab cleared the filter text and reset the table list to show everything. ## [0.51.1] - 2026-06-16 diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 81d59c0d8..5488328fd 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -237,10 +237,9 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi installToolbar(coordinator: sessionState.coordinator) } - if let currentSession, let coordinator = sessionState?.coordinator { + if let currentSession, sessionState?.coordinator != nil { sidebarContainer.updateSidebarState( - SharedSidebarState.forConnection(currentSession.connection.id), - windowState: coordinator.windowSidebarState + SharedSidebarState.forConnection(currentSession.connection.id) ) } @@ -311,7 +310,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi sessionState?.coordinator.teardown() sessionState = nil currentSession = nil - sidebarContainer.updateSidebarState(nil, windowState: nil) + sidebarContainer.updateSidebarState(nil) sidebarContainer.rootView = AnyView(buildSidebarView()) } return @@ -346,10 +345,9 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi private func rebuildPanes() { sidebarContainer.rootView = AnyView(buildSidebarView()) - if let currentSession, let coordinator = sessionState?.coordinator { + if let currentSession, sessionState?.coordinator != nil { sidebarContainer.updateSidebarState( - SharedSidebarState.forConnection(currentSession.connection.id), - windowState: coordinator.windowSidebarState + SharedSidebarState.forConnection(currentSession.connection.id) ) } detailHosting.rootView = AnyView(buildDetailView()) diff --git a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift index c87ab23ab..550d7220a 100644 --- a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift +++ b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift @@ -11,7 +11,6 @@ internal final class SidebarContainerViewController: NSViewController { private let searchField = NSSearchField() private var hostingController: NSHostingController private var sidebarState: SharedSidebarState? - private var windowState: WindowSidebarState? private var observationTask: Task? var rootView: AnyView { @@ -64,11 +63,10 @@ internal final class SidebarContainerViewController: NSViewController { view.window?.makeFirstResponder(searchField) } - func updateSidebarState(_ state: SharedSidebarState?, windowState: WindowSidebarState?) { + func updateSidebarState(_ state: SharedSidebarState?) { observationTask?.cancel() self.sidebarState = state - self.windowState = windowState - guard let state, let windowState else { + guard let state else { searchField.isHidden = true return } @@ -76,21 +74,21 @@ internal final class SidebarContainerViewController: NSViewController { observationTask = Task { @MainActor [weak self] in guard let self else { return } while !Task.isCancelled { - self.syncFromState(state, windowState: windowState) - await Self.awaitChange(state: state, windowState: windowState) + self.syncFromState(state) + await Self.awaitChange(state: state) } } } - private static func awaitChange(state: SharedSidebarState, windowState: WindowSidebarState) async { + private static func awaitChange(state: SharedSidebarState) async { let box = ObservationContinuationBox() await withTaskCancellationHandler { await withCheckedContinuation { continuation in box.attach(continuation) withObservationTracking { _ = state.selectedSidebarTab - _ = windowState.searchText - _ = windowState.favoritesSearchText + _ = state.searchText + _ = state.favoritesSearchText } onChange: { box.resume() } @@ -104,15 +102,15 @@ internal final class SidebarContainerViewController: NSViewController { observationTask?.cancel() } - private func syncFromState(_ state: SharedSidebarState, windowState: WindowSidebarState) { + private func syncFromState(_ state: SharedSidebarState) { let activeText: String let placeholder: String switch state.selectedSidebarTab { case .tables: - activeText = windowState.searchText + activeText = state.searchText placeholder = String(localized: "Filter") case .favorites: - activeText = windowState.favoritesSearchText + activeText = state.favoritesSearchText placeholder = String(localized: "Filter favorites") } @@ -141,12 +139,12 @@ extension SidebarContainerViewController: NSSearchFieldDelegate { } private func writeSearchText(_ text: String) { - guard let sidebarState, let windowState else { return } + guard let sidebarState else { return } switch sidebarState.selectedSidebarTab { case .tables: - windowState.searchText = text + sidebarState.searchText = text case .favorites: - windowState.favoritesSearchText = text + sidebarState.favoritesSearchText = text } } } diff --git a/TablePro/Models/UI/SharedSidebarState.swift b/TablePro/Models/UI/SharedSidebarState.swift index 235696802..2ebfc90ea 100644 --- a/TablePro/Models/UI/SharedSidebarState.swift +++ b/TablePro/Models/UI/SharedSidebarState.swift @@ -24,6 +24,9 @@ internal enum SidebarLayout: String, CaseIterable, Sendable { final class SharedSidebarState { var redisKeyTreeViewModel: RedisKeyTreeViewModel? + var searchText: String = "" + var favoritesSearchText: String = "" + var selectedSidebarTab: SidebarTab { didSet { UserDefaults.standard.set( diff --git a/TablePro/Models/UI/WindowSidebarState.swift b/TablePro/Models/UI/WindowSidebarState.swift index a7672ae89..877f97c2b 100644 --- a/TablePro/Models/UI/WindowSidebarState.swift +++ b/TablePro/Models/UI/WindowSidebarState.swift @@ -16,8 +16,6 @@ struct DatabaseSchemaKey: Hashable, Sendable { @Observable internal final class WindowSidebarState { var selectedTables: Set = [] - var searchText: String = "" - var favoritesSearchText: String = "" var expandedTreeSchemas: Set = [] var expandedTreeDatabases: Set = [] var expandedTreeDatabaseSchemas: Set = [] diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index ec6b7c44a..09e046f7e 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -12,12 +12,12 @@ internal struct FavoritesTabView: View { @State private var showRemoveLinkedFolderAlert = false @FocusState private var isRenameFocused: Bool let connectionId: UUID - let windowState: WindowSidebarState + let sharedSidebarState: SharedSidebarState let tables: [TableInfo] @Bindable private var sidebarState: ConnectionSidebarState private var coordinator: MainContentCoordinator? - private var searchText: String { windowState.favoritesSearchText } + private var searchText: String { sharedSidebarState.favoritesSearchText } private var activeDatabase: String? { let name = coordinator?.activeDatabaseName ?? "" return name.isEmpty ? nil : name @@ -39,9 +39,9 @@ internal struct FavoritesTabView: View { "\(schema ?? "")\u{1}\(name)" } - init(connectionId: UUID, windowState: WindowSidebarState, tables: [TableInfo], coordinator: MainContentCoordinator?) { + init(connectionId: UUID, sharedSidebarState: SharedSidebarState, tables: [TableInfo], coordinator: MainContentCoordinator?) { self.connectionId = connectionId - self.windowState = windowState + self.sharedSidebarState = sharedSidebarState self.tables = tables self.sidebarState = ConnectionSidebarState.shared(for: connectionId) _viewModel = State(wrappedValue: FavoritesSidebarViewModel(connectionId: connectionId)) diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index eb374eed8..ebd25268b 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -86,7 +86,7 @@ struct SidebarView: View { pendingDeletes: pendingDeletes, tableOperationOptions: tableOperationOptions ) - vm.searchText = windowState.searchText + vm.searchText = sidebarState.searchText if databaseType == .redis, let existingVM = sidebarState.redisKeyTreeViewModel { vm.redisKeyTreeViewModel = existingVM } @@ -109,7 +109,7 @@ struct SidebarView: View { if let coordinator { FavoritesTabView( connectionId: connectionId, - windowState: coordinator.windowSidebarState, + sharedSidebarState: sidebarState, tables: tables, coordinator: coordinator ) @@ -118,7 +118,7 @@ struct SidebarView: View { } } } - .onChange(of: windowState.searchText) { _, newValue in + .onChange(of: sidebarState.searchText) { _, newValue in viewModel.searchText = newValue } .onAppear { diff --git a/TableProTests/Models/SharedSidebarStateTests.swift b/TableProTests/Models/SharedSidebarStateTests.swift index 48226b15e..f9728d476 100644 --- a/TableProTests/Models/SharedSidebarStateTests.swift +++ b/TableProTests/Models/SharedSidebarStateTests.swift @@ -3,7 +3,7 @@ // TableProTests // // Tests for SharedSidebarState — per-connection shared sidebar state registry. -// Window-scoped state (selection, search) lives in WindowSidebarState; see +// Window-scoped state (table selection) lives in WindowSidebarState; see // WindowSidebarStateTests. // @@ -68,4 +68,41 @@ struct SharedSidebarStateTests { #expect(b.selectedSidebarTab == .favorites) SharedSidebarState.removeConnection(id) } + + // MARK: - Filter Text + + @Test("searchText persists across registry lookups for same connection") + @MainActor + func searchTextPersists() { + let id = UUID() + let a = SharedSidebarState.forConnection(id) + a.searchText = "users" + let b = SharedSidebarState.forConnection(id) + #expect(b.searchText == "users") + SharedSidebarState.removeConnection(id) + } + + @Test("favoritesSearchText persists across registry lookups for same connection") + @MainActor + func favoritesSearchTextPersists() { + let id = UUID() + let a = SharedSidebarState.forConnection(id) + a.favoritesSearchText = "daily" + let b = SharedSidebarState.forConnection(id) + #expect(b.favoritesSearchText == "daily") + SharedSidebarState.removeConnection(id) + } + + @Test("filter text is independent across different connections") + @MainActor + func filterTextIndependentAcrossConnections() { + let id1 = UUID() + let id2 = UUID() + let a = SharedSidebarState.forConnection(id1) + let b = SharedSidebarState.forConnection(id2) + a.searchText = "orders" + #expect(b.searchText.isEmpty) + SharedSidebarState.removeConnection(id1) + SharedSidebarState.removeConnection(id2) + } } diff --git a/TableProTests/ViewModels/WindowSidebarStateTests.swift b/TableProTests/ViewModels/WindowSidebarStateTests.swift index 152d470bb..e783226c6 100644 --- a/TableProTests/ViewModels/WindowSidebarStateTests.swift +++ b/TableProTests/ViewModels/WindowSidebarStateTests.swift @@ -2,9 +2,10 @@ // WindowSidebarStateTests.swift // TableProTests // -// Pins per-window scoping of sidebar state. Regression guard for #1313 where +// Pins per-window scoping of table selection. Regression guard for #1313 where // selectedTables was shared across windows of the same connection, causing -// Cmd+T to jump focus back to a sibling window. +// Cmd+T to jump focus back to a sibling window. Sidebar filter text is +// connection-scoped and lives in SharedSidebarState; see SharedSidebarStateTests. // import Foundation @@ -25,26 +26,4 @@ struct WindowSidebarStateTests { #expect(windowA.selectedTables == [users]) #expect(windowB.selectedTables.isEmpty) } - - @Test - func twoInstancesHoldIndependentSearchText() { - let windowA = WindowSidebarState() - let windowB = WindowSidebarState() - - windowA.searchText = "users" - - #expect(windowA.searchText == "users") - #expect(windowB.searchText.isEmpty) - } - - @Test - func twoInstancesHoldIndependentFavoritesSearch() { - let windowA = WindowSidebarState() - let windowB = WindowSidebarState() - - windowA.favoritesSearchText = "daily" - - #expect(windowA.favoritesSearchText == "daily") - #expect(windowB.favoritesSearchText.isEmpty) - } } From f1103e5a4564f89f7e8e6f1619618536c909895e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 18 Jun 2026 17:30:43 +0700 Subject: [PATCH 2/2] refactor(sidebar): fold ConnectionSidebarState into SharedSidebarState --- .../MainSplitViewController.swift | 4 +- TablePro/Models/UI/SharedSidebarState.swift | 16 +++++++ .../ViewModels/ConnectionSidebarState.swift | 48 ------------------- TablePro/Views/Sidebar/FavoritesTabView.swift | 8 ++-- .../Views/Sidebar/SidebarPersistenceKey.swift | 4 ++ .../Models/SharedSidebarStateTests.swift | 15 ++++++ 6 files changed, 40 insertions(+), 55 deletions(-) delete mode 100644 TablePro/ViewModels/ConnectionSidebarState.swift diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 5488328fd..8c9729794 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -237,7 +237,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi installToolbar(coordinator: sessionState.coordinator) } - if let currentSession, sessionState?.coordinator != nil { + if let currentSession, sessionState != nil { sidebarContainer.updateSidebarState( SharedSidebarState.forConnection(currentSession.connection.id) ) @@ -345,7 +345,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi private func rebuildPanes() { sidebarContainer.rootView = AnyView(buildSidebarView()) - if let currentSession, sessionState?.coordinator != nil { + if let currentSession, sessionState != nil { sidebarContainer.updateSidebarState( SharedSidebarState.forConnection(currentSession.connection.id) ) diff --git a/TablePro/Models/UI/SharedSidebarState.swift b/TablePro/Models/UI/SharedSidebarState.swift index 2ebfc90ea..bc4143f47 100644 --- a/TablePro/Models/UI/SharedSidebarState.swift +++ b/TablePro/Models/UI/SharedSidebarState.swift @@ -54,6 +54,18 @@ final class SharedSidebarState { } } + var selectedFavorite: FavoriteSelection? { + didSet { + guard oldValue != selectedFavorite else { return } + let key = SidebarPersistenceKey.selectedFavorite(connectionId: connectionId) + if let rawValue = selectedFavorite?.rawValue { + UserDefaults.standard.set(rawValue, forKey: key) + } else { + UserDefaults.standard.removeObject(forKey: key) + } + } + } + static var defaultLayout: SidebarLayout { get { guard let raw = UserDefaults.standard.string(forKey: SidebarPersistenceKey.defaultLayout), @@ -86,6 +98,9 @@ final class SharedSidebarState { self.sidebarLayout = SharedSidebarState.defaultLayout } self.databaseFilterSelected = DatabaseTreeFilterStorage.shared.selectedDatabases(connectionId: connectionId) + self.selectedFavorite = UserDefaults.standard.string( + forKey: SidebarPersistenceKey.selectedFavorite(connectionId: connectionId) + ).flatMap(FavoriteSelection.init(rawValue:)) } /// Default init for previews and tests @@ -94,6 +109,7 @@ final class SharedSidebarState { self.selectedSidebarTab = .tables self.sidebarLayout = .flat self.databaseFilterSelected = [] + self.selectedFavorite = nil } private static var registry: [UUID: SharedSidebarState] = [:] diff --git a/TablePro/ViewModels/ConnectionSidebarState.swift b/TablePro/ViewModels/ConnectionSidebarState.swift deleted file mode 100644 index 37dc3cd59..000000000 --- a/TablePro/ViewModels/ConnectionSidebarState.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// ConnectionSidebarState.swift -// TablePro -// - -import Foundation -import Observation - -@MainActor -@Observable -internal final class ConnectionSidebarState { - private static var instances: [UUID: ConnectionSidebarState] = [:] - - static func shared(for connectionId: UUID) -> ConnectionSidebarState { - if let existing = instances[connectionId] { return existing } - let state = ConnectionSidebarState(connectionId: connectionId) - instances[connectionId] = state - return state - } - - let connectionId: UUID - - var selectedFavorite: FavoriteSelection? { - didSet { - guard oldValue != selectedFavorite else { return } - persistFavoriteSelection() - } - } - - @ObservationIgnored private var favoriteSelectionKey: String { - "sidebar.selectedFavoriteNodeId.\(connectionId.uuidString)" - } - - private init(connectionId: UUID) { - self.connectionId = connectionId - self.selectedFavorite = UserDefaults.standard.string( - forKey: "sidebar.selectedFavoriteNodeId.\(connectionId.uuidString)" - ).flatMap(FavoriteSelection.init(rawValue:)) - } - - private func persistFavoriteSelection() { - if let rawValue = selectedFavorite?.rawValue { - UserDefaults.standard.set(rawValue, forKey: favoriteSelectionKey) - } else { - UserDefaults.standard.removeObject(forKey: favoriteSelectionKey) - } - } -} diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 09e046f7e..5a8bb8089 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -12,9 +12,8 @@ internal struct FavoritesTabView: View { @State private var showRemoveLinkedFolderAlert = false @FocusState private var isRenameFocused: Bool let connectionId: UUID - let sharedSidebarState: SharedSidebarState + @Bindable private var sharedSidebarState: SharedSidebarState let tables: [TableInfo] - @Bindable private var sidebarState: ConnectionSidebarState private var coordinator: MainContentCoordinator? private var searchText: String { sharedSidebarState.favoritesSearchText } @@ -43,7 +42,6 @@ internal struct FavoritesTabView: View { self.connectionId = connectionId self.sharedSidebarState = sharedSidebarState self.tables = tables - self.sidebarState = ConnectionSidebarState.shared(for: connectionId) _viewModel = State(wrappedValue: FavoritesSidebarViewModel(connectionId: connectionId)) self.coordinator = coordinator } @@ -163,7 +161,7 @@ internal struct FavoritesTabView: View { _ items: [FavoriteNode], filteredTables: [TableInfo] ) -> some View { - List(selection: $sidebarState.selectedFavorite) { + List(selection: $sharedSidebarState.selectedFavorite) { if !filteredTables.isEmpty { Section(String(localized: "Tables")) { ForEach(filteredTables) { table in @@ -281,7 +279,7 @@ internal struct FavoritesTabView: View { } private func deleteSelectedNode() { - guard let selection = sidebarState.selectedFavorite else { return } + guard let selection = sharedSidebarState.selectedFavorite else { return } switch selection { case .table(let database, let schema, let name): if let table = favoriteTable(database: database, schema: schema, name: name) { diff --git a/TablePro/Views/Sidebar/SidebarPersistenceKey.swift b/TablePro/Views/Sidebar/SidebarPersistenceKey.swift index f2c690ac6..405c69611 100644 --- a/TablePro/Views/Sidebar/SidebarPersistenceKey.swift +++ b/TablePro/Views/Sidebar/SidebarPersistenceKey.swift @@ -16,6 +16,10 @@ enum SidebarPersistenceKey { "sidebar.selectedTab.\(connectionId.uuidString)" } + static func selectedFavorite(connectionId: UUID) -> String { + "sidebar.selectedFavoriteNodeId.\(connectionId.uuidString)" + } + static let defaultLayout = "sidebar.defaultLayout" static func layout(connectionId: UUID) -> String { diff --git a/TableProTests/Models/SharedSidebarStateTests.swift b/TableProTests/Models/SharedSidebarStateTests.swift index f9728d476..87439e364 100644 --- a/TableProTests/Models/SharedSidebarStateTests.swift +++ b/TableProTests/Models/SharedSidebarStateTests.swift @@ -105,4 +105,19 @@ struct SharedSidebarStateTests { SharedSidebarState.removeConnection(id1) SharedSidebarState.removeConnection(id2) } + + // MARK: - Favorite Selection + + @Test("selectedFavorite persists across registry lookups for same connection") + @MainActor + func selectedFavoritePersists() { + let id = UUID() + let selection = FavoriteSelection.node(id: "fav-\(id.uuidString)") + let a = SharedSidebarState.forConnection(id) + a.selectedFavorite = selection + let b = SharedSidebarState.forConnection(id) + #expect(b.selectedFavorite == selection) + a.selectedFavorite = nil + SharedSidebarState.removeConnection(id) + } }