Skip to content

Commit 7b4012a

Browse files
authored
refactor(sidebar): connection-scoped favorites + multi-window tab persistence fix (#1000)
* refactor(sidebar): connection-scoped favorites cache and per-window search * fix(tabs): aggregate persistence saves to prevent multi-window tab loss * chore(sidebar): drop dead APIs and fix indentation per review * test(tabs): cover sourceFileURL persistence round-trip
1 parent d318af4 commit 7b4012a

15 files changed

Lines changed: 390 additions & 174 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313

1414
### Fixed
1515

16+
- Multi-window tab persistence dropped all but one tab on relaunch. Three save paths each wrote only the current window's tabs, racing the `willTerminate` aggregate save: `handleTabSelectionChange` (every time you switched tabs), the `mainWindowWillClose` notification observer, and the per-window `handleWindowWillClose` close path. On Cmd+Q, AppKit closes windows LIFO so the last writer left only the first-opened window's tab in the file. The tab-selection path now writes aggregated tabs from all windows of the connection. Per-window close-time saves no-op when the app is terminating, leaving the canonical `willTerminate` aggregate save intact. The `mainWindowWillClose` observer also routes through the aggregated save path.
1617
- Filter value autocomplete popover stole keyboard focus from the text field after the first keystroke when Full Keyboard Access was enabled (System Settings → Keyboard → Keyboard Navigation). The popover content used SwiftUI `Button` rows, which become focus targets under FKA, so SwiftUI auto-focused the first row when the popover appeared. Replaced the rows with `Text` + `.onTapGesture` (non-focusable) and marked the dropdown as `.focusable(false)`.
1718
- Toolbar database name was empty after relaunching with a connection that had no database configured but a last-used database restored via `selectDatabaseFromLastSession`. The window opened (and the toolbar resolved its initial name) before the post-connect actions populated `session.currentDatabase`, so the toolbar fell back to the empty `connection.database`. Sidebar and Cmd+K both worked because they read the session directly. The toolbar now re-syncs its database name on every `connectionStatusDidChange`, picking up the restored value once the session settles.
1819

1920
### Changed
2021

22+
- Favorites sidebar state is now connection-scoped, not window-scoped. Opening a second native tab for the same connection no longer reloads the favorites tree from SQLite or flashes a spinner. The folders/favorites/linked-files cache (`ConnectionDataCache`) is shared across windows of the same connection and refreshes on `.sqlFavoritesDidUpdate` and `.linkedSQLFoldersDidUpdate`. Favorite selection (`ConnectionSidebarState.selectedFavoriteNodeId`) is also shared, so highlighting a favorite in window A reflects in window B and persists across launches via UserDefaults. Favorites search text remains per-window (`WindowSidebarState.favoritesSearchText`), matching Mail/Notes patterns where each window can search independently. The single sidebar `NSSearchField` routes to the connection-shared text on Tables and to the window-local text on Favorites based on the active tab.
2123
- Connection Form rebuilt around macOS HIG sidebar navigation. The old segmented-tab form (~2200 lines across five files) is replaced by a `NavigationSplitView` with five sidebar panes (General, SSH Tunnel, SSL/TLS, Customization, Advanced). State previously held in 30+ flat `@State` vars is now split across six `@Observable` per-pane view models behind a `ConnectionFormCoordinator`. Plugin-driven additional fields auto-route to the right pane by their declared `FieldSection`. The toolbar exposes Cancel, Save, and Save & Connect natively; Test Connection lives inline in the General pane as a Status row. Each sidebar item shows a red warning triangle when its pane has missing required fields.
2224
- Import from URL flow moved to the database type chooser. Picking "Import from URL..." in the chooser footer opens a dedicated sheet; pasting a connection URL detects the type, previews host/user/database, and pre-fills the form. The form itself no longer has a URL import button — users decide before opening the form.
2325
- Native HIG polish across the form. General pane authentication rows reorder to Username → Password → Prompt for password → Use Password File. SSL/TLS mode caption renders as a Section footer instead of a stray row; CA Cert / Client Cert labels expand to full names. SSH Tunnel collapses "Profile" and "Profile Settings" into a single section with a "Profile Details" `DisclosureGroup`. Customization section "Safety" renames to "Query Behavior". Advanced pane's Startup Commands and Pre-Connect Script editors switch from a custom `RoundedRectangle.stroke` border to native `NSScrollView.borderType = .bezelBorder` (Terminal / Mail signature pattern); body captions move to Section footers. Connection form WindowGroup gains the standard three traffic lights.

TablePro/Core/Services/Infrastructure/MainSplitViewController.swift

Lines changed: 48 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,10 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
139139

140140
if currentSession == nil {
141141
sidebarSplitItem.isCollapsed = true
142-
} else {
142+
} else if let session = currentSession, let coordinator = sessionState?.coordinator {
143143
sidebarContainer.updateSidebarState(
144-
SharedSidebarState.forConnection(currentSession!.connection.id) // swiftlint:disable:this force_unwrapping
144+
SharedSidebarState.forConnection(session.connection.id),
145+
windowState: coordinator.windowSidebarState
145146
)
146147
}
147148
inspectorSplitItem.isCollapsed = !UserDefaults.standard.bool(forKey: Self.inspectorPresentedKey)
@@ -171,12 +172,16 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
171172
installToolbar(coordinator: sessionState.coordinator)
172173
}
173174

