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

@@ -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 iPhoneiPad
/// Projects sheet idiom Projects /
/// idiom+ stack/split `LayoutPolicy`
/// size-class
enum ProjectsGridLayout {
/// iPhone / iPad 退
static let singleColumn = 1
/// iPad 2 sheet ~440540pt
static let twoColumnMinWidth: CGFloat = 400
/// iPad 3 /
static let threeColumnMinWidth: CGFloat = 760
/// iPhone`.phone`/ `singleColumn` List
/// iPad`.pad` **** 13 `>= 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 sheetiPad detents
func adaptiveProjectsSheetDetents(idiom: UIUserInterfaceIdiom) -> some View {
modifier(AdaptiveSheetDetents(detents: ProjectsSheetSizing.detents(idiom: idiom)))
}
}

View File

@@ -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 detentsidiom
/// @Environment `ProjectsLayout`
/// idiom size classiPad 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.padiPhone
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: - Gridregular
/// 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(

View File

@@ -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)
}
}