From 7c2d3b44962f5c681d843b5f86bf1f1e11f3ec32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 24 Apr 2026 12:58:39 +0700 Subject: [PATCH 1/8] refactor(ios): replace segmented Picker with TabView(.sidebarAdaptable) architecture --- CHANGELOG.md | 4 + .../TableProMobile.xcodeproj/project.pbxproj | 8 +- TableProMobile/TableProMobile/AppState.swift | 1 + .../Coordinators/ConnectionCoordinator.swift | 317 +++++++++++ .../TableProMobile/Models/ConnectedTab.swift | 11 + .../TableProMobile/TableProMobileApp.swift | 1 + .../Views/Components/FilterSheetView.swift | 160 ++++++ .../Views/Components/RowCard.swift | 73 +++ .../TableProMobile/Views/ConnectedView.swift | 507 ++++-------------- .../Views/ConnectionListView.swift | 20 +- .../Views/DataBrowserView.swift | 228 +------- .../Views/QueryEditorView.swift | 101 +--- .../Views/QueryHistoryView.swift | 71 +++ .../TableProMobile/Views/TableListView.swift | 87 ++- 14 files changed, 856 insertions(+), 733 deletions(-) create mode 100644 TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift create mode 100644 TableProMobile/TableProMobile/Models/ConnectedTab.swift create mode 100644 TableProMobile/TableProMobile/Views/Components/FilterSheetView.swift create mode 100644 TableProMobile/TableProMobile/Views/Components/RowCard.swift create mode 100644 TableProMobile/TableProMobile/Views/QueryHistoryView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index df8a35aab..818e8b678 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- iOS: replace segmented Picker with TabView(.sidebarAdaptable) for 4-tab navigation (Tables, Query, History, Settings) with per-tab NavigationStack and iPad sidebar support +- iOS: extract ConnectionCoordinator from ConnectedView, eliminating prop drilling across table and query views +- iOS: minimum deployment target raised to iOS 18 - Native macOS UI patterns: Picker(.menu) for cell editors, native alerts, native List selection, .navigationTitle for sheets, NSSearchField for welcome search, borderless toolbar buttons, chevron indicator on SET picker - Quit dialog defaults to Cancel on Return key - Connection form delete button moved to far left @@ -24,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Crash when scrolling AI Chat during streaming on macOS 15.x - Connection failure on PostgreSQL-compatible databases (e.g., Aurora DSQL) that don't support `SET statement_timeout` - Schema-qualified table names (e.g. `public.users`) now correctly resolve in autocomplete - Alert dialogs use sheet attachment instead of bare modal diff --git a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj index 2b3cc9f18..530f72c14 100644 --- a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj +++ b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj @@ -1803,7 +1803,7 @@ INFOPLIST_FILE = TableProWidget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TableProWidget; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1834,7 +1834,7 @@ INFOPLIST_FILE = TableProWidget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TableProWidget; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1906,7 +1906,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -1964,7 +1964,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index 4f42b8026..62fe70579 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -16,6 +16,7 @@ final class AppState { var groups: [ConnectionGroup] = [] var tags: [ConnectionTag] = [] var pendingConnectionId: UUID? + var pendingTableName: String? let connectionManager: ConnectionManager let syncCoordinator = IOSSyncCoordinator() let sshProvider: IOSSSHProvider diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift new file mode 100644 index 000000000..01b3cea77 --- /dev/null +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -0,0 +1,317 @@ +// +// ConnectionCoordinator.swift +// TableProMobile +// + +import os +import Foundation +import Observation +import SwiftUI +import TableProDatabase +import TableProModels + +@MainActor @Observable +final class ConnectionCoordinator { + let connection: DatabaseConnection + + private(set) var session: ConnectionSession? + private(set) var phase: ConnectionPhase = .connecting + private(set) var tables: [TableInfo] = [] + private(set) var databases: [String] = [] + private(set) var schemas: [String] = [] + private(set) var activeDatabase: String = "" + private(set) var activeSchema: String = "public" + + private(set) var isSwitching = false + private(set) var isReconnecting = false + var failureAlertMessage: String? + var showFailureAlert = false + + var selectedTab: ConnectedTab = .tables { + didSet { + UserDefaults.standard.set(selectedTab.rawValue, forKey: "lastTab.\(connection.id.uuidString)") + } + } + var pendingQuery: String? + var tablesPath = NavigationPath() + + private(set) var queryHistory: [QueryHistoryItem] = [] + private let historyStorage = QueryHistoryStorage() + + private let appState: AppState + private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionCoordinator") + + enum ConnectionPhase: Sendable { + case connecting + case connected + case error(AppError) + } + + var displayName: String { + connection.name.isEmpty ? connection.host : connection.name + } + + var supportsDatabaseSwitching: Bool { + connection.type == .mysql || connection.type == .mariadb || + connection.type == .postgresql || connection.type == .redshift + } + + var supportsSchemas: Bool { + connection.type == .postgresql || connection.type == .redshift + } + + init(connection: DatabaseConnection, appState: AppState) { + self.connection = connection + self.appState = appState + } + + // MARK: - Persisted State + + func restorePersistedState() { + let key = connection.id.uuidString + if let savedTab = UserDefaults.standard.string(forKey: "lastTab.\(key)"), + let tab = ConnectedTab(rawValue: savedTab) { + selectedTab = tab + } + activeDatabase = UserDefaults.standard.string(forKey: "lastDB.\(key)") ?? "" + activeSchema = UserDefaults.standard.string(forKey: "lastSchema.\(key)") ?? "public" + } + + // MARK: - Connection Lifecycle + + private var isConnecting = false + + func connect() async { + guard !isConnecting, session == nil else { + if session != nil { phase = .connected } + return + } + + isConnecting = true + defer { isConnecting = false } + phase = .connecting + + if let existing = appState.connectionManager.session(for: connection.id) { + self.session = existing + do { + self.tables = try await existing.driver.fetchTables(schema: nil) + phase = .connected + await loadDatabases() + await loadSchemas() + navigateToPendingTable() + } catch { + self.session = nil + await appState.connectionManager.disconnect(connection.id) + await connectFresh() + } + return + } + + await connectFresh() + } + + private func connectFresh() async { + await appState.sshProvider.setPendingConnectionId(connection.id) + + do { + let newSession = try await appState.connectionManager.connect(connection) + self.session = newSession + self.tables = try await newSession.driver.fetchTables(schema: nil) + phase = .connected + await loadDatabases() + await loadSchemas() + navigateToPendingTable() + } catch { + let context = ErrorContext( + operation: "connect", + databaseType: connection.type, + host: connection.host, + sshEnabled: connection.sshEnabled + ) + phase = .error(ErrorClassifier.classify(error, context: context)) + } + } + + func reconnectIfNeeded() async { + guard let session, !isSwitching, !isReconnecting else { return } + isReconnecting = true + defer { isReconnecting = false } + do { + _ = try await session.driver.ping() + } catch { + do { + await appState.sshProvider.setPendingConnectionId(connection.id) + let newSession = try await appState.connectionManager.connect(connection) + self.session = newSession + } catch { + let context = ErrorContext( + operation: "reconnect", + databaseType: connection.type, + host: connection.host, + sshEnabled: connection.sshEnabled + ) + phase = .error(ErrorClassifier.classify(error, context: context)) + self.session = nil + } + } + } + + // MARK: - Database / Schema Switching + + func switchDatabase(to name: String) async { + guard let session, name != activeDatabase, !isSwitching else { return } + isSwitching = true + defer { isSwitching = false } + + if connection.type == .postgresql || connection.type == .redshift { + await reconnectWithDatabase(name) + } else { + do { + try await appState.connectionManager.switchDatabase(connection.id, to: name) + activeDatabase = name + UserDefaults.standard.set(name, forKey: "lastDB.\(connection.id.uuidString)") + self.tables = try await session.driver.fetchTables(schema: nil) + } catch { + failureAlertMessage = String(localized: "Failed to switch database") + showFailureAlert = true + } + } + } + + private func reconnectWithDatabase(_ database: String) async { + await appState.connectionManager.disconnect(connection.id) + self.session = nil + + var newConnection = connection + newConnection.database = database + + await appState.sshProvider.setPendingConnectionId(connection.id) + + do { + let newSession = try await appState.connectionManager.connect(newConnection) + self.session = newSession + self.tables = try await newSession.driver.fetchTables(schema: nil) + activeDatabase = database + UserDefaults.standard.set(database, forKey: "lastDB.\(connection.id.uuidString)") + await loadSchemas() + } catch { + Self.logger.error("Failed to switch to database \(database, privacy: .public): \(error.localizedDescription, privacy: .public)") + await appState.sshProvider.setPendingConnectionId(connection.id) + do { + let fallbackSession = try await appState.connectionManager.connect(connection) + self.session = fallbackSession + self.tables = try await fallbackSession.driver.fetchTables(schema: nil) + failureAlertMessage = String(localized: "Failed to switch database") + showFailureAlert = true + } catch { + let context = ErrorContext( + operation: "switchDatabase", + databaseType: connection.type, + host: connection.host, + sshEnabled: connection.sshEnabled + ) + phase = .error(ErrorClassifier.classify(error, context: context)) + self.session = nil + } + } + } + + func switchSchema(to name: String) async { + guard let session, name != activeSchema, !isSwitching else { return } + isSwitching = true + defer { isSwitching = false } + + do { + try await session.driver.switchSchema(to: name) + activeSchema = name + UserDefaults.standard.set(name, forKey: "lastSchema.\(connection.id.uuidString)") + self.tables = try await session.driver.fetchTables(schema: name) + } catch { + failureAlertMessage = String(localized: "Failed to switch schema") + showFailureAlert = true + } + } + + // MARK: - Tables + + func refreshTables() async { + guard let session else { return } + do { + let schema = supportsSchemas ? activeSchema : nil + self.tables = try await session.driver.fetchTables(schema: schema) + } catch { + Self.logger.warning("Failed to refresh tables: \(error.localizedDescription, privacy: .public)") + failureAlertMessage = String(localized: "Failed to refresh tables") + showFailureAlert = true + } + } + + // MARK: - Query History + + func loadHistory() { + queryHistory = historyStorage.load(for: connection.id) + } + + func addHistoryItem(_ item: QueryHistoryItem) { + historyStorage.save(item) + queryHistory.append(item) + } + + func deleteHistoryItem(_ id: UUID) { + historyStorage.delete(id) + queryHistory.removeAll { $0.id == id } + } + + func clearHistory() { + historyStorage.clearAll(for: connection.id) + queryHistory = [] + } + + private func navigateToPendingTable() { + guard let tableName = appState.pendingTableName, + let table = tables.first(where: { $0.name == tableName }) else { return } + appState.pendingTableName = nil + selectedTab = .tables + tablesPath.append(table) + } + + // MARK: - Private Helpers + + private func loadDatabases() async { + guard let session, supportsDatabaseSwitching else { return } + do { + databases = try await session.driver.fetchDatabases() + 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 + } + } catch { + // Silently fail + } + } + + private func loadSchemas() async { + guard let session, supportsSchemas else { return } + do { + schemas = try await session.driver.fetchSchemas() + 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 + } + } +} diff --git a/TableProMobile/TableProMobile/Models/ConnectedTab.swift b/TableProMobile/TableProMobile/Models/ConnectedTab.swift new file mode 100644 index 000000000..7f2b71ef2 --- /dev/null +++ b/TableProMobile/TableProMobile/Models/ConnectedTab.swift @@ -0,0 +1,11 @@ +// +// ConnectedTab.swift +// TableProMobile +// + +internal enum ConnectedTab: String, CaseIterable { + case tables + case query + case history + case settings +} diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index bb92990c5..a825f3694 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -49,6 +49,7 @@ struct TableProMobileApp: App { guard let connectionId = activity.userInfo?["connectionId"] as? String, let uuid = UUID(uuidString: connectionId) else { return } appState.pendingConnectionId = uuid + appState.pendingTableName = activity.userInfo?["tableName"] as? String } } .onChange(of: scenePhase) { _, phase in diff --git a/TableProMobile/TableProMobile/Views/Components/FilterSheetView.swift b/TableProMobile/TableProMobile/Views/Components/FilterSheetView.swift new file mode 100644 index 000000000..e6e9497dd --- /dev/null +++ b/TableProMobile/TableProMobile/Views/Components/FilterSheetView.swift @@ -0,0 +1,160 @@ +// +// FilterSheetView.swift +// TableProMobile +// + +import SwiftUI +import TableProModels + +struct FilterSheetView: View { + @Environment(\.dismiss) private var dismiss + @Binding var filters: [TableFilter] + @Binding var logicMode: FilterLogicMode + let columns: [ColumnInfo] + let onApply: () -> Void + let onClear: () -> Void + + @State private var draft: [TableFilter] = [] + @State private var draftLogicMode: FilterLogicMode = .and + @State private var showClearConfirmation = false + + private var hasValidFilters: Bool { + draft.contains { $0.isEnabled && $0.isValid } + } + + private func bindingForFilter(_ id: UUID) -> Binding? { + guard let index = draft.firstIndex(where: { $0.id == id }) else { return nil } + return $draft[index] + } + + var body: some View { + NavigationStack { + Form { + if draft.count > 1 { + Section { + Picker("Logic", selection: $draftLogicMode) { + Text("AND").tag(FilterLogicMode.and) + Text("OR").tag(FilterLogicMode.or) + } + .pickerStyle(.segmented) + } + } + + ForEach(draft) { filter in + if let binding = bindingForFilter(filter.id) { + Section { + Picker("Column", selection: binding.columnName) { + ForEach(columns, id: \.name) { col in + Text(col.name).tag(col.name) + } + } + + Picker("Operator", selection: binding.filterOperator) { + ForEach(FilterOperator.allCases, id: \.self) { op in + Text(op.displayName).tag(op) + } + } + + if filter.filterOperator.needsValue { + TextField("Value", text: binding.value) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + if filter.filterOperator == .between { + TextField("Second value", text: binding.secondValue) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + } + } + } + .onDelete { indexSet in + draft.remove(atOffsets: indexSet) + } + + Section { + Button { + draft.append(TableFilter(columnName: columns.first?.name ?? "")) + } label: { + Label("Add Filter", systemImage: "plus.circle") + } + } + + if !draft.isEmpty { + Section { + Button("Clear All Filters", role: .destructive) { + showClearConfirmation = true + } + } + } + } + .navigationTitle("Filters") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Apply") { + filters = draft + logicMode = draftLogicMode + onApply() + dismiss() + } + .disabled(!hasValidFilters) + } + } + .onAppear { + draft = filters + draftLogicMode = logicMode + } + .confirmationDialog( + String(localized: "Clear All Filters"), + isPresented: $showClearConfirmation, + titleVisibility: .visible + ) { + Button(String(localized: "Clear All"), role: .destructive) { + filters.removeAll() + logicMode = .and + onClear() + dismiss() + } + } message: { + Text("All filter conditions will be removed.") + } + } + } +} + +// MARK: - Filter Operator Display + +extension FilterOperator { + var displayName: String { + switch self { + case .equal: return "equals" + case .notEqual: return "not equals" + case .greaterThan: return "greater than" + case .greaterThanOrEqual: return "≥" + case .lessThan: return "less than" + case .lessThanOrEqual: return "≤" + case .like: return "like" + case .notLike: return "not like" + case .isNull: return "is null" + case .isNotNull: return "is not null" + case .in: return "in" + case .notIn: return "not in" + case .between: return "between" + case .contains: return "contains" + case .startsWith: return "starts with" + case .endsWith: return "ends with" + } + } + + var needsValue: Bool { + switch self { + case .isNull, .isNotNull: return false + default: return true + } + } +} diff --git a/TableProMobile/TableProMobile/Views/Components/RowCard.swift b/TableProMobile/TableProMobile/Views/Components/RowCard.swift new file mode 100644 index 000000000..f051a87e1 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/Components/RowCard.swift @@ -0,0 +1,73 @@ +// +// RowCard.swift +// TableProMobile +// + +import SwiftUI +import TableProModels + +struct RowCard: View { + let columns: [ColumnInfo] + let columnDetails: [ColumnInfo] + let row: [String?] + + private static let maxPreview = 4 + + private var pkNames: Set { + Set(columnDetails.filter(\.isPrimaryKey).map(\.name)) + } + + private var titlePair: (name: String, value: String)? { + let pks = pkNames + for (col, val) in zip(columns, row) where pks.contains(col.name) { + return (col.name, val ?? "NULL") + } + guard let first = columns.first else { return nil } + return (first.name, row.first.flatMap { $0 } ?? "NULL") + } + + private var detailPairs: [(name: String, value: String)] { + let pks = pkNames + let title = titlePair?.name + return zip(columns, row) + .filter { !pks.contains($0.0.name) && $0.0.name != title } + .prefix(Self.maxPreview - 1) + .map { ($0.0.name, $0.1 ?? "NULL") } + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + if let title = titlePair { + HStack(spacing: 6) { + Text(title.name) + .font(.caption2) + .foregroundStyle(.tertiary) + Text(verbatim: title.value) + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + } + } + + ForEach(Array(detailPairs.enumerated()), id: \.offset) { _, pair in + HStack(spacing: 6) { + Text(pair.name) + .font(.caption) + .foregroundStyle(.tertiary) + Text(verbatim: pair.value) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + if columns.count > Self.maxPreview { + Text("+\(columns.count - Self.maxPreview) more columns") + .font(.caption2) + .foregroundStyle(.quaternary) + } + } + .padding(.vertical, 2) + .accessibilityElement(children: .combine) + } +} diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index 8ebca3db8..b9c6edce1 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -11,445 +11,148 @@ import TableProModels struct ConnectedView: View { @Environment(AppState.self) private var appState @Environment(\.scenePhase) private var scenePhase + @Environment(\.dismiss) private var dismiss let connection: DatabaseConnection - private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectedView") - - @State private var session: ConnectionSession? - @State private var tables: [TableInfo] = [] - @State private var isConnecting = true - @State private var appError: AppError? - @State private var failureAlertMessage: String? - @State private var showFailureAlert = false - @State private var selectedTab: ConnectedTab = .tables - @State private var queryHistory: [QueryHistoryItem] = [] - @State private var historyStorage = QueryHistoryStorage() - @State private var databases: [String] = [] - @State private var activeDatabase: String = "" - @State private var schemas: [String] = [] - @State private var activeSchema: String = "public" - @State private var isSwitching = false - @State private var isReconnecting = false + @State private var coordinator: ConnectionCoordinator? @State private var hapticSuccess = false @State private var hapticError = false - @Environment(\.dismiss) private var dismiss - - enum ConnectedTab: String, CaseIterable { - case tables = "Tables" - case query = "Query" - } - - private var displayName: String { - connection.name.isEmpty ? connection.host : connection.name - } - - private var supportsDatabaseSwitching: Bool { - connection.type == .mysql || connection.type == .mariadb || - connection.type == .postgresql || connection.type == .redshift - } - - private var supportsSchemas: Bool { - connection.type == .postgresql || connection.type == .redshift - } - var body: some View { Group { - if isConnecting { - VStack(spacing: 16) { - ProgressView { - Text(String(format: String(localized: "Connecting to %@..."), displayName)) + if let coordinator { + switch coordinator.phase { + case .connecting: + connectingView + case .error(let error): + ErrorView(error: error) { + await coordinator.connect() } - Button(String(localized: "Cancel")) { - dismiss() - } - .buttonStyle(.bordered) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let appError { - ErrorView(error: appError) { - await connect() + case .connected: + connectedContent(coordinator) } } else { - connectedContent - .userActivity("com.TablePro.viewConnection") { activity in - activity.title = connection.name.isEmpty ? connection.host : connection.name - activity.isEligibleForHandoff = true - activity.userInfo = ["connectionId": connection.id.uuidString] - } - .allowsHitTesting(!isSwitching) - .overlay { - if isSwitching { - ZStack { - Rectangle() - .fill(.ultraThinMaterial) - .ignoresSafeArea() - ProgressView() - .controlSize(.large) - } - .transition(.opacity) - } - } - .animation(.default, value: isSwitching) - .overlay(alignment: .top) { - if isReconnecting { - HStack(spacing: 6) { - ProgressView() - .controlSize(.mini) - Text(String(localized: "Reconnecting...")) - .font(.caption) - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(.regularMaterial, in: Capsule()) - .transition(.move(edge: .top).combined(with: .opacity)) - .padding(.top, 4) - } - } - .animation(.default, value: isReconnecting) - } - } - .sensoryFeedback(.success, trigger: hapticSuccess) - .sensoryFeedback(.error, trigger: hapticError) - .alert("Error", isPresented: $showFailureAlert) { - Button("OK", role: .cancel) {} - } message: { - Text(failureAlertMessage ?? "") - } - .navigationTitle(supportsDatabaseSwitching && databases.count > 1 ? "" : displayName) - .navigationBarTitleDisplayMode(.inline) - .safeAreaInset(edge: .top) { - Picker("Tab", selection: $selectedTab) { - Text("Tables").tag(ConnectedTab.tables) - Text("Query").tag(ConnectedTab.query) - } - .pickerStyle(.segmented) - .padding(.horizontal) - .padding(.vertical, 8) - } - .background { - Button("") { selectedTab = .tables } - .keyboardShortcut("1", modifiers: .command) - .hidden() - Button("") { selectedTab = .query } - .keyboardShortcut("2", modifiers: .command) - .hidden() - } - .toolbar { - if connection.safeModeLevel != .off { - ToolbarItem(placement: .topBarTrailing) { - Image(systemName: connection.safeModeLevel == .readOnly ? "lock.fill" : "shield.fill") - .foregroundStyle(connection.safeModeLevel == .readOnly ? .red : .orange) - .font(.caption) - } - } - if supportsDatabaseSwitching && databases.count > 1 { - ToolbarItem(placement: .topBarLeading) { - Menu { - ForEach(databases, id: \.self) { db in - Button { - Task { await switchDatabase(to: db) } - } label: { - if db == activeDatabase { - Label(db, systemImage: "checkmark") - } else { - Text(db) - } - } - } - } label: { - HStack(spacing: 4) { - Text(activeDatabase) - .font(.subheadline) - if isSwitching { - ProgressView() - .controlSize(.mini) - } else { - Image(systemName: "chevron.down") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - } - .disabled(isSwitching) - } - } - if supportsSchemas && schemas.count > 1 && selectedTab == .tables { - ToolbarItem(placement: .topBarTrailing) { - Menu { - ForEach(schemas, id: \.self) { schema in - Button { - Task { await switchSchema(to: schema) } - } label: { - if schema == activeSchema { - Label(schema, systemImage: "checkmark") - } else { - Text(schema) - } - } - } - } label: { - Label(activeSchema, systemImage: "square.3.layers.3d") - .font(.subheadline) - } - .disabled(isSwitching) - } + connectingView } } .task { - restorePersistedState() - await connect() + let c = ConnectionCoordinator(connection: connection, appState: appState) + coordinator = c + c.restorePersistedState() + await c.connect() if !Task.isCancelled { - queryHistory = historyStorage.load(for: connection.id) + c.loadHistory() + hapticSuccess.toggle() } } - .onChange(of: selectedTab) { _, newValue in - UserDefaults.standard.set(newValue.rawValue, forKey: "lastTab.\(connection.id.uuidString)") - } - .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() } - } - } - } - - private var connectedContent: some View { - VStack(spacing: 0) { - switch selectedTab { - case .tables: - TableListView( - connection: connection, - tables: tables, - session: session, - onRefresh: { await refreshTables() } - ) - case .query: - QueryEditorView( - session: session, - tables: tables, - databaseType: connection.type, - safeModeLevel: connection.safeModeLevel, - queryHistory: $queryHistory, - connectionId: connection.id, - historyStorage: historyStorage - ) + if phase == .active { + Task { await coordinator?.reconnectIfNeeded() } } } + .sensoryFeedback(.success, trigger: hapticSuccess) + .sensoryFeedback(.error, trigger: hapticError) } - private func restorePersistedState() { - let key = connection.id.uuidString - if let savedTab = UserDefaults.standard.string(forKey: "lastTab.\(key)"), - let tab = ConnectedTab(rawValue: savedTab) { - selectedTab = tab - } - activeDatabase = UserDefaults.standard.string(forKey: "lastDB.\(key)") ?? "" - activeSchema = UserDefaults.standard.string(forKey: "lastSchema.\(key)") ?? "public" - } - - private func connect() async { - guard session == nil else { - isConnecting = false - return - } - - isConnecting = true - appError = nil + // MARK: - Connecting - if let existing = appState.connectionManager.session(for: connection.id) { - self.session = existing - do { - self.tables = try await existing.driver.fetchTables(schema: nil) - isConnecting = false - await loadDatabases() - await loadSchemas() - } catch { - self.session = nil - await appState.connectionManager.disconnect(connection.id) - await connectFresh() + private var connectingView: some View { + VStack(spacing: 16) { + ProgressView { + Text(String(format: String(localized: "Connecting to %@..."), + connection.name.isEmpty ? connection.host : connection.name)) } - return + Button(String(localized: "Cancel")) { + dismiss() + } + .buttonStyle(.bordered) } - - await connectFresh() + .frame(maxWidth: .infinity, maxHeight: .infinity) } - private func connectFresh() async { - await appState.sshProvider.setPendingConnectionId(connection.id) - - do { - let session = try await appState.connectionManager.connect(connection) - self.session = session - self.tables = try await session.driver.fetchTables(schema: nil) - isConnecting = false - hapticSuccess.toggle() - await loadDatabases() - await loadSchemas() - } catch { - let context = ErrorContext( - operation: "connect", - databaseType: connection.type, - host: connection.host, - sshEnabled: connection.sshEnabled - ) - appError = ErrorClassifier.classify(error, context: context) - isConnecting = false - hapticError.toggle() - } - } + // MARK: - Connected Content - private func reconnectIfNeeded() async { - guard let session, !isSwitching, !isReconnecting else { return } - isReconnecting = true - defer { isReconnecting = false } - do { - _ = try await session.driver.ping() - } catch { - // Connection lost — reconnect - do { - await appState.sshProvider.setPendingConnectionId(connection.id) - let newSession = try await appState.connectionManager.connect(connection) - self.session = newSession - } catch { - let context = ErrorContext( - operation: "reconnect", - databaseType: connection.type, - host: connection.host, - sshEnabled: connection.sshEnabled - ) - appError = ErrorClassifier.classify(error, context: context) - self.session = nil + private func connectedContent(_ coordinator: ConnectionCoordinator) -> some View { + @Bindable var coordinator = coordinator + return TabView(selection: $coordinator.selectedTab) { + Tab("Tables", systemImage: "tablecells", value: .tables) { + NavigationStack(path: $coordinator.tablesPath) { + TableListView() + .navigationTitle(coordinator.displayName) + .navigationBarTitleDisplayMode(.inline) + } } - } - } - - private func loadDatabases() async { - guard let session, supportsDatabaseSwitching else { return } - do { - databases = try await session.driver.fetchDatabases() - 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) + Tab("Query", systemImage: "terminal", value: .query) { + NavigationStack { + QueryEditorView() } - } else if let stored = appState.connectionManager.session(for: connection.id) { - activeDatabase = stored.activeDatabase - } else { - activeDatabase = connection.database } - } catch { - // Silently fail — just don't show picker - } - } - - private func loadSchemas() async { - guard let session, supportsSchemas else { return } - do { - schemas = try await session.driver.fetchSchemas() - 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 + Tab("History", systemImage: "clock", value: .history) { + NavigationStack { + QueryHistoryView() + } + } + Tab("Settings", systemImage: "gear", value: .settings) { + NavigationStack { + SettingsView() + } } - } catch { - // Silently fail — don't show picker } - } - - private func switchSchema(to name: String) async { - guard let session, name != activeSchema, !isSwitching else { return } - isSwitching = true - defer { isSwitching = false } - - do { - try await session.driver.switchSchema(to: name) - activeSchema = name - self.tables = try await session.driver.fetchTables(schema: name) - } catch { - failureAlertMessage = String(localized: "Failed to switch schema") - showFailureAlert = true + .tabViewStyle(.sidebarAdaptable) + .environment(coordinator as ConnectionCoordinator) + .background { + Button("") { coordinator.selectedTab = .tables } + .keyboardShortcut("1", modifiers: .command) + .hidden() + Button("") { coordinator.selectedTab = .query } + .keyboardShortcut("2", modifiers: .command) + .hidden() + Button("") { coordinator.selectedTab = .history } + .keyboardShortcut("3", modifiers: .command) + .hidden() + Button("") { coordinator.selectedTab = .settings } + .keyboardShortcut("4", modifiers: .command) + .hidden() } - } - - private func switchDatabase(to name: String) async { - guard let session, name != activeDatabase, !isSwitching else { return } - isSwitching = true - defer { isSwitching = false } - - if connection.type == .postgresql || connection.type == .redshift { - await reconnectWithDatabase(name) - } else { - do { - try await appState.connectionManager.switchDatabase(connection.id, to: name) - activeDatabase = name - self.tables = try await session.driver.fetchTables(schema: nil) - } catch { - failureAlertMessage = String(localized: "Failed to switch database") - showFailureAlert = true + .overlay(alignment: .top) { + if coordinator.isReconnecting { + HStack(spacing: 6) { + ProgressView() + .controlSize(.mini) + Text(String(localized: "Reconnecting...")) + .font(.caption) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.regularMaterial, in: Capsule()) + .transition(.move(edge: .top).combined(with: .opacity)) + .padding(.top, 4) } } - } - - private func reconnectWithDatabase(_ database: String) async { - await appState.connectionManager.disconnect(connection.id) - self.session = nil - - var newConnection = connection - newConnection.database = database - - await appState.sshProvider.setPendingConnectionId(connection.id) - - do { - let newSession = try await appState.connectionManager.connect(newConnection) - self.session = newSession - self.tables = try await newSession.driver.fetchTables(schema: nil) - activeDatabase = database - await loadSchemas() - } catch { - // Reconnect to original database as fallback - Self.logger.error("Failed to switch to database \(database, privacy: .public): \(error.localizedDescription, privacy: .public)") - await appState.sshProvider.setPendingConnectionId(connection.id) - do { - let fallbackSession = try await appState.connectionManager.connect(connection) - self.session = fallbackSession - self.tables = try await fallbackSession.driver.fetchTables(schema: nil) - failureAlertMessage = String(localized: "Failed to switch database") - showFailureAlert = true - } catch { - // Both failed — show error view - let context = ErrorContext( - operation: "switchDatabase", - databaseType: connection.type, - host: connection.host, - sshEnabled: connection.sshEnabled - ) - appError = ErrorClassifier.classify(error, context: context) - self.session = nil + .animation(.default, value: coordinator.isReconnecting) + .allowsHitTesting(!coordinator.isSwitching) + .overlay { + if coordinator.isSwitching { + ZStack { + Rectangle() + .fill(.ultraThinMaterial) + .ignoresSafeArea() + ProgressView() + .controlSize(.large) + } + .transition(.opacity) } } - } - - private func refreshTables() async { - guard let session else { return } - do { - let schema = supportsSchemas ? activeSchema : nil - self.tables = try await session.driver.fetchTables(schema: schema) - } catch { - Self.logger.warning("Failed to refresh tables: \(error.localizedDescription, privacy: .public)") - failureAlertMessage = String(localized: "Failed to refresh tables") - showFailureAlert = true + .animation(.default, value: coordinator.isSwitching) + .alert("Error", isPresented: $coordinator.showFailureAlert) { + Button("OK", role: .cancel) {} + } message: { + Text(coordinator.failureAlertMessage ?? "") + } + .userActivity("com.TablePro.viewConnection") { activity in + activity.title = connection.name.isEmpty ? connection.host : connection.name + activity.isEligibleForHandoff = true + activity.userInfo = ["connectionId": connection.id.uuidString] } } } diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 75057e9c9..c95ffaf5b 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -122,17 +122,15 @@ struct ConnectionListView: View { navigateToPendingConnection(appState.pendingConnectionId) } } detail: { - NavigationStack { - if let connection = selectedConnection { - ConnectedView(connection: connection) - .id(connection.id) - } else { - ContentUnavailableView( - "Select a Connection", - systemImage: "server.rack", - description: Text("Choose a connection from the sidebar.") - ) - } + if let connection = selectedConnection { + ConnectedView(connection: connection) + .id(connection.id) + } else { + ContentUnavailableView( + "Select a Connection", + systemImage: "server.rack", + description: Text("Choose a connection from the sidebar.") + ) } } .sheet(isPresented: $showingAddConnection) { diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 4e4d0968d..e3568a854 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -10,12 +10,14 @@ import TableProModels import TableProQuery struct DataBrowserView: View { - let connection: DatabaseConnection + @Environment(ConnectionCoordinator.self) private var coordinator let table: TableInfo - let session: ConnectionSession? private static let logger = Logger(subsystem: "com.TablePro", category: "DataBrowserView") + private var connection: DatabaseConnection { coordinator.connection } + private var session: ConnectionSession? { coordinator.session } + @State private var columns: [ColumnInfo] = [] @State private var columnDetails: [ColumnInfo] = [] @State private var rows: [[String?]] = [] @@ -686,225 +688,3 @@ struct DataBrowserView: View { } } -// MARK: - Filter Sheet - -private struct FilterSheetView: View { - @Environment(\.dismiss) private var dismiss - @Binding var filters: [TableFilter] - @Binding var logicMode: FilterLogicMode - let columns: [ColumnInfo] - let onApply: () -> Void - let onClear: () -> Void - - @State private var draft: [TableFilter] = [] - @State private var draftLogicMode: FilterLogicMode = .and - @State private var showClearConfirmation = false - - private var hasValidFilters: Bool { - draft.contains { $0.isEnabled && $0.isValid } - } - - private func bindingForFilter(_ id: UUID) -> Binding? { - guard let index = draft.firstIndex(where: { $0.id == id }) else { return nil } - return $draft[index] - } - - var body: some View { - NavigationStack { - Form { - if draft.count > 1 { - Section { - Picker("Logic", selection: $draftLogicMode) { - Text("AND").tag(FilterLogicMode.and) - Text("OR").tag(FilterLogicMode.or) - } - .pickerStyle(.segmented) - } - } - - ForEach(draft) { filter in - if let binding = bindingForFilter(filter.id) { - Section { - Picker("Column", selection: binding.columnName) { - ForEach(columns, id: \.name) { col in - Text(col.name).tag(col.name) - } - } - - Picker("Operator", selection: binding.filterOperator) { - ForEach(FilterOperator.allCases, id: \.self) { op in - Text(op.displayName).tag(op) - } - } - - if filter.filterOperator.needsValue { - TextField("Value", text: binding.value) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - } - - if filter.filterOperator == .between { - TextField("Second value", text: binding.secondValue) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - } - } - } - } - .onDelete { indexSet in - draft.remove(atOffsets: indexSet) - } - - Section { - Button { - draft.append(TableFilter(columnName: columns.first?.name ?? "")) - } label: { - Label("Add Filter", systemImage: "plus.circle") - } - } - - if !draft.isEmpty { - Section { - Button("Clear All Filters", role: .destructive) { - showClearConfirmation = true - } - } - } - } - .navigationTitle("Filters") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button("Apply") { - filters = draft - logicMode = draftLogicMode - onApply() - dismiss() - } - .disabled(!hasValidFilters) - } - } - .onAppear { - draft = filters - draftLogicMode = logicMode - } - .confirmationDialog( - String(localized: "Clear All Filters"), - isPresented: $showClearConfirmation, - titleVisibility: .visible - ) { - Button(String(localized: "Clear All"), role: .destructive) { - filters.removeAll() - logicMode = .and - onClear() - dismiss() - } - } message: { - Text("All filter conditions will be removed.") - } - } - } -} - -// MARK: - Filter Operator Display - -extension FilterOperator { - var displayName: String { - switch self { - case .equal: return "equals" - case .notEqual: return "not equals" - case .greaterThan: return "greater than" - case .greaterThanOrEqual: return "≥" - case .lessThan: return "less than" - case .lessThanOrEqual: return "≤" - case .like: return "like" - case .notLike: return "not like" - case .isNull: return "is null" - case .isNotNull: return "is not null" - case .in: return "in" - case .notIn: return "not in" - case .between: return "between" - case .contains: return "contains" - case .startsWith: return "starts with" - case .endsWith: return "ends with" - } - } - - var needsValue: Bool { - switch self { - case .isNull, .isNotNull: return false - default: return true - } - } -} - -// MARK: - Row Card - -private struct RowCard: View { - let columns: [ColumnInfo] - let columnDetails: [ColumnInfo] - let row: [String?] - - private static let maxPreview = 4 - - private var pkNames: Set { - Set(columnDetails.filter(\.isPrimaryKey).map(\.name)) - } - - private var titlePair: (name: String, value: String)? { - let pks = pkNames - for (col, val) in zip(columns, row) where pks.contains(col.name) { - return (col.name, val ?? "NULL") - } - guard let first = columns.first else { return nil } - return (first.name, row.first.flatMap { $0 } ?? "NULL") - } - - private var detailPairs: [(name: String, value: String)] { - let pks = pkNames - let title = titlePair?.name - return zip(columns, row) - .filter { !pks.contains($0.0.name) && $0.0.name != title } - .prefix(Self.maxPreview - 1) - .map { ($0.0.name, $0.1 ?? "NULL") } - } - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - if let title = titlePair { - HStack(spacing: 6) { - Text(title.name) - .font(.caption2) - .foregroundStyle(.tertiary) - Text(verbatim: title.value) - .font(.subheadline) - .fontWeight(.medium) - .lineLimit(1) - } - } - - ForEach(Array(detailPairs.enumerated()), id: \.offset) { _, pair in - HStack(spacing: 6) { - Text(pair.name) - .font(.caption) - .foregroundStyle(.tertiary) - Text(verbatim: pair.value) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - - if columns.count > Self.maxPreview { - Text("+\(columns.count - Self.maxPreview) more columns") - .font(.caption2) - .foregroundStyle(.quaternary) - } - } - .padding(.vertical, 2) - .accessibilityElement(children: .combine) - } -} diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 6673f3540..244496d88 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -9,11 +9,7 @@ import TableProDatabase import TableProModels struct QueryEditorView: View { - let session: ConnectionSession? - var tables: [TableInfo] = [] - var initialQuery: String = "" - var databaseType: DatabaseType = .sqlite - var safeModeLevel: SafeModeLevel = .off + @Environment(ConnectionCoordinator.self) private var coordinator private static let logger = Logger(subsystem: "com.TablePro", category: "QueryEditorView") @@ -25,11 +21,6 @@ struct QueryEditorView: View { @State private var executeTask: Task? @State private var saveQueryTask: Task? @State private var executionStartTime: Date? - @Binding var queryHistory: [QueryHistoryItem] - let connectionId: UUID - let historyStorage: QueryHistoryStorage - @State private var showHistory = false - @State private var showClearHistoryConfirmation = false @State private var showWriteConfirmation = false @State private var showWriteBlockedAlert = false @State private var pendingWriteQuery = "" @@ -38,16 +29,26 @@ struct QueryEditorView: View { @State private var shareText = "" @State private var hapticSuccess = false @State private var hapticError = false + + private var session: ConnectionSession? { coordinator.session } + private var tables: [TableInfo] { coordinator.tables } + private var databaseType: DatabaseType { coordinator.connection.type } + private var safeModeLevel: SafeModeLevel { coordinator.connection.safeModeLevel } + private var connectionId: UUID { coordinator.connection.id } + var body: some View { VStack(spacing: 0) { editorSection Divider() resultSection } + .navigationTitle("Query") + .navigationBarTitleDisplayMode(.inline) .toolbar { toolbarContent } .onAppear { - if !initialQuery.isEmpty { - query = initialQuery + if let pending = coordinator.pendingQuery { + query = pending + coordinator.pendingQuery = nil } else if query.isEmpty { query = UserDefaults.standard.string(forKey: "lastQuery.\(connectionId.uuidString)") ?? "" } @@ -60,6 +61,12 @@ struct QueryEditorView: View { UserDefaults.standard.set(newValue, forKey: "lastQuery.\(connectionId.uuidString)") } } + .onChange(of: coordinator.pendingQuery) { _, newQuery in + if let newQuery { + query = newQuery + coordinator.pendingQuery = nil + } + } .alert("Write Query Blocked", isPresented: $showWriteBlockedAlert) { Button("OK", role: .cancel) {} } message: { @@ -77,7 +84,6 @@ struct QueryEditorView: View { .sheet(isPresented: $showShareSheet) { ActivityViewController(items: [shareText]) } - .sheet(isPresented: $showHistory) { historySheet } .confirmationDialog( String(localized: "Clear Query"), isPresented: $showClearConfirmation, @@ -275,11 +281,10 @@ struct QueryEditorView: View { ToolbarItem(placement: .topBarTrailing) { Menu { Button { - showHistory = true + coordinator.selectedTab = .history } label: { Label("History", systemImage: "clock") } - .disabled(queryHistory.isEmpty) if !tables.isEmpty { Menu { @@ -336,69 +341,6 @@ struct QueryEditorView: View { } } - // MARK: - History - - private var historySheet: some View { - NavigationStack { - List { - ForEach(queryHistory.reversed()) { item in - Button { - query = item.query - showHistory = false - } label: { - VStack(alignment: .leading, spacing: 4) { - Text(verbatim: item.query) - .font(.system(.footnote, design: .monospaced)) - .lineLimit(3) - .foregroundStyle(.primary) - Text(item.timestamp, style: .relative) - .font(.caption2) - .foregroundStyle(.tertiary) - } - } - } - .onDelete { indexSet in - let reversed = queryHistory.reversed().map(\.id) - for index in indexSet { - historyStorage.delete(reversed[index]) - } - queryHistory = historyStorage.load(for: connectionId) - } - - if !queryHistory.isEmpty { - Section { - Button("Clear All History", role: .destructive) { - showClearHistoryConfirmation = true - } - } - } - } - .listStyle(.insetGrouped) - .navigationTitle("Query History") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { showHistory = false } - } - } - .confirmationDialog("Clear History", isPresented: $showClearHistoryConfirmation) { - Button("Clear All", role: .destructive) { - historyStorage.clearAll(for: connectionId) - queryHistory = [] - } - } - .overlay { - if queryHistory.isEmpty { - ContentUnavailableView( - "No History", - systemImage: "clock", - description: Text("Executed queries will appear here.") - ) - } - } - } - } - // MARK: - Execution private func isWriteQuery(_ sql: String) -> Bool { @@ -446,8 +388,7 @@ struct QueryEditorView: View { hapticSuccess.toggle() let item = QueryHistoryItem(query: trimmed, connectionId: connectionId) - historyStorage.save(item) - queryHistory = historyStorage.load(for: connectionId) + coordinator.addHistoryItem(item) } catch { let context = ErrorContext(operation: "executeQuery") self.appError = ErrorClassifier.classify(error, context: context) diff --git a/TableProMobile/TableProMobile/Views/QueryHistoryView.swift b/TableProMobile/TableProMobile/Views/QueryHistoryView.swift new file mode 100644 index 000000000..b9fd72a32 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/QueryHistoryView.swift @@ -0,0 +1,71 @@ +// +// QueryHistoryView.swift +// TableProMobile +// + +import SwiftUI +import UIKit + +struct QueryHistoryView: View { + @Environment(ConnectionCoordinator.self) private var coordinator + @State private var showClearConfirmation = false + + var body: some View { + List { + ForEach(coordinator.queryHistory.reversed()) { item in + Button { + coordinator.pendingQuery = item.query + coordinator.selectedTab = .query + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: item.query) + .font(.system(.footnote, design: .monospaced)) + .lineLimit(3) + .foregroundStyle(.primary) + Text(item.timestamp, style: .relative) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .contextMenu { + Button { + UIPasteboard.general.string = item.query + } label: { + Label("Copy Query", systemImage: "doc.on.doc") + } + } + } + .onDelete { indexSet in + let reversed = Array(coordinator.queryHistory.reversed()) + for index in indexSet { + coordinator.deleteHistoryItem(reversed[index].id) + } + } + + if !coordinator.queryHistory.isEmpty { + Section { + Button("Clear All History", role: .destructive) { + showClearConfirmation = true + } + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("History") + .navigationBarTitleDisplayMode(.inline) + .confirmationDialog("Clear History", isPresented: $showClearConfirmation) { + Button("Clear All", role: .destructive) { + coordinator.clearHistory() + } + } + .overlay { + if coordinator.queryHistory.isEmpty { + ContentUnavailableView( + "No History", + systemImage: "clock", + description: Text("Executed queries will appear here.") + ) + } + } + } +} diff --git a/TableProMobile/TableProMobile/Views/TableListView.swift b/TableProMobile/TableProMobile/Views/TableListView.swift index a020138d9..4a450fac5 100644 --- a/TableProMobile/TableProMobile/Views/TableListView.swift +++ b/TableProMobile/TableProMobile/Views/TableListView.swift @@ -8,10 +8,11 @@ import TableProDatabase import TableProModels struct TableListView: View { - let connection: DatabaseConnection - let tables: [TableInfo] - let session: ConnectionSession? - var onRefresh: (() async -> Void)? + @Environment(ConnectionCoordinator.self) private var coordinator + + private var connection: DatabaseConnection { coordinator.connection } + private var tables: [TableInfo] { coordinator.tables } + private var session: ConnectionSession? { coordinator.session } @State private var searchText = "" @State private var tableToTruncate: TableInfo? @@ -101,16 +102,13 @@ struct TableListView: View { } .listStyle(.insetGrouped) .searchable(text: $searchText, prompt: "Search tables") + .toolbar { connectionToolbar } .textInputAutocapitalization(.never) .refreshable { - await onRefresh?() + await coordinator.refreshTables() } .navigationDestination(for: TableInfo.self) { table in - DataBrowserView( - connection: connection, - table: table, - session: session - ) + DataBrowserView(table: table) } .overlay { if tables.isEmpty { @@ -134,7 +132,7 @@ struct TableListView: View { do { let quoted = SQLBuilder.quoteIdentifier(table.name, for: connection.type) _ = try await session?.driver.execute(query: "TRUNCATE TABLE \(quoted)") - await onRefresh?() + await coordinator.refreshTables() } catch { errorMessage = error.localizedDescription showError = true @@ -158,7 +156,7 @@ struct TableListView: View { do { let quoted = SQLBuilder.quoteIdentifier(table.name, for: connection.type) _ = try await session?.driver.execute(query: "DROP TABLE \(quoted)") - await onRefresh?() + await coordinator.refreshTables() } catch { errorMessage = error.localizedDescription showError = true @@ -177,6 +175,71 @@ struct TableListView: View { Text(errorMessage) } } + + // MARK: - Connection Toolbar + + @ToolbarContentBuilder + private var connectionToolbar: some ToolbarContent { + if connection.safeModeLevel != .off { + ToolbarItem(placement: .topBarTrailing) { + Image(systemName: connection.safeModeLevel == .readOnly ? "lock.fill" : "shield.fill") + .foregroundStyle(connection.safeModeLevel == .readOnly ? .red : .orange) + .font(.caption) + } + } + if coordinator.supportsDatabaseSwitching && coordinator.databases.count > 1 { + ToolbarItem(placement: .topBarLeading) { + Menu { + ForEach(coordinator.databases, id: \.self) { db in + Button { + Task { await coordinator.switchDatabase(to: db) } + } label: { + if db == coordinator.activeDatabase { + Label(db, systemImage: "checkmark") + } else { + Text(db) + } + } + } + } label: { + HStack(spacing: 4) { + Text(coordinator.activeDatabase) + .font(.subheadline) + if coordinator.isSwitching { + ProgressView() + .controlSize(.mini) + } else { + Image(systemName: "chevron.down") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + .disabled(coordinator.isSwitching) + } + } + if coordinator.supportsSchemas && coordinator.schemas.count > 1 { + ToolbarItem(placement: .topBarTrailing) { + Menu { + ForEach(coordinator.schemas, id: \.self) { schema in + Button { + Task { await coordinator.switchSchema(to: schema) } + } label: { + if schema == coordinator.activeSchema { + Label(schema, systemImage: "checkmark") + } else { + Text(schema) + } + } + } + } label: { + Label(coordinator.activeSchema, systemImage: "square.3.layers.3d") + .font(.subheadline) + } + .disabled(coordinator.isSwitching) + } + } + } } private struct TableRow: View { From 682f8c48571cb48c3b807077283136c36a18827e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 24 Apr 2026 13:12:16 +0700 Subject: [PATCH 2/8] docs: simplify iOS changelog entry --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 818e8b678..c5cbef6dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- iOS: replace segmented Picker with TabView(.sidebarAdaptable) for 4-tab navigation (Tables, Query, History, Settings) with per-tab NavigationStack and iPad sidebar support -- iOS: extract ConnectionCoordinator from ConnectedView, eliminating prop drilling across table and query views -- iOS: minimum deployment target raised to iOS 18 +- iOS: TabView with sidebar-adaptable navigation (Tables, Query, History, Settings), per-tab state preservation, iPad sidebar support (requires iOS 18) - Native macOS UI patterns: Picker(.menu) for cell editors, native alerts, native List selection, .navigationTitle for sheets, NSSearchField for welcome search, borderless toolbar buttons, chevron indicator on SET picker - Quit dialog defaults to Cancel on Return key - Connection form delete button moved to far left From 7bfce9090263a762fbfd57203da4b32e4338d78e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 24 Apr 2026 13:19:04 +0700 Subject: [PATCH 3/8] fix(ios): add safe mode indicator to Query tab toolbar, show connection name in nav title --- .../TableProMobile/Views/QueryEditorView.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 244496d88..3b0352559 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -42,7 +42,7 @@ struct QueryEditorView: View { Divider() resultSection } - .navigationTitle("Query") + .navigationTitle(coordinator.displayName) .navigationBarTitleDisplayMode(.inline) .toolbar { toolbarContent } .onAppear { @@ -263,6 +263,14 @@ struct QueryEditorView: View { @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { + if safeModeLevel != .off { + ToolbarItem(placement: .topBarLeading) { + Image(systemName: safeModeLevel == .readOnly ? "lock.fill" : "shield.fill") + .foregroundStyle(safeModeLevel == .readOnly ? .red : .orange) + .font(.caption) + } + } + ToolbarItem(placement: .primaryAction) { Button { if isExecuting { From 2e2be2a673a1ab98f3dd51be4b3388706d830237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 24 Apr 2026 13:23:35 +0700 Subject: [PATCH 4/8] fix(ios): haptic on error, deep link timing, stale session after DB switch, OSLog in catch blocks --- .../Coordinators/ConnectionCoordinator.swift | 14 +++++++++----- .../TableProMobile/Views/ConnectedView.swift | 10 +++++++--- .../TableProMobile/Views/TableListView.swift | 3 +++ 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index 01b3cea77..2b011fe8c 100644 --- a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -98,7 +98,6 @@ final class ConnectionCoordinator { phase = .connected await loadDatabases() await loadSchemas() - navigateToPendingTable() } catch { self.session = nil await appState.connectionManager.disconnect(connection.id) @@ -168,9 +167,14 @@ final class ConnectionCoordinator { } else { do { try await appState.connectionManager.switchDatabase(connection.id, to: name) + if let freshSession = appState.connectionManager.session(for: connection.id) { + self.session = freshSession + } activeDatabase = name UserDefaults.standard.set(name, forKey: "lastDB.\(connection.id.uuidString)") - self.tables = try await session.driver.fetchTables(schema: nil) + if let current = self.session { + self.tables = try await current.driver.fetchTables(schema: nil) + } } catch { failureAlertMessage = String(localized: "Failed to switch database") showFailureAlert = true @@ -267,7 +271,7 @@ final class ConnectionCoordinator { queryHistory = [] } - private func navigateToPendingTable() { + func navigateToPendingTable() { guard let tableName = appState.pendingTableName, let table = tables.first(where: { $0.name == tableName }) else { return } appState.pendingTableName = nil @@ -294,7 +298,7 @@ final class ConnectionCoordinator { activeDatabase = connection.database } } catch { - // Silently fail + Self.logger.warning("Failed to load databases: \(error.localizedDescription, privacy: .public)") } } @@ -311,7 +315,7 @@ final class ConnectionCoordinator { activeSchema = currentSchema } } catch { - // Silently fail + Self.logger.warning("Failed to load schemas: \(error.localizedDescription, privacy: .public)") } } } diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index b9c6edce1..037c5ece6 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -41,8 +41,12 @@ struct ConnectedView: View { c.restorePersistedState() await c.connect() if !Task.isCancelled { - c.loadHistory() - hapticSuccess.toggle() + if case .connected = c.phase { + c.loadHistory() + hapticSuccess.toggle() + } else if case .error = c.phase { + hapticError.toggle() + } } } .onChange(of: scenePhase) { _, phase in @@ -99,7 +103,7 @@ struct ConnectedView: View { } } .tabViewStyle(.sidebarAdaptable) - .environment(coordinator as ConnectionCoordinator) + .environment(coordinator) .background { Button("") { coordinator.selectedTab = .tables } .keyboardShortcut("1", modifiers: .command) diff --git a/TableProMobile/TableProMobile/Views/TableListView.swift b/TableProMobile/TableProMobile/Views/TableListView.swift index 4a450fac5..9f4e04cfb 100644 --- a/TableProMobile/TableProMobile/Views/TableListView.swift +++ b/TableProMobile/TableProMobile/Views/TableListView.swift @@ -110,6 +110,9 @@ struct TableListView: View { .navigationDestination(for: TableInfo.self) { table in DataBrowserView(table: table) } + .onAppear { + coordinator.navigateToPendingTable() + } .overlay { if tables.isEmpty { ContentUnavailableView( From e313237c880eb907b9e788d8bebf39c88cc6847f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 25 Apr 2026 17:56:38 +0700 Subject: [PATCH 5/8] wip --- CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f36d95eb6..447406e47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- iOS: sidebar-adaptable TabView navigation with per-tab state preservation + ## [0.35.0] - 2026-04-25 ### Added @@ -23,7 +27,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- iOS: TabView with sidebar-adaptable navigation (Tables, Query, History, Settings), per-tab state preservation, iPad sidebar support (requires iOS 18) - Native macOS UI: menu pickers, native alerts, native List selection, NSSearchField, borderless toolbar buttons - Quit dialog defaults to Cancel on Return key - Connection form delete button moved to far left @@ -34,7 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Missing confirmation on group deletion - Plugin principalClass resolved off main thread - Crash when scrolling AI Chat during streaming on macOS 15.x -- Connection failure on PostgreSQL-compatible databases (e.g., Aurora DSQL) that don't support `SET statement_timeout` +- Connection failure on PostgreSQL-compatible databases without `SET statement_timeout` - Schema-qualified table names resolve correctly in autocomplete - Alert dialogs use sheet attachment instead of bare modal From a92c57c954e701e666dcc710587e32ae0bc2e93e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 25 Apr 2026 18:33:26 +0700 Subject: [PATCH 6/8] wip --- TableProMobile/TableProMobile/Views/ConnectedView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index 037c5ece6..860a15ccc 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -85,16 +85,19 @@ struct ConnectedView: View { .navigationTitle(coordinator.displayName) .navigationBarTitleDisplayMode(.inline) } + .environment(coordinator) } Tab("Query", systemImage: "terminal", value: .query) { NavigationStack { QueryEditorView() } + .environment(coordinator) } Tab("History", systemImage: "clock", value: .history) { NavigationStack { QueryHistoryView() } + .environment(coordinator) } Tab("Settings", systemImage: "gear", value: .settings) { NavigationStack { @@ -103,7 +106,6 @@ struct ConnectedView: View { } } .tabViewStyle(.sidebarAdaptable) - .environment(coordinator) .background { Button("") { coordinator.selectedTab = .tables } .keyboardShortcut("1", modifiers: .command) From ea7ae20583c0d2fbe64f089815b63d92d9fe13ac Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 25 Apr 2026 22:58:33 +0700 Subject: [PATCH 7/8] refactor(ios): fix TabView navigation architecture inside NavigationSplitView - Replace per-tab NavigationStack with single NavigationStack wrapping TabView - Move toolbar and navigationDestination to NavigationStack level - Cache coordinators in ConnectionListView to preserve state across navigations - Load databases/schemas before setting phase to prevent toolbar flicker - Remove .tabViewStyle(.sidebarAdaptable) which blocked navigation preferences --- .../Coordinators/ConnectionCoordinator.swift | 4 +- .../TableProMobile/Views/ConnectedView.swift | 119 ++++++++++++++---- .../Views/ConnectionListView.swift | 7 +- .../TableProMobile/Views/TableListView.swift | 68 ---------- 4 files changed, 102 insertions(+), 96 deletions(-) diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index 2b011fe8c..3b3321fc8 100644 --- a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -95,9 +95,9 @@ final class ConnectionCoordinator { self.session = existing do { self.tables = try await existing.driver.fetchTables(schema: nil) - phase = .connected await loadDatabases() await loadSchemas() + phase = .connected } catch { self.session = nil await appState.connectionManager.disconnect(connection.id) @@ -116,9 +116,9 @@ final class ConnectionCoordinator { let newSession = try await appState.connectionManager.connect(connection) self.session = newSession self.tables = try await newSession.driver.fetchTables(schema: nil) - phase = .connected await loadDatabases() await loadSchemas() + phase = .connected navigateToPendingTable() } catch { let context = ErrorContext( diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index 860a15ccc..b41e45de8 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -3,7 +3,6 @@ // TableProMobile // -import os import SwiftUI import TableProDatabase import TableProModels @@ -13,6 +12,8 @@ struct ConnectedView: View { @Environment(\.scenePhase) private var scenePhase @Environment(\.dismiss) private var dismiss let connection: DatabaseConnection + let cachedCoordinator: ConnectionCoordinator? + let onCoordinatorCreated: (ConnectionCoordinator) -> Void @State private var coordinator: ConnectionCoordinator? @State private var hapticSuccess = false @@ -36,11 +37,18 @@ struct ConnectedView: View { } } .task { - let c = ConnectionCoordinator(connection: connection, appState: appState) - coordinator = c - c.restorePersistedState() - await c.connect() - if !Task.isCancelled { + if let cached = cachedCoordinator, cached.session != nil { + coordinator = cached + if case .connected = cached.phase { return } + await cached.connect() + } else { + let c = ConnectionCoordinator(connection: connection, appState: appState) + coordinator = c + onCoordinatorCreated(c) + c.restorePersistedState() + await c.connect() + } + if let c = coordinator, !Task.isCancelled { if case .connected = c.phase { c.loadHistory() hapticSuccess.toggle() @@ -78,34 +86,32 @@ struct ConnectedView: View { private func connectedContent(_ coordinator: ConnectionCoordinator) -> some View { @Bindable var coordinator = coordinator - return TabView(selection: $coordinator.selectedTab) { - Tab("Tables", systemImage: "tablecells", value: .tables) { - NavigationStack(path: $coordinator.tablesPath) { + return NavigationStack(path: $coordinator.tablesPath) { + TabView(selection: $coordinator.selectedTab) { + Tab("Tables", systemImage: "tablecells", value: .tables) { TableListView() - .navigationTitle(coordinator.displayName) - .navigationBarTitleDisplayMode(.inline) + .environment(coordinator) } - .environment(coordinator) - } - Tab("Query", systemImage: "terminal", value: .query) { - NavigationStack { + Tab("Query", systemImage: "terminal", value: .query) { QueryEditorView() + .environment(coordinator) } - .environment(coordinator) - } - Tab("History", systemImage: "clock", value: .history) { - NavigationStack { + Tab("History", systemImage: "clock", value: .history) { QueryHistoryView() + .environment(coordinator) } - .environment(coordinator) - } - Tab("Settings", systemImage: "gear", value: .settings) { - NavigationStack { + Tab("Settings", systemImage: "gear", value: .settings) { SettingsView() } } + .navigationTitle(coordinator.displayName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { connectionToolbar(coordinator) } + .navigationDestination(for: TableInfo.self) { table in + DataBrowserView(table: table) + .environment(coordinator) + } } - .tabViewStyle(.sidebarAdaptable) .background { Button("") { coordinator.selectedTab = .tables } .keyboardShortcut("1", modifiers: .command) @@ -161,4 +167,69 @@ struct ConnectedView: View { activity.userInfo = ["connectionId": connection.id.uuidString] } } + + // MARK: - Connection Toolbar + + @ToolbarContentBuilder + private func connectionToolbar(_ coordinator: ConnectionCoordinator) -> some ToolbarContent { + if connection.safeModeLevel != .off { + ToolbarItem(placement: .topBarTrailing) { + Image(systemName: connection.safeModeLevel == .readOnly ? "lock.fill" : "shield.fill") + .foregroundStyle(connection.safeModeLevel == .readOnly ? .red : .orange) + .font(.caption) + } + } + if coordinator.supportsDatabaseSwitching && coordinator.databases.count > 1 { + ToolbarItem(placement: .topBarLeading) { + Menu { + ForEach(coordinator.databases, id: \.self) { db in + Button { + Task { await coordinator.switchDatabase(to: db) } + } label: { + if db == coordinator.activeDatabase { + Label(db, systemImage: "checkmark") + } else { + Text(db) + } + } + } + } label: { + HStack(spacing: 4) { + Text(coordinator.activeDatabase) + .font(.subheadline) + if coordinator.isSwitching { + ProgressView() + .controlSize(.mini) + } else { + Image(systemName: "chevron.down") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + .disabled(coordinator.isSwitching) + } + } + if coordinator.supportsSchemas && coordinator.schemas.count > 1 { + ToolbarItem(placement: .topBarTrailing) { + Menu { + ForEach(coordinator.schemas, id: \.self) { schema in + Button { + Task { await coordinator.switchSchema(to: schema) } + } label: { + if schema == coordinator.activeSchema { + Label(schema, systemImage: "checkmark") + } else { + Text(schema) + } + } + } + } label: { + Label(coordinator.activeSchema, systemImage: "square.3.layers.3d") + .font(.subheadline) + } + .disabled(coordinator.isSwitching) + } + } + } } diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index c95ffaf5b..f8d30d9a9 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -21,6 +21,7 @@ struct ConnectionListView: View { @State private var editMode: EditMode = .inactive @State private var connectionToDelete: DatabaseConnection? @State private var showingSettings = false + @State private var coordinatorCache: [UUID: ConnectionCoordinator] = [:] private var showDeleteConfirmation: Binding { Binding( @@ -123,8 +124,10 @@ struct ConnectionListView: View { } } detail: { if let connection = selectedConnection { - ConnectedView(connection: connection) - .id(connection.id) + ConnectedView(connection: connection, cachedCoordinator: coordinatorCache[connection.id]) { coordinator in + coordinatorCache[connection.id] = coordinator + } + .id(connection.id) } else { ContentUnavailableView( "Select a Connection", diff --git a/TableProMobile/TableProMobile/Views/TableListView.swift b/TableProMobile/TableProMobile/Views/TableListView.swift index 9f4e04cfb..f537a4cd3 100644 --- a/TableProMobile/TableProMobile/Views/TableListView.swift +++ b/TableProMobile/TableProMobile/Views/TableListView.swift @@ -102,14 +102,10 @@ struct TableListView: View { } .listStyle(.insetGrouped) .searchable(text: $searchText, prompt: "Search tables") - .toolbar { connectionToolbar } .textInputAutocapitalization(.never) .refreshable { await coordinator.refreshTables() } - .navigationDestination(for: TableInfo.self) { table in - DataBrowserView(table: table) - } .onAppear { coordinator.navigateToPendingTable() } @@ -179,70 +175,6 @@ struct TableListView: View { } } - // MARK: - Connection Toolbar - - @ToolbarContentBuilder - private var connectionToolbar: some ToolbarContent { - if connection.safeModeLevel != .off { - ToolbarItem(placement: .topBarTrailing) { - Image(systemName: connection.safeModeLevel == .readOnly ? "lock.fill" : "shield.fill") - .foregroundStyle(connection.safeModeLevel == .readOnly ? .red : .orange) - .font(.caption) - } - } - if coordinator.supportsDatabaseSwitching && coordinator.databases.count > 1 { - ToolbarItem(placement: .topBarLeading) { - Menu { - ForEach(coordinator.databases, id: \.self) { db in - Button { - Task { await coordinator.switchDatabase(to: db) } - } label: { - if db == coordinator.activeDatabase { - Label(db, systemImage: "checkmark") - } else { - Text(db) - } - } - } - } label: { - HStack(spacing: 4) { - Text(coordinator.activeDatabase) - .font(.subheadline) - if coordinator.isSwitching { - ProgressView() - .controlSize(.mini) - } else { - Image(systemName: "chevron.down") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - } - .disabled(coordinator.isSwitching) - } - } - if coordinator.supportsSchemas && coordinator.schemas.count > 1 { - ToolbarItem(placement: .topBarTrailing) { - Menu { - ForEach(coordinator.schemas, id: \.self) { schema in - Button { - Task { await coordinator.switchSchema(to: schema) } - } label: { - if schema == coordinator.activeSchema { - Label(schema, systemImage: "checkmark") - } else { - Text(schema) - } - } - } - } label: { - Label(coordinator.activeSchema, systemImage: "square.3.layers.3d") - .font(.subheadline) - } - .disabled(coordinator.isSwitching) - } - } - } } private struct TableRow: View { From 80809bafadb744cb07642c4b945fb7e10a74d43a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 25 Apr 2026 23:04:50 +0700 Subject: [PATCH 8/8] refactor(ios): address review findings - Fix CHANGELOG to match final architecture (no sidebarAdaptable) - Remove redundant internal modifier on ConnectedTab - Fix navigateToPendingTable race between tab switch and path append - Evict coordinator cache on connection deletion --- CHANGELOG.md | 2 +- .../TableProMobile/Coordinators/ConnectionCoordinator.swift | 4 +++- TableProMobile/TableProMobile/Models/ConnectedTab.swift | 2 +- TableProMobile/TableProMobile/Views/ConnectionListView.swift | 1 + 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 447406e47..cbf77c056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- iOS: sidebar-adaptable TabView navigation with per-tab state preservation +- iOS: TabView navigation with ConnectionCoordinator extraction and coordinator caching ## [0.35.0] - 2026-04-25 diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index 3b3321fc8..cc47b386d 100644 --- a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -276,7 +276,9 @@ final class ConnectionCoordinator { let table = tables.first(where: { $0.name == tableName }) else { return } appState.pendingTableName = nil selectedTab = .tables - tablesPath.append(table) + Task { @MainActor in + tablesPath.append(table) + } } // MARK: - Private Helpers diff --git a/TableProMobile/TableProMobile/Models/ConnectedTab.swift b/TableProMobile/TableProMobile/Models/ConnectedTab.swift index 7f2b71ef2..d38c20ec5 100644 --- a/TableProMobile/TableProMobile/Models/ConnectedTab.swift +++ b/TableProMobile/TableProMobile/Models/ConnectedTab.swift @@ -3,7 +3,7 @@ // TableProMobile // -internal enum ConnectedTab: String, CaseIterable { +enum ConnectedTab: String, CaseIterable { case tables case query case history diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index f8d30d9a9..8d6728703 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -239,6 +239,7 @@ struct ConnectionListView: View { if selectedConnectionUUID == connection.id { selectedConnectionIdString = nil } + coordinatorCache.removeValue(forKey: connection.id) appState.removeConnection(connection) } }