mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
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.
203 lines
7.0 KiB
Swift
203 lines
7.0 KiB
Swift
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)
|
||
}
|
||
}
|