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

@@ -24,6 +24,15 @@
> 新会话读到的第一块。保持准确,只描述"此刻"。
### ✅ iOS 客户端 iPad 适配(完成 — 2026-07-05,分支 `feat/ios-client`;自适应布局,非分叉)
`docs/PLAN_IOS_IPAD.md`(5 人日)编排:W0 orchestrator 亲做 → 一个 ultracode `Workflow`(4 agents: T-iPad-2 核心串行 → T-iPad-3 ∥ T-iPad-4 → T-iPad-5 验收)。
- **[x] T-iPad-1 device family(orchestrator 亲做,commit 77502ec)**: `TARGETED_DEVICE_FAMILY` 全 target `1``1,2`;iPad `~ipad` 四方向(iPhone 不动);ios.yml 加 iPad Pro 11 测试腿。实测:产物 `UIDeviceFamily [1,2]`、iPad 原生满屏(不再 iPhone 黑边)。
- **[x] T-iPad-2 AdaptiveRootView + NavigationSplitView + LayoutPolicy(核心)**: `LayoutPolicy.mode(hSizeClass)` 唯一 size-class 决策点(regular→split/compact→stack,仿 PrivacyShadePolicy);`AdaptiveRootView` 把横切布线(**隐私遮罩 ZStack 顶层两分支共享**/scenePhase/deepLink/sheets)上提唯一一处;`StackRootView`=原 RootView body **原样搬迁**(iPhone 字节级零回归);`SplitRootView`=左 SessionListScreen(复用不改)+ 右 TerminalContainerView(复用不改,带 `.id`);AppCoordinator 加 SidebarItem 路由桥(非平行生命周期)。
- **[x] T-iPad-3 KeyBar 自适应 + 指针右键菜单**: `KeyBarVisibility` 纯谓词(硬件键盘在场默认隐);`TerminalContextMenu`(iPad idiom,复制选区/开新会话/结束会话,一一路由既有通道);kill 经 APIClient 带 Origin 单测。
- **[x] T-iPad-4 Projects 大屏化**: `ProjectsGridLayout.columnCount`(iPhone 恒 1/iPad 按宽 1-3);iPad 多列网格复用同款 row/分组/prefs 逻辑零改;Timeline 已有 detent 两端合理。**决策**: Projects 在 iPad 以 form sheet 呈现,其内 hSizeClass 恒 compact → 改用 `userInterfaceIdiom` 判据(与 size class 正交)。
- **[x] T-iPad-5 验收 PASS_WITH_FINDINGS → orchestrator 修复 4/4**: **iPhone 零回归硬门守住**(277 App+261 包+10 集成);iPad 分栏工作;无 CRITICAL/HIGH/安全回归。修复:①MED kill 菜单项无法渲染 → `onKillSession` 贯穿 TerminalContainerView→两根视图 + `AppCoordinator.killCurrentSession()`(复用 kill 通道);②MED split 未消费 route → `AdaptiveRootView``.loading/.pairing` 强制走 StackRootView(iPad 首启配对不被困在空态);③LOW 「继续上次」横幅补进 split sidebar(截图确认);④LOW iPad happy-path XCUITest **接受推迟**(split 选中逻辑已由 SidebarSelectionTests 9 测真 engine 覆盖,stack happy-path 未变,XCUITest 单跑 7-11min 且脆,成本不划算)。**验证(亲验)**: iPhone 16 **277** + iPad Pro 11 **278**(+kill 桥测试)全绿;iPad 分栏 sidebar+detail 截图确认。
- **合计**: iPad 适配零改 `ios/Packages/**`/`src/`/`public/`;纯逻辑包设备无关直接复用。**待用户**: 真机 iPad(分栏手势/硬件键盘/指针右键/Stage Manager)DEFERRED;多窗口(拖会话开新窗并排两终端)另立下一期(本期单场景)。
### ✅ iOS 客户端 P1 实施(完成 — 2026-07-05,分支 `feat/ios-client`,P0+P1 共 14 commits,未推送/未合并)
- **[x] P1-A(W6+W7 首发: T-iOS-37 ∥ T-iOS-20 ∥ T-iOS-38,3 builders ∥ + verify 6/6 PASS;`/push/apns-token` 线形状由 orchestrator 预冻结,两侧并行编码零失配——verify 逐项对照 method/path/body key/hex 规则/状态码/守卫/限额全 match)**:
- **T-iOS-37 server lastOutputAt**: `LiveSessionInfo.lastOutputAt?: number` 可选增量 + `manager.list()` 一行映射 + 4 测;web 端 grep 零消费者,tsc+全量双证兼容。

View File

@@ -11,6 +11,28 @@ import UIKit
/// never pops or fights the soft keyboard same reason the web bar bypasses
/// xterm and calls `ws.send`).
// MARK: - Visibility policy (T-iPad-3)
/// Pure predicate deciding whether the soft-keyboard KeyBar
/// (`inputAccessoryView`) should show THE single decision point (mirrors
/// `PrivacyShadePolicy` / `LayoutPolicy`, no scattered checks in views):
/// - a hardware keyboard makes the on-screen key bar redundant default hidden;
/// - no hardware keyboard shown (unchanged iPhone behavior zero regression);
/// - an explicit user toggle (`userOverride`) always wins over the auto default.
///
/// Hardware presence is injected (`GCKeyboard.coalesced != nil` at the call
/// site) so this stays a fast, device-agnostic unit (`KeyBarVisibilityTests`).
enum KeyBarVisibility {
/// - Parameters:
/// - hardwareKeyboardPresent: injected `GCKeyboard.coalesced != nil`.
/// - userOverride: nil = follow the auto default; true/false = the user
/// explicitly forced show/hide (wins over the hardware-driven default).
static func isVisible(hardwareKeyboardPresent: Bool, userOverride: Bool?) -> Bool {
if let userOverride { return userOverride }
return !hardwareKeyboardPresent
}
}
// MARK: - Layout data (mirror of KEYBAR_BUTTONS)
/// One key-bar button: glyph, short function caption (shown under the glyph,

View File

