summaryrefslogtreecommitdiff
path: root/Hutch/Views/Patchsets/PatchsetDetailView.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-15 21:48:58 -0500
committerChristian Cleberg <[email protected]>2026-07-15 21:48:58 -0500
commit0eceec357b7ef5251ed0ae9d42b513c17380e0af (patch)
tree98b421635cbaf5a846a460603f03765680d1da06 /Hutch/Views/Patchsets/PatchsetDetailView.swift
parentccec322f8eac4d14638f5abdae7dae7abc95eb7e (diff)
downloadhutch-0eceec357b7ef5251ed0ae9d42b513c17380e0af.tar.gz
hutch-0eceec357b7ef5251ed0ae9d42b513c17380e0af.tar.bz2
hutch-0eceec357b7ef5251ed0ae9d42b513c17380e0af.zip
feat: review patchsets
Patchsets are how contributions reach sourcehut, and Hutch had no reference to them anywhere. This adds review and triage: read a series, see its checks and version chain, and set its status. Two schema facts shaped the design. MailingList exposes no patchsets field, so a list's patchsets cannot be queried directly. They are reachable only through thread roots, so the existing threads query now also selects root.patchset — no extra request — and the Patches tab is derived from that. It appears only on lists that actually carry patches. Patch carries no diff. index, count, version, prefix, subject, and trailers are all it has; the diff exists only inside the email body. Patch bodies are split with the same InboxThreadUtilities.segmentMessageBody the inbox uses and rendered through the existing DiffView. Patches are ordered by their [PATCH n/m] index rather than receipt order, since mail arrives out of sequence. Patches with no index are kept at the end rather than dropped, because a one-off patch has no prefix. updatePatchset is nullable, so a null response is treated as a declined change and the local status is left alone rather than advanced optimistically. UNKNOWN and SUPERSEDED are not offered: the first is a sentinel, the second is set by the server when a newer version lands. Patch submission stays out of scope. It is a git send-email flow, not a GraphQL mutation.
Diffstat (limited to 'Hutch/Views/Patchsets/PatchsetDetailView.swift')
-rw-r--r--Hutch/Views/Patchsets/PatchsetDetailView.swift248
1 files changed, 248 insertions, 0 deletions
diff --git a/Hutch/Views/Patchsets/PatchsetDetailView.swift b/Hutch/Views/Patchsets/PatchsetDetailView.swift
new file mode 100644
index 0000000..d76aab4
--- /dev/null
+++ b/Hutch/Views/Patchsets/PatchsetDetailView.swift
@@ -0,0 +1,248 @@
+import SwiftUI
+
+struct PatchsetDetailView: View {
+ let patchsetID: Int
+ let listName: String?
+
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: PatchsetDetailViewModel?
+ @State private var showStatusPicker = false
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading Patchset…")
+ }
+ }
+ .navigationTitle("Patchset")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ if let viewModel, viewModel.patchset != nil {
+ ToolbarItem(placement: .topBarTrailing) {
+ actionsMenu(viewModel)
+ }
+ }
+ }
+ .task {
+ let model = viewModel ?? PatchsetDetailViewModel(patchsetID: patchsetID, client: appState.client)
+ viewModel = model
+ await model.loadPatchset()
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: PatchsetDetailViewModel) -> some View {
+ if viewModel.isLoading && viewModel.patchset == nil {
+ SRHTLoadingStateView(message: "Loading Patchset…")
+ } else if let patchset = viewModel.patchset {
+ List {
+ headerSection(patchset)
+ if let coverLetter = patchset.coverLetter {
+ emailSection(coverLetter, title: "Cover Letter")
+ }
+ if !patchset.tools.isEmpty {
+ toolsSection(patchset)
+ }
+ patchesSection(patchset)
+ }
+ .themedList()
+ .refreshable { await viewModel.loadPatchset() }
+ .overlay {
+ if viewModel.isUpdatingStatus {
+ ProgressView()
+ }
+ }
+ .confirmationDialog(
+ "Set Status",
+ isPresented: $showStatusPicker,
+ titleVisibility: .visible
+ ) {
+ ForEach(PatchsetStatus.assignable, id: \.self) { status in
+ Button(status.displayName) {
+ Task { await viewModel.updateStatus(to: status) }
+ }
+ }
+ Button("Cancel", role: .cancel) {}
+ }
+ .alert(
+ "Couldn't Update Patchset",
+ isPresented: .init(
+ get: { viewModel.error != nil },
+ set: { if !$0 { viewModel.error = nil } }
+ )
+ ) {
+ Button("OK", role: .cancel) { viewModel.error = nil }
+ } message: {
+ Text(viewModel.error ?? "")
+ }
+ } else if let error = viewModel.error {
+ SRHTErrorStateView(
+ title: "Couldn't Load Patchset",
+ message: error,
+ retryAction: { await viewModel.loadPatchset() }
+ )
+ }
+ }
+
+ // MARK: - Sections
+
+ @ViewBuilder
+ private func headerSection(_ patchset: PatchsetDetail) -> some View {
+ Section {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(patchset.subject)
+ .font(.headline)
+
+ HStack(spacing: 8) {
+ PatchsetStatusBadge(status: patchset.status)
+ if patchset.version > 1 {
+ Text("v\(patchset.version)")
+ .font(.caption.weight(.medium))
+ .foregroundStyle(.secondary)
+ }
+ Text("\(patchset.patches.count) patch\(patchset.patches.count == 1 ? "" : "es")")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Text("\(patchset.submitter.canonicalName) • \(patchset.updated.relativeDescription)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ if let listName {
+ Text(listName)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.vertical, 2)
+ .themedRow()
+
+ // The version chain matters during review: a superseded series should
+ // usually be read at its newest version instead.
+ if let supersededBy = patchset.supersededBy {
+ NavigationLink(value: MoreRoute.patchset(id: supersededBy, listName: listName)) {
+ SwiftUI.Label("Superseded by a newer version", systemImage: "arrow.right.circle")
+ .font(.subheadline)
+ }
+ .themedRow()
+ }
+
+ if let supersedes = patchset.supersedes {
+ NavigationLink(value: MoreRoute.patchset(id: supersedes, listName: listName)) {
+ SwiftUI.Label("Revises an earlier version", systemImage: "arrow.left.circle")
+ .font(.subheadline)
+ }
+ .themedRow()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func toolsSection(_ patchset: PatchsetDetail) -> some View {
+ Section("Checks") {
+ ForEach(patchset.tools) { tool in
+ HStack(spacing: 8) {
+ Image(systemName: tool.icon.systemImage)
+ .foregroundStyle(tool.icon == .failed ? .red : .secondary)
+ Text(tool.details)
+ .font(.subheadline)
+ }
+ .themedRow()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func patchesSection(_ patchset: PatchsetDetail) -> some View {
+ ForEach(patchset.patches) { patch in
+ emailSection(patch, title: patch.seriesLabel.map { "Patch \($0)" } ?? "Patch")
+ }
+ }
+
+ @ViewBuilder
+ private func emailSection(_ email: PatchsetEmail, title: String) -> some View {
+ Section(title) {
+ VStack(alignment: .leading, spacing: 10) {
+ Text(email.subject)
+ .font(.subheadline.weight(.semibold))
+ .textSelection(.enabled)
+
+ ForEach(Array(email.contentBlocks.enumerated()), id: \.offset) { _, block in
+ switch block {
+ case .plainText(let text):
+ Text(text)
+ .font(.body)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+ case .diff(let diff):
+ DiffView(diff: diff)
+ .textSelection(.enabled)
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ .themedRow()
+ }
+ }
+
+ // MARK: - Actions
+
+ @ViewBuilder
+ private func actionsMenu(_ viewModel: PatchsetDetailViewModel) -> some View {
+ Menu {
+ Button {
+ showStatusPicker = true
+ } label: {
+ SwiftUI.Label("Set Status", systemImage: "flag")
+ }
+ .disabled(viewModel.isUpdatingStatus)
+
+ if let mbox = viewModel.patchset?.mbox {
+ Divider()
+ ShareLink(item: mbox) {
+ SwiftUI.Label("Share mbox", systemImage: "square.and.arrow.up")
+ }
+ Button {
+ appState.copyToPasteboard(mbox.absoluteString, label: "mbox URL")
+ } label: {
+ SwiftUI.Label("Copy mbox URL", systemImage: "doc.on.doc")
+ }
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ .accessibilityLabel("Patchset actions")
+ }
+}
+
+// MARK: - Status Badge
+
+struct PatchsetStatusBadge: View {
+ let status: PatchsetStatus
+
+ var body: some View {
+ SwiftUI.Label(status.displayName, systemImage: status.systemImage)
+ .font(.caption.weight(.medium))
+ .padding(.horizontal, 8)
+ .padding(.vertical, 3)
+ .background(background, in: Capsule())
+ .foregroundStyle(foreground)
+ }
+
+ private var foreground: Color {
+ switch status {
+ case .applied, .approved: .green
+ case .rejected: .red
+ case .needsRevision: .orange
+ case .superseded, .unknown, .proposed: .secondary
+ }
+ }
+
+ private var background: Color {
+ foreground.opacity(0.12)
+ }
+}