Files
hapi/ios/Packages/HapiKit/Sources/HapiUI/Diff/DiffTextView.swift
T
weishu 10042f79c6 feat(ios): refine chat typography and reading layout
Use unified 16pt Dynamic Type body text, clearer Markdown spacing, and a shared reading column. Preserve transcript anchors across typography changes and bound tool command previews to two lines. Add layout and typography regression tests.
2026-09-10 16:22:36 +08:00

203 lines
7.0 KiB
Swift
Raw 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
/// Unified-diff renderer: per-line rows with a two-column line-number gutter,
/// `+`/`-` colored backgrounds, hunk headers, file headers with +/- stats,
/// horizontal scrolling, and an optional compact mode that shows the first N
/// rows with an expand control.
public struct DiffTextView: View {
public let files: [DiffFile]
public let compact: Bool
public let compactLineLimit: Int
@State private var expanded = false
@Environment(\.hapiTheme) private var theme
@Environment(\.hapiTypography) private var typography
public init(files: [DiffFile], compact: Bool = false, compactLineLimit: Int = 12) {
self.files = files
self.compact = compact
self.compactLineLimit = compactLineLimit
}
public init(file: DiffFile, compact: Bool = false, compactLineLimit: Int = 12) {
self.init(files: [file], compact: compact, compactLineLimit: compactLineLimit)
}
/// Parses `unifiedDiff` and renders every file it contains.
public init(unifiedDiff: String, compact: Bool = false, compactLineLimit: Int = 12) {
self.init(
files: UnifiedDiffParser.parse(unifiedDiff),
compact: compact,
compactLineLimit: compactLineLimit
)
}
// MARK: Rows
private enum RowKind {
case fileHeader(DiffFile)
case hunkHeader(String)
case line(DiffLine)
case binaryNote
}
private struct Row: Identifiable {
let id: Int
let kind: RowKind
}
private var allRows: [Row] {
var rows: [Row] = []
var id = 0
func append(_ kind: RowKind) {
rows.append(Row(id: id, kind: kind))
id += 1
}
for file in files {
append(.fileHeader(file))
if file.isBinary {
append(.binaryNote)
}
for hunk in file.hunks {
append(.hunkHeader(hunk.header))
for line in hunk.lines {
append(.line(line))
}
}
}
return rows
}
/// Width (in characters) of one gutter column.
private var gutterDigits: Int {
var maxNumber = 1
for file in files {
for hunk in file.hunks {
for line in hunk.lines {
if let old = line.oldNumber { maxNumber = max(maxNumber, old) }
if let new = line.newNumber { maxNumber = max(maxNumber, new) }
}
}
}
return max(2, String(maxNumber).count)
}
public var body: some View {
let rows = allRows
let limited = compact && !expanded && rows.count > compactLineLimit
let visible = limited ? Array(rows.prefix(compactLineLimit)) : rows
let digits = gutterDigits
VStack(alignment: .leading, spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
ForEach(visible) { row in
rowView(row, gutterDigits: digits)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
if limited {
Button {
expanded = true
} label: {
Text("Show all \(rows.count) lines")
.font(typography.captionFont)
.foregroundStyle(theme.link)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(minHeight: 44)
}
.buttonStyle(.plain)
}
}
.background(theme.codeBackground)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
@ViewBuilder
private func rowView(_ row: Row, gutterDigits: Int) -> some View {
switch row.kind {
case .fileHeader(let file):
HStack(spacing: 8) {
Text(file.displayPath)
.font(typography.captionMonoFont)
.foregroundStyle(theme.textSecondary)
.fixedSize(horizontal: false, vertical: true)
if file.kind == .renamed {
badge("renamed", color: theme.warning)
}
if file.isBinary {
badge("binary", color: theme.textHint)
} else {
badge("+\(file.additions)", color: theme.success)
badge("−\(file.deletions)", color: theme.danger)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(theme.codeHeaderBackground)
case .hunkHeader(let header):
Text(header)
.font(typography.codeFont)
.foregroundStyle(theme.textSecondary)
.padding(.horizontal, 12)
.padding(.vertical, 3)
.background(theme.hunkHeaderBackground)
case .binaryNote:
Text("Binary file not shown")
.font(typography.captionFont)
.foregroundStyle(theme.textHint)
.padding(.horizontal, 12)
.padding(.vertical, 6)
case .line(let line):
lineRow(line, gutterDigits: gutterDigits)
}
}
private func lineRow(_ line: DiffLine, gutterDigits: Int) -> some View {
let (background, foreground, prefix): (Color?, Color, String) = {
switch line.kind {
case .addition:
return (theme.diffAddedBackground, theme.diffAddedForeground, "+")
case .deletion:
return (theme.diffRemovedBackground, theme.diffRemovedForeground, "-")
case .context:
return (nil, theme.textPrimary, " ")
case .noNewlineMarker:
return (nil, theme.textHint, " ")
}
}()
var text = AttributedString(
paddedNumber(line.oldNumber, width: gutterDigits)
+ " "
+ paddedNumber(line.newNumber, width: gutterDigits)
+ " "
)
text.foregroundColor = theme.textHint
text.font = typography.captionMonoFont
var content = AttributedString(prefix + " " + line.text)
content.foregroundColor = foreground
text.append(content)
return Text(text)
.font(typography.codeFont)
.padding(.horizontal, 12)
.padding(.vertical, typography.codeLineSpacing / 2)
.background(background ?? Color.clear)
}
private func paddedNumber(_ number: Int?, width: Int) -> String {
let digits = number.map(String.init) ?? ""
if digits.count >= width { return digits }
return String(repeating: " ", count: width - digits.count) + digits
}
private func badge(_ label: String, color: Color) -> some View {
Text(label)
.font(typography.captionMonoFont)
.foregroundStyle(color)
}
}