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