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 { 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 { 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 = "项目" }