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

167 lines
6.0 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import WireProtocol
/// T-iOS-24 · Full activity-timeline drill-down, presented as a sheet from the
/// away-digestaffordance (TerminalContainerView wiring).
///
/// Mirrors the web timeline panel (public/timeline.ts): rows are
/// "HH:MM · icon · label", newest-first, capped at
/// `TimelineViewModel.maxEvents`. `label` is SERVER text (untrusted display
/// input) rendered via `Text(verbatim:)` only, `lineLimit(1)` (same row
/// discipline as AwayDigestView; web sets it via textContent, SEC-H6).
struct TimelineSheet: View {
let viewModel: TimelineViewModel
private enum Metrics {
static let rowSpacing: CGFloat = 10
static let iconColumnWidth: CGFloat = 24
}
var body: some View {
NavigationStack {
content
.navigationTitle(TimelineCopy.title)
.navigationBarTitleDisplayMode(.inline)
}
.presentationDetents([.medium, .large])
.task { await viewModel.load() } // fresh VM per presentation one fetch
}
// MARK: - Phase switch
@ViewBuilder private var content: some View {
switch viewModel.phase {
case .loading:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .empty:
emptyState
case .failed:
failedState
case .loaded(let events):
eventList(events)
}
}
/// Server replied `[]` covers both "no activity yet" and timeline
/// capture disabled host-side (src/server.ts:589-591). An empty timeline
/// is a normal state, NEVER an error (task spec).
private var emptyState: some View {
ContentUnavailableView(
TimelineCopy.emptyTitle,
systemImage: "clock.badge.questionmark",
description: Text(TimelineCopy.emptyDetail)
)
}
/// Explicit, retryable error state the fetch can fail transiently
/// (LAN hop, host asleep); retry re-runs the same load path.
private var failedState: some View {
ContentUnavailableView {
Label(TimelineCopy.loadFailed, systemImage: "wifi.exclamationmark")
} description: {
Text(TimelineCopy.loadFailedDetail)
} actions: {
Button(TimelineCopy.retry) {
Task { await viewModel.load() }
}
.buttonStyle(.borderedProminent)
}
}
// MARK: - Rows (newest-first, already ordered by the VM)
private func eventList(_ events: [TimelineEvent]) -> some View {
List {
// Offset identity (not `at`): the server can ingest several
// events in the same millisecond, and rows are static.
ForEach(Array(events.enumerated()), id: \.offset) { _, event in
row(event)
}
}
.listStyle(.plain)
}
private func row(_ event: TimelineEvent) -> some View {
HStack(spacing: Metrics.rowSpacing) {
Text(TimelineRowFormat.timeLabel(atMs: event.at))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
Text(verbatim: TimelineClassStyle.glyph(for: event.class))
.font(.callout)
.foregroundStyle(TimelineClassStyle.color(for: event.class))
.frame(width: Metrics.iconColumnWidth)
// Server-derived phrase untrusted: verbatim (never
// LocalizedStringKey/Markdown) + hard single-line truncation.
Text(verbatim: event.label)
.font(.subheadline)
.lineLimit(1)
Spacer(minLength: 0)
}
.accessibilityElement(children: .combine)
}
}
// MARK: - class icon / color mapping
/// Glyphs mirror web `timelineIcon` verbatim (public/timeline.ts:88-96).
/// Colors are semantic the web CSS defines NO tl-icon-* colors, so iOS
/// aligns with SessionListScreen's status color convention (waiting=orange,
/// stuck=red) and extends it. Total functions: the server is untrusted, so an
/// unknown class degrades to a neutral glyph/color instead of trapping (even
/// though `APIClient.events` already drops unknown classes defense in depth).
enum TimelineClassStyle {
static let fallbackGlyph = ""
static func glyph(for cls: String) -> String {
switch cls {
case "tool": return "🔧"
case "waiting": return ""
case "done": return ""
case "stuck": return ""
case "user": return "💬"
default: return fallbackGlyph
}
}
static func color(for cls: String) -> Color {
switch cls {
case "tool": return .blue
case "waiting": return .orange
case "done": return .green
case "stuck": return .red
case "user": return .purple
default: return .secondary
}
}
}
// MARK: - Row time formatting
/// Mirrors web `formatHHMM` (public/timeline.ts:101-106): 24-hour wall-clock
/// "HH:mm". Fixed POSIX locale so a 12-hour user locale can't leak AM/PM into
/// the fixed format; timezone injectable for deterministic tests.
enum TimelineRowFormat {
private static let millisecondsPerSecond = 1_000.0
static func timeLabel(atMs: Int, timeZone: TimeZone = .current) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = timeZone
formatter.dateFormat = "HH:mm"
let date = Date(timeIntervalSince1970: Double(atMs) / millisecondsPerSecond)
return formatter.string(from: date)
}
}
// MARK: - plan
enum TimelineCopy {
static let title = "活动时间线"
static let emptyTitle = "暂无活动"
static let emptyDetail = "会话还没有可展示的事件主机关闭时间线TIMELINE_ENABLED=0时也会显示为空。"
static let loadFailed = "时间线加载失败"
static let loadFailedDetail = "无法从主机获取活动时间线,请检查连接后重试。"
static let retry = "重试"
}