Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 != nil {
sidebarContainer.updateSidebarState(
SharedSidebarState.forConnection(currentSession.connection.id),
windowState: coordinator.windowSidebarState
SharedSidebarState.forConnection(currentSession.connection.id)
)
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 != nil {
sidebarContainer.updateSidebarState(
SharedSidebarState.forConnection(currentSession.connection.id),
windowState: coordinator.windowSidebarState
SharedSidebarState.forConnection(currentSession.connection.id)
)
}
detailHosting.rootView = AnyView(buildDetailView())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ internal final class SidebarContainerViewController: NSViewController {
private let searchField = NSSearchField()
private var hostingController: NSHostingController<AnyView>
private var sidebarState: SharedSidebarState?
private var windowState: WindowSidebarState?
private var observationTask: Task<Void, Never>?

var rootView: AnyView {
Expand Down Expand Up @@ -64,33 +63,32 @@ 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
}
searchField.isHidden = false
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()
}
Expand All @@ -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")
}

Expand Down Expand Up @@ -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
}
}
}
Expand Down
19 changes: 19 additions & 0 deletions TablePro/Models/UI/SharedSidebarState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -51,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),
Expand Down Expand Up @@ -83,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
Expand All @@ -91,6 +109,7 @@ final class SharedSidebarState {
self.selectedSidebarTab = .tables
self.sidebarLayout = .flat
self.databaseFilterSelected = []
self.selectedFavorite = nil
}

private static var registry: [UUID: SharedSidebarState] = [:]
Expand Down
2 changes: 0 additions & 2 deletions TablePro/Models/UI/WindowSidebarState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ struct DatabaseSchemaKey: Hashable, Sendable {
@Observable
internal final class WindowSidebarState {
var selectedTables: Set<TableInfo> = []
var searchText: String = ""
var favoritesSearchText: String = ""
var expandedTreeSchemas: Set<String> = []
var expandedTreeDatabases: Set<String> = []
var expandedTreeDatabaseSchemas: Set<DatabaseSchemaKey> = []
Expand Down
48 changes: 0 additions & 48 deletions TablePro/ViewModels/ConnectionSidebarState.swift

This file was deleted.

14 changes: 6 additions & 8 deletions TablePro/Views/Sidebar/FavoritesTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,11 @@ internal struct FavoritesTabView: View {
@State private var showRemoveLinkedFolderAlert = false
@FocusState private var isRenameFocused: Bool
let connectionId: UUID
let windowState: WindowSidebarState
@Bindable private var 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
Expand All @@ -39,11 +38,10 @@ 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))
self.coordinator = coordinator
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Views/Sidebar/SidebarPersistenceKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions TablePro/Views/Sidebar/SidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -109,7 +109,7 @@ struct SidebarView: View {
if let coordinator {
FavoritesTabView(
connectionId: connectionId,
windowState: coordinator.windowSidebarState,
sharedSidebarState: sidebarState,
tables: tables,
coordinator: coordinator
)
Expand All @@ -118,7 +118,7 @@ struct SidebarView: View {
}
}
}
.onChange(of: windowState.searchText) { _, newValue in
.onChange(of: sidebarState.searchText) { _, newValue in
viewModel.searchText = newValue
}
.onAppear {
Expand Down
54 changes: 53 additions & 1 deletion TableProTests/Models/SharedSidebarStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//

Expand Down Expand Up @@ -68,4 +68,56 @@ 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)
}

// 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)
}
}
Loading
Loading