aboutsummaryrefslogtreecommitdiff
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
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.
-rw-r--r--Hutch/App/RootView.swift3
-rw-r--r--Hutch/Views/Lookup/LookupView.swift2
-rw-r--r--Hutch/Views/Patchsets/PatchsetDetailView.swift248
-rw-r--r--Hutch/Views/Patchsets/PatchsetDetailViewModel.swift293
-rw-r--r--Hutch/Views/Projects/ProjectMailingListView.swift124
-rw-r--r--HutchTests/PatchsetTests.swift141
6 files changed, 810 insertions, 1 deletions
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index 1ae4651..59d7caa 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -439,6 +439,7 @@ enum MoreRoute: Hashable {
case projectDashboard(id: String, title: String?)
case mailingList(InboxMailingListReference)
case thread(InboxThreadSummary)
+ case patchset(id: Int, listName: String?)
case manPageBrowser
case manPage(URL)
}
@@ -472,6 +473,8 @@ private struct MoreNavigationRoot: View {
ProjectDashboardDeepLinkView(projectID: id, title: title)
case .mailingList(let mailingList):
MailingListDetailView(mailingList: mailingList)
+ case .patchset(let id, let listName):
+ PatchsetDetailView(patchsetID: id, listName: listName)
case .thread(let thread):
ThreadDetailView(
thread: thread,
diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift
index 2a26282..b52bb3f 100644
--- a/Hutch/Views/Lookup/LookupView.swift
+++ b/Hutch/Views/Lookup/LookupView.swift
@@ -465,6 +465,8 @@ struct LookupView: View {
ProjectDashboardDeepLinkView(projectID: id, title: title)
case .mailingList(let mailingList):
MailingListDetailView(mailingList: mailingList)
+ case .patchset(let id, let listName):
+ PatchsetDetailView(patchsetID: id, listName: listName)
case .thread(let thread):
ThreadDetailView(
thread: thread,
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)
+ }
+}
diff --git a/Hutch/Views/Patchsets/PatchsetDetailViewModel.swift b/Hutch/Views/Patchsets/PatchsetDetailViewModel.swift
new file mode 100644
index 0000000..88af328
--- /dev/null
+++ b/Hutch/Views/Patchsets/PatchsetDetailViewModel.swift
@@ -0,0 +1,293 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct PatchsetDetailResponse: Decodable, Sendable {
+ let patchset: PatchsetDetailPayload?
+}
+
+private struct PatchsetDetailPayload: Decodable, Sendable {
+ let id: Int
+ let created: Date
+ let updated: Date
+ let subject: String
+ let version: Int
+ let prefix: String?
+ let status: PatchsetStatus
+ let submitter: Entity
+ let coverLetter: PatchsetEmailPayload?
+ let supersededBy: PatchsetReferencePayload?
+ let supersedes: PatchsetReferencePayload?
+ let patches: PatchsetPatchPage
+ let tools: [PatchsetToolPayload]
+ let mbox: URL?
+}
+
+private struct PatchsetReferencePayload: Decodable, Sendable {
+ let id: Int
+}
+
+private struct PatchsetPatchPage: Decodable, Sendable {
+ let results: [PatchsetEmailPayload]
+ let cursor: String?
+}
+
+private struct PatchsetEmailPayload: Decodable, Sendable {
+ let id: Int
+ let subject: String
+ let date: Date?
+ let sender: Entity
+ let body: String
+ let patch: PatchIndexPayload?
+}
+
+private struct PatchIndexPayload: Decodable, Sendable {
+ let index: Int?
+ let count: Int?
+}
+
+private struct PatchsetToolPayload: Decodable, Sendable {
+ let id: Int
+ let icon: PatchsetToolIcon
+ let details: String
+}
+
+private struct UpdatePatchsetResponse: Decodable, Sendable {
+ let patchset: UpdatedPatchsetPayload?
+}
+
+private struct UpdatedPatchsetPayload: Decodable, Sendable {
+ let status: PatchsetStatus
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class PatchsetDetailViewModel {
+
+ let patchsetID: Int
+
+ private(set) var patchset: PatchsetDetail?
+ private(set) var isLoading = false
+ private(set) var isUpdatingStatus = false
+ var error: String?
+
+ private let client: SRHTClient
+
+ init(patchsetID: Int, client: SRHTClient) {
+ self.patchsetID = patchsetID
+ self.client = client
+ }
+
+ // MARK: - Queries
+
+ /// `patches` is paginated, but a series is small and reviewing half of one is
+ /// worse than useless, so every page is walked before rendering.
+ private static let detailQuery = """
+ query patchset($id: Int!, $cursor: Cursor) {
+ patchset(id: $id) {
+ id
+ created
+ updated
+ subject
+ version
+ prefix
+ status
+ submitter { canonicalName }
+ supersededBy { id }
+ supersedes { id }
+ coverLetter {
+ id
+ subject
+ date
+ sender { canonicalName }
+ body
+ patch { index count }
+ }
+ patches(cursor: $cursor) {
+ results {
+ id
+ subject
+ date
+ sender { canonicalName }
+ body
+ patch { index count }
+ }
+ cursor
+ }
+ tools { id icon details }
+ mbox
+ }
+ }
+ """
+
+ private static let updateStatusMutation = """
+ mutation updatePatchset($id: Int!, $status: PatchsetStatus!) {
+ patchset: updatePatchset(id: $id, status: $status) {
+ status
+ }
+ }
+ """
+
+ // MARK: - Loading
+
+ func loadPatchset() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+ defer { isLoading = false }
+
+ do {
+ patchset = try await fetchPatchset()
+ } catch {
+ self.error = error.userFacingMessage
+ }
+ }
+
+ private func fetchPatchset() async throws -> PatchsetDetail {
+ var cursor: String?
+ var payload: PatchsetDetailPayload?
+ var patches: [PatchsetEmailPayload] = []
+
+ // Walk the patches pages, keeping the first page's patchset fields.
+ while true {
+ var variables: [String: any Sendable] = ["id": patchsetID]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.detailQuery,
+ variables: variables,
+ responseType: PatchsetDetailResponse.self
+ )
+
+ guard let page = response.patchset else {
+ throw SRHTError.graphQLErrors([
+ GraphQLError(message: "That patchset is no longer available.", locations: nil)
+ ])
+ }
+
+ if payload == nil {
+ payload = page
+ }
+ patches.append(contentsOf: page.patches.results)
+
+ guard let next = page.patches.cursor, !next.isEmpty else { break }
+ cursor = next
+ }
+
+ guard let payload else {
+ throw SRHTError.graphQLErrors([
+ GraphQLError(message: "That patchset is no longer available.", locations: nil)
+ ])
+ }
+
+ return PatchsetDetail(
+ id: payload.id,
+ created: payload.created,
+ updated: payload.updated,
+ subject: payload.subject,
+ version: payload.version,
+ prefix: payload.prefix,
+ status: payload.status,
+ submitter: payload.submitter,
+ coverLetter: payload.coverLetter.map { Self.makeEmail(from: $0, isPatch: false) },
+ patches: Self.orderPatches(patches.map { Self.makeEmail(from: $0, isPatch: true) }),
+ supersededBy: payload.supersededBy?.id,
+ supersedes: payload.supersedes?.id,
+ tools: payload.tools.map {
+ PatchsetToolResult(id: $0.id, icon: $0.icon, details: $0.details)
+ },
+ mbox: payload.mbox
+ )
+ }
+
+ // MARK: - Status
+
+ /// Sets the review status. Returns true on success.
+ @discardableResult
+ func updateStatus(to newStatus: PatchsetStatus) async -> Bool {
+ guard !isUpdatingStatus, let current = patchset else { return false }
+ guard newStatus != current.status else { return true }
+
+ isUpdatingStatus = true
+ error = nil
+ defer { isUpdatingStatus = false }
+
+ do {
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.updateStatusMutation,
+ variables: [
+ "id": patchsetID,
+ "status": newStatus.rawValue
+ ],
+ responseType: UpdatePatchsetResponse.self
+ )
+
+ // updatePatchset is nullable: null means the server declined without
+ // erroring, so the local status must not be advanced.
+ guard let updated = response.patchset else {
+ self.error = "SourceHut did not apply that status change."
+ return false
+ }
+
+ apply(status: updated.status)
+ return true
+ } catch {
+ self.error = error.userFacingMessage
+ return false
+ }
+ }
+
+ private func apply(status: PatchsetStatus) {
+ guard let current = patchset else { return }
+ patchset = PatchsetDetail(
+ id: current.id,
+ created: current.created,
+ updated: current.updated,
+ subject: current.subject,
+ version: current.version,
+ prefix: current.prefix,
+ status: status,
+ submitter: current.submitter,
+ coverLetter: current.coverLetter,
+ patches: current.patches,
+ supersededBy: current.supersededBy,
+ supersedes: current.supersedes,
+ tools: current.tools,
+ mbox: current.mbox
+ )
+ }
+
+ // MARK: - Mapping
+
+ private nonisolated static func makeEmail(
+ from payload: PatchsetEmailPayload,
+ isPatch: Bool
+ ) -> PatchsetEmail {
+ PatchsetEmail(
+ id: payload.id,
+ subject: payload.subject,
+ date: payload.date,
+ sender: payload.sender,
+ contentBlocks: InboxThreadUtilities.segmentMessageBody(payload.body, isPatch: isPatch),
+ index: payload.patch?.index,
+ count: payload.patch?.count
+ )
+ }
+
+ /// Orders a series by its `[PATCH n/m]` index.
+ ///
+ /// sr.ht returns patches in receipt order, which is not series order when a
+ /// contributor's mail arrives out of sequence. Patches without an index keep
+ /// their relative position at the end rather than being dropped.
+ nonisolated static func orderPatches(_ patches: [PatchsetEmail]) -> [PatchsetEmail] {
+ let indexed = patches.filter { $0.index != nil }
+ let unindexed = patches.filter { $0.index == nil }
+ return indexed.sorted { ($0.index ?? 0) < ($1.index ?? 0) } + unindexed
+ }
+}
diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift
index d466121..3933bf3 100644
--- a/Hutch/Views/Projects/ProjectMailingListView.swift
+++ b/Hutch/Views/Projects/ProjectMailingListView.swift
@@ -24,12 +24,25 @@ private struct ProjectMailingListRootPayload: Decodable, Sendable {
let id: Int
let messageID: String
let patch: InboxPatchPreview?
+ /// Null unless the thread's root email opens a patchset. `MailingList` has no
+ /// patchsets field, so this is the only way to enumerate a list's patchsets.
+ let patchset: PatchsetSummaryPayload?
+}
+
+private struct PatchsetSummaryPayload: Decodable, Sendable {
+ let id: Int
+ let subject: String
+ let version: Int
+ let prefix: String?
+ let status: PatchsetStatus
}
@Observable
@MainActor
final class MailingListDetailViewModel {
private(set) var threads: [InboxThreadSummary] = []
+ /// Patchsets on this list, derived from thread roots — see the query below.
+ private(set) var patchsets: [PatchsetSummary] = []
private(set) var isLoading = false
var error: String?
var searchText = ""
@@ -52,6 +65,13 @@ final class MailingListDetailViewModel {
id
messageID
patch { subject }
+ patchset {
+ id
+ subject
+ version
+ prefix
+ status
+ }
}
}
}
@@ -87,11 +107,46 @@ final class MailingListDetailViewModel {
threads = deduplicateThreads(
response.list.threads.results.map(makeSummary(from:))
)
+ patchsets = Self.patchsets(from: response.list.threads.results)
} catch {
self.error = "Failed to load mailing list"
}
}
+ var filteredPatchsets: [PatchsetSummary] {
+ let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !query.isEmpty else { return patchsets }
+ return patchsets.filter { $0.subject.lowercased().contains(query) }
+ }
+
+ /// Collects the patchsets opened by these threads, newest first.
+ ///
+ /// A revised series arrives as its own thread, so the same subject can appear
+ /// at several versions; they are kept as distinct patchsets and the version
+ /// chain is shown in the detail view.
+ private nonisolated static func patchsets(
+ from threads: [ProjectMailingListThreadPayload]
+ ) -> [PatchsetSummary] {
+ var seenIDs = Set<Int>()
+ var results: [PatchsetSummary] = []
+
+ for thread in threads {
+ guard let payload = thread.root.patchset, !seenIDs.contains(payload.id) else { continue }
+ seenIDs.insert(payload.id)
+ results.append(
+ PatchsetSummary(
+ id: payload.id,
+ subject: payload.subject,
+ version: payload.version,
+ prefix: payload.prefix,
+ status: payload.status
+ )
+ )
+ }
+
+ return results
+ }
+
func markThreadRead(_ thread: InboxThreadSummary) {
let viewedAt = max(Date(), thread.lastActivityAt)
InboxReadStateStore.markViewed(viewedAt, for: thread.threadGroupingKey, defaults: defaults)
@@ -257,12 +312,25 @@ final class MailingListDetailViewModel {
}
}
+enum MailingListScope: String, CaseIterable, Hashable {
+ case threads
+ case patches
+
+ var displayName: String {
+ switch self {
+ case .threads: "Threads"
+ case .patches: "Patches"
+ }
+ }
+}
+
struct MailingListDetailView: View {
let mailingList: InboxMailingListReference
@Environment(AppState.self) private var appState
@State private var viewModel: MailingListDetailViewModel?
@State private var pinChangeCount = 0
+ @State private var scope: MailingListScope = .threads
private var currentUserKey: String? {
appState.currentUser?.canonicalName
@@ -337,6 +405,27 @@ struct MailingListDetailView: View {
@Bindable var vm = viewModel
List {
+ // Only offered when the list actually carries patches, so discussion
+ // lists do not grow an empty tab.
+ if !viewModel.patchsets.isEmpty {
+ Picker("Scope", selection: $scope) {
+ ForEach(MailingListScope.allCases, id: \.self) { scope in
+ Text(scope.displayName).tag(scope)
+ }
+ }
+ .pickerStyle(.segmented)
+ .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
+ .themedRow()
+ }
+
+ if showingPatches(viewModel) {
+ ForEach(viewModel.filteredPatchsets) { patchset in
+ NavigationLink(value: MoreRoute.patchset(id: patchset.id, listName: mailingList.name)) {
+ PatchsetRow(patchset: patchset)
+ }
+ .themedRow()
+ }
+ } else {
ForEach(viewModel.filteredThreads) { thread in
NavigationLink {
ThreadDetailView(
@@ -373,13 +462,14 @@ struct MailingListDetailView: View {
}
}
.themedRow()
+ }
}
.themedList()
.listStyle(.plain)
.searchable(
text: $vm.searchText,
placement: .navigationBarDrawer(displayMode: .always),
- prompt: "Search messages"
+ prompt: showingPatches(viewModel) ? "Search patches" : "Search messages"
)
.overlay {
if viewModel.isLoading, viewModel.threads.isEmpty {
@@ -390,6 +480,10 @@ struct MailingListDetailView: View {
message: error,
retryAction: { await viewModel.loadThreads() }
)
+ } else if showingPatches(viewModel) {
+ if !viewModel.patchsets.isEmpty, viewModel.filteredPatchsets.isEmpty {
+ ContentUnavailableView.search(text: viewModel.searchText)
+ }
} else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
ContentUnavailableView.search(text: viewModel.searchText)
} else if viewModel.threads.isEmpty {
@@ -405,6 +499,34 @@ struct MailingListDetailView: View {
}
.srhtErrorBanner(error: $vm.error)
}
+
+ private func showingPatches(_ viewModel: MailingListDetailViewModel) -> Bool {
+ scope == .patches && !viewModel.patchsets.isEmpty
+ }
+}
+
+struct PatchsetRow: View {
+ let patchset: PatchsetSummary
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(patchset.subject)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(2)
+
+ HStack(spacing: 8) {
+ PatchsetStatusBadge(status: patchset.status)
+ if let versionLabel = patchset.versionLabel {
+ Text(versionLabel)
+ .font(.caption.weight(.medium))
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .padding(.vertical, 2)
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("\(patchset.subject), \(patchset.status.displayName)")
+ }
}
struct ProjectMailingListView: View {
diff --git a/HutchTests/PatchsetTests.swift b/HutchTests/PatchsetTests.swift
new file mode 100644
index 0000000..945131f
--- /dev/null
+++ b/HutchTests/PatchsetTests.swift
@@ -0,0 +1,141 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct PatchsetStatusTests {
+
+ @Test
+ func statusRawValuesMatchTheGraphQLEnum() {
+ // lists.sr.ht's PatchsetStatus enum values, which are sent verbatim to
+ // updatePatchset.
+ #expect(PatchsetStatus.unknown.rawValue == "UNKNOWN")
+ #expect(PatchsetStatus.proposed.rawValue == "PROPOSED")
+ #expect(PatchsetStatus.needsRevision.rawValue == "NEEDS_REVISION")
+ #expect(PatchsetStatus.superseded.rawValue == "SUPERSEDED")
+ #expect(PatchsetStatus.approved.rawValue == "APPROVED")
+ #expect(PatchsetStatus.rejected.rawValue == "REJECTED")
+ #expect(PatchsetStatus.applied.rawValue == "APPLIED")
+ }
+
+ @Test
+ func assignableStatusesExcludeServerManagedOnes() {
+ // UNKNOWN is a sentinel and SUPERSEDED is set by the server when a newer
+ // version lands, so neither should be offered as a reviewer choice.
+ #expect(!PatchsetStatus.assignable.contains(.unknown))
+ #expect(!PatchsetStatus.assignable.contains(.superseded))
+ #expect(PatchsetStatus.assignable.contains(.approved))
+ #expect(PatchsetStatus.assignable.contains(.rejected))
+ #expect(PatchsetStatus.assignable.contains(.applied))
+ #expect(PatchsetStatus.assignable.contains(.needsRevision))
+ #expect(PatchsetStatus.assignable.contains(.proposed))
+ }
+
+ @Test
+ func openStatusesAreThoseAwaitingADecision() {
+ #expect(PatchsetStatus.proposed.isOpen)
+ #expect(PatchsetStatus.needsRevision.isOpen)
+ #expect(!PatchsetStatus.applied.isOpen)
+ #expect(!PatchsetStatus.rejected.isOpen)
+ #expect(!PatchsetStatus.superseded.isOpen)
+ }
+
+ @Test
+ func statusDecodesFromTheWireFormat() throws {
+ let decoded = try JSONDecoder().decode(PatchsetStatus.self, from: Data("\"NEEDS_REVISION\"".utf8))
+ #expect(decoded == .needsRevision)
+ }
+}
+
+struct PatchsetSummaryTests {
+
+ @Test
+ func versionLabelIsHiddenForFirstVersion() {
+ let summary = PatchsetSummary(
+ id: 1,
+ subject: "[PATCH] fix the thing",
+ version: 1,
+ prefix: nil,
+ status: .proposed
+ )
+
+ #expect(summary.versionLabel == nil)
+ }
+
+ @Test
+ func versionLabelIsShownForRevisions() {
+ let summary = PatchsetSummary(
+ id: 1,
+ subject: "[PATCH v3] fix the thing",
+ version: 3,
+ prefix: nil,
+ status: .proposed
+ )
+
+ #expect(summary.versionLabel == "v3")
+ }
+}
+
+@MainActor
+struct PatchsetOrderingTests {
+
+ private func makePatch(id: Int, index: Int?, count: Int?) -> PatchsetEmail {
+ PatchsetEmail(
+ id: id,
+ subject: "patch \(id)",
+ date: nil,
+ sender: Entity(canonicalName: "~someone"),
+ contentBlocks: [],
+ index: index,
+ count: count
+ )
+ }
+
+ @Test
+ func patchesAreOrderedBySeriesIndexNotReceiptOrder() {
+ let patches = [
+ makePatch(id: 30, index: 3, count: 3),
+ makePatch(id: 10, index: 1, count: 3),
+ makePatch(id: 20, index: 2, count: 3)
+ ]
+
+ let ordered = PatchsetDetailViewModel.orderPatches(patches)
+
+ #expect(ordered.map(\.index) == [1, 2, 3])
+ }
+
+ @Test
+ func unindexedPatchesAreKeptAtTheEndRatherThanDropped() {
+ let patches = [
+ makePatch(id: 99, index: nil, count: nil),
+ makePatch(id: 20, index: 2, count: 2),
+ makePatch(id: 10, index: 1, count: 2)
+ ]
+
+ let ordered = PatchsetDetailViewModel.orderPatches(patches)
+
+ #expect(ordered.count == 3)
+ #expect(ordered.map(\.index) == [1, 2, nil])
+ }
+
+ @Test
+ func orderingIsStableForASingleUnindexedPatch() {
+ // A lone patch with no [PATCH n/m] prefix is the common one-off case.
+ let patches = [makePatch(id: 1, index: nil, count: nil)]
+
+ let ordered = PatchsetDetailViewModel.orderPatches(patches)
+
+ #expect(ordered.map(\.id) == [1])
+ }
+
+ @Test
+ func seriesLabelIsHiddenForSinglePatchSeries() {
+ let patch = makePatch(id: 1, index: 1, count: 1)
+ #expect(patch.seriesLabel == nil)
+ }
+
+ @Test
+ func seriesLabelShowsPositionForMultiPatchSeries() {
+ let patch = makePatch(id: 1, index: 2, count: 5)
+ #expect(patch.seriesLabel == "2/5")
+ }
+}