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

660 lines
31 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
import UIKit
import GhosttyTerminal
import TXCore
struct ContentView: View {
@StateObject private var manager = SessionManager()
@StateObject private var router = AppRouter()
@StateObject private var hostStore = HostStore()
@StateObject private var credStore = FileCredentialStore()
@StateObject private var tailscaleStore = TailscaleStore()
@StateObject private var theme = TXThemeManager()
@Environment(\.scenePhase) private var scenePhase
var body: some View {
Group {
// M1 -txTsnetKey -txHost tsnet +
if let tsnetKey = UserDefaults.standard.string(forKey: "txTsnetKey"), !tsnetKey.isEmpty,
(UserDefaults.standard.string(forKey: "txHost") ?? "").isEmpty {
TsnetProbeView(authKey: tsnetKey)
} else {
RootView(manager: manager, router: router, hostStore: hostStore,
credStore: credStore, tailscaleStore: tailscaleStore)
}
}
.environmentObject(theme)
.preferredColorScheme(theme.palette.isDark ? .dark : .light)
.onChange(of: theme.palette.id) { _, _ in
// app palette surface
manager.applyTerminalTheme(theme.palette.terminalTheme)
}
.onAppear {
manager.bind(credentials: credStore, tailscale: tailscaleStore)
#if DEBUG
UIPreviewFixture.forceLandscapeIfRequested()
// UI + -txHost
if UIPreviewFixture.isEnabled {
hostStore.injectFixtureHosts(UIPreviewFixture.hosts)
tailscaleStore.injectFixtureConnections(UIPreviewFixture.tailnets)
if let s = manager.spawnFixtureSessions() { router.enter(s.id) }
return
}
#endif
#if DEBUG
// ****`-txOpenAsHost 1`
// manager.open(host:) tmux
// `-txHost` autoConnectIfConfigured +
if UserDefaults.standard.bool(forKey: "txOpenAsHost"),
let h = UIPreviewFixture.hostFromLaunchArgs() {
hostStore.injectFixtureHosts([h])
manager.open(host: h)
return
}
#endif
// -txHost
if let s = manager.autoConnectIfConfigured() { router.enter(s.id) }
}
.onChange(of: scenePhase) { _, phase in
switch phase {
case .background: manager.enterBackground()
case .active: manager.enterForeground()
default: break
}
}
#if DEBUG
// terminalx://ui/<> UIPreviewFixture.handle
// ui/ URL terminalx://resume app
.onOpenURL { url in
guard url.host == "ui" else { return }
let parts = url.pathComponents.filter { $0 != "/" }
_ = UIPreviewFixture.handle(uiCommand: parts, manager: manager, router: router)
}
#endif
}
}
///
///
/// ** fullScreenCover**
/// matchedGeometry presentation
struct RootView: 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
private var p: TXPalette { theme.palette }
var body: some View {
ZStack {
SessionCanvas(manager: manager, router: router,
hostStore: hostStore, credStore: credStore, tailscaleStore: tailscaleStore)
if router.route == .home {
HomeView(manager: manager, router: router, hostStore: hostStore,
credStore: credStore, tailscaleStore: tailscaleStore)
.transition(.opacity)
}
}
.background(p.bgDeep.ignoresSafeArea())
// 稿 1b/3a /logo1e 1g
.statusBarHidden(router.route != .home)
// tmux ****
// RootView
.sheet(isPresented: Binding(
get: { manager.sessionAwaitingChoice != nil },
set: { if !$0 { manager.sessionAwaitingChoice?.useNativeTerminal() } }
)) {
if let s = manager.sessionAwaitingChoice, let choice = s.pendingSessionChoice {
SessionPickerSheet(
host: s.displayName,
choice: choice,
onAttach: { s.attachTmuxSession($0) },
onCreate: { s.createTmuxSession($0) },
onNative: { s.useNativeTerminal() })
.environmentObject(theme)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
}
}
// / tmux /
.onChange(of: manager.enterRequest) { _, id in
guard let id else { return }
manager.enterRequest = nil
router.enter(id)
}
// /
.onChange(of: manager.sessions.count) { _, _ in
if let id = router.route.sessionID, manager.session(id) == nil { router.goHome() }
}
}
}
/// ****
///
/// "/"GhosttyKit surface grid
/// 沿 tmux tab ZStack + opacity +
/// ** PTY resize**
struct SessionCanvas: View {
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
@ObservedObject var hostStore: HostStore
@ObservedObject var credStore: FileCredentialStore
@ObservedObject var tailscaleStore: TailscaleStore
var body: some View {
ZStack {
ForEach(manager.sessions) { session in
let visible = router.route.sessionID == session.id
TerminalScreen(session: session, manager: manager, router: router,
tailscaleStore: tailscaleStore)
.opacity(visible ? 1 : 0)
// ""
// `scaleEffect` frame ** PTY resize**
// frame tmux
.scaleEffect(visible ? 1 : 0.97)
.animation(AppRouter.sidebarAnimation, value: visible)
.allowsHitTesting(visible)
}
}
}
}
/// · 稿 `1b` + `3a`
///
/// 56pt Pin 288pt + 86pt
/// 34pt **** reflow resize
/// Pin resize
struct TerminalScreen: View {
@ObservedObject var session: TerminalSession
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
@ObservedObject var tailscaleStore: TailscaleStore
@EnvironmentObject var theme: TXThemeManager
@State private var sidebarSearch = ""
/// tmux
@State private var showTmuxInstall = false
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: 0) {
if router.sidebar == .pinned {
sidebarPanel
} else {
RailView(manager: manager, router: router, session: session,
onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost)
}
stage
}
.background(p.bgDeep.ignoresSafeArea())
// 稿 1b/3a /logo RootView
// SessionCanvas hide
.ignoresSafeArea(edges: .top)
// 50%
// + 200260ms easeOut AppRouter
.overlay(alignment: .leading) {
if router.sidebar == .overlay {
ZStack(alignment: .leading) {
Color(hex: 0x0A0B12).opacity(0.5)
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture { router.dismissOverlaySidebar() }
.transition(.opacity)
sidebarPanel
.transition(.move(edge: .leading))
}
// safe area
.ignoresSafeArea()
}
}
// 3c3b alert/sheet
.overlay {
switch router.modal {
case .closeConfirm:
CloseConfirmDialog(
session: session,
onDetach: { router.dismissModal(); closeSession(kill: false) },
onKill: { router.dismissModal(); closeSession(kill: true) },
onCancel: { router.dismissModal() })
case .splitMenu(let vertical):
SplitMenuView(session: session, vertical: vertical,
onPick: { target in
router.dismissModal()
performSplit(target, vertical: vertical)
},
onCancel: { router.dismissModal() })
case nil:
EmptyView()
}
if showTmuxInstall {
TmuxInstallSheet(
onRun: { cmd in
showTmuxInstall = false
session.runInTerminal(cmd)
},
onCancel: { showTmuxInstall = false })
}
}
// host key 使
.alert("⚠️ Host Key 已变化", isPresented: Binding(
get: { session.hostKeyMismatch != nil },
set: { if !$0 { session.dismissHostKeyMismatch() } }
), presenting: session.hostKeyMismatch) { _ in
Button("信任新密钥", role: .destructive) { session.trustNewHostKey() }
Button("取消", role: .cancel) { session.dismissHostKeyMismatch() }
} message: { info in
Text("\(info.egress)/\(info.host):\(info.port) 的 host key 与已记住的不同,可能是中间人攻击。\n\n已记住:\(info.storedFp)\n本次:\(info.presentedFp)")
}
}
private var sidebarPanel: SidebarPanel {
SidebarPanel(manager: manager, router: router, session: session,
tailscaleStore: tailscaleStore,
onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost,
search: $sidebarSearch)
}
/// + / 齿
/// UI-4 /
private func goHomeToAddHost() {
router.goHome()
}
// MARK: -
private var stage: some View {
ZStack(alignment: .top) {
VStack(spacing: 0) {
// tmux 2c pane
if session.tmuxUnavailable {
NoTmuxBanner(onInstall: { showTmuxInstall = true },
onDismiss: { session.dismissTmuxBanner() })
.padding(.top, TX.Layout.capsuleInset)
.transition(.opacity)
}
// pane **** +
// surface 86pt
paneArea
// 2c 122pt
.padding(.top, session.tmuxUnavailable ? TX.Space.hairline : TX.Layout.capsuleInset)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(p.terminalBackground)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6).strokeBorder(p.border, lineWidth: 1)
)
// pane 线
.padding([.leading, .trailing, .top], TX.Layout.paneGap)
TerminalHintBar(context: hintContext, hints: hints)
}
capsules
}
.background(p.bgDeep)
.contentShape(Rectangle())
// Pin
.simultaneousGesture(TapGesture().onEnded { router.dismissOverlaySidebar() })
}
@ViewBuilder private var paneArea: some View {
ZStack {
if let tmux = session.tmuxController {
TmuxStage(controller: tmux)
} else {
RawTerminalView(model: session)
}
// / /
TerminalStateOverlay(session: session,
onRetry: { session.retry() },
onBack: { router.goHome() },
onCancel: {
let id = session.id
router.goHome()
manager.close(id)
})
}
}
private var capsules: some View {
HStack(alignment: .top) {
TerminalTitleCapsule(session: session, onClose: requestClose, onMinimize: minimizeSession)
Spacer(minLength: TX.Space.m)
TerminalStatusCapsule(session: session)
}
.padding(.horizontal, TX.Space.m)
.padding(.top, 14)
}
/// **** `closeSession(kill:)`
private func requestClose() {
router.present(.closeConfirm)
}
/// tmux `detach-client``kill=true` `kill-session`
/// teardown
private func closeSession(kill: Bool) {
let id = session.id
if let tmux = session.tmuxController {
if kill { tmux.killSession() } else { tmux.detach() }
}
withAnimation(AppRouter.sidebarAnimation) { router.goHome() }
// detach/kill tmux %exit onExit
Task { @MainActor in
try? await Task.sleep(nanoseconds: 250_000_000)
manager.close(id)
}
}
private func minimizeSession() {
session.captureSnapshot() //
manager.minimize(session.id)
withAnimation(AppRouter.sidebarAnimation) { router.goHome() }
}
///
private func performSplit(_ target: SplitMenuView.SplitTarget, vertical: Bool) {
guard let tmux = session.tmuxController else { return }
switch target {
case .currentSession:
tmux.splitWindow(vertical: vertical)
case .otherSession(let name):
tmux.splitWindow(vertical: vertical, joinSession: name)
case .newSession:
// tmux
tmux.splitIntoNewSession(vertical: vertical, name: "tx\(tmux.sessions.count + 1)")
case .nativeWindow:
break // HANDOFF §12.4
}
}
/// D / D list-sessions detached
private func requestSplit(vertical: Bool) {
session.tmuxController?.listSessions()
router.present(.splitMenu(vertical: vertical))
}
private var hintContext: String {
switch router.sidebar {
case .pinned: "Pin 态 · 终端变窄,仅在钉住/解除时 resize 一次"
case .overlay: "遮罩态 · 点终端任意处收起,终端不 reflow"
case .collapsed: session.isLive ? "已连接妙控键盘 · 附加行已收起" : session.phaseText
}
}
private var hints: [(key: String, label: String)] {
switch router.sidebar {
case .pinned: [("Pin", "解除钉住"), ("\\", "收起为轨道"), ("⌘⇧H", "回首页")]
case .overlay: [("\\", "收起"), ("Pin", "钉在左侧"), ("⌘⇧H", "回首页")]
case .collapsed: [("⌘K", "命令面板"), ("⌘D", "竖分屏"), ("⌘T", "新 window"), ("⌘⇧H", "回首页")]
}
}
}
/// tmux
struct RawTerminalView: View {
@ObservedObject var model: TerminalSession
@FocusState private var focused: Bool
var body: some View {
TerminalSurfaceView(context: model.state)
.terminalFocusOnAppear($focused)
.task {
for _ in 0 ..< 300 where model.state.surfaceSize == nil {
try? await Task.sleep(nanoseconds: 30_000_000)
}
model.markSurfaceReady()
}
}
}
/// tmux control-mode window window
/// window / window 稿 tab
struct TmuxStage: View {
@ObservedObject var controller: TmuxController
@EnvironmentObject var theme: TXThemeManager
@Environment(\.displayScale) private var displayScale
private var p: TXPalette { theme.palette }
var body: some View {
Group {
if controller.windows.isEmpty {
VStack {
Spacer()
Text("正在枚举 tmux window…")
.font(TX.Font.mono(12))
.foregroundStyle(p.textWeak)
Spacer()
}
} else {
// window pane
// ghostty surface grid
// %output
ZStack {
ForEach(controller.windows) { win in
TmuxWindowView(window: win, controller: controller)
.opacity(win.id == controller.activeWindowID ? 1 : 0)
.allowsHitTesting(win.id == controller.activeWindowID)
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// windows
// pane grid attach TmuxWindowView
// "windows list-windows " TmuxController.kickoffIfReady
.background(GeometryReader { geo in
Color.clear
.onAppear { controller.containerResized(widthPt: geo.size.width, heightPt: geo.size.height, scale: displayScale) }
.onChange(of: geo.size) { _, s in
controller.containerResized(widthPt: s.width, heightPt: s.height, scale: displayScale)
}
})
}
}
/// window tmux pane pane surface+ pane
struct TmuxWindowView: View {
@ObservedObject var window: TmuxWindow
let controller: TmuxController
var body: some View {
GeometryReader { geo in
Group {
if let layout = window.visibleLayout {
let root = layout.root.rect
let cw = geo.size.width / CGFloat(max(1, root.width))
let ch = geo.size.height / CGFloat(max(1, root.height))
ZStack(alignment: .topLeading) {
ForEach(leaves(layout.root), id: \.pane.raw) { item in
if let surface = window.panes[item.pane] {
TmuxPaneView(
surface: surface,
isActive: window.activePane == item.pane,
onTap: { controller.selectPane(item.pane, in: window.id) },
onReady: { controller.markPaneReady(item.pane) }
)
.frame(width: CGFloat(item.rect.width) * cw,
height: CGFloat(item.rect.height) * ch)
.position(x: (CGFloat(item.rect.x) + CGFloat(item.rect.width) / 2) * cw,
y: (CGFloat(item.rect.y) + CGFloat(item.rect.height) / 2) * ch)
}
}
// pane pane 12pt
ForEach(splitters(layout.root), id: \.id) { s in
PaneSplitter(splitter: s, cellW: cw, cellH: ch, controller: controller)
}
}
} else {
Color.clear
}
}
// TmuxStage containerResized
}
}
/// pane + rectidentity paneID surface view
private func leaves(_ node: TmuxLayout.Node) -> [(pane: TmuxPaneID, rect: TmuxLayout.Rect)] {
switch node {
case .leaf(let p, let r): return [(p, r)]
case .horizontal(let cs, _), .vertical(let cs, _): return cs.flatMap(leaves)
}
}
/// tmux `resize-pane -x/-y` ** pane **
/// resize " pane"
private func splitters(_ node: TmuxLayout.Node) -> [SplitterSpec] {
var out: [SplitterSpec] = []
func walk(_ n: TmuxLayout.Node) {
switch n {
case .leaf:
break
case .horizontal(let children, _):
// ****
for (i, child) in children.enumerated() where i < children.count - 1 {
let r = child.rect
if let pane = firstPane(child) {
out.append(SplitterSpec(
id: "h-\(pane.raw)-\(i)", isVertical: true, pane: pane,
x: r.x + r.width, y: r.y, length: r.height, currentSize: r.width))
}
}
children.forEach(walk)
case .vertical(let children, _):
// ****
for (i, child) in children.enumerated() where i < children.count - 1 {
let r = child.rect
if let pane = firstPane(child) {
out.append(SplitterSpec(
id: "v-\(pane.raw)-\(i)", isVertical: false, pane: pane,
x: r.x, y: r.y + r.height, length: r.width, currentSize: r.height))
}
}
children.forEach(walk)
}
}
walk(node)
return out
}
private func firstPane(_ node: TmuxLayout.Node) -> TmuxPaneID? {
switch node {
case .leaf(let p, _): return p
case .horizontal(let cs, _), .vertical(let cs, _): return cs.compactMap(firstPane).first
}
}
}
///
struct SplitterSpec {
let id: String
/// true = false =
let isVertical: Bool
/// resize pane pane
let pane: TmuxPaneID
let x: Int, y: Int
///
let length: Int
/// cols / rows
let currentSize: Int
}
/// pane 12pt 44×3pt
///
/// **** + 线 pane frame** `resize-pane`**
/// tmux `%layout-change` frame "线 b"
struct PaneSplitter: View {
let splitter: SplitterSpec
let cellW: CGFloat
let cellH: CGFloat
let controller: TmuxController
@State private var drag: CGFloat = 0
@State private var dragging = false
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var hit: CGFloat { TX.Layout.paneDragHit }
var body: some View {
Group {
if splitter.isVertical {
bar(width: hit, height: CGFloat(splitter.length) * cellH)
.position(x: CGFloat(splitter.x) * cellW + drag,
y: (CGFloat(splitter.y) + CGFloat(splitter.length) / 2) * cellH)
} else {
bar(width: CGFloat(splitter.length) * cellW, height: hit)
.position(x: (CGFloat(splitter.x) + CGFloat(splitter.length) / 2) * cellW,
y: CGFloat(splitter.y) * cellH + drag)
}
}
.gesture(
DragGesture(minimumDistance: 2)
.onChanged { v in
dragging = true
drag = splitter.isVertical ? v.translation.width : v.translation.height
}
.onEnded { _ in
// tmux
let cell = splitter.isVertical ? cellW : cellH
let delta = cell > 0 ? Int((drag / cell).rounded()) : 0
let newSize = max(1, splitter.currentSize + delta)
if delta != 0 {
controller.resizePane(splitter.pane,
cols: splitter.isVertical ? newSize : nil,
rows: splitter.isVertical ? nil : newSize)
}
drag = 0
dragging = false
}
)
}
/// + 44×3pt
private func bar(width: CGFloat, height: CGFloat) -> some View {
ZStack {
Color.clear.frame(width: width, height: height)
RoundedRectangle(cornerRadius: 2)
.fill(dragging ? TXAccent.base : p.borderStrong)
.frame(width: splitter.isVertical ? 3 : 44,
height: splitter.isVertical ? 44 : 3)
.opacity(dragging ? 1 : 0.55)
}
.contentShape(Rectangle())
}
}
/// pane + + pane 2pt
struct TmuxPaneView: View {
@ObservedObject var surface: TmuxPaneSurface
let isActive: Bool
let onTap: () -> Void
let onReady: () -> Void
@FocusState private var focused: Bool
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
TerminalSurfaceView(context: surface.state)
.terminalFocusOnAppear($focused)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(isActive ? TXAccent.border : p.divider,
lineWidth: isActive ? 1 : 0.5)
)
// pane tap-catcherTerminalSurfaceView pane
.overlay {
if !isActive {
Color.black.opacity(0.04).contentShape(Rectangle()).onTapGesture { onTap() }
}
}
.padding(TX.Layout.paneGap / 2) // pane 1pt 2pt
.onChange(of: isActive) { _, active in focused = active }
.task {
for _ in 0 ..< 300 where surface.state.surfaceSize == nil {
try? await Task.sleep(nanoseconds: 30_000_000)
}
onReady()
}
}
}