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:
@@ -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+全量双证兼容。
|
||||
|
||||
@@ -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,
|
||||
|
||||
144
ios/App/WebTerm/Components/TerminalContextMenu.swift
Normal file
144
ios/App/WebTerm/Components/TerminalContextMenu.swift
Normal 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 constants,plan §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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
73
ios/App/WebTerm/Screens/ProjectsLayout.swift
Normal file
73
ios/App/WebTerm/Screens/ProjectsLayout.swift
Normal 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 横屏零回归」的前提下区分「iPhone」与「iPad 的
|
||||
/// Projects 表单 sheet」—— 只有设备 idiom 能。故 Projects 自身的多列/卡片
|
||||
/// 决策以 idiom+宽度为判据;根视图的 stack/split 决策仍由 `LayoutPolicy`
|
||||
/// (唯一 size-class 读取点)负责,两者正交、各自单点。
|
||||
enum ProjectsGridLayout {
|
||||
/// iPhone / 极窄 iPad 的回退列数(现有单列布局)。
|
||||
static let singleColumn = 1
|
||||
/// iPad 下升到 2 列的最小可用宽度(表单 sheet 内宽 ~440–540pt 即两列)。
|
||||
static let twoColumnMinWidth: CGFloat = 400
|
||||
/// iPad 下升到 3 列的最小可用宽度(更宽的面板/横屏全宽时)。
|
||||
static let threeColumnMinWidth: CGFloat = 760
|
||||
|
||||
/// iPhone(`.phone`,任何朝向/尺寸)→ 恒 `singleColumn`(现有单列 List,
|
||||
/// 字节级不变)。iPad(`.pad`)→ 按**可用宽度** 1–3 列。恒 `>= 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 透传(现有全高 sheet,字节级不变);iPad 套卡片式 detents。
|
||||
func adaptiveProjectsSheetDetents(idiom: UIUserInterfaceIdiom) -> some View {
|
||||
modifier(AdaptiveSheetDetents(detents: ProjectsSheetSizing.detents(idiom: idiom)))
|
||||
}
|
||||
}
|
||||
@@ -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 detents。idiom 是设备常量、
|
||||
/// 运行期不变,故非 @Environment 即可。见 `ProjectsLayout` 注释解释为何用
|
||||
/// idiom 而非 size class(iPad 表单 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(.pad)分支,iPhone 不受影响。
|
||||
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: - Grid(regular 宽度:多列网格)
|
||||
|
||||
/// 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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
110
ios/App/WebTerm/Wiring/AdaptiveRootView.swift
Normal file
110
ios/App/WebTerm/Wiring/AdaptiveRootView.swift
Normal 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 立刻 regular→compact),
|
||||
/// 所以这不是两套 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 不变式的 close→open(与
|
||||
/// 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
|
||||
}
|
||||
|
||||
22
ios/App/WebTerm/Wiring/LayoutMode.swift
Normal file
22
ios/App/WebTerm/Wiring/LayoutMode.swift
Normal 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 或 nil(iPhone / iPad Slide Over / 小分屏 / size class 未定)→
|
||||
/// `.stack`。nil 走最保守回退:退到现有已测的 iPhone 路径,绝不误判成分栏。
|
||||
public enum LayoutPolicy {
|
||||
public static func mode(horizontalSizeClass: UserInterfaceSizeClass?) -> LayoutMode {
|
||||
horizontalSizeClass == .regular ? .split : .stack
|
||||
}
|
||||
}
|
||||
@@ -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`(regular,iPad 分栏),并把
|
||||
/// 隐私遮罩 / 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 = "项目"
|
||||
}
|
||||
|
||||
110
ios/App/WebTerm/Wiring/SplitRootView.swift
Normal file
110
ios/App/WebTerm/Wiring/SplitRootView.swift
Normal 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 buffer),identity 稳定复用 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 = "从左侧选择一个运行中的会话,或新建一个开始工作。"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
33
ios/App/WebTermTests/KeyBarVisibilityTests.swift
Normal file
33
ios/App/WebTermTests/KeyBarVisibilityTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
28
ios/App/WebTermTests/LayoutPolicyTests.swift
Normal file
28
ios/App/WebTermTests/LayoutPolicyTests.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iPad-2 · 自适应根视图的唯一 size-class 决策点(仿 `PrivacyShadePolicy`
|
||||
/// 的纯谓词先例)。regular 宽度 → 分栏(sidebar+detail);compact/未知 →
|
||||
/// 现有 iPhone stack 路径。这是**唯一**判据 —— 视图里严禁散落
|
||||
/// `if sizeClass == …`(PLAN_IOS_IPAD §4)。
|
||||
///
|
||||
/// 覆盖 `UserInterfaceSizeClass` 的三种可能取值(.regular / .compact / nil),
|
||||
/// 后者是「size class 尚未确定」的最保守回退:退到现有已测路径。
|
||||
@Suite("LayoutPolicy (T-iPad-2)")
|
||||
struct LayoutPolicyTests {
|
||||
@Test("regular 宽度 → split(iPad 全屏/大分屏:sidebar + detail)")
|
||||
func regularIsSplit() {
|
||||
#expect(LayoutPolicy.mode(horizontalSizeClass: .regular) == .split)
|
||||
}
|
||||
|
||||
@Test("compact 宽度 → stack(iPhone / iPad Slide Over:字节级复用现有路径)")
|
||||
func compactIsStack() {
|
||||
#expect(LayoutPolicy.mode(horizontalSizeClass: .compact) == .stack)
|
||||
}
|
||||
|
||||
@Test("nil(size class 未定)→ stack(最保守:退现有 iPhone 路径)")
|
||||
func nilIsStack() {
|
||||
#expect(LayoutPolicy.mode(horizontalSizeClass: nil) == .stack)
|
||||
}
|
||||
}
|
||||
75
ios/App/WebTermTests/ProjectsLayoutTests.swift
Normal file
75
ios/App/WebTermTests/ProjectsLayoutTests.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iPad-4 · Projects 大屏化的纯布局决策单测。判据是**设备 idiom + 可用宽度**
|
||||
/// (单点,仿 `LayoutPolicy` 先例)——见 `ProjectsLayout` 注释解释为何用 idiom
|
||||
/// 而非 size class(iPad 的 Projects 表单 sheet 内部恒 compact,与 iPhone 横屏
|
||||
/// 无异,size class 无法在不回归 iPhone 的前提下区分二者)。
|
||||
///
|
||||
/// iPhone 零回归的可测护栏:`.phone` 分支必须与「现状」逐字节等价——列数恒
|
||||
/// 为 1(现有单列 List)、不走网格、sheet 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])
|
||||
}
|
||||
}
|
||||
305
ios/App/WebTermTests/SidebarSelectionTests.swift
Normal file
305
ios/App/WebTermTests/SidebarSelectionTests.swift
Normal 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 不变式
|
||||
/// 的 close→open(复用 `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: - Fixtures(NewSessionInCwdTests / 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 握手完成(服务器 adopted):openTask 完成 ∧ 前 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()
|
||||
|
||||
// Act:sidebar 选中一行 —— 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-op(不崩、detail 仍空)。
|
||||
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()
|
||||
|
||||
// Assert:detail 被 detach(kill 走既有 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)
|
||||
|
||||
// Assert:getter 反映 adopted 会话。
|
||||
#expect(binding.wrappedValue == .session(adopted))
|
||||
controller.teardown()
|
||||
}
|
||||
|
||||
// MARK: - 3. 切会话:close→open 复用(非平行生命周期)
|
||||
|
||||
@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)
|
||||
|
||||
// Act:sidebar 选中会话 B(detail 即时切换)。
|
||||
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))
|
||||
|
||||
// Assert:controller 不换、无第二连接(避免无谓 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 Over):controller 归 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 只换渲染的
|
||||
// 根子视图(split↔stack),会话状态归 coordinator 所有,与布局正交。
|
||||
#expect(LayoutPolicy.mode(horizontalSizeClass: .regular) == .split)
|
||||
#expect(LayoutPolicy.mode(horizontalSizeClass: .compact) == .stack)
|
||||
|
||||
// Assert:controller 实例不变(复用 T-iOS-29 的 .id(controller.id) 稳定性
|
||||
// 前提:controller 不因布局分支切换而重建)、选中态如常。
|
||||
#expect(coordinator.terminalController === captured)
|
||||
#expect(coordinator.selectedSidebarItem == .session(adopted))
|
||||
captured.teardown()
|
||||
}
|
||||
}
|
||||
139
ios/App/WebTermTests/TerminalContextMenuTests.swift
Normal file
139
ios/App/WebTermTests/TerminalContextMenuTests.swift
Normal 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("isPointerMenuEnabled:iPad 开、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.items:onKill=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
|
||||
|
||||
// Assert:恰好一条请求,DELETE 到 kill 路由,带精确 Origin(G 铁律)。
|
||||
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)
|
||||
}
|
||||
}
|
||||
182
ios/App/WebTermUITests/ProjectsLayoutUITests.swift
Normal file
182
ios/App/WebTermUITests/ProjectsLayoutUITests.swift
Normal 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 提取)。
|
||||
///
|
||||
/// iPad(regular)用横屏,让 `NavigationSplitView` 的 sidebar(含「项目」入口)
|
||||
/// 常驻可见;iPhone(compact)保持竖屏走现有 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: " | ")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user