From 67a860be988ba351d81006f571e963553cd58de0 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: Tue, 31 Mar 2026 14:51:28 +0700 Subject: [PATCH 1/2] refactor: split ConnectionFormView into SSH, SSL, and Advanced subviews --- .../Connection/ConnectionAdvancedView.swift | 152 +++++ .../Views/Connection/ConnectionFormView.swift | 600 ++---------------- .../Connection/ConnectionSSHTunnelView.swift | 405 ++++++++++++ .../Views/Connection/ConnectionSSLView.swift | 90 +++ 4 files changed, 688 insertions(+), 559 deletions(-) create mode 100644 TablePro/Views/Connection/ConnectionAdvancedView.swift create mode 100644 TablePro/Views/Connection/ConnectionSSHTunnelView.swift create mode 100644 TablePro/Views/Connection/ConnectionSSLView.swift diff --git a/TablePro/Views/Connection/ConnectionAdvancedView.swift b/TablePro/Views/Connection/ConnectionAdvancedView.swift new file mode 100644 index 00000000..e5c2c15b --- /dev/null +++ b/TablePro/Views/Connection/ConnectionAdvancedView.swift @@ -0,0 +1,152 @@ +// +// ConnectionAdvancedView.swift +// TablePro +// +// Created by Ngo Quoc Dat on 31/3/26. +// + +import SwiftUI +import TableProPluginKit + +struct ConnectionAdvancedView: View { + @Binding var additionalFieldValues: [String: String] + @Binding var startupCommands: String + @Binding var preConnectScript: String + @Binding var aiPolicy: AIConnectionPolicy? + + let databaseType: DatabaseType + let additionalConnectionFields: [ConnectionField] + + var body: some View { + Form { + let advancedFields = additionalConnectionFields.filter { $0.section == .advanced } + if !advancedFields.isEmpty { + Section(databaseType.displayName) { + ForEach(advancedFields, id: \.id) { field in + if isFieldVisible(field) { + ConnectionFieldRow( + field: field, + value: Binding( + get: { + additionalFieldValues[field.id] + ?? field.defaultValue ?? "" + }, + set: { additionalFieldValues[field.id] = $0 } + ) + ) + } + } + } + } + + Section(String(localized: "Startup Commands")) { + StartupCommandsEditor(text: $startupCommands) + .frame(height: 80) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay( + RoundedRectangle(cornerRadius: 5) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + Text( + "SQL commands to run after connecting, e.g. SET time_zone = 'Asia/Ho_Chi_Minh'. One per line or separated by semicolons." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + + Section(String(localized: "Pre-Connect Script")) { + StartupCommandsEditor(text: $preConnectScript) + .frame(height: 80) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay( + RoundedRectangle(cornerRadius: 5) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + Text( + "Shell script to run before connecting. Non-zero exit aborts connection." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + + if AppSettingsManager.shared.ai.enabled { + Section(String(localized: "AI")) { + Picker(String(localized: "AI Policy"), selection: $aiPolicy) { + Text(String(localized: "Use Default")) + .tag(AIConnectionPolicy?.none as AIConnectionPolicy?) + ForEach(AIConnectionPolicy.allCases) { policy in + Text(policy.displayName) + .tag(AIConnectionPolicy?.some(policy) as AIConnectionPolicy?) + } + } + } + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } + + private func isFieldVisible(_ field: ConnectionField) -> Bool { + guard let rule = field.visibleWhen else { return true } + let currentValue = additionalFieldValues[rule.fieldId] ?? defaultFieldValue(rule.fieldId) + return rule.values.contains(currentValue) + } + + private func defaultFieldValue(_ fieldId: String) -> String { + additionalConnectionFields.first { $0.id == fieldId }?.defaultValue ?? "" + } +} + +// MARK: - Startup Commands Editor + +struct StartupCommandsEditor: NSViewRepresentable { + @Binding var text: String + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSTextView.scrollableTextView() + guard let textView = scrollView.documentView as? NSTextView else { return scrollView } + + textView.font = .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.isRichText = false + textView.string = text + textView.textContainerInset = NSSize(width: 2, height: 6) + textView.drawsBackground = false + textView.delegate = context.coordinator + + scrollView.borderType = .noBorder + scrollView.hasVerticalScroller = true + scrollView.drawsBackground = false + + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? NSTextView else { return } + if textView.string != text { + textView.string = text + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text) + } + + final class Coordinator: NSObject, NSTextViewDelegate { + private var text: Binding + + init(text: Binding) { + self.text = text + } + + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + text.wrappedValue = textView.string + } + } +} diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 80c48a7e..25e73c00 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -10,10 +10,7 @@ import SwiftUI import TableProPluginKit import UniformTypeIdentifiers -// swiftlint:disable file_length - -/// Form for creating or editing a database connection -struct ConnectionFormView: View { // swiftlint:disable:this type_body_length +struct ConnectionFormView: View { private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionFormView") @Environment(\.openWindow) private var openWindow @@ -233,11 +230,48 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length case .general: generalForm case .ssh: - sshForm + ConnectionSSHTunnelView( + sshEnabled: $sshEnabled, + sshProfileId: $sshProfileId, + sshProfiles: $sshProfiles, + showingCreateProfile: $showingCreateProfile, + editingProfile: $editingProfile, + showingSaveAsProfile: $showingSaveAsProfile, + sshHost: $sshHost, + sshPort: $sshPort, + sshUsername: $sshUsername, + sshPassword: $sshPassword, + sshAuthMethod: $sshAuthMethod, + sshPrivateKeyPath: $sshPrivateKeyPath, + sshAgentSocketOption: $sshAgentSocketOption, + customSSHAgentSocketPath: $customSSHAgentSocketPath, + keyPassphrase: $keyPassphrase, + sshConfigEntries: $sshConfigEntries, + selectedSSHConfigHost: $selectedSSHConfigHost, + jumpHosts: $jumpHosts, + totpMode: $totpMode, + totpSecret: $totpSecret, + totpAlgorithm: $totpAlgorithm, + totpDigits: $totpDigits, + totpPeriod: $totpPeriod, + databaseType: type + ) case .ssl: - sslForm + ConnectionSSLView( + sslMode: $sslMode, + sslCaCertPath: $sslCaCertPath, + sslClientCertPath: $sslClientCertPath, + sslClientKeyPath: $sslClientKeyPath + ) case .advanced: - advancedForm + ConnectionAdvancedView( + additionalFieldValues: $additionalFieldValues, + startupCommands: $startupCommands, + preConnectScript: $preConnectScript, + aiPolicy: $aiPolicy, + databaseType: type, + additionalConnectionFields: additionalConnectionFields + ) } } @@ -530,438 +564,6 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } } - // MARK: - SSH Tunnel Tab - - private var sshForm: some View { - Form { - Section { - Toggle(String(localized: "Enable SSH Tunnel"), isOn: $sshEnabled) - } - - if sshEnabled { - sshProfileSection - - if let profile = selectedSSHProfile { - sshProfileSummarySection(profile) - } else if sshProfileId != nil { - Section { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.yellow) - Text("Selected SSH profile no longer exists.") - } - Button("Switch to Inline Configuration") { - sshProfileId = nil - } - } - } else { - sshInlineFields - } - } - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) - } - - private var sshProfileSection: some View { - Section(String(localized: "SSH Profile")) { - Picker(String(localized: "Profile"), selection: $sshProfileId) { - Text("Inline Configuration").tag(UUID?.none) - ForEach(sshProfiles) { profile in - Text("\(profile.name) (\(profile.username)@\(profile.host))").tag(UUID?.some(profile.id)) - } - } - - HStack(spacing: 12) { - Button("Create New Profile...") { - showingCreateProfile = true - } - - if sshProfileId != nil { - Button("Edit Profile...") { - if let profileId = sshProfileId { - editingProfile = SSHProfileStorage.shared.profile(for: profileId) - } - } - } - - if sshProfileId == nil && sshEnabled && !sshHost.isEmpty { - Button("Save Current as Profile...") { - showingSaveAsProfile = true - } - } - } - .controlSize(.small) - } - .sheet(isPresented: $showingCreateProfile) { - SSHProfileEditorView(existingProfile: nil, onSave: { _ in - reloadProfiles() - }) - } - .sheet(item: $editingProfile) { profile in - SSHProfileEditorView(existingProfile: profile, onSave: { _ in - reloadProfiles() - }, onDelete: { - reloadProfiles() - }) - } - .sheet(isPresented: $showingSaveAsProfile) { - SSHProfileEditorView( - existingProfile: buildProfileFromInlineConfig(), - initialPassword: sshPassword, - initialKeyPassphrase: keyPassphrase, - initialTOTPSecret: totpSecret, - onSave: { savedProfile in - sshProfileId = savedProfile.id - reloadProfiles() - } - ) - } - } - - private var selectedSSHProfile: SSHProfile? { - guard let id = sshProfileId else { return nil } - return sshProfiles.first { $0.id == id } - } - - private func reloadProfiles() { - sshProfiles = SSHProfileStorage.shared.loadProfiles() - if let id = sshProfileId, - !SSHProfileStorage.shared.lastLoadFailed, - !sshProfiles.contains(where: { $0.id == id }) { - sshProfileId = nil - } - } - - private func buildProfileFromInlineConfig() -> SSHProfile { - SSHProfile( - name: "", - host: sshHost, - port: Int(sshPort) ?? 22, - username: sshUsername, - authMethod: sshAuthMethod, - privateKeyPath: sshPrivateKeyPath, - useSSHConfig: !selectedSSHConfigHost.isEmpty, - agentSocketPath: resolvedSSHAgentSocketPath, - jumpHosts: jumpHosts, - totpMode: totpMode, - totpAlgorithm: totpAlgorithm, - totpDigits: totpDigits, - totpPeriod: totpPeriod - ) - } - - private func sshProfileSummarySection(_ profile: SSHProfile) -> some View { - Section(String(localized: "Profile Settings")) { - LabeledContent(String(localized: "Host"), value: profile.host) - LabeledContent(String(localized: "Port"), value: String(profile.port)) - LabeledContent(String(localized: "Username"), value: profile.username) - LabeledContent(String(localized: "Auth Method"), value: profile.authMethod.rawValue) - if !profile.privateKeyPath.isEmpty { - LabeledContent(String(localized: "Key File"), value: profile.privateKeyPath) - } - if !profile.jumpHosts.isEmpty { - LabeledContent(String(localized: "Jump Hosts"), value: "\(profile.jumpHosts.count)") - } - } - } - - private var sshInlineFields: some View { - Group { - Section(String(localized: "Server")) { - if !sshConfigEntries.isEmpty { - Picker(String(localized: "Config Host"), selection: $selectedSSHConfigHost) { - Text(String(localized: "Manual")).tag("") - ForEach(sshConfigEntries) { entry in - Text(entry.displayName).tag(entry.host) - } - } - .onChange(of: selectedSSHConfigHost) { - applySSHConfigEntry(selectedSSHConfigHost) - } - } - if selectedSSHConfigHost.isEmpty || sshConfigEntries.isEmpty { - TextField(String(localized: "SSH Host"), text: $sshHost, prompt: Text("ssh.example.com")) - } - TextField(String(localized: "SSH Port"), text: $sshPort, prompt: Text("22")) - TextField(String(localized: "SSH User"), text: $sshUsername, prompt: Text("username")) - } - - Section(String(localized: "Authentication")) { - Picker(String(localized: "Method"), selection: $sshAuthMethod) { - ForEach(SSHAuthMethod.allCases) { method in - Text(method.rawValue).tag(method) - } - } - if sshAuthMethod == .password { - SecureField(String(localized: "Password"), text: $sshPassword) - } else if sshAuthMethod == .sshAgent { - Picker("Agent Socket", selection: $sshAgentSocketOption) { - ForEach(SSHAgentSocketOption.allCases) { option in - Text(option.displayName).tag(option) - } - } - if sshAgentSocketOption == .custom { - TextField("Custom Path", text: $customSSHAgentSocketPath, prompt: Text("/path/to/agent.sock")) - } - Text("Keys are provided by the SSH agent (e.g. 1Password, ssh-agent).") - .font(.caption) - .foregroundStyle(.secondary) - } else if sshAuthMethod == .keyboardInteractive { - SecureField(String(localized: "Password"), text: $sshPassword) - Text(String(localized: "Password is sent via keyboard-interactive challenge-response.")) - .font(.caption) - .foregroundStyle(.secondary) - } else { - LabeledContent(String(localized: "Key File")) { - HStack { - TextField("", text: $sshPrivateKeyPath, prompt: Text("~/.ssh/id_rsa")) - Button(String(localized: "Browse")) { browseForPrivateKey() } - .controlSize(.small) - } - } - SecureField(String(localized: "Passphrase"), text: $keyPassphrase) - } - } - - if sshAuthMethod == .keyboardInteractive || sshAuthMethod == .password { - Section(String(localized: "Two-Factor Authentication")) { - Picker(String(localized: "TOTP"), selection: $totpMode) { - ForEach(TOTPMode.allCases) { mode in - Text(mode.displayName).tag(mode) - } - } - - if totpMode == .autoGenerate { - SecureField(String(localized: "TOTP Secret"), text: $totpSecret) - .help(String(localized: "Base32-encoded secret from your authenticator setup")) - Picker(String(localized: "Algorithm"), selection: $totpAlgorithm) { - ForEach(TOTPAlgorithm.allCases) { algo in - Text(algo.rawValue).tag(algo) - } - } - Picker(String(localized: "Digits"), selection: $totpDigits) { - Text("6").tag(6) - Text("8").tag(8) - } - Picker(String(localized: "Period"), selection: $totpPeriod) { - Text("30s").tag(30) - Text("60s").tag(60) - } - } else if totpMode == .promptAtConnect { - Text(String(localized: "You will be prompted for a verification code each time you connect.")) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - - Section { - DisclosureGroup(String(localized: "Jump Hosts")) { - ForEach($jumpHosts) { $jumpHost in - DisclosureGroup { - TextField(String(localized: "Host"), text: $jumpHost.host, prompt: Text("bastion.example.com")) - HStack { - TextField( - String(localized: "Port"), - text: Binding( - get: { String(jumpHost.port) }, - set: { jumpHost.port = Int($0) ?? 22 } - ), - prompt: Text("22") - ) - .frame(width: 80) - TextField(String(localized: "Username"), text: $jumpHost.username, prompt: Text("admin")) - } - Picker(String(localized: "Auth"), selection: $jumpHost.authMethod) { - ForEach(SSHJumpAuthMethod.allCases) { method in - Text(method.rawValue).tag(method) - } - } - if jumpHost.authMethod == .privateKey { - LabeledContent(String(localized: "Key File")) { - HStack { - TextField("", text: $jumpHost.privateKeyPath, prompt: Text("~/.ssh/id_rsa")) - Button(String(localized: "Browse")) { - browseForJumpHostKey(jumpHost: $jumpHost) - } - .controlSize(.small) - } - } - } - } label: { - HStack { - Text( - jumpHost.host.isEmpty - ? String(localized: "New Jump Host") - : "\(jumpHost.username)@\(jumpHost.host)" - ) - .foregroundStyle(jumpHost.host.isEmpty ? .secondary : .primary) - Spacer() - Button { - let idToRemove = jumpHost.id - withAnimation { jumpHosts.removeAll { $0.id == idToRemove } } - } label: { - Image(systemName: "minus.circle.fill").foregroundStyle(.red) - } - .buttonStyle(.plain) - } - } - } - .onMove { indices, destination in - jumpHosts.move(fromOffsets: indices, toOffset: destination) - } - - Button { - jumpHosts.append(SSHJumpHost()) - } label: { - Label(String(localized: "Add Jump Host"), systemImage: "plus") - } - - Text("Jump hosts are connected in order before reaching the SSH server above. Only key and agent auth are supported for jumps.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - - // MARK: - SSL/TLS Tab - - private var sslForm: some View { - Form { - Section { - Picker(String(localized: "SSL Mode"), selection: $sslMode) { - ForEach(SSLMode.allCases) { mode in - Text(mode.rawValue).tag(mode) - } - } - } - - if sslMode != .disabled { - Section { - Text(sslMode.description) - .foregroundStyle(.secondary) - } - - if sslMode == .verifyCa || sslMode == .verifyIdentity { - Section(String(localized: "CA Certificate")) { - LabeledContent(String(localized: "CA Cert")) { - HStack { - TextField( - "", text: $sslCaCertPath, prompt: Text("/path/to/ca-cert.pem")) - Button(String(localized: "Browse")) { - browseForCertificate(binding: $sslCaCertPath) - } - .controlSize(.small) - } - } - } - } - - Section(String(localized: "Client Certificates (Optional)")) { - LabeledContent(String(localized: "Client Cert")) { - HStack { - TextField( - "", text: $sslClientCertPath, - prompt: Text(String(localized: "(optional)"))) - Button(String(localized: "Browse")) { - browseForCertificate(binding: $sslClientCertPath) - } - .controlSize(.small) - } - } - LabeledContent(String(localized: "Client Key")) { - HStack { - TextField( - "", text: $sslClientKeyPath, - prompt: Text(String(localized: "(optional)"))) - Button(String(localized: "Browse")) { - browseForCertificate(binding: $sslClientKeyPath) - } - .controlSize(.small) - } - } - } - } - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) - } - - // MARK: - Advanced Tab - - private var advancedForm: some View { - Form { - let advancedFields = additionalConnectionFields.filter { $0.section == .advanced } - if !advancedFields.isEmpty { - Section(type.displayName) { - ForEach(advancedFields, id: \.id) { field in - if isFieldVisible(field) { - ConnectionFieldRow( - field: field, - value: Binding( - get: { - additionalFieldValues[field.id] - ?? field.defaultValue ?? "" - }, - set: { additionalFieldValues[field.id] = $0 } - ) - ) - } - } - } - } - - Section(String(localized: "Startup Commands")) { - StartupCommandsEditor(text: $startupCommands) - .frame(height: 80) - .background(Color(nsColor: .textBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay( - RoundedRectangle(cornerRadius: 5) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - ) - Text( - "SQL commands to run after connecting, e.g. SET time_zone = 'Asia/Ho_Chi_Minh'. One per line or separated by semicolons." - ) - .font(.caption) - .foregroundStyle(.secondary) - } - - Section(String(localized: "Pre-Connect Script")) { - StartupCommandsEditor(text: $preConnectScript) - .frame(height: 80) - .background(Color(nsColor: .textBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay( - RoundedRectangle(cornerRadius: 5) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - ) - Text( - "Shell script to run before connecting. Non-zero exit aborts connection." - ) - .font(.caption) - .foregroundStyle(.secondary) - } - - if AppSettingsManager.shared.ai.enabled { - Section(String(localized: "AI")) { - Picker(String(localized: "AI Policy"), selection: $aiPolicy) { - Text(String(localized: "Use Default")) - .tag(AIConnectionPolicy?.none as AIConnectionPolicy?) - ForEach(AIConnectionPolicy.allCases) { policy in - Text(policy.displayName) - .tag(AIConnectionPolicy?.some(policy) as AIConnectionPolicy?) - } - } - } - } - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) - } - // MARK: - Footer private var footer: some View { @@ -1562,50 +1164,6 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length ConnectionStorage.shared.deleteAllPluginSecureFields(for: testId, fieldIds: secureFieldIds) } - private func browseForPrivateKey() { - let panel = NSOpenPanel() - panel.allowsMultipleSelection = false - panel.canChooseDirectories = false - panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent( - ".ssh") - panel.showsHiddenFiles = true - - panel.begin { response in - if response == .OK, let url = panel.url { - sshPrivateKeyPath = url.path(percentEncoded: false) - } - } - } - - private func browseForJumpHostKey(jumpHost: Binding) { - let panel = NSOpenPanel() - panel.allowsMultipleSelection = false - panel.canChooseDirectories = false - panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent( - ".ssh") - panel.showsHiddenFiles = true - - panel.begin { response in - if response == .OK, let url = panel.url { - jumpHost.wrappedValue.privateKeyPath = url.path(percentEncoded: false) - } - } - } - - private func browseForCertificate(binding: Binding) { - let panel = NSOpenPanel() - panel.allowsMultipleSelection = false - panel.canChooseDirectories = false - panel.allowedContentTypes = [.data] - panel.showsHiddenFiles = true - - panel.begin { response in - if response == .OK, let url = panel.url { - binding.wrappedValue = url.path(percentEncoded: false) - } - } - } - private func loadSSHConfig() { sshConfigEntries = SSHConfigParser.parse() } @@ -1679,30 +1237,6 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } } - private func applySSHConfigEntry(_ host: String) { - guard let entry = sshConfigEntries.first(where: { $0.host == host }) else { - return - } - - sshHost = entry.hostname ?? entry.host - if let port = entry.port { - sshPort = String(port) - } - if let user = entry.user { - sshUsername = user - } - if let agentPath = entry.identityAgent { - applySSHAgentSocketPath(agentPath) - sshAuthMethod = .sshAgent - } else if let keyPath = entry.identityFile { - sshPrivateKeyPath = keyPath - sshAuthMethod = .privateKey - } - if let proxyJump = entry.proxyJump { - jumpHosts = SSHConfigParser.parseProxyJump(proxyJump) - } - } - private func applySSHAgentSocketPath(_ socketPath: String) { let option = SSHAgentSocketOption(socketPath: socketPath) sshAgentSocketOption = option @@ -1734,58 +1268,6 @@ private enum PgpassStatus { } } -// MARK: - Startup Commands Editor - -private struct StartupCommandsEditor: NSViewRepresentable { - @Binding var text: String - - func makeNSView(context: Context) -> NSScrollView { - let scrollView = NSTextView.scrollableTextView() - guard let textView = scrollView.documentView as? NSTextView else { return scrollView } - - textView.font = .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) - textView.isAutomaticQuoteSubstitutionEnabled = false - textView.isAutomaticDashSubstitutionEnabled = false - textView.isAutomaticTextReplacementEnabled = false - textView.isAutomaticSpellingCorrectionEnabled = false - textView.isRichText = false - textView.string = text - textView.textContainerInset = NSSize(width: 2, height: 6) - textView.drawsBackground = false - textView.delegate = context.coordinator - - scrollView.borderType = .noBorder - scrollView.hasVerticalScroller = true - scrollView.drawsBackground = false - - return scrollView - } - - func updateNSView(_ scrollView: NSScrollView, context: Context) { - guard let textView = scrollView.documentView as? NSTextView else { return } - if textView.string != text { - textView.string = text - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(text: $text) - } - - final class Coordinator: NSObject, NSTextViewDelegate { - private var text: Binding - - init(text: Binding) { - self.text = text - } - - func textDidChange(_ notification: Notification) { - guard let textView = notification.object as? NSTextView else { return } - text.wrappedValue = textView.string - } - } -} - #Preview("New Connection") { ConnectionFormView(connectionId: nil) } diff --git a/TablePro/Views/Connection/ConnectionSSHTunnelView.swift b/TablePro/Views/Connection/ConnectionSSHTunnelView.swift new file mode 100644 index 00000000..a27287a0 --- /dev/null +++ b/TablePro/Views/Connection/ConnectionSSHTunnelView.swift @@ -0,0 +1,405 @@ +// +// ConnectionSSHTunnelView.swift +// TablePro +// +// Created by Ngo Quoc Dat on 31/3/26. +// + +import SwiftUI + +struct ConnectionSSHTunnelView: View { + @Binding var sshEnabled: Bool + @Binding var sshProfileId: UUID? + @Binding var sshProfiles: [SSHProfile] + @Binding var showingCreateProfile: Bool + @Binding var editingProfile: SSHProfile? + @Binding var showingSaveAsProfile: Bool + @Binding var sshHost: String + @Binding var sshPort: String + @Binding var sshUsername: String + @Binding var sshPassword: String + @Binding var sshAuthMethod: SSHAuthMethod + @Binding var sshPrivateKeyPath: String + @Binding var sshAgentSocketOption: SSHAgentSocketOption + @Binding var customSSHAgentSocketPath: String + @Binding var keyPassphrase: String + @Binding var sshConfigEntries: [SSHConfigEntry] + @Binding var selectedSSHConfigHost: String + @Binding var jumpHosts: [SSHJumpHost] + @Binding var totpMode: TOTPMode + @Binding var totpSecret: String + @Binding var totpAlgorithm: TOTPAlgorithm + @Binding var totpDigits: Int + @Binding var totpPeriod: Int + + let databaseType: DatabaseType + + var body: some View { + Form { + Section { + Toggle(String(localized: "Enable SSH Tunnel"), isOn: $sshEnabled) + } + + if sshEnabled { + sshProfileSection + + if let profile = selectedSSHProfile { + sshProfileSummarySection(profile) + } else if sshProfileId != nil { + Section { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + Text("Selected SSH profile no longer exists.") + } + Button("Switch to Inline Configuration") { + sshProfileId = nil + } + } + } else { + sshInlineFields + } + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } + + // MARK: - SSH Profile Section + + private var sshProfileSection: some View { + Section(String(localized: "SSH Profile")) { + Picker(String(localized: "Profile"), selection: $sshProfileId) { + Text("Inline Configuration").tag(UUID?.none) + ForEach(sshProfiles) { profile in + Text("\(profile.name) (\(profile.username)@\(profile.host))").tag(UUID?.some(profile.id)) + } + } + + HStack(spacing: 12) { + Button("Create New Profile...") { + showingCreateProfile = true + } + + if sshProfileId != nil { + Button("Edit Profile...") { + if let profileId = sshProfileId { + editingProfile = SSHProfileStorage.shared.profile(for: profileId) + } + } + } + + if sshProfileId == nil && sshEnabled && !sshHost.isEmpty { + Button("Save Current as Profile...") { + showingSaveAsProfile = true + } + } + } + .controlSize(.small) + } + .sheet(isPresented: $showingCreateProfile) { + SSHProfileEditorView(existingProfile: nil, onSave: { _ in + reloadProfiles() + }) + } + .sheet(item: $editingProfile) { profile in + SSHProfileEditorView(existingProfile: profile, onSave: { _ in + reloadProfiles() + }, onDelete: { + reloadProfiles() + }) + } + .sheet(isPresented: $showingSaveAsProfile) { + SSHProfileEditorView( + existingProfile: buildProfileFromInlineConfig(), + initialPassword: sshPassword, + initialKeyPassphrase: keyPassphrase, + initialTOTPSecret: totpSecret, + onSave: { savedProfile in + sshProfileId = savedProfile.id + reloadProfiles() + } + ) + } + } + + private var selectedSSHProfile: SSHProfile? { + guard let id = sshProfileId else { return nil } + return sshProfiles.first { $0.id == id } + } + + private func reloadProfiles() { + sshProfiles = SSHProfileStorage.shared.loadProfiles() + if let id = sshProfileId, + !SSHProfileStorage.shared.lastLoadFailed, + !sshProfiles.contains(where: { $0.id == id }) { + sshProfileId = nil + } + } + + private var resolvedSSHAgentSocketPath: String { + sshAgentSocketOption.resolvedPath(customPath: customSSHAgentSocketPath) + } + + private func buildProfileFromInlineConfig() -> SSHProfile { + SSHProfile( + name: "", + host: sshHost, + port: Int(sshPort) ?? 22, + username: sshUsername, + authMethod: sshAuthMethod, + privateKeyPath: sshPrivateKeyPath, + useSSHConfig: !selectedSSHConfigHost.isEmpty, + agentSocketPath: resolvedSSHAgentSocketPath, + jumpHosts: jumpHosts, + totpMode: totpMode, + totpAlgorithm: totpAlgorithm, + totpDigits: totpDigits, + totpPeriod: totpPeriod + ) + } + + private func sshProfileSummarySection(_ profile: SSHProfile) -> some View { + Section(String(localized: "Profile Settings")) { + LabeledContent(String(localized: "Host"), value: profile.host) + LabeledContent(String(localized: "Port"), value: String(profile.port)) + LabeledContent(String(localized: "Username"), value: profile.username) + LabeledContent(String(localized: "Auth Method"), value: profile.authMethod.rawValue) + if !profile.privateKeyPath.isEmpty { + LabeledContent(String(localized: "Key File"), value: profile.privateKeyPath) + } + if !profile.jumpHosts.isEmpty { + LabeledContent(String(localized: "Jump Hosts"), value: "\(profile.jumpHosts.count)") + } + } + } + + // MARK: - SSH Inline Fields + + private var sshInlineFields: some View { + Group { + Section(String(localized: "Server")) { + if !sshConfigEntries.isEmpty { + Picker(String(localized: "Config Host"), selection: $selectedSSHConfigHost) { + Text(String(localized: "Manual")).tag("") + ForEach(sshConfigEntries) { entry in + Text(entry.displayName).tag(entry.host) + } + } + .onChange(of: selectedSSHConfigHost) { + applySSHConfigEntry(selectedSSHConfigHost) + } + } + if selectedSSHConfigHost.isEmpty || sshConfigEntries.isEmpty { + TextField(String(localized: "SSH Host"), text: $sshHost, prompt: Text("ssh.example.com")) + } + TextField(String(localized: "SSH Port"), text: $sshPort, prompt: Text("22")) + TextField(String(localized: "SSH User"), text: $sshUsername, prompt: Text("username")) + } + + Section(String(localized: "Authentication")) { + Picker(String(localized: "Method"), selection: $sshAuthMethod) { + ForEach(SSHAuthMethod.allCases) { method in + Text(method.rawValue).tag(method) + } + } + if sshAuthMethod == .password { + SecureField(String(localized: "Password"), text: $sshPassword) + } else if sshAuthMethod == .sshAgent { + Picker("Agent Socket", selection: $sshAgentSocketOption) { + ForEach(SSHAgentSocketOption.allCases) { option in + Text(option.displayName).tag(option) + } + } + if sshAgentSocketOption == .custom { + TextField("Custom Path", text: $customSSHAgentSocketPath, prompt: Text("/path/to/agent.sock")) + } + Text("Keys are provided by the SSH agent (e.g. 1Password, ssh-agent).") + .font(.caption) + .foregroundStyle(.secondary) + } else if sshAuthMethod == .keyboardInteractive { + SecureField(String(localized: "Password"), text: $sshPassword) + Text(String(localized: "Password is sent via keyboard-interactive challenge-response.")) + .font(.caption) + .foregroundStyle(.secondary) + } else { + LabeledContent(String(localized: "Key File")) { + HStack { + TextField("", text: $sshPrivateKeyPath, prompt: Text("~/.ssh/id_rsa")) + Button(String(localized: "Browse")) { browseForPrivateKey() } + .controlSize(.small) + } + } + SecureField(String(localized: "Passphrase"), text: $keyPassphrase) + } + } + + if sshAuthMethod == .keyboardInteractive || sshAuthMethod == .password { + Section(String(localized: "Two-Factor Authentication")) { + Picker(String(localized: "TOTP"), selection: $totpMode) { + ForEach(TOTPMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + + if totpMode == .autoGenerate { + SecureField(String(localized: "TOTP Secret"), text: $totpSecret) + .help(String(localized: "Base32-encoded secret from your authenticator setup")) + Picker(String(localized: "Algorithm"), selection: $totpAlgorithm) { + ForEach(TOTPAlgorithm.allCases) { algo in + Text(algo.rawValue).tag(algo) + } + } + Picker(String(localized: "Digits"), selection: $totpDigits) { + Text("6").tag(6) + Text("8").tag(8) + } + Picker(String(localized: "Period"), selection: $totpPeriod) { + Text("30s").tag(30) + Text("60s").tag(60) + } + } else if totpMode == .promptAtConnect { + Text(String(localized: "You will be prompted for a verification code each time you connect.")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + Section { + DisclosureGroup(String(localized: "Jump Hosts")) { + ForEach($jumpHosts) { $jumpHost in + DisclosureGroup { + TextField(String(localized: "Host"), text: $jumpHost.host, prompt: Text("bastion.example.com")) + HStack { + TextField( + String(localized: "Port"), + text: Binding( + get: { String(jumpHost.port) }, + set: { jumpHost.port = Int($0) ?? 22 } + ), + prompt: Text("22") + ) + .frame(width: 80) + TextField(String(localized: "Username"), text: $jumpHost.username, prompt: Text("admin")) + } + Picker(String(localized: "Auth"), selection: $jumpHost.authMethod) { + ForEach(SSHJumpAuthMethod.allCases) { method in + Text(method.rawValue).tag(method) + } + } + if jumpHost.authMethod == .privateKey { + LabeledContent(String(localized: "Key File")) { + HStack { + TextField("", text: $jumpHost.privateKeyPath, prompt: Text("~/.ssh/id_rsa")) + Button(String(localized: "Browse")) { + browseForJumpHostKey(jumpHost: $jumpHost) + } + .controlSize(.small) + } + } + } + } label: { + HStack { + Text( + jumpHost.host.isEmpty + ? String(localized: "New Jump Host") + : "\(jumpHost.username)@\(jumpHost.host)" + ) + .foregroundStyle(jumpHost.host.isEmpty ? .secondary : .primary) + Spacer() + Button { + let idToRemove = jumpHost.id + withAnimation { jumpHosts.removeAll { $0.id == idToRemove } } + } label: { + Image(systemName: "minus.circle.fill").foregroundStyle(.red) + } + .buttonStyle(.plain) + } + } + } + .onMove { indices, destination in + jumpHosts.move(fromOffsets: indices, toOffset: destination) + } + + Button { + jumpHosts.append(SSHJumpHost()) + } label: { + Label(String(localized: "Add Jump Host"), systemImage: "plus") + } + + Text("Jump hosts are connected in order before reaching the SSH server above. Only key and agent auth are supported for jumps.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + // MARK: - Helper Methods + + private func browseForPrivateKey() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent( + ".ssh") + panel.showsHiddenFiles = true + + panel.begin { response in + if response == .OK, let url = panel.url { + sshPrivateKeyPath = url.path(percentEncoded: false) + } + } + } + + private func browseForJumpHostKey(jumpHost: Binding) { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent( + ".ssh") + panel.showsHiddenFiles = true + + panel.begin { response in + if response == .OK, let url = panel.url { + jumpHost.wrappedValue.privateKeyPath = url.path(percentEncoded: false) + } + } + } + + private func applySSHConfigEntry(_ host: String) { + guard let entry = sshConfigEntries.first(where: { $0.host == host }) else { + return + } + + sshHost = entry.hostname ?? entry.host + if let port = entry.port { + sshPort = String(port) + } + if let user = entry.user { + sshUsername = user + } + if let agentPath = entry.identityAgent { + applySSHAgentSocketPath(agentPath) + sshAuthMethod = .sshAgent + } else if let keyPath = entry.identityFile { + sshPrivateKeyPath = keyPath + sshAuthMethod = .privateKey + } + if let proxyJump = entry.proxyJump { + jumpHosts = SSHConfigParser.parseProxyJump(proxyJump) + } + } + + private func applySSHAgentSocketPath(_ socketPath: String) { + let option = SSHAgentSocketOption(socketPath: socketPath) + sshAgentSocketOption = option + + if option == .custom { + customSSHAgentSocketPath = socketPath.trimmingCharacters(in: .whitespacesAndNewlines) + } else { + customSSHAgentSocketPath = "" + } + } +} diff --git a/TablePro/Views/Connection/ConnectionSSLView.swift b/TablePro/Views/Connection/ConnectionSSLView.swift new file mode 100644 index 00000000..e095085f --- /dev/null +++ b/TablePro/Views/Connection/ConnectionSSLView.swift @@ -0,0 +1,90 @@ +// +// ConnectionSSLView.swift +// TablePro +// +// Created by Ngo Quoc Dat on 31/3/26. +// + +import SwiftUI + +struct ConnectionSSLView: View { + @Binding var sslMode: SSLMode + @Binding var sslCaCertPath: String + @Binding var sslClientCertPath: String + @Binding var sslClientKeyPath: String + + var body: some View { + Form { + Section { + Picker(String(localized: "SSL Mode"), selection: $sslMode) { + ForEach(SSLMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + } + + if sslMode != .disabled { + Section { + Text(sslMode.description) + .foregroundStyle(.secondary) + } + + if sslMode == .verifyCa || sslMode == .verifyIdentity { + Section(String(localized: "CA Certificate")) { + LabeledContent(String(localized: "CA Cert")) { + HStack { + TextField( + "", text: $sslCaCertPath, prompt: Text("/path/to/ca-cert.pem")) + Button(String(localized: "Browse")) { + browseForCertificate(binding: $sslCaCertPath) + } + .controlSize(.small) + } + } + } + } + + Section(String(localized: "Client Certificates (Optional)")) { + LabeledContent(String(localized: "Client Cert")) { + HStack { + TextField( + "", text: $sslClientCertPath, + prompt: Text(String(localized: "(optional)"))) + Button(String(localized: "Browse")) { + browseForCertificate(binding: $sslClientCertPath) + } + .controlSize(.small) + } + } + LabeledContent(String(localized: "Client Key")) { + HStack { + TextField( + "", text: $sslClientKeyPath, + prompt: Text(String(localized: "(optional)"))) + Button(String(localized: "Browse")) { + browseForCertificate(binding: $sslClientKeyPath) + } + .controlSize(.small) + } + } + } + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } + + private func browseForCertificate(binding: Binding) { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.allowedContentTypes = [.data] + panel.showsHiddenFiles = true + + panel.begin { response in + if response == .OK, let url = panel.url { + binding.wrappedValue = url.path(percentEncoded: false) + } + } + } +} From 597b68ce7cf794e01fbd1a8e1741c87007cb9c16 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: Tue, 31 Mar 2026 14:59:16 +0700 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20remov?= =?UTF-8?q?e=20duplicated=20resolvedSSHAgentSocketPath,=20fix=20testConnec?= =?UTF-8?q?tion=20SSH=20profile=20lookup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/Connection/ConnectionFormView.swift | 37 +++++++++++-------- .../Connection/ConnectionSSHTunnelView.swift | 6 +-- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 25e73c00..4c3e708a 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -999,22 +999,27 @@ struct ConnectionFormView: View { testSucceeded = false let window = NSApp.keyWindow - // Build SSH config - let sshConfig = SSHConfiguration( - enabled: sshEnabled, - host: sshHost, - port: Int(sshPort) ?? 22, - username: sshUsername, - authMethod: sshAuthMethod, - privateKeyPath: sshPrivateKeyPath, - useSSHConfig: !selectedSSHConfigHost.isEmpty, - agentSocketPath: resolvedSSHAgentSocketPath, - jumpHosts: jumpHosts, - totpMode: totpMode, - totpAlgorithm: totpAlgorithm, - totpDigits: totpDigits, - totpPeriod: totpPeriod - ) + let sshConfig: SSHConfiguration + if let profileId = sshProfileId, + let profile = sshProfiles.first(where: { $0.id == profileId }) { + sshConfig = profile.toSSHConfiguration() + } else { + sshConfig = SSHConfiguration( + enabled: sshEnabled, + host: sshHost, + port: Int(sshPort) ?? 22, + username: sshUsername, + authMethod: sshAuthMethod, + privateKeyPath: sshPrivateKeyPath, + useSSHConfig: !selectedSSHConfigHost.isEmpty, + agentSocketPath: resolvedSSHAgentSocketPath, + jumpHosts: jumpHosts, + totpMode: totpMode, + totpAlgorithm: totpAlgorithm, + totpDigits: totpDigits, + totpPeriod: totpPeriod + ) + } let sslConfig = SSLConfiguration( mode: sslMode, diff --git a/TablePro/Views/Connection/ConnectionSSHTunnelView.swift b/TablePro/Views/Connection/ConnectionSSHTunnelView.swift index a27287a0..d7311f2c 100644 --- a/TablePro/Views/Connection/ConnectionSSHTunnelView.swift +++ b/TablePro/Views/Connection/ConnectionSSHTunnelView.swift @@ -137,10 +137,6 @@ struct ConnectionSSHTunnelView: View { } } - private var resolvedSSHAgentSocketPath: String { - sshAgentSocketOption.resolvedPath(customPath: customSSHAgentSocketPath) - } - private func buildProfileFromInlineConfig() -> SSHProfile { SSHProfile( name: "", @@ -150,7 +146,7 @@ struct ConnectionSSHTunnelView: View { authMethod: sshAuthMethod, privateKeyPath: sshPrivateKeyPath, useSSHConfig: !selectedSSHConfigHost.isEmpty, - agentSocketPath: resolvedSSHAgentSocketPath, + agentSocketPath: sshAgentSocketOption.resolvedPath(customPath: customSSHAgentSocketPath), jumpHosts: jumpHosts, totpMode: totpMode, totpAlgorithm: totpAlgorithm,