Files
web-terminal/ios/App/WebTerm/Wiring/AppCoordinator.swift
Yaojia Wang 823432b1c8 feat(ipad): W1-W3 — adaptive split-view layout + finding fixes
T-iPad-2: AdaptiveRootView/LayoutPolicy (sole size-class decision), SplitRootView
(NavigationSplitView sidebar+detail), StackRootView (iPhone path verbatim, zero
regression); privacy shade hoisted to shared ZStack top for both branches
T-iPad-3: KeyBarVisibility predicate (hide when hardware keyboard present),
pointer context menu (copy/new-in-cwd/kill, all via existing channels)
T-iPad-4: Projects multi-column grid on iPad (idiom-gated), adaptive sheet detents
T-iPad-5 findings (4/4 fixed): kill onKillSession thread-through +
AppCoordinator.killCurrentSession; split route-gated to .sessions (iPad first-run
pairing); continue-last banner in split sidebar; iPad XCUITest deferred (covered
by SidebarSelectionTests)
Verified: iPhone 16 277 + iPad Pro 11 278 tests green; packages 261 + integration 10;
zero changes under ios/Packages, src/, public/
2026-07-05 19:58:30 +02:00

303 lines
13 KiB
Swift
Raw Permalink 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 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).
///
/// - Cold start: read the host store once no paired host Pairing, else
/// SessionList (`ColdStartPolicy.initialRoute`).
/// - `SessionListScreen.onOpen` build ONE `TerminalSessionController`
/// (single foreground session, plan §1) and push the terminal.
/// - Back `closeTerminal()` `engine.close()` (detach; PTY keeps running).
/// - scenePhase is forwarded to the open controller (`.background`
/// suspend/close, `.active` resume/rebuild). The privacy shade is view
/// layer (RootView + PrivacyShadePolicy), not coordinator state.
@MainActor
@Observable
final class AppCoordinator {
private(set) var route: ColdStartPolicy.RootRoute = .loading
private(set) var terminalController: TerminalSessionController?
/// First-run pairing VM (root route). Recreated per entry to keep pairing
/// state machines single-shot.
private(set) var rootPairingViewModel: PairingViewModel?
/// 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(),
unreadStore: environment.unreadStore
)
}
// MARK: - Cold start
/// Decide the boot route from the host store. A store READ failure routes
/// to the session list, whose own `reloadHosts` surfaces the explicit
/// error copy (never a silent empty pairing screen hiding a broken store).
func bootstrap() async {
guard route == .loading else { return }
do {
let hosts = try await environment.hostStore.loadAll()
route = ColdStartPolicy.initialRoute(pairedHostCount: hosts.count)
} catch {
route = .sessions
}
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
/// in the store PairingViewModel upserts before signalling).
func completeFirstPairing(_ host: HostRegistry.Host) {
rootPairingViewModel = nil
route = .sessions
Task { await sessionList.reloadHosts() }
}
// MARK: - Add-host sheet (list header hook)
func presentAddHost() {
addHostPairingViewModel = makePairingViewModel()
isAddHostPresented = true
}
func completeAddHost(_ host: HostRegistry.Host) {
isAddHostPresented = false
addHostDismissed()
}
/// Sheet gone (paired OR cancelled): drop the VM and refresh hosts the
/// list VM keeps the active host if it still exists.
func addHostDismissed() {
addHostPairingViewModel = nil
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: 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. 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 closeopen 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
}
/// T-iPad-3 · kill
/// `SessionListViewModel.kill` `APIClient.killSession` Origin
/// detach detail adopted no-op
func killCurrentSession() {
guard let sessionId = terminalController?.terminalViewModel.sessionId else { return }
Task { await sessionList.kill(sessionId: sessionId) }
closeTerminal()
}
// MARK: - Split-view sidebar bridge (T-iPad-2)
/// Detail sidebar getter
/// **adopted** adopted / detail nil
/// stack `terminalController` coordinator
///
var selectedSidebarItem: SidebarItem? {
terminalController?.terminalViewModel.sessionId.map(SidebarItem.session)
}
/// `NavigationSplitView` getter detail setter
/// `open` / `presentProjects`
/// set nil detail
var sidebarSelection: Binding<SidebarItem?> {
Binding(
get: { self.selectedSidebarItem },
set: { item in
guard let item else { return }
self.selectSidebarItem(item)
}
)
}
/// sidebar **** open(id) / open(nil) /
/// presentProjects API沿 WS closeopen
/// new-in-cwd / deep-link `.projects` sheet
func selectSidebarItem(_ item: SidebarItem) {
switch item {
case .session(let sessionId):
switchTerminal(sessionId: sessionId)
case .newSession:
switchTerminal(sessionId: nil)
case .projects:
presentProjects()
}
}
/// WS no-op churn
/// `closeTerminal()` last-seen engine detach
/// `open()`**** activeHost no-op
private func switchTerminal(sessionId: UUID?) {
guard let host = sessionList.activeHost else { return }
if let sessionId, selectedSidebarItem == .session(sessionId) { return }
if terminalController != nil { closeTerminal() }
open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId))
}
// 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 controllercwd
/// 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? {
ColdStartPolicy.continueLastSessionId(
activeHost: sessionList.activeHost,
store: environment.lastSessionStore
)
}
func openContinueLast() {
guard let host = sessionList.activeHost, let sessionId = continueLastSessionId else {
return
}
open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId))
}
// MARK: - scenePhase (plan §7 T-iOS-15 step 3)
func handleScenePhase(_ phase: ScenePhase) {
switch phase {
case .background:
terminalController?.suspend()
case .active:
terminalController?.resumeIfNeeded()
case .inactive:
break // transient; shade covers it at the view layer
@unknown default:
break
}
}
// MARK: - Helpers
private func makePairingViewModel() -> PairingViewModel {
PairingViewModel(store: environment.hostStore, probe: environment.probe)
}
}
/// T-iPad-2 · split sidebar `AppCoordinator`
/// `.session(id)` == `open(id)``.newSession` == `open(nil)`
/// `.projects` == `presentProjects()`
enum SidebarItem: Hashable {
case session(UUID)
case newSession
case projects
}