krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
remove-splash-highlighter: 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
157 // Author
158 HStack {
159 Label(commit.author.name, systemImage: "person")
160 Spacer()
161 Text(commit.author.time.relativeDescription)
162 .foregroundStyle(.secondary)
163 }
164 .font(.subheadline)
165
166 // Committer (if different from author)
167 if commit.committer.name != commit.author.name
168 || commit.committer.email != commit.author.email {
169 HStack {
170 Label(commit.committer.name, systemImage: "person.badge.shield.checkmark")
171 Spacer()
172 Text(commit.committer.time.relativeDescription)
173 .foregroundStyle(.secondary)
174 }
175 .font(.subheadline)
176 .foregroundStyle(.secondary)
177 }
178 }
179 .padding()
180 }
181
182 // MARK: - Message
183
184 @ViewBuilder
185 private func commitMessage(_ commit: CommitDetail) -> some View {
186 VStack(alignment: .leading, spacing: 8) {
187 Text("Message")
188 .font(.caption.weight(.semibold))
189 .foregroundStyle(.secondary)
190 .textCase(.uppercase)
191
192 Text(commit.title)
193 .font(.headline)
194
195 if let body = commit.body {
196 Text(body)
197 .font(.subheadline.monospaced())
198 .foregroundStyle(.secondary)
199 }
200 }
201 .padding()
202 .frame(maxWidth: .infinity, alignment: .leading)
203 }
204
205 // MARK: - Trailers
206
207 @ViewBuilder
208 private func trailersSection(_ trailers: [CommitTrailer]) -> some View {
209 VStack(alignment: .leading, spacing: 8) {
210 Text("Trailers")
211 .font(.caption.weight(.semibold))
212 .foregroundStyle(.secondary)
213 .textCase(.uppercase)
214
215 ForEach(trailers) { trailer in
216 HStack(alignment: .top, spacing: 4) {
217 Text("\(trailer.name):")
218 .font(.subheadline.monospaced().weight(.medium))
219 Text(trailer.value)
220 .font(.subheadline.monospaced())
221 .foregroundStyle(.secondary)
222 }
223 }
224 }
225 .padding()
226 .frame(maxWidth: .infinity, alignment: .leading)
227 }
228
229 // MARK: - Parents
230
231 @ViewBuilder
232 private func parentsSection(_ parents: [ParentCommit]) -> some View {
233 VStack(alignment: .leading, spacing: 8) {
234 Text("Parents")
235 .font(.caption.weight(.semibold))
236 .foregroundStyle(.secondary)
237 .textCase(.uppercase)
238
239 ForEach(parents) { parent in
240 NavigationLink(value: parent) {
241 HStack {
242 Text(parent.shortId)
243 .font(.subheadline.monospaced())
244 Text(parent.author.name)
245 .font(.subheadline)
246 .foregroundStyle(.secondary)
247 Spacer()
248 Image(systemName: "chevron.right")
249 .font(.caption)
250 .foregroundStyle(.tertiary)
251 }
252 }
253 .buttonStyle(.plain)
254 }
255 }
256 .padding()
257 .frame(maxWidth: .infinity, alignment: .leading)
258 }
259
260 // MARK: - Diff
261
262 @ViewBuilder
263 private func diffSection(_ diff: String) -> some View {
264 VStack(alignment: .leading, spacing: 8) {
265 Text("Diff")
266 .font(.caption.weight(.semibold))
267 .foregroundStyle(.secondary)
268 .textCase(.uppercase)
269 .padding(.horizontal)
270 .padding(.top)
271
272 DiffView(diff: invertDiff(diff))
273 .padding(.bottom)
274 }
275 }
276
277 /// The sr.ht API returns diffs comparing current→parent (inverted).
278 /// This swaps +/- prefixes so the diff reads as parent→current.
279 private func invertDiff(_ diff: String) -> String {
280 diff.split(separator: "\n", omittingEmptySubsequences: false)
281 .map { line in
282 let s = String(line)
283 if s.hasPrefix("@@") || s.hasPrefix("diff ") || s.hasPrefix("index ") {
284 return s
285 }
286 if s.hasPrefix("---") {
287 return "+++" + s.dropFirst(3)
288 }
289 if s.hasPrefix("+++") {
290 return "---" + s.dropFirst(3)
291 }
292 if s.hasPrefix("+") {
293 return "-" + s.dropFirst(1)
294 }
295 if s.hasPrefix("-") {
296 return "+" + s.dropFirst(1)
297 }
298 return s
299 }
300 .joined(separator: "\n")
301 }
302
303 // MARK: - Tree
304
305 @ViewBuilder
306 private func treeSection(_ entries: [CommitTreeEntry]) -> some View {
307 VStack(alignment: .leading, spacing: 8) {
308 Text("Tree")
309 .font(.caption.weight(.semibold))
310 .foregroundStyle(.secondary)
311 .textCase(.uppercase)
312
313 ForEach(entries) { entry in
314 HStack(spacing: 8) {
315 Image(systemName: treeEntryIcon(for: entry))
316 .foregroundStyle(treeEntryColor(for: entry))
317 .frame(width: 20)
318 Text(entry.name)
319 .font(.subheadline.monospaced())
320 Spacer()
321 if let obj = entry.object, let shortId = obj.shortId {
322 Text(shortId)
323 .font(.caption.monospaced())
324 .foregroundStyle(.tertiary)
325 }
326 }
327 }
328 }
329 .padding()
330 .frame(maxWidth: .infinity, alignment: .leading)
331 }
332
333 // MARK: - Helpers
334
335 private var sectionDivider: some View {
336 Divider().padding(.horizontal)
337 }
338
339 private func treeEntryIcon(for entry: CommitTreeEntry) -> String {
340 guard let type = entry.object?.type else {
341 return "doc"
342 }
343 switch type {
344 case "tree": return "folder"
345 case "blob": return "doc.text"
346 case "tag": return "tag"
347 case "commit": return "arrow.triangle.branch"
348 default: return "doc"
349 }
350 }
351
352 private func treeEntryColor(for entry: CommitTreeEntry) -> Color {
353 guard let type = entry.object?.type else {
354 return .secondary
355 }
356 switch type {
357 case "tree": return .blue
358 case "blob": return .secondary
359 case "tag": return .orange
360 case "commit": return .purple
361 default: return .secondary
362 }
363 }
364}