Files
web-terminal/ios/App/WebTerm/Wiring/TerminalContainerView.swift
Yaojia Wang f40b8f9400 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
2026-07-05 16:15:57 +02:00

152 lines
6.6 KiB
Swift

import SessionCore
import SwiftUI
/// T-iOS-15 · Terminal destination view: TerminalScreen (T-iOS-11) plus the
/// gate/digest surfaces (T-iOS-14) wired to the controller's GateViewModel
/// the " TerminalScreen wiring T-iOS-15" hand-off.
///
/// Layout decisions (documented):
/// - `.id(controller.generation)`: a rebuild (backgroundforeground) swaps the
/// ViewModels; without a new identity SwiftUI would keep the old UIView and
/// `makeUIView` (where the output sink attaches) would never run again.
/// - Digest + tool-gate banner live in a TOP overlay stack: the bottom edge
/// belongs to the keyboard + key bar. TerminalScreen's own ReconnectBanner
/// is also top-aligned, but it hides on `.connected` while gates/digests
/// only arrive connected practical overlap is nil.
/// - Plan gate presents as a medium-detent sheet; swiping it away is allowed
/// (the user may need to scroll the terminal to read the plan), and a
/// "" re-entry chip appears until the gate resolves. Deciding
/// removes the gate server-side `planGate` goes nil sheet dismisses.
struct TerminalContainerView: View {
let controller: TerminalSessionController
/// T-iOS-29 · pass-through to TerminalScreen's toolbar/exit-banner
/// "" action (RootView supplies the coordinator hop).
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
/// Epoch of a plan gate the user swiped away suppresses re-present for
/// THAT gate only; a new epoch re-presents automatically.
@State private var dismissedPlanGateEpoch: Int?
/// T-iOS-24 (additive) · Non-nil while the full-timeline sheet is up; a
/// FRESH VM per presentation (each open re-fetches /events).
@State private var timelineViewModel: TimelineViewModel?
/// T-iOS-25 (additive) · Quick-reply palette store per-container over
/// `.standard` defaults (non-secret UI prefs, plan §5.3 split).
@State private var quickReplyStore = QuickReplyStore()
private enum Metrics {
static let overlaySpacing: CGFloat = 8
static let overlayHorizontalPadding: CGFloat = 12
/// Clears TerminalScreen's own top-aligned ReconnectBanner zone.
static let overlayTopPadding: CGFloat = 52
/// Breathing room between the chip row and the keyboard/key-bar edge.
static let quickReplyBottomPadding: CGFloat = 8
}
private enum Copy {
static let planGatePending = "⚠ 计划待批准"
}
var body: some View {
TerminalScreen(
viewModel: controller.terminalViewModel,
onNewSessionInCwd: onNewSessionInCwd
)
.id(controller.generation)
.navigationBarTitleDisplayMode(.inline)
.overlay(alignment: .top) { topOverlays }
.overlay(alignment: .bottom) { quickReplyOverlay }
.sheet(isPresented: planGateBinding) { planGateSheet }
.sheet(item: $timelineViewModel) { TimelineSheet(viewModel: $0) }
}
// MARK: - Quick-reply chips (T-iOS-25, additive)
/// Chips float at the bottom edge (above the keyboard's safe area) ONLY
/// while a gate is waiting visibility/read-only logic lives in
/// `QuickReplyBar.isVisible`, driven by the SAME fan-out branches the two
/// VMs already consume (no extra branch needed).
@ViewBuilder private var quickReplyOverlay: some View {
QuickReplyBar(
terminalViewModel: controller.terminalViewModel,
gateViewModel: controller.gateViewModel,
store: quickReplyStore
)
.padding(.horizontal, Metrics.overlayHorizontalPadding)
.padding(.bottom, Metrics.quickReplyBottomPadding)
.animation(.default, value: controller.gateViewModel.currentGate)
}
// MARK: - Digest + tool gate (top stack)
@ViewBuilder private var topOverlays: some View {
let gateViewModel = controller.gateViewModel
VStack(spacing: Metrics.overlaySpacing) {
if let digest = gateViewModel.digest {
AwayDigestView(
digest: digest,
isExpanded: gateViewModel.isDigestExpanded,
onExpand: { expandDigestAndPresentTimeline() },
onDismiss: { gateViewModel.dismissDigest() }
)
}
if let gate = gateViewModel.toolGate {
GateBanner(gate: gate) { affordance, epoch in
gateViewModel.decide(affordance, epoch: epoch)
}
}
if hasDismissedPendingPlanGate {
Button(Copy.planGatePending) { dismissedPlanGateEpoch = nil }
.buttonStyle(.borderedProminent)
.tint(.orange)
.controlSize(.small)
}
}
.padding(.horizontal, Metrics.overlayHorizontalPadding)
.padding(.top, Metrics.overlayTopPadding)
.animation(.default, value: gateViewModel.toolGate)
.animation(.default, value: gateViewModel.digest)
}
// MARK: - Timeline drill-down (T-iOS-24, additive)
/// The digestaffordance does BOTH: inline expansion (which cancels
/// the auto-fade, T-iOS-14 semantics the digest must survive under the
/// sheet) and the full-timeline sheet. No adopted sessionId yet
/// `forSession` returns nil inline expand only (defensive; a digest
/// only ever arrives after `attached`).
private func expandDigestAndPresentTimeline() {
controller.gateViewModel.expandDigest()
timelineViewModel = TimelineViewModel.forSession(
controller.terminalViewModel.sessionId,
source: controller.timelineEventsSource
)
}
// MARK: - Plan gate sheet
private var hasDismissedPendingPlanGate: Bool {
guard let gate = controller.gateViewModel.planGate else { return false }
return gate.epoch == dismissedPlanGateEpoch
}
private var planGateBinding: Binding<Bool> {
Binding(
get: {
guard let gate = controller.gateViewModel.planGate else { return false }
return gate.epoch != dismissedPlanGateEpoch
},
set: { presented in
guard !presented else { return }
dismissedPlanGateEpoch = controller.gateViewModel.planGate?.epoch
}
)
}
@ViewBuilder private var planGateSheet: some View {
if let gate = controller.gateViewModel.planGate {
PlanGateSheet(gate: gate) { affordance, epoch in
controller.gateViewModel.decide(affordance, epoch: epoch)
}
}
}
}