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}