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, onMetrics: @escaping @Sendable (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 像素供换算,绝不反向驱动 tmux(防反馈环)。 resize: { vp in onMetrics(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 @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] = [] @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 private var cellWpx = 0, cellHpx = 0 // cell 设备像素(换算容器点尺寸→格子) private var lastSentCols = 0, lastSentRows = 0 private var resizeDebounce: Task? // control-mode 命令应答 FIFO 匹配。 private var attachAcked = false private enum PendingKind { case ignore, listWindows, capturePane(TmuxPaneID) } 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 init(sendRaw: @escaping @Sendable (Data) -> Void) { self.sendRaw = sendRaw } /// attach 序列启动器:仅当 attach 应答与容器几何都就绪时发一次(refresh-client 用容器换算尺寸)。 private func kickoffIfReady() { guard attachAcked, containerKnown, !kickoffDone else { return } kickoffDone = true NSLog("TMUXDBG kickoff refresh-client -C \(clientCols)x\(clientRows)") 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) } /// 由 model 在 attach 前告知全屏格子(tmux 据此 refresh-client -C 布局)。 func setClientSize(cols: Int, rows: Int) { clientCols = max(1, cols); clientRows = max(1, rows) } /// cell 设备像素(来自 raw 终端或 pane surface metrics)。 func setCellPixels(w: Int, h: Int) { if w > 0 { cellWpx = w }; if h > 0 { cellHpx = h } } /// pane surface metrics 回报(仅刷新 cell 缓存,绝不反向驱动 tmux)。 func noteCellPixels(w: Int, h: Int) { setCellPixels(w: w, h: h) } /// 容器尺寸变化(iPadOS 拖拽窗口/旋转/分屏):换算总格子 → 去重 + debounce → refresh-client -C。 /// 单向数据流:尺寸只来自"容器点尺寸 × displayScale ÷ cell 像素",绝不来自 surface resize 回调(防反馈环)。 func containerResized(widthPt: CGFloat, heightPt: CGFloat, scale: CGFloat) { guard cellWpx > 0, cellHpx > 0, widthPt > 0, heightPt > 0, scale > 0 else { 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 NSLog("TMUXDBG containerResized pt=\(Int(widthPt))x\(Int(heightPt)) scale=\(scale) cell=\(cellWpx)x\(cellHpx) -> \(cols)x\(rows)") // 首个真实几何:立即触发 attach kickoff(不 debounce),保证 attach 首帧起 client 格子==容器格子。 containerKnown = true if !kickoffDone { kickoffIfReady(); return } 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 } } 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) }, onMetrics: { [weak self] w, h in Task { @MainActor in self?.noteCellPixels(w: w, h: h) } } ) 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 } 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): // 剔除尾部空行: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 } 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() } /// 切换主题:把新终端配色推给所有已存在的 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: 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() } }