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:
Yaojia Wang
2026-07-05 16:15:57 +02:00
parent 4871e8ac3d
commit f40b8f9400
52 changed files with 8645 additions and 28 deletions

View File

@@ -0,0 +1,269 @@
import SwiftUI
import WireProtocol
/// T-iOS-27 · Read-only diff viewer web public/diff.ts render-only
/// src/http/diff.ts
///
/// T-iOS-26 ProjectDetail `(endpoint, path)` push/present
/// TerminalScreen per-session cwd
/// TerminalScreenT-iOS-11 Owns toolbar hook线
/// T-iOS-26/29
///
/// SEC-H4 diff ****
/// `Text(verbatim:)` LocalizedStringKey/Markdown/
/// List diff 2MB
/// Text
struct DiffScreen: View {
@State private var viewModel: DiffViewModel
private enum Metrics {
static let pickerHorizontalPadding: CGFloat = 16
static let pickerVerticalPadding: CGFloat = 8
static let lineVerticalInset: CGFloat = 1
static let lineHorizontalInset: CGFloat = 12
static let fileHeaderTopInset: CGFloat = 14
static let statTagSpacing: CGFloat = 8
static let lineBackgroundOpacity = 0.12
}
/// T-iOS-26 `(endpoint, path)` +
init(endpoint: HostEndpoint, path: String, http: any HTTPTransport) {
_viewModel = State(initialValue: .forProject(
endpoint: endpoint, path: path, http: http
))
}
/// / VM
init(viewModel: DiffViewModel) {
_viewModel = State(initialValue: viewModel)
}
var body: some View {
VStack(spacing: 0) {
scopePicker
.padding(.horizontal, Metrics.pickerHorizontalPadding)
.padding(.vertical, Metrics.pickerVerticalPadding)
content
}
.navigationTitle(DiffCopy.title)
.navigationBarTitleDisplayMode(.inline)
.task { await viewModel.load() } // fresh screen one initial fetch
}
// MARK: - staged/unstaged re-fetch VM
private var scopePicker: some View {
Picker(DiffCopy.scopePickerLabel, selection: Binding(
get: { viewModel.staged },
set: { newValue in Task { await viewModel.setStaged(newValue) } }
)) {
Text(DiffCopy.working).tag(false)
Text(DiffCopy.staged).tag(true)
}
.pickerStyle(.segmented)
}
// MARK: - Phase switch
@ViewBuilder private var content: some View {
switch viewModel.phase {
case .loading:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .empty(let truncated):
emptyState(truncated: truncated)
case .failed(let failure):
failedState(failure)
case .loaded(let presentation):
diffList(presentation)
}
}
/// files banner
///
private func emptyState(truncated: Bool) -> some View {
VStack(spacing: 0) {
if truncated { truncatedBanner }
ContentUnavailableView(
DiffCopy.emptyTitle,
systemImage: "checkmark.circle",
description: Text(DiffCopy.emptyDetail)
)
}
}
/// path 400/404 Steps
private func failedState(_ failure: DiffViewModel.Failure) -> some View {
let copy = Self.failureCopy(failure)
return ContentUnavailableView {
Label(copy.title, systemImage: "exclamationmark.triangle")
} description: {
Text(copy.detail)
} actions: {
Button(DiffCopy.retry) {
Task { await viewModel.load() }
}
.buttonStyle(.borderedProminent)
}
}
static func failureCopy(_ failure: DiffViewModel.Failure) -> (title: String, detail: String) {
switch failure {
case .pathInvalid:
return (DiffCopy.failedPathInvalid, DiffCopy.failedPathInvalidDetail)
case .notFound:
return (DiffCopy.failedNotFound, DiffCopy.failedNotFoundDetail)
case .unavailable:
return (DiffCopy.failedUnavailable, DiffCopy.failedUnavailableDetail)
}
}
// MARK: - VM
private func diffList(_ presentation: DiffPresentation) -> some View {
List {
if presentation.truncated {
truncatedBanner
.listRowSeparator(.hidden)
}
ForEach(presentation.rows) { row in
rowView(row)
}
}
.listStyle(.plain)
.environment(\.defaultMinListRowHeight, 0) // diff
}
private var truncatedBanner: some View {
Label(DiffCopy.truncatedBanner, systemImage: "scissors")
.font(.footnote)
.foregroundStyle(.orange)
}
@ViewBuilder private func rowView(_ row: DiffRow) -> some View {
switch row.kind {
case .fileHeader(let header):
fileHeaderRow(header)
case .binaryNotice:
Text(DiffCopy.binaryFile)
.font(.caption.italic())
.foregroundStyle(.secondary)
.listRowSeparator(.hidden)
case .hunkHeader(let header):
diffTextRow(
header, color: .blue,
background: Color.blue.opacity(Metrics.lineBackgroundOpacity)
)
case .line(let kind, let text):
diffTextRow(
text, color: Self.lineColor(kind), background: Self.lineBackground(kind)
)
}
}
private func fileHeaderRow(_ header: DiffFileHeader) -> some View {
HStack(spacing: Metrics.statTagSpacing) {
// verbatim +
Text(verbatim: header.pathLabel)
.font(.footnote.weight(.semibold).monospaced())
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
Text(verbatim: "+\(header.added)")
.font(.caption.monospacedDigit())
.foregroundStyle(.green)
Text(verbatim: "-\(header.removed)")
.font(.caption.monospacedDigit())
.foregroundStyle(.red)
Text(DiffStatusStyle.label(for: header.status))
.font(.caption2)
.foregroundStyle(DiffStatusStyle.color(for: header.status))
}
.padding(.top, Metrics.fileHeaderTopInset)
.accessibilityElement(children: .combine)
}
/// One monospaced diff line. UNTRUSTED server bytes: `Text(verbatim:)`,
/// single-line tail truncation (read-only skim view; no wrapping blob).
private func diffTextRow(_ text: String, color: Color, background: Color) -> some View {
Text(verbatim: text)
.font(.caption.monospaced())
.foregroundStyle(color)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
.listRowBackground(background)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(
top: Metrics.lineVerticalInset,
leading: Metrics.lineHorizontalInset,
bottom: Metrics.lineVerticalInset,
trailing: Metrics.lineHorizontalInset
))
}
// MARK: - kind kind .context
static func lineColor(_ kind: DiffLineKind) -> Color {
switch kind {
case .added: return .green
case .removed: return .red
case .context: return .primary
case .hunk: return .blue
case .meta: return .secondary
}
}
static func lineBackground(_ kind: DiffLineKind) -> Color {
switch kind {
case .added: return .green.opacity(Metrics.lineBackgroundOpacity)
case .removed: return .red.opacity(Metrics.lineBackgroundOpacity)
case .hunk: return .blue.opacity(Metrics.lineBackgroundOpacity)
case .context, .meta: return .clear
}
}
}
// MARK: - status /
enum DiffStatusStyle {
static func label(for status: DiffFileStatus) -> String {
switch status {
case .modified: return "修改"
case .added: return "新增"
case .deleted: return "删除"
case .renamed: return "重命名"
case .binary: return "二进制"
case .untracked: return "未跟踪"
}
}
static func color(for status: DiffFileStatus) -> Color {
switch status {
case .added, .untracked: return .green
case .deleted: return .red
case .renamed: return .blue
case .modified, .binary: return .secondary
}
}
}
// MARK: - plan
enum DiffCopy {
static let title = "代码差异"
static let scopePickerLabel = "差异范围"
static let working = "工作区"
static let staged = "已暂存"
static let truncatedBanner = "差异过大,已截断显示(主机侧 DIFF_MAX_BYTES / DIFF_MAX_FILES 限制)。"
static let emptyTitle = "无改动"
static let emptyDetail = "当前范围下没有可显示的改动。"
static let binaryFile = "二进制文件"
static let failedPathInvalid = "路径无效"
static let failedPathInvalidDetail = "请求的项目路径无效(服务器返回 400请从项目列表重新进入。"
static let failedNotFound = "项目未找到"
static let failedNotFoundDetail = "该路径不是主机上的 git 仓库或已被移除404"
static let failedUnavailable = "差异加载失败"
static let failedUnavailableDetail = "无法从主机获取 diff请检查连接后重试。"
static let retry = "重试"
}

