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