krz/hutch

an ios client for sourcehut

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

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