feat(ipad): W1-W3 — adaptive split-view layout + finding fixes

T-iPad-2: AdaptiveRootView/LayoutPolicy (sole size-class decision), SplitRootView
(NavigationSplitView sidebar+detail), StackRootView (iPhone path verbatim, zero
regression); privacy shade hoisted to shared ZStack top for both branches
T-iPad-3: KeyBarVisibility predicate (hide when hardware keyboard present),
pointer context menu (copy/new-in-cwd/kill, all via existing channels)
T-iPad-4: Projects multi-column grid on iPad (idiom-gated), adaptive sheet detents
T-iPad-5 findings (4/4 fixed): kill onKillSession thread-through +
AppCoordinator.killCurrentSession; split route-gated to .sessions (iPad first-run
pairing); continue-last banner in split sidebar; iPad XCUITest deferred (covered
by SidebarSelectionTests)
Verified: iPhone 16 277 + iPad Pro 11 278 tests green; packages 261 + integration 10;
zero changes under ios/Packages, src/, public/
This commit is contained in:
Yaojia Wang
2026-07-05 19:58:30 +02:00
parent 77502ec4fe
commit 823432b1c8
18 changed files with 1594 additions and 84 deletions

View File

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

View File

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