Files
Termind/Frameworks/TermindPresentation/ConnectionJourneySections.swift
T
2026-09-22 01:13:35 +08:00

226 lines
7.5 KiB
Swift

import SwiftUI
import TermindApplication
nonisolated enum ConnectionJourneySymbolPlacement {
case header
case routeDestination
}
extension ConnectionPurpose {
nonisolated func symbol(for placement: ConnectionJourneySymbolPlacement) -> String {
switch (self, placement) {
case (.sshSession, _): "apple.terminal.fill"
case (.telnetSession, _): "text.rectangle.fill"
case (.sftpChannel, .header): "folder.fill.badge.gearshape"
case (.sftpChannel, .routeDestination): "folder.fill"
case (.serialSession, _): "cable.connector"
case (.metrics, _): "waveform.path.ecg"
case (.portForwarding, _): "arrow.left.arrow.right"
}
}
}
public struct ConnectionJourneyHeader: View {
let journey: ConnectionJourney
let selectedForeground: Color
let adaptsLogButtonForAccessibility: Bool
let isEmbedded: Bool
let toggleLogs: () -> Void
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
public init(
journey: ConnectionJourney,
selectedForeground: Color = .white,
adaptsLogButtonForAccessibility: Bool = true,
isEmbedded: Bool = false,
toggleLogs: @escaping () -> Void
) {
self.journey = journey
self.selectedForeground = selectedForeground
self.adaptsLogButtonForAccessibility = adaptsLogButtonForAccessibility
self.isEmbedded = isEmbedded
self.toggleLogs = toggleLogs
}
public var body: some View {
HStack(spacing: 14) {
RoundedRectangle(cornerRadius: 13, style: .continuous)
.fill(Color.accentColor.gradient)
.frame(width: 52, height: 52)
.overlay {
Image(systemName: journey.purpose.symbol(for: .header))
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(selectedForeground)
}
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.title3.weight(.semibold))
.lineLimit(1)
.help(title)
Text(detail)
.font(.callout.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.help(detail)
}
Spacer(minLength: 6)
Button(action: toggleLogs) {
if isEmbedded || (adaptsLogButtonForAccessibility && dynamicTypeSize.isAccessibilitySize) {
Image(systemName: journey.showsLogs ? "eye.slash" : "list.bullet.rectangle")
.accessibilityLabel(journey.showsLogs ? Text("Hide Logs") : Text("Show Logs"))
} else {
Text(journey.showsLogs ? "Hide Logs" : "Show Logs")
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
}
.buttonStyle(.bordered)
.controlSize(.large)
}
.padding(.top, isEmbedded ? 16 : 30)
.padding(.horizontal, isEmbedded ? 16 : 34)
}
private var title: String { journey.destination?.name ?? String(localized: "SSH Connection") }
private var detail: String {
let protocolName: String =
switch journey.purpose {
case .sshSession, .metrics, .portForwarding: "SSH"
case .telnetSession: "Telnet"
case .sftpChannel: "SFTP"
case .serialSession: String(localized: "Serial")
}
let address = journey.destination?.address ?? ""
if journey.mode == .reconnection {
return String(localized: "Reconnecting \(protocolName) · \(address)")
}
return "\(protocolName) \(address)"
}
}
public struct ConnectionJourneyStatus: View {
let journey: ConnectionJourney
let accent: Color
let danger: Color
public init(journey: ConnectionJourney, accent: Color = .accentColor, danger: Color = .red) {
self.journey = journey
self.accent = accent
self.danger = danger
}
public var body: some View {
HStack(spacing: 9) {
Image(systemName: symbol).foregroundStyle(tint)
Text(text).font(.headline)
}
.padding(.top, 8)
}
private var text: String {
switch journey.phase {
case .connecting: String(localized: "Establishing connection…")
case .waiting: String(localized: "Waiting for you…")
case .succeeded:
switch journey.purpose {
case .sshSession, .telnetSession, .serialSession: String(localized: "Terminal ready")
case .sftpChannel: String(localized: "File browser ready")
case .metrics: String(localized: "Metrics ready")
case .portForwarding: String(localized: "Forwarding ready")
}
case .failed(let message): message
case .hidden: ""
}
}
private var symbol: String {
switch journey.phase {
case .succeeded: "checkmark.circle.fill"
case .failed: "exclamationmark.triangle.fill"
default: "lock.shield"
}
}
private var tint: Color {
switch journey.phase {
case .succeeded: accent
case .failed: danger
default: .secondary
}
}
}
public struct ConnectionJourneyReconnectStatus: View {
let state: ReconnectState
let accent: Color
let retryNow: () -> Void
let stop: () -> Void
public init(
state: ReconnectState,
accent: Color = .accentColor,
retryNow: @escaping () -> Void,
stop: @escaping () -> Void
) {
self.state = state
self.accent = accent
self.retryNow = retryNow
self.stop = stop
}
public var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { context in
VStack(spacing: 12) {
HStack(spacing: 9) {
Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90")
.foregroundStyle(accent)
Text(statusText(at: context.date)).font(.headline)
}
Text(state.lastError)
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(2)
.multilineTextAlignment(.center)
.textSelection(.enabled)
ViewThatFits(in: .horizontal) {
HStack(spacing: 10) {
retryButton
stopButton
}
VStack(spacing: 10) {
retryButton
stopButton
}
}
.controlSize(.large)
}
.padding(.top, 4)
}
}
private var retryButton: some View {
Button("Retry Now", action: retryNow)
.buttonStyle(.borderedProminent)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
private var stopButton: some View {
Button("Stop Reconnecting", role: .cancel, action: stop)
.buttonStyle(.bordered)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
private func statusText(at date: Date) -> String {
let seconds = max(0, Int(ceil(state.nextRetry.timeIntervalSince(date))))
if seconds == 0 { return String(localized: "Reconnect attempt \(state.attempt)…") }
return String(localized: "Reconnect attempt \(state.attempt) · retrying in \(seconds)s")
}
}