@@ -0,0 +1,144 @@
import UIKit
/// T-iPad-3 · /·
///
/// + `UIContextMenuInteraction` **
/// **
/// - `.copySelection` SwiftTerm `copy(_:)`
/// UI hover
/// - `.newInCwd` T-iOS-29 `onNewInCwd` cwd fresh spawn
/// - `.kill` `onKill`wiring `APIClient.killSession`
/// Origin G RO/G
///
/// iPad idiom `isPointerMenuEnabled` iPhone
/// SwiftTerm
/// One context-menu action, each mapped to an existing channel (no new path).
enum TerminalContextAction: String, CaseIterable, Sendable, Equatable {
case copySelection
case newInCwd
case kill
}
/// Display spec for one menu row (Chinese copy + SF Symbol + destructive flag).
struct TerminalContextItem: Equatable, Sendable {
let action: TerminalContextAction
let title: String
let systemImage: String
let isDestructive: Bool
}
enum TerminalContextMenu {
/// named constantsplan §4
enum Copy {
static let copySelection = "复制选区"
static let newInCwd = "在当前目录开新会话"
static let kill = "结束会话"
}
private enum Symbol {
static let copySelection = "doc.on.doc"
static let newInCwd = "plus.rectangle.on.folder"
static let kill = "xmark.circle"
}
/// iPad idiom size class
/// `LayoutPolicy` stack/split iPhone
/// interaction SwiftTerm
static func isPointerMenuEnabled(idiom: UIUserInterfaceIdiom) -> Bool {
idiom == .pad
}
///
static func items(
canCopySelection: Bool,
canNewInCwd: Bool,
canKill: Bool
) -> [TerminalContextItem] {
var result: [TerminalContextItem] = []
if canCopySelection {
result.append(TerminalContextItem(
action: .copySelection, title: Copy.copySelection,
systemImage: Symbol.copySelection, isDestructive: false
))
}
if canNewInCwd {
result.append(TerminalContextItem(
action: .newInCwd, title: Copy.newInCwd,
systemImage: Symbol.newInCwd, isDestructive: false
))
}
if canKill {
result.append(TerminalContextItem(
action: .kill, title: Copy.kill,
systemImage: Symbol.kill, isDestructive: true
))
}
return result
}
}
/// + `items` `perform`
/// wiring `onNewInCwd`T-iOS-29/ `onKill`APIClient.killSession
/// `onCopySelection`/`hasSelection`
@MainActor
struct TerminalContextMenuModel {
let onNewInCwd: (@MainActor () -> Void)?
let onKill: (@MainActor () -> Void)?
let onCopySelection: @MainActor () -> Void
let hasSelection: @MainActor () -> Bool
/// / wiring
/// =
var items: [TerminalContextItem] {
TerminalContextMenu.items(
canCopySelection: hasSelection(),
canNewInCwd: onNewInCwd != nil,
canKill: onKill != nil
)
}
///
func perform(_ action: TerminalContextAction) {
switch action {
case .copySelection:
onCopySelection()
case .newInCwd:
onNewInCwd?()
case .kill:
onKill?()
}
}
}
/// `UIContextMenuInteraction` `makeModel()`
/// / `UIMenu` `UIAction` `model.perform`
/// iPad `TerminalContextMenu.isPointerMenuEnabled`
@MainActor
final class TerminalContextMenuInteractionDelegate: NSObject, UIContextMenuInteractionDelegate {
private let makeModel: @MainActor () -> TerminalContextMenuModel
init(makeModel: @escaping @MainActor () -> TerminalContextMenuModel) {
self.makeModel = makeModel
}
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configurationForMenuAtLocation location: CGPoint
) -> UIContextMenuConfiguration? {
let model = makeModel()
let items = model.items
guard !items.isEmpty else { return nil } //
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
UIMenu(title: "", children: items.map { item in
UIAction(
title: item.title,
image: UIImage(systemName: item.systemImage),
attributes: item.isDestructive ? .destructive : []
) { _ in
model.perform(item.action)
}
})
}
}
}

View File

@@ -0,0 +1,73 @@
import SwiftUI
import UIKit
/// T-iPad-4 · Projects **** / / sheet
/// detents 仿 `LayoutPolicy`
/// 100%
///
/// ** `UIUserInterfaceIdiom` `horizontalSizeClass`**
/// iPad Projects ** sheet**form sheet
/// `horizontalSizeClass` **compact** iPhone
/// size class iPhone iPhoneiPad
/// Projects sheet idiom Projects /
/// idiom+ stack/split `LayoutPolicy`
/// size-class
enum ProjectsGridLayout {
/// iPhone / iPad 退
static let singleColumn = 1
/// iPad 2 sheet ~440540pt
static let twoColumnMinWidth: CGFloat = 400
/// iPad 3 /
static let threeColumnMinWidth: CGFloat = 760
/// iPhone`.phone`/ `singleColumn` List
/// iPad`.pad` **** 13 `>= 1`
/// 0
static func columnCount(
availableWidth: CGFloat,
idiom: UIUserInterfaceIdiom
) -> Int {
guard idiom == .pad else { return singleColumn }
if availableWidth >= threeColumnMinWidth { return 3 }
if availableWidth >= twoColumnMinWidth { return 2 }
return singleColumn
}
/// iPad iPhone List
static func usesGrid(idiom: UIUserInterfaceIdiom) -> Bool {
idiom == .pad
}
}
/// T-iPad-4 · Projects sheet detents idiom
enum ProjectsSheetSizing {
/// iPhone `nil`** `.presentationDetents`** sheet
/// iPhone iPad `[.medium, .large]`
///
static func detents(idiom: UIUserInterfaceIdiom) -> Set<PresentationDetent>? {
idiom == .pad ? [.medium, .large] : nil
}
}
// MARK: - iPhone iPad detents modifier
/// `.presentationDetents` modifier nil `content`
/// iPhone sheet
private struct AdaptiveSheetDetents: ViewModifier {
let detents: Set<PresentationDetent>?
func body(content: Content) -> some View {
if let detents {
content.presentationDetents(detents)
} else {
content
}
}
}
extension View {
/// iPhone sheetiPad detents
func adaptiveProjectsSheetDetents(idiom: UIUserInterfaceIdiom) -> some View {
modifier(AdaptiveSheetDetents(detents: ProjectsSheetSizing.detents(idiom: idiom)))
}
}

View File

