Files
Termind/Frameworks/TermindPresentation/HostIntegrationSection.swift
T
Kaede Akino 8e3f1128da feat(sessions): add tmux persistence and terminal context tracking
Add extensible remote session backends, safe ownership and restore handling, and pane-scoped shell integration over the existing SSH connection.

Keep iOS workspaces in memory and rebuild expired Activity targets on demand. Integrate session selection into Connection Journey with consistent controls, cancellation behavior, and localized counts.
2026-10-16 01:04:12 +08:00

196 lines
7.8 KiB
Swift

import SwiftUI
import TermindDomain
public struct HostSessionPersistenceSection: View {
@Binding private var preferences: SessionPersistencePreferences
public init(preferences: Binding<SessionPersistencePreferences>) { _preferences = preferences }
public var body: some View {
Section(AppStrings.SessionPersistence.section.swiftUIKey) {
Toggle(isOn: $preferences.enabled) { Text(AppStrings.Common.enabled) }
Picker(AppStrings.SessionPersistence.backend.swiftUIKey, selection: $preferences.backend) {
Text(verbatim: "tmux").tag(SessionPersistenceBackendID.tmux)
if preferences.backend != .tmux {
Text(verbatim: preferences.backend.rawValue).tag(preferences.backend)
}
}
.pickerStyle(.menu)
.menuOrder(.fixed)
.disabled(!preferences.enabled)
Picker(AppStrings.SessionPersistence.onConnect.swiftUIKey, selection: $preferences.onConnect) {
Text(AppStrings.SessionPersistence.ask).tag(SessionPersistencePreferences.OnConnect.ask)
Text(AppStrings.SessionPersistence.create).tag(SessionPersistencePreferences.OnConnect.createManaged)
Text(AppStrings.SessionPersistence.normalShell).tag(SessionPersistencePreferences.OnConnect.plainShell)
}
.pickerStyle(.menu)
.menuOrder(.fixed)
.disabled(!preferences.enabled)
}
}
}
public struct HostIntegrationSection: View {
@Binding private var mode: HostShellIntegrationMode
public init(mode: Binding<HostShellIntegrationMode>) { _mode = mode }
public var body: some View {
Section(AppStrings.Settings.integration.swiftUIKey) {
Picker(AppStrings.Settings.shellIntegration.swiftUIKey, selection: $mode) {
Text("Default").tag(HostShellIntegrationMode.followGlobalSettings)
Text(AppStrings.Settings.enable).tag(HostShellIntegrationMode.enabled)
Text("Disable").tag(HostShellIntegrationMode.disabled)
}
.pickerStyle(.menu)
.menuOrder(.fixed)
}
}
}
public struct HostSessionSection: View {
@Binding private var charset: RemoteCharset
@Binding private var sftpInitialPath: String
@Binding private var agentForwardingEnabled: Bool
@Binding private var sessionPersistence: SessionPersistencePreferences
@Binding private var startupSnippetID: Snippet.ID?
@Binding private var environmentVariables: [HostEnvironmentVariable]
private let allowsAgentForwarding: Bool
private let snippets: [Snippet]
private let snippetPackagePath: (SnippetPackage.ID?) -> String?
private let editEnvironmentVariable: (Int?) -> Void
public init(
charset: Binding<RemoteCharset>,
sftpInitialPath: Binding<String>,
startupSnippetID: Binding<Snippet.ID?> = .constant(nil),
agentForwardingEnabled: Binding<Bool> = .constant(false),
sessionPersistence: Binding<SessionPersistencePreferences> = .constant(.init()),
allowsAgentForwarding: Bool = true,
environmentVariables: Binding<[HostEnvironmentVariable]> = .constant([]),
snippets: [Snippet] = [],
snippetPackagePath: @escaping (SnippetPackage.ID?) -> String? = { _ in nil },
editEnvironmentVariable: @escaping (Int?) -> Void = { _ in }
) {
_charset = charset
_sftpInitialPath = sftpInitialPath
_startupSnippetID = startupSnippetID
self.allowsAgentForwarding = allowsAgentForwarding
_agentForwardingEnabled = agentForwardingEnabled
_sessionPersistence = sessionPersistence
_environmentVariables = environmentVariables
self.snippets = snippets
self.snippetPackagePath = snippetPackagePath
self.editEnvironmentVariable = editEnvironmentVariable
}
public var body: some View {
Section(AppStrings.Vault.session.swiftUIKey) {
Picker(AppStrings.Vault.charset.swiftUIKey, selection: $charset) {
ForEach(RemoteCharset.allCases) { charset in
Text(charset.displayName).tag(charset)
}
}
.pickerStyle(.menu)
.menuOrder(.fixed)
Picker("Startup Snippet", selection: $startupSnippetID) {
Text(AppStrings.Common.none).tag(Snippet.ID?.none)
if !snippets.isEmpty {
Divider()
ForEach(snippets) { snippet in
Text(snippetLabel(snippet)).tag(Snippet.ID?.some(snippet.id))
}
}
}
.pickerStyle(.menu)
.menuOrder(.fixed)
if allowsAgentForwarding {
Toggle(isOn: $agentForwardingEnabled) { Text(AppStrings.Connections.agentForwarding) }
}
LabeledContent("SFTP Initial Path") {
TextField("SFTP Initial Path", text: $sftpInitialPath.singleLineText, prompt: Text("~"))
.singleLinePaste()
.lineLimit(1)
.labelsHidden()
.multilineTextAlignment(.trailing)
.font(.body.monospaced())
#if os(iOS)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
#endif
}
}
HostSessionPersistenceSection(preferences: $sessionPersistence)
Section("Environment Variables") {
ForEach(environmentVariables.indices, id: \.self) { index in
environmentVariableRow(at: index)
}
Button {
editEnvironmentVariable(nil)
} label: {
HStack(spacing: 8) {
Text("Add Environment Variable")
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
.accessibilityHidden(true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
@ViewBuilder
private func environmentVariableRow(at index: Int) -> some View {
#if os(iOS)
Button {
editEnvironmentVariable(index)
} label: {
environmentVariableLabel(at: index)
}
.buttonStyle(.plain)
.swipeActions {
Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) {
environmentVariables.remove(at: index)
}
}
#else
HStack(spacing: 8) {
Button {
editEnvironmentVariable(index)
} label: {
environmentVariableLabel(at: index)
}
.buttonStyle(.plain)
Button(AppStrings.Common.delete.swiftUIKey, systemImage: "xmark", role: .destructive) {
environmentVariables.remove(at: index)
}
.labelStyle(.iconOnly)
.buttonStyle(.borderless)
}
#endif
}
private func environmentVariableLabel(at index: Int) -> some View {
Text("\(environmentVariables[index].variable)=\(environmentVariables[index].value)")
.font(.body.monospaced())
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
private func snippetLabel(_ snippet: Snippet) -> String {
guard let path = snippetPackagePath(snippet.packageID), !path.isEmpty else {
return snippet.title
}
return "\(path) / \(snippet.title)"
}
}