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