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