业务逻辑(用户定稿): - 点主机总是新建会话(会话:主机=多:1);activate 选中断线会话自动重连 - 「直接输入」凭据保存时自动落 Keychain(去重复用),主机只存引用 - 主机编辑页可就地新建 Tailscale 连接;删无人引用的旧 HostsListView 首页/弹框减法: - 删侧边栏「连接新主机」、无 tmux 横幅与安装引导、页头「新建连接」 - 搜索移到「全部主机」行;添加主机虚线卡与主机卡等高(骨架复刻) - 会话卡加 ✕ + 复用终端页同款二次确认 - 会话选择器 sheet → 项目风格居中弹框(去手柄/Header/最近) 终端页修复: - 侧边栏会话列表改创建顺序,切换不再重排抖动 - 焦点高亮上移到 pane 容器边框(四边完整,去负 padding) - 软键盘双重让位修复:画布忽略键盘 + TXKeyboardObserver 显式让位一次 - 自动聚焦:focusTick 脉冲强制翻转 FocusState + surface 就绪补聚焦; vendor 补 iPadOS 触摸板 indirectPointer 的 becomeFirstResponder Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
197 lines
8.6 KiB
Swift
197 lines
8.6 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: - 开 / 关
|
||
|
||
/// 打开一台主机:**总是新建会话**(用户定稿:会话与主机不是一对一,再点同一主机 =
|
||
/// 再开一条并行会话;回到已有会话走侧边栏行 / 首页会话卡,即 `activate`)。
|
||
/// 新会话**先不进终端** —— 等 tmux 探测/用户选择完成后由 `enterRequest` 通知视图层。
|
||
@discardableResult
|
||
func open(host: SavedHost) -> TerminalSession {
|
||
let s = makeSession()
|
||
sessions.append(s)
|
||
focus(s)
|
||
awaitingEnter.insert(s.id)
|
||
s.connect(to: host)
|
||
return s
|
||
}
|
||
|
||
/// 选中**既有**会话(侧边栏行 / 首页会话卡 / ⌘n / 无头 URL):聚焦,且**已断开的自动重连**
|
||
/// (用户定稿:点选未连接的会话 = 重新连接并重新判定 tmux/原生,不必再进终端找「重试」按钮)。
|
||
/// 连接中 / 重连中 / 已连接的不打扰 —— 只有明确死掉(失败 / 已关闭)才触发。
|
||
func activate(_ s: TerminalSession) {
|
||
focus(s)
|
||
guard !s.isSyntheticFixture else { return }
|
||
if s.phaseText == "已关闭" || s.phaseText.hasPrefix("失败") { s.retry() }
|
||
}
|
||
|
||
/// 会话状态变化后判断是否该进终端了。
|
||
///
|
||
/// 就绪 = 不需要用户选择,且(已建立 / 探测确认无 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)
|
||
// 活跃时刻错开,让首页卡片排序稳定可对拍。
|
||
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
|
||
}
|
||
}
|