View File

@@ -0,0 +1,286 @@
import APIClient
import SwiftUI
import WireProtocol
/// T-iOS-26 · sessions/worktrees/CLAUDE.md + diff
/// T-iOS-27 `DiffScreen(endpoint:path:http:)`+ ""
///
/// ///CLAUDE.md ****
/// `Text(verbatim:)` LocalizedStringKey/Markdown/
struct ProjectDetailScreen: View {
@State private var viewModel: ProjectDetailViewModel
private let endpoint: HostEndpoint
private let http: any HTTPTransport
private let onOpenClaude: (String) -> Void
@State private var isDiffPresented = false
private enum Metrics {
static let headerSpacing: CGFloat = 4
static let chipSpacing: CGFloat = 6
static let claudeMdLineLimit = 40
}
init(
viewModel: ProjectDetailViewModel,
endpoint: HostEndpoint,
http: any HTTPTransport,
onOpenClaude: @escaping (String) -> Void
) {
_viewModel = State(initialValue: viewModel)
self.endpoint = endpoint
self.http = http
self.onOpenClaude = onOpenClaude
}
var body: some View {
content
.navigationTitle(ProjectDetailCopy.title)
.navigationBarTitleDisplayMode(.inline)
.task { await viewModel.load() }
.navigationDestination(isPresented: $isDiffPresented) {
DiffScreen(endpoint: endpoint, path: viewModel.path, http: http)
}
}
// MARK: - Phase switch
@ViewBuilder private var content: some View {
switch viewModel.phase {
case .loading:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let failure):
failedState(failure)
case .loaded(let detail):
detailList(detail)
}
}
/// 400/404/500 `{error}` Steps
private func failedState(_ failure: ProjectDetailViewModel.Failure) -> some View {
let copy = Self.failureCopy(failure)
return ContentUnavailableView {
Label(copy.title, systemImage: "exclamationmark.triangle")
} description: {
Text(copy.detail)
} actions: {
Button(ProjectDetailCopy.retry) {
Task { await viewModel.load() }
}
.buttonStyle(.borderedProminent)
}
}
static func failureCopy(
_ failure: ProjectDetailViewModel.Failure
) -> (title: String, detail: String) {
switch failure {
case .pathInvalid:
return (ProjectDetailCopy.failedPathInvalid,
ProjectDetailCopy.failedPathInvalidDetail)
case .notFound:
return (ProjectDetailCopy.failedNotFound,
ProjectDetailCopy.failedNotFoundDetail)
case .unavailable:
return (ProjectDetailCopy.failedUnavailable,
ProjectDetailCopy.failedUnavailableDetail)
}
}
// MARK: - Loaded
private func detailList(_ detail: ProjectDetail) -> some View {
List {
headerSection(detail)
actionsSection(detail)
sessionsSection(detail.sessions)
worktreesSection(detail.worktrees)
claudeMdSection(detail)
}
.listStyle(.insetGrouped)
}
private func headerSection(_ detail: ProjectDetail) -> some View {
Section {
VStack(alignment: .leading, spacing: Metrics.headerSpacing) {
Text(verbatim: detail.name)
.font(.headline)
.lineLimit(1)
Text(verbatim: detail.path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
HStack(spacing: Metrics.chipSpacing) {
if let branch = detail.branch {
Label {
Text(verbatim: branch).lineLimit(1)
} icon: {
Image(systemName: "arrow.triangle.branch")
}
.font(.caption)
.foregroundStyle(.secondary)
}
if detail.dirty == true {
Text(ProjectsCopy.dirtyBadge)
.font(.caption2)
.foregroundStyle(.orange)
}
}
}
}
}
private func actionsSection(_ detail: ProjectDetail) -> some View {
Section {
Button {
onOpenClaude(detail.path)
} label: {
Label(ProjectDetailCopy.openClaude, systemImage: "terminal.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
if detail.isGit {
Button {
isDiffPresented = true
} label: {
Label(ProjectDetailCopy.viewDiff, systemImage: "plus.forwardslash.minus")
}
}
}
}
@ViewBuilder private func sessionsSection(_ sessions: [ProjectSessionRef]) -> some View {
Section(ProjectDetailCopy.sessionsHeader) {
if sessions.isEmpty {
Text(ProjectDetailCopy.noSessions)
.font(.footnote)
.foregroundStyle(.secondary)
} else {
ForEach(sessions, id: \.id) { session in
sessionRow(session)
}
}
}
}
private func sessionRow(_ session: ProjectSessionRef) -> some View {
HStack(spacing: Metrics.chipSpacing) {
Text(verbatim: Self.statusGlyph(session.status))
// title cwd verbatim退 id
Text(verbatim: session.title ?? String(
session.id.uuidString.lowercased().prefix(8)
))
.lineLimit(1)
Spacer(minLength: 0)
if session.exited {
Text(ProjectDetailCopy.sessionExited)
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text(ProjectDetailCopy.clientCount(session.clientCount))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
/// web claudeIconpublic/tabs.ts:74-80
static func statusGlyph(_ status: ClaudeStatus) -> String {
switch status {
case .working: return ""
case .waiting: return ""
case .idle: return ""
case .stuck: return ""
case .unknown: return ""
}
}
@ViewBuilder private func worktreesSection(_ worktrees: [WorktreeInfo]) -> some View {
if !worktrees.isEmpty {
Section(ProjectDetailCopy.worktreesHeader) {
ForEach(worktrees, id: \.path) { worktree in
worktreeRow(worktree)
}
}
}
}
private func worktreeRow(_ worktree: WorktreeInfo) -> some View {
VStack(alignment: .leading, spacing: Metrics.headerSpacing) {
HStack(spacing: Metrics.chipSpacing) {
Text(verbatim: worktree.branch ?? ProjectDetailCopy.detachedHead)
.font(.callout)
.lineLimit(1)
if worktree.isMain {
badge(ProjectDetailCopy.worktreeMain)
}
if worktree.isCurrent {
badge(ProjectDetailCopy.worktreeCurrent)
}
if worktree.locked == true {
badge(ProjectDetailCopy.worktreeLocked)
}
}
Text(verbatim: worktree.path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
private func badge(_ text: String) -> some View {
Text(text)
.font(.caption2)
.foregroundStyle(.blue)
}
@ViewBuilder private func claudeMdSection(_ detail: ProjectDetail) -> some View {
if detail.hasClaudeMd {
Section(ProjectDetailCopy.claudeMdHeader) {
if let content = detail.claudeMd {
// verbatim +
Text(verbatim: content)
.font(.caption.monospaced())
.lineLimit(Metrics.claudeMdLineLimit)
} else {
Text(ProjectDetailCopy.claudeMdPresent)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
}
// MARK: - plan §4
enum ProjectDetailCopy {
static let title = "项目详情"
static let openClaude = "在此仓库开新会话"
static let viewDiff = "查看代码差异"
static let sessionsHeader = "运行中的会话"
static let noSessions = "暂无运行中的会话。"
static let sessionExited = "已退出"
static let worktreesHeader = "Worktrees"
static let worktreeMain = ""
static let worktreeCurrent = "当前"
static let worktreeLocked = "已锁定"
static let detachedHead = "(分离 HEAD"
static let claudeMdHeader = "CLAUDE.md"
static let claudeMdPresent = "本仓库包含 CLAUDE.md。"
static let retry = "重试"
static let failedPathInvalid = "路径无效"
static let failedPathInvalidDetail = "请求的项目路径无效(服务器返回 400请从项目列表重新进入。"
static let failedNotFound = "项目未找到"
static let failedNotFoundDetail = "该路径不在主机的项目根目录下或已被移除404"
static let failedUnavailable = "详情加载失败"
static let failedUnavailableDetail = "无法从主机获取项目详情,请检查连接后重试。"
static func clientCount(_ count: Int) -> String {
"\(count) 个客户端"
}
}

View File

@@ -0,0 +1,184 @@
import APIClient
import SwiftUI
/// T-iOS-26 · Projects web v0.6 projects namespace
/// + + dirty + Active now +
/// /// `ProjectsViewModel`
///
/// //**** `Text(verbatim:)`
/// LocalizedStringKey/Markdown
struct ProjectsScreen: View {
@Bindable var viewModel: ProjectsViewModel
/// "" AppCoordinator.openProject
var onOpen: (ProjectOpenRequest) -> Void = { _ in }
private enum Metrics {
static let rowSpacing: CGFloat = 2
static let chipSpacing: CGFloat = 6
}
var body: some View {
list
.navigationTitle(ProjectsCopy.title)
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $viewModel.searchText, prompt: ProjectsCopy.searchPrompt)
.refreshable { await viewModel.refresh() }
.task { await viewModel.load() }
.onChange(of: viewModel.openRequest) { _, request in
guard let request else { return }
onOpen(request)
}
.navigationDestination(for: ProjectRoute.self) { route in
ProjectDetailScreen(
viewModel: viewModel.makeDetailViewModel(path: route.path),
endpoint: viewModel.host.endpoint,
http: viewModel.http,
onOpenClaude: { viewModel.requestOpenClaude(cwd: $0) }
)
}
}
// MARK: - List
private var list: some View {
List {
errorRows
if let message = viewModel.emptyStateMessage {
Text(message)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.listRowSeparator(.hidden)
}
ForEach(viewModel.groups) { group in
groupSection(group)
}
}
.listStyle(.insetGrouped)
.overlay {
if !viewModel.hasLoadedOnce && viewModel.fetchErrorMessage == nil {
ProgressView()
}
}
}
/// prefs
@ViewBuilder private var errorRows: some View {
ForEach(
[
viewModel.fetchErrorMessage,
viewModel.prefsErrorMessage,
viewModel.prefsSyncErrorMessage,
viewModel.openErrorMessage,
].compactMap(\.self),
id: \.self
) { message in
Label(message, systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.orange)
.listRowSeparator(.hidden)
}
}
// MARK: - Group sectionflat chromenamespace/other
@ViewBuilder private func groupSection(_ group: ProjectGroup) -> some View {
let isCollapsed = viewModel.isCollapsed(group)
Section {
if !isCollapsed {
ForEach(group.projects, id: \.path) { project in
projectRow(project, group: group)
}
}
} header: {
if group.kind != .flat {
groupHeader(group, isCollapsed: isCollapsed)
}
}
}
private func groupHeader(_ group: ProjectGroup, isCollapsed: Bool) -> some View {
HStack(spacing: Metrics.chipSpacing) {
if group.isCollapsible {
Image(systemName: isCollapsed ? "chevron.right" : "chevron.down")
.font(.caption2)
}
// namespace label verbatim
Text(verbatim: group.label)
.lineLimit(1)
Text(verbatim: "\(group.projects.count)")
.foregroundStyle(.secondary)
// web
if group.kind != .active && group.activeCount > 0 {
Text(ProjectsCopy.activeCountBadge(group.activeCount))
.font(.caption2)
.foregroundStyle(.green)
}
Spacer(minLength: 0)
}
.contentShape(Rectangle())
.onTapGesture {
guard group.isCollapsible else { return }
Task { await viewModel.toggleCollapsed(key: group.key) }
}
.accessibilityAddTraits(group.isCollapsible ? .isButton : [])
}
// MARK: - Project row
private func projectRow(_ project: ProjectInfo, group: ProjectGroup) -> some View {
NavigationLink(value: ProjectRoute(path: project.path)) {
HStack(spacing: Metrics.chipSpacing) {
favouriteButton(project)
VStack(alignment: .leading, spacing: Metrics.rowSpacing) {
Text(verbatim: ProjectGrouping.displayLabel(
name: project.name, groupKey: group.key
))
.font(.body.weight(.medium))
.lineLimit(1)
projectChips(project)
}
Spacer(minLength: 0)
if ProjectGrouping.hasRunningSession(project) {
Image(systemName: "circle.fill")
.font(.caption2)
.foregroundStyle(.green)
.accessibilityLabel(ProjectsCopy.activeCountBadge(1))
}
}
}
}
private func favouriteButton(_ project: ProjectInfo) -> some View {
Button {
Task { await viewModel.toggleFavourite(path: project.path) }
} label: {
Image(systemName: viewModel.isFavourite(project.path) ? "star.fill" : "star")
.foregroundStyle(.yellow)
}
.buttonStyle(.borderless) // List
}
@ViewBuilder private func projectChips(_ project: ProjectInfo) -> some View {
HStack(spacing: Metrics.chipSpacing) {
if let branch = project.branch {
Label {
Text(verbatim: branch).lineLimit(1)
} icon: {
Image(systemName: "arrow.triangle.branch")
}
.font(.caption)
.foregroundStyle(.secondary)
}
if project.dirty == true {
Text(ProjectsCopy.dirtyBadge)
.font(.caption2)
.foregroundStyle(.orange)
}
}
}
}
/// push value-based navigation
struct ProjectRoute: Hashable {
let path: String
}

View File

@@ -16,6 +16,11 @@ struct SessionListScreen: View {
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
@@ -60,7 +65,7 @@ struct SessionListScreen: View {
Button {
viewModel.openSession(id: row.id)
} label: {
SessionRowView(row: row)
SessionRowView(row: row, thumbnail: thumbnailSlot(for: row))
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
@@ -74,6 +79,23 @@ struct SessionListScreen: View {
.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)
@@ -142,15 +164,26 @@ struct SessionListScreen: View {
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) {
Text(title)
.font(.body)
.lineLimit(1)
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)
@@ -158,6 +191,10 @@ private struct SessionRowView: View {
TelemetryChips(model: telemetry)
}
}
if let thumbnail {
Spacer(minLength: Metrics.titleSpacing)
thumbnail
}
}
.opacity(row.info.exited ? Metrics.exitedOpacity : 1)
.accessibilityElement(children: .combine)
@@ -179,8 +216,19 @@ private struct SessionRowView: View {
}
}
/// `cwd` is server-supplied display text (untrusted plain Text only).
/// 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
}
@@ -213,6 +261,8 @@ private struct SessionRowView: View {
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
}
}
@@ -231,6 +281,7 @@ private enum ScreenCopy {
static let unknownDirectory = "未知目录"
static let exitedTag = "已退出"
static let pendingBadgeLabel = "等待审批"
static let unreadLabel = "有新输出"
static func clientCount(_ count: Int) -> String {
"\(count) 台设备在看"

View File

@@ -20,26 +20,51 @@ import UIKit
/// T-iOS-15 this screen only renders and routes.
struct TerminalScreen: View {
let viewModel: TerminalViewModel
/// T-iOS-29 · ""toolbar + exit
/// "" closeopen fresh spawncwd
/// `AppCoordinator.openNewSessionInCurrentCwd`nil =
/// / coordinator
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
private enum Metrics {
static let bannerHorizontalPadding: CGFloat = 12
static let bannerTopPadding: CGFloat = 8
}
private enum Copy {
static let newSessionInCwd = "在当前目录开新会话"
}
var body: some View {
TerminalHostView(viewModel: viewModel)
.ignoresSafeArea(.container, edges: .bottom)
.overlay(alignment: .top) {
if let model = viewModel.bannerModel {
ReconnectBanner(model: model)
ReconnectBanner(model: model, onNewSession: onNewSessionInCwd)
.padding(.horizontal, Metrics.bannerHorizontalPadding)
.padding(.top, Metrics.bannerTopPadding)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.animation(.default, value: viewModel.bannerModel)
.toolbar { newSessionToolbarItem }
.onAppear { viewModel.start() }
}
/// Mirrors web `tabs.ts newTab()` (M6): the + affordance opens a fresh
/// session in the active session's cwd, if known.
@ToolbarContentBuilder private var newSessionToolbarItem: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
if let onNewSessionInCwd {
Button {
onNewSessionInCwd()
} label: {
Label(Copy.newSessionInCwd, systemImage: "plus.rectangle.on.folder")
}
.accessibilityIdentifier("terminal.newInCwdButton")
}
}
}
}
// MARK: - SwiftTerm bridge
@@ -112,9 +137,15 @@ private struct TerminalHostView: UIViewRepresentable {
UIApplication.shared.open(url)
}
// Title/cwd surface in the session list via the server (T-iOS-13/23),
// not from the local emulator deliberate no-ops.
func setTerminalTitle(source: TerminalView, title: String) {}
/// OSC 0/2 title (T-iOS-23): hostile input the VM sanitizes
/// (TitleSanitizer) before any UI/registry use, then the wiring
/// surfaces it on the session-list row.
func setTerminalTitle(source: TerminalView, title: String) {
viewModel.setTerminalTitle(title)
}
// cwd surfaces in the session list via the server (T-iOS-13), not
// from the local emulator deliberate no-op.
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
func scrolled(source: TerminalView, position: Double) {}
func rangeChanged(source: TerminalView, startY: Int, endY: Int) {}

View File

@@ -0,0 +1,166 @@
import SwiftUI
import WireProtocol
/// T-iOS-24 · Full activity-timeline drill-down, presented as a sheet from the
/// away-digestaffordance (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 = "重试"
}