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:
102
packages/TXCore/Sources/TXCore/Tmux/TmuxSessionProbe.swift
Normal file
102
packages/TXCore/Sources/TXCore/Tmux/TmuxSessionProbe.swift
Normal file
@@ -0,0 +1,102 @@
|
||||
import Foundation
|
||||
|
||||
/// 在**普通 shell**(还没进 control mode)里探测服务器的 tmux 能力与会话列表。
|
||||
///
|
||||
/// 为什么需要它:点主机连接时要先让用户挑「attach 哪个会话 / 新建 / 跳过用原生终端」,
|
||||
/// 而这个选择必须在进 control mode **之前**做出 —— 一旦发了 `tmux -CC new` 就已经建了会话。
|
||||
///
|
||||
/// **PTY 回显坑**:命令本身会被 PTY 回显。若标记字面量出现在命令里,回显行会被误当成结果起点。
|
||||
/// 故命令里把标记写成相邻字符串拼接(`"__TX""_SESSIONS_BEGIN"`):shell 输出完整标记,
|
||||
/// 回显的却是拼接前的样子,扫描器只认完整标记,天然不会命中回显。
|
||||
///
|
||||
/// **为什么要 `tmux -V`**:`tmux list-sessions` 在「没装 tmux」和「装了但没有会话」两种情况下
|
||||
/// 都只往 stderr 写东西(被 `2>/dev/null` 吞掉),标记之间同样是空的 —— 只靠会话行分不开这两态,
|
||||
/// 而它们的正确 UI 完全不同(装安装引导 vs 让用户新建会话)。版本行用来定这一刀。
|
||||
public struct TmuxSessionProbe: Sendable {
|
||||
public static let beginMark = "__TX_SESSIONS_BEGIN"
|
||||
public static let endMark = "__TX_SESSIONS_END"
|
||||
/// 会话行前缀(与版本行区分)。
|
||||
public static let sessionPrefix = "S"
|
||||
/// 字段分隔符:与 `TmuxController.fieldSep` 同源理由(tab 经 PTY 会被替换成下划线)。
|
||||
public static let fieldSep = "|:|"
|
||||
|
||||
/// 要发进 shell 的探测命令(自带换行)。用 `printf` 而非 `echo`:跨 shell(fish/bash/zsh)一致。
|
||||
///
|
||||
/// **末尾清屏**:命令本身会被 PTY 回显、结果也会打在屏幕上,用户不该看见这些内部细节。
|
||||
/// 探测发生在刚连上、用户还没输入任何东西时,清掉的只有 motd/shell 欢迎语,换来干净的首屏。
|
||||
/// 用裸 ANSI(`[H` 归位 + `[2J` 清屏 + `[3J` 清 scrollback)而非 `clear`:不依赖 terminfo。
|
||||
public static var command: String {
|
||||
let begin = #"printf '%s\n' "__TX""_SESSIONS_BEGIN""#
|
||||
let end = #"printf '%s\n' "__TX""_SESSIONS_END""#
|
||||
let version = "tmux -V 2>/dev/null"
|
||||
let list = "tmux list-sessions -F '\(sessionPrefix)\(fieldSep)#{session_name}\(fieldSep)#{session_windows}\(fieldSep)#{session_attached}\(fieldSep)#{session_activity}' 2>/dev/null"
|
||||
let clear = #"printf '\033[H\033[2J\033[3J'"#
|
||||
return "\(begin); \(version); \(list); \(end); \(clear)\n"
|
||||
}
|
||||
|
||||
/// 一条会话记录(探测阶段拿到的最小信息)。
|
||||
public struct Session: Equatable, Sendable {
|
||||
public let name: String
|
||||
public let windows: Int
|
||||
public let isAttached: Bool
|
||||
/// `#{session_activity}`(Unix 秒)→ 选择器副行的「N 分钟前」。
|
||||
public let lastActivity: Date?
|
||||
|
||||
public init(name: String, windows: Int, isAttached: Bool, lastActivity: Date? = nil) {
|
||||
self.name = name
|
||||
self.windows = windows
|
||||
self.isAttached = isAttached
|
||||
self.lastActivity = lastActivity
|
||||
}
|
||||
}
|
||||
|
||||
public enum Result: Equatable, Sendable {
|
||||
/// tmux 可用。`sessions` 可能为空 —— 装了但还没有任何会话,UI 只给「新建 / 跳过」。
|
||||
case available(version: String, sessions: [Session])
|
||||
/// 没有 tmux(`tmux -V` 没输出)→ 走原生终端 + 安装引导横幅。
|
||||
case unavailable
|
||||
}
|
||||
|
||||
private var buffer = ""
|
||||
private var inBlock = false
|
||||
private var version: String?
|
||||
private var sessions: [Session] = []
|
||||
private var done = false
|
||||
|
||||
public init() {}
|
||||
|
||||
/// 流式喂入终端输出。命中 END 标记时返回结果;之后恒返回 nil(一次性)。
|
||||
public mutating func feed(_ text: String) -> Result? {
|
||||
guard !done else { return nil }
|
||||
buffer += text
|
||||
// 按行切:PTY 行尾是 CRLF,而 Swift 视 "\r\n" 为单个 Character,故用 isNewline 判定。
|
||||
while let idx = buffer.firstIndex(where: { $0.isNewline }) {
|
||||
let raw = String(buffer[buffer.startIndex ..< idx])
|
||||
buffer.removeSubrange(buffer.startIndex ... idx)
|
||||
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !inBlock {
|
||||
if line == Self.beginMark { inBlock = true }
|
||||
continue
|
||||
}
|
||||
if line == Self.endMark {
|
||||
done = true
|
||||
guard let version else { return .unavailable }
|
||||
return .available(version: version, sessions: sessions)
|
||||
}
|
||||
if line.hasPrefix(Self.sessionPrefix + Self.fieldSep) {
|
||||
if let s = Self.parseSession(line) { sessions.append(s) }
|
||||
} else if line.hasPrefix("tmux ") {
|
||||
version = line // 例:tmux 3.7b
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// `S|:|name|:|windows|:|attached|:|activity` → Session
|
||||
static func parseSession(_ line: String) -> Session? {
|
||||
let f = line.components(separatedBy: fieldSep)
|
||||
guard f.count >= 4, f[0] == sessionPrefix, !f[1].isEmpty, let w = Int(f[2]) else { return nil }
|
||||
let activity = f.count >= 5 ? Double(f[4]).map { Date(timeIntervalSince1970: $0) } : nil
|
||||
return Session(name: f[1], windows: w, isAttached: f[3] == "1", lastActivity: activity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import TXCore
|
||||
|
||||
@Suite("tmux 会话探测(连接前选择用)")
|
||||
struct TmuxSessionProbeTests {
|
||||
/// 命令里的标记必须是**拼接写法** —— 否则 PTY 回显会被误当成结果起点。
|
||||
@Test("命令不含完整标记字面量,避免 PTY 回显误命中")
|
||||
func commandAvoidsEchoSelfMatch() {
|
||||
let cmd = TmuxSessionProbe.command
|
||||
#expect(!cmd.contains(TmuxSessionProbe.beginMark))
|
||||
#expect(!cmd.contains(TmuxSessionProbe.endMark))
|
||||
#expect(cmd.contains("tmux -V"))
|
||||
#expect(cmd.contains("list-sessions"))
|
||||
}
|
||||
|
||||
@Test("解析会话列表(CRLF 行尾 + 版本行)")
|
||||
func parsesSessions() {
|
||||
var p = TmuxSessionProbe()
|
||||
// 先喂一段回显噪声(含拼接前的命令文本),不应触发。
|
||||
#expect(p.feed("kid@Air ~> printf '%s\\n' \"__TX\"\"_SESSIONS_BEGIN\"\r\n") == nil)
|
||||
#expect(p.feed("\(TmuxSessionProbe.beginMark)\r\n") == nil)
|
||||
#expect(p.feed("tmux 3.7b\r\n") == nil)
|
||||
#expect(p.feed("S|:|main|:|5|:|0|:|1784988802\r\n") == nil)
|
||||
#expect(p.feed("S|:|demo|:|2|:|1|:|1784901778\r\n") == nil)
|
||||
let result = p.feed("\(TmuxSessionProbe.endMark)\r\n")
|
||||
guard case .available(let version, let sessions) = result else {
|
||||
Issue.record("期望 available,实际 \(String(describing: result))")
|
||||
return
|
||||
}
|
||||
#expect(version == "tmux 3.7b")
|
||||
#expect(sessions.count == 2)
|
||||
#expect(sessions[0] == TmuxSessionProbe.Session(
|
||||
name: "main", windows: 5, isAttached: false,
|
||||
lastActivity: Date(timeIntervalSince1970: 1784988802)))
|
||||
#expect(sessions[1].name == "demo")
|
||||
#expect(sessions[1].isAttached)
|
||||
// 一次性:命中后不再返回结果。
|
||||
#expect(p.feed("S|:|late|:|1|:|0|:|1\r\n") == nil)
|
||||
}
|
||||
|
||||
@Test("装了 tmux 但没有会话 → available 且列表为空(UI 只给新建/跳过)")
|
||||
func availableWithNoSessions() {
|
||||
var p = TmuxSessionProbe()
|
||||
_ = p.feed("\(TmuxSessionProbe.beginMark)\n")
|
||||
_ = p.feed("tmux 3.5a\n")
|
||||
let r = p.feed("\(TmuxSessionProbe.endMark)\n")
|
||||
#expect(r == .available(version: "tmux 3.5a", sessions: []))
|
||||
}
|
||||
|
||||
@Test("没装 tmux(无版本行)→ unavailable,直接走原生终端")
|
||||
func unavailableWithoutVersion() {
|
||||
var p = TmuxSessionProbe()
|
||||
_ = p.feed("\(TmuxSessionProbe.beginMark)\n")
|
||||
let r = p.feed("\(TmuxSessionProbe.endMark)\n")
|
||||
#expect(r == .unavailable)
|
||||
}
|
||||
|
||||
@Test("畸形会话行被跳过而非崩溃")
|
||||
func skipsMalformedLines() {
|
||||
var p = TmuxSessionProbe()
|
||||
_ = p.feed("\(TmuxSessionProbe.beginMark)\ntmux 3.7\n")
|
||||
_ = p.feed("S|:|\n") // 字段不足
|
||||
_ = p.feed("S|:||:|3|:|0\n") // 空名字
|
||||
_ = p.feed("S|:|ok|:|x|:|0\n") // windows 非数字
|
||||
let r = p.feed("\(TmuxSessionProbe.endMark)\n")
|
||||
#expect(r == .available(version: "tmux 3.7", sessions: []))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user