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
82 lines
3.4 KiB
Swift
82 lines
3.4 KiB
Swift
import Foundation
|
|
import Observation
|
|
import WireProtocol
|
|
|
|
/// T-iOS-24 · State for the full-timeline drill-down sheet (`TimelineSheet`).
|
|
///
|
|
/// One VM per presentation (the container view builds a fresh one on each
|
|
/// digest「展开」tap), so every open re-fetches `GET /live-sessions/:id/events`.
|
|
/// The fetch closure is injected — production wraps `APIClient.events` via
|
|
/// `TerminalSessionController.timelineEventsSource`; tests inject fakes.
|
|
///
|
|
/// Server data is UNTRUSTED at this boundary (plan §4): the tolerant decode
|
|
/// (malformed / unknown-class entries dropped, non-array → `[]`) already
|
|
/// happened inside `APIClient.events` → `TimelineEvent.decodeList`. This VM
|
|
/// only distinguishes the three USER-visible outcomes:
|
|
/// - `[]` → `.empty` — the server replies `[]` both for "no activity yet" and
|
|
/// for timeline capture disabled (src/server.ts:589-591); NEVER an error;
|
|
/// - thrown fetch → `.failed` — explicit, retryable (`load()` again);
|
|
/// - events → `.loaded`, ordered exactly like the web panel.
|
|
@MainActor
|
|
@Observable
|
|
final class TimelineViewModel: Identifiable {
|
|
/// Rendering phase — an explicit enum so the sheet can never show an
|
|
/// error and rows at the same time.
|
|
enum Phase: Equatable {
|
|
case loading
|
|
/// Display-ready rows: capped + newest-first (web parity, see
|
|
/// `presentation(for:)`).
|
|
case loaded([TimelineEvent])
|
|
/// Server returned `[]` (no activity OR timeline disabled host-side).
|
|
case empty
|
|
/// Fetch failed — retryable via `load()`.
|
|
case failed
|
|
}
|
|
|
|
/// Web parity: `DEFAULT_MAX_EVENTS` (public/timeline.ts:20).
|
|
static let maxEvents = 50
|
|
|
|
/// `.sheet(item:)` identity — a fresh VM per presentation.
|
|
nonisolated let id = UUID()
|
|
|
|
private(set) var phase: Phase = .loading
|
|
|
|
@ObservationIgnored private let fetch: @Sendable () async throws -> [TimelineEvent]
|
|
|
|
init(fetch: @escaping @Sendable () async throws -> [TimelineEvent]) {
|
|
self.fetch = fetch
|
|
}
|
|
|
|
/// Assembly seam for the digest「展开」entry point: no adopted sessionId
|
|
/// yet → no sheet (defensive — a digest only arrives after `attached`, so
|
|
/// the id is normally known). Otherwise the id is passed to `source`
|
|
/// verbatim on every load.
|
|
static func forSession(
|
|
_ sessionId: UUID?,
|
|
source: @escaping @Sendable (UUID) async throws -> [TimelineEvent]
|
|
) -> TimelineViewModel? {
|
|
guard let sessionId else { return nil }
|
|
return TimelineViewModel(fetch: { try await source(sessionId) })
|
|
}
|
|
|
|
/// Fetch and present. Also the「重试」path: callable again from `.failed`.
|
|
func load() async {
|
|
phase = .loading
|
|
do {
|
|
let events = try await fetch()
|
|
phase = Self.presentation(for: events)
|
|
} catch {
|
|
phase = .failed // user-facing copy lives in TimelineSheet
|
|
}
|
|
}
|
|
|
|
/// Pure presentation reducer, mirroring the web panel's render() pipeline
|
|
/// line for line (public/timeline.ts:174-189): the server returns
|
|
/// oldest-first → `slice(0, maxEvents)` first, THEN reverse, so the sheet
|
|
/// shows the same capped slice newest-first as the web timeline panel.
|
|
static func presentation(for events: [TimelineEvent]) -> Phase {
|
|
guard !events.isEmpty else { return .empty }
|
|
return .loaded(Array(events.prefix(maxEvents).reversed()))
|
|
}
|
|
}
|