Files
terminalX/apps/TerminalX/iOS/HomeView.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

633 lines
25 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
/// · 稿 `1e -`
///
/// App ****
/// 4 40pt
struct HomeView: View {
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
@ObservedObject var hostStore: HostStore
@ObservedObject var credStore: FileCredentialStore
@ObservedObject var tailscaleStore: TailscaleStore
@EnvironmentObject var theme: TXThemeManager
@State private var search = ""
@State private var groupFilter: String? = nil
@State private var showEditor = false
@State private var editing: SavedHost?
@State private var showSettings = false
private var p: TXPalette { theme.palette }
/// chips+
private var groups: [String] {
var seen: [String] = []
for h in hostStore.hosts {
if let g = h.group, !seen.contains(g) { seen.append(g) }
}
return seen
}
private var filteredHosts: [SavedHost] {
hostStore.hosts.filter { h in
let matchesGroup = groupFilter == nil || h.group == groupFilter
guard matchesGroup else { return false }
guard !search.isEmpty else { return true }
let q = search.lowercased()
return h.name.lowercased().contains(q) || h.host.lowercased().contains(q)
|| (h.os?.lowercased().contains(q) ?? false)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
if hostStore.hosts.isEmpty && manager.sessions.isEmpty {
firstRunEmptyState
} else {
ScrollView {
VStack(alignment: .leading, spacing: TX.Space.m) {
// tmux
ForEach(manager.connectingSessions) { s in
ConnectingRow(session: s) { manager.close(s.id) }
}
if liveSessions.isEmpty {
if manager.connectingSessions.isEmpty { noSessionHint }
} else {
TXSectionLabel(text: "活动会话")
.padding(.top, TX.Space.xs)
sessionCards
}
HStack(alignment: .center) {
TXSectionLabel(text: "全部主机")
Spacer()
if !groups.isEmpty { chips }
}
.padding(.top, manager.sessions.isEmpty ? TX.Space.xs : TX.Space.s)
if filteredHosts.isEmpty {
noMatchState
} else {
hostGrid
}
}
.padding(.horizontal, TX.Space.screenGutter)
.padding(.bottom, TX.Space.xxl)
}
}
}
.background(p.bg.ignoresSafeArea())
//
.task(id: manager.sessions.count) {
while !Task.isCancelled {
for s in manager.sessions { s.captureSnapshot() }
try? await Task.sleep(nanoseconds: 1_500_000_000)
}
}
.sheet(isPresented: $showEditor) {
HostEditorView(existing: editing, credStore: credStore, tailscaleStore: tailscaleStore,
existingGroups: groups) { host in
if hostStore.hosts.contains(where: { $0.id == host.id }) { hostStore.update(host) }
else { hostStore.add(host) }
}
}
.sheet(isPresented: $showSettings) {
SettingsHost(credStore: credStore, tailscaleStore: tailscaleStore)
}
}
// MARK: -
///
private var firstRunEmptyState: some View {
VStack(spacing: TX.Space.m) {
Spacer()
Image(systemName: "terminal")
.font(.system(size: 40, weight: .ultraLight))
.foregroundStyle(TXAccent.mid)
VStack(spacing: 6) {
Text("还没有主机")
.font(TX.Font.mono(15, .medium))
.foregroundStyle(p.text)
Text("添加一台服务器就能开始SSH 直连,或经 Tailscale 出口访问内网。\n开启 mosh 后断线自动接回。")
.font(TX.Font.mono(12))
.foregroundStyle(p.textWeak)
.multilineTextAlignment(.center)
.lineSpacing(4)
}
HStack(spacing: TX.Space.s) {
TXButton(title: "新建连接", emphasized: true) {
editing = nil
showEditor = true
}
TXButton(title: "先配置 Tailscale") { showSettings = true }
}
.padding(.top, TX.Space.xs)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
///
private var noSessionHint: some View {
HStack(spacing: TX.Space.s) {
Image(systemName: "arrow.down")
.font(.system(size: 12))
.foregroundStyle(p.textWeak)
Text("还没有在跑的会话 —— 点下面任意主机即可连接")
.font(TX.Font.mono(12))
.foregroundStyle(p.textWeak)
}
.padding(.horizontal, TX.Space.s)
.frame(height: 40)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.card)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
.foregroundStyle(p.divider)
)
.padding(.top, TX.Space.xs)
}
/// /
private var noMatchState: some View {
VStack(spacing: TX.Space.s) {
Text(search.isEmpty ? "该分组下没有主机" : "没有匹配「\(search)」的主机")
.font(TX.Font.mono(12.5, .medium))
.foregroundStyle(p.text3)
TXButton(title: "清除筛选") {
search = ""
groupFilter = nil
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, TX.Space.xl)
}
// MARK: -
private var header: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("terminalX")
.font(TX.Font.mono(34, .medium))
.tracking(-0.85)
.foregroundStyle(p.text)
Text(subtitle)
.font(TX.Font.mono(12))
.foregroundStyle(p.textWeak)
}
Spacer()
HStack(spacing: TX.Space.s) {
TXSearchField(text: $search, placeholder: "搜索", shortcut: "⌘K")
.frame(width: 220)
TXButton(title: "新建连接", emphasized: true) {
editing = nil
showEditor = true
}
TXIconButton(icon: "gearshape", label: "设置") { showSettings = true }
}
}
.padding(.horizontal, TX.Space.screenGutter)
.padding(.top, TX.Space.l)
.padding(.bottom, TX.Space.m)
}
private var subtitle: String {
var parts: [String] = []
if !manager.sessions.isEmpty { parts.append("\(manager.sessions.count) 个会话在跑") }
parts.append("\(hostStore.hosts.count) 台主机")
if let tailnet = tailscaleStore.connections.first {
parts.append("Tailscale \(tailnet.name)")
}
return parts.joined(separator: " · ")
}
private var chips: some View {
HStack(spacing: TX.Space.xs) {
TXChip(text: "全部", isSelected: groupFilter == nil) { groupFilter = nil }
ForEach(groups, id: \.self) { g in
TXChip(text: g, isSelected: groupFilter == g) { groupFilter = g }
}
}
}
/// tmux
/// `SessionManager` manager
struct ConnectingRow: View {
@ObservedObject var session: TerminalSession
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var failed: Bool { session.phaseText.hasPrefix("失败") }
var body: some View {
HStack(spacing: TX.Space.s) {
if failed {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 13))
.foregroundStyle(p.danger)
} else {
ProgressView().controlSize(.small).tint(TXAccent.base)
}
VStack(alignment: .leading, spacing: 2) {
Text(failed ? "连接 \(session.displayName) 失败" : "正在连接 \(session.displayName)")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Text(detail)
.font(TX.Font.mono(11))
.foregroundStyle(failed ? p.danger : p.textWeak)
.lineLimit(1)
}
Spacer(minLength: TX.Space.s)
if failed {
TXButton(title: "重试") { session.retry() }
}
TXButton(title: failed ? "移除" : "取消", action: onCancel)
}
.padding(.horizontal, TX.Space.m)
.padding(.vertical, TX.Space.s)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.card)
.strokeBorder(failed ? p.danger.opacity(0.4) : p.border, lineWidth: 1)
)
.padding(.top, TX.Space.xs)
}
private var detail: String {
if failed { return String(session.phaseText.dropFirst(3)) }
if session.pendingSessionChoice != nil { return "等待选择 tmux 会话" }
if !session.banner.isEmpty { return session.banner }
return session.phaseText
}
}
// MARK: - 3
/// **** ConnectingRow
/// tmux
private var liveSessions: [TerminalSession] {
let connecting = Set(manager.connectingSessions.map(\.id))
return manager.activeSessions.filter {
($0.isLive || $0.isMinimized) && !connecting.contains($0.id)
}
}
private var sessionCards: some View {
HStack(alignment: .top, spacing: TX.Space.m) {
ForEach(liveSessions) { s in
SessionCard(
session: s,
shortcutIndex: (liveSessions.firstIndex { $0.id == s.id } ?? 0) + 1,
isFocused: manager.focusedID == s.id
) {
manager.focus(s)
router.enter(s.id)
}
}
}
.frame(height: 560)
}
// MARK: - 4
private var hostGrid: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: TX.Space.m), count: 4),
spacing: TX.Space.m) {
ForEach(filteredHosts) { host in
HostTile(host: host,
credName: credName(for: host),
hasTsnet: host.tailscaleID != nil,
liveSession: manager.sessions.first { $0.host?.id == host.id }) {
// tmux manager.enterRequest
manager.open(host: host)
}
.contextMenu {
Button { editing = host; showEditor = true } label: {
Label("编辑", systemImage: "pencil")
}
Button(role: .destructive) { hostStore.remove(host) } label: {
Label("删除", systemImage: "trash")
}
}
}
addHostTile
}
}
private var addHostTile: some View {
Button {
editing = nil
showEditor = true
} label: {
VStack {
Text("添加主机")
.font(TX.Font.mono(12, .medium))
.foregroundStyle(p.textWeak)
}
.frame(maxWidth: .infinity, minHeight: 96)
.background(
RoundedRectangle(cornerRadius: TX.Radius.card)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
.foregroundStyle(p.border)
)
}
.buttonStyle(.plain)
}
private func credName(for host: SavedHost) -> String? {
if case .credential(let id) = host.auth {
return credStore.list().first { $0.id == id }?.name
}
return nil
}
}
/// tmux
/// `SessionManager` manager
struct ConnectingRow: View {
@ObservedObject var session: TerminalSession
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var failed: Bool { session.phaseText.hasPrefix("失败") }
var body: some View {
HStack(spacing: TX.Space.s) {
if failed {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 13))
.foregroundStyle(p.danger)
} else {
ProgressView().controlSize(.small).tint(TXAccent.base)
}
VStack(alignment: .leading, spacing: 2) {
Text(failed ? "连接 \(session.displayName) 失败" : "正在连接 \(session.displayName)")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Text(detail)
.font(TX.Font.mono(11))
.foregroundStyle(failed ? p.danger : p.textWeak)
.lineLimit(1)
}
Spacer(minLength: TX.Space.s)
if failed {
TXButton(title: "重试") { session.retry() }
}
TXButton(title: failed ? "移除" : "取消", action: onCancel)
}
.padding(.horizontal, TX.Space.m)
.padding(.vertical, TX.Space.s)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.card)
.strokeBorder(failed ? p.danger.opacity(0.4) : p.border, lineWidth: 1)
)
.padding(.top, TX.Space.xs)
}
private var detail: String {
if failed { return String(session.phaseText.dropFirst(3)) }
if session.pendingSessionChoice != nil { return "等待选择 tmux 会话" }
if !session.banner.isEmpty { return session.banner }
return session.phaseText
}
}
// MARK: -
/// + + tmux + · · +
/// 线/ + overlay
struct SessionCard: View {
@ObservedObject var session: TerminalSession
let shortcutIndex: Int
let isFocused: Bool
let onOpen: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var statusColor: Color {
switch session.link {
case .direct, .relay: p.online
case .reconnecting: p.reconnecting
case .connecting: p.text4
case .offline: p.offline
}
}
private var isTroubled: Bool {
switch session.link {
case .reconnecting, .offline: true
default: false
}
}
var body: some View {
VStack(spacing: 0) {
cardHead
Rectangle().fill(p.divider).frame(height: 1)
// 线/ + blur(1.5px) + rgba(24,26,40,.72)
//
TXTerminalMirror(lines: session.snapshotLines, background: p.terminalBackground)
.blur(radius: isTroubled ? 1.5 : 0)
.overlay { if isTroubled { troubleVeil } }
Rectangle().fill(p.divider).frame(height: 1)
cardFoot
}
.background(p.surfaceDark)
.clipShape(RoundedRectangle(cornerRadius: 13))
.overlay(
RoundedRectangle(cornerRadius: 13)
.strokeBorder(isFocused ? TXAccent.selectedStroke : p.border, lineWidth: 1)
)
.shadow(color: .black.opacity(isFocused ? 0.4 : 0.2), radius: isFocused ? 9 : 5, y: 6)
.contentShape(Rectangle())
.onTapGesture(perform: onOpen)
}
private var cardHead: some View {
HStack(spacing: TX.Space.xs) {
TXStatusDot(color: statusColor)
Text(session.displayName)
.font(TX.Font.mono(13, .semibold))
.foregroundStyle(p.text)
.lineLimit(1)
if let t = session.tmuxSummary {
Text(t.name.isEmpty ? "tmux" : "tmux \(t.name)")
.font(TX.Font.mono(11))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
Spacer(minLength: TX.Space.xs)
linkTag
}
.padding(.horizontal, TX.Space.s)
.frame(height: 36)
}
/// RTT RTT / HANDOFF §12.4
@ViewBuilder private var linkTag: some View {
switch session.link {
case .direct:
tag("direct", p.online)
case .relay:
tag("tsnet", p.online)
case .connecting:
tag("连接中", p.text4)
case .reconnecting(let n):
tag("重连中 · 第 \(n)", p.reconnecting)
case .offline:
tag("离线", p.offline)
}
}
private func tag(_ s: String, _ c: Color) -> some View {
Text(s).font(TX.Font.mono(11, .medium)).foregroundStyle(c).lineLimit(1)
}
/// +
private var troubleVeil: some View {
ZStack {
Color(hex: 0x181A28).opacity(0.72)
VStack(spacing: TX.Space.s) {
if case .reconnecting = session.link {
ProgressView().controlSize(.regular).tint(p.reconnecting)
}
Text(veilTitle)
.font(TX.Font.mono(12, .medium))
.foregroundStyle(p.reconnecting)
Text("屏幕内容已缓存,恢复后原样接回")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.text4)
}
.multilineTextAlignment(.center)
.padding(TX.Space.m)
}
}
private var veilTitle: String {
switch session.link {
case .reconnecting(let n): "\(session.moshEngaged ? "mosh " : "")会话保活中 · 第 \(n) 次重连"
default: "已断开"
}
}
private var cardFoot: some View {
HStack(spacing: TX.Space.xs) {
Text(footMeta)
.font(TX.Font.mono(11))
.foregroundStyle(p.textWeak)
.lineLimit(1)
Spacer(minLength: TX.Space.xs)
Text("\(shortcutIndex)")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
Button(action: onOpen) {
Text(isTroubled ? "立即重试" : "回到会话 →")
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(TXAccent.textBright)
}
.buttonStyle(.plain)
}
.padding(.horizontal, TX.Space.s)
.frame(height: 38)
}
private var footMeta: String {
var parts: [String] = []
if let t = session.tmuxSummary {
parts.append("\(t.windows) window\(t.windows == 1 ? "" : "s")")
if t.panes > 0 { parts.append("\(t.panes) panes") }
} else {
parts.append("原生窗口")
}
if session.isMinimized {
parts.append("最小化中")
} else {
parts.append(Self.relative(session.lastActiveAt))
}
return parts.joined(separator: " · ")
}
/// ///
static func relative(_ date: Date) -> String {
let s = Int(Date().timeIntervalSince(date))
if s < 60 { return "\(max(1, s)) 秒前" }
if s < 3600 { return "\(s / 60) 分钟前" }
if s < 86400 { return "\(s / 3600) 小时前" }
return "\(s / 86400) 天前"
}
}
// MARK: -
/// 4 + + os + +
struct HostTile: View {
let host: SavedHost
let credName: String?
let hasTsnet: Bool
///
let liveSession: TerminalSession?
let onTap: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var icon: String {
let os = (host.os ?? "").lowercased()
if os.contains("macos") { return "desktopcomputer" }
if os.contains("raspberry") { return "cpu" }
if os.contains("nas") || host.name.lowercased().contains("nas") { return "externaldrive" }
return "server.rack"
}
var body: some View {
Button(action: onTap) {
VStack(alignment: .leading, spacing: TX.Space.s) {
HStack(alignment: .top, spacing: TX.Space.s) {
Image(systemName: icon)
.font(.system(size: 15, weight: .light))
.foregroundStyle(p.text4)
.frame(width: 30, height: 30)
.background(p.surface, in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 2) {
Text(host.name)
.font(TX.Font.mono(13, .semibold))
.foregroundStyle(p.text)
.lineLimit(1)
Text(host.os ?? host.addressLine)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
Spacer(minLength: 0)
if let s = liveSession {
TXStatusDot(color: s.link == .offline ? p.offline
: (s.isMinimized ? TXAccent.mid : p.online))
} else {
TXStatusDot(color: p.offline.opacity(0.5))
}
}
HStack(spacing: 5) {
if host.useMosh { TXBadge(text: "mosh") } else { TXBadge(text: "SSH") }
if hasTsnet { TXBadge(text: "tsnet") }
if let credName { TXBadge(text: credName, emphasized: true) }
}
}
.padding(TX.Space.s)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.card)
.strokeBorder(p.border, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}