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:
166
ios/App/WebTerm/Screens/TimelineSheet.swift
Normal file
166
ios/App/WebTerm/Screens/TimelineSheet.swift
Normal file
@@ -0,0 +1,166 @@
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-24 · Full activity-timeline drill-down, presented as a sheet from the
|
||||
/// away-digest「展开」affordance (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 = "重试"
|
||||
}
|
||||
Reference in New Issue
Block a user