import Foundation import GhosttyTerminal import TXCore /// 一个 tmux window(MVP:显示其活动 pane)。每 window 独立终端会话 + surface。 @MainActor final class TmuxWindow: ObservableObject, Identifiable { let id: TmuxWindowID @Published var title: String let state: TerminalViewState let session: InMemoryTerminalSession let gate: OutputGate var activePane: TmuxPaneID? init(id: TmuxWindowID, title: String, onInput: @escaping @Sendable (Data) -> Void) { self.id = id self.title = title let session = InMemoryTerminalSession( write: { data in onInput(data) }, // 用户输入 → send-keys(由 controller 处理) resize: { _ in } ) let state = TerminalViewState() state.configuration = TerminalSurfaceOptions(backend: .inMemory(session)) self.session = session self.state = state self.gate = OutputGate(session: session) } } /// tmux control-mode 网关:消费 `%`-协议流,把 tmux windows 映射为原生 tab。 /// MVP:每 window 显示活动 pane;输入经 `send-keys -H`;切 tab 经 `select-window`。 /// 已简化:多 pane 分屏只显示活动 pane;历史依赖 attach 重绘;命令不做严格应答匹配。 @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 paneToWindow: [TmuxPaneID: TmuxWindowID] = [:] private let sendRaw: @Sendable (Data) -> Void var onExit: (() -> Void)? // control-mode 命令应答 FIFO 匹配(attach 场景需主动 list-windows 枚举)。 private var attachAcked = false private enum PendingKind { case ignore, listWindows, listPanes(TmuxWindowID), capturePane(TmuxWindowID) } private var pending: [PendingKind] = [] init(sendRaw: @escaping @Sendable (Data) -> Void) { self.sendRaw = sendRaw } 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) 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 .windowPaneChanged(let w, let pane): paneToWindow[pane] = w windowByID[w]?.activePane = pane case .output(let pane, let data): if let wid = paneToWindow[pane] { windowByID[wid]?.gate.deliver(data) } case .extendedOutput(let pane, _, let data): if let wid = paneToWindow[pane] { windowByID[wid]?.gate.deliver(data) } case .sessionWindowChanged(_, let w): if windowByID[w] != nil { activeWindowID = w } case .commandResponse(let response): handleCommandResponse(response) case .exit: onExit?() default: break } } /// control-mode 命令应答。第一个应答是 attach 的隐式块(无对应命令)→ 触发 list-windows 枚举; /// 之后按 FIFO 匹配我方发出的命令。 private func handleCommandResponse(_ r: TmuxCommandResponse) { if !attachAcked { attachAcked = true pending.append(.listWindows) sendCommand("list-windows -F \"#{window_id} #{window_name}\"") return } guard !pending.isEmpty else { return } switch pending.removeFirst() { case .ignore: 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 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}\"") } 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 } } // 抓当前屏幕内容作初始渲染(初始 %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)) } } } @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) } 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) { 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)) } /// 用户在某 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)") } } 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)") } /// 某 window 的 surface 就绪 → 放行其缓冲输出。 func markWindowReady(_ w: TmuxWindowID) { windowByID[w]?.gate.markReady() } } /// 传输字节分流器:检测 `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() } }