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
This commit is contained in:
81
ios/App/WebTerm/ViewModels/TimelineViewModel.swift
Normal file
81
ios/App/WebTerm/ViewModels/TimelineViewModel.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
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()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user