按 Claude Design 定稿(归档在 docs/design/)重做 UI,并把 tmux 从 「从不自动进」修成「连接前探测 → 让用户选会话」。 设计令牌与导航地基 - Theme 拆三层:TXAccent(恒定 blurple) / TXChrome(中性阶×4 表) / TXFlavor(Catppuccin 终端) - vendor JetBrains Mono 四权重(附 OFL 许可) - AppRouter + SessionManager:多会话并存,路由与会话生命周期解耦 - SessionCanvas 常驻挂载会话 surface(摘除即丢内容,也是缩回动画的前提) 按设计稿落地的屏 - 沉浸轨道页:56pt 轨道 + 浮起标题/状态胶囊 + 侧边栏三态(遮罩不 resize、Pin 各一次) - 首页:活动会话卡(readViewportText 文本镜像)+ 主机网格 + 筛选 chips - 关闭二次确认(tmux 仅断开 / 原生窗口两套文案)、分屏菜单、pane 拖拽条 - 空状态:首次运行 / 无会话 / 搜索无命中 / 连接中·失败·已关闭 tmux 真实链路(真机查出并修掉 4 个 bug) - 全代码库从来没人发起 attach → 连接前探测 + 会话选择器(接回 / 新建 / 原生终端) - format 分隔符 tab 经 PTY 变成下划线 → 改用 |:| - controller 变化不冒泡到 session → Combine 转发(否则数据解析对了 UI 不刷新) - 当前会话名不能靠 session_attached 反推 → 改用 display-message 传输层:SSH connect 加超时(原来阻塞到系统 TCP 超时 75s+) 无头验证设施:假会话 fixture · terminalx://ui/* 驱动 · 横屏截图脚本 · 对拍走查法 TXCore 45 tests 绿(新增 5 个会话探测单测) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
407 lines
17 KiB
Swift
407 lines
17 KiB
Swift
import SwiftUI
|
||
|
||
// 设计稿 `1b 终端-沉浸轨道` 的浮起 chrome:左上标题胶囊 + 右上状态胶囊 + 底部提示条。
|
||
//
|
||
// 浮起(absolute + blur)而非占一行布局:终端面积最大化是这一屏的唯一目标。
|
||
// 终端内容容器顶部留 86pt 预留带给胶囊,预留带用 padding 实现(容器 clipped 防向上溢出)。
|
||
|
||
/// 浮起胶囊的共用外观:blur 材质 + 半透底 + 常规描边 + 圆角 11。
|
||
private struct FloatingCapsule<Content: View>: 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<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)
|
||
}
|
||
}
|
||
|
||
/// 无 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)
|
||
}
|
||
}
|