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`。 /// - `AdaptiveRootView` 按 `horizontalSizeClass` 选 `StackRootView`(compact, /// 现有路径,字节级不变)或 `SplitRootView`(regular,iPad 分栏),并把 /// 隐私遮罩 / scenePhase / deepLink / sheets 这些**设备无关的横切布线** /// 上提到唯一一处,两分支共享(遮罩因此仍是两分支共同的 ZStack 顶层)。 /// /// `StackRootView` 是原 `RootView` 的 body **原样搬迁** —— compact 分支复用它 /// 不改一行,故 iPhone 逐屏渲染与适配前一致(零回归硬性要求)。 struct RootView: View { @Bindable var coordinator: AppCoordinator var body: some View { AdaptiveRootView(coordinator: coordinator) } } /// T-iPad-2 · compact 宽度(iPhone / iPad Slide Over)的根视图 —— 原 /// `RootView` 的 `NavigationStack` 主体原样搬迁,行为字节级不变。横切布线 /// (遮罩/scenePhase/deepLink/sheets)不在这里,已上提到 `AdaptiveRootView`。 struct StackRootView: View { @Bindable var coordinator: AppCoordinator 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() } ) .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 { 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) } } } enum RootMetrics { static let bannerHorizontalPadding: CGFloat = 16 static let bannerVerticalPadding: CGFloat = 8 } /// 根层用户可见文案(internal —— `SplitRootView` 复用同一「项目」标签,DRY)。 enum RootCopy { static let continueLast = "继续上次会话" static let projects = "项目" }