krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.16.0: Hutch/Views/Repositories/CommitDetailView.swift · raw
1import SwiftUI
2
3struct CommitDetailView: View {
4 let commitSummary: CommitSummary
5 let repository: RepositorySummary
6
7 @Environment(AppState.self) private var appState
8 @State private var viewModel: CommitDetailViewModel?
9
10 var body: some View {
11 Group {
12 if let viewModel {
13 commitContent(viewModel)
14 } else {
15 ProgressView()
16 }
17 }
18 .navigationTitle(commitSummary.shortId)
19 .navigationBarTitleDisplayMode(.inline)
20 .toolbar {
21 ToolbarItem(placement: .topBarTrailing) {
22 SRHTShareButton(url: SRHTWebURL.commit(repository: repository, commitId: commitSummary.id), target: .commit) {
23 Image(systemName: "square.and.arrow.up")
24 }
25 }
26 }
27 .task {
28 if viewModel == nil {
29 let vm = CommitDetailViewModel(
30 repositoryRid: repository.rid,
31 service: repository.service,
32 commitId: commitSummary.id,
33 client: appState.client
34 )
35 viewModel = vm
36 await vm.loadCommit()
37 }
38 }
39 }
40
41 @ViewBuilder
42 private func commitContent(_ viewModel: CommitDetailViewModel) -> some View {
43 if viewModel.isLoading {
44 ProgressView()
45 } else if let error = viewModel.error {
46 ContentUnavailableView {
47 Label("Error", systemImage: "exclamationmark.triangle")
48 } description: {
49 Text(error)
50 } actions: {
51 Button("Retry") {
52 Task { await viewModel.loadCommit() }
53 }
54 }
55 } else if let commit = viewModel.commit {
56 ScrollView {
57 LazyVStack(alignment: .leading, spacing: 0) {
58 // Header
59 commitHeader(commit)
60
61 sectionDivider
62
63 // Message
64 commitMessage(commit)
65
66 // Trailers
67 if !commit.trailers.isEmpty {
68 sectionDivider
69 trailersSection(commit.trailers)
70 }
71
72 // Parents
73 if !commit.parents.isEmpty {
74 sectionDivider
75 parentsSection(commit.parents)
76 }
77
78 // Diff
79 if let diff = commit.diff, !diff.isEmpty {
80 sectionDivider
81 diffSection(diff)
82 }
83
84 // Tree
85 if let tree = commit.tree, !tree.entries.results.isEmpty {
86 sectionDivider
87 treeSection(tree.entries.results)
88 }
89 }
90 }
91 .navigationDestination(for: ParentCommit.self) { parent in
92 CommitDetailView(
93 commitSummary: CommitSummary(
94 id: parent.id,
95 shortId: parent.shortId,
96 author: CommitAuthor(name: parent.author.name, email: nil, time: .now),
97 message: ""
98 ),
99 repository: repository
100 )
101 }
102 }
103 }
104
105 // MARK: - Header
106
107 @ViewBuilder
108 private func commitHeader(_ commit: CommitDetail) -> some View {
109 VStack(alignment: .leading, spacing: 8) {
110 // Full hash — tappable to copy
111 Button {
112 UIPasteboard.general.string = commit.id
113 } label: {
114 HStack(spacing: 4) {
115 Text(commit.id)
116 .font(.caption.monospaced())
117 .lineLimit(1)
118 .truncationMode(.middle)
119 Image(systemName: "doc.on.doc")
120 .font(.caption2)
121 }
122 .foregroundStyle(.secondary)
123 }
124
125 // Author
126 HStack {
127 Label(commit.author.name, systemImage: "person")
128 Spacer()
129 Text(commit.author.time.relativeDescription)
130 .foregroundStyle(.secondary)
131 }
132 .font(.subheadline)
133
134 // Committer (if different from author)
135 if commit.committer.name != commit.author.name
136 || commit.committer.email != commit.author.email {
137 HStack {
138 Label(commit.committer.name, systemImage: "person.badge.shield.checkmark")
139 Spacer()
140 Text(commit.committer.time.relativeDescription)
141 .foregroundStyle(.secondary)
142 }
143 .font(.subheadline)
144 .foregroundStyle(.secondary)
145 }
146 }
147 .padding()
148 }
149
150 // MARK: - Message
151
152 @ViewBuilder
153 private func commitMessage(_ commit: CommitDetail) -> some View {
154 VStack(alignment: .leading, spacing: 8) {
155 Text("Message")
156 .font(.caption.weight(.semibold))
157 .foregroundStyle(.secondary)
158 .textCase(.uppercase)
159
160 Text(commit.title)
161 .font(.headline)
162
163 if let body = commit.body {
164 Text(body)
165 .font(.subheadline.monospaced())
166 .foregroundStyle(.secondary)
167 }
168 }
169 .padding()
170 .frame(maxWidth: .infinity, alignment: .leading)
171 }
172
173 // MARK: - Trailers
174
175 @ViewBuilder
176 private func trailersSection(_ trailers: [CommitTrailer]) -> some View {
177 VStack(alignment: .leading, spacing: 8) {
178 Text("Trailers")
179 .font(.caption.weight(.semibold))
180 .foregroundStyle(.secondary)
181 .textCase(.uppercase)
182
183 ForEach(trailers) { trailer in
184 HStack(alignment: .top, spacing: 4) {
185 Text("\(trailer.name):")
186 .font(.subheadline.monospaced().weight(.medium))
187 Text(trailer.value)
188 .font(.subheadline.monospaced())
189 .foregroundStyle(.secondary)
190 }
191 }
192 }
193 .padding()
194 .frame(maxWidth: .infinity, alignment: .leading)
195 }
196
197 // MARK: - Parents
198
199 @ViewBuilder
200 private func parentsSection(_ parents: [ParentCommit]) -> some View {
201 VStack(alignment: .leading, spacing: 8) {
202 Text("Parents")
203 .font(.caption.weight(.semibold))
204 .foregroundStyle(.secondary)
205 .textCase(.uppercase)
206
207 ForEach(parents) { parent in
208 NavigationLink(value: parent) {
209 HStack {
210 Text(parent.shortId)
211 .font(.subheadline.monospaced())
212 Text(parent.author.name)
213 .font(.subheadline)
214 .foregroundStyle(.secondary)
215 Spacer()
216 Image(systemName: "chevron.right")
217 .font(.caption)
218 .foregroundStyle(.tertiary)
219 }
220 }
221 .buttonStyle(.plain)
222 }
223 }
224 .padding()
225 .frame(maxWidth: .infinity, alignment: .leading)
226 }
227
228 // MARK: - Diff
229
230 @ViewBuilder
231 private func diffSection(_ diff: String) -> some View {
232 VStack(alignment: .leading, spacing: 8) {
233 Text("Diff")
234 .font(.caption.weight(.semibold))
235 .foregroundStyle(.secondary)
236 .textCase(.uppercase)
237 .padding(.horizontal)
238 .padding(.top)
239
240 DiffView(diff: invertDiff(diff))
241 .padding(.bottom)
242 }
243 }
244
245 /// The sr.ht API returns diffs comparing current→parent (inverted).
246 /// This swaps +/- prefixes so the diff reads as parent→current.
247 private func invertDiff(_ diff: String) -> String {
248 diff.split(separator: "\n", omittingEmptySubsequences: false)
249 .map { line in
250 let s = String(line)
251 if s.hasPrefix("@@") || s.hasPrefix("diff ") || s.hasPrefix("index ") {
252 return s
253 }
254 if s.hasPrefix("---") {
255 return "+++" + s.dropFirst(3)
256 }
257 if s.hasPrefix("+++") {
258 return "---" + s.dropFirst(3)
259 }
260 if s.hasPrefix("+") {
261 return "-" + s.dropFirst(1)
262 }
263 if s.hasPrefix("-") {
264 return "+" + s.dropFirst(1)
265 }
266 return s
267 }
268 .joined(separator: "\n")
269 }
270
271 // MARK: - Tree
272
273 @ViewBuilder
274 private func treeSection(_ entries: [CommitTreeEntry]) -> some View {
275 VStack(alignment: .leading, spacing: 8) {
276 Text("Tree")
277 .font(.caption.weight(.semibold))
278 .foregroundStyle(.secondary)
279 .textCase(.uppercase)
280
281 ForEach(entries) { entry in
282 HStack(spacing: 8) {
283 Image(systemName: treeEntryIcon(for: entry))
284 .foregroundStyle(treeEntryColor(for: entry))
285 .frame(width: 20)
286 Text(entry.name)
287 .font(.subheadline.monospaced())
288 Spacer()
289 if let obj = entry.object, let shortId = obj.shortId {
290 Text(shortId)
291 .font(.caption.monospaced())
292 .foregroundStyle(.tertiary)
293 }
294 }
295 }
296 }
297 .padding()
298 .frame(maxWidth: .infinity, alignment: .leading)
299 }
300
301 // MARK: - Helpers
302
303 private var sectionDivider: some View {
304 Divider().padding(.horizontal)
305 }
306
307 private func treeEntryIcon(for entry: CommitTreeEntry) -> String {
308 guard let type = entry.object?.type else {
309 return "doc"
310 }
311 switch type {
312 case "tree": return "folder"
313 case "blob": return "doc.text"
314 case "tag": return "tag"
315 case "commit": return "arrow.triangle.branch"
316 default: return "doc"
317 }
318 }
319
320 private func treeEntryColor(for entry: CommitTreeEntry) -> Color {
321 guard let type = entry.object?.type else {
322 return .secondary
323 }
324 switch type {
325 case "tree": return .blue
326 case "blob": return .secondary
327 case "tag": return .orange
328 case "commit": return .purple
329 default: return .secondary
330 }
331 }
332}