Files
web-terminal/ios/App/WebTerm/ViewModels/TimelineViewModel.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

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
/// digesttap), 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 digestentry 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 thepath: 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()))
}
}