import HostRegistry import SwiftUI /// T-iOS-15 / T-iPad-2 · App 根入口。历史上这里是单一 `NavigationStack` + /// 隐私遮罩 + sheets/scenePhase/deepLink 的全部布线;iPad 自适应(T-iPad-2) /// 把它拆成两层: /// - `RootView`(本 struct)保持 `@main` 的唯一实例化点不变(WebTermApp 仍 /// `RootView(coordinator:)`),内部委托给 `AdaptiveRootView` 并在这唯一处 /// 注入设计系统的 accent tint(`DS.Palette.accent`,DS 建议的「根注入一次」) /// —— 全 App 的系统控件/工具栏/`.borderedProminent` 由此统一到靛紫强调色。 /// - `AdaptiveRootView` 按 `horizontalSizeClass` 选 `StackRootView`(compact, /// 现有路径,字节级不变)或 `SplitRootView`(regular,iPad 分栏),并把 /// 隐私遮罩 / scenePhase / deepLink / sheets 这些**设备无关的横切布线** /// 上提到唯一一处,两分支共享(遮罩因此仍是两分支共同的 ZStack 顶层)。 /// /// `StackRootView` 是原 `RootView` 的 body **原样搬迁** —— compact 分支复用它 /// 不改一行逻辑,故 iPhone 逐屏渲染与适配前一致(零回归硬性要求)。视觉层 /// 只按冻结的 `DS.*` token 重塑(横幅/占位/遮罩),行为不动。 struct RootView: View { @Bindable var coordinator: AppCoordinator var body: some View { AdaptiveRootView(coordinator: coordinator) // DS:唯一的根 tint 注入点(Tokens.swift 头注约定)。子树若需别的 // 语义色(gate 的 amber、终端的 orange)在各自局部 `.tint` 覆盖。 .tint(DS.Palette.accent) // 深色优先 —— 对齐桌面 web 主题(DEFAULT_SETTINGS.theme = 'dark')。 // 琥珀金强调色是为暖深色背景设计的(桌面 --bg #100F0D);浅色底上 // 金字对比不足。深色下 accent/status 全部高对比,且与桌面观感一致。 .preferredColorScheme(.dark) } } /// T-iPad-2 · compact 宽度(iPhone / iPad Slide Over)的根视图 —— 原 /// `RootView` 的 `NavigationStack` 主体原样搬迁,行为字节级不变。横切布线 /// (遮罩/scenePhase/deepLink/sheets)不在这里,已上提到 `AdaptiveRootView`。 struct StackRootView: View { @Bindable var coordinator: AppCoordinator @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { NavigationStack { rootContent .navigationDestination(isPresented: terminalBinding) { terminalDestination } } } // 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() }, onDeviceCert: { coordinator.presentDeviceCert() } ) .safeAreaInset(edge: .bottom) { continueLastBanner } // 横幅出现/消失走 DS 动效(reduceMotion 时塌成瞬切,无位移)。 .animation( DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion), value: coordinator.continueLastSessionId ) // T-iOS-26 · Projects 入口挂在 RootView 层(SessionListScreen 是 // T-iOS-23 的 W7 独占文件 —— 列表侧入口整合移交给它)。leading 位, // 避开列表自己的 topBarTrailing hostMenu。 .toolbar { ProjectsToolbarItem(coordinator: coordinator) } } // MARK: - "继续上次" highlight (cold start step 5) @ViewBuilder private var continueLastBanner: some View { if coordinator.continueLastSessionId != nil { ContinueLastBanner { coordinator.openContinueLast() } .transition(.move(edge: .bottom).combined(with: .opacity)) } } // MARK: - Terminal push private var terminalBinding: Binding { 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() }, onKillSession: { coordinator.killCurrentSession() } // T-iPad-3 ) // 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: - Continue-last banner (shared stack + split, DRY) /// 冷启动第 5 步的「继续上次会话」再入 CTA —— stack 底栏与 split sidebar 底栏 /// 共用同一个组件(DRY)。设计:`.regularMaterial` 底 + 顶部发丝分隔 + /// 全宽 accent 主按钮(`DSButtonStyle(.primary)`),克制而不喧宾夺主。行为仅 /// 一条 `action`(= `coordinator.openContinueLast()`),出现条件由调用点把关。 struct ContinueLastBanner: View { let action: () -> Void var body: some View { Button(action: action) { Label(RootCopy.continueLast, systemImage: "arrow.uturn.forward.circle.fill") } .buttonStyle(DSButtonStyle(kind: .primary)) .accessibilityIdentifier("root.continueLastButton") .padding(.horizontal, DS.Space.lg16) .padding(.top, DS.Space.md12) .padding(.bottom, DS.Space.sm8) .background(.regularMaterial) .overlay(alignment: .top) { Rectangle() .fill(DS.Palette.hairline) .frame(height: DS.Stroke.hairline) } } } // MARK: - Projects toolbar item (shared stack + split, DRY) /// 「项目」leading 工具栏入口 —— stack 与 split 共用(同 disabled 条件、同 /// `presentProjects` 动作、同 a11y id)。根 tint 令其 label 呈 accent。 struct ProjectsToolbarItem: ToolbarContent { @Bindable var coordinator: AppCoordinator var body: some ToolbarContent { ToolbarItem(placement: .topBarLeading) { Button { coordinator.presentProjects() } label: { Label(RootCopy.projects, systemImage: "folder") } .disabled(coordinator.sessionList.activeHost == nil) .accessibilityIdentifier("sessions.projectsButton") } } } /// 根层用户可见文案(internal —— `SplitRootView` 复用同一「项目」/「继续」标签,DRY)。 enum RootCopy { static let continueLast = "继续上次会话" static let projects = "项目" static let done = "完成" }