@@ -11,14 +11,26 @@ struct ProjectsScreen: View {
@Bindable var viewModel: ProjectsViewModel
/// "" AppCoordinator.openProject
var onOpen: (ProjectOpenRequest) -> Void = { _ in }
/// T-iPad-4 · idiom `ProjectsGridLayout`/
/// `ProjectsSheetSizing` iPhone List
/// iPad + sheet detentsidiom
/// @Environment `ProjectsLayout`
/// idiom size classiPad sheet compact
private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
private enum Metrics {
static let rowSpacing: CGFloat = 2
static let chipSpacing: CGFloat = 6
// T-iPad-4 · iPad.padiPhone
static let gridSectionSpacing: CGFloat = 12
static let gridPadding: CGFloat = 16
static let gridCardVerticalPadding: CGFloat = 8
static let gridCardHorizontalPadding: CGFloat = 10
static let gridCardCornerRadius: CGFloat = 10
}
var body: some View {
list
adaptiveContent
.navigationTitle(ProjectsCopy.title)
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $viewModel.searchText, prompt: ProjectsCopy.searchPrompt)
@@ -36,6 +48,22 @@ struct ProjectsScreen: View {
onOpenClaude: { viewModel.requestOpenClaude(cwd: $0) }
)
}
// T-iPad-4 · iPhone sheet iPad
// detents
.adaptiveProjectsSheetDetents(idiom: idiom)
}
// MARK: - idiom ProjectsGridLayout
/// iPhone `list`iPad
/// `gridList`///prefs `ProjectsViewModel`
///
@ViewBuilder private var adaptiveContent: some View {
if ProjectsGridLayout.usesGrid(idiom: idiom) {
gridList
} else {
list
}
}
// MARK: - List
@@ -61,6 +89,75 @@ struct ProjectsScreen: View {
}
}
// MARK: - Gridregular
/// iPad / `ProjectsGridLayout.columnCount`
/// ****GeometryReader/// List
/// builder`groupHeader`/`projectRow`/`errorRows`
/// //prefs
private var gridList: some View {
GeometryReader { proxy in
let columns = ProjectsGridLayout.columnCount(
availableWidth: proxy.size.width,
idiom: idiom
)
ScrollView {
LazyVStack(alignment: .leading, spacing: Metrics.gridSectionSpacing) {
errorRows
if let message = viewModel.emptyStateMessage {
Text(message)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
}
ForEach(viewModel.groups) { group in
gridSection(group, columns: columns)
}
}
.padding(Metrics.gridPadding)
}
.overlay {
if !viewModel.hasLoadedOnce && viewModel.fetchErrorMessage == nil {
ProgressView()
}
}
}
}
@ViewBuilder private func gridSection(_ group: ProjectGroup, columns: Int) -> some View {
let isCollapsed = viewModel.isCollapsed(group)
VStack(alignment: .leading, spacing: Metrics.rowSpacing) {
if group.kind != .flat {
groupHeader(group, isCollapsed: isCollapsed)
.padding(.top, Metrics.gridSectionSpacing)
}
if !isCollapsed {
LazyVGrid(
columns: gridColumns(columns),
alignment: .leading,
spacing: Metrics.chipSpacing
) {
ForEach(group.projects, id: \.path) { project in
projectRow(project, group: group)
.padding(.vertical, Metrics.gridCardVerticalPadding)
.padding(.horizontal, Metrics.gridCardHorizontalPadding)
.background(
.quaternary,
in: RoundedRectangle(cornerRadius: Metrics.gridCardCornerRadius)
)
}
}
}
}
}
/// `columns >= 1` `ProjectsGridLayout`
private func gridColumns(_ count: Int) -> [GridItem] {
Array(
repeating: GridItem(.flexible(), spacing: Metrics.chipSpacing),
count: max(count, ProjectsGridLayout.singleColumn)
)
}
/// prefs
@ViewBuilder private var errorRows: some View {
ForEach(

View File

@@ -1,3 +1,4 @@
import GameController
import SessionCore
import SwiftTerm
import SwiftUI
@@ -25,6 +26,16 @@ struct TerminalScreen: View {
/// `AppCoordinator.openNewSessionInCurrentCwd`nil =
/// / coordinator
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
/// T-iPad-3 · wiring
/// `APIClient.killSession` Origin G nil = kill
/// /线
var onKillSession: (@MainActor () -> Void)? = nil
/// T-iPad-3 · `GCKeyboard.coalesced != nil`
/// / `KeyBarVisibility`
@State private var hasHardwareKeyboard = GCKeyboard.coalesced != nil
/// T-iPad-3 · KeyBar nil =
@State private var keyBarUserOverride: Bool?
private enum Metrics {
static let bannerHorizontalPadding: CGFloat = 12
@@ -33,10 +44,25 @@ struct TerminalScreen: View {
private enum Copy {
static let newSessionInCwd = "在当前目录开新会话"
static let showKeyBar = "显示快捷键栏"
static let hideKeyBar = "隐藏快捷键栏"
}
/// KeyBar `KeyBarVisibility`
private var isKeyBarVisible: Bool {
KeyBarVisibility.isVisible(
hardwareKeyboardPresent: hasHardwareKeyboard,
userOverride: keyBarUserOverride
)
}
var body: some View {
TerminalHostView(viewModel: viewModel)
TerminalHostView(
viewModel: viewModel,
keyBarVisible: isKeyBarVisible,
onNewSessionInCwd: onNewSessionInCwd,
onKillSession: onKillSession
)
.ignoresSafeArea(.container, edges: .bottom)
.overlay(alignment: .top) {
if let model = viewModel.bannerModel {
@@ -47,10 +73,39 @@ struct TerminalScreen: View {
}
}
.animation(.default, value: viewModel.bannerModel)
.toolbar { newSessionToolbarItem }
.toolbar {
newSessionToolbarItem
keyBarToggleToolbarItem
}
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidConnect)) { _ in
hasHardwareKeyboard = true
}
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidDisconnect)) { _ in
hasHardwareKeyboard = GCKeyboard.coalesced != nil
}
.onAppear { viewModel.start() }
}
/// T-iPad-3 · KeyBar
/// iPhone
/// /
@ToolbarContentBuilder private var keyBarToggleToolbarItem: some ToolbarContent {
if hasHardwareKeyboard {
ToolbarItem(placement: .topBarTrailing) {
Button {
keyBarUserOverride = !isKeyBarVisible
} label: {
Label(
isKeyBarVisible ? Copy.hideKeyBar : Copy.showKeyBar,
systemImage: isKeyBarVisible
? "keyboard.chevron.compact.down" : "keyboard"
)
}
.accessibilityIdentifier("terminal.keyBarToggleButton")
}
}
}
/// Mirrors web `tabs.ts newTab()` (M6): the + affordance opens a fresh
/// session in the active session's cwd, if known.
@ToolbarContentBuilder private var newSessionToolbarItem: some ToolbarContent {
@@ -74,6 +129,11 @@ struct TerminalScreen: View {
/// `KeyCommandTerminalView` subclass. Both route through the ViewModel.
private struct TerminalHostView: UIViewRepresentable {
let viewModel: TerminalViewModel
/// T-iPad-3 · KeyBar (`inputAccessoryView`) `KeyBarVisibility`
/// SwiftUI `updateUIView`
let keyBarVisible: Bool
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
var onKillSession: (@MainActor () -> Void)? = nil
func makeCoordinator() -> Coordinator {
Coordinator(viewModel: viewModel)
@@ -88,7 +148,14 @@ private struct TerminalHostView: UIViewRepresentable {
let keyBar = KeyBarView()
keyBar.onKey = { key in viewModel.send(key: key) }
terminal.inputAccessoryView = keyBar
terminal.installKeyBar(keyBar, visible: keyBarVisible)
// T-iPad-3 · / iPad iPhone
// SwiftTerm
terminal.installPointerContextMenuIfSupported(
onNewInCwd: onNewSessionInCwd,
onKill: onKillSession
)
// Output sink: buffered replay flushes now, live bytes follow.
// @MainActor-typed closure feeding off the main actor cannot compile.
@@ -100,7 +167,10 @@ private struct TerminalHostView: UIViewRepresentable {
func updateUIView(_ uiView: KeyCommandTerminalView, context: Context) {
// State-driven UI lives in SwiftUI (banner overlay); the terminal view
// itself is driven by the sink/delegate, nothing to push here.
// itself is driven by the sink/delegate. The only push is the KeyBar
// visibility (hardware-keyboard aware / user toggle) a no-op unless it
// actually changed, so iPhone (always-visible) never reloads input views.
uiView.setKeyBarVisible(keyBarVisible)
}
/// SwiftTerm's delegate is a pre-concurrency protocol; the conformance is
@@ -164,6 +234,14 @@ final class KeyCommandTerminalView: TerminalView {
/// Chord outlet; the screen routes it to `TerminalViewModel.send(key:)`.
var onKeyCommand: (@MainActor (KeyByteMap.Key) -> Void)?
/// Retained KeyBar so visibility can toggle it in/out of `inputAccessoryView`
/// (T-iPad-3). The last-applied value avoids reloading input views when the
/// visibility did not change (iPhone stays byte-identical).
private var keyBar: KeyBarView?
private var appliedKeyBarVisible = true
/// Retained so the interaction's delegate outlives menu presentation.
private var contextMenuDelegate: TerminalContextMenuInteractionDelegate?
override var keyCommands: [UIKeyCommand]? {
(super.keyCommands ?? [])
+ HardwareKeyCommands.build(action: #selector(runHardwareKeyCommand(_:)))
@@ -173,4 +251,64 @@ final class KeyCommandTerminalView: TerminalView {
guard let key = HardwareKeyCommands.key(matching: sender) else { return }
onKeyCommand?(key)
}
// MARK: - KeyBar install / visibility (T-iPad-3)
/// Install the KeyBar as `inputAccessoryView`, honoring the initial
/// visibility (hidden when a hardware keyboard makes it redundant).
func installKeyBar(_ bar: KeyBarView, visible: Bool) {
keyBar = bar
appliedKeyBarVisible = visible
inputAccessoryView = visible ? bar : nil
}
/// Apply a visibility change; a no-op when unchanged so no needless
/// `reloadInputViews()` (the KeyByteMap routing on `keyBar.onKey` is
/// untouched the same bar is only detached/re-attached).
func setKeyBarVisible(_ visible: Bool) {
guard visible != appliedKeyBarVisible else { return }
appliedKeyBarVisible = visible
inputAccessoryView = visible ? keyBar : nil
reloadInputViews()
}
// MARK: - Pointer context menu (T-iPad-3, iPad only)
/// Install the secondary-click / long-press context menu iPad only, so
/// iPhone long-press keeps SwiftTerm's native selection gesture (zero
/// regression). The delegate builds a fresh model per presentation so the
/// copy item reflects the live selection.
func installPointerContextMenuIfSupported(
onNewInCwd: (@MainActor () -> Void)?,
onKill: (@MainActor () -> Void)?
) {
// `UIDevice.current.userInterfaceIdiom` (not `traitCollection`, which can
// be `.unspecified` before the view joins a window at makeUIView time).
guard TerminalContextMenu.isPointerMenuEnabled(
idiom: UIDevice.current.userInterfaceIdiom
) else { return }
let delegate = TerminalContextMenuInteractionDelegate { [weak self] in
TerminalContextMenuModel(
onNewInCwd: onNewInCwd,
onKill: onKill,
onCopySelection: { [weak self] in self?.copySelectionToPasteboard() },
hasSelection: { [weak self] in self?.hasActiveSelection ?? false }
)
}
contextMenuDelegate = delegate
addInteraction(UIContextMenuInteraction(delegate: delegate))
}
/// Whether a selection exists reuses SwiftTerm's own `copy` eligibility
/// (`canPerformAction` returns `selection.active`); pure read, no bytes.
var hasActiveSelection: Bool {
canPerformAction(#selector(UIResponderStandardEditActions.copy(_:)), withSender: nil)
}
/// Copy the current selection via SwiftTerm's own `copy(_:)` (selection
/// `UIPasteboard`). Pure UI: it never writes to the PTY, so the byte stream
/// is untouched (invariant preserved same as pointer hover highlight).
func copySelectionToPasteboard() {
copy(nil)
}
}

View File

@@ -0,0 +1,110 @@
import SwiftUI
/// T-iPad-2 · size-class `LayoutPolicy.mode` compact
/// iPhone / iPad Slide Over `StackRootView`
/// regular iPad / / Stage Manager `SplitRootView`
///
///
/// **线**PLAN_IOS_IPAD §5 T-iPad-2
/// - ZStack **** split detail
/// `scenePhase != .active` `PrivacyShadePolicy`
/// split detail stack push
/// - `.task` bootstrap / `.onChange(scenePhase)` / `.onOpenURL` / deep-link
/// alert / add-host sheet / Projects sheet `RootView`
/// compact
///
/// size class ****iPad Slide Over regularcompact
/// UI `terminalController`
/// `AppCoordinator` T-iOS-29 `.id`
///
struct AdaptiveRootView: View {
@Bindable var coordinator: AppCoordinator
@Environment(\.scenePhase) private var scenePhase
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
ZStack {
layoutBranch
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
}
}
// MARK: - Layout branch (the SOLE size-class consumer)
@ViewBuilder private var layoutBranch: some View {
// Split only makes sense once we're in the session list. `.loading` and
// `.pairing` (genuine iPad first-run, no paired host) get the full-screen
// stack flow regardless of size class a split sidebar has nothing to
// list yet and would strand the user on the not-paired empty state
// (T-iPad-5 finding). Route-gate the split branch.
switch LayoutPolicy.mode(horizontalSizeClass: horizontalSizeClass) {
case .split where coordinator.route == .sessions:
SplitRootView(coordinator: coordinator)
default:
StackRootView(coordinator: coordinator)
}
}
// MARK: - Deep-link hint alert (T-iOS-22)
/// unknown host / store failure `RootView`
private var deepLinkHintBinding: Binding<Bool> {
Binding(
get: { coordinator.deepLink.hintMessage != nil },
set: { presented in
guard !presented else { return }
coordinator.deepLink.clearHint()
}
)
}
// 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)
}
}
}
}
}

View File

@@ -170,6 +170,62 @@ final class AppCoordinator {
terminalController = nil
}
/// T-iPad-3 · kill
/// `SessionListViewModel.kill` `APIClient.killSession` Origin
/// detach detail adopted no-op
func killCurrentSession() {
guard let sessionId = terminalController?.terminalViewModel.sessionId else { return }
Task { await sessionList.kill(sessionId: sessionId) }
closeTerminal()
}
// MARK: - Split-view sidebar bridge (T-iPad-2)
/// Detail sidebar getter
/// **adopted** adopted / detail nil
/// stack `terminalController` coordinator
///
var selectedSidebarItem: SidebarItem? {
terminalController?.terminalViewModel.sessionId.map(SidebarItem.session)
}
/// `NavigationSplitView` getter detail setter
/// `open` / `presentProjects`
/// set nil detail
var sidebarSelection: Binding<SidebarItem?> {
Binding(
get: { self.selectedSidebarItem },
set: { item in
guard let item else { return }
self.selectSidebarItem(item)
}
)
}
/// sidebar **** open(id) / open(nil) /
/// presentProjects API沿 WS closeopen
/// new-in-cwd / deep-link `.projects` sheet
func selectSidebarItem(_ item: SidebarItem) {
switch item {
case .session(let sessionId):
switchTerminal(sessionId: sessionId)
case .newSession:
switchTerminal(sessionId: nil)
case .projects:
presentProjects()
}
}
/// WS no-op churn
/// `closeTerminal()` last-seen engine detach
/// `open()`**** activeHost no-op
private func switchTerminal(sessionId: UUID?) {
guard let host = sessionList.activeHost else { return }
if let sessionId, selectedSidebarItem == .session(sessionId) { return }
if terminalController != nil { closeTerminal() }
open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId))
}
// MARK: - "" (T-iOS-29)
/// cwd/live-sessions server-adopted
@@ -235,3 +291,12 @@ final class AppCoordinator {
PairingViewModel(store: environment.hostStore, probe: environment.probe)
}
}
/// T-iPad-2 · split sidebar `AppCoordinator`
/// `.session(id)` == `open(id)``.newSession` == `open(nil)`
/// `.projects` == `presentProjects()`
enum SidebarItem: Hashable {
case session(UUID)
case newSession
case projects
}

View File

@@ -0,0 +1,22 @@
import SwiftUI
/// T-iPad-2 ·
public enum LayoutMode: Equatable {
/// iPhone `NavigationStack` push
case stack
/// iPad `NavigationSplitView`sidebar + detail
case split
}
/// T-iPad-2 · **** size-class 仿 `PrivacyShadePolicy`
/// `if sizeClass == `PLAN_IOS_IPAD §4
/// 100%
///
/// regular iPad / / Stage Manager `.split`
/// compact niliPhone / iPad Slide Over / / size class
/// `.stack`nil 退退 iPhone
public enum LayoutPolicy {
public static func mode(horizontalSizeClass: UserInterfaceSizeClass?) -> LayoutMode {
horizontalSizeClass == .regular ? .split : .stack
}
}

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 {
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
}
}
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<Bool> {
Binding(
get: { coordinator.deepLink.hintMessage != nil },
set: { presented in
guard !presented else { return }
coordinator.deepLink.clearHint()
}
)
}
// 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 = "项目"
}

View File

@@ -0,0 +1,110 @@
import SwiftUI
/// T-iPad-2 · regular iPad / / Stage Manager
/// sidebar = `SessionListScreen` **** +
/// stack leading detail = `TerminalContainerView`
/// + gate/digest
///
///
/// - **detail `TerminalContainerView`** push destination
/// split detail PLAN_IOS_IPAD §1
/// - ** `AppCoordinator` **sidebar
/// `selectSidebarItem` / `open` stack
/// - **detail `.id(controller.id)`** controller
/// SwiftTerm ring bufferidentity T-iOS-29
/// - **** `AdaptiveRootView` ZStack
/// detail `scenePhase != .active`
struct SplitRootView: View {
@Bindable var coordinator: AppCoordinator
var body: some View {
NavigationSplitView {
sidebar
} detail: {
detail
}
}
// MARK: - Sidebar + SessionListScreen
private var sidebar: some View {
SessionListScreen(
viewModel: coordinator.sessionList,
onOpen: { request in
// selectSidebarItem
coordinator.selectSidebarItem(sidebarItem(for: request))
},
onAddHost: { coordinator.presentAddHost() }
)
.safeAreaInset(edge: .bottom) { continueLastBanner }
.toolbar { projectsToolbarItem }
}
/// `StackRootView.continueLastBanner` 5
/// sidebar iPad T-iPad-5 finding
@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)
}
}
/// `OpenRequest` sidebar sessionId = /
private func sidebarItem(for request: SessionListViewModel.OpenRequest) -> SidebarItem {
request.sessionId.map(SidebarItem.session) ?? .newSession
}
/// `StackRootView.projectsToolbarItem` leading disabled
/// `presentProjects` iPad Projects sheet
/// T-iPad-4
@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: - Detail
@ViewBuilder private var detail: some View {
if let controller = coordinator.terminalController {
NavigationStack {
TerminalContainerView(
controller: controller,
onNewSessionInCwd: { coordinator.openNewSessionInCurrentCwd() },
onKillSession: { coordinator.killCurrentSession() } // T-iPad-3
)
.id(controller.id) // T-iOS-29 · identity per controller
}
} else {
placeholder
}
}
private var placeholder: some View {
ContentUnavailableView {
Label(SplitCopy.placeholderTitle, systemImage: "sidebar.left")
} description: {
Text(SplitCopy.placeholderHint)
}
}
}
/// split `RootCopy.projects`DRY
private enum SplitCopy {
static let placeholderTitle = "选择或新建会话"
static let placeholderHint = "从左侧选择一个运行中的会话,或新建一个开始工作。"
}

