Files
terminalX/apps/TerminalX/iOS/TerminalDialogs.swift
kid 28e9cfc207 feat: UI 商业化改造 + 连接前 tmux 会话选择(真机验证)
按 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>
2026-07-25 22:59:35 +08:00

280 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = Dfalse = 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)
}
}