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:
@@ -2,22 +2,24 @@ import Foundation
|
||||
import GhosttyTerminal
|
||||
import TXCore
|
||||
|
||||
/// 一个 tmux window(MVP:显示其活动 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 的 %output(避免与快照重复);tmux 单线程串行保证正确。
|
||||
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 映射为原生 tab、window 内多 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(保内容跨 zoom);grid 取可见树 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 网关。
|
||||
|
||||
Reference in New Issue
Block a user