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>
This commit is contained in:
kid
2026-07-24 17:30:17 +08:00
parent 866df1a323
commit 9a27338222
5 changed files with 239 additions and 82 deletions

View File

@@ -1,5 +1,6 @@
import SwiftUI
import GhosttyTerminal
import TXCore
struct ContentView: View {
@StateObject private var model = SSHTerminalModel()
@@ -162,19 +163,75 @@ struct TmuxTabChip: View {
}
}
/// 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: window.state)
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 window.state.surfaceSize == nil {
for _ in 0 ..< 300 where surface.state.surfaceSize == nil {
try? await Task.sleep(nanoseconds: 30_000_000)
}
controller.markWindowReady(window.id)
onReady()
}
}
}

View File

@@ -111,6 +111,27 @@ final class OutputGate: @unchecked Sendable {
}
}
/// tmux enter / bytes AsyncStream 线
/// " chunk Task" pane %
enum TmuxByteEvent: Sendable { case enter([UInt8]); case bytes([UInt8]) }
/// 线 tmux continuation线 yield线
final class TmuxByteChannel: @unchecked Sendable {
let stream: AsyncStream<TmuxByteEvent>
private let cont: AsyncStream<TmuxByteEvent>.Continuation
init() { (stream, cont) = AsyncStream.makeStream() }
func enter(_ b: [UInt8]) { cont.yield(.enter(b)) }
func bytes(_ b: [UInt8]) { cont.yield(.bytes(b)) }
}
/// 线tmux attach `refresh-client -C`
final class GridBox: @unchecked Sendable {
private let lock = NSLock()
private var v: (cols: Int, rows: Int) = (80, 24)
var value: (cols: Int, rows: Int) { lock.lock(); defer { lock.unlock() }; return v }
func set(cols: Int, rows: Int) { lock.lock(); v = (max(1, cols), max(1, rows)); lock.unlock() }
}
/// M0 SSH SSHSession(libssh2) GhosttyKit in-memory
/// TXCore.SessionMachine 线退/
@MainActor
@@ -152,9 +173,14 @@ final class SSHTerminalModel: ObservableObject {
private var bgTask: UIBackgroundTaskIdentifier = .invalid
#endif
private let tmuxChannel = TmuxByteChannel()
/// raw surface resize tmux attach refresh-client -C
let screenGrid = GridBox()
init() {
let holder = TransportHolder()
let moshHolder = MoshHolder()
let grid = screenGrid
let session = InMemoryTerminalSession(
// /resizemosh mosh SSH transport
write: { data in
@@ -162,6 +188,7 @@ final class SSHTerminalModel: ObservableObject {
else { holder.transport?.send(data) }
},
resize: { vp in
grid.set(cols: Int(vp.columns), rows: Int(vp.rows)) // tmux
if let m = moshHolder.session { m.resize(cols: Int(vp.columns), rows: Int(vp.rows)) }
else { holder.transport?.resize(cols: vp.columns, rows: vp.rows) }
}
@@ -173,6 +200,18 @@ final class SSHTerminalModel: ObservableObject {
self.session = session
self.state = state
self.gate = OutputGate(session: session)
// tmux enter controller+bytes
let ch = tmuxChannel
Task { @MainActor [weak self] in
for await ev in ch.stream {
guard let self else { continue }
switch ev {
case .enter(let initial): self.enterTmux(initialBytes: initial)
case .bytes(let b): self.tmuxController?.feed(Data(b))
}
}
}
}
// MARK: -
@@ -285,18 +324,18 @@ final class SSHTerminalModel: ObservableObject {
private lazy var tmuxRouter = TmuxRouter(
rawGate: gate,
enterGateway: { [weak self] after in
Task { @MainActor in self?.enterTmux(initialBytes: after) }
},
gatewayBytes: { [weak self] bytes in
Task { @MainActor in self?.tmuxController?.feed(Data(bytes)) }
}
// 线enter bytes
enterGateway: { [tmuxChannel] after in tmuxChannel.enter(after) },
gatewayBytes: { [tmuxChannel] bytes in tmuxChannel.bytes(bytes) }
)
private func enterTmux(initialBytes: [UInt8]) {
let holder = self.holder
let controller = TmuxController(sendRaw: { data in holder.transport?.send(data) })
controller.onExit = { [weak self] in Task { @MainActor in self?.exitTmux() } }
// attach refresh-client -C tmux
let g = screenGrid.value
controller.setClientSize(cols: g.cols, rows: g.rows)
tmuxController = controller
if !initialBytes.isEmpty { controller.feed(Data(initialBytes)) }
}

View File

@@ -2,22 +2,24 @@ import Foundation
import GhosttyTerminal
import TXCore
/// tmux windowMVP pane window + surface
/// pane session + surface state +
@MainActor
final class TmuxWindow: ObservableObject, Identifiable {
let id: TmuxWindowID
@Published var title: String
final class TmuxPaneSurface: ObservableObject, Identifiable {
let id: TmuxPaneID
let state: TerminalViewState
let session: InMemoryTerminalSession
let gate: OutputGate
var activePane: TmuxPaneID?
/// layout rect cols×rows frame
var grid: (cols: Int, rows: Int)
/// `capture-pane` pane %outputtmux 线
var awaitingCapture = true
init(id: TmuxWindowID, title: String, onInput: @escaping @Sendable (Data) -> Void) {
init(id: TmuxPaneID, cols: Int, rows: Int, onInput: @escaping @Sendable (Data) -> Void) {
self.id = id
self.title = title
self.grid = (cols, rows)
let session = InMemoryTerminalSession(
write: { data in onInput(data) }, // send-keys controller
resize: { _ in }
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))
@@ -27,9 +29,24 @@ final class TmuxWindow: ObservableObject, Identifiable {
}
}
/// tmux control-mode `%`- tmux windows tab
/// MVP window pane `send-keys -H` tab `select-window`
/// pane pane attach
/// 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] = []
@@ -37,19 +54,25 @@ final class TmuxController: ObservableObject {
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)?
// control-mode FIFO attach list-windows
private var clientCols = 80, clientRows = 24
// control-mode FIFO
private var attachAcked = false
private enum PendingKind { case ignore, listWindows, listPanes(TmuxWindowID), capturePane(TmuxWindowID) }
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]
@@ -65,29 +88,20 @@ final class TmuxController: ObservableObject {
private func apply(_ event: TmuxEvent) {
switch event {
case .windowAdd(let w):
_ = ensureWindow(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 win = ensureWindow(w)
if let parsed = try? TmuxLayout.parse(layout) {
let panes = parsed.paneIDs
for p in panes { paneToWindow[p] = w }
if win.activePane == nil { win.activePane = panes.first }
}
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
windowByID[w]?.activePane = pane // select-pane
case .output(let pane, let data):
if let wid = paneToWindow[pane] {
windowByID[wid]?.gate.deliver(data)
}
routeOutput(pane, data)
case .extendedOutput(let pane, _, let data):
if let wid = paneToWindow[pane] {
windowByID[wid]?.gate.deliver(data)
}
routeOutput(pane, data)
case .sessionWindowChanged(_, let w):
if windowByID[w] != nil { activeWindowID = w }
case .commandResponse(let response):
@@ -99,13 +113,67 @@ final class TmuxController: ObservableObject {
}
}
/// control-mode attach list-windows
/// FIFO
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)
sendCommand("list-windows -F \"#{window_id} #{window_name}\"")
return
}
guard !pending.isEmpty else { return }
@@ -114,34 +182,21 @@ final class TmuxController: ObservableObject {
break
case .listWindows:
for line in r.lines {
let parts = line.split(separator: " ", maxSplits: 1)
guard let first = parts.first, let w = TmuxWindowID.parse(first) else { continue }
let parts = line.split(separator: "\t", omittingEmptySubsequences: false)
guard parts.count >= 5, let w = TmuxWindowID.parse(parts[0]) else { continue }
let win = ensureWindow(w)
if parts.count > 1 { win.title = String(parts[1]) }
pending.append(.listPanes(w))
sendCommand("list-panes -t @\(w.raw) -F \"#{pane_id} #{pane_active}\"")
win.title = String(parts[1])
if parts[2] == "1" { activeWindowID = w }
applyLayout(window: w, full: String(parts[3]), visible: String(parts[4]))
}
sendCommand("refresh-client") // %output
pending.append(.ignore)
case .listPanes(let w):
for line in r.lines {
let parts = line.split(separator: " ")
guard let first = parts.first, let p = TmuxPaneID.parse(first) else { continue }
paneToWindow[p] = w
let active = parts.count > 1 && parts[1] == "1"
if active || windowByID[w]?.activePane == nil {
windowByID[w]?.activePane = p
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))
}
}
// %output
if let pane = windowByID[w]?.activePane {
pending.append(.capturePane(w))
sendCommand("capture-pane -t %\(pane.raw) -p -e -J")
}
case .capturePane(let w):
let screen = r.lines.joined(separator: "\r\n")
if !screen.isEmpty {
windowByID[w]?.gate.deliver(Data((screen + "\r\n").utf8))
s.awaitingCapture = false // %output
}
}
}
@@ -149,9 +204,7 @@ final class TmuxController: ObservableObject {
@discardableResult
private func ensureWindow(_ w: TmuxWindowID) -> TmuxWindow {
if let existing = windowByID[w] { return existing }
let win = TmuxWindow(id: w, title: "窗口 \(w.raw)") { [weak self] data in
self?.enqueueInput(window: w, data: data)
}
let win = TmuxWindow(id: w, title: "窗口 \(w.raw)")
windowByID[w] = win
windows.append(win)
windows.sort { $0.id.raw < $1.id.raw }
@@ -160,6 +213,9 @@ final class TmuxController: ObservableObject {
}
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 }
@@ -172,13 +228,16 @@ final class TmuxController: ObservableObject {
sendRaw(Data((command + "\n").utf8))
}
/// window send-keys -H pane线线
private nonisolated func enqueueInput(window w: TmuxWindowID, data: Data) {
Task { @MainActor in
guard let pane = self.windowByID[w]?.activePane else { return }
let hex = data.map { String(format: "%02x", $0) }.joined(separator: " ")
self.sendCommand("send-keys -t %\(pane.raw) -H \(hex)")
}
/// 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) {
@@ -189,8 +248,8 @@ final class TmuxController: ObservableObject {
func newWindow() { sendCommand("new-window") }
func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") }
/// window surface
func markWindowReady(_ w: TmuxWindowID) { windowByID[w]?.gate.markReady() }
/// pane surface
func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() }
}
/// `tmux -CC` DCS tmux