- 顶带:实心圆窗口按钮、无边框 tab 胶囊、右上 mosh/SSH 协议徽标 - 减法:删「新建 window/回首页」按钮、tab 组 +、pane 拖拽调宽, window/pane 管理交还 tmux(连带清理 resizePane/newWindow/ui-resize) - 底部状态行去重:只留链路 + tmux 概览,协议由顶带徽标独占 - 徽标全局统一:淡染底+同色文字胶囊(线框废除),拼写走 TXTech 规范 - CLAUDE.md 记录无头截图横屏帧缓冲右侧裁切大坑 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
517 lines
23 KiB
Swift
517 lines
23 KiB
Swift
import SwiftUI
|
||
|
||
// 终端顶带(`1b` 的浮起胶囊已按用户裁决落地为**真实 chrome 行**):按钮、标题、tab 全在
|
||
// 终端**外**的 bgDeep 带上,终端 pane 从带下方开始 —— 不再有 86pt 预留带、不再遮内容。
|
||
//
|
||
// 视觉语言对齐 Shimo 参考(用户第二轮裁决):**实心软圆按钮 + 无边框淡染胶囊**,
|
||
// 全带不再出现描边方框 —— 分组靠留白与底色深浅,不靠画框。
|
||
// [● ● 实心圆按钮] · 弱化标题(· 分隔的一句话) · [无边框 tab 胶囊组] …… [Mosh/SSH 徽标]
|
||
// tab 组 = tmux window 的切换胶囊(window 映射原生 tab 的惯例);活动会话切换在侧边栏。
|
||
|
||
/// 终端顶带:实心圆窗口按钮 + 弱化标题 + tmux window tab 胶囊 + 传输徽标。
|
||
///
|
||
/// 要点(Shimo 化第三轮):① ✕/⌄ 是**实心圆**(macOS 交通灯的色语义 + 常显深色字形),
|
||
/// 不再是描边方块;② 标题弱化成 `主机名 · cwd · 格子数` 一句话,各段逐级降灰、`·` 分隔;
|
||
/// ③ tab 胶囊无槽无描边,选中 = accent 淡染胶囊浮起;④ 尾部 Mosh/SSH 淡染徽标
|
||
/// —— 一眼确认传输方式(mosh 断线可存活)就在手边。
|
||
struct TerminalTopBar: 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 {
|
||
HStack(spacing: TX.Space.m) {
|
||
// 顺序沿用 macOS 的肌肉记忆(关闭在左、最小化在右)。
|
||
HStack(spacing: TX.Space.s) {
|
||
windowButton(icon: "xmark", tint: TXWindowDot.close,
|
||
label: "关闭会话", action: onClose)
|
||
// 向下的 chevron:对上「下滑最小化 = 缩回首页卡片」的手势语义。
|
||
windowButton(icon: "chevron.down", tint: TXWindowDot.minimize,
|
||
label: "最小化到首页", action: onMinimize)
|
||
}
|
||
titleLine
|
||
TmuxWindowTabGroup(session: session)
|
||
Spacer(minLength: 0)
|
||
}
|
||
.frame(height: 40)
|
||
// 徽标走 overlay 而不是 HStack 尾巴:tab 组的 ScrollView 会贪掉剩余宽度,让尾巴上的
|
||
// 兄弟布局不稳;悬浮在右端与 tab 数量无关,chip 多了从徽标下面滚过(徽标带实底)。
|
||
.overlay(alignment: .trailing) { transportBadge }
|
||
}
|
||
|
||
/// 弱化标题行:状态点 + `主机名 · cwd · 格子数`,一句话逐级降灰、`·` 分隔(Shimo 的
|
||
/// 状态文案写法)。主机身份已由侧边栏选中行承担,这里只是就近确认。
|
||
private var titleLine: some View {
|
||
HStack(spacing: TX.Space.xs) {
|
||
TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 5)
|
||
Text(session.displayName)
|
||
.font(TX.Font.mono(12, .medium))
|
||
.foregroundStyle(p.text3)
|
||
.lineLimit(1)
|
||
if !contextLine.isEmpty {
|
||
dotSeparator
|
||
Text(contextLine)
|
||
.font(TX.Font.mono(11))
|
||
.foregroundStyle(p.textWeak)
|
||
.lineLimit(1)
|
||
}
|
||
dotSeparator
|
||
// 终端格子数。侧边栏开合会 resize —— 数字经 GridBox.onGridChange →
|
||
// session.objectWillChange 跟着刷新。
|
||
Text(gridLabel)
|
||
.font(TX.Font.mono(10.5))
|
||
.foregroundStyle(p.textFaint)
|
||
}
|
||
}
|
||
|
||
private var dotSeparator: some View {
|
||
Text("·")
|
||
.font(TX.Font.mono(11))
|
||
.foregroundStyle(p.textFaint)
|
||
}
|
||
|
||
/// 传输协议徽标(Shimo 右上角同款:淡染底+同色文字、无描边)。这是 mosh/SSH 的
|
||
/// **唯一**出口——底部状态行不再重复(协议是连接的静态属性,链路动态归左下)。
|
||
/// 拼写与配色走 `TXTech`(mosh 小写、SSH 全大写)。连接建立前不显示(还不知道走什么)。
|
||
@ViewBuilder private var transportBadge: some View {
|
||
if session.isLive || session.moshEngaged {
|
||
let t = TXTech.transport(session.moshEngaged, p)
|
||
Text(t.text)
|
||
.font(TX.Font.mono(11.5, .medium))
|
||
.foregroundStyle(t.tint)
|
||
.padding(.horizontal, 12)
|
||
.frame(height: 26)
|
||
// 双层底:tint 淡染在上、bgDeep 实底垫底(badge 悬浮在 tab 滚动区之上,
|
||
// 不垫实底会透出滚过的 chip)。
|
||
.background(t.tint.opacity(0.14), in: Capsule())
|
||
.background(p.bgDeep, in: Capsule())
|
||
}
|
||
}
|
||
|
||
/// 标题的上下文只留 cwd —— 当前 window title 已由 tab 组的选中 chip 承担,写两遍是重复。
|
||
/// 无 tmux(没有 tab 组)时退回连接状态文案。
|
||
private var contextLine: String {
|
||
guard let t = session.tmuxSummary else {
|
||
return session.isLive ? "" : session.phaseText
|
||
}
|
||
return t.cwd.isEmpty ? "" : Self.abbreviate(t.cwd)
|
||
}
|
||
|
||
/// `/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 {
|
||
// tmux 模式读 controller 的客户端格子(raw 的 screenGrid 随 RawTerminalView 卸载而冻结,
|
||
// 侧边栏开合后会显示旧值);raw 模式仍读 surface 实报的 screenGrid。
|
||
if let g = session.tmuxController?.clientGrid, g.cols > 0 {
|
||
return "\(g.cols)×\(g.rows)"
|
||
}
|
||
let g = session.screenGrid.value
|
||
return "\(g.cols)×\(g.rows)"
|
||
}
|
||
|
||
/// 关闭 / 最小化按钮:**实心圆**(Shimo 同款)+ 常显深色字形(触屏没有 hover,
|
||
/// 纯色点说明不了自己)。颜色语义保留(红=关闭、琥珀=最小化),饱和圆面自己就是容器,
|
||
/// 不再描边画框。
|
||
private func windowButton(icon: String, tint: Color, label: String,
|
||
action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
Image(systemName: icon)
|
||
.font(.system(size: 11, weight: .bold))
|
||
.foregroundStyle(Color.black.opacity(0.55))
|
||
.frame(width: 26, height: 26)
|
||
.background(tint, in: Circle())
|
||
.frame(width: 30, height: 40)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.txPressTight)
|
||
.accessibilityLabel(label)
|
||
}
|
||
}
|
||
|
||
/// tmux window 的 **tab 组**:无槽无描边的胶囊排(Shimo 化第三轮 —— 旧「描边槽 + 描边
|
||
/// chip」被用户裁决砍掉:暗底上层层画框显脏)。
|
||
///
|
||
/// 「组」的语义靠**相邻**表达:选中 chip 是唯一的 accent 淡染胶囊(浮起),未选 chip 纯文字
|
||
/// 贴底;尾部 `+` 新建 window(浏览器 tab 组惯例)。
|
||
/// controller 未 attach 但已知 tmux 概览时按占位渲染(接回前 / fixture),原生窗口不显示。
|
||
struct TmuxWindowTabGroup: View {
|
||
@ObservedObject var session: TerminalSession
|
||
|
||
var body: some View {
|
||
if let tmux = session.tmuxController {
|
||
WindowTabGroupRow(controller: tmux)
|
||
} else if let summary = session.tmuxSummary {
|
||
TabGroupSlot {
|
||
ForEach(0 ..< max(1, summary.windows), id: \.self) { i in
|
||
WindowTabChipBody(
|
||
index: "\(i)",
|
||
title: i == 0 && !summary.currentTitle.isEmpty
|
||
? summary.currentTitle : "window \(i)",
|
||
isSelected: i == 0)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 真实 tmux 的 tab 组行。独立视图直接订阅 controller(嵌套 ObservableObject 不冒泡)。
|
||
private struct WindowTabGroupRow: View {
|
||
@ObservedObject var controller: TmuxController
|
||
|
||
var body: some View {
|
||
// 无 `+` 新建入口(第一版减法,用户裁决):window 的增删都交给 tmux 自己
|
||
// (prefix+c / prefix+&),tab 只做**切换与反映**。
|
||
TabGroupSlot {
|
||
ForEach(controller.windows) { win in
|
||
WindowTabChip(window: win, isSelected: win.id == controller.activeWindowID) {
|
||
controller.selectWindow(win.id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// tab 组的容器:**无槽体**,chip 靠相邻成组(Shimo 语言:分组靠留白,不靠画框)。
|
||
/// 条目多时横向滚动。
|
||
private struct TabGroupSlot<Content: View>: View {
|
||
@ViewBuilder let content: Content
|
||
|
||
var body: some View {
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: TX.Space.hairline) { content }
|
||
}
|
||
.frame(height: 36)
|
||
}
|
||
}
|
||
|
||
/// 单个 window chip(title/index 是 window 自己的 @Published,须直接订阅才会刷新)。
|
||
private struct WindowTabChip: View {
|
||
@ObservedObject var window: TmuxWindow
|
||
let isSelected: Bool
|
||
let onTap: () -> Void
|
||
|
||
var body: some View {
|
||
Button(action: onTap) {
|
||
WindowTabChipBody(index: "\(window.index)", title: window.title, isSelected: isSelected)
|
||
}
|
||
.buttonStyle(.txPressTight)
|
||
.accessibilityLabel("切换到 window \(window.index)")
|
||
}
|
||
}
|
||
|
||
/// chip 的视觉本体(真实 window 与占位共用):`编号 标题` 胶囊。选中 = accent 淡染胶囊
|
||
/// 浮起(无描边,Shimo 的徽标语言),未选 = 纯文字贴底。
|
||
private struct WindowTabChipBody: View {
|
||
let index: String
|
||
let title: String
|
||
let isSelected: Bool
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
HStack(spacing: 5) {
|
||
Text(index)
|
||
.font(TX.Font.mono(10.5, .semibold))
|
||
.foregroundStyle(isSelected ? TXAccent.text : p.textFaint)
|
||
Text(title)
|
||
.font(TX.Font.mono(12, isSelected ? .medium : .regular))
|
||
.foregroundStyle(isSelected ? TXAccent.textBright : p.textWeak)
|
||
.lineLimit(1)
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.frame(height: 28)
|
||
.background(isSelected ? TXAccent.selectedBg : .clear, in: Capsule())
|
||
.contentShape(Rectangle())
|
||
.animation(TX.Motion.quick, value: isSelected)
|
||
}
|
||
}
|
||
|
||
/// 终端区的**非内容态**覆盖层:连接中 / 失败 / 已关闭。
|
||
///
|
||
/// 真实连接不是瞬间的:解析凭据 → 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<Actions: View>(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)
|
||
// 连上后这一层要淡出去,别"啪"地掀掉(动画由 paneArea 按 phaseText 驱动)。
|
||
.transition(.opacity)
|
||
}
|
||
}
|
||
|
||
/// 无 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(.txPressTight)
|
||
}
|
||
.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)
|
||
)
|
||
.txShadow(TX.Elevation.dialog)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 舞台底部**状态行**(动作按钮已按第一版减法裁掉:window 管理交给 tmux 自己,
|
||
/// 回首页走侧边栏 / 顶带 ⌄ 最小化)。
|
||
///
|
||
/// 左下是**网络/会话**状态的唯一出口:`[dot 链路] | tmux 概览`。传输协议(mosh/SSH)
|
||
/// **不在这里**——它是连接的静态属性,由顶带右上的协议徽标独占(原先两处都写 mosh,
|
||
/// 用户裁决去重);重连计数并入第一格(重连是链路层的事)。
|
||
/// **只染出问题的那层**:链路断/重连 → 第一格转琥珀/灰;tmux 缺失 → 第二格写
|
||
/// 「无 tmux · 客户端窗口」而非报错。主机名不再重复出现(顶带标题已有)。
|
||
///
|
||
/// 高度 = 内容带 34pt + home indicator 安全区,内容在其中**居中** —— 与侧边栏脚
|
||
/// 同一条中心线。(此前只有 34pt 且被安全区顶起,屏幕底下留一条 24pt 的空带,看着像"没铺到底"。)
|
||
struct TerminalActionBar: View {
|
||
@ObservedObject var session: TerminalSession
|
||
let safeBottom: CGFloat
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
HStack(spacing: TX.Space.s) {
|
||
statusLine
|
||
Spacer(minLength: TX.Space.s)
|
||
}
|
||
.padding(.horizontal, TX.Space.m)
|
||
.frame(height: TX.Layout.footBand(safeBottom))
|
||
.animation(TX.Motion.standard, value: contextText)
|
||
}
|
||
|
||
// MARK: - 状态行
|
||
|
||
private var statusLine: some View {
|
||
HStack(spacing: 0) {
|
||
cell(egressText, color: egressColor, dot: true)
|
||
separator
|
||
cell(contextText, color: p.text3, dot: false)
|
||
}
|
||
}
|
||
|
||
private var separator: some View {
|
||
Rectangle().fill(p.border).frame(width: 1, height: 12)
|
||
.padding(.horizontal, TX.Space.xs)
|
||
}
|
||
|
||
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(11.5, .medium))
|
||
.foregroundStyle(color)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
|
||
// 第一格:链路(tsnet 出口 / 直连 / 重连计数)。拼写按 TXTech 规范(tsnet 官方小写)。
|
||
private var egressText: String {
|
||
switch session.link {
|
||
case .relay: "tsnet"
|
||
case .direct: "直连"
|
||
case .connecting: "连接中"
|
||
case .reconnecting(let n): "重连中 · 第 \(n) 次"
|
||
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
|
||
}
|
||
}
|
||
|
||
// 第二格:会话复用层概览(windows 数保留)。
|
||
private var contextText: String {
|
||
if let t = session.tmuxSummary {
|
||
let name = t.name.isEmpty ? "tmux" : "tmux \(t.name)"
|
||
return "\(name) · \(t.windows) window\(t.windows == 1 ? "" : "s")"
|
||
}
|
||
// 还在探测 / 等用户选会话时别谎报「无 tmux」——那是探测确定不可用后才成立的结论。
|
||
if session.pendingSessionChoice != nil { return "选择 tmux 会话…" }
|
||
if session.tmuxSkipped { return "原生窗口 · 不经 tmux" }
|
||
if session.tmuxUnavailable { return "无 tmux · 客户端窗口" }
|
||
return session.isLive ? "检测 tmux…" : session.phaseText
|
||
}
|
||
}
|