- 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>
238 lines
8.9 KiB
Swift
238 lines
8.9 KiB
Swift
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 加入+对端枚举自检。
|
||
// 若同时有 -txHost,则走正常终端流程(model 经 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-mode:window 映射为原生 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 + 绝对 rect),identity 只用 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-catcher(TerminalSurfaceView 会吞触摸);活动 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()
|
||
}
|
||
}
|
||
}
|