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:
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user