View File

@@ -22,6 +22,10 @@ struct TerminalContainerView: View {
/// T-iOS-29 · pass-through to TerminalScreen's toolbar/exit-banner
/// "" action (RootView supplies the coordinator hop).
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
/// T-iPad-3 · pass-through to TerminalScreen's pointer context-menu
/// "" action (root layers supply the coordinator hop). nil on
/// surfaces without a kill affordance (e.g. previews).
var onKillSession: (@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?
@@ -48,7 +52,8 @@ struct TerminalContainerView: View {
var body: some View {
TerminalScreen(
viewModel: controller.terminalViewModel,
onNewSessionInCwd: onNewSessionInCwd
onNewSessionInCwd: onNewSessionInCwd,
onKillSession: onKillSession
)
.id(controller.generation)
.navigationBarTitleDisplayMode(.inline)

View File

@@ -0,0 +1,33 @@
import Testing
@testable import WebTerm
/// T-iPad-3 · KeyBar KeyBar
/// `inputAccessoryView` iPhone
/// `userOverride`
///
@Suite("KeyBarVisibility (T-iPad-3)")
struct KeyBarVisibilityTests {
// MARK: -
@Test("硬件键盘在场 + 无用户覆盖 → 隐藏(软 KeyBar 冗余)")
func hardwarePresentAutoHides() {
#expect(KeyBarVisibility.isVisible(hardwareKeyboardPresent: true, userOverride: nil) == false)
}
@Test("无硬件键盘 + 无用户覆盖 → 显示iPhone 默认,零回归)")
func noHardwareAutoShows() {
#expect(KeyBarVisibility.isVisible(hardwareKeyboardPresent: false, userOverride: nil) == true)
}
// MARK: -
@Test("用户强制显示压过硬件在场的自动隐藏")
func userForceShowWinsOverHardware() {
#expect(KeyBarVisibility.isVisible(hardwareKeyboardPresent: true, userOverride: true) == true)
}
@Test("用户强制隐藏压过无硬件的自动显示")
func userForceHideWinsOverNoHardware() {
#expect(KeyBarVisibility.isVisible(hardwareKeyboardPresent: false, userOverride: false) == false)
}
}

View File

@@ -0,0 +1,28 @@
import SwiftUI
import Testing
@testable import WebTerm
/// T-iPad-2 · size-class 仿 `PrivacyShadePolicy`
/// regular sidebar+detailcompact/
/// iPhone stack ****
/// `if sizeClass == `PLAN_IOS_IPAD §4
///
/// `UserInterfaceSizeClass` .regular / .compact / nil
/// size class 退退
@Suite("LayoutPolicy (T-iPad-2)")
struct LayoutPolicyTests {
@Test("regular 宽度 → splitiPad 全屏/大分屏sidebar + detail")
func regularIsSplit() {
#expect(LayoutPolicy.mode(horizontalSizeClass: .regular) == .split)
}
@Test("compact 宽度 → stackiPhone / iPad Slide Over字节级复用现有路径")
func compactIsStack() {
#expect(LayoutPolicy.mode(horizontalSizeClass: .compact) == .stack)
}
@Test("nilsize class 未定)→ stack最保守退现有 iPhone 路径)")
func nilIsStack() {
#expect(LayoutPolicy.mode(horizontalSizeClass: nil) == .stack)
}
}

View File

@@ -0,0 +1,75 @@
import SwiftUI
import Testing
import UIKit
@testable import WebTerm
/// T-iPad-4 · Projects ** idiom + **
/// 仿 `LayoutPolicy` `ProjectsLayout` idiom
/// size classiPad Projects sheet compact iPhone
/// size class iPhone
///
/// iPhone `.phone`
/// 1 Listsheet detents nil =
/// sheet`.pad`
@Suite("ProjectsGridLayout column count (T-iPad-4)")
struct ProjectsGridLayoutTests {
// MARK: - iPhone 1 /
@Test(".phone → 恒 1 列,宽度无关(含横屏宽尺寸,现有单列 List 原样复用)")
func phoneIsAlwaysSingleColumn() {
#expect(ProjectsGridLayout.columnCount(availableWidth: 320, idiom: .phone) == 1)
// iPhone ~852
#expect(ProjectsGridLayout.columnCount(availableWidth: 852, idiom: .phone) == 1)
#expect(ProjectsGridLayout.columnCount(availableWidth: 2000, idiom: .phone) == 1)
}
@Test(".phone 不走网格;.pad 走网格")
func usesGridOnlyOnPad() {
#expect(ProjectsGridLayout.usesGrid(idiom: .phone) == false)
#expect(ProjectsGridLayout.usesGrid(idiom: .pad) == true)
}
// MARK: - iPad 1/2/3
@Test(".pad 且窄于两列阈值 → 1 列(极窄 iPad 多任务,绝不挤压)")
func padNarrowIsSingleColumn() {
#expect(ProjectsGridLayout.columnCount(
availableWidth: ProjectsGridLayout.twoColumnMinWidth - 1, idiom: .pad) == 1)
}
@Test(".pad 且在 [两列阈值, 三列阈值) → 2 列(表单 sheet 内宽即此档)")
func padMediumIsTwoColumns() {
#expect(ProjectsGridLayout.columnCount(
availableWidth: ProjectsGridLayout.twoColumnMinWidth, idiom: .pad) == 2)
#expect(ProjectsGridLayout.columnCount(
availableWidth: ProjectsGridLayout.threeColumnMinWidth - 1, idiom: .pad) == 2)
}
@Test(".pad 且 >= 三列阈值 → 3 列(更宽面板/横屏全宽)")
func padWideIsThreeColumns() {
#expect(ProjectsGridLayout.columnCount(
availableWidth: ProjectsGridLayout.threeColumnMinWidth, idiom: .pad) == 3)
#expect(ProjectsGridLayout.columnCount(availableWidth: 1400, idiom: .pad) == 3)
}
@Test("列数恒 >= 1任何宽度都不返回 0/负,防空网格崩溃)")
func columnCountIsAlwaysPositive() {
for width in stride(from: CGFloat(0), through: 2000, by: 137) {
#expect(ProjectsGridLayout.columnCount(availableWidth: width, idiom: .pad) >= 1)
#expect(ProjectsGridLayout.columnCount(availableWidth: width, idiom: .phone) == 1)
}
}
}
@Suite("ProjectsSheetSizing (T-iPad-4)")
struct ProjectsSheetSizingTests {
@Test(".phone → nil不加 detent 修饰符 = 现有默认全高 sheet字节级不变")
func phoneIsNil() {
#expect(ProjectsSheetSizing.detents(idiom: .phone) == nil)
}
@Test(".pad → [.medium, .large]iPad 卡片式,不铺满大屏;仍可上拉到大)")
func padIsCardDetents() {
#expect(ProjectsSheetSizing.detents(idiom: .pad) == [.medium, .large])
}
}

