Files
terminalX/apps/TerminalX/iOS/TmuxController.swift
kid 5d1abdc7ac feat: 修复终端换行错乱根因 + 顶带重设计 + 移除移动端分屏
- 换行错乱根因:tmux 客户端格子改以 ghostty 实测为权威(像素除法
  偏大 1 行/列 → 远端按更宽排版、本地自动换行掉字符),单 pane 实测
  格子直发 refresh-client;kickoff 用 raw surface 真实格子;顶栏
  格子数 tmux 模式读 clientGrid(模拟器连本机 sshd 端到端验证)
- 顶带落地为真实 chrome 行:✕/⌄ 独立按钮组 + 弱化标题 +
  TmuxWindowTabGroup window tab 组,pane 不再被悬浮胶囊遮内容
- 放弃移动端分屏:删 SplitMenu/分屏入口,tmux window 切换上移顶部
  tab,侧边栏改列活动会话;底部动作条合并状态行
- Info.plist 补 NSLocalNetworkUsageDescription(LAN 直连必需,
  缺失时 socket 静默超时)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 11:11:20 +08:00

470 lines
23 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 %outputtmux 线
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 tabwindow 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 kickoffrefresh-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
/// TUIClaude 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 zoomgrid 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)")
}
func newWindow() { sendCommand("new-window") }
func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") }
// MARK: - / resizeUI-4
// app split-window / join-pane
// pane **** attach layout
/// tmux ****
/// tmux `%exit` `onExit`
func detach() { sendCommand("detach-client") }
/// tmux
func killSession() { sendCommand("kill-session") }
/// pane ****
/// `%layout-change` frame "线 b"
func resizePane(_ p: TmuxPaneID, cols: Int? = nil, rows: Int? = nil) {
if let cols, cols > 0 { sendCommand("resize-pane -t %\(p.raw) -x \(cols)") }
if let rows, rows > 0 { sendCommand("resize-pane -t %\(p.raw) -y \(rows)") }
}
/// 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()
}
}