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 = "重试"
}