feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly) T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner) T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state), prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller SwiftTerm view bug via .id(controller.id) T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp) T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) + NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback) CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports, permission prompt reached, privacy shade correctly covering during system alert) Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
This commit is contained in:
@@ -2,6 +2,7 @@ import Foundation
|
||||
import HostRegistry
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-15 · Navigation + lifecycle owner: Pairing → SessionList → Terminal
|
||||
/// with the production dependency graph (plan §7 T-iOS-15 step 1).
|
||||
@@ -25,16 +26,25 @@ final class AppCoordinator {
|
||||
/// Add-host pairing VM (sheet from the list header).
|
||||
private(set) var addHostPairingViewModel: PairingViewModel?
|
||||
var isAddHostPresented = false
|
||||
/// T-iOS-26 · Projects sheet(入口在 RootView 的 toolbar —— 不碰
|
||||
/// `SessionListScreen`,该文件 W7 内归 T-iOS-23)。每次呈现新建 VM,
|
||||
/// prefs 每次进入都重新拉取。
|
||||
private(set) var projectsViewModel: ProjectsViewModel?
|
||||
var isProjectsPresented = false
|
||||
|
||||
let sessionList: SessionListViewModel
|
||||
@ObservationIgnored let environment: AppEnvironment
|
||||
/// T-iOS-22 · Deep-link handler; all routing/wiring logic lives in
|
||||
/// DeepLinkRouter.swift (incl. the `makeDeepLinkHandler` extension).
|
||||
@ObservationIgnored private(set) lazy var deepLink: DeepLinkHandler = makeDeepLinkHandler()
|
||||
|
||||
init(environment: AppEnvironment) {
|
||||
self.environment = environment
|
||||
sessionList = SessionListViewModel(
|
||||
hostStore: environment.hostStore,
|
||||
http: environment.http,
|
||||
clock: ContinuousClock()
|
||||
clock: ContinuousClock(),
|
||||
unreadStore: environment.unreadStore
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,6 +64,7 @@ final class AppCoordinator {
|
||||
if route == .pairing {
|
||||
rootPairingViewModel = makePairingViewModel()
|
||||
}
|
||||
await deepLink.markReady() // flush a cold-launch deep link (T-iOS-22)
|
||||
}
|
||||
|
||||
/// First-run pairing done → move to the list (the paired host is already
|
||||
@@ -83,30 +94,110 @@ final class AppCoordinator {
|
||||
Task { await sessionList.reloadHosts() }
|
||||
}
|
||||
|
||||
// MARK: - Projects (T-iOS-26)
|
||||
|
||||
/// Toolbar 入口(RootView):以当前活跃主机呈现 Projects sheet。
|
||||
func presentProjects() {
|
||||
guard let host = sessionList.activeHost else { return }
|
||||
projectsViewModel = ProjectsViewModel(host: host, http: environment.http)
|
||||
isProjectsPresented = true
|
||||
}
|
||||
|
||||
/// Sheet 消失(打开会话 OR 手动关闭):丢弃 VM。
|
||||
func projectsDismissed() {
|
||||
projectsViewModel = nil
|
||||
}
|
||||
|
||||
/// "在此仓库开新会话":关 sheet → fresh spawn(`attach(null, cwd)`)+
|
||||
/// attach 后注入 `claude\r`(帧序由 engine 的 attach-first 队列保证)。
|
||||
func openProject(_ request: ProjectOpenRequest) {
|
||||
guard terminalController == nil else { return } // one foreground session
|
||||
isProjectsPresented = false
|
||||
projectsViewModel = nil
|
||||
startTerminal(
|
||||
host: request.host, sessionId: nil,
|
||||
spawnCwd: request.cwd, bootstrapInput: request.bootstrapInput
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Terminal open/close
|
||||
|
||||
/// `SessionListScreen.onOpen` (one navigation signal per tap) and the
|
||||
/// "继续上次" banner both land here. `sessionId == nil` = new session.
|
||||
func open(_ request: SessionListViewModel.OpenRequest) {
|
||||
guard terminalController == nil else { return } // one foreground session
|
||||
startTerminal(host: request.host, sessionId: request.sessionId)
|
||||
}
|
||||
|
||||
/// 唯一的 controller 构造点(普通打开与 T-iOS-26 项目内 spawn 共用)。
|
||||
private func startTerminal(
|
||||
host: HostRegistry.Host,
|
||||
sessionId: UUID?,
|
||||
spawnCwd: String? = nil,
|
||||
bootstrapInput: String? = nil
|
||||
) {
|
||||
let controller = TerminalSessionController(
|
||||
host: request.host,
|
||||
sessionId: request.sessionId,
|
||||
host: host,
|
||||
sessionId: sessionId,
|
||||
environment: environment,
|
||||
onPendingChanged: { [weak self] sessionId, pending in
|
||||
self?.sessionList.setPendingApproval(sessionId: sessionId, pending: pending)
|
||||
}
|
||||
},
|
||||
onTitleChanged: { [weak self] sessionId, title in
|
||||
// T-iOS-23 · OSC title → list row (already sanitized in the
|
||||
// VM; the list VM sanitizes once more at its own boundary).
|
||||
self?.sessionList.setSessionTitle(sessionId: sessionId, title: title)
|
||||
},
|
||||
spawnCwd: spawnCwd,
|
||||
bootstrapInput: bootstrapInput
|
||||
)
|
||||
terminalController = controller
|
||||
controller.start()
|
||||
}
|
||||
|
||||
/// Back navigation popped the terminal: explicit detach.
|
||||
/// Back navigation popped the terminal: explicit detach. Also the first
|
||||
/// half of every session SWITCH (single live WS invariant, plan §1):
|
||||
/// list back-nav and `openDeepLinkedSession` both close here before the
|
||||
/// next `open` — one engine at a time, always close→open with replay.
|
||||
func closeTerminal() {
|
||||
// T-iOS-23 · leaving = seen: stamp the unread watermark for the
|
||||
// adopted session so output watched in the terminal never relights
|
||||
// the list dot.
|
||||
if let sessionId = terminalController?.terminalViewModel.sessionId {
|
||||
sessionList.markSeen(sessionId: sessionId)
|
||||
}
|
||||
terminalController?.teardown()
|
||||
terminalController = nil
|
||||
}
|
||||
|
||||
// MARK: - "在当前目录开新会话" (T-iOS-29)
|
||||
|
||||
/// 当前终端会话的 cwd。解析次序:/live-sessions 行数据(server-adopted
|
||||
/// id 匹配行,列表 VM 的最近快照)→ controller 的 `spawnCwd`(T-iOS-26
|
||||
/// 项目 fresh-spawn 尚未进列表轮询)→ nil(未知)。服务器数据不可信:
|
||||
/// 非绝对路径按未知处理(ProjectsViewModel 同款纪律;engine 侧还会再验)。
|
||||
var currentTerminalCwd: String? {
|
||||
guard let controller = terminalController else { return nil }
|
||||
let fromRows = controller.terminalViewModel.sessionId.flatMap { id in
|
||||
sessionList.rows.first(where: { $0.id == id })?.info.cwd
|
||||
}
|
||||
let candidate = fromRows ?? controller.spawnCwd
|
||||
return candidate.flatMap { Validation.isAbsoluteCwd($0) ? $0 : nil }
|
||||
}
|
||||
|
||||
/// TerminalScreen 工具栏与 exit 横幅共用动作:在当前会话的 cwd fresh
|
||||
/// spawn(`attach(null, cwd)`,镜像 web tabs.ts `newTab()` M6)。单活
|
||||
/// WS 不变式:先 `closeTerminal()`(旧会话记 last-seen 水位、engine
|
||||
/// detach),再开新 controller。cwd 未知 → 普通新会话;无打开的终端
|
||||
/// → no-op。不注入 bootstrap —— "开新 shell"不是"起 claude"。
|
||||
func openNewSessionInCurrentCwd() {
|
||||
guard let controller = terminalController else { return }
|
||||
let host = controller.host
|
||||
let cwd = currentTerminalCwd
|
||||
closeTerminal()
|
||||
startTerminal(host: host, sessionId: nil, spawnCwd: cwd)
|
||||
}
|
||||
|
||||
// MARK: - "继续上次" (cold start step 5)
|
||||
|
||||
var continueLastSessionId: UUID? {
|
||||
|
||||
Reference in New Issue
Block a user