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
201 lines
8.2 KiB
Swift
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)
|
|
}
|
|
}
|