krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

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