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
This commit is contained in:
235
ios/App/WebTerm/Screens/SessionListScreen.swift
Normal file
235
ios/App/WebTerm/Screens/SessionListScreen.swift
Normal file
@@ -0,0 +1,235 @@
|
||||
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) 台设备在看"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user