diff --git a/CHANGELOG.md b/CHANGELOG.md index 33f7c4712..cbf77c056 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: TabView navigation with ConnectionCoordinator extraction and coordinator caching + ## [0.35.0] - 2026-04-25 ### Added 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..cc47b386d --- /dev/null +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -0,0 +1,323 @@ +// +// 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) + await loadDatabases() + await loadSchemas() + phase = .connected + } 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) + await loadDatabases() + await loadSchemas() + phase = .connected + 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) + if let freshSession = appState.connectionManager.session(for: connection.id) { + self.session = freshSession + } + activeDatabase = name + UserDefaults.standard.set(name, forKey: "lastDB.\(connection.id.uuidString)") + if let current = self.session { + self.tables = try await current.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 = [] + } + + func navigateToPendingTable() { + guard let tableName = appState.pendingTableName, + let table = tables.first(where: { $0.name == tableName }) else { return } + appState.pendingTableName = nil + selectedTab = .tables + Task { @MainActor in + 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 { + Self.logger.warning("Failed to load databases: \(error.localizedDescription, privacy: .public)") + } + } + + 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 { + Self.logger.warning("Failed to load schemas: \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/TableProMobile/TableProMobile/Models/ConnectedTab.swift b/TableProMobile/TableProMobile/Models/ConnectedTab.swift new file mode 100644 index 000000000..d38c20ec5 --- /dev/null +++ b/TableProMobile/TableProMobile/Models/ConnectedTab.swift @@ -0,0 +1,11 @@ +// +// ConnectedTab.swift +// TableProMobile +// + +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..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 @@ -11,445 +10,226 @@ import TableProModels struct ConnectedView: View { @Environment(AppState.self) private var appState @Environment(\.scenePhase) private var scenePhase + @Environment(\.dismiss) private var dismiss let connection: DatabaseConnection + let cachedCoordinator: ConnectionCoordinator? + let onCoordinatorCreated: (ConnectionCoordinator) -> Void - 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) + connectingView } } - .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) - } + .task { + 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 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) + if let c = coordinator, !Task.isCancelled { + if case .connected = c.phase { + c.loadHistory() + hapticSuccess.toggle() + } else if case .error = c.phase { + hapticError.toggle() } } } - .task { - restorePersistedState() - await connect() - if !Task.isCancelled { - queryHistory = historyStorage.load(for: connection.id) - } - } - .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() } + if phase == .active { + Task { await coordinator?.reconnectIfNeeded() } } } + .sensoryFeedback(.success, trigger: hapticSuccess) + .sensoryFeedback(.error, trigger: hapticError) } - 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 - ) - } - } - } + // MARK: - Connecting - private func restorePersistedState() { - let key = connection.id.uuidString - if let savedTab = UserDefaults.standard.string(forKey: "lastTab.\(key)"), - let tab = ConnectedTab(rawValue: savedTab) { - selectedTab = tab + private var connectingView: some View { + VStack(spacing: 16) { + ProgressView { + Text(String(format: String(localized: "Connecting to %@..."), + connection.name.isEmpty ? connection.host : connection.name)) + } + Button(String(localized: "Cancel")) { + dismiss() + } + .buttonStyle(.bordered) } - activeDatabase = UserDefaults.standard.string(forKey: "lastDB.\(key)") ?? "" - activeSchema = UserDefaults.standard.string(forKey: "lastSchema.\(key)") ?? "public" + .frame(maxWidth: .infinity, maxHeight: .infinity) } - private func connect() async { - guard session == nil else { - isConnecting = false - return - } - - isConnecting = true - appError = nil + // MARK: - Connected Content - 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 func connectedContent(_ coordinator: ConnectionCoordinator) -> some View { + @Bindable var coordinator = coordinator + return NavigationStack(path: $coordinator.tablesPath) { + TabView(selection: $coordinator.selectedTab) { + Tab("Tables", systemImage: "tablecells", value: .tables) { + TableListView() + .environment(coordinator) + } + Tab("Query", systemImage: "terminal", value: .query) { + QueryEditorView() + .environment(coordinator) + } + Tab("History", systemImage: "clock", value: .history) { + QueryHistoryView() + .environment(coordinator) + } + 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) } - return } - - await connectFresh() - } - - 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() + .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 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 + .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 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) + .animation(.default, value: coordinator.isReconnecting) + .allowsHitTesting(!coordinator.isSwitching) + .overlay { + if coordinator.isSwitching { + ZStack { + Rectangle() + .fill(.ultraThinMaterial) + .ignoresSafeArea() + ProgressView() + .controlSize(.large) } - } else if let stored = appState.connectionManager.session(for: connection.id) { - activeDatabase = stored.activeDatabase - } else { - activeDatabase = connection.database + .transition(.opacity) } - } 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 - } - } catch { - // Silently fail — don't show picker + .animation(.default, value: coordinator.isSwitching) + .alert("Error", isPresented: $coordinator.showFailureAlert) { + Button("OK", role: .cancel) {} + } message: { + Text(coordinator.failureAlertMessage ?? "") } - } - - 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 + .userActivity("com.TablePro.viewConnection") { activity in + activity.title = connection.name.isEmpty ? connection.host : connection.name + activity.isEligibleForHandoff = true + activity.userInfo = ["connectionId": connection.id.uuidString] } } - private func switchDatabase(to name: String) async { - guard let session, name != activeDatabase, !isSwitching else { return } - isSwitching = true - defer { isSwitching = false } + // MARK: - Connection Toolbar - 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 + @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) } } - } - - 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 + 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) } } - } - - 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 + 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 75057e9c9..8d6728703 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( @@ -122,17 +123,17 @@ 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, cachedCoordinator: coordinatorCache[connection.id]) { coordinator in + coordinatorCache[connection.id] = coordinator } + .id(connection.id) + } else { + ContentUnavailableView( + "Select a Connection", + systemImage: "server.rack", + description: Text("Choose a connection from the sidebar.") + ) } } .sheet(isPresented: $showingAddConnection) { @@ -238,6 +239,7 @@ struct ConnectionListView: View { if selectedConnectionUUID == connection.id { selectedConnectionIdString = nil } + coordinatorCache.removeValue(forKey: connection.id) appState.removeConnection(connection) } } 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..3b0352559 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(coordinator.displayName) + .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, @@ -257,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 { @@ -275,11 +289,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 +349,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 +396,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..f537a4cd3 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? @@ -103,14 +104,10 @@ struct TableListView: View { .searchable(text: $searchText, prompt: "Search tables") .textInputAutocapitalization(.never) .refreshable { - await onRefresh?() + await coordinator.refreshTables() } - .navigationDestination(for: TableInfo.self) { table in - DataBrowserView( - connection: connection, - table: table, - session: session - ) + .onAppear { + coordinator.navigateToPendingTable() } .overlay { if tables.isEmpty { @@ -134,7 +131,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 +155,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 +174,7 @@ struct TableListView: View { Text(errorMessage) } } + } private struct TableRow: View {