krz/hutch

an ios client for sourcehut

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

main: 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            .accessibilityHint(isExpanded ? "Collapses this file" : "Expands this file")
 62            .background(Color(.tertiarySystemBackground))
 63
 64            if isExpanded {
 65                DiffBlockView(lines: section.lines)
 66            }
 67        }
 68        .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
 69        .overlay {
 70            RoundedRectangle(cornerRadius: 8, style: .continuous)
 71                .strokeBorder(Color.primary.opacity(0.06))
 72        }
 73    }
 74}
 75
 76private struct DiffBlockView: View {
 77    let lines: [String]
 78
 79    var body: some View {
 80        let hunks = DiffHunk.split(lines: lines)
 81        LazyVStack(alignment: .leading, spacing: 0) {
 82            ForEach(hunks) { hunk in
 83                DiffHunkView(hunk: hunk)
 84            }
 85        }
 86        .font(.system(.caption, design: .monospaced))
 87        .background(Color(.secondarySystemBackground))
 88    }
 89}
 90
 91private struct DiffHunk: Identifiable {
 92    let id: Int
 93    let header: String?
 94    let lines: [String]
 95    let isFileHeader: Bool
 96
 97    static func split(lines: [String]) -> [DiffHunk] {
 98        var hunks: [DiffHunk] = []
 99        var current: [String] = []
100        var hunkIndex = 0
101        var headerLines: [String] = []
102        var passedFirstHunk = false
103
104        for line in lines {
105            if line.hasPrefix("@@") {
106                if !passedFirstHunk {
107                    // Collect file header lines before first hunk
108                    if !current.isEmpty {
109                        headerLines = current
110                        hunks.append(DiffHunk(id: hunkIndex, header: nil, lines: headerLines, isFileHeader: true))
111                        hunkIndex += 1
112                    }
113                    current = [line]
114                    passedFirstHunk = true
115                } else {
116                    // End previous hunk, start new one
117                    if !current.isEmpty {
118                        let header = current.first
119                        hunks.append(DiffHunk(id: hunkIndex, header: header, lines: current, isFileHeader: false))
120                        hunkIndex += 1
121                    }
122                    current = [line]
123                }
124            } else {
125                current.append(line)
126            }
127        }
128
129        if !current.isEmpty {
130            if passedFirstHunk {
131                let header = current.first(where: { $0.hasPrefix("@@") }) ?? current.first
132                hunks.append(DiffHunk(id: hunkIndex, header: header, lines: current, isFileHeader: false))
133            } else {
134                hunks.append(DiffHunk(id: hunkIndex, header: nil, lines: current, isFileHeader: true))
135            }
136        }
137
138        return hunks
139    }
140}
141
142private struct DiffHunkView: View {
143    let hunk: DiffHunk
144    @State private var isExpanded = true
145
146    private var isCollapsible: Bool {
147        !hunk.isFileHeader && hunk.lines.count > 1
148    }
149
150    var body: some View {
151        if isCollapsible {
152            Button {
153                withAnimation(.snappy(duration: 0.2)) {
154                    isExpanded.toggle()
155                }
156            } label: {
157                HStack(spacing: 6) {
158                    Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
159                        .font(.system(size: 8, weight: .bold, design: .monospaced))
160                        .foregroundStyle(.secondary)
161                        .frame(width: 10)
162
163                    Text(hunk.header ?? "")
164                        .foregroundStyle(.secondary)
165                        .lineLimit(1)
166                }
167                .padding(.horizontal, 8)
168                .padding(.vertical, 4)
169                .frame(maxWidth: .infinity, alignment: .leading)
170                .contentShape(Rectangle())
171            }
172            .buttonStyle(.plain)
173            .accessibilityHint(isExpanded ? "Collapses this hunk" : "Expands this hunk")
174            .background(Color(.systemBackground).opacity(0.5))
175
176            if isExpanded {
177                hunkContent(lines: hunk.lines.dropFirst().map { $0 })
178            }
179        } else {
180            hunkContent(lines: hunk.lines)
181        }
182    }
183
184    @ViewBuilder
185    private func hunkContent(lines: [String]) -> some View {
186        ScrollView(.horizontal, showsIndicators: false) {
187            VStack(alignment: .leading, spacing: 0) {
188                ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
189                    DiffLineView(line: line)
190                }
191            }
192        }
193    }
194}
195
196private struct DiffFileSection: Identifiable {
197    let id: String
198    let filename: String
199    let lines: [String]
200    let additions: Int
201    let deletions: Int
202
203    var changeSummary: String {
204        "+\(additions)  -\(deletions)"
205    }
206
207    static func parse(from diff: String) -> [DiffFileSection] {
208        let lines = diff.components(separatedBy: "\n")
209        guard !lines.isEmpty else { return [] }
210
211        let boundaries = lines.enumerated().compactMap { index, line in
212            line.hasPrefix("diff --git ") ? index : nil
213        }
214
215        guard !boundaries.isEmpty else {
216            let section = makeSection(lines: lines, fallbackIndex: 0)
217            return section.lines.isEmpty ? [] : [section]
218        }
219
220        var sections: [DiffFileSection] = []
221        for (position, startIndex) in boundaries.enumerated() {
222            let endIndex = position + 1 < boundaries.count ? boundaries[position + 1] : lines.count
223            let sectionLines = Array(lines[startIndex..<endIndex])
224            let section = makeSection(lines: sectionLines, fallbackIndex: position)
225            if !section.lines.isEmpty {
226                sections.append(section)
227            }
228        }
229        return sections
230    }
231
232    private static func makeSection(lines: [String], fallbackIndex: Int) -> DiffFileSection {
233        let filename = fileName(from: lines) ?? "File \(fallbackIndex + 1)"
234        let additions = lines.filter { $0.hasPrefix("+") && !$0.hasPrefix("+++") }.count
235        let deletions = lines.filter { $0.hasPrefix("-") && !$0.hasPrefix("---") }.count
236        return DiffFileSection(
237            id: "\(fallbackIndex)-\(filename)",
238            filename: filename,
239            lines: lines,
240            additions: additions,
241            deletions: deletions
242        )
243    }
244
245    private static func fileName(from lines: [String]) -> String? {
246        if let diffHeader = lines.first(where: { $0.hasPrefix("diff --git ") }) {
247            let parts = diffHeader.split(separator: " ")
248            if let rhs = parts.last, rhs.hasPrefix("b/") {
249                return String(rhs.dropFirst(2))
250            }
251        }
252
253        if let plusHeader = lines.first(where: { $0.hasPrefix("+++ ") }) {
254            let path = String(plusHeader.dropFirst(4))
255            if path.hasPrefix("b/") {
256                return String(path.dropFirst(2))
257            }
258            return path
259        }
260
261        if let minusHeader = lines.first(where: { $0.hasPrefix("--- ") }) {
262            let path = String(minusHeader.dropFirst(4))
263            if path.hasPrefix("a/") {
264                return String(path.dropFirst(2))
265            }
266            return path
267        }
268
269        return nil
270    }
271}
272
273private struct DiffLineView: View {
274    let line: String
275
276    var body: some View {
277        Text(line.isEmpty ? " " : line)
278            .fixedSize(horizontal: true, vertical: false)
279            .padding(.horizontal, 8)
280            .padding(.vertical, 1)
281            .frame(maxWidth: .infinity, alignment: .leading)
282            .background(backgroundColor)
283            .foregroundStyle(foregroundColor)
284            .fontWeight(isHeader ? .semibold : .regular)
285    }
286
287    private var kind: DiffLineKind {
288        if line.hasPrefix("@@") { return .hunk }
289        if line.hasPrefix("+++") || line.hasPrefix("---") { return .fileHeader }
290        if line.hasPrefix("diff ") { return .fileHeader }
291        if line.hasPrefix("index ") { return .meta }
292        if line.hasPrefix("+") { return .added }
293        if line.hasPrefix("-") { return .removed }
294        return .context
295    }
296
297    private var backgroundColor: Color {
298        switch kind {
299        case .added:      .green.opacity(0.15)
300        case .removed:    .red.opacity(0.15)
301        case .hunk:       .clear
302        case .fileHeader: .clear
303        case .meta:       .clear
304        case .context:    .clear
305        }
306    }
307
308    private var foregroundColor: Color {
309        switch kind {
310        case .added:   .green
311        case .removed: .red
312        case .hunk:    .secondary
313        case .meta:    .secondary
314        default:       .primary
315        }
316    }
317
318    private var isHeader: Bool {
319        kind == .fileHeader
320    }
321}
322
323private enum DiffLineKind {
324    case added
325    case removed
326    case hunk
327    case fileHeader
328    case meta
329    case context
330}