krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.7.0: Hutch/Views/Patchsets/PatchsetDetailView.swift · raw
1import SwiftUI
2
3struct PatchsetDetailView: View {
4 let patchsetID: Int
5 let listName: String?
6
7 @Environment(AppState.self) private var appState
8 @State private var viewModel: PatchsetDetailViewModel?
9 @State private var showStatusPicker = false
10 @State private var expandedPatchIDs: Set<Int> = []
11
12 var body: some View {
13 Group {
14 if let viewModel {
15 content(viewModel)
16 } else {
17 SRHTLoadingStateView(message: "Loading Patchset…")
18 }
19 }
20 .navigationTitle("Patchset")
21 .navigationBarTitleDisplayMode(.inline)
22 .toolbar {
23 if let viewModel, viewModel.patchset != nil {
24 ToolbarItem(placement: .topBarTrailing) {
25 actionsMenu(viewModel)
26 }
27 }
28 }
29 .task {
30 let model = viewModel ?? PatchsetDetailViewModel(patchsetID: patchsetID, client: appState.client)
31 viewModel = model
32 await model.loadPatchset()
33 }
34 }
35
36 @ViewBuilder
37 private func content(_ viewModel: PatchsetDetailViewModel) -> some View {
38 if viewModel.isLoading && viewModel.patchset == nil {
39 SRHTLoadingStateView(message: "Loading Patchset…")
40 } else if let patchset = viewModel.patchset {
41 List {
42 headerSection(patchset)
43 if let coverLetter = patchset.coverLetter {
44 emailSection(coverLetter, title: "Cover Letter")
45 }
46 if !patchset.tools.isEmpty {
47 toolsSection(patchset)
48 }
49 patchesSection(patchset)
50 }
51 .themedList()
52 // Inset grouped lays cells out at a rounded width while the content
53 // measures itself at the unrounded one, so a long Text reflows to a
54 // different height than the cell was sized for and the two chase each
55 // other into a layout loop. ThreadDetailView renders the same bodies
56 // through the same DiffView on a plain list without that fight.
57 .listStyle(.plain)
58 .refreshable { await viewModel.loadPatchset() }
59 .overlay {
60 if viewModel.isUpdatingStatus {
61 ProgressView()
62 }
63 }
64 .confirmationDialog(
65 "Set Status",
66 isPresented: $showStatusPicker,
67 titleVisibility: .visible
68 ) {
69 ForEach(PatchsetStatus.assignable, id: \.self) { status in
70 Button(status.displayName) {
71 Task { await viewModel.updateStatus(to: status) }
72 }
73 }
74 Button("Cancel", role: .cancel) {}
75 }
76 .alert(
77 "Couldn't Update Patchset",
78 isPresented: .init(
79 get: { viewModel.error != nil },
80 set: { if !$0 { viewModel.error = nil } }
81 )
82 ) {
83 Button("OK", role: .cancel) { viewModel.error = nil }
84 } message: {
85 Text(viewModel.error ?? "")
86 }
87 } else if let error = viewModel.error {
88 SRHTErrorStateView(
89 title: "Couldn't Load Patchset",
90 message: error,
91 retryAction: { await viewModel.loadPatchset() }
92 )
93 }
94 }
95
96 // MARK: - Sections
97
98 @ViewBuilder
99 private func headerSection(_ patchset: PatchsetDetail) -> some View {
100 Section {
101 VStack(alignment: .leading, spacing: 8) {
102 Text(patchset.subject)
103 .font(.headline)
104
105 HStack(spacing: 8) {
106 PatchsetStatusBadge(status: patchset.status)
107 if patchset.version > 1 {
108 Text("v\(patchset.version)")
109 .font(.caption.weight(.medium))
110 .foregroundStyle(.secondary)
111 }
112 Text("\(patchset.patches.count) patch\(patchset.patches.count == 1 ? "" : "es")")
113 .font(.caption)
114 .foregroundStyle(.secondary)
115 }
116
117 Text("\(patchset.submitter.canonicalName) • \(patchset.updated.relativeDescription)")
118 .font(.caption)
119 .foregroundStyle(.secondary)
120
121 if let listName {
122 Text(listName)
123 .font(.caption)
124 .foregroundStyle(.secondary)
125 }
126 }
127 .padding(.vertical, 2)
128 .themedRow()
129
130 // The version chain matters during review: a superseded series should
131 // usually be read at its newest version instead.
132 //
133 // Pushed directly rather than by value, for the same reason as the rows
134 // that lead here — this view inherits whatever stack presented it, and
135 // not all of them declare a MoreRoute destination.
136 if let supersededBy = patchset.supersededBy {
137 NavigationLink {
138 PatchsetDetailView(patchsetID: supersededBy, listName: listName)
139 } label: {
140 SwiftUI.Label("Superseded by a newer version", systemImage: "arrow.right.circle")
141 .font(.subheadline)
142 }
143 .themedRow()
144 }
145
146 if let supersedes = patchset.supersedes {
147 NavigationLink {
148 PatchsetDetailView(patchsetID: supersedes, listName: listName)
149 } label: {
150 SwiftUI.Label("Revises an earlier version", systemImage: "arrow.left.circle")
151 .font(.subheadline)
152 }
153 .themedRow()
154 }
155 }
156 }
157
158 @ViewBuilder
159 private func toolsSection(_ patchset: PatchsetDetail) -> some View {
160 Section("Checks") {
161 ForEach(patchset.tools) { tool in
162 HStack(spacing: 8) {
163 Image(systemName: tool.icon.systemImage)
164 .foregroundStyle(tool.icon == .failed ? .red : .secondary)
165 Text(tool.details)
166 .font(.subheadline)
167 }
168 .themedRow()
169 }
170 }
171 }
172
173 /// Patches start collapsed.
174 ///
175 /// A diff is tall, and a series is many of them. Rendering every patch expanded
176 /// puts a dozen self-sizing diffs in one List, which drives UICollectionView
177 /// into a recursive layout loop and wedges the app. The inbox thread view
178 /// collapses all but the last message for the same reason.
179 @ViewBuilder
180 private func patchesSection(_ patchset: PatchsetDetail) -> some View {
181 Section("Patches") {
182 ForEach(patchset.patches) { patch in
183 PatchRow(
184 patch: patch,
185 isExpanded: expandedPatchIDs.contains(patch.id),
186 onToggle: {
187 withAnimation(.easeInOut(duration: 0.2)) {
188 if expandedPatchIDs.contains(patch.id) {
189 expandedPatchIDs.remove(patch.id)
190 } else {
191 expandedPatchIDs.insert(patch.id)
192 }
193 }
194 }
195 )
196 .themedRow()
197 }
198 }
199 }
200
201 @ViewBuilder
202 private func emailSection(_ email: PatchsetEmail, title: String) -> some View {
203 Section(title) {
204 VStack(alignment: .leading, spacing: 10) {
205 Text(email.subject)
206 .font(.subheadline.weight(.semibold))
207 .textSelection(.enabled)
208
209 PatchsetContentBlocks(blocks: email.contentBlocks)
210 }
211 .padding(.vertical, 4)
212 .themedRow()
213 }
214 }
215
216 // MARK: - Actions
217
218 @ViewBuilder
219 private func actionsMenu(_ viewModel: PatchsetDetailViewModel) -> some View {
220 Menu {
221 Button {
222 showStatusPicker = true
223 } label: {
224 SwiftUI.Label("Set Status", systemImage: "flag")
225 }
226 .disabled(viewModel.isUpdatingStatus)
227
228 if let mbox = viewModel.patchset?.mbox {
229 Divider()
230 ShareLink(item: mbox) {
231 SwiftUI.Label("Share mbox", systemImage: "square.and.arrow.up")
232 }
233 Button {
234 appState.copyToPasteboard(mbox.absoluteString, label: "mbox URL")
235 } label: {
236 SwiftUI.Label("Copy mbox URL", systemImage: "doc.on.doc")
237 }
238 }
239 } label: {
240 Image(systemName: "ellipsis.circle")
241 }
242 .accessibilityLabel("Patchset actions")
243 }
244}
245
246// MARK: - Patch Row
247
248private struct PatchRow: View {
249 let patch: PatchsetEmail
250 let isExpanded: Bool
251 let onToggle: () -> Void
252
253 var body: some View {
254 VStack(alignment: .leading, spacing: isExpanded ? 10 : 0) {
255 Button(action: onToggle) {
256 HStack(alignment: .top, spacing: 12) {
257 Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
258 .font(.caption)
259 .foregroundStyle(.tertiary)
260 .padding(.top, 3)
261
262 VStack(alignment: .leading, spacing: 2) {
263 Text(patch.subject)
264 .font(.subheadline.weight(.medium))
265 .lineLimit(isExpanded ? nil : 2)
266 .multilineTextAlignment(.leading)
267 .frame(maxWidth: .infinity, alignment: .leading)
268
269 if let seriesLabel = patch.seriesLabel {
270 Text(seriesLabel)
271 .font(.caption)
272 .foregroundStyle(.secondary)
273 }
274 }
275 }
276 }
277 .buttonStyle(.plain)
278 .accessibilityHint(isExpanded ? "Collapses this patch" : "Expands this patch")
279
280 if isExpanded {
281 PatchsetContentBlocks(blocks: patch.contentBlocks)
282 }
283 }
284 .padding(.vertical, 4)
285 }
286}
287
288// MARK: - Content Blocks
289
290private struct PatchsetContentBlocks: View {
291 let blocks: [InboxMessageContentBlock]
292
293 var body: some View {
294 ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
295 switch block {
296 case .plainText(let text):
297 Text(text)
298 .font(.body)
299 .textSelection(.enabled)
300 .frame(maxWidth: .infinity, alignment: .leading)
301 .fixedSize(horizontal: false, vertical: true)
302 case .diff(let diff):
303 DiffView(diff: diff)
304 .textSelection(.enabled)
305 }
306 }
307 }
308}
309
310// MARK: - Status Badge
311
312struct PatchsetStatusBadge: View {
313 let status: PatchsetStatus
314
315 var body: some View {
316 SwiftUI.Label(status.displayName, systemImage: status.systemImage)
317 .font(.caption.weight(.medium))
318 .padding(.horizontal, 8)
319 .padding(.vertical, 3)
320 .background(background, in: Capsule())
321 .foregroundStyle(foreground)
322 }
323
324 private var foreground: Color {
325 switch status {
326 case .applied, .approved: .green
327 case .rejected: .red
328 case .needsRevision: .orange
329 case .superseded, .unknown, .proposed: .secondary
330 }
331 }
332
333 private var background: Color {
334 foreground.opacity(0.12)
335 }
336}