Files
terminalX/apps/TerminalX/iOS/ContentView.swift
kid 5d1abdc7ac feat: 修复终端换行错乱根因 + 顶带重设计 + 移除移动端分屏
- 换行错乱根因:tmux 客户端格子改以 ghostty 实测为权威(像素除法
  偏大 1 行/列 → 远端按更宽排版、本地自动换行掉字符),单 pane 实测
  格子直发 refresh-client;kickoff 用 raw surface 真实格子;顶栏
  格子数 tmux 模式读 clientGrid(模拟器连本机 sshd 端到端验证)
- 顶带落地为真实 chrome 行:✕/⌄ 独立按钮组 + 弱化标题 +
  TmuxWindowTabGroup window tab 组,pane 不再被悬浮胶囊遮内容
- 放弃移动端分屏:删 SplitMenu/分屏入口,tmux window 切换上移顶部
  tab,侧边栏改列活动会话;底部动作条合并状态行
- Info.plist 补 NSLocalNetworkUsageDescription(LAN 直连必需,
  缺失时 socket 静默超时)

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

638 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)
// 97% ""
.transition(TX.Motion.pushBack)
}
}
.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
@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)
}
}
}
}
/// · 稿 `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 = ""
/// tmux
@State private var showTmuxInstall = false
private var p: TXPalette { theme.palette }
var body: some View {
// TXScreenMetrics
TXScreenMetrics { geo in
screen(safeTop: geo.safeAreaInsets.top, safeBottom: geo.safeAreaInsets.bottom)
.ignoresSafeArea()
}
}
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,
onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost,
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()
}
if showTmuxInstall {
TmuxInstallSheet(
onRun: { cmd in
withAnimation(TX.Motion.standard) { showTmuxInstall = false }
session.runInTerminal(cmd)
},
onCancel: { withAnimation(TX.Motion.standard) { 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)")
}
}
/// / 48
private var railWidth: CGFloat {
hSize == .compact ? TX.Layout.railWidthCompact : TX.Layout.railWidth
}
/// + / 齿
/// UI-4 /
private func goHomeToAddHost() {
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 2c pane
if session.tmuxUnavailable {
NoTmuxBanner(onInstall: { withAnimation(TX.Motion.standard) { showTmuxInstall = true } },
onDismiss: {
withAnimation(TX.Motion.standard) { session.dismissTmuxBanner() }
})
.transition(.opacity)
}
paneArea
// pane
.padding(.top, TX.Space.s)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(p.terminalBackground)
.clipShape(paneShape)
.overlay(
paneShape.strokeBorder(p.border, lineWidth: 1)
//
// 线 chrome
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: -1))
)
// **** 2pt
// pane 线
.padding(.leading, TX.Layout.paneGap)
TerminalActionBar(session: session, actions: barActions, safeBottom: safeBottom)
}
.background(p.bgDeep)
}
/// 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)
} else {
RawTerminalView(model: session)
}
// / /
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()
}
/// **** window
/// `TerminalActionBar.statusLine`
private var barActions: [TerminalActionBar.Action] {
let tmux = session.tmuxController
return [
.init(id: "window", icon: "plus.rectangle.on.rectangle", title: "新建 window",
enabled: tmux != nil) {
tmux?.newWindow()
},
.init(id: "home", icon: "house", title: "回首页") { router.goHome() },
]
}
}
/// 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))
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,
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
/// window pane pane surface ==
let solo: 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(solo ? 0 : TX.Layout.paneGap / 2) // pane 1pt 2pt pane
.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()
}
}
}