Files
terminalX/apps/TerminalX/iOS/SessionManager.swift
kid 4b5292107b feat: 业务逻辑对齐 + 首页/弹框七项减法 + 终端页四项体验修复
业务逻辑(用户定稿):
- 点主机总是新建会话(会话:主机=多: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>
2026-07-26 17:49:58 +08:00

197 lines
8.6 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 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
}
}
/// 19
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
}
}