krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.5.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 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}