- 顶带:实心圆窗口按钮、无边框 tab 胶囊、右上 mosh/SSH 协议徽标 - 减法:删「新建 window/回首页」按钮、tab 组 +、pane 拖拽调宽, window/pane 管理交还 tmux(连带清理 resizePane/newWindow/ui-resize) - 底部状态行去重:只留链路 + tmux 概览,协议由顶带徽标独占 - 徽标全局统一:淡染底+同色文字胶囊(线框废除),拼写走 TXTech 规范 - CLAUDE.md 记录无头截图横屏帧缓冲右侧裁切大坑 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
462 lines
23 KiB
Swift
462 lines
23 KiB
Swift
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 的 %output(避免与快照重复);tmux 单线程串行保证正确。
|
||
var awaitingCapture = true
|
||
|
||
init(id: TmuxPaneID, cols: Int, rows: Int,
|
||
onInput: @escaping @Sendable (Data) -> Void,
|
||
onViewport: @escaping @Sendable (Int, Int, Int, Int) -> Void) {
|
||
self.id = id
|
||
self.grid = (cols, rows)
|
||
let session = InMemoryTerminalSession(
|
||
write: { data in onInput(data) }, // 输入直接绑到本 pane → send-keys -t %self
|
||
// tmux 模式:surface resize 上报**真实格子 + cell 像素**(cols/rows/cellW/cellH)。
|
||
// controller 只在「单 pane window」时把它当客户端尺寸的权威(见 paneViewportChanged),
|
||
// 多 pane 下仅刷新 cell 缓存 —— 依旧不构成反馈环。
|
||
resize: { vp in onViewport(Int(vp.columns), Int(vp.rows),
|
||
Int(vp.cellWidthPixels), Int(vp.cellHeightPixels)) }
|
||
)
|
||
let state = TerminalViewState()
|
||
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
|
||
_ = state.controller.setTheme(.txDefault) // Catppuccin Mocha 终端配色
|
||
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
|
||
/// tmux `#{window_index}`(用户可见编号,轨道/侧边栏显示这个;`id` 是内部 `@N`)。
|
||
@Published var index: Int
|
||
@Published var title: String
|
||
/// 活动 pane 的工作目录(`#{pane_current_path}`)。title 是 shell 默认值时 UI 回退显示它。
|
||
@Published var cwd: 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, index: Int, title: String) {
|
||
self.id = id; self.index = index; 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] = []
|
||
@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
|
||
/// 客户端格子对外快照(顶栏胶囊显示用 —— tmux 模式下 raw 的 screenGrid 已随 RawTerminalView 卸载冻结)。
|
||
@Published private(set) var clientGrid: (cols: Int, rows: Int) = (0, 0)
|
||
private var cellWpx = 0, cellHpx = 0 // cell 设备像素(换算容器点尺寸→格子)
|
||
private var lastSentCols = 0, lastSentRows = 0
|
||
private var resizeDebounce: Task<Void, Never>?
|
||
|
||
// control-mode 命令应答 FIFO 匹配。
|
||
private var attachAcked = false
|
||
private enum PendingKind: CustomStringConvertible {
|
||
case ignore, listWindows, currentSession, capturePane(TmuxPaneID)
|
||
var description: String {
|
||
switch self {
|
||
case .ignore: "ignore"
|
||
case .listWindows: "listWindows"
|
||
case .currentSession: "currentSession"
|
||
case .capturePane(let p): "capturePane(%\(p.raw))"
|
||
}
|
||
}
|
||
}
|
||
private var pending: [PendingKind] = []
|
||
|
||
// attach kickoff(refresh-client + list-windows + 各 pane capture)必须等"容器真实几何已知"再发:
|
||
// pane 的 ghostty grid 尺寸由容器像素几何决定(tab 条吃掉顶部高度),一开始就 ≠ raw 全屏格子。
|
||
// 若先按 raw 尺寸 attach+capture,快照行数 > pane grid 行数 → 铺快照时溢出上滚 → 提示符被拆/内容钉底。
|
||
// 故 kickoff 双条件:attach 首应答已到 ∧ 容器几何已到(先到者等后到者触发)。
|
||
private var containerKnown = false
|
||
private var kickoffDone = false
|
||
|
||
/// format 字段分隔符。**不要用 tab**:真机实测 tab 经 PTY 到 tmux 后变成下划线,
|
||
/// 导致 list-windows/list-sessions 整行分不开(title/cwd/会话名全丢,UI 显示兜底名)。
|
||
/// 也不能用 `#`(tmux format 里 `#{}`/`##` 有特殊含义)。
|
||
static let fieldSep = "|:|"
|
||
|
||
init(sendRaw: @escaping @Sendable (Data) -> Void) {
|
||
self.sendRaw = sendRaw
|
||
}
|
||
|
||
/// attach 序列启动器:仅当 attach 应答与容器几何都就绪时发一次(refresh-client 用容器换算尺寸)。
|
||
private func kickoffIfReady() {
|
||
guard attachAcked, containerKnown, !kickoffDone else { return }
|
||
kickoffDone = true
|
||
lastSentCols = clientCols; lastSentRows = clientRows // 后续实测同值时不再重发
|
||
NSLog("TMUXDBG kickoff refresh-client -C \(clientCols)x\(clientRows)")
|
||
sendCommand("refresh-client -C \(clientCols)x\(clientRows)") // raw surface 实测格子(非像素换算)
|
||
pending.append(.ignore)
|
||
let sep = Self.fieldSep
|
||
sendCommand("list-windows -F \"#{window_id}\(sep)#{window_name}\(sep)#{window_active}\(sep)#{window_layout}\(sep)#{window_visible_layout}\(sep)#{pane_current_path}\(sep)#{window_index}\"")
|
||
pending.append(.listWindows)
|
||
// 本客户端所在的会话名。**不能**用 list-sessions 的 `session_attached` 反推——
|
||
// 服务器上别的客户端 attach 着其它会话时会猜错(真机实测把 `demo` 当成了当前会话)。
|
||
sendCommand("display-message -p \"#{session_name}\"")
|
||
pending.append(.currentSession)
|
||
}
|
||
|
||
/// 由 model 在 attach 前告知全屏格子(tmux 据此 refresh-client -C 布局)。
|
||
func setClientSize(cols: Int, rows: Int) {
|
||
clientCols = max(1, cols); clientRows = max(1, rows)
|
||
clientGrid = (clientCols, clientRows)
|
||
}
|
||
|
||
/// cell 设备像素(来自 raw 终端或 pane surface metrics)。
|
||
func setCellPixels(w: Int, h: Int) { if w > 0 { cellWpx = w }; if h > 0 { cellHpx = h } }
|
||
|
||
/// pane surface 的真实格子上报 —— **客户端尺寸的权威来源**。
|
||
///
|
||
/// 像素除法(containerResized)复刻不了 ghostty 内部的取整/内边距:真机与模拟器实测
|
||
/// 「容器 px ÷ 整数 cell px」比 surface 真实格子**偏大 1 行/列**(如算得 164×51、真实 163×50)。
|
||
/// 偏大意味着远端按更宽的行排版、surface 按真实宽度自动换行 → 每条满宽行掉字符,
|
||
/// 全屏 TUI(Claude Code 等)重绘几轮就积累成行首碎片(真机花屏根因)。
|
||
/// 单 pane window 的 pane 帧恒等于容器帧,其 ghostty 实测格子就是客户端应有的格子 ——
|
||
/// 用它发 refresh-client。帧尺寸只由容器决定、不受 tmux 布局影响,故没有反馈环。
|
||
func paneViewportChanged(pane: TmuxPaneID, cols: Int, rows: Int, cellW: Int, cellH: Int) {
|
||
setCellPixels(w: cellW, h: cellH)
|
||
guard kickoffDone, cols > 0, rows > 0 else { return }
|
||
// 多 pane window 的 pane 帧只是容器的比例切片,格子代表不了客户端整体 → 只刷 cell 缓存。
|
||
guard let w = paneToWindow[pane], windowByID[w]?.panes.count == 1 else { return }
|
||
guard cols != lastSentCols || rows != lastSentRows else { return }
|
||
lastSentCols = cols; lastSentRows = rows
|
||
clientCols = cols; clientRows = rows
|
||
clientGrid = (cols, rows)
|
||
resizeDebounce?.cancel()
|
||
resizeDebounce = Task { [weak self] in
|
||
try? await Task.sleep(nanoseconds: 120_000_000) // 合并侧边栏动画期间的连续变化
|
||
guard !Task.isCancelled, let self else { return }
|
||
NSLog("TMUXDBG pane-measured refresh-client -C \(cols)x\(rows)")
|
||
self.sendCommand("refresh-client -C \(cols)x\(rows)")
|
||
}
|
||
}
|
||
|
||
/// 容器尺寸变化(iPadOS 拖拽窗口/旋转/分屏):换算总格子 → 去重 + debounce → refresh-client -C。
|
||
/// 尺寸只来自"容器点尺寸 × displayScale ÷ cell 像素",**仅作兜底**:有单 pane window 在场时
|
||
/// 让位给 paneViewportChanged 的实测格子(像素除法会偏大 1 行/列,见其注释)。
|
||
func containerResized(widthPt: CGFloat, heightPt: CGFloat, scale: CGFloat) {
|
||
guard cellWpx > 0, cellHpx > 0, widthPt > 0, heightPt > 0, scale > 0 else { return }
|
||
containerKnown = true
|
||
if !kickoffDone {
|
||
// kickoff 初值不用像素换算覆盖 —— setClientSize 已带 raw surface 的真实格子
|
||
// (attach 前 RawTerminalView 与 TmuxStage 同槽同尺寸,ghostty 实测值可直接沿用)。
|
||
NSLog("TMUXDBG containerKnown pt=\(Int(widthPt))x\(Int(heightPt)) -> kickoff with real grid \(clientCols)x\(clientRows)")
|
||
kickoffIfReady()
|
||
return
|
||
}
|
||
if windows.contains(where: { $0.panes.count == 1 }) { return } // 实测路径接管
|
||
let cols = max(1, Int(widthPt * scale / CGFloat(cellWpx)))
|
||
let rows = max(1, Int(heightPt * scale / CGFloat(cellHpx)))
|
||
guard cols != lastSentCols || rows != lastSentRows else { return }
|
||
lastSentCols = cols; lastSentRows = rows
|
||
clientCols = cols; clientRows = rows
|
||
clientGrid = (cols, rows)
|
||
NSLog("TMUXDBG containerResized pt=\(Int(widthPt))x\(Int(heightPt)) scale=\(scale) cell=\(cellWpx)x\(cellHpx) -> \(cols)x\(rows)")
|
||
resizeDebounce?.cancel()
|
||
resizeDebounce = Task { [weak self] in
|
||
try? await Task.sleep(nanoseconds: 200_000_000) // 合并拖拽期间的连续变化
|
||
guard !Task.isCancelled, let self, self.kickoffDone else { return }
|
||
NSLog("TMUXDBG refresh-client -C \(cols)x\(rows)")
|
||
self.sendCommand("refresh-client -C \(cols)x\(rows)") // tmux 重排 → 回 %layout-change → 更新 frame
|
||
}
|
||
}
|
||
|
||
/// 本客户端所在的 tmux 会话名(`display-message -p "#{session_name}"` 的权威答案)。
|
||
/// 拿不到时为空串 —— UI 只显示「tmux」而不编造名字。
|
||
@Published private(set) var currentSessionName = ""
|
||
|
||
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(保内容跨 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 单帧更新,无闪烁
|
||
let rr = fullTree.root.rect
|
||
NSLog("TMUXDBG layout win=@\(w.raw) root=\(rr.width)x\(rr.height) panes=\(fullTree.paneIDs.count)")
|
||
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,
|
||
onInput: { [weak self] data in self?.sendKeys(pane: p, data: data) },
|
||
onViewport: { [weak self] c, r, cw, ch in
|
||
Task { @MainActor in self?.paneViewportChanged(pane: p, cols: c, rows: r, cellW: cw, cellH: ch) }
|
||
}
|
||
)
|
||
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
|
||
// kickoff 延后到容器几何已知(见 kickoffIfReady):避免用 raw 全屏格子 attach 导致
|
||
// 快照行数 > pane grid 行数、铺快照溢出上滚。几何先到则此处直接触发。
|
||
kickoffIfReady()
|
||
return
|
||
}
|
||
guard !pending.isEmpty else { return }
|
||
let kind = pending.removeFirst()
|
||
switch kind {
|
||
case .ignore:
|
||
break
|
||
case .listWindows:
|
||
for line in r.lines {
|
||
let parts = line.components(separatedBy: Self.fieldSep)
|
||
guard parts.count >= 5, let w = TmuxWindowID.parse(parts[0]) else {
|
||
NSLog("TMUXDBG winparse FAILED count=\(parts.count) line=[\(line.prefix(160))]")
|
||
continue
|
||
}
|
||
let win = ensureWindow(w)
|
||
win.title = String(parts[1])
|
||
if parts.count >= 6 { win.cwd = String(parts[5]) }
|
||
if parts.count >= 7, let idx = Int(parts[6]) { win.index = idx }
|
||
if parts[2] == "1" { activeWindowID = w }
|
||
applyLayout(window: w, full: String(parts[3]), visible: String(parts[4]))
|
||
}
|
||
case .currentSession:
|
||
if let name = r.lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
|
||
currentSessionName = name
|
||
NSLog("TMUXDBG currentSession=\(name)")
|
||
}
|
||
case .capturePane(let p):
|
||
// 剔除尾部空行:capture-pane 常返回补满 pane 高度的尾随空行,若行数 > pane grid 行数,
|
||
// 铺快照(ESC[H + N-1 个 \r\n)会溢出上滚、把顶部内容顶出屏幕(提示符被拆/内容钉底)。
|
||
// ESC[H 铺法本身正确,前提是行数 ≤ grid 行数;trim 后冷启动近空屏恒成立。
|
||
var lines = r.lines
|
||
while lines.last?.isEmpty == true { lines.removeLast() }
|
||
let snapshot = 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 }
|
||
// index 先用 id 占位,list-windows 应答到达后校正为真实 window_index。
|
||
let win = TmuxWindow(id: w, index: w.raw, 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)")
|
||
}
|
||
|
||
// MARK: - 关闭(UI-4)
|
||
|
||
// 注:window 的增删(new-window / kill-window)与 pane resize 已按「第一版减法」移除
|
||
// —— window/pane 管理完全交给 tmux 自己(prefix+c / prefix+& / prefix+方向键),app 只做
|
||
// 切换与被动渲染;app 内主动分屏(split-window / join-pane)更早已随「放弃移动端分屏」移除。
|
||
// 多 pane 的**被动渲染**保留 —— 别的客户端建好的分屏 attach 进来仍按 layout 正确显示。
|
||
|
||
/// 断开 tmux 客户端:**服务端会话继续跑**,下次原样接回。红点确认里的推荐动作。
|
||
/// tmux 会回 `%exit` → 既有 `onExit` 链路收尾。
|
||
func detach() { sendCommand("detach-client") }
|
||
|
||
/// 结束当前 tmux 会话(服务端进程一并结束)。红点确认里的危险次要动作,需再确认一次。
|
||
func killSession() { sendCommand("kill-session") }
|
||
|
||
/// 某 pane 的 surface 就绪 → 放行其缓冲输出。
|
||
func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() }
|
||
|
||
/// 切换主题:把新终端配色推给所有已存在的 pane surface。
|
||
func applyTerminalTheme(_ t: TerminalTheme) {
|
||
for s in surfaceByPane.values { _ = s.state.controller.setTheme(t) }
|
||
}
|
||
}
|
||
|
||
/// 传输字节分流器:检测 `tmux -CC` 进入 DCS 前 → 原始终端;进入后 → tmux 网关。
|
||
/// 在传输后台线程调用;原始字节走线程安全的 rawGate,网关字节经闭包 hop 到主线程。
|
||
final class TmuxRouter: @unchecked Sendable {
|
||
private let lock = NSLock()
|
||
private let rawGate: any RawOutputSink
|
||
private let enterGateway: @Sendable ([UInt8]) -> Void
|
||
private let gatewayBytes: @Sendable ([UInt8]) -> Void
|
||
private var carry: [UInt8] = []
|
||
private var inGateway = false
|
||
|
||
init(rawGate: any RawOutputSink,
|
||
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()
|
||
}
|
||
}
|