From 823432b1c8b7841df352eca7363a85b5e652cfbe Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sun, 5 Jul 2026 19:58:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(ipad):=20W1-W3=20=E2=80=94=20adaptive=20sp?= =?UTF-8?q?lit-view=20layout=20+=20finding=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ --- docs/PROGRESS_LOG.md | 9 + ios/App/WebTerm/Components/KeyBar.swift | 22 ++ .../Components/TerminalContextMenu.swift | 144 +++++++++ ios/App/WebTerm/Screens/ProjectsLayout.swift | 73 +++++ ios/App/WebTerm/Screens/ProjectsScreen.swift | 99 +++++- ios/App/WebTerm/Screens/TerminalScreen.swift | 146 ++++++++- ios/App/WebTerm/Wiring/AdaptiveRootView.swift | 110 +++++++ ios/App/WebTerm/Wiring/AppCoordinator.swift | 65 ++++ ios/App/WebTerm/Wiring/LayoutMode.swift | 22 ++ ios/App/WebTerm/Wiring/RootView.swift | 109 ++----- ios/App/WebTerm/Wiring/SplitRootView.swift | 110 +++++++ .../Wiring/TerminalContainerView.swift | 7 +- .../WebTermTests/KeyBarVisibilityTests.swift | 33 ++ ios/App/WebTermTests/LayoutPolicyTests.swift | 28 ++ .../WebTermTests/ProjectsLayoutTests.swift | 75 +++++ .../WebTermTests/SidebarSelectionTests.swift | 305 ++++++++++++++++++ .../TerminalContextMenuTests.swift | 139 ++++++++ .../ProjectsLayoutUITests.swift | 182 +++++++++++ 18 files changed, 1594 insertions(+), 84 deletions(-) create mode 100644 ios/App/WebTerm/Components/TerminalContextMenu.swift create mode 100644 ios/App/WebTerm/Screens/ProjectsLayout.swift create mode 100644 ios/App/WebTerm/Wiring/AdaptiveRootView.swift create mode 100644 ios/App/WebTerm/Wiring/LayoutMode.swift create mode 100644 ios/App/WebTerm/Wiring/SplitRootView.swift create mode 100644 ios/App/WebTermTests/KeyBarVisibilityTests.swift create mode 100644 ios/App/WebTermTests/LayoutPolicyTests.swift create mode 100644 ios/App/WebTermTests/ProjectsLayoutTests.swift create mode 100644 ios/App/WebTermTests/SidebarSelectionTests.swift create mode 100644 ios/App/WebTermTests/TerminalContextMenuTests.swift create mode 100644 ios/App/WebTermUITests/ProjectsLayoutUITests.swift diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 51e7128..20c189e 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -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+全量双证兼容。 diff --git a/ios/App/WebTerm/Components/KeyBar.swift b/ios/App/WebTerm/Components/KeyBar.swift index b7ec8c1..9236553 100644 --- a/ios/App/WebTerm/Components/KeyBar.swift +++ b/ios/App/WebTerm/Components/KeyBar.swift @@ -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, diff --git a/ios/App/WebTerm/Components/TerminalContextMenu.swift b/ios/App/WebTerm/Components/TerminalContextMenu.swift new file mode 100644 index 0000000..fea173b --- /dev/null +++ b/ios/App/WebTerm/Components/TerminalContextMenu.swift @@ -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) + } + }) + } + } +} diff --git a/ios/App/WebTerm/Screens/ProjectsLayout.swift b/ios/App/WebTerm/Screens/ProjectsLayout.swift new file mode 100644 index 0000000..b383d11 --- /dev/null +++ b/ios/App/WebTerm/Screens/ProjectsLayout.swift @@ -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? { + idiom == .pad ? [.medium, .large] : nil + } +} + +// MARK: - 应用点(把「iPhone 不套、iPad 套 detents」收进一个 modifier) + +/// 条件套 `.presentationDetents` 的 modifier —— nil 时 `content` 原样透传, +/// 故 iPhone 分支不引入任何 sheet 尺寸修饰符(零回归)。 +private struct AdaptiveSheetDetents: ViewModifier { + let detents: Set? + + 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))) + } +} diff --git a/ios/App/WebTerm/Screens/ProjectsScreen.swift b/ios/App/WebTerm/Screens/ProjectsScreen.swift index 73ff08e..a1b9b98 100644 --- a/ios/App/WebTerm/Screens/ProjectsScreen.swift +++ b/ios/App/WebTerm/Screens/ProjectsScreen.swift @@ -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( diff --git a/ios/App/WebTerm/Screens/TerminalScreen.swift b/ios/App/WebTerm/Screens/TerminalScreen.swift index b947d25..43464b0 100644 --- a/ios/App/WebTerm/Screens/TerminalScreen.swift +++ b/ios/App/WebTerm/Screens/TerminalScreen.swift @@ -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) + } } diff --git a/ios/App/WebTerm/Wiring/AdaptiveRootView.swift b/ios/App/WebTerm/Wiring/AdaptiveRootView.swift new file mode 100644 index 0000000..fb25f0b --- /dev/null +++ b/ios/App/WebTerm/Wiring/AdaptiveRootView.swift @@ -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 { + 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) + } + } + } + } +} diff --git a/ios/App/WebTerm/Wiring/AppCoordinator.swift b/ios/App/WebTerm/Wiring/AppCoordinator.swift index 6b65ee1..4ba86bc 100644 --- a/ios/App/WebTerm/Wiring/AppCoordinator.swift +++ b/ios/App/WebTerm/Wiring/AppCoordinator.swift @@ -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 { + 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 +} diff --git a/ios/App/WebTerm/Wiring/LayoutMode.swift b/ios/App/WebTerm/Wiring/LayoutMode.swift new file mode 100644 index 0000000..874a254 --- /dev/null +++ b/ios/App/WebTerm/Wiring/LayoutMode.swift @@ -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 + } +} diff --git a/ios/App/WebTerm/Wiring/RootView.swift b/ios/App/WebTerm/Wiring/RootView.swift index 1c6c48a..5181dc6 100644 --- a/ios/App/WebTerm/Wiring/RootView.swift +++ b/ios/App/WebTerm/Wiring/RootView.swift @@ -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 { - NavigationStack { - rootContent - .navigationDestination(isPresented: terminalBinding) { - terminalDestination - } - } - if PrivacyShadePolicy.isShadeVisible(for: scenePhase) { - PrivacyShadeView() - } - } - .task { await coordinator.bootstrap() } - .onChange(of: scenePhase) { _, phase in - coordinator.handleScenePhase(phase) - } - .onOpenURL { coordinator.handleDeepLink(url: $0) } // T-iOS-22 - .alert(DeepLinkCopy.hintTitle, isPresented: deepLinkHintBinding) { - Button(DeepLinkCopy.hintConfirm) { coordinator.deepLink.clearHint() } - } message: { - Text(coordinator.deepLink.hintMessage ?? "") - } - .sheet( - isPresented: $coordinator.isAddHostPresented, - onDismiss: { coordinator.addHostDismissed() } - ) { - addHostSheet - } - .sheet( - isPresented: $coordinator.isProjectsPresented, - onDismiss: { coordinator.projectsDismissed() } - ) { - projectsSheet - } + AdaptiveRootView(coordinator: coordinator) } +} - /// Deep-link hint alert (unknown host / store failure, T-iOS-22). - private var deepLinkHintBinding: Binding { - Binding( - get: { coordinator.deepLink.hintMessage != nil }, - set: { presented in - guard !presented else { return } - coordinator.deepLink.clearHint() - } - ) +/// T-iPad-2 · compact 宽度(iPhone / iPad Slide Over)的根视图 —— 原 +/// `RootView` 的 `NavigationStack` 主体原样搬迁,行为字节级不变。横切布线 +/// (遮罩/scenePhase/deepLink/sheets)不在这里,已上提到 `AdaptiveRootView`。 +struct StackRootView: View { + @Bindable var coordinator: AppCoordinator + + var body: some View { + NavigationStack { + rootContent + .navigationDestination(isPresented: terminalBinding) { + terminalDestination + } + } } // MARK: - Route switch @@ -140,7 +117,8 @@ struct RootView: View { if let controller = coordinator.terminalController { TerminalContainerView( controller: controller, - onNewSessionInCwd: { coordinator.openNewSessionInCurrentCwd() } + onNewSessionInCwd: { coordinator.openNewSessionInCurrentCwd() }, + onKillSession: { coordinator.killCurrentSession() } // T-iPad-3 ) // T-iOS-29 · identity PER CONTROLLER: an in-place session switch // (new-in-cwd, deep link) swaps the controller while the @@ -151,40 +129,15 @@ struct RootView: View { .id(controller.id) } } - - // MARK: - Add-host sheet (multi-host entry, list header) - - @ViewBuilder private var addHostSheet: some View { - if let viewModel = coordinator.addHostPairingViewModel { - NavigationStack { - PairingScreen(viewModel: viewModel) { host in - coordinator.completeAddHost(host) - } - } - } - } - - // MARK: - Projects sheet (T-iOS-26) - - /// 自带 NavigationStack:列表 → 详情 → 差异都在 sheet 内 push; - /// "在此仓库开新会话" 关掉 sheet 并在根导航推入终端。 - @ViewBuilder private var projectsSheet: some View { - if let viewModel = coordinator.projectsViewModel { - NavigationStack { - ProjectsScreen(viewModel: viewModel) { request in - coordinator.openProject(request) - } - } - } - } } -private enum RootMetrics { +enum RootMetrics { static let bannerHorizontalPadding: CGFloat = 16 static let bannerVerticalPadding: CGFloat = 8 } -private enum RootCopy { +/// 根层用户可见文案(internal —— `SplitRootView` 复用同一「项目」标签,DRY)。 +enum RootCopy { static let continueLast = "继续上次会话" static let projects = "项目" } diff --git a/ios/App/WebTerm/Wiring/SplitRootView.swift b/ios/App/WebTerm/Wiring/SplitRootView.swift new file mode 100644 index 0000000..19d7fc0 --- /dev/null +++ b/ios/App/WebTerm/Wiring/SplitRootView.swift @@ -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 = "从左侧选择一个运行中的会话,或新建一个开始工作。" +} diff --git a/ios/App/WebTerm/Wiring/TerminalContainerView.swift b/ios/App/WebTerm/Wiring/TerminalContainerView.swift index cb81d69..8b3b086 100644 --- a/ios/App/WebTerm/Wiring/TerminalContainerView.swift +++ b/ios/App/WebTerm/Wiring/TerminalContainerView.swift @@ -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) diff --git a/ios/App/WebTermTests/KeyBarVisibilityTests.swift b/ios/App/WebTermTests/KeyBarVisibilityTests.swift new file mode 100644 index 0000000..0c883b6 --- /dev/null +++ b/ios/App/WebTermTests/KeyBarVisibilityTests.swift @@ -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) + } +} diff --git a/ios/App/WebTermTests/LayoutPolicyTests.swift b/ios/App/WebTermTests/LayoutPolicyTests.swift new file mode 100644 index 0000000..ad4d493 --- /dev/null +++ b/ios/App/WebTermTests/LayoutPolicyTests.swift @@ -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) + } +} diff --git a/ios/App/WebTermTests/ProjectsLayoutTests.swift b/ios/App/WebTermTests/ProjectsLayoutTests.swift new file mode 100644 index 0000000..c82443c --- /dev/null +++ b/ios/App/WebTermTests/ProjectsLayoutTests.swift @@ -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]) + } +} diff --git a/ios/App/WebTermTests/SidebarSelectionTests.swift b/ios/App/WebTermTests/SidebarSelectionTests.swift new file mode 100644 index 0000000..e225aab --- /dev/null +++ b/ios/App/WebTermTests/SidebarSelectionTests.swift @@ -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() + } +} diff --git a/ios/App/WebTermTests/TerminalContextMenuTests.swift b/ios/App/WebTermTests/TerminalContextMenuTests.swift new file mode 100644 index 0000000..7695992 --- /dev/null +++ b/ios/App/WebTermTests/TerminalContextMenuTests.swift @@ -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? + } + + 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) + } +} diff --git a/ios/App/WebTermUITests/ProjectsLayoutUITests.swift b/ios/App/WebTermUITests/ProjectsLayoutUITests.swift new file mode 100644 index 0000000..61776d9 --- /dev/null +++ b/ios/App/WebTermUITests/ProjectsLayoutUITests.swift @@ -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: " | ") + } +}