按 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>
194 lines
8.4 KiB
Swift
194 lines
8.4 KiB
Swift
import Combine
|
||
import Foundation
|
||
import GhosttyTerminal
|
||
|
||
/// 多会话持有者:一台主机可以有多条会话,会话在后台继续跑(最小化 / 回首页都不断)。
|
||
///
|
||
/// 设计要点:
|
||
/// - **会话不因导航而销毁**:只有显式关闭(红点 / ⌘W)才 teardown。
|
||
/// - 会话 surface 由 `SessionCanvas` 常驻挂载,故 `%output` 持续消费、内容不丢。
|
||
/// - 生命周期广播(前后台)由本类扇出给每条会话——`TerminalSession` 自己不监听 scenePhase。
|
||
@MainActor
|
||
final class SessionManager: ObservableObject {
|
||
@Published private(set) var sessions: [TerminalSession] = []
|
||
/// 当前焦点会话(进终端时显示哪条)。
|
||
@Published var focusedID: UUID?
|
||
|
||
/// 「已就绪、可以进终端」的会话(视图层消费后清空)。
|
||
///
|
||
/// 为什么要这个信号:点主机后**先留在首页**探测 tmux、弹会话选择器,选完(或探测到没有
|
||
/// tmux)才进终端页 —— 连接过程属于"准备连接",不该先把用户丢进空终端再弹框。
|
||
@Published var enterRequest: UUID?
|
||
|
||
/// 点了主机但还没进终端的会话 id(等探测/选择结果)。
|
||
private var awaitingEnter: Set<UUID> = []
|
||
/// 转发每条会话的变化(嵌套 ObservableObject 不冒泡,见 CLAUDE.md)。
|
||
private var sessionObservers: [UUID: AnyCancellable] = [:]
|
||
|
||
private weak var credentialStore: (any CredentialStore)?
|
||
private weak var tailscaleStore: TailscaleStore?
|
||
|
||
var focused: TerminalSession? {
|
||
guard let id = focusedID else { return nil }
|
||
return sessions.first { $0.id == id }
|
||
}
|
||
|
||
func session(_ id: UUID) -> TerminalSession? {
|
||
sessions.first { $0.id == id }
|
||
}
|
||
|
||
/// 正在建立中(还没进终端)的会话 —— 首页据此显示连接进度。
|
||
var connectingSessions: [TerminalSession] {
|
||
sessions.filter { awaitingEnter.contains($0.id) }
|
||
}
|
||
|
||
/// 等用户选 tmux 会话的那条(选择器数据源)。
|
||
var sessionAwaitingChoice: TerminalSession? {
|
||
sessions.first { $0.pendingSessionChoice != nil }
|
||
}
|
||
|
||
/// 首页「活动会话」区的排序:最小化的置顶(设计要求,否则用户不知道东西去哪了),其余按最近活跃。
|
||
var activeSessions: [TerminalSession] {
|
||
sessions.sorted {
|
||
if $0.isMinimized != $1.isMinimized { return $0.isMinimized }
|
||
return $0.lastActiveAt > $1.lastActiveAt
|
||
}
|
||
}
|
||
|
||
/// ⌘1…9 的会话顺序(与首页卡片顺序一致)。
|
||
func session(atShortcutIndex i: Int) -> TerminalSession? {
|
||
let list = activeSessions
|
||
return i >= 0 && i < list.count ? list[i] : nil
|
||
}
|
||
|
||
/// 注入解析凭据/出口所需的 Store(新建会话时转交)。
|
||
func bind(credentials: any CredentialStore, tailscale: TailscaleStore) {
|
||
credentialStore = credentials
|
||
tailscaleStore = tailscale
|
||
for s in sessions { s.bind(credentials: credentials, tailscale: tailscale) }
|
||
}
|
||
|
||
// MARK: - 开 / 关
|
||
|
||
/// 打开一台主机的会话:**已有该主机的活会话则复用**(对标设计「回到刚才那个会话」),否则新建并连接。
|
||
/// 打开一台主机:**已有该主机的活会话则直接复用并进终端**;否则新建会话开始连接,
|
||
/// 但**先不进终端** —— 等 tmux 探测/用户选择完成后由 `enterRequest` 通知视图层。
|
||
@discardableResult
|
||
func open(host: SavedHost) -> TerminalSession {
|
||
if let existing = sessions.first(where: { $0.host?.id == host.id }) {
|
||
focus(existing)
|
||
enterRequest = existing.id // 复用的会话没有连接过程,立刻进
|
||
return existing
|
||
}
|
||
let s = makeSession()
|
||
sessions.append(s)
|
||
focus(s)
|
||
awaitingEnter.insert(s.id)
|
||
s.connect(to: host)
|
||
return s
|
||
}
|
||
|
||
/// 会话状态变化后判断是否该进终端了。
|
||
///
|
||
/// 就绪 = 不需要用户选择,且(已建立 / 探测确认无 tmux / 用户选了原生 / 连接失败)。
|
||
/// 失败也进终端:那里有「重试 / 返回首页」,比留在首页只显示一行错误更有出路。
|
||
private func evaluateEnter(_ s: TerminalSession) {
|
||
guard awaitingEnter.contains(s.id) else { return }
|
||
if s.pendingSessionChoice != nil { return } // 等用户选会话
|
||
if s.isProbingTmux { return } // 还在探测 → 留在首页,别先把人送进终端
|
||
let failed = s.phaseText.hasPrefix("失败")
|
||
guard s.isLive || s.tmuxUnavailable || s.tmuxSkipped || failed else { return }
|
||
awaitingEnter.remove(s.id)
|
||
enterRequest = s.id
|
||
}
|
||
|
||
/// 焦点切换:记录活跃时刻并解除最小化标记。
|
||
func focus(_ s: TerminalSession) {
|
||
focusedID = s.id
|
||
s.isMinimized = false
|
||
s.lastActiveAt = Date()
|
||
}
|
||
|
||
/// 最小化:**不确认**、不动传输,只标记 + 记时刻(回首页由调用方改路由)。
|
||
func minimize(_ id: UUID) {
|
||
guard let s = session(id) else { return }
|
||
s.isMinimized = true
|
||
s.lastActiveAt = Date()
|
||
}
|
||
|
||
/// 关闭并移除会话。tmux 的「仅断开 / 结束会话」分支在 UI-4 的确认对话框里决定,
|
||
/// 这里只负责最终 teardown + 摘除(会话对象随之释放,surface 一并回收)。
|
||
func close(_ id: UUID) {
|
||
guard let s = session(id) else { return }
|
||
s.close()
|
||
sessions.removeAll { $0.id == id }
|
||
sessionObservers[id] = nil
|
||
awaitingEnter.remove(id)
|
||
if focusedID == id { focusedID = activeSessions.first?.id }
|
||
}
|
||
|
||
// MARK: - 广播
|
||
|
||
// 假会话不参与:它没有传输层,广播会经 syncUI 把注入状态覆盖回 idle。
|
||
func enterBackground() { for s in sessions where !s.isSyntheticFixture { s.enterBackground() } }
|
||
func enterForeground() { for s in sessions where !s.isSyntheticFixture { s.enterForeground() } }
|
||
|
||
/// 主题切换:推给所有会话的 raw surface 与 tmux pane。
|
||
func applyTerminalTheme(_ t: TerminalTheme) {
|
||
for s in sessions { s.applyTerminalTheme(t) }
|
||
}
|
||
|
||
/// 任一会话的 host key 告警(多会话时取第一条,逐条处理)。
|
||
var pendingHostKeyMismatch: TerminalSession? {
|
||
sessions.first { $0.hostKeyMismatch != nil }
|
||
}
|
||
|
||
// MARK: - 无头验证
|
||
|
||
/// 启动参数自动连接(simctl launch --args -txHost …)。返回新建的会话供调用方切路由。
|
||
@discardableResult
|
||
func autoConnectIfConfigured() -> TerminalSession? {
|
||
let d = UserDefaults.standard
|
||
guard let host = d.string(forKey: "txHost"), !host.isEmpty else { return nil }
|
||
let s = makeSession()
|
||
sessions.append(s)
|
||
focus(s)
|
||
s.autoConnectIfConfigured()
|
||
return s
|
||
}
|
||
|
||
#if DEBUG
|
||
/// 无头 UI 验证:批量造假会话(`-txUIFixture 1`)。返回第一条供调用方进终端。
|
||
@discardableResult
|
||
func spawnFixtureSessions() -> TerminalSession? {
|
||
for (i, spec) in UIPreviewFixture.specs.enumerated() {
|
||
// 复用 UIPreviewFixture.hosts 里的同名主机(同一 id),主机卡才能亮「已有会话」。
|
||
guard let host = UIPreviewFixture.hosts.first(where: { $0.name == spec.name }) else { continue }
|
||
let s = makeSession()
|
||
s.injectFixture(host: host, lines: spec.lines, link: spec.link,
|
||
mosh: host.useMosh, tmux: spec.tmux,
|
||
tmuxSessions: spec.tmux == nil ? [] : UIPreviewFixture.tmuxSessions(current: spec.tmux!.name))
|
||
// 活跃时刻错开,让首页卡片排序稳定可对拍。
|
||
s.lastActiveAt = Date(timeIntervalSinceNow: Double(-i) * 60)
|
||
sessions.append(s)
|
||
}
|
||
focusedID = sessions.first?.id
|
||
return sessions.first
|
||
}
|
||
#endif
|
||
|
||
private func makeSession() -> TerminalSession {
|
||
let s = TerminalSession()
|
||
if let c = credentialStore, let t = tailscaleStore {
|
||
s.bind(credentials: c, tailscale: t)
|
||
}
|
||
// 转发会话变化:① 让订阅 manager 的视图(首页/选择器)跟着刷新;② 驱动 enter 判定。
|
||
sessionObservers[s.id] = s.objectWillChange.sink { [weak self, weak s] _ in
|
||
guard let self else { return }
|
||
self.objectWillChange.send()
|
||
if let s { Task { @MainActor in self.evaluateEnter(s) } }
|
||
}
|
||
return s
|
||
}
|
||
}
|