diff --git a/CHANGELOG.md b/CHANGELOG.md index 035225ee..e40c83f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iPad keyboard shortcuts (Cmd+N new connection, Cmd+Return execute query, Cmd+1/2 switch tabs) and trackpad hover effects on list rows - Server Dashboard with active sessions, server metrics, and slow query monitoring (PostgreSQL, MySQL, MSSQL, ClickHouse, DuckDB, SQLite) - Handoff support for cross-device continuity between iOS and macOS +- State restoration across app lifecycle on iOS (selected connection, active tab, query text, database/schema selection) ## [0.30.1] - 2026-04-10 diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index 6b834552..58ddfdbc 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -18,10 +18,11 @@ struct ConnectedView: View { @State private var session: ConnectionSession? @State private var tables: [TableInfo] = [] @State private var isConnecting = true + @State private var isConnectInProgress = false @State private var appError: AppError? @State private var failureAlertMessage: String? @State private var showFailureAlert = false - @State private var selectedTab = ConnectedTab.tables + @AppStorage("lastSelectedTab") private var selectedTabRaw: String = ConnectedTab.tables.rawValue @State private var queryHistory: [QueryHistoryItem] = [] @State private var historyStorage = QueryHistoryStorage() @State private var databases: [String] = [] @@ -40,6 +41,18 @@ struct ConnectedView: View { case query = "Query" } + private var selectedTab: ConnectedTab { + get { ConnectedTab(rawValue: selectedTabRaw) ?? .tables } + set { selectedTabRaw = newValue.rawValue } + } + + private var selectedTabBinding: Binding { + Binding( + get: { ConnectedTab(rawValue: selectedTabRaw) ?? .tables }, + set: { selectedTabRaw = $0.rawValue } + ) + } + private var displayName: String { connection.name.isEmpty ? connection.host : connection.name } @@ -119,7 +132,7 @@ struct ConnectedView: View { .navigationTitle(supportsDatabaseSwitching && databases.count > 1 ? "" : displayName) .navigationBarTitleDisplayMode(.inline) .safeAreaInset(edge: .top) { - Picker("Tab", selection: $selectedTab) { + Picker("Tab", selection: selectedTabBinding) { Text("Tables").tag(ConnectedTab.tables) Text("Query").tag(ConnectedTab.query) } @@ -128,10 +141,10 @@ struct ConnectedView: View { .padding(.vertical, 8) } .background { - Button("") { selectedTab = .tables } + Button("") { selectedTabRaw = ConnectedTab.tables.rawValue } .keyboardShortcut("1", modifiers: .command) .hidden() - Button("") { selectedTab = .query } + Button("") { selectedTabRaw = ConnectedTab.query.rawValue } .keyboardShortcut("2", modifiers: .command) .hidden() } @@ -203,12 +216,22 @@ struct ConnectedView: View { } } .onAppear { + let key = connection.id.uuidString + activeDatabase = UserDefaults.standard.string(forKey: "lastDB.\(key)") ?? "" + activeSchema = UserDefaults.standard.string(forKey: "lastSchema.\(key)") ?? "public" + let hasDriver = appState.connectionManager.session(for: connection.id)?.driver != nil - if !hasDriver, !isConnecting { - appError = nil + if !hasDriver, !isConnecting, appError == nil { Task { await connect() } } } + .onChange(of: activeDatabase) { _, newValue in + guard !newValue.isEmpty else { return } + UserDefaults.standard.set(newValue, forKey: "lastDB.\(connection.id.uuidString)") + } + .onChange(of: activeSchema) { _, newValue in + UserDefaults.standard.set(newValue, forKey: "lastSchema.\(connection.id.uuidString)") + } .onChange(of: scenePhase) { _, phase in if phase == .active, session != nil { Task { await reconnectIfNeeded() } @@ -241,11 +264,15 @@ struct ConnectedView: View { } private func connect() async { + guard !isConnectInProgress else { return } guard session == nil else { isConnecting = false return } + isConnectInProgress = true + defer { isConnectInProgress = false } + if let existing = appState.connectionManager.session(for: connection.id) { self.session = existing do { @@ -271,10 +298,6 @@ struct ConnectedView: View { do { let session = try await appState.connectionManager.connect(connection) - guard !Task.isCancelled else { - await appState.connectionManager.disconnect(connection.id) - return - } self.session = session self.tables = try await session.driver.fetchTables(schema: nil) isConnecting = false @@ -282,7 +305,6 @@ struct ConnectedView: View { await loadDatabases() await loadSchemas() } catch { - guard !Task.isCancelled else { return } let context = ErrorContext( operation: "connect", databaseType: connection.type, @@ -324,8 +346,14 @@ struct ConnectedView: View { guard let session, supportsDatabaseSwitching else { return } do { databases = try await session.driver.fetchDatabases() - // Use session's active database (may differ from connection.database after a switch) - if let stored = appState.connectionManager.session(for: connection.id) { + if !activeDatabase.isEmpty, databases.contains(activeDatabase) { + let sessionDB = appState.connectionManager.session(for: connection.id)?.activeDatabase ?? connection.database + if activeDatabase != sessionDB { + let target = activeDatabase + activeDatabase = sessionDB + await switchDatabase(to: target) + } + } else if let stored = appState.connectionManager.session(for: connection.id) { activeDatabase = stored.activeDatabase } else { activeDatabase = connection.database @@ -339,7 +367,14 @@ struct ConnectedView: View { guard let session, supportsSchemas else { return } do { schemas = try await session.driver.fetchSchemas() - activeSchema = session.driver.currentSchema ?? "public" + let currentSchema = session.driver.currentSchema ?? "public" + if schemas.contains(activeSchema), activeSchema != currentSchema { + let target = activeSchema + activeSchema = currentSchema + await switchSchema(to: target) + } else if !schemas.contains(activeSchema) { + activeSchema = currentSchema + } } catch { // Silently fail — don't show picker } diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 2b7d8a3e..49db3ee2 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -12,12 +12,12 @@ struct ConnectionListView: View { @Environment(\.horizontalSizeClass) private var sizeClass @State private var showingAddConnection = false @State private var editingConnection: DatabaseConnection? - @State private var selectedConnectionId: UUID? + @AppStorage("lastConnectionId") private var selectedConnectionIdString: String? @State private var columnVisibility: NavigationSplitViewVisibility = .automatic @State private var showingGroupManagement = false @State private var showingTagManagement = false - @State private var filterTagId: UUID? - @State private var groupByGroup = false + @AppStorage("lastFilterTagId") private var filterTagIdString: String? + @AppStorage("groupByGroup") private var groupByGroup = false @State private var editMode: EditMode = .inactive @State private var connectionToDelete: DatabaseConnection? @@ -28,6 +28,21 @@ struct ConnectionListView: View { ) } + private var selectedConnectionId: Binding { + Binding( + get: { selectedConnectionIdString.flatMap { UUID(uuidString: $0) } }, + set: { selectedConnectionIdString = $0?.uuidString } + ) + } + + private var selectedConnectionUUID: UUID? { + selectedConnectionIdString.flatMap { UUID(uuidString: $0) } + } + + private var filterTagId: UUID? { + filterTagIdString.flatMap { UUID(uuidString: $0) } + } + private var displayedConnections: [DatabaseConnection] { var result = appState.connections if let filterTagId { @@ -44,8 +59,8 @@ struct ConnectionListView: View { } private var selectedConnection: DatabaseConnection? { - guard let selectedConnectionId else { return nil } - return appState.connections.first { $0.id == selectedConnectionId } + guard let id = selectedConnectionUUID else { return nil } + return appState.connections.first { $0.id == id } } var body: some View { @@ -90,7 +105,7 @@ struct ConnectionListView: View { .onChange(of: appState.pendingConnectionId) { _, newId in navigateToPendingConnection(newId) } - .onChange(of: filterTagId) { + .onChange(of: filterTagIdString) { editMode = .inactive } .onChange(of: groupByGroup) { @@ -135,7 +150,7 @@ struct ConnectionListView: View { @ViewBuilder private var connectionList: some View { - let list = List(selection: $selectedConnectionId) { + let list = List(selection: selectedConnectionId) { if groupByGroup { groupedContent } else { @@ -201,8 +216,8 @@ struct ConnectionListView: View { ) { Button(String(localized: "Delete"), role: .destructive) { if let connection = connectionToDelete { - if selectedConnectionId == connection.id { - selectedConnectionId = nil + if selectedConnectionUUID == connection.id { + selectedConnectionIdString = nil } appState.removeConnection(connection) } @@ -222,7 +237,7 @@ struct ConnectionListView: View { if !appState.tags.isEmpty { Section("Filter by Tag") { Button { - filterTagId = nil + filterTagIdString = nil } label: { HStack { Text("All") @@ -233,7 +248,7 @@ struct ConnectionListView: View { } ForEach(appState.tags) { tag in Button { - filterTagId = tag.id + filterTagIdString = tag.id.uuidString } label: { HStack { Image(systemName: "circle.fill") @@ -339,7 +354,7 @@ struct ConnectionListView: View { private func navigateToPendingConnection(_ id: UUID?) { guard let id, appState.connections.contains(where: { $0.id == id }) else { return } - selectedConnectionId = id + selectedConnectionIdString = id.uuidString appState.pendingConnectionId = nil } diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 32ddd5c4..acc2ddaa 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -45,7 +45,14 @@ struct QueryEditorView: View { } .toolbar { toolbarContent } .onAppear { - if !initialQuery.isEmpty { query = initialQuery } + if !initialQuery.isEmpty { + query = initialQuery + } else if query.isEmpty { + query = UserDefaults.standard.string(forKey: "lastQuery.\(connectionId.uuidString)") ?? "" + } + } + .onChange(of: query) { _, newValue in + UserDefaults.standard.set(newValue, forKey: "lastQuery.\(connectionId.uuidString)") } .alert("Write Query Blocked", isPresented: $showWriteBlockedAlert) { Button("OK", role: .cancel) {}