feat: UI 商业化改造 + 连接前 tmux 会话选择(真机验证)
按 Claude Design 定稿(归档在 docs/design/)重做 UI,并把 tmux 从 「从不自动进」修成「连接前探测 → 让用户选会话」。 设计令牌与导航地基 - Theme 拆三层:TXAccent(恒定 blurple) / TXChrome(中性阶×4 表) / TXFlavor(Catppuccin 终端) - vendor JetBrains Mono 四权重(附 OFL 许可) - AppRouter + SessionManager:多会话并存,路由与会话生命周期解耦 - SessionCanvas 常驻挂载会话 surface(摘除即丢内容,也是缩回动画的前提) 按设计稿落地的屏 - 沉浸轨道页:56pt 轨道 + 浮起标题/状态胶囊 + 侧边栏三态(遮罩不 resize、Pin 各一次) - 首页:活动会话卡(readViewportText 文本镜像)+ 主机网格 + 筛选 chips - 关闭二次确认(tmux 仅断开 / 原生窗口两套文案)、分屏菜单、pane 拖拽条 - 空状态:首次运行 / 无会话 / 搜索无命中 / 连接中·失败·已关闭 tmux 真实链路(真机查出并修掉 4 个 bug) - 全代码库从来没人发起 attach → 连接前探测 + 会话选择器(接回 / 新建 / 原生终端) - format 分隔符 tab 经 PTY 变成下划线 → 改用 |:| - controller 变化不冒泡到 session → Combine 转发(否则数据解析对了 UI 不刷新) - 当前会话名不能靠 session_attached 反推 → 改用 display-message 传输层:SSH connect 加超时(原来阻塞到系统 TCP 超时 75s+) 无头验证设施:假会话 fixture · terminalx://ui/* 驱动 · 横屏截图脚本 · 对拍走查法 TXCore 45 tests 绿(新增 5 个会话探测单测) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,11 @@ final class TmuxPaneSurface: ObservableObject, Identifiable {
|
||||
@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?
|
||||
@@ -45,7 +49,19 @@ final class TmuxWindow: ObservableObject, Identifiable {
|
||||
var fullLayout: TmuxLayout?
|
||||
@Published var activePane: TmuxPaneID?
|
||||
|
||||
init(id: TmuxWindowID, title: String) { self.id = id; self.title = title }
|
||||
init(id: TmuxWindowID, index: Int, title: String) {
|
||||
self.id = id; self.index = index; self.title = title
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个 tmux 会话的概要(`list-sessions` 结果)。分屏菜单列「分到哪里」用。
|
||||
struct TmuxSessionInfo: Identifiable, Equatable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let windows: Int
|
||||
/// 是否已被某客户端 attach(当前会话即 attached 的那个)。
|
||||
let isAttached: Bool
|
||||
let lastActivity: Date?
|
||||
}
|
||||
|
||||
/// tmux control-mode 网关:消费 `%`-协议流,把 window 映射为原生 tab、window 内多 pane 映射为分屏。
|
||||
@@ -55,6 +71,8 @@ final class TmuxWindow: ObservableObject, Identifiable {
|
||||
final class TmuxController: ObservableObject {
|
||||
@Published private(set) var windows: [TmuxWindow] = []
|
||||
@Published var activeWindowID: TmuxWindowID?
|
||||
/// 本 tmux server 上的会话(`listSessions()` 填充,分屏菜单用)。
|
||||
@Published private(set) var sessions: [TmuxSessionInfo] = []
|
||||
|
||||
private let parser = TmuxControlParser()
|
||||
private var windowByID: [TmuxWindowID: TmuxWindow] = [:]
|
||||
@@ -70,7 +88,18 @@ final class TmuxController: ObservableObject {
|
||||
|
||||
// control-mode 命令应答 FIFO 匹配。
|
||||
private var attachAcked = false
|
||||
private enum PendingKind { case ignore, listWindows, capturePane(TmuxPaneID) }
|
||||
private enum PendingKind: CustomStringConvertible {
|
||||
case ignore, listWindows, listSessions, currentSession, capturePane(TmuxPaneID)
|
||||
var description: String {
|
||||
switch self {
|
||||
case .ignore: "ignore"
|
||||
case .listWindows: "listWindows"
|
||||
case .listSessions: "listSessions"
|
||||
case .currentSession: "currentSession"
|
||||
case .capturePane(let p): "capturePane(%\(p.raw))"
|
||||
}
|
||||
}
|
||||
}
|
||||
private var pending: [PendingKind] = []
|
||||
|
||||
// attach kickoff(refresh-client + list-windows + 各 pane capture)必须等"容器真实几何已知"再发:
|
||||
@@ -80,6 +109,11 @@ final class TmuxController: ObservableObject {
|
||||
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
|
||||
}
|
||||
@@ -91,8 +125,14 @@ final class TmuxController: ObservableObject {
|
||||
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}\"")
|
||||
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)
|
||||
listSessions() // 分屏菜单要列全部会话
|
||||
}
|
||||
|
||||
/// 由 model 在 attach 前告知全屏格子(tmux 据此 refresh-client -C 布局)。
|
||||
@@ -125,6 +165,10 @@ final class TmuxController: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// 本客户端所在的 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]
|
||||
@@ -231,18 +275,45 @@ final class TmuxController: ObservableObject {
|
||||
return
|
||||
}
|
||||
guard !pending.isEmpty else { return }
|
||||
switch pending.removeFirst() {
|
||||
let kind = pending.removeFirst()
|
||||
switch kind {
|
||||
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 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 .listSessions:
|
||||
sessions = r.lines.compactMap { line in
|
||||
let f = line.components(separatedBy: Self.fieldSep)
|
||||
guard f.count >= 3 else { return nil }
|
||||
let activity = f.count >= 4 ? Double(f[3]).map { Date(timeIntervalSince1970: $0) } : nil
|
||||
return TmuxSessionInfo(name: f[0],
|
||||
windows: Int(f[1]) ?? 1,
|
||||
isAttached: f[2] == "1",
|
||||
lastActivity: activity)
|
||||
}
|
||||
NSLog("TMUXDBG sessions=\(sessions.map { "\($0.name)/w\($0.windows)/a\($0.isAttached)" })")
|
||||
case .currentSession:
|
||||
if let name = r.lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
|
||||
currentSessionName = name
|
||||
NSLog("TMUXDBG currentSession=\(name)")
|
||||
}
|
||||
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)会溢出上滚、把顶部内容顶出屏幕(提示符被拆/内容钉底)。
|
||||
@@ -263,7 +334,8 @@ 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)")
|
||||
// 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 }
|
||||
@@ -307,6 +379,47 @@ final class TmuxController: ObservableObject {
|
||||
func newWindow() { sendCommand("new-window") }
|
||||
func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") }
|
||||
|
||||
// MARK: - 关闭 / 分屏 / resize(UI-4)
|
||||
|
||||
/// 断开 tmux 客户端:**服务端会话继续跑**,下次原样接回。红点确认里的推荐动作。
|
||||
/// tmux 会回 `%exit` → 既有 `onExit` 链路收尾。
|
||||
func detach() { sendCommand("detach-client") }
|
||||
|
||||
/// 结束当前 tmux 会话(服务端进程一并结束)。红点确认里的危险次要动作,需再确认一次。
|
||||
func killSession() { sendCommand("kill-session") }
|
||||
|
||||
/// 分屏。tmux 语义:`-h` 左右并排(竖直分隔线)、`-v` 上下堆叠。
|
||||
/// 只发命令即可 —— tmux 回 `%layout-change`,既有 reconcile 会为新 pane 建 surface
|
||||
/// 并走 capture-pane 快照链路(见 `applyLayout` / `ensurePane`)。
|
||||
func splitWindow(vertical: Bool, joinSession: String? = nil) {
|
||||
if let s = joinSession {
|
||||
// 把另一个会话的当前 window 拉进本 window 当 pane(tmux 原生支持,不嵌套 tmux)。
|
||||
sendCommand("join-pane \(vertical ? "-h" : "-v") -s \(s):")
|
||||
} else {
|
||||
sendCommand("split-window \(vertical ? "-h" : "-v")")
|
||||
}
|
||||
}
|
||||
|
||||
/// 新建 tmux 会话并把它的 window 拉进当前 window 分屏(detached 建,避免抢走客户端)。
|
||||
func splitIntoNewSession(vertical: Bool, name: String) {
|
||||
sendCommand("new-session -d -s \(name)")
|
||||
sendCommand("join-pane \(vertical ? "-h" : "-v") -s \(name):")
|
||||
}
|
||||
|
||||
/// 拖 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)") }
|
||||
}
|
||||
|
||||
/// 枚举本 tmux server 上的会话(分屏菜单要列 detached 会话)。结果进 `sessions`。
|
||||
func listSessions() {
|
||||
let sep = Self.fieldSep
|
||||
sendCommand("list-sessions -F \"#{session_name}\(sep)#{session_windows}\(sep)#{session_attached}\(sep)#{session_activity}\"")
|
||||
pending.append(.listSessions)
|
||||
}
|
||||
|
||||
/// 某 pane 的 surface 就绪 → 放行其缓冲输出。
|
||||
func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() }
|
||||
|
||||
@@ -320,13 +433,13 @@ final class TmuxController: ObservableObject {
|
||||
/// 在传输后台线程调用;原始字节走线程安全的 rawGate,网关字节经闭包 hop 到主线程。
|
||||
final class TmuxRouter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let rawGate: OutputGate
|
||||
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: OutputGate,
|
||||
init(rawGate: any RawOutputSink,
|
||||
enterGateway: @escaping @Sendable ([UInt8]) -> Void,
|
||||
gatewayBytes: @escaping @Sendable ([UInt8]) -> Void) {
|
||||
self.rawGate = rawGate
|
||||
|
||||
Reference in New Issue
Block a user