View File

@@ -0,0 +1,305 @@
import Foundation
import HostRegistry
import SessionCore
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// T-iPad-2 · split sidebar `AppCoordinator` **
/// ** UI
/// `open(id)` / `open(nil)` / `presentProjects()`
///
/// - `.session(id)` == `open(id)` `attach(sessionId:id, cwd:nil)`
/// - `.newSession` == `open(nil)` `attach(sessionId:nil, cwd:nil)`
/// - `.projects` == Projects `isProjectsPresented`
///
/// /`selectedSidebarItem`
/// adopted = `.session(adoptedId)` nil沿 WS
/// closeopen `closeTerminal` last-seen + `open`****
///
/// `SessionEngine`/`AppCoordinator` over FakeTransport/FakeHTTPTransport
/// openTask + waitUntilProcessed NewSessionInCwdTests
@MainActor
@Suite("SidebarSelection (T-iPad-2)")
struct SidebarSelectionTests {
private nonisolated static let base = "http://192.168.1.5:3000"
// MARK: - FixturesNewSessionInCwdTests / SessionSwitcherTests
private struct Fixture {
let transport: FakeTransport
let http: FakeHTTPTransport
let host: HostRegistry.Host
let unreadStore: InMemoryUnreadWatermarkStore
let environment: AppEnvironment
}
private func makeFixture(suiteName: String) throws -> Fixture {
let baseURL = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let transport = FakeTransport()
let http = FakeHTTPTransport()
let unreadStore = InMemoryUnreadWatermarkStore()
let host = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
let defaults = try #require(UserDefaults(suiteName: suiteName))
return Fixture(
transport: transport,
http: http,
host: host,
unreadStore: unreadStore,
environment: AppEnvironment(
hostStore: InMemoryHostStore(hosts: [host]),
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
http: http,
termTransport: transport,
probe: { _ in .failure(.timeout) },
unreadStore: unreadStore
)
)
}
/// activeHost 线sidebar
private func primeHost(_ coordinator: AppCoordinator) async {
await coordinator.sessionList.reloadHosts()
}
/// attach adoptedopenTask 3
private func adopt(
_ controller: TerminalSessionController,
transport: FakeTransport,
sessionId: UUID
) async {
await controller.openTask?.value
await transport.emit(
frame: #"{"type":"attached","sessionId":"\#(sessionId.uuidString.lowercased())"}"#
)
await controller.terminalViewModel.waitUntilProcessed(eventCount: 3)
}
/// controller open
private func awaitAttachSettled(_ controller: TerminalSessionController) async {
await controller.openTask?.value
await controller.terminalViewModel.waitUntilProcessed(eventCount: 2)
}
// MARK: - 1. SidebarItem coordinator API
@Test("选 .session(id) == 现有 open(id):首帧 attach(sessionId:id, cwd:nil),单一连接")
func sessionItemOpensSameAsOpenId() async throws {
// Arrange
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.sessionItem")
let coordinator = AppCoordinator(environment: fixture.environment)
await primeHost(coordinator)
let sessionId = UUID()
// Actsidebar split
coordinator.selectSidebarItem(.session(sessionId))
// Assert open startTerminal id attach
let controller = try #require(coordinator.terminalController)
await awaitAttachSettled(controller)
let byConnection = await fixture.transport.sentFramesByConnection
#expect(byConnection.count == 1) // engine
#expect(byConnection[0] == [MessageCodec.encode(.attach(sessionId: sessionId, cwd: nil))])
controller.teardown()
}
@Test("选 .newSession == 现有 open(nil):首帧 attach(sessionId:nil, cwd:nil)")
func newSessionItemOpensFreshSession() async throws {
// Arrange
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.newSession")
let coordinator = AppCoordinator(environment: fixture.environment)
await primeHost(coordinator)
// Act
coordinator.selectSidebarItem(.newSession)
// Assert
let controller = try #require(coordinator.terminalController)
await awaitAttachSettled(controller)
let byConnection = await fixture.transport.sentFramesByConnection
#expect(byConnection.count == 1)
#expect(byConnection[0] == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))])
controller.teardown()
}
@Test("选 .projects == 现有 Projects 呈现:置位 isProjectsPresented不开终端")
func projectsItemPresentsProjects() async throws {
// Arrange
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.projects")
let coordinator = AppCoordinator(environment: fixture.environment)
await primeHost(coordinator)
// Act
coordinator.selectSidebarItem(.projects)
// Assert presentProjects terminalController
#expect(coordinator.isProjectsPresented)
#expect(coordinator.projectsViewModel != nil)
#expect(coordinator.terminalController == nil)
let attempts = await fixture.transport.connectAttempts
#expect(attempts.isEmpty)
}
@Test("无 activeHost → .session/.newSession 均 no-op不 spawn、不连接")
func withoutActiveHostIsNoOp() async throws {
// Arrange reloadHosts activeHost nil
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.noHost")
let coordinator = AppCoordinator(environment: fixture.environment)
// Act
coordinator.selectSidebarItem(.session(UUID()))
coordinator.selectSidebarItem(.newSession)
// Assert
#expect(coordinator.terminalController == nil)
let attempts = await fixture.transport.connectAttempts
#expect(attempts.isEmpty)
}
// MARK: - 2. selectedSidebarItem /
@Test("打开终端 → selectedSidebarItem = .session(adoptedId)closeTerminal → 归 nil")
func selectionReflectsOpenAndClose() async throws {
// Arrange
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.reflect")
let coordinator = AppCoordinator(environment: fixture.environment)
await primeHost(coordinator)
let adopted = UUID()
//
#expect(coordinator.selectedSidebarItem == nil)
// Act adopted
coordinator.selectSidebarItem(.newSession)
let controller = try #require(coordinator.terminalController)
await adopt(controller, transport: fixture.transport, sessionId: adopted)
// Assert adopted detail
#expect(coordinator.selectedSidebarItem == .session(adopted))
// Act detail
coordinator.closeTerminal()
// Assert detail
#expect(coordinator.selectedSidebarItem == nil)
}
@Test("killCurrentSession有 adopted 会话 → detach detail归 nil无会话 → no-op")
func killCurrentSessionClosesDetail() async throws {
// Arrange
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.kill")
let coordinator = AppCoordinator(environment: fixture.environment)
await primeHost(coordinator)
// kill = no-opdetail
coordinator.killCurrentSession()
#expect(coordinator.terminalController == nil)
// Act adopt kill
coordinator.selectSidebarItem(.newSession)
let controller = try #require(coordinator.terminalController)
await adopt(controller, transport: fixture.transport, sessionId: UUID())
coordinator.killCurrentSession()
// Assertdetail detachkill SessionListViewModel.kill
// APIClient.killSession Origin T-iOS-8
#expect(coordinator.terminalController == nil)
#expect(coordinator.selectedSidebarItem == nil)
}
@Test("sidebarSelection 绑定 get/set 往返set(.newSession) 触发打开get 反映 adopted")
func bindingRoundTrips() async throws {
// Arrange
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.binding")
let coordinator = AppCoordinator(environment: fixture.environment)
await primeHost(coordinator)
let adopted = UUID()
let binding = coordinator.sidebarSelection
// Act setter == NavigationSplitView
binding.wrappedValue = .newSession
let controller = try #require(coordinator.terminalController)
await adopt(controller, transport: fixture.transport, sessionId: adopted)
// Assertgetter adopted
#expect(binding.wrappedValue == .session(adopted))
controller.teardown()
}
// MARK: - 3. closeopen
@Test("sidebar 选另一会话 → closeTerminal(记 last-seen)+open新 controller、第二连接 attach B")
func switchingSessionsReusesCloseThenOpen() async throws {
// Arrange A adopted
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.switch")
let coordinator = AppCoordinator(environment: fixture.environment)
await primeHost(coordinator)
let sessionA = UUID()
let sessionB = UUID()
coordinator.selectSidebarItem(.session(sessionA))
let first = try #require(coordinator.terminalController)
await adopt(first, transport: fixture.transport, sessionId: sessionA)
// Actsidebar Bdetail
coordinator.selectSidebarItem(.session(sessionB))
// Assert WS close(A)open(B) controller attach B
let second = try #require(coordinator.terminalController)
#expect(second !== first)
await awaitAttachSettled(second)
let byConnection = await fixture.transport.sentFramesByConnection
#expect(byConnection.count == 2)
#expect(byConnection[1] == [MessageCodec.encode(.attach(sessionId: sessionB, cwd: nil))])
// closeTerminal A last-seen
#expect(fixture.unreadStore.snapshot[sessionA] != nil)
second.teardown()
}
@Test("重复选中当前 detail 会话 → no-op不 close/open无第二连接")
func reselectingCurrentSessionIsNoOp() async throws {
// Arrange
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.reselect")
let coordinator = AppCoordinator(environment: fixture.environment)
await primeHost(coordinator)
let sessionA = UUID()
coordinator.selectSidebarItem(.session(sessionA))
let first = try #require(coordinator.terminalController)
await adopt(first, transport: fixture.transport, sessionId: sessionA)
// Act
coordinator.selectSidebarItem(.session(sessionA))
// Assertcontroller churn
#expect(coordinator.terminalController === first)
let attempts = await fixture.transport.connectAttempts
#expect(attempts.count == 1)
first.teardown()
}
// MARK: - 4. T-iOS-29 .id
@Test("regular→compact 切换Slide Overcontroller 归 coordinator 所有,实例不变、选中态保持")
func controllerIdentityStableAcrossLayoutSwitch() async throws {
// Arrange adopted
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.identity")
let coordinator = AppCoordinator(environment: fixture.environment)
await primeHost(coordinator)
let adopted = UUID()
coordinator.selectSidebarItem(.newSession)
let captured = try #require(coordinator.terminalController)
await adopt(captured, transport: fixture.transport, sessionId: adopted)
// Act iPad / Slide Over AdaptiveRootView
// splitstack coordinator
#expect(LayoutPolicy.mode(horizontalSizeClass: .regular) == .split)
#expect(LayoutPolicy.mode(horizontalSizeClass: .compact) == .stack)
// Assertcontroller T-iOS-29 .id(controller.id)
// controller
#expect(coordinator.terminalController === captured)
#expect(coordinator.selectedSidebarItem == .session(adopted))
captured.teardown()
}
}

