krz/hutch

an ios client for sourcehut

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

v3.9.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) { /* dismisses the dialog; no action needed */ }
 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}