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:
269
ios/App/WebTerm/Screens/DiffScreen.swift
Normal file
269
ios/App/WebTerm/Screens/DiffScreen.swift
Normal 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 入口
|
||||
/// 未接 —— TerminalScreen(T-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 = "重试"
|
||||
}
|
||||
286
ios/App/WebTerm/Screens/ProjectDetailScreen.swift
Normal file
286
ios/App/WebTerm/Screens/ProjectDetailScreen.swift
Normal 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 claudeIcon(public/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) 个客户端"
|
||||
}
|
||||
}
|
||||
184
ios/App/WebTerm/Screens/ProjectsScreen.swift
Normal file
184
ios/App/WebTerm/Screens/ProjectsScreen.swift
Normal 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 section(flat 无 chrome;namespace/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
|
||||
}
|
||||
@@ -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) 台设备在看"
|
||||
|
||||
@@ -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 横幅上的
|
||||
/// "开新会话"(同一动作 —— close→open fresh spawn,cwd 解析在
|
||||
/// `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) {}
|
||||
|
||||
166
ios/App/WebTerm/Screens/TimelineSheet.swift
Normal file
166
ios/App/WebTerm/Screens/TimelineSheet.swift
Normal file
@@ -0,0 +1,166 @@
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-24 · Full activity-timeline drill-down, presented as a sheet from the
|
||||
/// away-digest「展开」affordance (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 = "重试"
|
||||
}
|
||||
Reference in New Issue
Block a user