174-
if let currentSession {
175+
if let currentSession, let coordinator = sessionState?.coordinator {
175176
sidebarContainer.updateSidebarState(
176-
SharedSidebarState.forConnection(currentSession.connection.id)
177+
SharedSidebarState.forConnection(currentSession.connection.id),
178+
windowState: coordinator.windowSidebarState
177179
)
178180
}
179181

182+
sidebarContainer.view.layoutSubtreeIfNeeded()
183+
sidebarContainer.view.display()
184+
180185
installObservers()
181186
}
182187

@@ -249,7 +254,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
249254
sessionState?.coordinator.teardown()
250255
sessionState = nil
251256
currentSession = nil
252-
sidebarContainer.updateSidebarState(nil)
257+
sidebarContainer.updateSidebarState(nil, windowState: nil)
253258
if view.window?.isVisible == true {
254259
sidebarSplitItem.animator().isCollapsed = true
255260
} else {
@@ -293,9 +298,10 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
293298

294299
private func rebuildPanes() {
295300
sidebarContainer.rootView = AnyView(buildSidebarView())
296-
if let currentSession {
301+
if let currentSession, let coordinator = sessionState?.coordinator {
297302
sidebarContainer.updateSidebarState(
298-
SharedSidebarState.forConnection(currentSession.connection.id)
303+
SharedSidebarState.forConnection(currentSession.connection.id),
304+
windowState: coordinator.windowSidebarState
299305
)
300306
}
301307
detailHosting.rootView = AnyView(buildDetailView())
@@ -305,37 +311,46 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
305311
@ViewBuilder
306312
private func buildSidebarView() -> some View {
307313
if let currentSession, let sessionState {
308-
SidebarView(
309-
sidebarState: SharedSidebarState.forConnection(currentSession.connection.id),
310-
onDoubleClick: { [weak self] table in
311-
guard let coordinator = self?.sessionState?.coordinator else { return }
312-
let connectionId = coordinator.connectionId
313-
let isView = table.type == .view
314-
if let preview = WindowLifecycleMonitor.shared.previewWindow(for: connectionId),
315-
let previewCoordinator = MainContentCoordinator.coordinator(for: preview.windowId) {
316-
if previewCoordinator.tabManager.selectedTab?.tableContext.tableName == table.name {
317-
previewCoordinator.promotePreviewTab()
318-
} else {
319-
previewCoordinator.promotePreviewTab()
320-
coordinator.openTableTab(table.name, isView: isView)
321-
}
322-
} else {
323-
coordinator.promotePreviewTab()
324-
coordinator.openTableTab(table.name, isView: isView)
325-
}
326-
},
327-
pendingTruncates: sessionPendingTruncatesBinding,
328-
pendingDeletes: sessionPendingDeletesBinding,
329-
tableOperationOptions: sessionTableOperationOptionsBinding,
330-
databaseType: currentSession.connection.type,
331-
connectionId: currentSession.connection.id,
332-
coordinator: sessionState.coordinator
333-
)
314+
sidebarBody(currentSession: currentSession, sessionState: sessionState)
315+
.transaction { $0.animation = nil }
334316
} else {
335317
Color.clear
336318
}
337319
}
338320

321+
@ViewBuilder
322+
private func sidebarBody(
323+
currentSession: ConnectionSession,
324+
sessionState: SessionStateFactory.SessionState
325+
) -> some View {
326+
SidebarView(
327+
sidebarState: SharedSidebarState.forConnection(currentSession.connection.id),
328+
onDoubleClick: { [weak self] table in
329+
guard let coordinator = self?.sessionState?.coordinator else { return }
330+
let connectionId = coordinator.connectionId
331+
let isView = table.type == .view
332+
if let preview = WindowLifecycleMonitor.shared.previewWindow(for: connectionId),
333+
let previewCoordinator = MainContentCoordinator.coordinator(for: preview.windowId) {
334+
if previewCoordinator.tabManager.selectedTab?.tableContext.tableName == table.name {
335+
previewCoordinator.promotePreviewTab()
336+
} else {
337+
previewCoordinator.promotePreviewTab()
338+
coordinator.openTableTab(table.name, isView: isView)
339+
}
340+
} else {
341+
coordinator.promotePreviewTab()
342+
coordinator.openTableTab(table.name, isView: isView)
343+
}
344+
},
345+
pendingTruncates: sessionPendingTruncatesBinding,
346+
pendingDeletes: sessionPendingDeletesBinding,
347+
tableOperationOptions: sessionTableOperationOptionsBinding,
348+
databaseType: currentSession.connection.type,
349+
connectionId: currentSession.connection.id,
350+
coordinator: sessionState.coordinator
351+
)
352+
}
353+
339354
@ViewBuilder
340355
private func buildDetailView() -> some View {
341356
if let currentSession, let rightPanelState, let sessionState {

TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift

Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,6 @@
22
// SidebarContainerViewController.swift
33
// TablePro
44
//
5-
// AppKit container that places a native NSSearchField above the SwiftUI sidebar content.
6-
// The search field inherits sidebar vibrancy from the NSSplitViewItem automatically.
7-
//
85

96
import AppKit
107
import SwiftUI
@@ -14,6 +11,7 @@ internal final class SidebarContainerViewController: NSViewController {
1411
private let searchField = NSSearchField()
1512
private var hostingController: NSHostingController<AnyView>
1613
private var sidebarState: SharedSidebarState?
14+
private var windowState: WindowSidebarState?
1715
private var observationGeneration = 0
1816

1917
var rootView: AnyView {
@@ -59,53 +57,76 @@ internal final class SidebarContainerViewController: NSViewController {
5957
])
6058
}
6159

62-
// MARK: - State Management
63-
64-
func updateSidebarState(_ state: SharedSidebarState?) {
60+
func updateSidebarState(_ state: SharedSidebarState?, windowState: WindowSidebarState?) {
6561
observationGeneration += 1
66-
sidebarState = state
67-
guard let state else {
62+
self.sidebarState = state
63+
self.windowState = windowState
64+
guard let state, let windowState else {
6865
searchField.isHidden = true
6966
return
7067
}
7168
searchField.isHidden = false
72-
syncFromState(state)
73-
startObserving(state, generation: observationGeneration)
69+
syncFromState(state, windowState: windowState)
70+
startObserving(state, windowState: windowState, generation: observationGeneration)
7471
}
7572

76-
private func startObserving(_ state: SharedSidebarState, generation: Int) {
73+
private func startObserving(
74+
_ state: SharedSidebarState,
75+
windowState: WindowSidebarState,
76+
generation: Int
77+
) {
7778
withObservationTracking {
7879
_ = state.searchText
7980
_ = state.selectedSidebarTab
81+
_ = windowState.favoritesSearchText
8082
} onChange: { [weak self] in
8183
Task { @MainActor [weak self] in
82-
guard let self, generation == self.observationGeneration,
83-
let sidebarState = self.sidebarState else { return }
84-
self.syncFromState(sidebarState)
85-
self.startObserving(sidebarState, generation: generation)
84+
guard let self,
85+
generation == self.observationGeneration,
86+
let sidebarState = self.sidebarState,
87+
let windowState = self.windowState else { return }
88+
self.syncFromState(sidebarState, windowState: windowState)
89+
self.startObserving(sidebarState, windowState: windowState, generation: generation)
8690
}
8791
}
8892
}
8993

90-
private func syncFromState(_ state: SharedSidebarState) {
91-
if searchField.stringValue != state.searchText {
92-
searchField.stringValue = state.searchText
94+
private func syncFromState(_ state: SharedSidebarState, windowState: WindowSidebarState) {
95+
let activeText: String
96+
let placeholder: String
97+
switch state.selectedSidebarTab {
98+
case .tables:
99+
activeText = state.searchText
100+
placeholder = String(localized: "Filter")
101+
case .favorites:
102+
activeText = windowState.favoritesSearchText
103+
placeholder = String(localized: "Filter favorites")
104+
}
105+
106+
if searchField.stringValue != activeText {
107+
searchField.stringValue = activeText
93108
}
94-
searchField.placeholderString = state.selectedSidebarTab == .tables
95-
? String(localized: "Filter")
96-
: String(localized: "Filter favorites")
109+
searchField.placeholderString = placeholder
97110
}
98111
}
99112

100-
// MARK: - NSSearchFieldDelegate
101-
102113
extension SidebarContainerViewController: NSSearchFieldDelegate {
103114
func controlTextDidChange(_ obj: Notification) {
104115
guard let field = obj.object as? NSSearchField else { return }
105-
sidebarState?.searchText = field.stringValue
116+
writeSearchText(field.stringValue)
106117
}
107118

108119
func searchFieldDidEndSearching(_ sender: NSSearchField) {
109-
sidebarState?.searchText = ""
120+
writeSearchText("")
121+
}
122+
123+
private func writeSearchText(_ text: String) {
124+
guard let sidebarState else { return }
125+
switch sidebarState.selectedSidebarTab {
126+
case .tables:
127+
sidebarState.searchText = text
128+
case .favorites:
129+
windowState?.favoritesSearchText = text
130+
}
110131
}
111132
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//
2+
// WindowSidebarState.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import Observation
8+
9+
@MainActor
10+
@Observable
11+
internal final class WindowSidebarState {
12+
var favoritesSearchText: String = ""
13+
}

0 commit comments

Comments
 (0)