App layer, four sequential slices (a shared .xcodeproj means adding files regenerates it, so these could not run in parallel): - token UX end to end: pairing prompts for a token when a host 401s, POST /auth validates it, and 204-without-Set-Cookie is correctly read as "this server has auth disabled" rather than "authenticated". A host paired before the token was turned on recovers by re-pairing in place. Remove-host now exists and finally gives PushRegistrar.handleHostRemoved a caller. - project git panel + worktree lifecycle (T-iOS-32) + claude --resume history — the parity gap with Android and the web front end. - terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a session switch between dictation and confirm cannot inject into the wrong session. - theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no longer hard-locks .preferredColorScheme(.dark). Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that collided with the upstream one, verified green from a fresh derivedDataPath. Includes the two HIGH fixes the security review found: - iOS resolved the WS token host-independently, so a token-gated host sitting next to an open one could never open a terminal and no on-screen remedy could fix it. Now one transport per host; cross-host leakage is structurally impossible since both read paths return only that host's own value. - Android reported the host's own git-credential 401 (git-ops.ts:108, "Push authentication required on the host.") as "your access token is wrong", because a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now ROUTE_DEFINED and keep the server's message. And the doc sync: README/ios README no longer claim the client is unmerged on feat/ios-client, the Clients section finally lists Android, and the plan checkboxes reflect what is actually built. iOS 534 app tests + 452 package tests; Android 687 tests.
464 lines
21 KiB
Swift
464 lines
21 KiB
Swift
import GameController
|
||
import SessionCore
|
||
import SwiftTerm
|
||
import SwiftUI
|
||
import UIKit
|
||
|
||
/// T-iOS-11 · The terminal screen: SwiftTerm view + key bar + status banner.
|
||
///
|
||
/// Data flow (byte-shuttle preserved end to end):
|
||
/// - inbound: `SessionEvent.output` → `TerminalViewModel` sink → `feed(text:)`
|
||
/// (opaque ANSI/UTF-8 — the app NEVER parses terminal semantics);
|
||
/// - outbound: SwiftTerm delegate `send` → `viewModel.sendInput`,
|
||
/// `sizeChanged` → `viewModel.sendResize`; KeyBar taps and hardware
|
||
/// `UIKeyCommand`s go through `viewModel.send(key:)` — every label→bytes
|
||
/// lookup via `KeyByteMap`, bypassing SwiftTerm's text path so the soft
|
||
/// keyboard never pops (mirrors the web bar's ws.send bypass).
|
||
/// - IME: NO custom keydown/composition interception — SwiftTerm manages
|
||
/// composition itself (CLAUDE.md gotcha; plan §7 T-iOS-11).
|
||
///
|
||
/// Navigation/lifecycle wiring (engine open/close, scenePhase) lands in
|
||
/// T-iOS-15 — this screen only renders and routes.
|
||
struct TerminalScreen: View {
|
||
let viewModel: TerminalViewModel
|
||
/// T-iOS-29 · "在当前目录开新会话":toolbar 常驻入口 + exit 横幅上的
|
||
/// "开新会话"(同一动作 —— close→open fresh spawn,cwd 解析在
|
||
/// `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?
|
||
|
||
/// T-iOS-33 · 终端内搜索面板状态(引擎在 `makeUIView` 里绑定)。
|
||
@State private var searchModel = TerminalSearchModel()
|
||
/// T-iOS-31 · 语音 PTT 状态机 + 它的 epoch 源。两者在 `init` 里成对建立,
|
||
/// 因为 PTT 的注入出口/只读判据/epoch 都来自本屏的 `viewModel`。
|
||
@State private var voiceEpochSource: VoiceEpochSource
|
||
@State private var voiceModel: VoicePTTViewModel
|
||
|
||
/// 无障碍:减弱动态效果时,横幅进出塌成即时切换(DS.Motion.gated)。
|
||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||
|
||
private enum Copy {
|
||
static let newSessionInCwd = "在当前目录开新会话"
|
||
static let showKeyBar = "显示快捷键栏"
|
||
static let hideKeyBar = "隐藏快捷键栏"
|
||
}
|
||
|
||
/// 显式 `init`(成员逐一对齐旧的合成 init,调用点不变):语音 PTT 的
|
||
/// ViewModel 必须在 `@State` 初值里就拿到本屏的 `viewModel`,才能把注入出口
|
||
/// 接到 `sendInput`、把 epoch 接到 sessionId/连接世代上。
|
||
///
|
||
/// SwiftUI 只保留**首次**的 `@State` 初值 —— 这正确,因为
|
||
/// `TerminalContainerView` 用 `.id(controller.generation)` 换代时会整屏重建,
|
||
/// 一代之内 `controller.terminalViewModel` 是稳定的。
|
||
init(
|
||
viewModel: TerminalViewModel,
|
||
onNewSessionInCwd: (@MainActor () -> Void)? = nil,
|
||
onKillSession: (@MainActor () -> Void)? = nil
|
||
) {
|
||
self.viewModel = viewModel
|
||
self.onNewSessionInCwd = onNewSessionInCwd
|
||
self.onKillSession = onKillSession
|
||
|
||
let epochSource = VoiceEpochSource()
|
||
_voiceEpochSource = State(initialValue: epochSource)
|
||
_voiceModel = State(initialValue: VoicePTTViewModel(
|
||
dictation: SpeechDictation(),
|
||
clock: ContinuousClock(),
|
||
epoch: { epochSource.epoch },
|
||
isReadOnly: { viewModel.isReadOnly },
|
||
inject: { text in viewModel.sendInput(text) }
|
||
))
|
||
}
|
||
|
||
/// KeyBar 是否可见 —— 唯一判据经 `KeyBarVisibility` 纯谓词。
|
||
private var isKeyBarVisible: Bool {
|
||
KeyBarVisibility.isVisible(
|
||
hardwareKeyboardPresent: hasHardwareKeyboard,
|
||
userOverride: keyBarUserOverride
|
||
)
|
||
}
|
||
|
||
var body: some View {
|
||
TerminalHostView(
|
||
viewModel: viewModel,
|
||
keyBarVisible: isKeyBarVisible,
|
||
searchModel: searchModel,
|
||
voiceModel: voiceModel,
|
||
onNewSessionInCwd: onNewSessionInCwd,
|
||
onKillSession: onKillSession
|
||
)
|
||
.ignoresSafeArea(.container, edges: .bottom)
|
||
.overlay(alignment: .top) {
|
||
if let model = viewModel.bannerModel {
|
||
ReconnectBanner(model: model, onNewSession: onNewSessionInCwd)
|
||
.padding(.horizontal, DS.Space.md12)
|
||
.padding(.top, DS.Space.sm8)
|
||
.transition(.move(edge: .top).combined(with: .opacity))
|
||
}
|
||
}
|
||
// T-iOS-33 · 搜索条钉在右上 —— 位置对齐 web `#searchbox`
|
||
// (`position:fixed; top; right`, style.css:812)。
|
||
.overlay(alignment: .topTrailing) {
|
||
if searchModel.isPresented {
|
||
TerminalSearchBar(model: searchModel)
|
||
.padding(.horizontal, DS.Space.sm8)
|
||
.padding(.top, DS.Space.sm8)
|
||
.transition(.move(edge: .top).combined(with: .opacity))
|
||
}
|
||
}
|
||
// T-iOS-31 · PTT 卡贴底(紧邻键栏上的 🎤 键)。
|
||
.overlay(alignment: .bottom) {
|
||
VoicePTTBanner(model: voiceModel)
|
||
.padding(.horizontal, DS.Space.md12)
|
||
.padding(.bottom, DS.Space.xl20)
|
||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||
}
|
||
.animation(
|
||
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
|
||
value: viewModel.bannerModel
|
||
)
|
||
.animation(
|
||
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
|
||
value: searchModel.isPresented
|
||
)
|
||
.animation(
|
||
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
|
||
value: voiceModel.phase
|
||
)
|
||
.toolbar {
|
||
searchToolbarItem
|
||
newSessionToolbarItem
|
||
keyBarToggleToolbarItem
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidConnect)) { _ in
|
||
hasHardwareKeyboard = true
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidDisconnect)) { _ in
|
||
hasHardwareKeyboard = GCKeyboard.coalesced != nil
|
||
}
|
||
// T-iOS-31 · epoch 源跟住会话身份与连接世代:任一变化都作废在飞的口述。
|
||
.onChange(of: viewModel.sessionId) { _, id in voiceEpochSource.noteSession(id) }
|
||
.onChange(of: viewModel.banner) { _, banner in voiceEpochSource.noteBanner(banner) }
|
||
.onAppear {
|
||
viewModel.start()
|
||
voiceEpochSource.noteSession(viewModel.sessionId)
|
||
}
|
||
}
|
||
|
||
/// T-iOS-33 · 搜索开关(镜像 web toolbar 的 🔍:开/关同一个键)。
|
||
@ToolbarContentBuilder private var searchToolbarItem: some ToolbarContent {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button {
|
||
if searchModel.isPresented {
|
||
searchModel.dismiss()
|
||
} else {
|
||
searchModel.present()
|
||
}
|
||
} label: {
|
||
Label(
|
||
searchModel.isPresented
|
||
? TerminalSearchModel.Copy.close : TerminalSearchModel.Copy.open,
|
||
systemImage: searchModel.isPresented ? "magnifyingglass.circle.fill"
|
||
: "magnifyingglass"
|
||
)
|
||
}
|
||
.accessibilityIdentifier("terminal.searchToggleButton")
|
||
}
|
||
}
|
||
|
||
/// 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 {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
if let onNewSessionInCwd {
|
||
Button {
|
||
onNewSessionInCwd()
|
||
} label: {
|
||
Label(Copy.newSessionInCwd, systemImage: "plus.rectangle.on.folder")
|
||
}
|
||
.accessibilityIdentifier("terminal.newInCwdButton")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Terminal theme
|
||
|
||
/// Refined terminal theme (精致原生) matching the desktop: a fixed warm-dark
|
||
/// canvas (#100F0D bg / #ECE9E3 fg, web --bg/--text) so the terminal reads the
|
||
/// same on iOS and desktop regardless of app appearance, with caret/selection
|
||
/// in the one gold accent. Every value routes through `DS` (no literals); the
|
||
/// glyph font is Dynamic-Type-aware SF Mono so terminal text respects the
|
||
/// user's text-size choice at launch.
|
||
private enum TerminalTheme {
|
||
@MainActor
|
||
static func apply(to terminal: TerminalView) {
|
||
terminal.font = UIFont.monospacedSystemFont(
|
||
ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize,
|
||
weight: .regular
|
||
)
|
||
terminal.nativeBackgroundColor = UIColor(DS.Palette.terminalBackground)
|
||
terminal.nativeForegroundColor = UIColor(DS.Palette.terminalForeground)
|
||
terminal.caretColor = DS.Palette.accentUIColor()
|
||
terminal.selectedTextBackgroundColor = DS.Palette.accentUIColor()
|
||
}
|
||
}
|
||
|
||
// MARK: - SwiftTerm bridge
|
||
|
||
/// `UIViewRepresentable` around `SwiftTerm.TerminalView` (plan §3.5). The
|
||
/// KeyBar is installed as `inputAccessoryView`; hardware chords come from the
|
||
/// `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
|
||
/// T-iOS-33 · 搜索面板 —— `makeUIView` 把 SwiftTerm 视图绑给它做检索引擎。
|
||
let searchModel: TerminalSearchModel
|
||
/// T-iOS-31 · PTT 状态机 —— 键栏 🎤 键的按下/松手出口。
|
||
let voiceModel: VoicePTTViewModel
|
||
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
|
||
var onKillSession: (@MainActor () -> Void)? = nil
|
||
|
||
func makeCoordinator() -> Coordinator {
|
||
Coordinator(viewModel: viewModel)
|
||
}
|
||
|
||
func makeUIView(context: Context) -> KeyCommandTerminalView {
|
||
let terminal = KeyCommandTerminalView(frame: .zero)
|
||
terminal.terminalDelegate = context.coordinator
|
||
TerminalTheme.apply(to: terminal)
|
||
|
||
let viewModel = viewModel
|
||
terminal.onKeyCommand = { key in viewModel.send(key: key) }
|
||
|
||
// T-iOS-33 · SwiftTerm 自带搜索即检索引擎(纯读,绝不产生 PTY 字节)。
|
||
searchModel.attach(terminal)
|
||
|
||
// T-iOS-31 · 🎤 键:按下开录、松手收尾,全程不经 KeyByteMap。
|
||
let voiceModel = voiceModel
|
||
let keyBar = KeyBarView(voice: KeyBarVoiceHandlers(
|
||
onDown: { Task { await voiceModel.pressDown() } },
|
||
onUp: { Task { await voiceModel.pressUp() } }
|
||
))
|
||
keyBar.onKey = { key in viewModel.send(key: key) }
|
||
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.
|
||
viewModel.attachTerminalSink { [weak terminal] text in
|
||
terminal?.feed(text: text)
|
||
}
|
||
return terminal
|
||
}
|
||
|
||
func updateUIView(_ uiView: KeyCommandTerminalView, context: Context) {
|
||
// State-driven UI lives in SwiftUI (banner overlay); the terminal view
|
||
// 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
|
||
/// `@preconcurrency` — SwiftTerm only calls it from the main thread (the
|
||
/// view itself is `@MainActor`), which the runtime check enforces.
|
||
@MainActor
|
||
final class Coordinator: NSObject, @preconcurrency TerminalViewDelegate {
|
||
private let viewModel: TerminalViewModel
|
||
|
||
init(viewModel: TerminalViewModel) {
|
||
self.viewModel = viewModel
|
||
}
|
||
|
||
/// User typed into SwiftTerm (soft/hardware keyboard, IME result):
|
||
/// raw bytes, passed through verbatim (invariant #9).
|
||
func send(source: TerminalView, data: ArraySlice<UInt8>) {
|
||
viewModel.sendInput(String(decoding: data, as: UTF8.self))
|
||
}
|
||
|
||
/// Layout produced a new grid → server `resize` (SIGWINCH). This is
|
||
/// also the latest-writer-wins size claim (v0.4 sizing model).
|
||
func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
|
||
guard newCols > 0, newRows > 0 else { return } // pre-layout noise
|
||
viewModel.sendResize(cols: newCols, rows: newRows)
|
||
}
|
||
|
||
/// Tapped link (OSC 8 / detected URL — mirrors web M2 WebLinksAddon).
|
||
/// The link string is untrusted terminal output: http(s) only.
|
||
func requestOpenLink(source: TerminalView, link: String, params: [String: String]) {
|
||
guard let url = URL(string: link),
|
||
let scheme = url.scheme?.lowercased(),
|
||
scheme == "http" || scheme == "https"
|
||
else { return }
|
||
UIApplication.shared.open(url)
|
||
}
|
||
|
||
/// OSC 0/2 title (T-iOS-23): hostile input — the VM sanitizes
|
||
/// (TitleSanitizer) before any UI/registry use, then the wiring
|
||
/// surfaces it on the session-list row.
|
||
func setTerminalTitle(source: TerminalView, title: String) {
|
||
viewModel.setTerminalTitle(title)
|
||
}
|
||
|
||
// cwd surfaces in the session list via the server (T-iOS-13), not
|
||
// from the local emulator — deliberate no-op.
|
||
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
|
||
func scrolled(source: TerminalView, position: Double) {}
|
||
func rangeChanged(source: TerminalView, startY: Int, endY: Int) {}
|
||
|
||
/// OSC 52 lets the HOST write the device clipboard silently — declined
|
||
/// (server output is untrusted input; plan §4).
|
||
func clipboardCopy(source: TerminalView, content: Data) {}
|
||
}
|
||
}
|
||
|
||
/// `TerminalView` subclass adding hardware-keyboard `UIKeyCommand`s with the
|
||
/// SAME `KeyByteMap` mapping as the key bar (scope decision documented on
|
||
/// `HardwareKeyCommands`). Everything else — rendering, selection, IME —
|
||
/// is stock SwiftTerm.
|
||
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(_:)))
|
||
}
|
||
|
||
@objc private func runHardwareKeyCommand(_ sender: UIKeyCommand) {
|
||
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))
|
||
}
|
||
|
||
// NOTE (T-iOS-33/31 root fix): the "does a selection exist" read used to be
|
||
// a LOCAL `hasActiveSelection` here, computed via SwiftTerm's own `copy`
|
||
// eligibility (`canPerformAction` answers from `selection.active`). SwiftTerm
|
||
// v1.14.0 promoted the very same thing to `public var hasActiveSelection`
|
||
// (`selection?.active ?? false`) on its own view, which then COLLIDED with
|
||
// the local one and pinned the dependency at 1.13.0 (`override` cannot fix a
|
||
// `public`-but-not-`open` property). The local copy is therefore gone and
|
||
// every caller — the pointer context menu, the find-bar tests — now reads
|
||
// upstream's property, so the pin rides at 1.15.0. Do not reintroduce it.
|
||
|
||
/// 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)
|
||
}
|
||
}
|
||
|
||
// MARK: - Terminal search binding (T-iOS-33)
|
||
|
||
/// The find bar's engine is SwiftTerm's own scrollback search
|
||
/// (`TerminalViewSearch.swift`): `findNext`/`findPrevious` select + scroll the
|
||
/// match (that selection IS the highlight) and `clearSearch` drops both. Pure
|
||
/// read — no PTY byte is produced, so search also works on an exited session.
|
||
extension KeyCommandTerminalView: TerminalSearching {
|
||
func searchNext(term: String) -> Bool {
|
||
findNext(term)
|
||
}
|
||
|
||
func searchPrevious(term: String) -> Bool {
|
||
findPrevious(term)
|
||
}
|
||
|
||
func searchClear() {
|
||
clearSearch()
|
||
}
|
||
}
|