Files
web-terminal/ios/App/WebTerm/Screens/SessionListScreen.swift
Yaojia Wang 5098643355 feat(ios): W3 UI layer + integration CI + ntfy docs
T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM)
T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling
T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field)
T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics
T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed)
T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites)
Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
2026-07-05 00:13:14 +02:00

236 lines
8.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 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 = {}
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")
}
ForEach(viewModel.rows) { row in
Button {
viewModel.openSession(id: row.id)
} label: {
SessionRowView(row: 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() }
}
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)
}
}
// 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"
)
}
}
}
}
// MARK: - Row
private struct SessionRowView: View {
let row: SessionListViewModel.SessionRow
var body: some View {
HStack(alignment: .top, spacing: Metrics.rowSpacing) {
indicator
.frame(width: Metrics.indicatorWidth)
VStack(alignment: .leading, spacing: Metrics.rowInnerSpacing) {
Text(title)
.font(.body)
.lineLimit(1)
Text(meta)
.font(.caption)
.foregroundStyle(.secondary)
if let telemetry = row.telemetry, !telemetry.isEmpty {
TelemetryChips(model: telemetry)
}
}
}
.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))
}
}
/// `cwd` is server-supplied display text (untrusted plain Text only).
private var title: String {
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
}
}
// 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 func clientCount(_ count: Int) -> String {
"\(count) 台设备在看"
}
}