import SwiftUI import TermindApplication import TermindDomain import TermindPresentation import struct TermindDomain.Host /// Add or edit a host. /// /// A route inside Vault rather than a dynamic workspace or sheet. It edits durable /// connection data without entering SSH/SFTP Workspace lifecycle state. struct HostEditorView: View { private struct CustomIdentityState: Equatable { let sshUsername: String let sshPassword: String let sshKeyID: SSHKeyRef.ID? let telnetUsername: String let telnetPassword: String let usesCustomTelnetSelection: Bool let usesCustomProxy: Bool let proxyPassword: String let usesCustomTelnetProxy: Bool let telnetProxyPassword: String init(_ draft: HostCustomIdentityDraft) { sshUsername = draft.sshUsername sshPassword = draft.sshPassword sshKeyID = draft.sshKeyID telnetUsername = draft.telnetUsername telnetPassword = draft.telnetPassword usesCustomTelnetSelection = draft.usesCustomTelnetSelection usesCustomProxy = draft.usesCustomProxy proxyPassword = draft.proxyPassword usesCustomTelnetProxy = draft.usesCustomTelnetProxy telnetProxyPassword = draft.telnetProxyPassword } } private enum Route: Hashable { case identity case telnetIdentity case proxyIdentity case telnetProxyIdentity case group case environmentVariable(Int?) } let hostID: Host.ID? let initialGroupID: HostGroup.ID? @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window @Environment(VaultWorkEditingSession.self) private var editingSession @State private var draft: Host @State private var original: Host @State private var originalCustomIdentity = CustomIdentityState(HostCustomIdentityDraft()) @State private var route: Route? @State private var saveError: String? @State private var didLoad = false @State private var customIdentity = HostCustomIdentityDraft() @State private var selectedProtocol = HostTerminalProtocol.ssh @State private var confirmsDeletion = false @State private var editingToken: UUID? init(hostID: Host.ID?, initialGroupID: HostGroup.ID? = nil) { self.hostID = hostID self.initialGroupID = initialGroupID let initial = Host(name: "", host: "", groupID: hostID == nil ? initialGroupID : nil) _draft = State(initialValue: initial) _original = State(initialValue: initial) } var body: some View { ZStack { if let route { destination(route) .transition(.move(edge: .trailing)) } else { VaultDetailScaffold( title: hostID == nil ? "New Host" : "Edit Host", symbol: "server.rack", close: cancel, closeLabel: "Cancel" ) { VStack(spacing: 0) { HostProtocolProfileControls(protocol: $selectedProtocol) .padding(.horizontal, Metrics.Space.l) .padding(.vertical, Metrics.Space.m) .termindGlassBar() Divider().opacity(0.25) form } } footer: { footer } .transition(.move(edge: .leading)) } } // Keep nested editors inside the Host work column. A NavigationStack // here is promoted by macOS into the enclosing NavigationSplitView's // detail, which makes its destination cover the Hosts collection too. .frame(maxWidth: .infinity, maxHeight: .infinity) .clipped() .animation(.snappy(duration: 0.2), value: route) .onAppear { loadOnce() original = draft originalCustomIdentity = CustomIdentityState(customIdentity) editingToken = editingSession.register( route: .hostEditor(hostID, groupID: initialGroupID), isDirty: { isDirty } ) { commitChanges() } } .onDisappear { guard let editingToken else { return } editingSession.unregister(editingToken) } .confirmationDialog( "Delete Host?", isPresented: $confirmsDeletion, titleVisibility: .visible ) { Button("Delete", role: .destructive) { guard let hostID else { return } app.requestHostDeletion(hostID) closeTab() } Button("Cancel", role: .cancel) {} } message: { Text("Open connections and references to this host will be removed.") } } private var form: some View { Form { HostOrganizationSection(host: $draft) { Picker("Group", selection: groupChoice) { Text("None").tag(Choice.unset) ForEach(app.hostLibrary.groups) { group in Text(group.name).tag(Choice.existing(group.id)) } Divider() Text("New Group").tag(Choice.new) } } tagsField: { // Full width rather than a `LabeledContent`, which would squeeze // the chips, the field, and the dropdown into the trailing half // of the row. VStack(alignment: .leading, spacing: Metrics.Space.s) { Text("Tags") TagField(tags: $draft.tags, suggestions: app.hostLibrary.allTags) } } HostConnectionFields(host: $draft, protocol: $selectedProtocol) if selectedProtocol == .ssh && draft.sshEnabled { Section("Authentication") { // One control, because a host says *where* and an identity says // *who*. The login name and the secret travel together — a // fleet shares one `deploy` account, and typing that name into // eight host forms is eight places to fix when it changes. // // No SSH Agent option, and there will not be one: reaching // `SSH_AUTH_SOCK` means opening a UNIX socket outside the // container, which the App Sandbox does not grant. Offering the // mode and then failing at connect time would be worse than not // offering it. Picker("Identity", selection: identityChoice) { Text("Custom").tag(Choice.unset) Divider() ForEach(app.hostLibrary.reusableIdentities) { identity in Text("\(identity.name) · \(identity.username)") .tag(Choice.existing(identity.id)) } Divider() Text("New Identity").tag(Choice.new) } if usesCustomIdentity { TextField("Username", text: $customIdentity.sshUsername.singleLineText, axis: .horizontal) .singleLinePaste().lineLimit(1) .padding(.leading, Metrics.Space.xxl) RevealableSecretField("Password", text: $customIdentity.sshPassword, prompt: Text("Optional")) .padding(.leading, Metrics.Space.xxl) .disabled(!customIdentity.sshPasswordIsEditable) Picker("Key", selection: $customIdentity.sshKeyID) { Text("None").tag(SSHKeyRef.ID?.none) ForEach(app.hostLibrary.keys) { key in Text(key.label).tag(SSHKeyRef.ID?.some(key.id)) } } .padding(.leading, Metrics.Space.xxl) } } Section("Routing") { Picker("Jump Host", selection: $draft.jumpHostID) { Text("None").tag(Host.ID?.none) Divider() ForEach(jumpHostSections) { section in Section(section.title) { ForEach(section.hosts) { host in Text(host.name) .tag(Host.ID?.some(host.id)) } } } } Picker("Proxy", selection: proxyKind) { Text("None").tag(HostProxy.ProtocolKind?.none) ForEach(HostProxy.ProtocolKind.allCases) { kind in Text(kind.displayName).tag(HostProxy.ProtocolKind?.some(kind)) } } if draft.proxy != nil { TextField("Hostname", text: proxyString(\.hostname).singleLineText, axis: .horizontal) .singleLinePaste().lineLimit(1) .padding(.leading, Metrics.Space.xxl) .accessibilityLabel("Proxy Hostname") TextField( "Port", value: proxyInt(\.port), format: .number.grouping(.never) ).lineLimit(1) .padding(.leading, Metrics.Space.xxl) .accessibilityLabel("Proxy Port") Picker("Identity", selection: proxyIdentityChoice) { Text("None").tag(Choice.unset) Text("Custom").tag(Choice.custom) Divider() ForEach(proxyIdentities) { identity in Text("\(identity.name) · \(identity.username)") .tag(Choice.existing(identity.id)) } Divider() Text("New Identity").tag(Choice.new) } .padding(.leading, Metrics.Space.xxl) .accessibilityLabel("Proxy Identity") if customIdentity.usesCustomProxy { TextField("Username", text: proxyString(\.username).singleLineText, axis: .horizontal) .singleLinePaste().lineLimit(1) .padding(.leading, Metrics.Space.xxl) .accessibilityLabel("Proxy Username") RevealableSecretField( "Password", text: $customIdentity.proxyPassword, prompt: Text("Required") ) .padding(.leading, Metrics.Space.xxl) .disabled(!customIdentity.proxyPasswordIsEditable) } else if draft.proxy?.identityID != nil { TextField("Username", text: proxyString(\.username).singleLineText, axis: .horizontal) .singleLinePaste() .lineLimit(1) .padding(.leading, Metrics.Space.xxl) .accessibilityLabel("Proxy Username") } } if !draft.host.trimmingCharacters(in: .whitespaces).isEmpty { LabeledContent("Route") { Text(app.hostLibrary.routeSummary(for: draft)) .font(.caption.monospaced()) .foregroundStyle(.secondary) .multilineTextAlignment(.trailing) } } } HostSessionSection( charset: $draft.sshCharset, sftpInitialPath: $draft.sftpChannelInitialPath, startupSnippetID: startupSnippetChoice, environmentVariables: $draft.environmentVariables, snippets: app.hostLibrary.snippets, snippetPackagePath: app.hostLibrary.snippetPackagePath, editEnvironmentVariable: { route = .environmentVariable($0) } ) Section("Port Forwarding") { Stepper( value: $draft.portForwardingMaxConnections, in: Host.portForwardingMaxConnectionsRange ) { HStack { Text("Max Connections") Spacer() Text(draft.portForwardingMaxConnections, format: .number).monospacedDigit() } } Text("Local and dynamic forwards share this connection pool. Changes apply after restart.") .font(.caption) .foregroundStyle(.secondary) } HostIntegrationSection(mode: $draft.shellIntegrationMode) if !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Section("SSH Command") { if let command = app.hostLibrary.sshCommand(for: draft) { HStack(spacing: Metrics.Space.s) { Text(command) .font(.caption.monospaced()) .foregroundStyle(.secondary) .textSelection(.enabled) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) Button { copyToPasteboard(command) } label: { Image(systemName: "doc.on.doc") } .buttonStyle(.borderless) .accessibilityLabel("Copy SSH Command") } } else { Label( "OpenSSH commands are unavailable for routes using an HTTP or SOCKS5 proxy.", systemImage: "info.circle" ) .font(.caption) .foregroundStyle(.secondary) } } } } else if selectedProtocol == .telnet && draft.telnetEnabled { telnetAuthenticationSection telnetRoutingSection Section("Session") { Picker("Charset", selection: $draft.telnetCharset) { ForEach(RemoteCharset.allCases) { charset in Text(charset.displayName).tag(charset) } } .pickerStyle(.menu) .menuOrder(.fixed) } telnetCommandSection } } .formStyle(.grouped) } private var telnetAuthenticationSection: some View { Section("Authentication") { Picker("Identity", selection: telnetIdentityChoice) { Text("None").tag(Choice.unset) Text("Custom").tag(Choice.custom) Divider() ForEach(telnetIdentities) { identity in Text("\(identity.name) · \(identity.username)").tag(Choice.existing(identity.id)) } Divider() Text("New Identity").tag(Choice.new) } if customIdentity.usesCustomTelnet(for: draft) { TextField("Username", text: $customIdentity.telnetUsername.singleLineText, axis: .horizontal) .singleLinePaste().lineLimit(1).padding(.leading, Metrics.Space.xxl) RevealableSecretField("Password", text: $customIdentity.telnetPassword, prompt: Text("Required")) .padding(.leading, Metrics.Space.xxl) .disabled(!customIdentity.telnetPasswordIsEditable) } } } @ViewBuilder private var telnetRoutingSection: some View { Section("Routing") { Picker("Proxy", selection: telnetProxyKind) { Text("None").tag(HostProxy.ProtocolKind?.none) ForEach(HostProxy.ProtocolKind.allCases) { kind in Text(kind.displayName).tag(HostProxy.ProtocolKind?.some(kind)) } } if draft.telnetProxy != nil { TextField("Hostname", text: telnetProxyString(\.hostname).singleLineText) .singleLinePaste().lineLimit(1).padding(.leading, Metrics.Space.xxl) TextField("Port", value: telnetProxyInt(\.port), format: .number.grouping(.never)) .lineLimit(1).padding(.leading, Metrics.Space.xxl) Picker("Identity", selection: telnetProxyIdentityChoice) { Text("None").tag(Choice.unset) Text("Custom").tag(Choice.custom) Divider() ForEach(telnetIdentities) { identity in Text("\(identity.name) · \(identity.username)").tag(Choice.existing(identity.id)) } Divider() Text("New Identity").tag(Choice.new) } .padding(.leading, Metrics.Space.xxl) if customIdentity.usesCustomTelnetProxy { TextField("Username", text: telnetProxyString(\.username).singleLineText) .singleLinePaste().lineLimit(1).padding(.leading, Metrics.Space.xxl) RevealableSecretField( "Password", text: $customIdentity.telnetProxyPassword, prompt: Text("Required") ) .padding(.leading, Metrics.Space.xxl) .disabled(!customIdentity.telnetProxyPasswordIsEditable) } } } } @ViewBuilder private var telnetCommandSection: some View { if !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Section("Telnet Command") { let command = app.hostLibrary.telnetCommand(for: draft) HStack(spacing: Metrics.Space.s) { Text(command) .font(.caption.monospaced()).foregroundStyle(.secondary) .textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) Button { copyToPasteboard(command) } label: { Image(systemName: "doc.on.doc") } .buttonStyle(.borderless).accessibilityLabel("Copy Telnet Command") } } } } @ViewBuilder private func destination(_ route: Route) -> some View { switch route { case .identity: NewIdentitySheet( defaultPasswordStorage: app.newVaultPasswordStorage, back: closeNestedEditor ) { identity, passwordUpdate in try app.hostLibrary.upsert(identity, passwordUpdate: passwordUpdate) draft.identityID = identity.id } case .telnetIdentity: NewIdentitySheet( configuration: .proxy, defaultPasswordStorage: app.newVaultPasswordStorage, back: closeNestedEditor ) { identity, passwordUpdate in try app.hostLibrary.upsert(identity, passwordUpdate: passwordUpdate) customIdentity.selectTelnetIdentity(identity, on: &draft) } case .group: NewGroupSheet(back: closeNestedEditor) { group in app.hostLibrary.upsert(group) draft.groupID = group.id } case .proxyIdentity: NewIdentitySheet( configuration: .proxy, defaultPasswordStorage: app.newVaultPasswordStorage, back: closeNestedEditor ) { identity, passwordUpdate in try app.hostLibrary.upsert(identity, passwordUpdate: passwordUpdate) customIdentity.selectProxyIdentity(identity, on: &draft) } case .telnetProxyIdentity: NewIdentitySheet( configuration: .proxy, defaultPasswordStorage: app.newVaultPasswordStorage, back: closeNestedEditor ) { identity, passwordUpdate in try app.hostLibrary.upsert(identity, passwordUpdate: passwordUpdate) customIdentity.selectTelnetProxyIdentity(identity, on: &draft) } case .environmentVariable(let index): HostEnvironmentVariableEditor( variable: index.map { draft.environmentVariables[$0] }, usedVariables: Set( draft.environmentVariables.indices.compactMap { $0 == index ? nil : draft.environmentVariables[$0].variable }), back: closeNestedEditor ) { variable in if let index { draft.environmentVariables[index] = variable } else { draft.environmentVariables.append(variable) } } } } private func closeNestedEditor() { route = nil } // MARK: - Picking, and making /// What a picker row means: nothing, one of the saved records, or the row /// that makes a new one. /// /// A row in a picker is a *value*, not a button — so "New Identity" has to /// be a third case rather than an action, and the binding below is what /// turns choosing it back into one. Generic over the id because Identity and /// HostGroup need the same three answers; both ids are `UUID`, so a single /// non-generic enum would let a group id be selected into the identity /// picker and still compile. private enum Choice: Hashable { case unset case custom case existing(ID) case new } /// `.new` deliberately leaves the draft alone. Nothing is written until the /// sheet hands back something to point at, which is what makes Cancel a /// no-op: the getter still reads the old value, so the picker snaps back to /// it on the next pass. private var identityChoice: Binding> { Binding { guard let id = draft.identityID, app.hostLibrary.identity(id)?.isHostCustom != true else { return .unset } return .existing(id) } set: { choice in switch choice { case .unset, .custom: customIdentity.selectCustomSSH(on: &draft) case .existing(let id): guard let identity = app.hostLibrary.identity(id) else { return } customIdentity.selectSSHIdentity(identity, on: &draft) case .new: route = .identity } } } private var telnetIdentityChoice: Binding> { Binding { if customIdentity.usesCustomTelnetSelection { return .custom } guard let id = draft.telnetIdentityID else { return .unset } return app.hostLibrary.identity(id)?.isHostCustom == true ? .custom : .existing(id) } set: { choice in switch choice { case .unset: customIdentity.clearTelnetIdentity(on: &draft) case .custom: customIdentity.selectCustomTelnet(on: &draft) case .existing(let id): guard let identity = app.hostLibrary.identity(id) else { return } customIdentity.selectTelnetIdentity(identity, on: &draft) case .new: route = .telnetIdentity } } } private var telnetIdentities: [Identity] { app.hostLibrary.reusableIdentities.filter(app.hostLibrary.isProxyEligibleOnThisDevice) } private var groupChoice: Binding> { Binding { draft.groupID.map(Choice.existing) ?? .unset } set: { choice in switch choice { case .unset, .custom: draft.groupID = nil case .existing(let id): draft.groupID = id case .new: route = .group } } } private var startupSnippetChoice: Binding { Binding { draft.startupSnippetID.flatMap { app.hostLibrary.snippet($0)?.id } } set: { draft.startupSnippetID = $0 } } private var jumpHostChoices: [Host] { app.hostLibrary.hosts.filter { candidate in candidate.id != draft.id && { var proposed = draft proposed.jumpHostID = candidate.id return app.hostLibrary.validatedRoute(for: proposed) != nil }() } } private struct JumpHostSection: Identifiable { enum ID: Hashable { case group(HostGroup.ID) case ungrouped } let id: ID let title: String let hosts: [Host] } private var jumpHostSections: [JumpHostSection] { let eligibleIDs = Set(jumpHostChoices.map(\.id)) var result: [JumpHostSection] = [] func walk(_ group: HostGroup, prefix: String) { let title = prefix.isEmpty ? group.name : "\(prefix) / \(group.name)" let hosts = app.hostLibrary.hosts(in: group.id).filter { eligibleIDs.contains($0.id) } if !hosts.isEmpty { result.append( JumpHostSection(id: .group(group.id), title: title, hosts: hosts) ) } for child in app.hostLibrary.childGroups(of: group.id) { walk(child, prefix: title) } } for group in app.hostLibrary.rootGroups { walk(group, prefix: "") } let ungrouped = app.hostLibrary.hosts(in: nil).filter { eligibleIDs.contains($0.id) } if !ungrouped.isEmpty { result.append( JumpHostSection( id: .ungrouped, title: String(localized: "Ungrouped"), hosts: ungrouped ) ) } return result } private var proxyKind: Binding { Binding { draft.proxy?.kind } set: { kind in guard let kind else { draft.proxy = nil return } if draft.proxy == nil { draft.proxy = HostProxy(kind: kind, port: kind.defaultPort) } else if draft.proxy?.kind != kind { let oldDefault = draft.proxy?.kind.defaultPort draft.proxy?.kind = kind if draft.proxy?.port == oldDefault { draft.proxy?.port = kind.defaultPort } } } } private var telnetProxyKind: Binding { Binding { draft.telnetProxy?.kind } set: { kind in guard let kind else { draft.telnetProxy = nil return } if draft.telnetProxy == nil { draft.telnetProxy = HostProxy(kind: kind, port: kind.defaultPort) } else if draft.telnetProxy?.kind != kind { let oldDefault = draft.telnetProxy?.kind.defaultPort draft.telnetProxy?.kind = kind if draft.telnetProxy?.port == oldDefault { draft.telnetProxy?.port = kind.defaultPort } } } } private func proxyString(_ keyPath: WritableKeyPath) -> Binding { Binding( get: { draft.proxy?[keyPath: keyPath] ?? "" }, set: { draft.proxy?[keyPath: keyPath] = $0 } ) } private func proxyInt(_ keyPath: WritableKeyPath) -> Binding { Binding( get: { draft.proxy?[keyPath: keyPath] ?? 0 }, set: { draft.proxy?[keyPath: keyPath] = $0 } ) } private func telnetProxyString(_ keyPath: WritableKeyPath) -> Binding { Binding( get: { draft.telnetProxy?[keyPath: keyPath] ?? "" }, set: { draft.telnetProxy?[keyPath: keyPath] = $0 }) } private func telnetProxyInt(_ keyPath: WritableKeyPath) -> Binding { Binding( get: { draft.telnetProxy?[keyPath: keyPath] ?? 0 }, set: { draft.telnetProxy?[keyPath: keyPath] = $0 }) } private var proxyIdentityChoice: Binding> { Binding { if customIdentity.usesCustomProxy { return .custom } return draft.proxy?.identityID.map(Choice.existing) ?? .unset } set: { choice in switch choice { case .unset: customIdentity.clearProxyIdentity(on: &draft) case .custom: customIdentity.selectCustomProxy(on: &draft, using: app.hostLibrary) case .existing(let id): guard let identity = app.hostLibrary.identity(id) else { return } customIdentity.selectProxyIdentity(identity, on: &draft) case .new: route = .proxyIdentity } } } private var telnetProxyIdentityChoice: Binding> { Binding { if customIdentity.usesCustomTelnetProxy { return .custom } return draft.telnetProxy?.identityID.map(Choice.existing) ?? .unset } set: { choice in switch choice { case .unset: customIdentity.clearTelnetProxyIdentity(on: &draft) case .custom: customIdentity.selectCustomTelnetProxy(on: &draft, using: app.hostLibrary) case .existing(let id): guard let identity = app.hostLibrary.identity(id) else { return } customIdentity.selectTelnetProxyIdentity(identity, on: &draft) case .new: route = .telnetProxyIdentity } } } private var proxyIdentities: [Identity] { app.hostLibrary.reusableIdentities.filter(app.hostLibrary.isProxyEligibleOnThisDevice) } private var usesCustomIdentity: Bool { customIdentity.usesCustomSSH(for: draft) } private var footer: some View { HStack(spacing: Metrics.Space.m) { if hostID != nil { Button("Delete", role: .destructive) { confirmsDeletion = true } } if let saveError { Label(saveError, systemImage: "exclamationmark.triangle.fill") .font(.caption) .foregroundStyle(.orange) .lineLimit(2) } Spacer() Button("Cancel", action: cancel) Button("Save") { saveAndClose() } .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) .disabled(!isValid) } .padding(Metrics.Space.l) .termindGlassBar() } /// An identity is not required. A host can be filed before it is known how /// to get into it, and forcing a login to be chosen here would mean either /// inventing one or refusing to save the address. private var isValid: Bool { !draft.host.trimmingCharacters(in: .whitespaces).isEmpty && draft.preferredTerminalProtocol != nil && (!draft.sshEnabled || (1...65_535).contains(draft.sshPort)) && (!draft.telnetEnabled || (1...65_535).contains(draft.telnetPort)) && customIdentity.isProxyValid(for: draft) && customIdentity.isSSHValid(for: draft) && customIdentity.isTelnetValid(for: draft) && customIdentity.isTelnetProxyValid(for: draft) && (!draft.sshEnabled || draft.environmentVariablesAreValid) && (!draft.sshEnabled || draft.sftpChannelInitialPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || draft.sftpChannelInitialPath.hasPrefix("/")) && (!draft.sshEnabled || app.hostLibrary.validatedRoute(for: draft) != nil) } private func load() { guard let hostID, let existing = app.hostLibrary.host(hostID) else { return } draft = existing selectedProtocol = existing.sshEnabled ? .ssh : .telnet } private func loadOnce() { guard !didLoad else { return } didLoad = true load() customIdentity = HostCustomIdentityDraft(host: draft, library: app.hostLibrary) } /// No tag bookkeeping here: `TagField` writes chips straight into /// `draft.tags`, so there is never a half-typed word left to flush. private var isDirty: Bool { draft != original || CustomIdentityState(customIdentity) != originalCustomIdentity } private func commitChanges() -> Bool { guard isDirty else { return true } guard isValid else { saveError = String(localized: "Complete all required fields before saving.") return false } let initialPath = draft.sftpChannelInitialPath.trimmingCharacters(in: .whitespacesAndNewlines) draft.sftpChannelInitialPath = initialPath.isEmpty ? "" : RemotePath.normalized(initialPath) do { try customIdentity.save(into: &draft, using: app.hostLibrary) } catch { saveError = error.localizedDescription return false } original = draft originalCustomIdentity = CustomIdentityState(customIdentity) saveError = nil window.selectVaultItem(.host(draft.id), on: .hosts) return true } private func saveAndClose() { guard commitChanges() else { return } closeTab() } private func cancel() { editingSession.requestTransition( route: .hostEditor(hostID, groupID: initialGroupID), action: closeTab) } /// Return to Hosts so the saved or retained record is immediately visible. private func closeTab() { window.openPage(.hosts) } } private struct HostEnvironmentVariableEditor: View { let original: HostEnvironmentVariable? let usedVariables: Set let back: () -> Void let save: (HostEnvironmentVariable) -> Void @State private var draft: HostEnvironmentVariable init( variable: HostEnvironmentVariable?, usedVariables: Set, back: @escaping () -> Void, save: @escaping (HostEnvironmentVariable) -> Void ) { original = variable self.usedVariables = usedVariables self.back = back self.save = save _draft = State(initialValue: variable ?? HostEnvironmentVariable(variable: "", value: "")) } var body: some View { VaultDetailScaffold( title: original == nil ? "New Environment Variable" : "Edit Environment Variable", symbol: "text.line.first.and.arrowtriangle.forward", close: back, closeLabel: "Back", closeSymbol: "chevron.backward" ) { Form { Section { LabeledContent("Variable") { TextField("Variable", text: $draft.variable.singleLineText) .singleLinePaste() .lineLimit(1) .labelsHidden() .multilineTextAlignment(.trailing) .font(.body.monospaced()) } LabeledContent("Value") { TextField("Value", text: $draft.value.singleLineText) .singleLinePaste() .lineLimit(1) .labelsHidden() .multilineTextAlignment(.trailing) .font(.body.monospaced()) } } } .formStyle(.grouped) } footer: { HStack { Spacer() Button("Cancel", action: back).keyboardShortcut(.cancelAction) Button(original == nil ? "Add" : "Save") { save(draft) back() } .keyboardShortcut(.defaultAction) .disabled(!isValid) } .padding(Metrics.Space.l) } } private var isValid: Bool { draft.isValid && !usedVariables.contains(draft.variable) } }