按 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>
280 lines
12 KiB
Swift
280 lines
12 KiB
Swift
import SwiftUI
|
||
|
||
// 设计稿 `3c 关闭确认` 与 `3b 新建分屏菜单`。
|
||
//
|
||
// 语义分工(务必别混):
|
||
// - **黄点 / ⌘M / 下滑 = 最小化**:不确认,直接回首页,会话继续跑。
|
||
// - **红点 / ⌘W = 关闭**:弹一次确认,按主机能力两套文案;`esc` 取消、`⏎` 选推荐项。
|
||
|
||
/// 浮层容器:遮罩 + 居中卡片(对话框 / 菜单共用)。
|
||
struct TXModalScrim<Content: View>: View {
|
||
let onDismiss: () -> Void
|
||
@ViewBuilder let content: Content
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
Color(hex: 0x0A0B12).opacity(0.42)
|
||
.ignoresSafeArea()
|
||
.contentShape(Rectangle())
|
||
.onTapGesture(perform: onDismiss)
|
||
content
|
||
}
|
||
.transition(.opacity)
|
||
}
|
||
}
|
||
|
||
// MARK: - 关闭确认(3c)
|
||
|
||
/// 关闭确认。两套文案由会话能力决定:
|
||
/// - **tmux 会话**:推荐「仅断开」(`detach-client`,服务端继续跑);左侧次要文字按钮「结束会话」
|
||
/// (`kill-session`,**需再确认一次**)。
|
||
/// - **原生窗口**:琥珀警告「没有 tmux 兜底,前台进程会随之结束」,推荐「取消」,危险按钮「关闭窗口」。
|
||
struct CloseConfirmDialog: View {
|
||
@ObservedObject var session: TerminalSession
|
||
/// 仅断开(tmux)/ 直接关闭(原生窗口)
|
||
let onDetach: () -> Void
|
||
/// 结束 tmux 会话(kill-session)
|
||
let onKill: () -> Void
|
||
let onCancel: () -> Void
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
@State private var confirmingKill = false
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
private var isTmux: Bool { session.tmuxSummary != nil }
|
||
|
||
var body: some View {
|
||
TXModalScrim(onDismiss: onCancel) {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Text(title)
|
||
.font(TX.Font.mono(15, .medium))
|
||
.foregroundStyle(p.text)
|
||
.padding(.bottom, 8)
|
||
message
|
||
.padding(.bottom, TX.Space.l)
|
||
buttons
|
||
}
|
||
.padding(TX.Space.l)
|
||
.frame(width: isTmux ? 436 : 404)
|
||
.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)
|
||
}
|
||
}
|
||
|
||
private var title: String {
|
||
if let t = session.tmuxSummary, !t.name.isEmpty {
|
||
return "关闭 \(session.displayName) · \(t.name)?"
|
||
}
|
||
// 原生窗口带窗口编号(首版每主机单窗口,恒为 1;多窗口是二期能力)。
|
||
return "关闭 \(session.displayName) · 窗口 1?"
|
||
}
|
||
|
||
@ViewBuilder private var message: some View {
|
||
if let t = session.tmuxSummary {
|
||
Text("tmux 会话 (\(t.windows) windows / \(t.panes) panes)。仅断开后服务端继续跑,下次原样接回。")
|
||
.font(TX.Font.mono(12))
|
||
.foregroundStyle(p.text3)
|
||
.lineSpacing(4)
|
||
} else {
|
||
Text("没有 tmux 兜底,")
|
||
.font(TX.Font.mono(12))
|
||
.foregroundStyle(p.text3)
|
||
+ Text("前台进程会随之结束")
|
||
.font(TX.Font.mono(12, .medium))
|
||
.foregroundStyle(p.warning)
|
||
+ Text("。想留着就点黄点最小化。")
|
||
.font(TX.Font.mono(12))
|
||
.foregroundStyle(p.text3)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private var buttons: some View {
|
||
HStack(spacing: TX.Space.s) {
|
||
if isTmux {
|
||
// 次要文字按钮(危险):kill-session 需再点一次确认。
|
||
Button {
|
||
if confirmingKill { onKill() } else { confirmingKill = true }
|
||
} label: {
|
||
Text(confirmingKill ? "确认结束会话?" : "结束会话")
|
||
.font(TX.Font.mono(12, confirmingKill ? .semibold : .regular))
|
||
.foregroundStyle(p.danger)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
Spacer()
|
||
TXButton(title: "取消", action: onCancel)
|
||
if isTmux {
|
||
TXButton(title: "仅断开 ⏎", emphasized: true, action: onDetach)
|
||
} else {
|
||
dangerButton
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 危险按钮(设计给的精确色:描边 #8d5560 / 底 rgba(92,58,63,.35) / 字 #f5a0ac)。
|
||
private var dangerButton: some View {
|
||
Button(action: onDetach) {
|
||
Text("关闭窗口")
|
||
.font(TX.Font.mono(12, .medium))
|
||
.foregroundStyle(Color(hex: 0xF5A0AC))
|
||
.padding(.horizontal, 14)
|
||
.frame(height: 32)
|
||
.background(Color(hex: 0x5C3A3F).opacity(0.35),
|
||
in: RoundedRectangle(cornerRadius: TX.Radius.control))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: TX.Radius.control)
|
||
.strokeBorder(Color(hex: 0x8D5560), lineWidth: 1)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
// MARK: - 新建分屏菜单(3b)
|
||
|
||
/// 分屏菜单。**不问方向**(竖分 ⌘D / 横分 ⌘⇧D 本就是两个入口),只问「分到哪里」:
|
||
/// 当前会话 → 直接 `split-window`;其余 detached 会话 → `join-pane -s`;也可新建一个 tmux 会话。
|
||
///
|
||
/// 模型约束:分屏只发生在**同一台主机内**(跨主机分屏设计已取消)。
|
||
struct SplitMenuView: View {
|
||
@ObservedObject var session: TerminalSession
|
||
/// true = 竖分(左右并排,⌘D);false = 横分(上下堆叠,⌘⇧D)
|
||
let vertical: Bool
|
||
let onPick: (SplitTarget) -> Void
|
||
let onCancel: () -> Void
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
enum SplitTarget {
|
||
case currentSession
|
||
case otherSession(String)
|
||
case newSession
|
||
/// 原生窗口(不经 tmux)。首版不可用:需要一台主机多条 SSH channel(见 HANDOFF §12.4)。
|
||
case nativeWindow
|
||
}
|
||
|
||
/// 其余会话 = 除了本客户端所在的那个。(不能按 `isAttached` 过滤——服务器上别的客户端
|
||
/// attach 着其它会话时会把它们错误地排除掉。)
|
||
private var detached: [TmuxSessionInfo] {
|
||
let current = session.tmuxSummary?.name ?? ""
|
||
return session.tmuxSessionList.filter { $0.name != current }
|
||
}
|
||
|
||
var body: some View {
|
||
TXModalScrim(onDismiss: onCancel) {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
header
|
||
Rectangle().fill(p.divider).frame(height: 1)
|
||
if session.tmuxSummary != nil {
|
||
TXSectionLabel(text: "tmux 会话")
|
||
.padding(.horizontal, TX.Space.m)
|
||
.padding(.top, TX.Space.s)
|
||
.padding(.bottom, 2)
|
||
row(title: currentName, detail: currentDetail, dot: p.online,
|
||
tag: "当前", selected: true) { onPick(.currentSession) }
|
||
ForEach(detached) { s in
|
||
// detached 会话的进程仍在服务端跑 → 状态点同样是绿的(设计稿如此)。
|
||
row(title: s.name,
|
||
detail: "\(s.windows) window\(s.windows == 1 ? "" : "s") · detached",
|
||
dot: p.online,
|
||
tag: s.lastActivity.map { SessionCard.relative($0) }) {
|
||
onPick(.otherSession(s.name))
|
||
}
|
||
}
|
||
row(title: "新建 tmux 会话…", detail: nil, dot: nil, tag: nil) {
|
||
onPick(.newSession)
|
||
}
|
||
Rectangle().fill(p.divider).frame(height: 1)
|
||
.padding(.vertical, TX.Space.xs)
|
||
}
|
||
// 原生窗口:设计里是并列选项;首版能力未就绪 → 灰显并注明,不装作能用。
|
||
row(title: "原生窗口", detail: "不经 tmux · 关 App 即结束(二期)",
|
||
dot: nil, tag: nil, disabled: true) {}
|
||
}
|
||
.padding(.bottom, TX.Space.xs)
|
||
.frame(width: 360)
|
||
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog - 1))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: TX.Radius.dialog - 1)
|
||
.strokeBorder(p.borderStrong, lineWidth: 1)
|
||
)
|
||
.shadow(color: .black.opacity(0.7), radius: 40, y: 30)
|
||
}
|
||
}
|
||
|
||
private var header: some View {
|
||
HStack {
|
||
Text(vertical ? "竖分屏到…" : "横分屏到…")
|
||
.font(TX.Font.mono(13, .medium))
|
||
.foregroundStyle(p.text)
|
||
Spacer()
|
||
Text(session.displayName)
|
||
.font(TX.Font.mono(11))
|
||
.foregroundStyle(TXAccent.mid)
|
||
}
|
||
.padding(.horizontal, TX.Space.m)
|
||
.frame(height: 44)
|
||
}
|
||
|
||
private var currentName: String {
|
||
guard let t = session.tmuxSummary else { return session.displayName }
|
||
return t.name.isEmpty ? "当前会话" : t.name
|
||
}
|
||
|
||
private var currentDetail: String {
|
||
guard let t = session.tmuxSummary else { return "" }
|
||
var parts: [String] = []
|
||
if !t.currentTitle.isEmpty { parts.append(t.currentTitle) }
|
||
parts.append("\(t.windows) window\(t.windows == 1 ? "" : "s")")
|
||
if t.panes > 0 { parts.append("\(t.panes) panes") }
|
||
return parts.joined(separator: " · ")
|
||
}
|
||
|
||
/// 菜单行:**前导列统一 20pt**(状态点也包在 20pt 居中列里),保证所有行文字左缘一致。
|
||
private func row(title: String, detail: String?, dot: Color?, tag: String?,
|
||
selected: Bool = false, disabled: Bool = false,
|
||
action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
HStack(spacing: TX.Space.s) {
|
||
ZStack {
|
||
if let dot { TXStatusDot(color: dot, size: 7) }
|
||
}
|
||
.frame(width: 20)
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text(title)
|
||
.font(TX.Font.mono(13, .medium))
|
||
.foregroundStyle(disabled ? p.textWeak : p.text)
|
||
if let detail, !detail.isEmpty {
|
||
Text(detail)
|
||
.font(TX.Font.mono(10.5))
|
||
.foregroundStyle(p.textWeak)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
Spacer(minLength: TX.Space.xs)
|
||
if let tag {
|
||
Text(tag)
|
||
.font(TX.Font.mono(10.5, .medium))
|
||
.foregroundStyle(selected ? TXAccent.text : TXAccent.mid)
|
||
}
|
||
}
|
||
.padding(.horizontal, TX.Space.s)
|
||
.frame(height: 40)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(selected ? TXAccent.selectedBg : .clear,
|
||
in: RoundedRectangle(cornerRadius: TX.Radius.control))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: TX.Radius.control)
|
||
.strokeBorder(selected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
|
||
)
|
||
.padding(.horizontal, TX.Space.xs)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(disabled)
|
||
}
|
||
}
|