Files
terminalX/apps/TerminalX/iOS/ContentView.swift
kid 4b5292107b feat: 业务逻辑对齐 + 首页/弹框七项减法 + 终端页四项体验修复
业务逻辑(用户定稿):
- 点主机总是新建会话(会话:主机=多:1);activate 选中断线会话自动重连
- 「直接输入」凭据保存时自动落 Keychain(去重复用),主机只存引用
- 主机编辑页可就地新建 Tailscale 连接;删无人引用的旧 HostsListView

首页/弹框减法:
- 删侧边栏「连接新主机」、无 tmux 横幅与安装引导、页头「新建连接」
- 搜索移到「全部主机」行;添加主机虚线卡与主机卡等高(骨架复刻)
- 会话卡加 ✕ + 复用终端页同款二次确认
- 会话选择器 sheet → 项目风格居中弹框(去手柄/Header/最近)

终端页修复:
- 侧边栏会话列表改创建顺序,切换不再重排抖动
- 焦点高亮上移到 pane 容器边框(四边完整,去负 padding)
- 软键盘双重让位修复:画布忽略键盘 + TXKeyboardObserver 显式让位一次
- 自动聚焦:focusTick 脉冲强制翻转 FocusState + surface 就绪补聚焦;
  vendor 补 iPadOS 触摸板 indirectPointer 的 becomeFirstResponder

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 17:49:58 +08:00

