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>
This commit is contained in:
kid
2026-07-25 22:59:35 +08:00
parent 6dd23275ff
commit 28e9cfc207
46 changed files with 10237 additions and 1204 deletions

View File

@@ -0,0 +1,529 @@
import SwiftUI
// 稿 `1b -` 56pt + `3a -/Pin ` 288pt
//
// ****`` App logo
// »«Pin
//
/// 56pt 48pt线
struct RailView: View {
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
/// TMUX window
@ObservedObject var session: TerminalSession
let onNewHost: () -> Void
let onSettings: () -> Void
@EnvironmentObject var theme: TXThemeManager
@Environment(\.horizontalSizeClass) private var hSize
private var p: TXPalette { theme.palette }
private var width: CGFloat {
hSize == .compact ? TX.Layout.railWidthCompact : TX.Layout.railWidth
}
private var avatarSize: CGFloat { hSize == .compact ? 34 : 38 }
// 稿 `1b` 1366×1024
// logo 28..62 · 75..109 · 118..152 · 160 · 174/220/266 8
// · 线+ 313..351 · 370 · TMUX 382 · window 408 7
// · 齿 985..1019 · 932..967 · 914
var body: some View {
VStack(spacing: 0) {
logo
.padding(.top, 28)
.padding(.bottom, 13)
// / / 稿**** logo
TXIconButton(icon: "magnifyingglass", bare: true, hitSize: 34, label: "搜索") {
router.toggleSidebar()
}
.padding(.bottom, 9)
TXIconButton(icon: "house", bare: true, hitSize: 34, label: "首页") { router.goHome() }
.padding(.bottom, 8)
divider
.padding(.bottom, 13)
hostAvatars
newHostButton
divider
.padding(.top, 9)
.padding(.bottom, 12)
tmuxSection
Spacer(minLength: TX.Space.s)
TXStatusDot(color: linkColor, punchOut: p.surfaceDark)
.padding(.bottom, 11)
// 稿****
TXIconButton(icon: "chevron.right.2", hitSize: 34, label: "展开侧边栏") {
router.toggleSidebar()
}
.padding(.bottom, 18)
TXIconButton(icon: "gearshape", bare: true, hitSize: 34, label: "设置", action: onSettings)
.padding(.bottom, 5)
}
.frame(width: width)
.background(p.surfaceDark)
}
/// App logo``****
private var logo: some View {
Text("")
.font(TX.Font.mono(16, .bold))
.foregroundStyle(TXAccent.text)
.frame(width: 34, height: 34)
.background(p.surface, in: RoundedRectangle(cornerRadius: 11))
.overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border, lineWidth: 1))
.accessibilityLabel("terminalX")
}
private var divider: some View {
Rectangle()
.fill(p.divider)
.frame(width: 24, height: 1)
}
private var hostAvatars: some View {
VStack(spacing: 8) {
ForEach(manager.activeSessions) { s in
Button {
manager.focus(s)
router.enter(s.id)
} label: {
TXInitialsAvatar(initials: s.initials,
size: avatarSize,
isSelected: router.route.sessionID == s.id,
status: RailSidebarStyle.statusColor(s, p),
punchOut: p.surfaceDark)
}
.buttonStyle(.plain)
}
}
}
/// 线 +
private var newHostButton: some View {
Button(action: onNewHost) {
Image(systemName: "plus")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(p.textWeak)
.frame(width: avatarSize, height: avatarSize)
.background(
RoundedRectangle(cornerRadius: 11)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3, 3]))
.foregroundStyle(p.border)
)
}
.buttonStyle(.plain)
.padding(.top, 9)
}
/// TMUX + window tmux
private var tmuxSection: some View {
VStack(spacing: 7) {
Text(session.tmuxSummary == nil ? "窗口" : "TMUX")
.font(TX.Font.mono(9, .semibold))
.tracking(0.9)
// 稿 TMUX accent-500
.foregroundStyle(TXAccent.mid)
.padding(.bottom, 9)
if let tmux = session.tmuxController {
ForEach(tmux.windows) { win in
Button { tmux.selectWindow(win.id) } label: {
windowNumber("\(win.index)", isActive: win.id == tmux.activeWindowID)
}
.buttonStyle(.plain)
}
} else {
// attach window
ForEach(0 ..< max(1, session.tmuxSummary?.windows ?? 1), id: \.self) { i in
windowNumber("\(i)", isActive: i == 0)
}
}
}
}
/// window 34×30 +
private func windowNumber(_ text: String, isActive: Bool) -> some View {
Text(text)
.font(TX.Font.mono(12, isActive ? .semibold : .regular))
.foregroundStyle(isActive ? p.text : p.text4)
.frame(width: 34, height: 30)
.background(isActive ? p.surface : .clear, in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(isActive ? p.borderDialog : .clear, lineWidth: 1)
)
}
private var linkColor: Color { RailSidebarStyle.statusColor(session, p) }
}
///
@MainActor
enum RailSidebarStyle {
static func statusColor(_ s: TerminalSession, _ p: TXPalette) -> Color {
switch s.link {
case .direct, .relay: s.isMinimized ? TXAccent.mid : p.online
case .reconnecting: p.reconnecting
case .connecting: p.text4
case .offline: p.offline
}
}
}
// MARK: - 288pt
/// / Pin
///
/// = 26pt + 10pt gapwindow = 26×22pt + 10pt gap
/// **inset **`strokeBorder` border 1pt
struct SidebarPanel: View {
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
@ObservedObject var session: TerminalSession
@ObservedObject var tailscaleStore: TailscaleStore
let onNewHost: () -> Void
let onSettings: () -> Void
@Binding var search: String
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
TXSearchField(text: $search, placeholder: "搜索主机 · 会话")
.padding(.horizontal, TX.Space.m)
.padding(.bottom, TX.Space.s)
homeRow
sessionsSection
// 1pt ****线
LinearGradient(colors: [p.divider.opacity(0), p.divider, p.divider.opacity(0)],
startPoint: .leading, endPoint: .trailing)
.frame(height: 1)
.padding(.horizontal, TX.Space.m)
.padding(.vertical, TX.Space.s)
tmuxSection
Spacer(minLength: 0)
tailscaleFoot
}
.frame(width: TX.Layout.sidebarWidth)
.background(p.surfaceDark)
}
private var header: some View {
HStack(spacing: TX.Space.s) {
Text("")
.font(TX.Font.mono(15, .bold))
.foregroundStyle(TXAccent.text)
.frame(width: 30, height: 30)
.background(p.surface, in: RoundedRectangle(cornerRadius: 9))
.overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.border, lineWidth: 1))
Text("terminalX")
.font(TX.Font.mono(14, .medium))
.foregroundStyle(p.text)
Spacer()
// Pin
TXIconButton(icon: "pin", size: 28, isSelected: router.sidebar == .pinned,
label: "钉在左侧") { router.togglePin() }
//
TXIconButton(icon: "chevron.left.2", size: 28, label: "收起") { router.toggleSidebar() }
}
.padding(.horizontal, TX.Space.m)
.padding(.top, 11)
.padding(.bottom, 6)
}
private var homeRow: some View {
Button { router.goHome() } label: {
HStack(spacing: TX.Space.s) {
Image(systemName: "house")
.font(.system(size: 13))
.foregroundStyle(p.text2)
.frame(width: 26)
Text("首页")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Spacer()
Text("⌘⇧H")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
}
.padding(.horizontal, TX.Space.m)
.frame(height: 36)
}
.buttonStyle(.plain)
}
private var sessionsSection: some View {
VStack(alignment: .leading, spacing: 2) {
TXSectionLabel(text: "活动会话", trailing: "\(manager.sessions.count)")
.padding(.horizontal, TX.Space.m)
.padding(.top, TX.Space.s)
.padding(.bottom, 2)
if manager.activeSessions.isEmpty {
Text("没有在跑的会话")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.padding(.leading, 53)
.frame(height: 32, alignment: .leading)
}
ForEach(manager.activeSessions) { s in
SidebarSessionRow(session: s, isSelected: router.route.sessionID == s.id) {
manager.focus(s)
router.enter(s.id)
}
}
Button(action: onNewHost) {
Text("连接新主机…")
.font(TX.Font.mono(12))
.foregroundStyle(p.text4)
.padding(.leading, 53) //
.frame(height: 32, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
}
}
private var tmuxSection: some View {
VStack(alignment: .leading, spacing: 2) {
TXSectionLabel(text: tmuxHeader)
.padding(.horizontal, TX.Space.m)
.padding(.bottom, 2)
if let tmux = session.tmuxController {
ForEach(tmux.windows) { win in
SidebarWindowRow(window: win,
isSelected: win.id == tmux.activeWindowID) {
tmux.selectWindow(win.id)
}
}
newWindowRow { tmux.newWindow() }
} else if let summary = session.tmuxSummary {
// tmux controller attach
ForEach(0 ..< max(1, summary.windows), id: \.self) { i in
placeholderWindowRow(
index: i,
title: i == 0 && !summary.currentTitle.isEmpty ? summary.currentTitle : "window \(i)",
detail: i == 0 && summary.panes > 0
? "\(summary.panes) pane\(summary.panes == 1 ? "" : "s")" : "",
isSelected: i == 0)
}
newWindowRow {}
} else {
Text("无 tmux · 客户端窗口")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.padding(.horizontal, TX.Space.m)
.padding(.vertical, TX.Space.xs)
}
}
}
/// window T window x=53
private func newWindowRow(_ action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
Text("新建 window")
.font(TX.Font.mono(12))
.foregroundStyle(p.text4)
Spacer()
Text("⌘T")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
}
.padding(.leading, 53)
.padding(.trailing, TX.Space.m)
.frame(height: 32)
}
.buttonStyle(.plain)
}
/// controller window SidebarWindowRow
private func placeholderWindowRow(index: Int, title: String, detail: String,
isSelected: Bool) -> some View {
HStack(spacing: TX.Space.s) {
Text("\(index)")
.font(TX.Font.mono(11, .semibold))
.foregroundStyle(isSelected ? TXAccent.text : p.text4)
.frame(width: 26, height: 22)
.background(isSelected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: 5))
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(TX.Font.mono(13, isSelected ? .medium : .regular))
.foregroundStyle(isSelected ? p.text : p.text2)
.lineLimit(1)
if !detail.isEmpty {
Text(detail)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
}
}
Spacer(minLength: 0)
if isSelected { TXStatusDot(color: p.online, size: 7) }
}
.padding(.horizontal, TX.Space.s)
.frame(height: 44)
.background(isSelected ? TXAccent.selectedBg.opacity(0.75) : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.control))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.padding(.horizontal, TX.Space.s)
}
private var tmuxHeader: String {
let host = session.displayName
if let t = session.tmuxSummary, !t.name.isEmpty { return "TMUX · \(host) · \(t.name)" }
return session.tmuxSummary == nil ? "窗口 · \(host)" : "TMUX · \(host)"
}
/// tailnet 绿 + tailnet + IP
///
/// **** `TX.Layout.footHeight`线 y
/// 线 1pt divider稿
private var tailscaleFoot: some View {
VStack(spacing: 0) {
Rectangle().fill(p.divider).frame(height: 1)
HStack(spacing: TX.Space.s) {
TXStatusDot(color: tailscaleStore.connections.isEmpty ? p.offline : p.online)
VStack(alignment: .leading, spacing: 1) {
Text(tailscaleStore.connections.isEmpty ? "Tailscale 未配置" : "Tailscale 已连接")
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(p.text2)
if let c = tailscaleStore.connections.first {
// tailnet + tailnet IPUp SelfIP
Text([c.hostname, c.lastSelfIP].compactMap { $0 }.joined(separator: " · "))
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
}
Spacer(minLength: 0)
}
.padding(.horizontal, TX.Space.m)
.frame(height: TX.Layout.footHeight)
}
}
}
/// 26pt + ·window title + /
struct SidebarSessionRow: View {
@ObservedObject var session: TerminalSession
let isSelected: Bool
let onTap: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
Button(action: onTap) {
HStack(spacing: TX.Space.s) {
TXInitialsAvatar(initials: session.initials, size: 26,
isSelected: isSelected,
status: RailSidebarStyle.statusColor(session, p),
punchOut: isSelected ? TXAccent.selectedBg : p.surfaceDark)
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
.lineLimit(1)
Text(subtitle)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
Spacer(minLength: TX.Space.xs)
trailingTag
}
.padding(.horizontal, TX.Space.s)
.frame(height: 48)
.background(isSelected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.control))
// inset border 1pt
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.padding(.horizontal, TX.Space.s)
}
.buttonStyle(.plain)
}
private var title: String {
if let t = session.tmuxSummary, !t.name.isEmpty {
return "\(session.displayName) · \(t.name)"
}
return session.displayName
}
private var subtitle: String {
if session.isMinimized { return "最小化中" }
guard let t = session.tmuxSummary else { return "原生窗口" }
let title = t.currentTitle.isEmpty ? session.phaseText : t.currentTitle
return "\(title) · \(t.windows) window\(t.windows == 1 ? "" : "s")"
}
@ViewBuilder private var trailingTag: some View {
switch session.link {
case .reconnecting:
Text("重连中").font(TX.Font.mono(10.5, .medium)).foregroundStyle(p.reconnecting)
case .offline:
Text("离线").font(TX.Font.mono(10.5)).foregroundStyle(p.offline)
default:
EmptyView()
}
}
}
/// window 26×22pt + title + cwd/panes
struct SidebarWindowRow: View {
@ObservedObject var window: TmuxWindow
let isSelected: Bool
let onTap: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var subtitle: String {
var parts: [String] = []
if !window.cwd.isEmpty { parts.append(TerminalTitleCapsule.abbreviate(window.cwd)) }
let n = window.panes.count
if n > 1 { parts.append("\(n) panes") }
return parts.isEmpty ? "1 pane" : parts.joined(separator: " · ")
}
var body: some View {
Button(action: onTap) {
HStack(spacing: TX.Space.s) {
Text("\(window.index)")
.font(TX.Font.mono(11, .semibold))
.foregroundStyle(isSelected ? TXAccent.text : p.text4)
.frame(width: 26, height: 22)
.background(isSelected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: 5))
VStack(alignment: .leading, spacing: 1) {
Text(window.title)
.font(TX.Font.mono(13, isSelected ? .medium : .regular))
.foregroundStyle(isSelected ? p.text : p.text2)
.lineLimit(1)
// cwd · n panescwd #{pane_current_path}
Text(subtitle)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
Spacer(minLength: 0)
if isSelected {
TXStatusDot(color: p.online, size: 7)
}
}
.padding(.horizontal, TX.Space.s)
.frame(height: 44)
.background(isSelected ? TXAccent.selectedBg.opacity(0.75) : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.control))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.padding(.horizontal, TX.Space.s)
}
.buttonStyle(.plain)
}
}