import SwiftUI import WireProtocol /// T-iOS-11 · Connection-state banner over the terminal (plan §1: the terminal /// must NEVER "look connected but dead" — every non-live state is explicit). /// /// Render input is the `Model` enum only; the mapping from engine events to a /// `Model` lives in `TerminalViewModel.bannerModel` (tested there). struct ReconnectBanner: View { /// What the banner says. Terminal phases (`failed`/`exited`) win over /// transient connection states (mapping in `TerminalViewModel.bannerModel`). enum Model: Equatable { case connecting case reconnecting(attempt: Int, retryIn: Duration) /// Non-retryable failure: actionable copy, NO spinner, NO retry loop. case failed(message: String) /// Session over — terminal is read-only from here on. case exited(code: Int, reason: String?) } let model: Model /// T-iOS-29 · "开新会话" on the EXITED banner only (session over → the /// natural next step; manager.ts:145-153 — the old session is done for /// good). nil = affordance hidden. Not offered on `.failed`: that copy /// asks the user to change a knob first, not to spawn again. var onNewSession: (@MainActor () -> Void)? = nil private enum Copy { static let newSession = "开新会话" } /// 精致原生:材质胶囊 + 发丝描边,状态只由前导指示色(+ 图标形状)表达, /// 而非整块告警底色 —— 重连读作「安静的进行中」,不惊扰(GROUP BRIEF)。 var body: some View { HStack(spacing: DS.Space.sm8) { leadingIndicator Text(text) .font(DS.Typography.callout) .foregroundStyle(DS.Palette.textPrimary) .multilineTextAlignment(.leading) if let onNewSession, Self.isNewSessionActionAvailable(for: model) { Button(Copy.newSession) { onNewSession() } .font(DS.Typography.callout.weight(.semibold)) .foregroundStyle(DS.Palette.accent) .buttonStyle(.plain) .accessibilityIdentifier("terminal.exitNewSessionButton") } } .padding(.horizontal, DS.Space.lg16) .padding(.vertical, DS.Space.sm8) .background(.regularMaterial, in: Capsule()) .overlay( Capsule().strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline) ) .accessibilityElement(children: .combine) } /// Spinner while a connection is in flight, otherwise the state's icon — /// both painted in the semantic indicator color (shape + color, never color /// alone). @ViewBuilder private var leadingIndicator: some View { if isSpinning { ProgressView() .controlSize(.small) .tint(indicatorColor) } else { Image(systemName: iconName) .foregroundStyle(indicatorColor) } } /// The new-session affordance appears on the `.exited` banner only — /// pure predicate so the T-iOS-29 tests pin the rule. static func isNewSessionActionAvailable(for model: Model) -> Bool { if case .exited = model { return true } return false } private var isSpinning: Bool { switch model { case .connecting, .reconnecting: return true case .failed, .exited: return false } } private var iconName: String { switch model { case .failed: return "exclamationmark.octagon.fill" case .exited: return "flag.checkered" case .connecting, .reconnecting: return "wifi" } } /// Semantic indicator color (DS only): connecting is quiet gray, reconnecting /// is the calm accent (not an orange alarm), failed is the stuck red, exited /// is dimmed secondary. Paired with a distinct icon so it is never color-only. private var indicatorColor: Color { switch model { case .connecting: return DS.Palette.textSecondary case .reconnecting: return DS.Palette.accent case .failed: return DS.Palette.statusStuck case .exited: return DS.Palette.statusExited } } private var text: String { switch model { case .connecting: return "连接中…" case .reconnecting(let attempt, let retryIn): return "连接已断开 · 第 \(attempt) 次重连将在 \(retryIn.components.seconds)s 后…" case .failed(let message): return message case .exited(let code, let reason): return Self.exitText(code: code, reason: reason) } } /// Exit copy: spawn failure (-1, M4) reads as an error; a normal exit reads /// as a conclusion. `reason` is server-supplied display text (untrusted — /// rendered as plain text only). private static func exitText(code: Int, reason: String?) -> String { if code == WireConstants.spawnFailedExitCode { return "启动 shell 失败:\(reason ?? "未知原因")" } let suffix = reason.map { "(\($0))" } ?? "" return "会话已退出 exit \(code)\(suffix) · 终端已只读" } }