diff --git a/CHANGELOG.md b/CHANGELOG.md index c2d858402..009d1bacc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Internal: iOS connection form (test connection, save, file picker handlers, default port resolution, credential hydration) moves out of the View into `ConnectionFormViewModel`. The View drops from 53 to 5 `@State` properties; behavior is unchanged - Internal: iOS data browser business logic (page load, pagination, sort, filter, search, delete, foreign-key fetch, memory pressure) moves out of the View into `DataBrowserViewModel`. The View drops 30 of its 33 `@State` properties and a dozen private functions; behavior is unchanged - iOS: metadata badges (column types, primary key markers, row counts) cap at the first accessibility size so they stay readable without breaking layouts at the largest Dynamic Type sizes - iOS: SQL editor keyboard accessory uses the system keyboard input view, dropping the deprecated screen-width measurement diff --git a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift new file mode 100644 index 000000000..faa6cd515 --- /dev/null +++ b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift @@ -0,0 +1,347 @@ +// +// ConnectionFormViewModel.swift +// TableProMobile +// + +import Foundation +import os +import TableProDatabase +import TableProModels + +@MainActor +@Observable +final class ConnectionFormViewModel { + enum KeyInputMode: String, CaseIterable { + case file = "Import File" + case paste = "Paste Key" + } + + struct TestResult: Sendable { + let success: Bool + let message: String + let recovery: String? + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionFormViewModel") + + // Form fields + var name = "" + var type: DatabaseType = .mysql { + didSet { onTypeChange(from: oldValue) } + } + var host = "127.0.0.1" + var port = "3306" + var username = "" + var password = "" + var database = "" + var sslEnabled = false + + // Organization + var groupId: UUID? + var tagId: UUID? + var safeModeLevel: SafeModeLevel = .off + + // SSH + var sshEnabled = false + var sshHost = "" + var sshPort = "22" + var sshUsername = "" + var sshPassword = "" + var sshAuthMethod: SSHConfiguration.SSHAuthMethod = .password + var sshKeyPath = "" + var sshKeyContent = "" + var sshKeyPassphrase = "" + var sshKeyInputMode: KeyInputMode = .file + + // File picker output + var selectedFileURL: URL? + var newDatabaseName = "" + + // Async state + private(set) var isTesting = false + private(set) var testResult: TestResult? + private(set) var credentialError: String? + + @ObservationIgnored let existingConnection: DatabaseConnection? + + init(editing: DatabaseConnection? = nil) { + self.existingConnection = editing + guard let conn = editing else { return } + name = conn.name + type = conn.type + host = conn.host + port = String(conn.port) + username = conn.username + database = conn.database + sslEnabled = conn.sslEnabled + sshEnabled = conn.sshEnabled + groupId = conn.groupId + tagId = conn.tagId + safeModeLevel = conn.safeModeLevel + if let ssh = conn.sshConfiguration { + sshHost = ssh.host + sshPort = String(ssh.port) + sshUsername = ssh.username + sshAuthMethod = ssh.authMethod + sshKeyPath = ssh.privateKeyPath ?? "" + sshKeyContent = ssh.privateKeyData ?? "" + if let keyData = ssh.privateKeyData, !keyData.isEmpty { + sshKeyInputMode = .paste + } + } + if conn.type == .sqlite { + selectedFileURL = URL(fileURLWithPath: conn.database) + } + } + + // MARK: - Computed + + var canSave: Bool { + if type == .sqlite { + return !database.isEmpty + } + return !host.isEmpty + } + + var isEditing: Bool { existingConnection != nil } + + // MARK: - Credential Hydration + + func loadStoredCredentials(secureStore: KeychainSecureStore) async { + guard let conn = existingConnection else { return } + let connKey = "com.TablePro.password.\(conn.id.uuidString)" + if let stored = try? secureStore.retrieve(forKey: connKey), !stored.isEmpty { + password = stored + } + if let sshPwd = try? secureStore.retrieve(forKey: "com.TablePro.sshpassword.\(conn.id.uuidString)"), !sshPwd.isEmpty { + sshPassword = sshPwd + } + if let passphrase = try? secureStore.retrieve(forKey: "com.TablePro.keypassphrase.\(conn.id.uuidString)"), !passphrase.isEmpty { + sshKeyPassphrase = passphrase + } + } + + // MARK: - Type Change + + private func onTypeChange(from oldType: DatabaseType) { + guard oldType != type else { return } + updateDefaultPort() + selectedFileURL = nil + database = "" + } + + private func updateDefaultPort() { + switch type { + case .mysql, .mariadb: port = "3306" + case .postgresql: port = "5432" + case .redshift: port = "5439" + case .redis: port = "6379" + case .sqlite: port = "" + default: port = "3306" + } + } + + // MARK: - File Picker + + func handleSQLiteFilePicker(_ result: Result<[URL], Error>) { + guard case .success(let urls) = result, let url = urls.first else { return } + guard url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + + let destURL = copyToDocuments(url) + selectedFileURL = destURL + database = destURL.path + if name.isEmpty { + name = destURL.deletingPathExtension().lastPathComponent + } + } + + func handleSSHKeyFilePicker(_ result: Result<[URL], Error>) { + guard case .success(let urls) = result, let url = urls.first else { return } + guard url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + + if let content = try? String(contentsOf: url, encoding: .utf8) { + sshKeyContent = content + sshKeyInputMode = .paste + } else { + guard let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } + let dest = docsDir.appendingPathComponent("ssh_" + url.lastPathComponent) + try? FileManager.default.removeItem(at: dest) + try? FileManager.default.copyItem(at: url, to: dest) + sshKeyPath = dest.path + } + } + + private func copyToDocuments(_ sourceURL: URL) -> URL { + guard let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + return sourceURL + } + var destURL = documentsDir.appendingPathComponent(sourceURL.lastPathComponent) + + if FileManager.default.fileExists(atPath: destURL.path) { + let baseName = sourceURL.deletingPathExtension().lastPathComponent + let ext = sourceURL.pathExtension + let suffix = UUID().uuidString.prefix(8) + destURL = documentsDir.appendingPathComponent("\(baseName)_\(suffix).\(ext)") + } + + try? FileManager.default.copyItem(at: sourceURL, to: destURL) + return destURL + } + + func clearSelectedFile() { + selectedFileURL = nil + database = "" + } + + func createNewDatabase() { + guard !newDatabaseName.isEmpty else { return } + + let safeName = newDatabaseName.hasSuffix(".db") ? newDatabaseName : "\(newDatabaseName).db" + guard let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } + let fileURL = documentsDir.appendingPathComponent(safeName) + + selectedFileURL = fileURL + database = fileURL.path + if name.isEmpty { + name = newDatabaseName + } + newDatabaseName = "" + } + + // MARK: - Test Connection + + func testConnection(appState: AppState, secureStore: KeychainSecureStore) async { + isTesting = true + testResult = nil + defer { isTesting = false } + + let tempId = UUID() + var testConn = buildConnection() + testConn.id = tempId + + if !password.isEmpty { + try? appState.connectionManager.storePassword(password, for: tempId) + } + if sshEnabled && !sshPassword.isEmpty { + try? secureStore.store(sshPassword, forKey: "com.TablePro.sshpassword.\(tempId.uuidString)") + } + if sshEnabled && !sshKeyPassphrase.isEmpty { + try? secureStore.store(sshKeyPassphrase, forKey: "com.TablePro.keypassphrase.\(tempId.uuidString)") + } + if sshEnabled && !sshKeyContent.isEmpty { + try? secureStore.store(sshKeyContent, forKey: "com.TablePro.sshkeydata.\(tempId.uuidString)") + } + + defer { + try? appState.connectionManager.deletePassword(for: tempId) + try? secureStore.delete(forKey: "com.TablePro.sshpassword.\(tempId.uuidString)") + try? secureStore.delete(forKey: "com.TablePro.keypassphrase.\(tempId.uuidString)") + try? secureStore.delete(forKey: "com.TablePro.sshkeydata.\(tempId.uuidString)") + } + + await appState.sshProvider.setPendingConnectionId(tempId) + + do { + _ = try await appState.connectionManager.connect(testConn) + await appState.connectionManager.disconnect(tempId) + testResult = TestResult( + success: true, + message: String(localized: "Connection successful"), + recovery: nil + ) + } catch { + let context = ErrorContext( + operation: "testConnection", + databaseType: type, + host: host, + sshEnabled: sshEnabled + ) + let classified = ErrorClassifier.classify(error, context: context) + testResult = TestResult(success: false, message: classified.message, recovery: classified.recovery) + } + } + + // MARK: - Save + + func save(appState: AppState, secureStore: KeychainSecureStore) -> DatabaseConnection? { + let connection = buildConnection() + var storageFailed = false + + if !password.isEmpty { + do { + try appState.connectionManager.storePassword(password, for: connection.id) + } catch { + Self.logger.error("Failed to store password: \(error.localizedDescription, privacy: .public)") + storageFailed = true + } + } + + if sshEnabled { + if !sshPassword.isEmpty { + do { + try secureStore.store(sshPassword, forKey: "com.TablePro.sshpassword.\(connection.id.uuidString)") + } catch { + Self.logger.error("Failed to store SSH password: \(error.localizedDescription, privacy: .public)") + storageFailed = true + } + } + if !sshKeyPassphrase.isEmpty { + do { + try secureStore.store(sshKeyPassphrase, forKey: "com.TablePro.keypassphrase.\(connection.id.uuidString)") + } catch { + Self.logger.error("Failed to store SSH key passphrase: \(error.localizedDescription, privacy: .public)") + storageFailed = true + } + } + if !sshKeyContent.isEmpty { + do { + try secureStore.store(sshKeyContent, forKey: "com.TablePro.sshkeydata.\(connection.id.uuidString)") + } catch { + Self.logger.error("Failed to store SSH key data: \(error.localizedDescription, privacy: .public)") + storageFailed = true + } + } + } + + if storageFailed { + credentialError = String(localized: "Some credentials could not be saved to the keychain. You may need to re-enter them later.") + return nil + } + + return connection + } + + func dismissCredentialError() { + credentialError = nil + } + + private func buildConnection() -> DatabaseConnection { + var conn = DatabaseConnection( + id: existingConnection?.id ?? UUID(), + name: name.isEmpty ? (selectedFileURL?.lastPathComponent ?? host) : name, + type: type, + host: host, + port: Int(port) ?? 3306, + username: username, + database: database, + sshEnabled: sshEnabled, + sslEnabled: sslEnabled, + groupId: groupId, + tagId: tagId + ) + conn.safeModeLevel = safeModeLevel + if sshEnabled { + conn.sshConfiguration = SSHConfiguration( + host: sshHost, + port: Int(sshPort) ?? 22, + username: sshUsername, + authMethod: sshAuthMethod, + privateKeyPath: sshKeyPath.isEmpty ? nil : sshKeyPath, + privateKeyData: sshKeyContent.isEmpty ? nil : sshKeyContent + ) + } + return conn + } +} diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index aa9df4517..6e85fc540 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -3,7 +3,6 @@ // TableProMobile // -import os import SwiftUI import TableProDatabase import TableProModels @@ -13,66 +12,13 @@ struct ConnectionFormView: View { @Environment(\.dismiss) private var dismiss @Environment(AppState.self) private var appState - @State private var name = "" - @State private var type: DatabaseType = .mysql - @State private var host = "127.0.0.1" - @State private var port = "3306" - @State private var username = "" - @State private var password = "" - @State private var database = "" - @State private var sslEnabled = false - - // File pickers - enum ActiveFilePicker: Identifiable { - case sqliteDatabase - case sshKey - var id: Int { hashValue } - } + @State private var viewModel: ConnectionFormViewModel @State private var activeFilePicker: ActiveFilePicker? @State private var pendingFilePicker: ActiveFilePicker? - @State private var selectedFileURL: URL? @State private var showNewDatabaseAlert = false - @State private var newDatabaseName = "" - - // Organization - @State private var groupId: UUID? - @State private var tagId: UUID? - @State private var safeModeLevel: SafeModeLevel = .off - - // SSH - @State private var sshEnabled = false - @State private var sshHost = "" - @State private var sshPort = "22" - @State private var sshUsername = "" - @State private var sshPassword = "" - @State private var sshAuthMethod: SSHConfiguration.SSHAuthMethod = .password - @State private var sshKeyPath = "" - @State private var sshKeyContent = "" - @State private var sshKeyPassphrase = "" - @State private var sshKeyInputMode = KeyInputMode.file - private var showFilePicker: Binding { - Binding( - get: { activeFilePicker != nil }, - set: { if !$0 { activeFilePicker = nil } } - ) - } - - enum KeyInputMode: String, CaseIterable { - case file = "Import File" - case paste = "Paste Key" - } - - // Test connection - @State private var isTesting = false - @State private var testResult: TestResult? - @State private var credentialError: String? - @State private var showCredentialError = false @State private var hapticSuccess = false @State private var hapticError = false - private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionFormView") - - private let existingConnection: DatabaseConnection? var onSave: (DatabaseConnection) -> Void private let databaseTypes: [(DatabaseType, String)] = [ @@ -83,178 +29,66 @@ struct ConnectionFormView: View { (.redis, "Redis"), ] + enum ActiveFilePicker: Identifiable { + case sqliteDatabase + case sshKey + var id: Int { hashValue } + } + init(editing connection: DatabaseConnection? = nil, onSave: @escaping (DatabaseConnection) -> Void) { - self.existingConnection = connection + _viewModel = State(wrappedValue: ConnectionFormViewModel(editing: connection)) self.onSave = onSave - if let connection { - _name = State(initialValue: connection.name) - _type = State(initialValue: connection.type) - _host = State(initialValue: connection.host) - _port = State(initialValue: String(connection.port)) - _username = State(initialValue: connection.username) - _database = State(initialValue: connection.database) - _sslEnabled = State(initialValue: connection.sslEnabled) - _sshEnabled = State(initialValue: connection.sshEnabled) - if let ssh = connection.sshConfiguration { - _sshHost = State(initialValue: ssh.host) - _sshPort = State(initialValue: String(ssh.port)) - _sshUsername = State(initialValue: ssh.username) - _sshAuthMethod = State(initialValue: ssh.authMethod) - _sshKeyPath = State(initialValue: ssh.privateKeyPath ?? "") - _sshKeyContent = State(initialValue: ssh.privateKeyData ?? "") - if let keyData = ssh.privateKeyData, !keyData.isEmpty { - _sshKeyInputMode = State(initialValue: .paste) - } - } - _groupId = State(initialValue: connection.groupId) - _tagId = State(initialValue: connection.tagId) - _safeModeLevel = State(initialValue: connection.safeModeLevel) - if connection.type == .sqlite { - _selectedFileURL = State(initialValue: URL(fileURLWithPath: connection.database)) - } - } } - var body: some View { - NavigationStack { - Form { - Section("Connection") { - TextField("Name", text: $name) - .textInputAutocapitalization(.never) - - Picker("Database Type", selection: $type) { - ForEach(databaseTypes, id: \.0.rawValue) { dbType, label in - Label { - Text(label) - } icon: { - Image(dbType.iconName) - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - } - .tag(dbType) - } - } - .onChange(of: type) { _, newType in - updateDefaultPort(for: newType) - selectedFileURL = nil - database = "" - } - } + private var showFilePicker: Binding { + Binding( + get: { activeFilePicker != nil }, + set: { if !$0 { activeFilePicker = nil } } + ) + } - Section("Organization") { - Picker("Group", selection: $groupId) { - Text("None").tag(UUID?.none) - ForEach(appState.groups) { group in - HStack { - Circle() - .fill(ConnectionColorPicker.swiftUIColor(for: group.color)) - .frame(width: 8, height: 8) - Text(group.name) - } - .tag(Optional(group.id)) - } - } - .pickerStyle(.menu) - - Picker("Tag", selection: $tagId) { - Text("None").tag(UUID?.none) - ForEach(appState.tags) { tag in - HStack { - Circle() - .fill(ConnectionColorPicker.swiftUIColor(for: tag.color)) - .frame(width: 8, height: 8) - Text(tag.name) - } - .tag(Optional(tag.id)) - } - } - .pickerStyle(.menu) + private var showCredentialError: Binding { + Binding( + get: { viewModel.credentialError != nil }, + set: { if !$0 { viewModel.dismissCredentialError() } } + ) + } - Picker("Safe Mode", selection: $safeModeLevel) { - ForEach(SafeModeLevel.allCases) { level in - Text(level.displayName).tag(level) - } - } - .pickerStyle(.menu) - } + var body: some View { + @Bindable var viewModel = viewModel + return NavigationStack { + Form { + connectionSection(viewModel: viewModel) + organizationSection(viewModel: viewModel) - if type == .sqlite { - sqliteSection + if viewModel.type == .sqlite { + sqliteSection(viewModel: viewModel) } else { - serverSection + serverSection(viewModel: viewModel) } - if type != .sqlite { + if viewModel.type != .sqlite { Section { - Toggle("SSL", isOn: $sslEnabled) + Toggle("SSL", isOn: $viewModel.sslEnabled) } + sshSection(viewModel: viewModel) } - if type != .sqlite { - sshSection - } - - Section { - Button { - Task { await testConnection() } - } label: { - HStack { - if isTesting { - ProgressView() - .controlSize(.small) - Text("Testing...") - } else { - Label("Test Connection", systemImage: "antenna.radiowaves.left.and.right") - } - } - } - .disabled(isTesting || !canSave) - - if let testResult { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - Image(systemName: testResult.success ? "checkmark.circle.fill" : "xmark.circle.fill") - .foregroundStyle(testResult.success ? .green : .red) - Text(verbatim: testResult.message) - .font(.footnote) - .foregroundStyle(testResult.success ? .green : .red) - } - if let recovery = testResult.recovery { - Text(verbatim: recovery) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, 28) - } - } - } - } + testSection } .scrollDismissesKeyboard(.interactively) .task { - if let conn = existingConnection { - let connKey = "com.TablePro.password.\(conn.id.uuidString)" - if let stored = try? appState.secureStore.retrieve(forKey: connKey), !stored.isEmpty { - password = stored - } - if let sshPwd = try? appState.secureStore.retrieve(forKey: "com.TablePro.sshpassword.\(conn.id.uuidString)"), !sshPwd.isEmpty { - sshPassword = sshPwd - } - if let passphrase = try? appState.secureStore.retrieve(forKey: "com.TablePro.keypassphrase.\(conn.id.uuidString)"), !passphrase.isEmpty { - sshKeyPassphrase = passphrase - } - } + await viewModel.loadStoredCredentials(secureStore: appState.secureStore) } - .navigationTitle(existingConnection != nil ? String(localized: "Edit Connection") : String(localized: "New Connection")) + .navigationTitle(viewModel.isEditing ? String(localized: "Edit Connection") : String(localized: "New Connection")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { - Button("Save") { save() } - .disabled(!canSave) + Button("Save", action: handleSave) + .disabled(!viewModel.canSave) } } .fileImporter( @@ -265,49 +99,101 @@ struct ConnectionFormView: View { let picker = pendingFilePicker pendingFilePicker = nil switch picker { - case .sqliteDatabase: - handleFilePickerResult(result) - case .sshKey: - if case .success(let urls) = result, let url = urls.first { - guard url.startAccessingSecurityScopedResource() else { return } - defer { url.stopAccessingSecurityScopedResource() } - if let content = try? String(contentsOf: url, encoding: .utf8) { - sshKeyContent = content - sshKeyInputMode = .paste - } else { - guard let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } - let dest = docsDir.appendingPathComponent("ssh_" + url.lastPathComponent) - try? FileManager.default.removeItem(at: dest) - try? FileManager.default.copyItem(at: url, to: dest) - sshKeyPath = dest.path - } - } - case nil: - break + case .sqliteDatabase: viewModel.handleSQLiteFilePicker(result) + case .sshKey: viewModel.handleSSHKeyFilePicker(result) + case nil: break } } .alert("New Database", isPresented: $showNewDatabaseAlert) { - TextField("Database name", text: $newDatabaseName) - Button("Create") { createNewDatabase() } - Button("Cancel", role: .cancel) { newDatabaseName = "" } + TextField("Database name", text: $viewModel.newDatabaseName) + Button("Create") { viewModel.createNewDatabase() } + Button("Cancel", role: .cancel) { viewModel.newDatabaseName = "" } } message: { Text("Enter a name for the new SQLite database.") } - .sensoryFeedback(.success, trigger: hapticSuccess) - .sensoryFeedback(.error, trigger: hapticError) - .alert("Keychain Warning", isPresented: $showCredentialError) { + .alert("Keychain Warning", isPresented: showCredentialError) { Button("OK", role: .cancel) {} } message: { - Text(credentialError ?? "Failed to save credentials.") + Text(viewModel.credentialError ?? "Failed to save credentials.") + } + .sensoryFeedback(.success, trigger: hapticSuccess) + .sensoryFeedback(.error, trigger: hapticError) + } + } + + // MARK: - Connection Section + + @ViewBuilder + private func connectionSection(viewModel: ConnectionFormViewModel) -> some View { + @Bindable var viewModel = viewModel + Section("Connection") { + TextField("Name", text: $viewModel.name) + .textInputAutocapitalization(.never) + + Picker("Database Type", selection: $viewModel.type) { + ForEach(databaseTypes, id: \.0.rawValue) { dbType, label in + Label { + Text(label) + } icon: { + Image(dbType.iconName) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + } + .tag(dbType) + } } } } + @ViewBuilder + private func organizationSection(viewModel: ConnectionFormViewModel) -> some View { + @Bindable var viewModel = viewModel + Section("Organization") { + Picker("Group", selection: $viewModel.groupId) { + Text("None").tag(UUID?.none) + ForEach(appState.groups) { group in + HStack { + Circle() + .fill(ConnectionColorPicker.swiftUIColor(for: group.color)) + .frame(width: 8, height: 8) + Text(group.name) + } + .tag(Optional(group.id)) + } + } + .pickerStyle(.menu) + + Picker("Tag", selection: $viewModel.tagId) { + Text("None").tag(UUID?.none) + ForEach(appState.tags) { tag in + HStack { + Circle() + .fill(ConnectionColorPicker.swiftUIColor(for: tag.color)) + .frame(width: 8, height: 8) + Text(tag.name) + } + .tag(Optional(tag.id)) + } + } + .pickerStyle(.menu) + + Picker("Safe Mode", selection: $viewModel.safeModeLevel) { + ForEach(SafeModeLevel.allCases) { level in + Text(level.displayName).tag(level) + } + } + .pickerStyle(.menu) + } + } + // MARK: - SQLite Section - private var sqliteSection: some View { + @ViewBuilder + private func sqliteSection(viewModel: ConnectionFormViewModel) -> some View { Section("Database File") { - if let url = selectedFileURL { + if let url = viewModel.selectedFileURL { HStack { Image(systemName: "doc.fill") .foregroundStyle(.blue) @@ -320,8 +206,7 @@ struct ConnectionFormView: View { } Spacer() Button { - selectedFileURL = nil - database = "" + viewModel.clearSelectedFile() } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) @@ -344,309 +229,165 @@ struct ConnectionFormView: View { } } - // MARK: - Server Section (MySQL, PostgreSQL, Redis) + // MARK: - Server Section - private var serverSection: some View { - Group { - Section("Server") { - TextField("Host", text: $host) - .textInputAutocapitalization(.never) - .keyboardType(.URL) - - TextField("Port", text: $port) - .keyboardType(.numberPad) - - TextField("Username", text: $username) - .textInputAutocapitalization(.never) - - SecureField("Password", text: $password) - } - - Section("Database") { - TextField("Database Name", text: $database) - .textInputAutocapitalization(.never) - } + @ViewBuilder + private func serverSection(viewModel: ConnectionFormViewModel) -> some View { + @Bindable var viewModel = viewModel + Section("Server") { + TextField("Host", text: $viewModel.host) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + TextField("Port", text: $viewModel.port) + .keyboardType(.numberPad) + TextField("Username", text: $viewModel.username) + .textInputAutocapitalization(.never) + SecureField("Password", text: $viewModel.password) + } + Section("Database") { + TextField("Database Name", text: $viewModel.database) + .textInputAutocapitalization(.never) } } // MARK: - SSH Section @ViewBuilder - private var sshSection: some View { + private func sshSection(viewModel: ConnectionFormViewModel) -> some View { + @Bindable var viewModel = viewModel Section { - Toggle("SSH Tunnel", isOn: $sshEnabled) + Toggle("SSH Tunnel", isOn: $viewModel.sshEnabled) } - if sshEnabled { + if viewModel.sshEnabled { Section("SSH Server") { - TextField("SSH Host", text: $sshHost) + TextField("SSH Host", text: $viewModel.sshHost) .textInputAutocapitalization(.never) .keyboardType(.URL) - TextField("SSH Port", text: $sshPort) + TextField("SSH Port", text: $viewModel.sshPort) .keyboardType(.numberPad) - TextField("SSH Username", text: $sshUsername) + TextField("SSH Username", text: $viewModel.sshUsername) .textInputAutocapitalization(.never) - Picker("Auth Method", selection: $sshAuthMethod) { + Picker("Auth Method", selection: $viewModel.sshAuthMethod) { Text("Password").tag(SSHConfiguration.SSHAuthMethod.password) Text("Private Key").tag(SSHConfiguration.SSHAuthMethod.privateKey) } .pickerStyle(.segmented) } - if sshAuthMethod == .password { + if viewModel.sshAuthMethod == .password { Section("SSH Password") { - SecureField("Password", text: $sshPassword) + SecureField("Password", text: $viewModel.sshPassword) } } else { - Section("Private Key") { - Picker("Input Method", selection: $sshKeyInputMode) { - ForEach(KeyInputMode.allCases, id: \.self) { mode in - Text(mode.rawValue).tag(mode) - } - } - .pickerStyle(.segmented) - - if sshKeyInputMode == .file { - Button { - pendingFilePicker = .sshKey - activeFilePicker = .sshKey - } label: { - HStack { - Text(sshKeyPath.isEmpty - ? "Select Private Key" - : URL(fileURLWithPath: sshKeyPath).lastPathComponent) - Spacer() - Image(systemName: "folder") - } - } - } else { - TextEditor(text: $sshKeyContent) - .font(.system(.caption, design: .monospaced)) - .frame(minHeight: 120) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .overlay(alignment: .topLeading) { - if sshKeyContent.isEmpty { - Text("Paste private key (PEM format)") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.tertiary) - .padding(.top, 8) - .padding(.leading, 4) - .allowsHitTesting(false) - } - } - } - - SecureField("Passphrase (optional)", text: $sshKeyPassphrase) - } + privateKeySection(viewModel: viewModel) } } } - // MARK: - Logic - - private var canSave: Bool { - if type == .sqlite { - return !database.isEmpty - } - return !host.isEmpty - } - - private var sqliteContentTypes: [UTType] { - [UTType.database, UTType(filenameExtension: "sqlite3") ?? .data, .data] - } - - private func updateDefaultPort(for type: DatabaseType) { - switch type { - case .mysql, .mariadb: port = "3306" - case .postgresql: port = "5432" - case .redshift: port = "5439" - case .redis: port = "6379" - case .sqlite: port = "" - default: port = "3306" - } - } - - private func handleFilePickerResult(_ result: Result<[URL], Error>) { - guard case .success(let urls) = result, let url = urls.first else { return } - - guard url.startAccessingSecurityScopedResource() else { return } - defer { url.stopAccessingSecurityScopedResource() } - - let destURL = copyToDocuments(url) - selectedFileURL = destURL - database = destURL.path - if name.isEmpty { - name = destURL.deletingPathExtension().lastPathComponent - } - } - - private func copyToDocuments(_ sourceURL: URL) -> URL { - guard let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { - return sourceURL - } - var destURL = documentsDir.appendingPathComponent(sourceURL.lastPathComponent) - - if FileManager.default.fileExists(atPath: destURL.path) { - let name = sourceURL.deletingPathExtension().lastPathComponent - let ext = sourceURL.pathExtension - let suffix = UUID().uuidString.prefix(8) - destURL = documentsDir.appendingPathComponent("\(name)_\(suffix).\(ext)") - } - - try? FileManager.default.copyItem(at: sourceURL, to: destURL) - return destURL - } - - private func createNewDatabase() { - guard !newDatabaseName.isEmpty else { return } - - let safeName = newDatabaseName.hasSuffix(".db") ? newDatabaseName : "\(newDatabaseName).db" - guard let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } - let fileURL = documentsDir.appendingPathComponent(safeName) - - selectedFileURL = fileURL - database = fileURL.path - if name.isEmpty { - name = newDatabaseName - } - newDatabaseName = "" - } - - private func testConnection() async { - isTesting = true - testResult = nil - - let tempId = UUID() - var testConn = buildConnection() - testConn.id = tempId - - if !password.isEmpty { - try? appState.connectionManager.storePassword(password, for: tempId) - } - - let secureStore = KeychainSecureStore() - if sshEnabled && !sshPassword.isEmpty { - try? secureStore.store(sshPassword, forKey: "com.TablePro.sshpassword.\(tempId.uuidString)") - } - if sshEnabled && !sshKeyPassphrase.isEmpty { - try? secureStore.store(sshKeyPassphrase, forKey: "com.TablePro.keypassphrase.\(tempId.uuidString)") - } - if sshEnabled && !sshKeyContent.isEmpty { - try? secureStore.store(sshKeyContent, forKey: "com.TablePro.sshkeydata.\(tempId.uuidString)") - } - - defer { - try? appState.connectionManager.deletePassword(for: tempId) - try? secureStore.delete(forKey: "com.TablePro.sshpassword.\(tempId.uuidString)") - try? secureStore.delete(forKey: "com.TablePro.keypassphrase.\(tempId.uuidString)") - try? secureStore.delete(forKey: "com.TablePro.sshkeydata.\(tempId.uuidString)") - isTesting = false - } - - await appState.sshProvider.setPendingConnectionId(tempId) - - do { - _ = try await appState.connectionManager.connect(testConn) - await appState.connectionManager.disconnect(tempId) - testResult = TestResult(success: true, message: String(localized: "Connection successful"), recovery: nil) - hapticSuccess.toggle() - } catch { - let context = ErrorContext( - operation: "testConnection", - databaseType: type, - host: host, - sshEnabled: sshEnabled - ) - let classified = ErrorClassifier.classify(error, context: context) - testResult = TestResult(success: false, message: classified.message, recovery: classified.recovery) - hapticError.toggle() - } - } + @ViewBuilder + private func privateKeySection(viewModel: ConnectionFormViewModel) -> some View { + @Bindable var viewModel = viewModel + Section("Private Key") { + Picker("Input Method", selection: $viewModel.sshKeyInputMode) { + ForEach(ConnectionFormViewModel.KeyInputMode.allCases, id: \.self) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + + if viewModel.sshKeyInputMode == .file { + Button { + pendingFilePicker = .sshKey + activeFilePicker = .sshKey + } label: { + HStack { + Text(viewModel.sshKeyPath.isEmpty + ? "Select Private Key" + : URL(fileURLWithPath: viewModel.sshKeyPath).lastPathComponent) + Spacer() + Image(systemName: "folder") + } + } + } else { + TextEditor(text: $viewModel.sshKeyContent) + .font(.system(.caption, design: .monospaced)) + .frame(minHeight: 120) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .overlay(alignment: .topLeading) { + if viewModel.sshKeyContent.isEmpty { + Text("Paste private key (PEM format)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.tertiary) + .padding(.top, 8) + .padding(.leading, 4) + .allowsHitTesting(false) + } + } + } - private func buildConnection() -> DatabaseConnection { - var conn = DatabaseConnection( - id: existingConnection?.id ?? UUID(), - name: name.isEmpty ? (selectedFileURL?.lastPathComponent ?? host) : name, - type: type, - host: host, - port: Int(port) ?? 3306, // swiftlint:disable:this number_separator - username: username, - database: database, - sshEnabled: sshEnabled, - sslEnabled: sslEnabled, - groupId: groupId, - tagId: tagId - ) - conn.safeModeLevel = safeModeLevel - if sshEnabled { - conn.sshConfiguration = SSHConfiguration( - host: sshHost, - port: Int(sshPort) ?? 22, - username: sshUsername, - authMethod: sshAuthMethod, - privateKeyPath: sshKeyPath.isEmpty ? nil : sshKeyPath, - privateKeyData: sshKeyContent.isEmpty ? nil : sshKeyContent - ) + SecureField("Passphrase (optional)", text: $viewModel.sshKeyPassphrase) } - return conn } - private func save() { - let connection = buildConnection() - var storageFailed = false - - if !password.isEmpty { - do { - try appState.connectionManager.storePassword(password, for: connection.id) - } catch { - Self.logger.error("Failed to store password: \(error.localizedDescription, privacy: .public)") - storageFailed = true - } - } - - if sshEnabled { - let secureStore = KeychainSecureStore() + // MARK: - Test Section - if !sshPassword.isEmpty { - do { - try secureStore.store(sshPassword, forKey: "com.TablePro.sshpassword.\(connection.id.uuidString)") - } catch { - Self.logger.error("Failed to store SSH password: \(error.localizedDescription, privacy: .public)") - storageFailed = true - } - } - if !sshKeyPassphrase.isEmpty { - do { - try secureStore.store(sshKeyPassphrase, forKey: "com.TablePro.keypassphrase.\(connection.id.uuidString)") - } catch { - Self.logger.error("Failed to store SSH key passphrase: \(error.localizedDescription, privacy: .public)") - storageFailed = true + private var testSection: some View { + Section { + Button { + Task { await handleTest() } + } label: { + HStack { + if viewModel.isTesting { + ProgressView() + .controlSize(.small) + Text("Testing...") + } else { + Label("Test Connection", systemImage: "antenna.radiowaves.left.and.right") + } } } - if !sshKeyContent.isEmpty { - do { - try secureStore.store(sshKeyContent, forKey: "com.TablePro.sshkeydata.\(connection.id.uuidString)") - } catch { - Self.logger.error("Failed to store SSH key data: \(error.localizedDescription, privacy: .public)") - storageFailed = true + .disabled(viewModel.isTesting || !viewModel.canSave) + + if let testResult = viewModel.testResult { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Image(systemName: testResult.success ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(testResult.success ? .green : .red) + Text(verbatim: testResult.message) + .font(.footnote) + .foregroundStyle(testResult.success ? .green : .red) + } + if let recovery = testResult.recovery { + Text(verbatim: recovery) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 28) + } } } } + } + + // MARK: - Actions - if storageFailed { - credentialError = String(localized: "Some credentials could not be saved to the keychain. You may need to re-enter them later.") - showCredentialError = true - return + private func handleTest() async { + await viewModel.testConnection(appState: appState, secureStore: appState.secureStore) + if let result = viewModel.testResult { + if result.success { hapticSuccess.toggle() } else { hapticError.toggle() } } + } + private func handleSave() { + guard let connection = viewModel.save(appState: appState, secureStore: appState.secureStore) else { return } onSave(connection) } -} -private struct TestResult { - let success: Bool - let message: String - let recovery: String? + // MARK: - Helpers + + private var sqliteContentTypes: [UTType] { + [UTType.database, UTType(filenameExtension: "sqlite3") ?? .data, .data] + } }