feat(ipad): W1-W3 — adaptive split-view layout + finding fixes

T-iPad-2: AdaptiveRootView/LayoutPolicy (sole size-class decision), SplitRootView
(NavigationSplitView sidebar+detail), StackRootView (iPhone path verbatim, zero
regression); privacy shade hoisted to shared ZStack top for both branches
T-iPad-3: KeyBarVisibility predicate (hide when hardware keyboard present),
pointer context menu (copy/new-in-cwd/kill, all via existing channels)
T-iPad-4: Projects multi-column grid on iPad (idiom-gated), adaptive sheet detents
T-iPad-5 findings (4/4 fixed): kill onKillSession thread-through +
AppCoordinator.killCurrentSession; split route-gated to .sessions (iPad first-run
pairing); continue-last banner in split sidebar; iPad XCUITest deferred (covered
by SidebarSelectionTests)
Verified: iPhone 16 277 + iPad Pro 11 278 tests green; packages 261 + integration 10;
zero changes under ios/Packages, src/, public/
This commit is contained in:
Yaojia Wang
2026-07-05 19:58:30 +02:00
parent 77502ec4fe
commit 823432b1c8
18 changed files with 1594 additions and 84 deletions

View File

@@ -1,62 +1,39 @@
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.
/// 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`regulariPad
/// / scenePhase / deepLink / sheets **线**
/// ZStack
///
/// 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).
/// `StackRootView` `RootView` body **** compact
/// iPhone
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
}
AdaptiveRootView(coordinator: coordinator)
}
}
/// 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()
}
)
/// 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
@@ -140,7 +117,8 @@ struct RootView: View {
if let controller = coordinator.terminalController {
TerminalContainerView(
controller: controller,
onNewSessionInCwd: { coordinator.openNewSessionInCurrentCwd() }
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
@@ -151,40 +129,15 @@ struct RootView: View {
.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 {
enum RootMetrics {
static let bannerHorizontalPadding: CGFloat = 16
static let bannerVerticalPadding: CGFloat = 8
}
private enum RootCopy {
/// internal `SplitRootView` DRY
enum RootCopy {
static let continueLast = "继续上次会话"
static let projects = "项目"
}