Skip to content

Commit f415a9c

Browse files
authored
refactor: sidebar architecture - single source of truth, explicit loading states (#690)
* refactor: sidebar architecture - single source of truth, explicit loading states * fix: address review issues - skip double fetch, restore stale cleanup, fix error path * docs: simplify unreleased changelog entries
1 parent 9213e60 commit f415a9c

19 files changed

Lines changed: 162 additions & 786 deletions

CHANGELOG.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12-
- Full-text search across all columns in iOS data browser
13-
- iPad keyboard shortcuts (Cmd+N new connection, Cmd+Return execute query, Cmd+1/2 switch tabs) and trackpad hover effects on list rows
14-
- Server Dashboard with active sessions, server metrics, and slow query monitoring (PostgreSQL, MySQL, MSSQL, ClickHouse, DuckDB, SQLite)
15-
- Handoff support for cross-device continuity between iOS and macOS
16-
- State restoration across app lifecycle on iOS (selected connection, active tab, query text, database/schema selection)
12+
- Server Dashboard: active sessions, metrics, slow queries (PostgreSQL, MySQL, MSSQL, ClickHouse, DuckDB, SQLite)
13+
- Handoff support between iOS and macOS
14+
- iOS: full-text search in data browser, state restoration, iPad keyboard shortcuts
15+
16+
### Changed
17+
18+
- Sidebar table loading refactored: single source of truth, explicit loading states, no race conditions on database switch
1719

1820
### Fixed
1921

20-
- Create Database dialog showing MySQL charset/collation options for all database types; now shows database-specific options (encoding/LC_COLLATE for PostgreSQL, hidden for Redis/etcd)
21-
- SSH Tunnel not working with `~/.ssh/config` profiles (#672): added `Include` directive support, SSH token expansion (`%d`, `%h`, `%u`, `%r`), multi-word `Host` filtering, and detailed handshake error messages
22+
- Create Database dialog now shows correct options per database type (encoding/LC_COLLATE for PostgreSQL, hidden for Redis/etcd)
23+
- SSH tunnel with `~/.ssh/config` profiles (#672): `Include` directives, token expansion, multi-word `Host` filtering
2224

2325
## [0.30.1] - 2026-04-10
2426

TablePro/ContentView.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,6 @@ struct ContentView: View {
184184
tableOperationOptions: sessionTableOperationOptionsBinding,
185185
databaseType: currentSession.connection.type,
186186
connectionId: currentSession.connection.id,
187-
schemaProvider: SchemaProviderRegistry.shared.provider(for: currentSession.connection.id),
188187
coordinator: sessionState.coordinator
189188
)
190189
}

TablePro/Core/Autocomplete/SQLSchemaProvider.swift

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -107,27 +107,17 @@ actor SQLSchemaProvider {
107107
isLoading
108108
}
109109

110-
/// Invalidate cache and reload
111-
func invalidateCache() {
112-
tables.removeAll()
113-
columnCache.removeAll()
114-
columnAccessOrder.removeAll()
115-
cachedDriver = nil
116-
}
117-
118-
func invalidateTables() {
119-
tables.removeAll()
120-
}
121-
122110
func updateTables(_ newTables: [TableInfo]) {
123111
tables = newTables
124112
}
125113

126-
func fetchFreshTables() async throws -> [TableInfo]? {
127-
guard let driver = cachedDriver else { return nil }
128-
let fresh = try await driver.fetchTables()
129-
tables = fresh
130-
return fresh
114+
func resetForDatabase(_ database: String?, tables newTables: [TableInfo], driver: DatabaseDriver) {
115+
self.tables = newTables
116+
self.columnCache.removeAll()
117+
self.columnAccessOrder.removeAll()
118+
self.cachedDriver = driver
119+
self.isLoading = false
120+
self.lastLoadError = nil
131121
}
132122

133123
/// Find table name from alias

TablePro/ViewModels/SidebarViewModel.swift

Lines changed: 2 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -3,63 +3,18 @@
33
// TablePro
44
//
55
// ViewModel for SidebarView.
6-
// Handles table loading, search filtering, and batch operations.
6+
// Handles search filtering and batch operations.
77
//
88

99
import Observation
10-
import os
1110
import SwiftUI
1211

13-
// MARK: - TableFetcher Protocol
14-
15-
/// Abstraction over table fetching for testability
16-
protocol TableFetcher: Sendable {
17-
func fetchTables(force: Bool) async throws -> [TableInfo]
18-
}
19-
20-
private let sidebarLogger = Logger(subsystem: "com.TablePro", category: "SidebarViewModel")
21-
22-
/// Production implementation that uses DatabaseManager, with optional schema provider cache
23-
struct LiveTableFetcher: TableFetcher {
24-
let connectionId: UUID
25-
let schemaProvider: SQLSchemaProvider?
26-
27-
init(connectionId: UUID, schemaProvider: SQLSchemaProvider? = nil) {
28-
self.connectionId = connectionId
29-
self.schemaProvider = schemaProvider
30-
}
31-
32-
func fetchTables(force: Bool) async throws -> [TableInfo] {
33-
if let provider = schemaProvider {
34-
if force {
35-
if let fresh = try await provider.fetchFreshTables() { return fresh }
36-
} else {
37-
let cached = await provider.getTables()
38-
if !cached.isEmpty { return cached }
39-
}
40-
}
41-
guard let driver = await DatabaseManager.shared.driver(for: connectionId) else {
42-
sidebarLogger.warning("Driver is nil for connection \(connectionId)")
43-
return []
44-
}
45-
let fetched = try await driver.fetchTables()
46-
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
47-
sidebarLogger.debug("Fetched \(fetched.count) tables")
48-
if let provider = schemaProvider {
49-
await provider.updateTables(fetched)
50-
}
51-
return fetched
52-
}
53-
}
54-
5512
// MARK: - SidebarViewModel
5613

5714
@MainActor @Observable
5815
final class SidebarViewModel {
5916
// MARK: - Published State
6017

61-
var isLoading = false
62-
var errorMessage: String?
6318
var debouncedSearchText = ""
6419
var isTablesExpanded: Bool = {
6520
let key = "sidebar.isTablesExpanded"
@@ -84,11 +39,6 @@ final class SidebarViewModel {
8439
var pendingOperationType: TableOperationType?
8540
var pendingOperationTables: [String] = []
8641

87-
// MARK: - Internal State
88-
89-
/// Prevents selection callback during programmatic updates (e.g., refresh)
90-
var isRestoringSelection = false
91-
9242
// MARK: - Binding Storage
9343

9444
private var tablesBinding: Binding<[TableInfo]>
@@ -101,8 +51,6 @@ final class SidebarViewModel {
10151
// MARK: - Dependencies
10252

10353
private let connectionId: UUID
104-
private let tableFetcher: TableFetcher
105-
private var loadTask: Task<Void, Never>?
10654

10755
// MARK: - Convenience Accessors
10856

@@ -140,9 +88,7 @@ final class SidebarViewModel {
14088
pendingDeletes: Binding<Set<String>>,
14189
tableOperationOptions: Binding<[String: TableOperationOptions]>,
14290
databaseType: DatabaseType,
143-
connectionId: UUID,
144-
schemaProvider: SQLSchemaProvider? = nil,
145-
tableFetcher: TableFetcher? = nil
91+
connectionId: UUID
14692
) {
14793
self.tablesBinding = tables
14894
self.selectedTablesBinding = selectedTables
@@ -151,94 +97,6 @@ final class SidebarViewModel {
15197
self.tableOperationOptionsBinding = tableOperationOptions
15298
self.databaseType = databaseType
15399
self.connectionId = connectionId
154-
self.tableFetcher = tableFetcher ?? LiveTableFetcher(connectionId: connectionId, schemaProvider: schemaProvider)
155-
}
156-
157-
// MARK: - Lifecycle
158-
159-
func onAppear() {
160-
guard tables.isEmpty else {
161-
sidebarLogger.debug("onAppear: tables not empty (\(self.tables.count)), skipping")
162-
return
163-
}
164-
if DatabaseManager.shared.driver(for: connectionId) != nil {
165-
sidebarLogger.debug("onAppear: loading tables")
166-
loadTables()
167-
} else {
168-
sidebarLogger.warning("onAppear: driver is nil for \(self.connectionId)")
169-
}
170-
}
171-
172-
// MARK: - Table Loading
173-
174-
func loadTables(force: Bool = false) {
175-
loadTask?.cancel()
176-
guard !isLoading else { return }
177-
isLoading = true
178-
errorMessage = nil
179-
loadTask = Task {
180-
await loadTablesAsync(force: force)
181-
}
182-
}
183-
184-
func forceLoadTables() {
185-
loadTask?.cancel()
186-
loadTask = nil
187-
isLoading = false
188-
loadTables(force: true)
189-
}
190-
191-
private func loadTablesAsync(force: Bool = false) async {
192-
let previousSelectedName: String? = tables.isEmpty ? nil : selectedTables.first?.name
193-
194-
do {
195-
let fetchedTables = try await tableFetcher.fetchTables(force: force)
196-
tables = fetchedTables
197-
198-
// Clean up stale entries for tables that no longer exist
199-
let fetchedNames = Set(fetchedTables.map(\.name))
200-
201-
let staleSelections = selectedTables.filter { !fetchedNames.contains($0.name) }
202-
if !staleSelections.isEmpty {
203-
isRestoringSelection = true
204-
selectedTables.subtract(staleSelections)
205-
isRestoringSelection = false
206-
}
207-
208-
let stalePendingDeletes = pendingDeletes.subtracting(fetchedNames)
209-
let stalePendingTruncates = pendingTruncates.subtracting(fetchedNames)
210-
if !stalePendingDeletes.isEmpty {
211-
pendingDeletes.subtract(stalePendingDeletes)
212-
for name in stalePendingDeletes {
213-
tableOperationOptions.removeValue(forKey: name)
214-
}
215-
}
216-
if !stalePendingTruncates.isEmpty {
217-
pendingTruncates.subtract(stalePendingTruncates)
218-
for name in stalePendingTruncates {
219-
tableOperationOptions.removeValue(forKey: name)
220-
}
221-
}
222-
223-
// Only restore selection if it was cleared (prevent reopening tabs)
224-
if let name = previousSelectedName {
225-
let currentNames = Set(selectedTables.map { $0.name })
226-
if !currentNames.contains(name) {
227-
// Selection was cleared, restore it without triggering callback
228-
isRestoringSelection = true
229-
if let restored = fetchedTables.first(where: { $0.name == name }) {
230-
selectedTables = [restored]
231-
}
232-
isRestoringSelection = false
233-
}
234-
}
235-
isLoading = false
236-
} catch is CancellationError {
237-
isLoading = false
238-
} catch {
239-
errorMessage = error.localizedDescription
240-
isLoading = false
241-
}
242100
}
243101

244102
// MARK: - Batch Operations

TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,6 @@ extension MainContentCoordinator {
8080
tabManager.tabs[index].pendingChanges = TabPendingChanges()
8181
}
8282

83-
reloadSidebar()
83+
Task { await refreshTables() }
8484
}
8585
}

0 commit comments

Comments
 (0)