import SwiftUI // 设计稿 `1b 终端-沉浸轨道` 的浮起 chrome:左上标题胶囊 + 右上状态胶囊 + 底部提示条。 // // 浮起(absolute + blur)而非占一行布局:终端面积最大化是这一屏的唯一目标。 // 终端内容容器顶部留 86pt 预留带给胶囊,预留带用 padding 实现(容器 clipped 防向上溢出)。 /// 浮起胶囊的共用外观:blur 材质 + 半透底 + 常规描边 + 圆角 11。 private struct FloatingCapsule: View { @EnvironmentObject var theme: TXThemeManager @ViewBuilder let content: Content private var p: TXPalette { theme.palette } var body: some View { content .padding(.horizontal, TX.Space.s) .frame(height: 34) .background { RoundedRectangle(cornerRadius: TX.Radius.capsule) .fill(p.surfaceDark.opacity(0.88)) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: TX.Radius.capsule)) } .overlay( RoundedRectangle(cornerRadius: TX.Radius.capsule) .strokeBorder(p.border, lineWidth: 1) ) .shadow(color: .black.opacity(0.55), radius: 15, y: 10) } } /// 左上标题胶囊:红/黄圆点 → 竖分隔 → 状态点 → 主机名 → 「window title · cwd」。 struct TerminalTitleCapsule: View { @ObservedObject var session: TerminalSession let onClose: () -> Void let onMinimize: () -> Void @EnvironmentObject var theme: TXThemeManager private var p: TXPalette { theme.palette } var body: some View { FloatingCapsule { HStack(spacing: TX.Space.xs) { windowDot(TXWindowDot.close, "关闭会话", action: onClose) windowDot(TXWindowDot.minimize, "最小化", action: onMinimize) Rectangle().fill(p.border).frame(width: 1, height: 14) .padding(.horizontal, 2) TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 6) Text(session.displayName) .font(TX.Font.mono(12.5, .medium)) .foregroundStyle(p.text) .lineLimit(1) if !contextLine.isEmpty { Text(contextLine) .font(TX.Font.mono(11.5)) .foregroundStyle(p.textWeak) .lineLimit(1) } // 终端格子数(设计稿 08 的「· 168×44」)。首次布局后稳定,故不必 @Published。 Text(gridLabel) .font(TX.Font.mono(11.5)) .foregroundStyle(p.textFaint) } } } /// 「当前 window title · cwd」(设计稿 3a)。无 tmux 时退回连接状态文案。 private var contextLine: String { guard let t = session.tmuxSummary else { return session.isLive ? "" : session.phaseText } var parts: [String] = [] if !t.currentTitle.isEmpty { parts.append(t.currentTitle) } if !t.cwd.isEmpty { parts.append(Self.abbreviate(t.cwd)) } return parts.joined(separator: " · ") } /// `/Users/yz/src/x` → `~/src/x`(终端习惯写法,设计稿即此形态)。 static func abbreviate(_ path: String) -> String { let home = NSHomeDirectory() if !home.isEmpty, path.hasPrefix(home) { return "~" + path.dropFirst(home.count) } // 远端 home 与本机不同,按常见前缀兜底。 for prefix in ["/home/", "/Users/"] where path.hasPrefix(prefix) { let rest = path.dropFirst(prefix.count) if let slash = rest.firstIndex(of: "/") { return "~" + rest[slash...] } return "~" } return path } private var gridLabel: String { let g = session.screenGrid.value return "\(g.cols)×\(g.rows)" } private func windowDot(_ color: Color, _ label: String, action: @escaping () -> Void) -> some View { Button(action: action) { Circle() .fill(color) .frame(width: 13, height: 13) .overlay(Circle().strokeBorder(Color.black.opacity(0.25), lineWidth: 0.5)) .frame(width: 22, height: 30) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel(label) } } /// 右上状态胶囊:三格分层徽标 `tsnet | mosh | tmux`,格间 1×16pt 竖线。 /// /// **只染出问题的那层**:网络层不可达 → 第一格转琥珀;重连中 → 第二格转琥珀;tmux 缺失 → 第三格 /// 写「无 tmux · 客户端窗口」而非报错。首版第二格不显示 RTT 数字(见 HANDOFF §12.4)。 struct TerminalStatusCapsule: View { @ObservedObject var session: TerminalSession @EnvironmentObject var theme: TXThemeManager private var p: TXPalette { theme.palette } var body: some View { FloatingCapsule { HStack(spacing: 0) { cell(egressText, color: egressColor, dot: true) separator cell(transportText, color: transportColor, dot: false) separator cell(tmuxText, color: p.text2, dot: false) } } } private var separator: some View { Rectangle().fill(p.border).frame(width: 1, height: 16) .padding(.horizontal, TX.Space.s) } private func cell(_ text: String, color: Color, dot: Bool) -> some View { HStack(spacing: 5) { if dot { TXStatusDot(color: color, size: 6) } Text(text) .font(TX.Font.mono(12, .medium)) .foregroundStyle(color) .lineLimit(1) } } // 第一格:网络可达性(tsnet 出口 / 直连) private var egressText: String { switch session.link { case .relay: "tsnet" case .direct: "direct" case .connecting: "连接中" case .reconnecting: "tsnet" case .offline: "离线" } } private var egressColor: Color { switch session.link { case .direct, .relay: p.online case .connecting: p.text4 case .reconnecting: p.reconnecting case .offline: p.offline } } // 第二格:传输层(mosh / SSH) private var transportText: String { if case .reconnecting(let n) = session.link { return "\(session.moshEngaged ? "mosh" : "ssh") 重连 \(n)" } return session.moshEngaged ? "mosh" : "ssh" } private var transportColor: Color { if case .reconnecting = session.link { return p.reconnecting } // 设计:这一格的文字是中性色,只有 RTT 数字才染绿/琥珀(首版无 RTT → 全中性)。 return p.text2 } // 第三格:会话复用层 private var tmuxText: String { if let t = session.tmuxSummary { return t.name.isEmpty ? "tmux" : "tmux \(t.name)" } // 还在探测 / 等用户选会话时别谎报「无 tmux」——那是探测确定不可用后才成立的结论。 if session.pendingSessionChoice != nil { return "选择 tmux 会话…" } if session.tmuxSkipped { return "原生窗口 · 不经 tmux" } if session.tmuxUnavailable { return "无 tmux · 客户端窗口" } return session.isLive ? "检测 tmux…" : "—" } } /// 终端区的**非内容态**覆盖层:连接中 / 失败 / 已关闭。 /// /// 真实连接不是瞬间的:解析凭据 → tsnet Up → dial → 认证 → (mosh 引导)→ tmux attach, /// 期间终端一片空白。这层给出「现在在干什么、失败了能怎么办」,避免用户对着黑屏猜。 /// 已连接(含重连中,屏幕内容还在)时**不显示**,不遮挡终端。 struct TerminalStateOverlay: View { @ObservedObject var session: TerminalSession let onRetry: () -> Void let onBack: () -> Void /// 取消正在进行的连接(用户不想再等)。 let onCancel: () -> Void @EnvironmentObject var theme: TXThemeManager /// 连接已等待秒数 —— 真实连接可能要走 tsnet Up / mosh 引导,等待要可见、可取消。 @State private var waited = 0 private var p: TXPalette { theme.palette } /// 失败态:phaseText 由 SessionMachine 给出(`失败:…`)。 private var failure: String? { session.phaseText.hasPrefix("失败") ? String(session.phaseText.dropFirst(3)) : nil } private var isConnecting: Bool { !session.isLive && failure == nil && session.phaseText != "已关闭" } var body: some View { if let failure { state(icon: "exclamationmark.triangle", tint: p.danger, title: "连接失败", detail: failure) { HStack(spacing: TX.Space.s) { TXButton(title: "返回首页", action: onBack) TXButton(title: "重试", emphasized: true, action: onRetry) } } } else if session.phaseText == "已关闭" { state(icon: "power", tint: p.textWeak, title: "会话已关闭", detail: "服务端连接已断开。") { HStack(spacing: TX.Space.s) { TXButton(title: "返回首页", action: onBack) TXButton(title: "重新连接", emphasized: true, action: onRetry) } } } else if isConnecting { state(icon: nil, tint: p.text4, title: session.phaseText, detail: session.banner.isEmpty ? connectHint : session.banner) { VStack(spacing: TX.Space.xs) { if waited >= 3 { Text("已等待 \(waited)s") .font(TX.Font.mono(11)) .foregroundStyle(p.textFaint) } if waited >= 5 { // 等久了才给取消:避免正常的 1–2s 连接闪现一个按钮。 TXButton(title: "取消连接", action: onCancel) } } } .task(id: session.phaseText) { waited = 0 while !Task.isCancelled { try? await Task.sleep(nanoseconds: 1_000_000_000) waited += 1 } } } } /// 分阶段提示:让等待有解释(尤其 tsnet 首次 Up 与 mosh 引导都要数秒)。 private var connectHint: String { switch session.link { case .relay: "正在经 Tailscale 出口建立连接…" case .connecting, .direct: session.moshEngaged ? "正在引导 mosh…" : "正在建立 SSH 连接…" case .reconnecting(let n): "第 \(n) 次重连中…" case .offline: "等待连接…" } } private func state(icon: String?, tint: Color, title: String, detail: String, @ViewBuilder actions: () -> Actions) -> some View { VStack(spacing: TX.Space.s) { if let icon { Image(systemName: icon) .font(.system(size: 26, weight: .light)) .foregroundStyle(tint) } else { ProgressView().controlSize(.regular).tint(TXAccent.base) } Text(title) .font(TX.Font.mono(13, .medium)) .foregroundStyle(p.text) Text(detail) .font(TX.Font.mono(11.5)) .foregroundStyle(p.textWeak) .multilineTextAlignment(.center) .lineLimit(3) .frame(maxWidth: 420) actions() .padding(.top, TX.Space.xs) } .padding(TX.Space.l) .frame(maxWidth: .infinity, maxHeight: .infinity) } } /// 无 tmux 主机的行内横幅(设计稿 `2c`):说明窗口由 App 维持、mosh 兜底,但**关掉 App 不会在 /// 服务端继续跑**;右侧给「安装并启用 tmux」次要按钮。**不弹窗、不强推**,可关闭。 struct NoTmuxBanner: View { let onInstall: () -> Void let onDismiss: () -> Void @EnvironmentObject var theme: TXThemeManager private var p: TXPalette { theme.palette } var body: some View { HStack(spacing: TX.Space.s) { Image(systemName: "info.circle") .font(.system(size: 12)) .foregroundStyle(p.warning) Text("未检测到 tmux · 当前是客户端窗口:mosh 能兜断线,但**关掉 App 后服务端不会继续跑**") .font(TX.Font.mono(11.5)) .foregroundStyle(p.text3) .lineLimit(1) Spacer(minLength: TX.Space.s) TXButton(title: "安装并启用 tmux", action: onInstall) Button(action: onDismiss) { Image(systemName: "xmark") .font(.system(size: 11, weight: .medium)) .foregroundStyle(p.textWeak) .frame(width: 28, height: 28) .contentShape(Rectangle()) } .buttonStyle(.plain) } .padding(.horizontal, TX.Space.s) .frame(height: 40) .background(p.surfaceDark.opacity(0.92)) .overlay(alignment: .bottom) { Rectangle().fill(p.divider).frame(height: 1) } } } /// 「安装并启用 tmux」的引导(点横幅按钮后):给出各发行版命令,用户可一键塞进终端执行。 struct TmuxInstallSheet: View { let onRun: (String) -> Void let onCancel: () -> Void @EnvironmentObject var theme: TXThemeManager private var p: TXPalette { theme.palette } /// 常见包管理器。跨发行版探测一条命令搞定:有哪个用哪个。 private let combined = "command -v apt-get >/dev/null && sudo apt-get install -y tmux || " + "command -v dnf >/dev/null && sudo dnf install -y tmux || " + "command -v pacman >/dev/null && sudo pacman -S --noconfirm tmux || " + "command -v apk >/dev/null && sudo apk add tmux || " + "command -v brew >/dev/null && brew install tmux" var body: some View { TXModalScrim(onDismiss: onCancel) { VStack(alignment: .leading, spacing: TX.Space.s) { Text("安装 tmux") .font(TX.Font.mono(15, .medium)) .foregroundStyle(p.text) Text("tmux 让会话留在服务端:关掉 App、换网络、睡一觉回来都能原样接回。\n下面这条会按服务器的包管理器自动选择(需要 sudo 权限)。") .font(TX.Font.mono(12)) .foregroundStyle(p.text3) .lineSpacing(4) Text(combined) .font(TX.Font.mono(10.5)) .foregroundStyle(p.text4) .padding(TX.Space.s) .frame(maxWidth: .infinity, alignment: .leading) .background(p.bgDeep, in: RoundedRectangle(cornerRadius: 8)) HStack(spacing: TX.Space.s) { Spacer() TXButton(title: "取消", action: onCancel) TXButton(title: "在终端执行", emphasized: true) { onRun(combined) } } .padding(.top, TX.Space.xs) } .padding(TX.Space.l) .frame(width: 520) .background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog)) .overlay( RoundedRectangle(cornerRadius: TX.Radius.dialog) .strokeBorder(p.borderStrong, lineWidth: 1) ) .shadow(color: .black.opacity(0.7), radius: 40, y: 30) } } } /// 底部提示条(34pt):接硬件键盘时只留这条快捷键清单;触控时由键盘附加行替换(UI-5)。 struct TerminalHintBar: View { /// 左侧上下文说明(如「Pin 态 · 终端变窄,仅在钉住/解除时 resize 一次」)。 let context: String let hints: [(key: String, label: String)] @EnvironmentObject var theme: TXThemeManager private var p: TXPalette { theme.palette } var body: some View { HStack(spacing: TX.Space.s) { Text(context) .font(TX.Font.mono(11.5)) .foregroundStyle(p.textWeak) .lineLimit(1) Spacer(minLength: TX.Space.m) ForEach(hints, id: \.key) { hint in HStack(spacing: 5) { Text(hint.key) .font(TX.Font.mono(11.5, .medium)) .foregroundStyle(p.text4) Text(hint.label) .font(TX.Font.mono(11.5)) .foregroundStyle(p.textWeak) } } } .padding(.horizontal, TX.Space.m) .frame(height: TX.Layout.footHeight) } }