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 currentparent (inverted).
279    /// This swaps +/- prefixes so the diff reads as parentcurrent.
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}