Skip to content

Commit 3744150

Browse files
authored
feat(ios): add state restoration across app lifecycle (#683)
1 parent 1bb338c commit 3744150

File tree

4 files changed

+85
-27
lines changed

4 files changed

+85
-27
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- iPad keyboard shortcuts (Cmd+N new connection, Cmd+Return execute query, Cmd+1/2 switch tabs) and trackpad hover effects on list rows
1414
- Server Dashboard with active sessions, server metrics, and slow query monitoring (PostgreSQL, MySQL, MSSQL, ClickHouse, DuckDB, SQLite)
1515
- 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)
1617

1718
## [0.30.1] - 2026-04-10
1819

TableProMobile/TableProMobile/Views/ConnectedView.swift

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ struct ConnectedView: View {
1818
@State private var session: ConnectionSession?
1919
@State private var tables: [TableInfo] = []
2020
@State private var isConnecting = true
21+
@State private var isConnectInProgress = false
2122
@State private var appError: AppError?
2223
@State private var failureAlertMessage: String?
2324
@State private var showFailureAlert = false
24-
@State private var selectedTab = ConnectedTab.tables
25+
@AppStorage("lastSelectedTab") private var selectedTabRaw: String = ConnectedTab.tables.rawValue
2526
@State private var queryHistory: [QueryHistoryItem] = []
2627
@State private var historyStorage = QueryHistoryStorage()
2728
@State private var databases: [String] = []
@@ -40,6 +41,18 @@ struct ConnectedView: View {
4041
case query = "Query"
4142
}
4243

44+
private var selectedTab: ConnectedTab {
45+
get { ConnectedTab(rawValue: selectedTabRaw) ?? .tables }
46+
set { selectedTabRaw = newValue.rawValue }
47+
}
48+
49+
private var selectedTabBinding: Binding<ConnectedTab> {
50+
Binding(
51+
get: { ConnectedTab(rawValue: selectedTabRaw) ?? .tables },
52+
set: { selectedTabRaw = $0.rawValue }
53+
)
54+
}
55+
4356
private var displayName: String {
4457
connection.name.isEmpty ? connection.host : connection.name
4558
}
@@ -119,7 +132,7 @@ struct ConnectedView: View {
119132
.navigationTitle(supportsDatabaseSwitching && databases.count > 1 ? "" : displayName)
120133
.navigationBarTitleDisplayMode(.inline)
121134
.safeAreaInset(edge: .top) {
122-
Picker("Tab", selection: $selectedTab) {
135+
Picker("Tab", selection: selectedTabBinding) {
123136
Text("Tables").tag(ConnectedTab.tables)
124137
Text("Query").tag(ConnectedTab.query)
125138
}
@@ -128,10 +141,10 @@ struct ConnectedView: View {
128141
.padding(.vertical, 8)
129142
}
130143
.background {
131-
Button("") { selectedTab = .tables }
144+
Button("") { selectedTabRaw = ConnectedTab.tables.rawValue }
132145
.keyboardShortcut("1", modifiers: .command)
133146
.hidden()
134-
Button("") { selectedTab = .query }
147+
Button("") { selectedTabRaw = ConnectedTab.query.rawValue }
135148
.keyboardShortcut("2", modifiers: .command)
136149
.hidden()
137150
}
@@ -203,12 +216,22 @@ struct ConnectedView: View {
203216
}
204217
}
205218
.onAppear {
219+
let key = connection.id.uuidString
220+
activeDatabase = UserDefaults.standard.string(forKey: "lastDB.\(key)") ?? ""
221+
activeSchema = UserDefaults.standard.string(forKey: "lastSchema.\(key)") ?? "public"
222+
206223
let hasDriver = appState.connectionManager.session(for: connection.id)?.driver != nil
207-
if !hasDriver, !isConnecting {
208-
appError = nil
224+
if !hasDriver, !isConnecting, appError == nil {
209225
Task { await connect() }
210226
}
211227
}
228+
.onChange(of: activeDatabase) { _, newValue in
229+
guard !newValue.isEmpty else { return }
230+
UserDefaults.standard.set(newValue, forKey: "lastDB.\(connection.id.uuidString)")
231+
}
232+
.onChange(of: activeSchema) { _, newValue in
233+
UserDefaults.standard.set(newValue, forKey: "lastSchema.\(connection.id.uuidString)")
234+
}
212235
.onChange(of: scenePhase) { _, phase in
213236
if phase == .active, session != nil {
214237
Task { await reconnectIfNeeded() }
@@ -241,11 +264,15 @@ struct ConnectedView: View {
241264
}
242265

243266
private func connect() async {
267+
guard !isConnectInProgress else { return }
244268
guard session == nil else {
245269
isConnecting = false
246270
return
247271
}
248272

273+
isConnectInProgress = true
274+
defer { isConnectInProgress = false }
275+
249276
if let existing = appState.connectionManager.session(for: connection.id) {
250277
self.session = existing
251278
do {
@@ -271,18 +298,13 @@ struct ConnectedView: View {
271298

272299
do {
273300
let session = try await appState.connectionManager.connect(connection)
274-
guard !Task.isCancelled else {
275-
await appState.connectionManager.disconnect(connection.id)
276-
return
277-
}
278301
self.session = session
279302
self.tables = try await session.driver.fetchTables(schema: nil)
280303
isConnecting = false
281304
hapticSuccess.toggle()
282305
await loadDatabases()
283306
await loadSchemas()
284307
} catch {
285-
guard !Task.isCancelled else { return }
286308
let context = ErrorContext(
287309
operation: "connect",
288310
databaseType: connection.type,
@@ -324,8 +346,14 @@ struct ConnectedView: View {
324346
guard let session, supportsDatabaseSwitching else { return }
325347
do {
326348
databases = try await session.driver.fetchDatabases()
327-
// Use session's active database (may differ from connection.database after a switch)
328-
if let stored = appState.connectionManager.session(for: connection.id) {
349+
if !activeDatabase.isEmpty, databases.contains(activeDatabase) {
350+
let sessionDB = appState.connectionManager.session(for: connection.id)?.activeDatabase ?? connection.database
351+
if activeDatabase != sessionDB {
352+
let target = activeDatabase
353+
activeDatabase = sessionDB
354+
await switchDatabase(to: target)
355+
}
356+
} else if let stored = appState.connectionManager.session(for: connection.id) {
329357
activeDatabase = stored.activeDatabase
330358
} else {
331359
activeDatabase = connection.database
@@ -339,7 +367,14 @@ struct ConnectedView: View {
339367
guard let session, supportsSchemas else { return }
340368
do {
341369
schemas = try await session.driver.fetchSchemas()
342-
activeSchema = session.driver.currentSchema ?? "public"
370+
let currentSchema = session.driver.currentSchema ?? "public"
371+
if schemas.contains(activeSchema), activeSchema != currentSchema {
372+
let target = activeSchema
373+
activeSchema = currentSchema
374+
await switchSchema(to: target)
375+
} else if !schemas.contains(activeSchema) {
376+
activeSchema = currentSchema
377+
}
343378
} catch {
344379
// Silently fail — don't show picker
345380
}

TableProMobile/TableProMobile/Views/ConnectionListView.swift

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ struct ConnectionListView: View {
1212
@Environment(\.horizontalSizeClass) private var sizeClass
1313
@State private var showingAddConnection = false
1414
@State private var editingConnection: DatabaseConnection?
15-
@State private var selectedConnectionId: UUID?
15+
@AppStorage("lastConnectionId") private var selectedConnectionIdString: String?
1616
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
1717
@State private var showingGroupManagement = false
1818
@State private var showingTagManagement = false
19-
@State private var filterTagId: UUID?
20-
@State private var groupByGroup = false
19+
@AppStorage("lastFilterTagId") private var filterTagIdString: String?
20+
@AppStorage("groupByGroup") private var groupByGroup = false
2121
@State private var editMode: EditMode = .inactive
2222
@State private var connectionToDelete: DatabaseConnection?
2323

@@ -28,6 +28,21 @@ struct ConnectionListView: View {
2828
)
2929
}
3030

31+
private var selectedConnectionId: Binding<UUID?> {
32+
Binding(
33+
get: { selectedConnectionIdString.flatMap { UUID(uuidString: $0) } },
34+
set: { selectedConnectionIdString = $0?.uuidString }
35+
)
36+
}
37+
38+
private var selectedConnectionUUID: UUID? {
39+
selectedConnectionIdString.flatMap { UUID(uuidString: $0) }
40+
}
41+
42+
private var filterTagId: UUID? {
43+
filterTagIdString.flatMap { UUID(uuidString: $0) }
44+
}
45+
3146
private var displayedConnections: [DatabaseConnection] {
3247
var result = appState.connections
3348
if let filterTagId {
@@ -44,8 +59,8 @@ struct ConnectionListView: View {
4459
}
4560

4661
private var selectedConnection: DatabaseConnection? {
47-
guard let selectedConnectionId else { return nil }
48-
return appState.connections.first { $0.id == selectedConnectionId }
62+
guard let id = selectedConnectionUUID else { return nil }
63+
return appState.connections.first { $0.id == id }
4964
}
5065

5166
var body: some View {
@@ -90,7 +105,7 @@ struct ConnectionListView: View {
90105
.onChange(of: appState.pendingConnectionId) { _, newId in
91106
navigateToPendingConnection(newId)
92107
}
93-
.onChange(of: filterTagId) {
108+
.onChange(of: filterTagIdString) {
94109
editMode = .inactive
95110
}
96111
.onChange(of: groupByGroup) {
@@ -135,7 +150,7 @@ struct ConnectionListView: View {
135150

136151
@ViewBuilder
137152
private var connectionList: some View {
138-
let list = List(selection: $selectedConnectionId) {
153+
let list = List(selection: selectedConnectionId) {
139154
if groupByGroup {
140155
groupedContent
141156
} else {
@@ -201,8 +216,8 @@ struct ConnectionListView: View {
201216
) {
202217
Button(String(localized: "Delete"), role: .destructive) {
203218
if let connection = connectionToDelete {
204-
if selectedConnectionId == connection.id {
205-
selectedConnectionId = nil
219+
if selectedConnectionUUID == connection.id {
220+
selectedConnectionIdString = nil
206221
}
207222
appState.removeConnection(connection)
208223
}
@@ -222,7 +237,7 @@ struct ConnectionListView: View {
222237
if !appState.tags.isEmpty {
223238
Section("Filter by Tag") {
224239
Button {
225-
filterTagId = nil
240+
filterTagIdString = nil
226241
} label: {
227242
HStack {
228243
Text("All")
@@ -233,7 +248,7 @@ struct ConnectionListView: View {
233248
}
234249
ForEach(appState.tags) { tag in
235250
Button {
236-
filterTagId = tag.id
251+
filterTagIdString = tag.id.uuidString
237252
} label: {
238253
HStack {
239254
Image(systemName: "circle.fill")
@@ -339,7 +354,7 @@ struct ConnectionListView: View {
339354
private func navigateToPendingConnection(_ id: UUID?) {
340355
guard let id,
341356
appState.connections.contains(where: { $0.id == id }) else { return }
342-
selectedConnectionId = id
357+
selectedConnectionIdString = id.uuidString
343358
appState.pendingConnectionId = nil
344359
}
345360

TableProMobile/TableProMobile/Views/QueryEditorView.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,14 @@ struct QueryEditorView: View {
4545
}
4646
.toolbar { toolbarContent }
4747
.onAppear {
48-
if !initialQuery.isEmpty { query = initialQuery }
48+
if !initialQuery.isEmpty {
49+
query = initialQuery
50+
} else if query.isEmpty {
51+
query = UserDefaults.standard.string(forKey: "lastQuery.\(connectionId.uuidString)") ?? ""
52+
}
53+
}
54+
.onChange(of: query) { _, newValue in
55+
UserDefaults.standard.set(newValue, forKey: "lastQuery.\(connectionId.uuidString)")
4956
}
5057
.alert("Write Query Blocked", isPresented: $showWriteBlockedAlert) {
5158
Button("OK", role: .cancel) {}

0 commit comments

Comments
 (0)