Freeze a shared design system (DesignSystem/{Tokens,Typography,StatusStyle,
Primitives}.swift): indigo #7C8CFF accent (root .tint), semantic status colors,
2-4-8-12-16-20-24 spacing + sm8/md12/lg16 radii scale, SF Mono tabular numbers,
reduce-motion-gated animations, haptics, reusable StatusBadge/TelemetryChip/Card/
SectionHeader/DSButtonStyle/ContinueLastBanner. Every changed view consumes tokens
— no hardcoded colors/spacing.
Applied across all surfaces (visual only — zero behavior/logic change, all suites
green): session list rows + status system (shape+color, not color-alone) +
telemetry chips + thumbnail placeholders; terminal gate card (≥44pt approve/reject)
+ keybar + reconnect + quick-reply + digest + SwiftTerm accent theme; pairing hero
+ warning tiers + Projects cards + Timeline/Diff; nav chrome + iPad split placeholder
+ privacy shade + motion. Chinese gate copy. UX finding fixed: timeline class colors
now via DS.Palette (+timelineTool/timelineUser tokens).
Design review 8.5/10. Verified: iPhone 16 290 + iPad Pro 11 290 tests green;
packages + integration green; consistency audit ~clean; zero changes under
ios/Packages, src/, public/.
274 lines
11 KiB
Swift
274 lines
11 KiB
Swift
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 {
|
||
/// Faint per-line tint alpha for added/removed/hunk rows. This is a
|
||
/// **code-diff syntax constant** (like terminal/xterm colors), NOT a
|
||
/// card/status design token — the frozen `DS.Opacity` scale
|
||
/// (stale/exited/pressed) has no semantic slot for a syntax highlight
|
||
/// fill. The tint HUE always comes from `DS.Palette` (below); only this
|
||
/// scanning-aid alpha is diff-local.
|
||
static let lineTintOpacity = 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, DS.Space.lg16)
|
||
.padding(.vertical, DS.Space.sm8)
|
||
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)
|
||
.tint(DS.Palette.accent)
|
||
}
|
||
}
|
||
|
||
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(DS.Typography.caption)
|
||
.foregroundStyle(DS.Palette.statusWaiting)
|
||
}
|
||
|
||
@ViewBuilder private func rowView(_ row: DiffRow) -> some View {
|
||
switch row.kind {
|
||
case .fileHeader(let header):
|
||
fileHeaderRow(header)
|
||
case .binaryNotice:
|
||
Text(DiffCopy.binaryFile)
|
||
.font(DS.Typography.caption.italic())
|
||
.foregroundStyle(DS.Palette.textSecondary)
|
||
.listRowSeparator(.hidden)
|
||
case .hunkHeader(let header):
|
||
diffTextRow(
|
||
header, color: DS.Palette.accent,
|
||
background: DS.Palette.accent.opacity(Metrics.lineTintOpacity)
|
||
)
|
||
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: DS.Space.sm8) {
|
||
// 路径是服务器字节:verbatim + 单行中段截断(等宽对齐)。
|
||
Text(verbatim: header.pathLabel)
|
||
.font(DS.Typography.mono(.footnote).weight(.semibold))
|
||
.foregroundStyle(DS.Palette.textPrimary)
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
Spacer(minLength: 0)
|
||
Text(verbatim: "+\(header.added)")
|
||
.font(DS.Typography.mono(.caption))
|
||
.foregroundStyle(DS.Palette.statusWorking)
|
||
Text(verbatim: "-\(header.removed)")
|
||
.font(DS.Typography.mono(.caption))
|
||
.foregroundStyle(DS.Palette.statusStuck)
|
||
Text(DiffStatusStyle.label(for: header.status))
|
||
.font(DS.Typography.caption)
|
||
.foregroundStyle(DiffStatusStyle.color(for: header.status))
|
||
}
|
||
.padding(.top, DS.Space.lg16)
|
||
.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(DS.Typography.mono(.caption))
|
||
.foregroundStyle(color)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.listRowBackground(background)
|
||
.listRowSeparator(.hidden)
|
||
.listRowInsets(EdgeInsets(
|
||
top: DS.Space.xs2,
|
||
leading: DS.Space.md12,
|
||
bottom: DS.Space.xs2,
|
||
trailing: DS.Space.md12
|
||
))
|
||
}
|
||
|
||
// MARK: - kind → 颜色(全函数;未知 kind 已在解码层降级为 .context)
|
||
// 语义色一律取自 DS.Palette(added=working 绿 / removed=stuck 红 /
|
||
// hunk=accent),与全 App 品牌色对齐;仅淡底 alpha 是 diff 语法常量。
|
||
|
||
static func lineColor(_ kind: DiffLineKind) -> Color {
|
||
switch kind {
|
||
case .added: return DS.Palette.statusWorking
|
||
case .removed: return DS.Palette.statusStuck
|
||
case .context: return DS.Palette.textPrimary
|
||
case .hunk: return DS.Palette.accent
|
||
case .meta: return DS.Palette.textSecondary
|
||
}
|
||
}
|
||
|
||
static func lineBackground(_ kind: DiffLineKind) -> Color {
|
||
switch kind {
|
||
case .added: return DS.Palette.statusWorking.opacity(Metrics.lineTintOpacity)
|
||
case .removed: return DS.Palette.statusStuck.opacity(Metrics.lineTintOpacity)
|
||
case .hunk: return DS.Palette.accent.opacity(Metrics.lineTintOpacity)
|
||
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 DS.Palette.statusWorking
|
||
case .deleted: return DS.Palette.statusStuck
|
||
case .renamed: return DS.Palette.accent
|
||
case .modified, .binary: return DS.Palette.textSecondary
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 = "重试"
|
||
}
|