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.
201 lines
7.4 KiB
Swift
201 lines
7.4 KiB
Swift
import SwiftUI
|
||
|
||
/// T-iOS-33 · 终端内搜索(scrollback find bar)。
|
||
///
|
||
/// 分层与 web 完全对齐:`public/search.ts` 只管一个输入框 + ↑/↓/× 三个键,真正
|
||
/// 的搜索交给 xterm 的 SearchAddon(`terminal-session.ts:547-553`)。iOS 侧同理
|
||
/// —— 本文件只有**纯归约**(提交策略 + 命中态)与**一层协议缝**,检索算法用
|
||
/// SwiftTerm 1.13.0 自带的 `findNext`/`findPrevious`/`clearSearch`
|
||
/// (`SearchOptions` 默认 caseSensitive:false / regex:false,与 xterm 一致)。
|
||
///
|
||
/// 铁律:搜索是**纯读** —— 它只动 SwiftTerm 的选区/滚动位置,绝不产生一个 PTY
|
||
/// 字节。终端已退出(read-only)时依然可用。
|
||
|
||
// MARK: - Engine seam
|
||
|
||
/// 搜索引擎缝:生产实现是 `KeyCommandTerminalView`(SwiftTerm),测试注入替身。
|
||
/// `AnyObject` 约束让 `TerminalSearchModel` 能弱持有视图(视图归 UIKit 所有)。
|
||
@MainActor
|
||
protocol TerminalSearching: AnyObject {
|
||
/// 向下一处匹配,命中返回 true(并把选区移到命中处 = 高亮)。
|
||
@discardableResult func searchNext(term: String) -> Bool
|
||
/// 向上一处匹配,命中返回 true。
|
||
@discardableResult func searchPrevious(term: String) -> Bool
|
||
/// 清掉搜索状态与高亮选区。
|
||
func searchClear()
|
||
}
|
||
|
||
/// 搜索方向(web:Enter = next,Shift+Enter = prev)。
|
||
enum TerminalSearchDirection: String, Equatable, Sendable {
|
||
case next
|
||
case previous
|
||
}
|
||
|
||
/// 上一次搜索的结果态。`.idle` = 还没搜 / 词已改(旧结论作废)。
|
||
enum TerminalSearchOutcome: Equatable, Sendable {
|
||
case idle
|
||
case found
|
||
case notFound
|
||
}
|
||
|
||
/// 提交策略(纯函数)。**只在空串时拦**:镜像 web 的 `hooks.find(input.value, …)`
|
||
/// —— 查询词逐字符原样下传,绝不 trim、绝不改大小写(单个空格是合法搜索词)。
|
||
enum TerminalSearchQuery {
|
||
static func isSubmittable(_ raw: String) -> Bool {
|
||
!raw.isEmpty
|
||
}
|
||
}
|
||
|
||
// MARK: - Model
|
||
|
||
/// 搜索面板状态。命中态是**派生值**:只要 `query` 变了,上一次结论自动作废回
|
||
/// `.idle`(不需要 didSet,也不会把「无匹配」挂在新词上)。
|
||
@MainActor
|
||
@Observable
|
||
final class TerminalSearchModel {
|
||
/// 用户可见文案(named constants)。
|
||
enum Copy {
|
||
static let title = "搜索回滚缓冲"
|
||
static let placeholder = "搜索终端输出…"
|
||
static let noMatch = "无匹配"
|
||
static let previous = "上一处匹配"
|
||
static let next = "下一处匹配"
|
||
static let close = "关闭搜索"
|
||
static let open = "搜索"
|
||
}
|
||
|
||
/// 一次搜索的结论,连同它对应的词 —— 词一改,`outcome` 立即回 `.idle`。
|
||
private struct Attempt: Equatable {
|
||
let term: String
|
||
let didHit: Bool
|
||
}
|
||
|
||
var query: String = ""
|
||
private(set) var isPresented = false
|
||
private var lastAttempt: Attempt?
|
||
|
||
/// 弱持有:SwiftTerm 视图归 UIKit 所有,面板绝不延长它的生命周期。
|
||
@ObservationIgnored private weak var searcher: (any TerminalSearching)?
|
||
|
||
/// 派生命中态。
|
||
var outcome: TerminalSearchOutcome {
|
||
guard let lastAttempt, lastAttempt.term == query else { return .idle }
|
||
return lastAttempt.didHit ? .found : .notFound
|
||
}
|
||
|
||
/// 状态行文案 —— 只有「无匹配」需要说话(命中时选区高亮本身就是反馈)。
|
||
var statusText: String? {
|
||
outcome == .notFound ? Copy.noMatch : nil
|
||
}
|
||
|
||
/// 绑定 SwiftTerm 视图(`makeUIView` 时调用一次)。
|
||
func attach(_ searcher: any TerminalSearching) {
|
||
self.searcher = searcher
|
||
}
|
||
|
||
func present() {
|
||
isPresented = true
|
||
}
|
||
|
||
/// 关闭面板 —— 镜像 web `hide()`:清高亮(`hooks.clear()`)+ 清输入框。
|
||
func dismiss() {
|
||
isPresented = false
|
||
query = ""
|
||
lastAttempt = nil
|
||
searcher?.searchClear()
|
||
}
|
||
|
||
/// 搜一次。空词零引擎调用;未绑定引擎(预览/测试)安全无操作。
|
||
func find(_ direction: TerminalSearchDirection) {
|
||
let term = query
|
||
guard TerminalSearchQuery.isSubmittable(term), let searcher else { return }
|
||
let didHit: Bool = switch direction {
|
||
case .next: searcher.searchNext(term: term)
|
||
case .previous: searcher.searchPrevious(term: term)
|
||
}
|
||
lastAttempt = Attempt(term: term, didHit: didHit)
|
||
}
|
||
}
|
||
|
||
// MARK: - View
|
||
|
||
/// 顶部右侧浮出的搜索条(位置对齐 web `#searchbox`:`position:fixed; top; right`)。
|
||
/// 输入框 submit = 下一处;↑/↓ 手动翻;× 关闭并清高亮。
|
||
struct TerminalSearchBar: View {
|
||
let model: TerminalSearchModel
|
||
/// 输入框首次出现即取焦(web `open()` 里的 `input.focus()`)。
|
||
@FocusState private var isFieldFocused: Bool
|
||
|
||
private enum Metrics {
|
||
static let fieldMinWidth: CGFloat = 140
|
||
static let fieldMaxWidth: CGFloat = 220
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(spacing: DS.Space.xs4) {
|
||
searchField
|
||
if let status = model.statusText {
|
||
Text(status)
|
||
.font(DS.Typography.caption)
|
||
.foregroundStyle(DS.Palette.statusStuck)
|
||
.accessibilityIdentifier("terminal.search.status")
|
||
}
|
||
iconButton(
|
||
systemImage: "chevron.up",
|
||
title: TerminalSearchModel.Copy.previous,
|
||
identifier: "terminal.search.previousButton"
|
||
) { model.find(.previous) }
|
||
iconButton(
|
||
systemImage: "chevron.down",
|
||
title: TerminalSearchModel.Copy.next,
|
||
identifier: "terminal.search.nextButton"
|
||
) { model.find(.next) }
|
||
iconButton(
|
||
systemImage: "xmark",
|
||
title: TerminalSearchModel.Copy.close,
|
||
identifier: "terminal.search.closeButton"
|
||
) { model.dismiss() }
|
||
}
|
||
.padding(.horizontal, DS.Space.sm8)
|
||
.padding(.vertical, DS.Space.xs4)
|
||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: DS.Radius.md12)
|
||
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
|
||
)
|
||
.onAppear { isFieldFocused = true }
|
||
.accessibilityLabel(TerminalSearchModel.Copy.title)
|
||
}
|
||
|
||
private var searchField: some View {
|
||
TextField(
|
||
TerminalSearchModel.Copy.placeholder,
|
||
text: Binding(get: { model.query }, set: { model.query = $0 })
|
||
)
|
||
.textFieldStyle(.plain)
|
||
.font(DS.Typography.callout)
|
||
.autocorrectionDisabled()
|
||
.textInputAutocapitalization(.never)
|
||
.submitLabel(.search)
|
||
.focused($isFieldFocused)
|
||
.frame(minWidth: Metrics.fieldMinWidth, maxWidth: Metrics.fieldMaxWidth)
|
||
.onSubmit { model.find(.next) }
|
||
.accessibilityIdentifier("terminal.search.field")
|
||
}
|
||
|
||
private func iconButton(
|
||
systemImage: String,
|
||
title: String,
|
||
identifier: String,
|
||
action: @escaping () -> Void
|
||
) -> some View {
|
||
Button(action: action) {
|
||
Image(systemName: systemImage)
|
||
.frame(minWidth: DS.Layout.minHitTarget, minHeight: DS.Layout.minHitTarget)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(title)
|
||
.accessibilityIdentifier(identifier)
|
||
}
|
||
}
|