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

302 lines
12 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 Foundation
import GhosttyTerminal
import TXCore
/// pane session + surface state +
@MainActor
final class TmuxPaneSurface: ObservableObject, Identifiable {
let id: TmuxPaneID
let state: TerminalViewState
let session: InMemoryTerminalSession
let gate: OutputGate
/// layout rect cols×rows frame
var grid: (cols: Int, rows: Int)
/// `capture-pane` pane %outputtmux 线
var awaitingCapture = true
init(id: TmuxPaneID, cols: Int, rows: Int, onInput: @escaping @Sendable (Data) -> Void) {
self.id = id
self.grid = (cols, rows)
let session = InMemoryTerminalSession(
write: { data in onInput(data) }, // pane send-keys -t %self
resize: { _ in } // tmux surface resize tmux
)
let state = TerminalViewState()
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
self.session = session
self.state = state
self.gate = OutputGate(session: session)
}
}
/// tmux window pane surface + / diff
@MainActor
final class TmuxWindow: ObservableObject, Identifiable {
let id: TmuxWindowID
@Published var title: String
@Published var panes: [TmuxPaneID: TmuxPaneSurface] = [:]
/// zoom zoom pane
@Published var visibleLayout: TmuxLayout?
/// pane diff
var fullLayout: TmuxLayout?
@Published var activePane: TmuxPaneID?
init(id: TmuxWindowID, title: String) { self.id = id; self.title = title }
}
/// tmux control-mode `%`- window tabwindow pane
/// "线 b"app `refresh-client -C WxH` tmux pane surface
/// layout rect%output tmux pane /
@MainActor
final class TmuxController: ObservableObject {
@Published private(set) var windows: [TmuxWindow] = []
@Published var activeWindowID: TmuxWindowID?
private let parser = TmuxControlParser()
private var windowByID: [TmuxWindowID: TmuxWindow] = [:]
private var surfaceByPane: [TmuxPaneID: TmuxPaneSurface] = [:] // %output O(1) pane
private var paneToWindow: [TmuxPaneID: TmuxWindowID] = [:]
private let sendRaw: @Sendable (Data) -> Void
var onExit: (() -> Void)?
private var clientCols = 80, clientRows = 24
// control-mode FIFO
private var attachAcked = false
private enum PendingKind { case ignore, listWindows, capturePane(TmuxPaneID) }
private var pending: [PendingKind] = []
init(sendRaw: @escaping @Sendable (Data) -> Void) {
self.sendRaw = sendRaw
}
/// model attach tmux refresh-client -C
func setClientSize(cols: Int, rows: Int) { clientCols = max(1, cols); clientRows = max(1, rows) }
var activeWindow: TmuxWindow? {
guard let id = activeWindowID else { return windows.first }
return windowByID[id]
}
/// control-mode
func feed(_ data: Data) {
for event in parser.feed(data) { apply(event) }
}
// MARK: -
private func apply(_ event: TmuxEvent) {
switch event {
case .windowAdd(let w):
_ = ensureWindow(w) // %layout-change
case .windowClose(let w), .unlinkedWindowClose(let w):
removeWindow(w)
case .windowRenamed(let w, let name):
windowByID[w]?.title = name
case .layoutChange(let w, let layout, let visible):
applyLayout(window: w, full: layout, visible: visible ?? layout)
case .windowPaneChanged(let w, let pane):
paneToWindow[pane] = w
windowByID[w]?.activePane = pane // select-pane
case .output(let pane, let data):
routeOutput(pane, data)
case .extendedOutput(let pane, _, let data):
routeOutput(pane, data)
case .sessionWindowChanged(_, let w):
if windowByID[w] != nil { activeWindowID = w }
case .commandResponse(let response):
handleCommandResponse(response)
case .exit:
onExit?()
default:
break
}
}
private func routeOutput(_ pane: TmuxPaneID, _ data: Data) {
guard let s = surfaceByPane[pane] else { return }
if s.awaitingCapture { return } // capture
s.gate.deliver(data)
}
/// layout reconcile pane surface//
private func applyLayout(window w: TmuxWindowID, full: String, visible: String) {
let win = ensureWindow(w)
guard let fullTree = try? TmuxLayout.parse(full) else { return }
let visTree = (try? TmuxLayout.parse(visible)) ?? fullTree
let newPanes = Set(fullTree.paneIDs)
// pane
for p in Array(win.panes.keys) where !newPanes.contains(p) {
win.panes[p] = nil; surfaceByPane[p] = nil; paneToWindow[p] = nil
}
// pane surface zoomgrid rect
walkLeaves(fullTree.root) { pane, rect in
ensurePane(pane, window: w, cols: rect.width, rows: rect.height)
}
walkLeaves(visTree.root) { pane, rect in
surfaceByPane[pane]?.grid = (rect.width, rect.height)
}
win.fullLayout = fullTree
win.visibleLayout = visTree // SwiftUI
if win.activePane == nil || !newPanes.contains(win.activePane!) {
win.activePane = fullTree.paneIDs.first
}
}
private func walkLeaves(_ node: TmuxLayout.Node, _ visit: (TmuxPaneID, TmuxLayout.Rect) -> Void) {
switch node {
case .leaf(let p, let r): visit(p, r)
case .horizontal(let cs, _), .vertical(let cs, _): cs.forEach { walkLeaves($0, visit) }
}
}
private func ensurePane(_ p: TmuxPaneID, window w: TmuxWindowID, cols: Int, rows: Int) {
paneToWindow[p] = w
guard let win = windowByID[w] else { return }
if let existing = surfaceByPane[p] { existing.grid = (cols, rows); return } //
let surface = TmuxPaneSurface(id: p, cols: cols, rows: rows) { [weak self] data in
self?.sendKeys(pane: p, data: data)
}
surfaceByPane[p] = surface
win.panes[p] = surface
// capture %end %output
pending.append(.capturePane(p))
sendCommand("capture-pane -t %\(p.raw) -p -e -J")
}
/// control-mode attach + windows FIFO
private func handleCommandResponse(_ r: TmuxCommandResponse) {
if !attachAcked {
attachAcked = true
// tmux layout
sendCommand("refresh-client -C \(clientCols)x\(clientRows)")
pending.append(.ignore)
sendCommand("list-windows -F \"#{window_id}\t#{window_name}\t#{window_active}\t#{window_layout}\t#{window_visible_layout}\"")
pending.append(.listWindows)
return
}
guard !pending.isEmpty else { return }
switch pending.removeFirst() {
case .ignore:
break
case .listWindows:
for line in r.lines {
let parts = line.split(separator: "\t", omittingEmptySubsequences: false)
guard parts.count >= 5, let w = TmuxWindowID.parse(parts[0]) else { continue }
let win = ensureWindow(w)
win.title = String(parts[1])
if parts[2] == "1" { activeWindowID = w }
applyLayout(window: w, full: String(parts[3]), visible: String(parts[4]))
}
case .capturePane(let p):
let snapshot = r.lines.joined(separator: "\r\n")
if let s = surfaceByPane[p] {
if !snapshot.isEmpty {
//
s.gate.deliver(Data(("\u{1b}[H" + snapshot).utf8))
}
s.awaitingCapture = false // %output
}
}
}
@discardableResult
private func ensureWindow(_ w: TmuxWindowID) -> TmuxWindow {
if let existing = windowByID[w] { return existing }
let win = TmuxWindow(id: w, title: "窗口 \(w.raw)")
windowByID[w] = win
windows.append(win)
windows.sort { $0.id.raw < $1.id.raw }
if activeWindowID == nil { activeWindowID = w }
return win
}
private func removeWindow(_ w: TmuxWindowID) {
if let win = windowByID[w] {
for p in win.panes.keys { surfaceByPane[p] = nil }
}
windowByID[w] = nil
windows.removeAll { $0.id == w }
paneToWindow = paneToWindow.filter { $0.value != w }
if activeWindowID == w { activeWindowID = windows.first?.id }
}
// MARK: - /
func sendCommand(_ command: String) {
sendRaw(Data((command + "\n").utf8))
}
/// pane surface send-keys -H pane线hop 线
private nonisolated func sendKeys(pane p: TmuxPaneID, data: Data) {
let hex = data.map { String(format: "%02x", $0) }.joined(separator: " ")
Task { @MainActor in self.sendCommand("send-keys -t %\(p.raw) -H \(hex)") }
}
/// pane + tmux%window-pane-changed
func selectPane(_ p: TmuxPaneID, in w: TmuxWindowID) {
windowByID[w]?.activePane = p
sendCommand("select-pane -t %\(p.raw)")
}
func selectWindow(_ w: TmuxWindowID) {
activeWindowID = w
sendCommand("select-window -t @\(w.raw)")
}
func newWindow() { sendCommand("new-window") }
func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") }
/// pane surface
func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() }
}
/// `tmux -CC` DCS tmux
/// 线线 rawGate hop 线
final class TmuxRouter: @unchecked Sendable {
private let lock = NSLock()
private let rawGate: OutputGate
private let enterGateway: @Sendable ([UInt8]) -> Void
private let gatewayBytes: @Sendable ([UInt8]) -> Void
private var carry: [UInt8] = []
private var inGateway = false
init(rawGate: OutputGate,
enterGateway: @escaping @Sendable ([UInt8]) -> Void,
gatewayBytes: @escaping @Sendable ([UInt8]) -> Void) {
self.rawGate = rawGate
self.enterGateway = enterGateway
self.gatewayBytes = gatewayBytes
}
func feed(_ data: Data) {
lock.lock(); defer { lock.unlock() }
if inGateway {
gatewayBytes(Array(data))
return
}
let buf = carry + Array(data)
if let idx = TmuxControlSequence.find(TmuxControlSequence.enter, in: buf) {
let before = Array(buf[0 ..< idx])
let after = Array(buf[(idx + TmuxControlSequence.enter.count)...])
if !before.isEmpty { rawGate.deliver(Data(before)) }
inGateway = true
carry = []
enterGateway(after)
} else {
//
let k = TmuxControlSequence.partialTrailingMatchLength(TmuxControlSequence.enter, in: buf)
if buf.count > k {
rawGate.deliver(Data(buf[0 ..< (buf.count - k)]))
}
carry = k > 0 ? Array(buf.suffix(k)) : []
}
}
/// tmux 退
func reset() {
lock.lock(); inGateway = false; carry = []; lock.unlock()
}
}