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`. /// /// UX polish (G1-list): the row is restructured for the 5-second glance — /// prominent title, a `StatusBadge` (semantic color + distinct SF Symbol, never /// color alone), a monospaced meta line, and telemetry as proper /// `TelemetryChip`s. Rows are DS `Card`s; active/exited sessions are split under /// `SectionHeader`s (presentation-only — the VM already groups exited last). /// Every color/spacing/radius/font/motion comes from `DS.*`. /// /// 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 = {} /// C-iOS-3 (HIGH reachability fix) · "设备证书" entry — presents /// `ClientCertScreen` (import / rotate the mTLS device cert). The device /// cert is a global concern, but the toolbar host-menu is the app's only /// settings-like surface and is present in every empty state, so this is the /// nav idiom that makes the orphaned screen reachable. var onDeviceCert: () -> Void = {} /// B3 · "自动获取证书" entry — presents `EnrollmentScreen` (the zero-`.p12` /// enrollment flow: login → Secure-Enclave key + CSR → device cert). Same /// host-menu surface as `onDeviceCert`; that screen imports a `.p12`, this /// one obtains a hardware-bound cert with no file at all. var onEnroll: () -> 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 // Accent is not injected app-wide; scope it here so native chrome // (nav bar, bordered controls) picks up the DS indigo. Presentation // only — no behavior change. .tint(DS.Palette.accent) .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) } newSessionRow if !activeRows.isEmpty { Section { ForEach(activeRows) { row in sessionRow(row) } } header: { SectionHeader(title: ScreenCopy.activeSection) } } if !exitedRows.isEmpty { Section { ForEach(exitedRows) { row in sessionRow(row) } } header: { SectionHeader(title: ScreenCopy.exitedSection) } } } .listStyle(.plain) .refreshable { await viewModel.refresh() } } /// Split the VM's (already exited-last) rows into the two visual groups. /// Presentation only — no ordering/logic decision lives here. private var activeRows: [SessionListViewModel.SessionRow] { viewModel.rows.filter { !$0.info.exited } } private var exitedRows: [SessionListViewModel.SessionRow] { viewModel.rows.filter { $0.info.exited } } /// Inviting primary entry — accent-tinted card row. private var newSessionRow: some View { Button { viewModel.requestNewSession() } label: { NewSessionRow() } .buttonStyle(.plain) .accessibilityIdentifier("sessions.newButton") .listRowSeparator(.hidden) .listRowInsets(rowInsets(vertical: DS.Space.sm8)) .listRowBackground(Color.clear) } private func sessionRow(_ row: SessionListViewModel.SessionRow) -> some View { Button { viewModel.openSession(id: row.id) } label: { SessionRowView(row: row, thumbnail: thumbnailSlot(for: row)) } .buttonStyle(.plain) .listRowSeparator(.hidden) .listRowInsets(rowInsets(vertical: DS.Space.xs4)) .listRowBackground(Color.clear) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { Task { await viewModel.kill(sessionId: row.id) } } label: { Label(ScreenCopy.kill, systemImage: "xmark.circle.fill") } } } /// Carded-list row inset: full-bleed gutter (`lg16`) + a small vertical gap /// so adjacent cards breathe. private func rowInsets(vertical: CGFloat) -> EdgeInsets { EdgeInsets( top: vertical, leading: DS.Space.lg16, bottom: vertical, trailing: DS.Space.lg16 ) } /// 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.fill") .font(DS.Typography.caption) .foregroundStyle(DS.Palette.statusStuck) .listRowSeparator(.hidden) .listRowInsets(rowInsets(vertical: DS.Space.xs4)) .listRowBackground(Color.clear) } // MARK: - Empty states private var notPairedView: some View { ContentUnavailableView { Label(ScreenCopy.notPairedTitle, systemImage: "personalhotspot") } description: { Text(ScreenCopy.notPairedHint) } actions: { Button(ScreenCopy.addHost) { onAddHost() } .buttonStyle(DSButtonStyle(kind: .primary)) } } private var noSessionsView: some View { ContentUnavailableView { Label(ScreenCopy.noSessionsTitle, systemImage: "terminal") } description: { Text(ScreenCopy.noSessionsHint) } actions: { Button(ScreenCopy.newSession) { viewModel.requestNewSession() } .buttonStyle(DSButtonStyle(kind: .primary)) .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") } Button { onEnroll() } label: { Label(ScreenCopy.enroll, systemImage: "checkmark.shield") } .accessibilityIdentifier("sessions.enroll") Button { onDeviceCert() } label: { Label(ScreenCopy.deviceCert, systemImage: "lock.shield") } .accessibilityIdentifier("sessions.deviceCert") } label: { Label( viewModel.activeHost?.name ?? ScreenCopy.hostMenuFallback, systemImage: "desktopcomputer" ) } .accessibilityIdentifier("sessions.hostMenu") } } } // MARK: - Row /// Inviting "新建会话" card — accent icon + accent title on a DS `Card`. private struct NewSessionRow: View { var body: some View { Card(padding: DS.Space.md12) { HStack(spacing: DS.Space.md12) { Image(systemName: "plus.circle.fill") .font(DS.Typography.title) .foregroundStyle(DS.Palette.accent) Text(ScreenCopy.newSession) .font(DS.Typography.headline) .foregroundStyle(DS.Palette.accent) Spacer(minLength: 0) } } } } /// One session row: `StatusBadge` · title · mono meta · telemetry chips · /// optional live thumbnail — laid out inside a DS `Card`. 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 { Card(padding: DS.Space.md12) { HStack(alignment: .top, spacing: DS.Space.md12) { // ⚠ pending / exited outrank the raw status (VM decides pending; // exited is a row fact) — resolved to a single DisplayStatus so // the badge shows color + distinct shape + Chinese VoiceOver. StatusBadge(status: displayStatus) .padding(.top, DS.Space.xs2) VStack(alignment: .leading, spacing: DS.Space.xs4) { titleLine Text(meta) .dsMetaText() .lineLimit(1) if let telemetry = row.telemetry, !telemetry.isEmpty { TelemetryChips(model: telemetry) } } if let thumbnail { Spacer(minLength: DS.Space.sm8) thumbnail } } } .opacity(row.info.exited ? DS.Opacity.exited : 1) .accessibilityElement(children: .combine) } private var titleLine: some View { HStack(spacing: DS.Space.sm8) { // 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(DS.Typography.headline) .foregroundStyle(DS.Palette.textPrimary) .lineLimit(1) if row.isUnread { unreadDot } Spacer(minLength: 0) } } /// Unread dot (T-iOS-23): output newer than the local last-seen watermark. /// Accent (indigo) continues the web selection color — distinct from the /// gray/green status shapes. private var unreadDot: some View { Circle() .fill(DS.Palette.accent) .frame(width: DS.Space.sm8, height: DS.Space.sm8) .accessibilityLabel(ScreenCopy.unreadLabel) } /// Resolve the row's status to one `DisplayStatus`. Exited is a terminal /// fact (top precedence); otherwise the VM's badge priority stands /// (pending ⚠ outranks the live status). No VM logic re-implemented here. private var displayStatus: DisplayStatus { if row.info.exited { return .exited } switch row.indicator { case .pendingApproval: return .pendingApproval case .status(let status): return DisplayStatus(status) } } /// 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 } /// Client count + `cols×rows`, rendered mono-tabular via `.dsMetaText()`. /// The exited state is conveyed by the badge + section + dimming, so it is /// not duplicated here. private var meta: String { [ ScreenCopy.clientCount(row.info.clientCount), "\(row.info.cols)×\(row.info.rows)", ].joined(separator: " · ") } } // MARK: - Screen copy private enum ScreenCopy { static let title = "会话" static let newSession = "新建会话" static let kill = "结束" static let addHost = "配对新主机" static let deviceCert = "设备证书" static let enroll = "自动获取证书" static let hostMenuFallback = "主机" static let notPairedTitle = "还没有配对的主机" static let notPairedHint = "先配对你电脑上的 web-terminal(扫码或手输地址),会话会出现在这里。" static let noSessionsTitle = "主机上没有运行中的会话" static let noSessionsHint = "新建一个会话开始工作;关掉 App 后会话仍会在主机上继续跑。" static let unknownDirectory = "未知目录" static let unreadLabel = "有新输出" static let activeSection = "运行中" static let exitedSection = "已结束" static func clientCount(_ count: Int) -> String { "\(count) 台设备在看" } }