Files
web-terminal/ios/App/WebTerm/Screens/DiffScreen.swift
Yaojia Wang 660a40491a feat(ios): comprehensive iPhone+iPad UX polish — refined-native design system
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/.
2026-07-05 22:00:31 +02:00

274 lines
11 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {
/// 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.Paletteadded=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 = "重试"
}