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
191 lines
6.6 KiB
Swift
191 lines
6.6 KiB
Swift
import HostRegistry
|
||
import SwiftUI
|
||
|
||
/// T-iOS-15 · Root of the app: route switch (Pairing / SessionList), terminal
|
||
/// push, add-host sheet, scenePhase forwarding and the privacy shade.
|
||
///
|
||
/// The shade is the TOPMOST layer of this ZStack and appears whenever
|
||
/// `scenePhase != .active` (exact rule — PrivacyShadePolicy + tests); it
|
||
/// covers the whole navigation tree, terminal included. Sheets live above any
|
||
/// overlay, but no sheet renders terminal bytes (pairing / plan gate only).
|
||
struct RootView: View {
|
||
@Bindable var coordinator: AppCoordinator
|
||
@Environment(\.scenePhase) private var scenePhase
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
NavigationStack {
|
||
rootContent
|
||
.navigationDestination(isPresented: terminalBinding) {
|
||
terminalDestination
|
||
}
|
||
}
|
||
if PrivacyShadePolicy.isShadeVisible(for: scenePhase) {
|
||
PrivacyShadeView()
|
||
}
|
||
}
|
||
.task { await coordinator.bootstrap() }
|
||
.onChange(of: scenePhase) { _, phase in
|
||
coordinator.handleScenePhase(phase)
|
||
}
|
||
.onOpenURL { coordinator.handleDeepLink(url: $0) } // T-iOS-22
|
||
.alert(DeepLinkCopy.hintTitle, isPresented: deepLinkHintBinding) {
|
||
Button(DeepLinkCopy.hintConfirm) { coordinator.deepLink.clearHint() }
|
||
} message: {
|
||
Text(coordinator.deepLink.hintMessage ?? "")
|
||
}
|
||
.sheet(
|
||
isPresented: $coordinator.isAddHostPresented,
|
||
onDismiss: { coordinator.addHostDismissed() }
|
||
) {
|
||
addHostSheet
|
||
}
|
||
.sheet(
|
||
isPresented: $coordinator.isProjectsPresented,
|
||
onDismiss: { coordinator.projectsDismissed() }
|
||
) {
|
||
projectsSheet
|
||
}
|
||
}
|
||
|
||
/// Deep-link hint alert (unknown host / store failure, T-iOS-22).
|
||
private var deepLinkHintBinding: Binding<Bool> {
|
||
Binding(
|
||
get: { coordinator.deepLink.hintMessage != nil },
|
||
set: { presented in
|
||
guard !presented else { return }
|
||
coordinator.deepLink.clearHint()
|
||
}
|
||
)
|
||
}
|
||
|
||
// MARK: - Route switch
|
||
|
||
@ViewBuilder private var rootContent: some View {
|
||
switch coordinator.route {
|
||
case .loading:
|
||
ProgressView()
|
||
case .pairing:
|
||
firstRunPairing
|
||
case .sessions:
|
||
sessionList
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private var firstRunPairing: some View {
|
||
if let viewModel = coordinator.rootPairingViewModel {
|
||
PairingScreen(viewModel: viewModel) { host in
|
||
coordinator.completeFirstPairing(host)
|
||
}
|
||
} else {
|
||
ProgressView()
|
||
}
|
||
}
|
||
|
||
private var sessionList: some View {
|
||
SessionListScreen(
|
||
viewModel: coordinator.sessionList,
|
||
onOpen: { coordinator.open($0) },
|
||
onAddHost: { coordinator.presentAddHost() }
|
||
)
|
||
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
||
// T-iOS-26 · Projects 入口挂在 RootView 层(SessionListScreen 是
|
||
// T-iOS-23 的 W7 独占文件 —— 列表侧入口整合移交给它)。leading 位,
|
||
// 避开列表自己的 topBarTrailing hostMenu。
|
||
.toolbar { projectsToolbarItem }
|
||
}
|
||
|
||
@ToolbarContentBuilder private var projectsToolbarItem: some ToolbarContent {
|
||
ToolbarItem(placement: .topBarLeading) {
|
||
Button {
|
||
coordinator.presentProjects()
|
||
} label: {
|
||
Label(RootCopy.projects, systemImage: "folder")
|
||
}
|
||
.disabled(coordinator.sessionList.activeHost == nil)
|
||
.accessibilityIdentifier("sessions.projectsButton")
|
||
}
|
||
}
|
||
|
||
// MARK: - "继续上次" highlight (cold start step 5)
|
||
|
||
@ViewBuilder private var continueLastBanner: some View {
|
||
if coordinator.continueLastSessionId != nil {
|
||
Button {
|
||
coordinator.openContinueLast()
|
||
} label: {
|
||
Label(RootCopy.continueLast, systemImage: "arrow.uturn.forward.circle.fill")
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.padding(.horizontal, RootMetrics.bannerHorizontalPadding)
|
||
.padding(.vertical, RootMetrics.bannerVerticalPadding)
|
||
.background(.thinMaterial)
|
||
}
|
||
}
|
||
|
||
// MARK: - Terminal push
|
||
|
||
private var terminalBinding: Binding<Bool> {
|
||
Binding(
|
||
get: { coordinator.terminalController != nil },
|
||
set: { presented in
|
||
guard !presented else { return }
|
||
coordinator.closeTerminal() // back → engine.close() (detach)
|
||
}
|
||
)
|
||
}
|
||
|
||
@ViewBuilder private var terminalDestination: some View {
|
||
if let controller = coordinator.terminalController {
|
||
TerminalContainerView(
|
||
controller: controller,
|
||
onNewSessionInCwd: { coordinator.openNewSessionInCurrentCwd() }
|
||
)
|
||
// T-iOS-29 · identity PER CONTROLLER: an in-place session switch
|
||
// (new-in-cwd, deep link) swaps the controller while the
|
||
// destination stays presented — without a new identity SwiftUI
|
||
// keeps the old SwiftTerm UIView and the new ViewModel's output
|
||
// sink never attaches (makeUIView never re-runs). Also resets the
|
||
// container's per-session @State (plan-gate dismissal, timeline).
|
||
.id(controller.id)
|
||
}
|
||
}
|
||
|
||
// MARK: - Add-host sheet (multi-host entry, list header)
|
||
|
||
@ViewBuilder private var addHostSheet: some View {
|
||
if let viewModel = coordinator.addHostPairingViewModel {
|
||
NavigationStack {
|
||
PairingScreen(viewModel: viewModel) { host in
|
||
coordinator.completeAddHost(host)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Projects sheet (T-iOS-26)
|
||
|
||
/// 自带 NavigationStack:列表 → 详情 → 差异都在 sheet 内 push;
|
||
/// "在此仓库开新会话" 关掉 sheet 并在根导航推入终端。
|
||
@ViewBuilder private var projectsSheet: some View {
|
||
if let viewModel = coordinator.projectsViewModel {
|
||
NavigationStack {
|
||
ProjectsScreen(viewModel: viewModel) { request in
|
||
coordinator.openProject(request)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private enum RootMetrics {
|
||
static let bannerHorizontalPadding: CGFloat = 16
|
||
static let bannerVerticalPadding: CGFloat = 8
|
||
}
|
||
|
||
private enum RootCopy {
|
||
static let continueLast = "继续上次会话"
|
||
static let projects = "项目"
|
||
}
|