555 lines
29 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)
// 97% ""
.transition(TX.Motion.pushBack)
}
}
.background(p.bgDeep.ignoresSafeArea())
// 稿 1b/3a /logo1e 1g
.statusBarHidden(router.route != .home)
// tmux ****
// RootView
// **** sheet+""
// = sheet =
.overlay {
if let s = manager.sessionAwaitingChoice, let choice = s.pendingSessionChoice {
SessionPickerDialog(
host: s.displayName,
choice: choice,
onAttach: { s.attachTmuxSession($0) },
onCreate: { s.createTmuxSession($0) },
onNative: { s.useNativeTerminal() },
onCancel: { manager.close(s.id) })
.transition(TX.Motion.pop)
}
}
.animation(TX.Motion.standard, value: manager.sessionAwaitingChoice?.id)
// / 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
@Environment(\.accessibilityReduceMotion) private var reduceMotion
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 || reduceMotion ? 1 : 0.97)
.animation(TX.Motion.standard, value: visible)
.allowsHitTesting(visible)
}
}
// TerminalScreen TXKeyboardObserver
// **** + footBand 6 TXParts
.ignoresSafeArea(.keyboard)
}
}
/// · 稿 `1b` + `3a`/
///
/// `TerminalSidebar`56 288+ 86pt
///
/// resize
///
/// **** chrome ****`ignoresSafeArea()`
/// / `TX.Layout.footBand`线 y
struct TerminalScreen: View {
@ObservedObject var session: TerminalSession
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
@ObservedObject var tailscaleStore: TailscaleStore
@EnvironmentObject var theme: TXThemeManager
@Environment(\.horizontalSizeClass) private var hSize
@State private var sidebarSearch = ""
/// TXKeyboardObserver
@StateObject private var keyboard = TXKeyboardObserver()
private var p: TXPalette { theme.palette }
var body: some View {
// TXScreenMetrics
TXScreenMetrics { geo in
screen(safeTop: geo.safeAreaInsets.top, safeBottom: geo.safeAreaInsets.bottom)
.ignoresSafeArea()
}
// /
// SessionCanvas onAppear
.onChange(of: router.route.sessionID) { _, id in
if id == session.id { session.focusTick &+= 1 }
}
}
private func screen(safeTop: CGFloat, safeBottom: CGFloat) -> some View {
HStack(spacing: 0) {
// **** TerminalSidebar
// 56 288
// resize
TerminalSidebar(manager: manager, router: router,
tailscaleStore: tailscaleStore,
railWidth: railWidth, safeBottom: safeBottom,
onSettings: goHomeToSettings,
search: $sidebarSearch)
stage(safeTop: safeTop, safeBottom: safeBottom)
}
.background(p.bgDeep)
// 3c 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 nil:
EmptyView()
}
}
// 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)")
}
}
/// / 48
private var railWidth: CGFloat {
hSize == .compact ? TX.Layout.railWidthCompact : TX.Layout.railWidth
}
/// 齿
private func goHomeToSettings() {
router.goHome()
}
// MARK: -
private func stage(safeTop: CGFloat, safeBottom: CGFloat) -> some View {
VStack(spacing: 0) {
// 稿/ + + tmux window tab
// **** chrome pane
TerminalTopBar(session: session, onClose: requestClose, onMinimize: minimizeSession)
.padding(.horizontal, TX.Space.m)
// iPad 14/
.padding(.top, TX.Layout.capsuleTop(safeTop))
.padding(.bottom, TX.Space.s)
// tmux tmux
// " tmux · "
paneArea
// pane
.padding(.top, TX.Space.s)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(p.terminalBackground)
.clipShape(paneShape)
.overlay(
// ****线 accent pane
// accent /
// 10pt 2026-07-26
//
//
// ""
paneShape.strokeBorder(session.isLive ? TXAccent.border : p.border,
lineWidth: 1)
.animation(TX.Motion.quick, value: session.isLive)
)
// **** 2pt
// pane 线
.padding(.leading, TX.Layout.paneGap)
// home indicator
TerminalActionBar(session: session,
safeBottom: keyboard.height > 0 ? 0 : safeBottom)
}
// ****/
.padding(.bottom, keyboard.height)
.background(p.bgDeep)
.animation(TX.Motion.standard, value: keyboard.height)
}
/// pane ** app chrome **
///
/// iPad/iPhone 6pt
/// ""
/// ****
/// pane chrome
private var paneShape: UnevenRoundedRectangle {
UnevenRoundedRectangle(
topLeadingRadius: TX.Radius.pane,
bottomLeadingRadius: TX.Radius.pane,
bottomTrailingRadius: TX.Radius.pane,
topTrailingRadius: 0)
}
@ViewBuilder private var paneArea: some View {
ZStack {
if let tmux = session.tmuxController {
TmuxStage(controller: tmux, focusTick: session.focusTick,
focusEligible: router.route.sessionID == session.id)
} else {
RawTerminalView(model: session,
focusEligible: router.route.sessionID == session.id)
}
// / /
TerminalStateOverlay(session: session,
onRetry: { session.retry() },
onBack: { router.goHome() },
onCancel: {
let id = session.id
router.goHome()
manager.close(id)
})
//
.animation(TX.Motion.standard, value: session.phaseText)
}
}
/// **** `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() }
}
router.goHome() // router TX.Motion.standard
// 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)
router.goHome()
}
}
/// tmux
struct RawTerminalView: View {
@ObservedObject var model: TerminalSession
///
let focusEligible: Bool
@FocusState private var focused: Bool
var body: some View {
TerminalSurfaceView(context: model.state)
.terminalFocusOnAppear($focused)
// / ** false true**
// onAppear FocusState true window
// truetrue updateUIViewbecomeFirstResponder
.onChange(of: model.focusTick) { _, _ in
focused = false
Task { @MainActor in focused = true }
}
.task {
for _ in 0 ..< 300 where model.state.surfaceSize == nil {
try? await Task.sleep(nanoseconds: 30_000_000)
}
model.markSurfaceReady()
// surface window
// route focusTick
if focusEligible {
focused = false
try? await Task.sleep(nanoseconds: 50_000_000)
focused = true
}
}
}
}
/// tmux control-mode window window
/// window / window 稿 tab
struct TmuxStage: View {
@ObservedObject var controller: TmuxController
/// TerminalSession.focusTick pane
let focusTick: Int
///
let focusEligible: Bool
@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,
focusTick: focusTick, focusEligible: focusEligible)
.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 ** app UI** tmux prefix+
/// app `%layout-change`
struct TmuxWindowView: View {
@ObservedObject var window: TmuxWindow
let controller: TmuxController
let focusTick: Int
let focusEligible: Bool
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))
let items = leaves(layout.root)
ZStack(alignment: .topLeading) {
ForEach(items, id: \.pane.raw) { item in
if let surface = window.panes[item.pane] {
TmuxPaneView(
surface: surface,
isActive: window.activePane == item.pane,
// panesurface
// paneViewportChanged
solo: items.count == 1,
focusTick: focusTick,
focusEligible: focusEligible,
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)
}
}
}
} 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)
}
}
}
/// pane + + pane 2pt
struct TmuxPaneView: View {
@ObservedObject var surface: TmuxPaneSurface
let isActive: Bool
/// window pane pane surface ==
let solo: Bool
/// pane becomeFirstResponder
let focusTick: Int
///
let focusEligible: 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)
// pane paneShape/
// TerminalScreen.paneArea pane pane
.clipShape(RoundedRectangle(cornerRadius: solo ? 0 : 6))
.overlay {
if !solo {
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(solo ? 0 : TX.Layout.paneGap / 2) // pane 1pt 2pt pane
.onChange(of: isActive) { _, active in focused = active }
// / pane
// ** false true**FocusState true
// truetrue updateUIView RawTerminalView
.onChange(of: focusTick) { _, _ in
guard isActive else { return }
focused = false
Task { @MainActor in focused = true }
}
.task {
for _ in 0 ..< 300 where surface.state.surfaceSize == nil {
try? await Task.sleep(nanoseconds: 30_000_000)
}
onReady()
// surface pane focusTick
if focusEligible, isActive {
focused = false
try? await Task.sleep(nanoseconds: 50_000_000)
focused = true
}
}
}
}