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
290 lines
11 KiB
Swift
290 lines
11 KiB
Swift
import HostRegistry
|
||
import SwiftUI
|
||
import WireProtocol
|
||
|
||
/// T-iOS-13 · Session list screen (merged chooser + dashboard). Pure
|
||
/// presentation over `SessionListViewModel` — polling cadence, badge priority,
|
||
/// staleness, optimistic kill and the navigation signal are all VM logic,
|
||
/// unit-tested in `SessionListViewModelTests`.
|
||
///
|
||
/// The T-iOS-15 wiring provides `onOpen` (push `TerminalScreen`, open with
|
||
/// `request.sessionId` — nil = new session) and `onAddHost` (present the
|
||
/// pairing sheet; call `viewModel.reloadHosts()` when it completes).
|
||
struct SessionListScreen: View {
|
||
var viewModel: SessionListViewModel
|
||
/// Navigation hook: fired once per `OpenRequest` (unique id per tap).
|
||
var onOpen: (SessionListViewModel.OpenRequest) -> Void = { _ in }
|
||
/// Host-switch header hook: "添加主机" entry (pairing sheet, T-iOS-15).
|
||
var onAddHost: () -> Void = {}
|
||
/// T-iOS-28 (additive slot) · shared thumbnail pipeline: one cache + one
|
||
/// render-concurrency gate across ALL rows (scrolling must never spawn
|
||
/// unbounded offscreen terminals). `@State` keeps it stable across body
|
||
/// rebuilds; `live()` is cheap (the URLSession is a static shared).
|
||
@State private var thumbnails = SessionThumbnailPipeline.live()
|
||
|
||
var body: some View {
|
||
content
|
||
.navigationTitle(ScreenCopy.title)
|
||
.toolbar { hostMenu }
|
||
.onAppear { viewModel.appeared() }
|
||
.onDisappear { viewModel.disappeared() }
|
||
.onChange(of: viewModel.openRequest) { _, request in
|
||
guard let request else { return }
|
||
onOpen(request)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private var content: some View {
|
||
switch viewModel.emptyState {
|
||
case .notPaired:
|
||
notPairedView
|
||
case .noSessions:
|
||
noSessionsView
|
||
case nil:
|
||
sessionList
|
||
}
|
||
}
|
||
|
||
// MARK: - List
|
||
|
||
private var sessionList: some View {
|
||
List {
|
||
if let message = viewModel.fetchErrorMessage {
|
||
errorRow(message)
|
||
}
|
||
if let message = viewModel.killErrorMessage {
|
||
errorRow(message)
|
||
}
|
||
Button {
|
||
viewModel.requestNewSession()
|
||
} label: {
|
||
Label(ScreenCopy.newSession, systemImage: "plus.circle.fill")
|
||
}
|
||
.accessibilityIdentifier("sessions.newButton")
|
||
ForEach(viewModel.rows) { row in
|
||
Button {
|
||
viewModel.openSession(id: row.id)
|
||
} label: {
|
||
SessionRowView(row: row, thumbnail: thumbnailSlot(for: row))
|
||
}
|
||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||
Button(role: .destructive) {
|
||
Task { await viewModel.kill(sessionId: row.id) }
|
||
} label: {
|
||
Label(ScreenCopy.kill, systemImage: "xmark.circle.fill")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.refreshable { await viewModel.refresh() }
|
||
}
|
||
|
||
/// T-iOS-28 · build one row's thumbnail slot. No paired host (defensive —
|
||
/// rows imply a host) → no slot; the request key carries `lastOutputAt`
|
||
/// so unchanged sessions render exactly once (pipeline cache).
|
||
private func thumbnailSlot(
|
||
for row: SessionListViewModel.SessionRow
|
||
) -> SessionThumbnailView? {
|
||
guard let endpoint = viewModel.activeHost?.endpoint else { return nil }
|
||
return SessionThumbnailView(
|
||
request: SessionThumbnailRequest(
|
||
endpoint: endpoint,
|
||
sessionId: row.id,
|
||
lastOutputAt: row.info.lastOutputAt
|
||
),
|
||
pipeline: thumbnails
|
||
)
|
||
}
|
||
|
||
private func errorRow(_ message: String) -> some View {
|
||
Label(message, systemImage: "exclamationmark.triangle")
|
||
.font(.footnote)
|
||
.foregroundStyle(.red)
|
||
}
|
||
|
||
// MARK: - Empty states
|
||
|
||
private var notPairedView: some View {
|
||
ContentUnavailableView {
|
||
Label(ScreenCopy.notPairedTitle, systemImage: "personalhotspot")
|
||
} description: {
|
||
Text(ScreenCopy.notPairedHint)
|
||
} actions: {
|
||
Button(ScreenCopy.addHost) { onAddHost() }
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
}
|
||
|
||
private var noSessionsView: some View {
|
||
ContentUnavailableView {
|
||
Label(ScreenCopy.noSessionsTitle, systemImage: "terminal")
|
||
} description: {
|
||
Text(ScreenCopy.noSessionsHint)
|
||
} actions: {
|
||
Button(ScreenCopy.newSession) { viewModel.requestNewSession() }
|
||
.buttonStyle(.borderedProminent)
|
||
.accessibilityIdentifier("sessions.newButton")
|
||
}
|
||
}
|
||
|
||
// MARK: - Host-switch header (multi-host from HostStore)
|
||
|
||
private var hostMenu: some ToolbarContent {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Menu {
|
||
ForEach(viewModel.hosts) { host in
|
||
Button {
|
||
Task { await viewModel.selectHost(id: host.id) }
|
||
} label: {
|
||
if host.id == viewModel.activeHost?.id {
|
||
Label(host.name, systemImage: "checkmark")
|
||
} else {
|
||
Text(host.name)
|
||
}
|
||
}
|
||
}
|
||
Divider()
|
||
Button {
|
||
onAddHost()
|
||
} label: {
|
||
Label(ScreenCopy.addHost, systemImage: "plus")
|
||
}
|
||
} label: {
|
||
Label(
|
||
viewModel.activeHost?.name ?? ScreenCopy.hostMenuFallback,
|
||
systemImage: "desktopcomputer"
|
||
)
|
||
}
|
||
.accessibilityIdentifier("sessions.hostMenu")
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Row
|
||
|
||
private struct SessionRowView: View {
|
||
let row: SessionListViewModel.SessionRow
|
||
/// T-iOS-28 (additive) · trailing live-preview thumbnail; nil = no slot
|
||
/// (no host / preview-less contexts) and the row lays out as before.
|
||
var thumbnail: SessionThumbnailView?
|
||
|
||
var body: some View {
|
||
HStack(alignment: .top, spacing: Metrics.rowSpacing) {
|
||
indicator
|
||
.frame(width: Metrics.indicatorWidth)
|
||
VStack(alignment: .leading, spacing: Metrics.rowInnerSpacing) {
|
||
HStack(spacing: Metrics.titleSpacing) {
|
||
// T-iOS-23: OSC titles are attacker-controlled — already
|
||
// sanitized in the VM, rendered verbatim (no Markdown /
|
||
// LocalizedStringKey interpretation), one line only.
|
||
Text(verbatim: title)
|
||
.font(.body)
|
||
.lineLimit(1)
|
||
if row.isUnread {
|
||
unreadDot
|
||
}
|
||
}
|
||
Text(meta)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
if let telemetry = row.telemetry, !telemetry.isEmpty {
|
||
TelemetryChips(model: telemetry)
|
||
}
|
||
}
|
||
if let thumbnail {
|
||
Spacer(minLength: Metrics.titleSpacing)
|
||
thumbnail
|
||
}
|
||
}
|
||
.opacity(row.info.exited ? Metrics.exitedOpacity : 1)
|
||
.accessibilityElement(children: .combine)
|
||
}
|
||
|
||
/// ⚠ badge outranks the status dot (VM decides via `indicator`).
|
||
@ViewBuilder private var indicator: some View {
|
||
switch row.indicator {
|
||
case .pendingApproval:
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
.foregroundStyle(.orange)
|
||
.accessibilityLabel(ScreenCopy.pendingBadgeLabel)
|
||
case .status(let status):
|
||
Circle()
|
||
.fill(Self.dotColor(status))
|
||
.frame(width: Metrics.dotSize, height: Metrics.dotSize)
|
||
.padding(.top, Metrics.dotTopPadding)
|
||
.accessibilityLabel(Text(status.rawValue))
|
||
}
|
||
}
|
||
|
||
/// Unread dot (T-iOS-23): output newer than the local last-seen watermark.
|
||
private var unreadDot: some View {
|
||
Circle()
|
||
.fill(.blue)
|
||
.frame(width: Metrics.unreadDotSize, height: Metrics.unreadDotSize)
|
||
.accessibilityLabel(ScreenCopy.unreadLabel)
|
||
}
|
||
|
||
/// Sanitized OSC title first (T-iOS-23, mirrors web autoTitle precedence,
|
||
/// public/tabs.ts:570), else the cwd-derived name. Both are server-supplied
|
||
/// display text (untrusted → verbatim Text only).
|
||
private var title: String {
|
||
if let osc = row.title, !osc.isEmpty { return osc }
|
||
guard let cwd = row.info.cwd, !cwd.isEmpty else { return ScreenCopy.unknownDirectory }
|
||
return URL(fileURLWithPath: cwd).lastPathComponent
|
||
}
|
||
|
||
private var meta: String {
|
||
var parts = [
|
||
ScreenCopy.clientCount(row.info.clientCount),
|
||
"\(row.info.cols)×\(row.info.rows)",
|
||
]
|
||
if row.info.exited {
|
||
parts.append(ScreenCopy.exitedTag)
|
||
}
|
||
return parts.joined(separator: " · ")
|
||
}
|
||
|
||
private static func dotColor(_ status: ClaudeStatus) -> Color {
|
||
switch status {
|
||
case .working: return .green
|
||
case .waiting: return .orange
|
||
case .idle: return .blue
|
||
case .stuck: return .red
|
||
case .unknown: return .gray
|
||
}
|
||
}
|
||
|
||
private enum Metrics {
|
||
static let rowSpacing: CGFloat = 10
|
||
static let rowInnerSpacing: CGFloat = 3
|
||
static let indicatorWidth: CGFloat = 18
|
||
static let dotSize: CGFloat = 10
|
||
static let dotTopPadding: CGFloat = 5
|
||
static let exitedOpacity = 0.55
|
||
static let titleSpacing: CGFloat = 6
|
||
static let unreadDotSize: CGFloat = 8
|
||
}
|
||
}
|
||
|
||
// MARK: - Screen copy
|
||
|
||
private enum ScreenCopy {
|
||
static let title = "会话"
|
||
static let newSession = "新建会话"
|
||
static let kill = "结束"
|
||
static let addHost = "配对新主机"
|
||
static let hostMenuFallback = "主机"
|
||
static let notPairedTitle = "还没有配对的主机"
|
||
static let notPairedHint = "先配对你电脑上的 web-terminal(扫码或手输地址),会话会出现在这里。"
|
||
static let noSessionsTitle = "主机上没有运行中的会话"
|
||
static let noSessionsHint = "新建一个会话开始工作;关掉 App 后会话仍会在主机上继续跑。"
|
||
static let unknownDirectory = "未知目录"
|
||
static let exitedTag = "已退出"
|
||
static let pendingBadgeLabel = "等待审批"
|
||
static let unreadLabel = "有新输出"
|
||
|
||
static func clientCount(_ count: Int) -> String {
|
||
"\(count) 台设备在看"
|
||
}
|
||
}
|