Skip to content

Commit 3bd95cb

Browse files
committed
feat(ios): add state restoration across app lifecycle
1 parent 1bb338c commit 3bd95cb

File tree

4 files changed

+79
-25
lines changed

4 files changed

+79
-25
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: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ struct ConnectedView: View {
2121
@State private var appError: AppError?
2222
@State private var failureAlertMessage: String?
2323
@State private var showFailureAlert = false
24-
@State private var selectedTab = ConnectedTab.tables
24+
@AppStorage("lastSelectedTab") private var selectedTabRaw: String = ConnectedTab.tables.rawValue
2525
@State private var queryHistory: [QueryHistoryItem] = []
2626
@State private var historyStorage = QueryHistoryStorage()
2727
@State private var databases: [String] = []
@@ -40,6 +40,18 @@ struct ConnectedView: View {
4040
case query = "Query"
4141
}
4242

43+
private var selectedTab: ConnectedTab {
44+
get { ConnectedTab(rawValue: selectedTabRaw) ?? .tables }
45+
set { selectedTabRaw = newValue.rawValue }
46+
}
47+
48+
private var selectedTabBinding: Binding<ConnectedTab> {
49+
Binding(
50+
get: { ConnectedTab(rawValue: selectedTabRaw) ?? .tables },
51+
set: { selectedTabRaw = $0.rawValue }
52+
)
53+
}
54+
4355
private var displayName: String {
4456
connection.name.isEmpty ? connection.host : connection.name
4557
}
@@ -119,7 +131,7 @@ struct ConnectedView: View {
119131
.navigationTitle(supportsDatabaseSwitching && databases.count > 1 ? "" : displayName)
120132
.navigationBarTitleDisplayMode(.inline)
121133
.safeAreaInset(edge: .top) {
122-
Picker("Tab", selection: $selectedTab) {
134+
Picker("Tab", selection: selectedTabBinding) {
123135
Text("Tables").tag(ConnectedTab.tables)
124136
Text("Query").tag(ConnectedTab.query)
125137
}
@@ -128,10 +140,10 @@ struct ConnectedView: View {
128140
.padding(.vertical, 8)
129141
}
130142
.background {
131-
Button("") { selectedTab = .tables }
143+
Button("") { selectedTabRaw = ConnectedTab.tables.rawValue }
132144
.keyboardShortcut("1", modifiers: .command)
133145
.hidden()
134-
Button("") { selectedTab = .query }
146+
Button("") { selectedTabRaw = ConnectedTab.query.rawValue }
135147
.keyboardShortcut("2", modifiers: .command)
136148
.hidden()
137149
}
@@ -203,12 +215,23 @@ struct ConnectedView: View {
203215
}
204216
}
205217
.onAppear {
218+
let key = connection.id.uuidString
219+
activeDatabase = UserDefaults.standard.string(forKey: "lastDB.\(key)") ?? ""
220+
activeSchema = UserDefaults.standard.string(forKey: "lastSchema.\(key)") ?? "public"
221+
206222
let hasDriver = appState.connectionManager.session(for: connection.id)?.driver != nil
207223
if !hasDriver, !isConnecting {
208224
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() }
@@ -271,18 +294,13 @@ struct ConnectedView: View {
271294

272295
do {
273296
let session = try await appState.connectionManager.connect(connection)
274-
guard !Task.isCancelled else {
275-
await appState.connectionManager.disconnect(connection.id)
276-
return
277-
}
278297
self.session = session
279298
self.tables = try await session.driver.fetchTables(schema: nil)
280299
isConnecting = false
281300
hapticSuccess.toggle()
282301
await loadDatabases()
283302
await loadSchemas()
284303
} catch {
285-
guard !Task.isCancelled else { return }
286304
let context = ErrorContext(
287305
operation: "connect",
288306
databaseType: connection.type,
@@ -324,8 +342,14 @@ struct ConnectedView: View {
324342
guard let session, supportsDatabaseSwitching else { return }
325343
do {
326344
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) {
345+
if !activeDatabase.isEmpty, databases.contains(activeDatabase) {
346+
let sessionDB = appState.connectionManager.session(for: connection.id)?.activeDatabase ?? connection.database
347+
if activeDatabase != sessionDB {
348+
let target = activeDatabase
349+
activeDatabase = sessionDB
350+
await switchDatabase(to: target)
351+
}
352+
} else if let stored = appState.connectionManager.session(for: connection.id) {
329353
activeDatabase = stored.activeDatabase
330354
} else {
331355
activeDatabase = connection.database
@@ -339,7 +363,14 @@ struct ConnectedView: View {
339363
guard let session, supportsSchemas else { return }
340364
do {
341365
schemas = try await session.driver.fetchSchemas()
342-
activeSchema = session.driver.currentSchema ?? "public"
366+
let currentSchema = session.driver.currentSchema ?? "public"
367+
if schemas.contains(activeSchema), activeSchema != currentSchema {
368+
let target = activeSchema
369+
activeSchema = currentSchema
370+
await switchSchema(to: target)
371+
} else if !schemas.contains(activeSchema) {
372+
activeSchema = currentSchema
373+
}
343374
} catch {
344375
// Silently fail — don't show picker
345376
}

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)