View File

@@ -0,0 +1,139 @@
import APIClient
import Foundation
import TestSupport
import Testing
import UIKit
import WireProtocol
@testable import WebTerm
/// T-iPad-3 · /****
///
/// - `.newInCwd` T-iOS-29 `onNewInCwd` cwd fresh spawn
/// - `.kill` `onKill`wiring `APIClient.killSession` Origin
/// G RO/G
/// - `.copySelection` SwiftTerm copy
@MainActor
@Suite("TerminalContextMenu (T-iPad-3)")
struct TerminalContextMenuTests {
private nonisolated static let base = "http://192.168.1.5:3000"
/// perform
@MainActor
private final class ChannelRecorder {
var newInCwd = 0
var kill = 0
var copy = 0
}
/// onKill fire-and-forget Task await
@MainActor
private final class TaskBox {
var task: Task<Void, Never>?
}
private func endpoint() throws -> HostEndpoint {
let url = try #require(URL(string: Self.base))
return try #require(HostEndpoint(baseURL: url))
}
private func deleteURL(id: UUID) throws -> URL {
try #require(URL(string: "\(Self.base)/live-sessions/\(id.uuidString.lowercased())"))
}
// MARK: - 1. iPad iPhone SwiftTerm
@Test("isPointerMenuEnablediPad 开、iPhone 关idiom 与 size class 正交)")
func pointerMenuEnabledOnlyOnPad() {
#expect(TerminalContextMenu.isPointerMenuEnabled(idiom: .pad))
#expect(!TerminalContextMenu.isPointerMenuEnabled(idiom: .phone))
}
// MARK: - 2.
@Test("三项全可用 → 复制选区、开新会话、结束会话固定顺序kill 破坏性)")
func itemsAllAvailableInOrder() {
let items = TerminalContextMenu.items(canCopySelection: true, canNewInCwd: true, canKill: true)
#expect(items.map(\.action) == [.copySelection, .newInCwd, .kill])
#expect(items.map(\.title) == [
TerminalContextMenu.Copy.copySelection,
TerminalContextMenu.Copy.newInCwd,
TerminalContextMenu.Copy.kill,
])
#expect(items.first { $0.action == .kill }?.isDestructive == true)
}
@Test("无选区 → 隐藏复制选区;无 kill 通道 → 隐藏结束会话")
func itemsFilteredByAvailability() {
let noCopy = TerminalContextMenu.items(canCopySelection: false, canNewInCwd: true, canKill: true)
#expect(!noCopy.contains { $0.action == .copySelection })
let noKill = TerminalContextMenu.items(canCopySelection: true, canNewInCwd: true, canKill: false)
#expect(!noKill.contains { $0.action == .kill })
let empty = TerminalContextMenu.items(canCopySelection: false, canNewInCwd: false, canKill: false)
#expect(empty.isEmpty)
}
// MARK: - 3. Model.items + perform
@Test("model.itemsonKill=nil → 无结束会话hasSelection=false → 无复制选区")
func modelItemsReflectInjectedAvailability() {
let model = TerminalContextMenuModel(
onNewInCwd: {},
onKill: nil,
onCopySelection: {},
hasSelection: { false }
)
#expect(model.items.map(\.action) == [.newInCwd])
}
@Test("perform 把每个动作路由到对应既有闭包(不误触其它通道)")
func performRoutesToExistingChannels() {
let recorder = ChannelRecorder()
let model = TerminalContextMenuModel(
onNewInCwd: { recorder.newInCwd += 1 },
onKill: { recorder.kill += 1 },
onCopySelection: { recorder.copy += 1 },
hasSelection: { true }
)
model.perform(.copySelection)
#expect((recorder.copy, recorder.newInCwd, recorder.kill) == (1, 0, 0))
model.perform(.newInCwd)
#expect((recorder.copy, recorder.newInCwd, recorder.kill) == (1, 1, 0))
model.perform(.kill)
#expect((recorder.copy, recorder.newInCwd, recorder.kill) == (1, 1, 1))
}
// MARK: - 4. kill APIClient.killSession Origin
@Test("kill 动作 → 唯一一条 DELETE /live-sessions/:id带精确 Origin复用 APIClient无绕过")
func killActionReusesAPIClientWithOrigin() async throws {
// Arrange APIClient over FakeHTTPTransport wiring kill
let endpoint = try endpoint()
let http = FakeHTTPTransport()
let sessionId = UUID()
await http.queueSuccess(method: "DELETE", url: try deleteURL(id: sessionId), status: 204)
let api = APIClient(endpoint: endpoint, http: http)
let box = TaskBox()
let model = TerminalContextMenuModel(
onNewInCwd: nil,
onKill: { box.task = Task { try? await api.killSession(id: sessionId) } },
onCopySelection: {},
hasSelection: { false }
)
// Act kill
model.perform(.kill)
await box.task?.value
// AssertDELETE kill OriginG
let requests = await http.recordedRequests
#expect(requests.count == 1)
let deleteRequest = requests.first { $0.httpMethod == "DELETE" }
#expect(deleteRequest?.url == (try deleteURL(id: sessionId)))
#expect(deleteRequest?.value(forHTTPHeaderField: "Origin") == endpoint.originHeader)
}
}

