Files
web-terminal/ios/App/WebTerm/Components/QuickReplyStore.swift
Yaojia Wang f40b8f9400 feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests
T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand
T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly)
T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner)
T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state),
prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap
T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize
T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller
SwiftTerm view bug via .id(controller.id)
T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp)
T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) +
NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback)
CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited
closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports,
permission prompt reached, privacy shade correctly covering during system alert)
Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
2026-07-05 16:15:57 +02:00

201 lines
8.2 KiB
Swift

import Foundation
import Observation
import SessionCore
/// T-iOS-25 · Quick-reply data layer: chip model, pure immutable CRUD and the
/// UserDefaults-backed palette store a behavior mirror of
/// `public/quick-reply.ts` (Chip / addChip / removeChip / reorderChip /
/// updateChip / loadPalette / savePalette).
///
/// Storage decision (plan §5.3 split): the palette is NON-SECRET UI prefs
/// UserDefaults with an injectable suite (same pattern as
/// `UserDefaultsLastSessionStore`), never Keychain.
/// One sendable snippet (mirror of the web `Chip` interface).
struct QuickReplyChip: Sendable, Equatable, Codable, Identifiable {
/// Stable identifier (built-ins use the `__` prefix, user chips `user_`).
let id: String
/// Raw bytes to send (without the Enter suffix).
let text: String
/// Display label on the chip button. Rendered via `Text(verbatim:)` only
/// the SwiftUI analogue of the web's textContent-never-innerHTML (SEC-L3).
let label: String
/// If true, Enter (`\r`) is appended to `text` when sending.
let appendEnter: Bool
}
/// Pure palette operations every function returns a NEW array and never
/// mutates its input (web quick-reply.ts CRUD, verbatim semantics).
enum QuickReplyPalette {
/// The six built-in chips for Claude Code interaction, order and values
/// mirroring web `BUILT_IN_CHIPS`. Esc bytes resolve through `KeyByteMap`
/// hand-writing an escape sequence in the App layer is a review finding.
static let builtInChips: [QuickReplyChip] = [
QuickReplyChip(id: "__yes", text: "yes", label: "yes", appendEnter: true),
QuickReplyChip(id: "__continue", text: "continue", label: "continue",
appendEnter: true),
QuickReplyChip(id: "__1", text: "1", label: "1", appendEnter: true),
QuickReplyChip(id: "__2", text: "2", label: "2", appendEnter: true),
QuickReplyChip(id: "__3", text: "3", label: "3", appendEnter: true),
QuickReplyChip(id: "__esc", text: KeyByteMap.bytes(for: .esc), label: "Esc",
appendEnter: false),
]
/// Prefix for user-created chip ids (web: `user_<ts>_<rand>`; iOS: UUID).
static let userChipIdPrefix = "user_"
/// The full byte string a chip tap sends: text plus when `appendEnter`
/// the Enter byte from `KeyByteMap` (`\r`, 0x0D, NOT `\n`; CLAUDE.md gotcha).
static func payload(for chip: QuickReplyChip) -> String {
chip.text + (chip.appendEnter ? KeyByteMap.bytes(for: .enter) : "")
}
/// New array with `chip` appended (web `addChip`).
static func adding(_ chips: [QuickReplyChip], _ chip: QuickReplyChip) -> [QuickReplyChip] {
chips + [chip]
}
/// New array with the chip matching `id` removed (web `removeChip`).
static func removing(_ chips: [QuickReplyChip], id: String) -> [QuickReplyChip] {
chips.filter { $0.id != id }
}
/// New array with the chip at `fromIndex` moved to `toIndex` (web
/// `reorderChip` splice semantics). Either index out of bounds the input
/// returned unchanged. Operates on a local copy value semantics
/// guarantee the caller's array is never touched.
static func reordering(
_ chips: [QuickReplyChip], fromIndex: Int, toIndex: Int
) -> [QuickReplyChip] {
guard chips.indices.contains(fromIndex), chips.indices.contains(toIndex) else {
return chips
}
var result = chips
let moved = result.remove(at: fromIndex)
result.insert(moved, at: toIndex)
return result
}
/// New array where the chip with `id` has non-nil fields patched (web
/// `updateChip`: shallow merge, id immutable). Unknown id unchanged.
static func updating(
_ chips: [QuickReplyChip], id: String,
text: String? = nil, label: String? = nil, appendEnter: Bool? = nil
) -> [QuickReplyChip] {
chips.map { chip in
guard chip.id == id else { return chip }
return QuickReplyChip(
id: chip.id,
text: text ?? chip.text,
label: label ?? chip.label,
appendEnter: appendEnter ?? chip.appendEnter
)
}
}
}
/// Per-item lossy decode wrapper (mirror of the web `filter(isValidChip)`):
/// one malformed entry is dropped, the well-formed rest survive stored data
/// crosses a storage boundary and is never trusted (LastSessionStore pattern).
private struct LossyQuickReplyChip: Decodable {
let chip: QuickReplyChip?
init(from decoder: any Decoder) throws {
chip = try? QuickReplyChip(from: decoder)
}
}
/// Observable palette store: holds the user chips, persists every change to
/// the injected `UserDefaults` and exposes the render list (built-ins first,
/// then user chips web render order).
@MainActor
@Observable
final class QuickReplyStore {
/// Single UserDefaults key for the encoded user palette
/// (web: localStorage `web-terminal:quick-reply-palette`).
static let paletteDefaultsKey = "quickReplyPalette"
private(set) var userChips: [QuickReplyChip]
@ObservationIgnored private let defaults: UserDefaults
/// Built-in chips first, then the user palette (web `render()` order).
var allChips: [QuickReplyChip] {
QuickReplyPalette.builtInChips + userChips
}
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
userChips = Self.loadPalette(from: defaults)
}
/// Add a new phrase (web editor `onSaveClick` semantics): text/label are
/// trimmed, empty text is a no-op (returns false), empty label defaults to
/// the text, the id is minted with the `user_` prefix.
@discardableResult
func addChip(text: String, label: String, appendEnter: Bool) -> Bool {
let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedText.isEmpty else { return false }
let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines)
let chip = QuickReplyChip(
id: QuickReplyPalette.userChipIdPrefix + UUID().uuidString,
text: trimmedText,
label: trimmedLabel.isEmpty ? trimmedText : trimmedLabel,
appendEnter: appendEnter
)
setUserChips(QuickReplyPalette.adding(userChips, chip))
return true
}
func removeChip(id: String) {
setUserChips(QuickReplyPalette.removing(userChips, id: id))
}
func moveChip(fromIndex: Int, toIndex: Int) {
setUserChips(QuickReplyPalette.reordering(
userChips, fromIndex: fromIndex, toIndex: toIndex
))
}
/// SwiftUI `List.onMove` adapter (IndexSet + insert-before destination on
/// the ORIGINAL array `Array.move` implements that convention).
func moveChips(fromOffsets: IndexSet, toOffset: Int) {
var next = userChips
next.move(fromOffsets: fromOffsets, toOffset: toOffset)
setUserChips(next)
}
func updateChip(
id: String, text: String? = nil, label: String? = nil, appendEnter: Bool? = nil
) {
setUserChips(QuickReplyPalette.updating(
userChips, id: id, text: text, label: label, appendEnter: appendEnter
))
}
/// Load the persisted palette. Missing key, non-JSON data or a non-array
/// root `[]`; malformed items are dropped per-entry. Never throws
/// (web `loadPalette` contract).
static func loadPalette(from defaults: UserDefaults) -> [QuickReplyChip] {
guard let data = defaults.data(forKey: paletteDefaultsKey) else { return [] }
guard let lossy = try? JSONDecoder().decode([LossyQuickReplyChip].self, from: data)
else { return [] }
return lossy.compactMap(\.chip)
}
// MARK: - Private
private func setUserChips(_ chips: [QuickReplyChip]) {
userChips = chips
persist(chips)
}
/// Best-effort save (web `savePalette` contract: storage failure never
/// throws or crashes the in-memory palette stays authoritative for the
/// session). Encoding a `[QuickReplyChip]` cannot practically fail.
private func persist(_ chips: [QuickReplyChip]) {
guard let data = try? JSONEncoder().encode(chips) else { return }
defaults.set(data, forKey: Self.paletteDefaultsKey)
}
}