Files
terminalX/apps/TerminalX/iOS/ContentView.swift
kid 9a27338222 feat: tmux 多 pane 分屏(pane-per-surface + refresh-client -C 尺寸协调)
- TmuxController 重构为 pane-per-surface:TmuxPaneSurface(session+state+gate/pane)
  + TmuxWindow(panes 字典/visibleLayout 渲染/fullLayout diff) + surfaceByPane O(1) 路由
- 尺寸走路线 b:attach 发 refresh-client -C WxH 让 tmux 布局,pane surface 尺寸取 layout
  rect(%output 按 tmux pane 宽高排版,一致才不换行/清屏错乱);surface resize 只断言不反向驱动
- applyLayout reconcile(增删留、复用不重建);新 pane capture-pane %end 前丢弃 %output
- 输入绑 pane→send-keys -t %self;tap→select-pane;ContentView 按 rect 绝对定位分屏 + 焦点边框
- 前置修复:tmux 网关字节改 TmuxByteChannel(AsyncStream 单消费者)保序,防多 pane 打碎协议
- 验证(192.168.9.199 直连 tmux -CC attach):左右 pane 各渲染 LEFT/RIGHT 输出、实时、无错乱

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-24 17:30:17 +08:00

238 lines
8.9 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 GhosttyTerminal
import TXCore
struct ContentView: View {
@StateObject private var model = SSHTerminalModel()
@Environment(\.scenePhase) private var scenePhase
var body: some View {
// M1 -txTsnetKey -txHost tsnet +
// -txHostmodel tsnet fd
if let tsnetKey = UserDefaults.standard.string(forKey: "txTsnetKey"), !tsnetKey.isEmpty,
(UserDefaults.standard.string(forKey: "txHost") ?? "").isEmpty {
TsnetProbeView(authKey: tsnetKey)
} else {
Group {
if model.showsTerminal {
TerminalScreen(model: model)
} else {
ConnectionForm(model: model)
}
}
.onAppear { model.autoConnectIfConfigured() }
.onChange(of: scenePhase) { _, phase in
switch phase {
case .background: model.enterBackground()
case .active: model.enterForeground()
default: break
}
}
}
}
}
/// M0 SSH tsnet
struct ConnectionForm: View {
@ObservedObject var model: SSHTerminalModel
@State private var host = ""
@State private var port = "22"
@State private var username = ""
@State private var password = ""
var body: some View {
NavigationStack {
Form {
Section("SSH 直连 (M0)") {
TextField("主机 / IP", text: $host)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
TextField("端口", text: $port)
.keyboardType(.numberPad)
TextField("用户名", text: $username)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
SecureField("密码", text: $password)
}
Section {
Button {
model.connect(host: host, port: Int(port) ?? 22,
username: username, password: password)
} label: {
Text("连接").frame(maxWidth: .infinity)
}
.disabled(host.isEmpty || username.isEmpty)
}
Section("状态") {
Text(model.phaseText)
.foregroundStyle(model.phaseText.hasPrefix("失败") ? .red : .secondary)
if !model.banner.isEmpty {
Text(model.banner).font(.caption).foregroundStyle(.secondary)
}
}
}
.navigationTitle("terminalX")
}
}
}
struct TerminalScreen: View {
@ObservedObject var model: SSHTerminalModel
var body: some View {
if let tmux = model.tmuxController {
TmuxTabbedView(controller: tmux) // tmux -CC tab
} else {
RawTerminalView(model: model) //
}
}
}
/// tmux
struct RawTerminalView: View {
@ObservedObject var model: SSHTerminalModel
@FocusState private var focused: Bool
var body: some View {
TerminalSurfaceView(context: model.state)
.terminalFocusOnAppear($focused)
.overlay(alignment: .top) {
if model.phaseText != "已连接", !model.banner.isEmpty {
Text(model.banner)
.font(.caption)
.padding(.horizontal, 10).padding(.vertical, 6)
.background(.thinMaterial, in: Capsule())
.padding(.top, 6)
}
}
.task {
for _ in 0 ..< 300 where model.state.surfaceSize == nil {
try? await Task.sleep(nanoseconds: 30_000_000)
}
model.markSurfaceReady()
}
}
}
/// tmux control-modewindow tab + window
struct TmuxTabbedView: View {
@ObservedObject var controller: TmuxController
var body: some View {
VStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(controller.windows) { win in
TmuxTabChip(window: win,
isActive: win.id == controller.activeWindowID) {
controller.selectWindow(win.id)
}
}
Button { controller.newWindow() } label: {
Image(systemName: "plus").padding(.horizontal, 6)
}
}
.padding(.horizontal, 8).padding(.vertical, 6)
}
.background(.thinMaterial)
Divider()
if let win = controller.activeWindow {
TmuxWindowView(window: win, controller: controller)
.id(win.id.raw)
} else {
Spacer(); Text("无 tmux 窗口").foregroundStyle(.secondary); Spacer()
}
}
}
}
struct TmuxTabChip: View {
@ObservedObject var window: TmuxWindow
let isActive: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
Text(window.title)
.font(.caption).lineLimit(1)
.padding(.horizontal, 12).padding(.vertical, 6)
.background(isActive ? Color.accentColor.opacity(0.25) : Color.gray.opacity(0.12),
in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
}
/// window tmux pane pane surface
struct TmuxWindowView: View {
@ObservedObject var window: TmuxWindow
let controller: TmuxController
var body: some View {
GeometryReader { geo in
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)
}
}
}
} else {
Color.clear
}
}
}
/// 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 + +
struct TmuxPaneView: View {
@ObservedObject var surface: TmuxPaneSurface
let isActive: Bool
let onTap: () -> Void
let onReady: () -> Void
@FocusState private var focused: Bool
var body: some View {
TerminalSurfaceView(context: surface.state)
.terminalFocusOnAppear($focused)
.overlay(
Rectangle().stroke(isActive ? Color.accentColor : Color.gray.opacity(0.35),
lineWidth: isActive ? 2 : 0.5)
)
// pane tap-catcherTerminalSurfaceView pane
.overlay {
if !isActive {
Color.black.opacity(0.04).contentShape(Rectangle()).onTapGesture { onTap() }
}
}
.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()
}
}
}