krz/hutch

an ios client for sourcehut

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

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