View File

@@ -0,0 +1,182 @@
import XCTest
/// T-iPad-4 · Projects UI ****iPad
/// vs iPhone List/detents
/// `ProjectsLayoutTests`
///
/// harness `HappyPathUITests` URL
/// `TEST_RUNNER_WEBTERM_SERVER_URL` runner
/// Projects `XCTAttachment(.keepAlways)`宿 .xcresult
///
/// iPadregular `NavigationSplitView` sidebar
/// iPhonecompact stack
@MainActor
final class ProjectsLayoutUITests: XCTestCase {
private enum Deadline {
static let launchSurface: TimeInterval = 30
static let ui: TimeInterval = 20
static let projects: TimeInterval = 25
static let poll: TimeInterval = 0.5
}
override func setUpWithError() throws {
continueAfterFailure = false
}
func testProjectsScreenScreenshot() throws {
let server = try serverBaseURL()
let isPad = UIDevice.current.userInterfaceIdiom == .pad
if isPad {
XCUIDevice.shared.orientation = .landscapeLeft
}
let app = XCUIApplication()
app.launch()
try revealSidebarIfNeeded(app, isPad: isPad)
try pairCurrentServer(app, server: server)
// leading identifier
let projectsButton = app.buttons["sessions.projectsButton"].firstMatch
XCTAssertTrue(
projectsButton.waitForExistence(timeout: Deadline.ui),
"项目 toolbar button not found (on-screen: \(visibleStaticTexts(app)))"
)
// host disabled
let hittableDeadline = Date().addingTimeInterval(Deadline.ui)
while !projectsButton.isHittable, Date() < hittableDeadline {
Thread.sleep(forTimeInterval: Deadline.poll)
}
projectsButton.tap()
// Projects sheet seeded
let projectsTitle = app.navigationBars["项目"].firstMatch
XCTAssertTrue(
projectsTitle.waitForExistence(timeout: Deadline.projects),
"Projects screen (项目) did not appear (on-screen: \(visibleStaticTexts(app)))"
)
// /projects +
Thread.sleep(forTimeInterval: 2)
let shot = XCTAttachment(screenshot: app.screenshot())
shot.name = isPad ? "ipad_projects_grid" : "iphone_projects_list"
shot.lifetime = .keepAlways
add(shot)
}
// MARK: - Sidebar reveal (iPad split, collapsed in)
private func revealSidebarIfNeeded(_ app: XCUIApplication, isPad: Bool) throws {
guard isPad else { return }
let projectsButton = app.buttons["sessions.projectsButton"].firstMatch
if projectsButton.waitForExistence(timeout: 3) { return }
// sidebar
let toggle = app.navigationBars.buttons.matching(
NSPredicate(format: "identifier CONTAINS[c] %@", "sidebar")
).firstMatch
if toggle.exists {
toggle.tap()
} else {
let edge = app.coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.4))
let inward = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.4))
edge.press(forDuration: 0.1, thenDragTo: inward)
}
_ = projectsButton.waitForExistence(timeout: Deadline.ui)
}
// MARK: - Pairing (tolerant, mirrors HappyPathUITests)
private func serverBaseURL() throws -> URL {
let raw = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"]
guard let raw, let url = URL(string: raw), url.host != nil else {
XCTFail("WEBTERM_SERVER_URL missing/invalid in runner env — pass TEST_RUNNER_WEBTERM_SERVER_URL")
throw NSError(domain: "ProjectsLayoutUITests", code: 1)
}
return url
}
private func pairCurrentServer(_ app: XCUIApplication, server: URL) throws {
let urlField = app.textFields["pairing.urlField"].firstMatch
let hostMenu = app.buttons["sessions.hostMenu"].firstMatch
let emptyAddHost = app.buttons["配对新主机"].firstMatch
let deadline = Date().addingTimeInterval(Deadline.launchSurface)
while Date() < deadline {
if urlField.exists {
try fillPairingForm(app, server: server)
return
}
if hostMenu.exists || emptyAddHost.exists {
if hostMenu.exists { hostMenu.tap() }
XCTAssertTrue(
emptyAddHost.waitForExistence(timeout: Deadline.ui),
"配对新主机 entry not found"
)
emptyAddHost.tap()
try fillPairingForm(app, server: server)
try selectJustPairedHost(app, server: server, hostMenu: hostMenu)
return
}
Thread.sleep(forTimeInterval: Deadline.poll)
}
XCTFail("neither PairingScreen nor session list appeared (on-screen: \(visibleStaticTexts(app)))")
}
private func fillPairingForm(_ app: XCUIApplication, server: URL) throws {
let urlField = app.textFields["pairing.urlField"].firstMatch
XCTAssertTrue(urlField.waitForExistence(timeout: Deadline.ui), "pairing URL field not found")
urlField.tap()
urlField.typeText(server.absoluteString)
app.buttons["pairing.submitButton"].firstMatch.tap()
let confirmButton = app.buttons["pairing.confirmButton"].firstMatch
XCTAssertTrue(
confirmButton.waitForExistence(timeout: Deadline.ui),
"pairing confirm page not reached (on-screen: \(visibleStaticTexts(app)))"
)
confirmButton.tap()
let pairingNavBar = app.navigationBars["配对主机"].firstMatch
let retryButton = app.buttons["重试"].firstMatch
let resolveDeadline = Date().addingTimeInterval(Deadline.ui)
while pairingNavBar.exists {
if retryButton.exists {
XCTFail("pairing probe FAILED (on-screen: \(visibleStaticTexts(app)))")
return
}
if Date() >= resolveDeadline {
XCTFail("pairing did not complete (on-screen: \(visibleStaticTexts(app)))")
return
}
Thread.sleep(forTimeInterval: Deadline.poll)
}
}
private func selectJustPairedHost(
_ app: XCUIApplication, server: URL, hostMenu: XCUIElement
) throws {
let hostLabel = server.host ?? "127.0.0.1"
XCTAssertTrue(hostMenu.waitForExistence(timeout: Deadline.ui), "host menu not visible")
let hittableDeadline = Date().addingTimeInterval(Deadline.ui)
while !hostMenu.isHittable, Date() < hittableDeadline {
Thread.sleep(forTimeInterval: Deadline.poll)
}
hostMenu.tap()
let rows = app.buttons.matching(
NSPredicate(format: "label == %@ AND identifier != %@", hostLabel, "sessions.hostMenu")
)
let rowDeadline = Date().addingTimeInterval(Deadline.ui)
while rows.count == 0, Date() < rowDeadline {
Thread.sleep(forTimeInterval: Deadline.poll)
}
guard let lastRow = rows.allElementsBoundByIndex.last else {
XCTFail("no host menu row labelled \(hostLabel)")
return
}
lastRow.tap()
}
private func visibleStaticTexts(_ app: XCUIApplication) -> String {
app.staticTexts.allElementsBoundByIndex.prefix(12).map(\.label).joined(separator: " | ")
}
}