krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.13.0: Hutch/Views/Repositories/DiffView.swift · raw

  1import SwiftUI
  2
  3/// Renders a unified diff string with syntax highlighting:
  4/// - Green background for added lines (+)
  5/// - Red background for removed lines (-)
  6/// - Gray for hunk headers (@@)
  7/// - File headers (--- / +++ / diff) in bold
  8struct DiffView: View {
  9    let diff: String
 10
 11    var body: some View {
 12        VStack(alignment: .leading, spacing: 12) {
 13            ForEach(fileSections) { section in
 14                DiffFileSectionView(section: section)
 15            }
 16        }
 17    }
 18
 19    private var fileSections: [DiffFileSection] {
 20        DiffFileSection.parse(from: normalizedDiff)
 21    }
 22
 23    private var normalizedDiff: String {
 24        diff
 25            .replacingOccurrences(of: "\r\n", with: "\n")
 26            .replacingOccurrences(of: "\r", with: "\n")
 27    }
 28}
 29
 30private struct DiffFileSectionView: View {
 31    let section: DiffFileSection
 32    @State private var isExpanded = true
 33
 34    var body: some View {
 35        VStack(alignment: .leading, spacing: 0) {
 36            Button {
 37                isExpanded.toggle()
 38            } label: {
 39                HStack(spacing: 10) {
 40                    Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
 41                        .font(.caption.weight(.semibold))
 42                        .foregroundStyle(.secondary)
 43                        .frame(width: 12)
 44
 45                    Text(section.filename)
 46                        .font(.subheadline.weight(.semibold))
 47                        .foregroundStyle(.primary)
 48                        .lineLimit(1)
 49
 50                    Spacer(minLength: 8)
 51
 52                    Text(section.changeSummary)
 53                        .font(.caption.weight(.medium))
 54                        .foregroundStyle(.secondary)
 55                }
 56                .padding(.horizontal, 10)
 57                .padding(.vertical, 8)
 58                .contentShape(Rectangle())
 59            }
 60            .buttonStyle(.plain)
 61            .background(Color(.tertiarySystemBackground))
 62
 63            if isExpanded {
 64                DiffBlockView(lines: section.lines)
 65            }
 66        }
 67        .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
 68        .overlay {
 69            RoundedRectangle(cornerRadius: 8, style: .continuous)
 70                .strokeBorder(Color.primary.opacity(0.06))
 71        }
 72    }
 73}
 74
 75private struct DiffBlockView: View {
 76    let lines: [String]
 77
 78    var body: some View {
 79        ScrollView(.horizontal, showsIndicators: false) {
 80            LazyVStack(alignment: .leading, spacing: 0) {
 81                ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
 82                    DiffLineView(line: line)
 83                }
 84            }
 85            .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
 86        }
 87        .font(.system(.caption, design: .monospaced))
 88        .background(Color(.secondarySystemBackground))
 89    }
 90}
 91
 92private struct DiffFileSection: Identifiable {
 93    let id: String
 94    let filename: String
 95    let lines: [String]
 96    let additions: Int
 97    let deletions: Int
 98
 99    var changeSummary: String {
100        "+\(additions)  -\(deletions)"
101    }
102
103    static func parse(from diff: String) -> [DiffFileSection] {
104        let lines = diff.components(separatedBy: "\n")
105        guard !lines.isEmpty else { return [] }
106
107        let boundaries = lines.enumerated().compactMap { index, line in
108            line.hasPrefix("diff --git ") ? index : nil
109        }
110
111        guard !boundaries.isEmpty else {
112            let section = makeSection(lines: lines, fallbackIndex: 0)
113            return section.lines.isEmpty ? [] : [section]
114        }
115
116        var sections: [DiffFileSection] = []
117        for (position, startIndex) in boundaries.enumerated() {
118            let endIndex = position + 1 < boundaries.count ? boundaries[position + 1] : lines.count
119            let sectionLines = Array(lines[startIndex..<endIndex])
120            let section = makeSection(lines: sectionLines, fallbackIndex: position)
121            if !section.lines.isEmpty {
122                sections.append(section)
123            }
124        }
125        return sections
126    }
127
128    private static func makeSection(lines: [String], fallbackIndex: Int) -> DiffFileSection {
129        let filename = fileName(from: lines) ?? "File \(fallbackIndex + 1)"
130        let additions = lines.filter { $0.hasPrefix("+") && !$0.hasPrefix("+++") }.count
131        let deletions = lines.filter { $0.hasPrefix("-") && !$0.hasPrefix("---") }.count
132        return DiffFileSection(
133            id: "\(fallbackIndex)-\(filename)",
134            filename: filename,
135            lines: lines,
136            additions: additions,
137            deletions: deletions
138        )
139    }
140
141    private static func fileName(from lines: [String]) -> String? {
142        if let diffHeader = lines.first(where: { $0.hasPrefix("diff --git ") }) {
143            let parts = diffHeader.split(separator: " ")
144            if let rhs = parts.last, rhs.hasPrefix("b/") {
145                return String(rhs.dropFirst(2))
146            }
147        }
148
149        if let plusHeader = lines.first(where: { $0.hasPrefix("+++ ") }) {
150            let path = String(plusHeader.dropFirst(4))
151            if path.hasPrefix("b/") {
152                return String(path.dropFirst(2))
153            }
154            return path
155        }
156
157        if let minusHeader = lines.first(where: { $0.hasPrefix("--- ") }) {
158            let path = String(minusHeader.dropFirst(4))
159            if path.hasPrefix("a/") {
160                return String(path.dropFirst(2))
161            }
162            return path
163        }
164
165        return nil
166    }
167}
168
169private struct DiffLineView: View {
170    let line: String
171
172    var body: some View {
173        Text(line.isEmpty ? " " : line)
174            .frame(maxWidth: .infinity, alignment: .leading)
175            .padding(.horizontal, 8)
176            .background(backgroundColor)
177            .foregroundStyle(foregroundColor)
178            .fontWeight(isHeader ? .semibold : .regular)
179    }
180
181    private var kind: DiffLineKind {
182        if line.hasPrefix("@@") { return .hunk }
183        if line.hasPrefix("+++") || line.hasPrefix("---") { return .fileHeader }
184        if line.hasPrefix("diff ") { return .fileHeader }
185        if line.hasPrefix("index ") { return .meta }
186        if line.hasPrefix("+") { return .added }
187        if line.hasPrefix("-") { return .removed }
188        return .context
189    }
190
191    private var backgroundColor: Color {
192        switch kind {
193        case .added:      .green.opacity(0.15)
194        case .removed:    .red.opacity(0.15)
195        case .hunk:       .clear
196        case .fileHeader: .clear
197        case .meta:       .clear
198        case .context:    .clear
199        }
200    }
201
202    private var foregroundColor: Color {
203        switch kind {
204        case .added:   .green
205        case .removed: .red
206        case .hunk:    .secondary
207        case .meta:    .secondary
208        default:       .primary
209        }
210    }
211
212    private var isHeader: Bool {
213        kind == .fileHeader
214    }
215}
216
217private enum DiffLineKind {
218    case added
219    case removed
220    case hunk
221    case fileHeader
222    case meta
223    case context
224}