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:
@@ -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(
|
||||
// 输入/resize:mosh 激活时路由到 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)) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user