Commit 023f46c63e

023f46c63e18509120dc971694e8e65c9d419c1c

parent: aa850ed86d

Unsigned

cmc <hello@cleberg.net> · 2026-07-16 02:48 UTC

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.

Layout: unified · split

Hutch/App/RootView.swift +3
@@ -439,6 +439,7 @@ enum MoreRoute: Hashable {
439 case projectDashboard(id: String, title: String?) 439 case projectDashboard(id: String, title: String?)
440 case mailingList(InboxMailingListReference) 440 case mailingList(InboxMailingListReference)
441 case thread(InboxThreadSummary) 441 case thread(InboxThreadSummary)
442 case patchset(id: Int, listName: String?)
442 case manPageBrowser 443 case manPageBrowser
443 case manPage(URL) 444 case manPage(URL)
444} 445}
@@ -472,6 +473,8 @@ private struct MoreNavigationRoot: View {
472 ProjectDashboardDeepLinkView(projectID: id, title: title) 473 ProjectDashboardDeepLinkView(projectID: id, title: title)
473 case .mailingList(let mailingList): 474 case .mailingList(let mailingList):
474 MailingListDetailView(mailingList: mailingList) 475 MailingListDetailView(mailingList: mailingList)
476 case .patchset(let id, let listName):
477 PatchsetDetailView(patchsetID: id, listName: listName)
475 case .thread(let thread): 478 case .thread(let thread):
476 ThreadDetailView( 479 ThreadDetailView(
477 thread: thread, 480 thread: thread,
Hutch/Views/Lookup/LookupView.swift +2
@@ -465,6 +465,8 @@ struct LookupView: View {
465 ProjectDashboardDeepLinkView(projectID: id, title: title) 465 ProjectDashboardDeepLinkView(projectID: id, title: title)
466 case .mailingList(let mailingList): 466 case .mailingList(let mailingList):
467 MailingListDetailView(mailingList: mailingList) 467 MailingListDetailView(mailingList: mailingList)
468 case .patchset(let id, let listName):
469 PatchsetDetailView(patchsetID: id, listName: listName)
468 case .thread(let thread): 470 case .thread(let thread):
469 ThreadDetailView( 471 ThreadDetailView(
470 thread: thread, 472 thread: thread,
Hutch/Views/Patchsets/PatchsetDetailView.swift added +248
@@ -0,0 +1,248 @@
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
11 var body: some View {
12 Group {
13 if let viewModel {
14 content(viewModel)
15 } else {
16 SRHTLoadingStateView(message: "Loading Patchset…")
17 }
18 }
19 .navigationTitle("Patchset")
20 .navigationBarTitleDisplayMode(.inline)
21 .toolbar {
22 if let viewModel, viewModel.patchset != nil {
23 ToolbarItem(placement: .topBarTrailing) {
24 actionsMenu(viewModel)
25 }
26 }
27 }
28 .task {
29 let model = viewModel ?? PatchsetDetailViewModel(patchsetID: patchsetID, client: appState.client)
30 viewModel = model
31 await model.loadPatchset()
32 }
33 }
34
35 @ViewBuilder
36 private func content(_ viewModel: PatchsetDetailViewModel) -> some View {
37 if viewModel.isLoading && viewModel.patchset == nil {
38 SRHTLoadingStateView(message: "Loading Patchset…")
39 } else if let patchset = viewModel.patchset {
40 List {
41 headerSection(patchset)
42 if let coverLetter = patchset.coverLetter {
43 emailSection(coverLetter, title: "Cover Letter")
44 }
45 if !patchset.tools.isEmpty {
46 toolsSection(patchset)
47 }
48 patchesSection(patchset)
49 }
50 .themedList()
51 .refreshable { await viewModel.loadPatchset() }
52 .overlay {
53 if viewModel.isUpdatingStatus {
54 ProgressView()
55 }
56 }
57 .confirmationDialog(
58 "Set Status",
59 isPresented: $showStatusPicker,
60 titleVisibility: .visible
61 ) {
62 ForEach(PatchsetStatus.assignable, id: \.self) { status in
63 Button(status.displayName) {
64 Task { await viewModel.updateStatus(to: status) }
65 }
66 }
67 Button("Cancel", role: .cancel) {}
68 }
69 .alert(
70 "Couldn't Update Patchset",
71 isPresented: .init(
72 get: { viewModel.error != nil },
73 set: { if !$0 { viewModel.error = nil } }
74 )
75 ) {
76 Button("OK", role: .cancel) { viewModel.error = nil }
77 } message: {
78 Text(viewModel.error ?? "")
79 }
80 } else if let error = viewModel.error {
81 SRHTErrorStateView(
82 title: "Couldn't Load Patchset",
83 message: error,
84 retryAction: { await viewModel.loadPatchset() }
85 )
86 }
87 }
88
89 // MARK: - Sections
90
91 @ViewBuilder
92 private func headerSection(_ patchset: PatchsetDetail) -> some View {
93 Section {
94 VStack(alignment: .leading, spacing: 8) {
95 Text(patchset.subject)
96 .font(.headline)
97
98 HStack(spacing: 8) {
99 PatchsetStatusBadge(status: patchset.status)
100 if patchset.version > 1 {
101 Text("v\(patchset.version)")
102 .font(.caption.weight(.medium))
103 .foregroundStyle(.secondary)
104 }
105 Text("\(patchset.patches.count) patch\(patchset.patches.count == 1 ? "" : "es")")
106 .font(.caption)
107 .foregroundStyle(.secondary)
108 }
109
110 Text("\(patchset.submitter.canonicalName) • \(patchset.updated.relativeDescription)")
111 .font(.caption)
112 .foregroundStyle(.secondary)
113
114 if let listName {
115 Text(listName)
116 .font(.caption)
117 .foregroundStyle(.secondary)
118 }
119 }
120 .padding(.vertical, 2)
121 .themedRow()
122
123 // The version chain matters during review: a superseded series should
124 // usually be read at its newest version instead.
125 if let supersededBy = patchset.supersededBy {
126 NavigationLink(value: MoreRoute.patchset(id: supersededBy, listName: listName)) {
127 SwiftUI.Label("Superseded by a newer version", systemImage: "arrow.right.circle")
128 .font(.subheadline)
129 }
130 .themedRow()
131 }
132
133 if let supersedes = patchset.supersedes {
134 NavigationLink(value: MoreRoute.patchset(id: supersedes, listName: listName)) {
135 SwiftUI.Label("Revises an earlier version", systemImage: "arrow.left.circle")
136 .font(.subheadline)
137 }
138 .themedRow()
139 }
140 }
141 }
142
143 @ViewBuilder
144 private func toolsSection(_ patchset: PatchsetDetail) -> some View {
145 Section("Checks") {
146 ForEach(patchset.tools) { tool in
147 HStack(spacing: 8) {
148 Image(systemName: tool.icon.systemImage)
149 .foregroundStyle(tool.icon == .failed ? .red : .secondary)
150 Text(tool.details)
151 .font(.subheadline)
152 }
153 .themedRow()
154 }
155 }
156 }
157
158 @ViewBuilder
159 private func patchesSection(_ patchset: PatchsetDetail) -> some View {
160 ForEach(patchset.patches) { patch in
161 emailSection(patch, title: patch.seriesLabel.map { "Patch \($0)" } ?? "Patch")
162 }
163 }
164
165 @ViewBuilder
166 private func emailSection(_ email: PatchsetEmail, title: String) -> some View {
167 Section(title) {
168 VStack(alignment: .leading, spacing: 10) {
169 Text(email.subject)
170 .font(.subheadline.weight(.semibold))
171 .textSelection(.enabled)
172
173 ForEach(Array(email.contentBlocks.enumerated()), id: \.offset) { _, block in
174 switch block {
175 case .plainText(let text):
176 Text(text)
177 .font(.body)
178 .textSelection(.enabled)
179 .frame(maxWidth: .infinity, alignment: .leading)
180 .fixedSize(horizontal: false, vertical: true)
181 case .diff(let diff):
182 DiffView(diff: diff)
183 .textSelection(.enabled)
184 }
185 }
186 }
187 .padding(.vertical, 4)
188 .themedRow()
189 }
190 }
191
192 // MARK: - Actions
193
194 @ViewBuilder
195 private func actionsMenu(_ viewModel: PatchsetDetailViewModel) -> some View {
196 Menu {
197 Button {
198 showStatusPicker = true
199 } label: {
200 SwiftUI.Label("Set Status", systemImage: "flag")
201 }
202 .disabled(viewModel.isUpdatingStatus)
203
204 if let mbox = viewModel.patchset?.mbox {
205 Divider()
206 ShareLink(item: mbox) {
207 SwiftUI.Label("Share mbox", systemImage: "square.and.arrow.up")
208 }
209 Button {
210 appState.copyToPasteboard(mbox.absoluteString, label: "mbox URL")
211 } label: {
212 SwiftUI.Label("Copy mbox URL", systemImage: "doc.on.doc")
213 }
214 }
215 } label: {
216 Image(systemName: "ellipsis.circle")
217 }
218 .accessibilityLabel("Patchset actions")
219 }
220}
221
222// MARK: - Status Badge
223
224struct PatchsetStatusBadge: View {
225 let status: PatchsetStatus
226
227 var body: some View {
228 SwiftUI.Label(status.displayName, systemImage: status.systemImage)
229 .font(.caption.weight(.medium))
230 .padding(.horizontal, 8)
231 .padding(.vertical, 3)
232 .background(background, in: Capsule())
233 .foregroundStyle(foreground)
234 }
235
236 private var foreground: Color {
237 switch status {
238 case .applied, .approved: .green
239 case .rejected: .red
240 case .needsRevision: .orange
241 case .superseded, .unknown, .proposed: .secondary
242 }
243 }
244
245 private var background: Color {
246 foreground.opacity(0.12)
247 }
248}
Hutch/Views/Patchsets/PatchsetDetailViewModel.swift added +293
@@ -0,0 +1,293 @@
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct PatchsetDetailResponse: Decodable, Sendable {
6 let patchset: PatchsetDetailPayload?
7}
8
9private struct PatchsetDetailPayload: Decodable, Sendable {
10 let id: Int
11 let created: Date
12 let updated: Date
13 let subject: String
14 let version: Int
15 let prefix: String?
16 let status: PatchsetStatus
17 let submitter: Entity
18 let coverLetter: PatchsetEmailPayload?
19 let supersededBy: PatchsetReferencePayload?
20 let supersedes: PatchsetReferencePayload?
21 let patches: PatchsetPatchPage
22 let tools: [PatchsetToolPayload]
23 let mbox: URL?
24}
25
26private struct PatchsetReferencePayload: Decodable, Sendable {
27 let id: Int
28}
29
30private struct PatchsetPatchPage: Decodable, Sendable {
31 let results: [PatchsetEmailPayload]
32 let cursor: String?
33}
34
35private struct PatchsetEmailPayload: Decodable, Sendable {
36 let id: Int
37 let subject: String
38 let date: Date?
39 let sender: Entity
40 let body: String
41 let patch: PatchIndexPayload?
42}
43
44private struct PatchIndexPayload: Decodable, Sendable {
45 let index: Int?
46 let count: Int?
47}
48
49private struct PatchsetToolPayload: Decodable, Sendable {
50 let id: Int
51 let icon: PatchsetToolIcon
52 let details: String
53}
54
55private struct UpdatePatchsetResponse: Decodable, Sendable {
56 let patchset: UpdatedPatchsetPayload?
57}
58
59private struct UpdatedPatchsetPayload: Decodable, Sendable {
60 let status: PatchsetStatus
61}
62
63// MARK: - View Model
64
65@Observable
66@MainActor
67final class PatchsetDetailViewModel {
68
69 let patchsetID: Int
70
71 private(set) var patchset: PatchsetDetail?
72 private(set) var isLoading = false
73 private(set) var isUpdatingStatus = false
74 var error: String?
75
76 private let client: SRHTClient
77
78 init(patchsetID: Int, client: SRHTClient) {
79 self.patchsetID = patchsetID
80 self.client = client
81 }
82
83 // MARK: - Queries
84
85 /// `patches` is paginated, but a series is small and reviewing half of one is
86 /// worse than useless, so every page is walked before rendering.
87 private static let detailQuery = """
88 query patchset($id: Int!, $cursor: Cursor) {
89 patchset(id: $id) {
90 id
91 created
92 updated
93 subject
94 version
95 prefix
96 status
97 submitter { canonicalName }
98 supersededBy { id }
99 supersedes { id }
100 coverLetter {
101 id
102 subject
103 date
104 sender { canonicalName }
105 body
106 patch { index count }
107 }
108 patches(cursor: $cursor) {
109 results {
110 id
111 subject
112 date
113 sender { canonicalName }
114 body
115 patch { index count }
116 }
117 cursor
118 }
119 tools { id icon details }
120 mbox
121 }
122 }
123 """
124
125 private static let updateStatusMutation = """
126 mutation updatePatchset($id: Int!, $status: PatchsetStatus!) {
127 patchset: updatePatchset(id: $id, status: $status) {
128 status
129 }
130 }
131 """
132
133 // MARK: - Loading
134
135 func loadPatchset() async {
136 guard !isLoading else { return }
137 isLoading = true
138 error = nil
139 defer { isLoading = false }
140
141 do {
142 patchset = try await fetchPatchset()
143 } catch {
144 self.error = error.userFacingMessage
145 }
146 }
147
148 private func fetchPatchset() async throws -> PatchsetDetail {
149 var cursor: String?
150 var payload: PatchsetDetailPayload?
151 var patches: [PatchsetEmailPayload] = []
152
153 // Walk the patches pages, keeping the first page's patchset fields.
154 while true {
155 var variables: [String: any Sendable] = ["id": patchsetID]
156 if let cursor {
157 variables["cursor"] = cursor
158 }
159
160 let response = try await client.execute(
161 service: .lists,
162 query: Self.detailQuery,
163 variables: variables,
164 responseType: PatchsetDetailResponse.self
165 )
166
167 guard let page = response.patchset else {
168 throw SRHTError.graphQLErrors([
169 GraphQLError(message: "That patchset is no longer available.", locations: nil)
170 ])
171 }
172
173 if payload == nil {
174 payload = page
175 }
176 patches.append(contentsOf: page.patches.results)
177
178 guard let next = page.patches.cursor, !next.isEmpty else { break }
179 cursor = next
180 }
181
182 guard let payload else {
183 throw SRHTError.graphQLErrors([
184 GraphQLError(message: "That patchset is no longer available.", locations: nil)
185 ])
186 }
187
188 return PatchsetDetail(
189 id: payload.id,
190 created: payload.created,
191 updated: payload.updated,
192 subject: payload.subject,
193 version: payload.version,
194 prefix: payload.prefix,
195 status: payload.status,
196 submitter: payload.submitter,
197 coverLetter: payload.coverLetter.map { Self.makeEmail(from: $0, isPatch: false) },
198 patches: Self.orderPatches(patches.map { Self.makeEmail(from: $0, isPatch: true) }),
199 supersededBy: payload.supersededBy?.id,
200 supersedes: payload.supersedes?.id,
201 tools: payload.tools.map {
202 PatchsetToolResult(id: $0.id, icon: $0.icon, details: $0.details)
203 },
204 mbox: payload.mbox
205 )
206 }
207
208 // MARK: - Status
209
210 /// Sets the review status. Returns true on success.
211 @discardableResult
212 func updateStatus(to newStatus: PatchsetStatus) async -> Bool {
213 guard !isUpdatingStatus, let current = patchset else { return false }
214 guard newStatus != current.status else { return true }
215
216 isUpdatingStatus = true
217 error = nil
218 defer { isUpdatingStatus = false }
219
220 do {
221 let response = try await client.execute(
222 service: .lists,
223 query: Self.updateStatusMutation,
224 variables: [
225 "id": patchsetID,
226 "status": newStatus.rawValue
227 ],
228 responseType: UpdatePatchsetResponse.self
229 )
230
231 // updatePatchset is nullable: null means the server declined without
232 // erroring, so the local status must not be advanced.
233 guard let updated = response.patchset else {
234 self.error = "SourceHut did not apply that status change."
235 return false
236 }
237
238 apply(status: updated.status)
239 return true
240 } catch {
241 self.error = error.userFacingMessage
242 return false
243 }
244 }
245
246 private func apply(status: PatchsetStatus) {
247 guard let current = patchset else { return }
248 patchset = PatchsetDetail(
249 id: current.id,
250 created: current.created,
251 updated: current.updated,
252 subject: current.subject,
253 version: current.version,
254 prefix: current.prefix,
255 status: status,
256 submitter: current.submitter,
257 coverLetter: current.coverLetter,
258 patches: current.patches,
259 supersededBy: current.supersededBy,
260 supersedes: current.supersedes,
261 tools: current.tools,
262 mbox: current.mbox
263 )
264 }
265
266 // MARK: - Mapping
267
268 private nonisolated static func makeEmail(
269 from payload: PatchsetEmailPayload,
270 isPatch: Bool
271 ) -> PatchsetEmail {
272 PatchsetEmail(
273 id: payload.id,
274 subject: payload.subject,
275 date: payload.date,
276 sender: payload.sender,
277 contentBlocks: InboxThreadUtilities.segmentMessageBody(payload.body, isPatch: isPatch),
278 index: payload.patch?.index,
279 count: payload.patch?.count
280 )
281 }
282
283 /// Orders a series by its `[PATCH n/m]` index.
284 ///
285 /// sr.ht returns patches in receipt order, which is not series order when a
286 /// contributor's mail arrives out of sequence. Patches without an index keep
287 /// their relative position at the end rather than being dropped.
288 nonisolated static func orderPatches(_ patches: [PatchsetEmail]) -> [PatchsetEmail] {
289 let indexed = patches.filter { $0.index != nil }
290 let unindexed = patches.filter { $0.index == nil }
291 return indexed.sorted { ($0.index ?? 0) < ($1.index ?? 0) } + unindexed
292 }
293}
Hutch/Views/Projects/ProjectMailingListView.swift +123 −1
@@ -24,12 +24,25 @@ private struct ProjectMailingListRootPayload: Decodable, Sendable {
24 let id: Int 24 let id: Int
25 let messageID: String 25 let messageID: String
26 let patch: InboxPatchPreview? 26 let patch: InboxPatchPreview?
27 /// Null unless the thread's root email opens a patchset. `MailingList` has no
28 /// patchsets field, so this is the only way to enumerate a list's patchsets.
29 let patchset: PatchsetSummaryPayload?
30}
31
32private struct PatchsetSummaryPayload: Decodable, Sendable {
33 let id: Int
34 let subject: String
35 let version: Int
36 let prefix: String?
37 let status: PatchsetStatus
27} 38}
28 39
29@Observable 40@Observable
30@MainActor 41@MainActor
31final class MailingListDetailViewModel { 42final class MailingListDetailViewModel {
32 private(set) var threads: [InboxThreadSummary] = [] 43 private(set) var threads: [InboxThreadSummary] = []
44 /// Patchsets on this list, derived from thread roots — see the query below.
45 private(set) var patchsets: [PatchsetSummary] = []
33 private(set) var isLoading = false 46 private(set) var isLoading = false
34 var error: String? 47 var error: String?
35 var searchText = "" 48 var searchText = ""
@@ -52,6 +65,13 @@ final class MailingListDetailViewModel {
52 id 65 id
53 messageID 66 messageID
54 patch { subject } 67 patch { subject }
68 patchset {
69 id
70 subject
71 version
72 prefix
73 status
74 }
55 } 75 }
56 } 76 }
57 } 77 }
@@ -87,11 +107,46 @@ final class MailingListDetailViewModel {
87 threads = deduplicateThreads( 107 threads = deduplicateThreads(
88 response.list.threads.results.map(makeSummary(from:)) 108 response.list.threads.results.map(makeSummary(from:))
89 ) 109 )
110 patchsets = Self.patchsets(from: response.list.threads.results)
90 } catch { 111 } catch {
91 self.error = "Failed to load mailing list" 112 self.error = "Failed to load mailing list"
92 } 113 }
93 } 114 }
94 115
116 var filteredPatchsets: [PatchsetSummary] {
117 let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
118 guard !query.isEmpty else { return patchsets }
119 return patchsets.filter { $0.subject.lowercased().contains(query) }
120 }
121
122 /// Collects the patchsets opened by these threads, newest first.
123 ///
124 /// A revised series arrives as its own thread, so the same subject can appear
125 /// at several versions; they are kept as distinct patchsets and the version
126 /// chain is shown in the detail view.
127 private nonisolated static func patchsets(
128 from threads: [ProjectMailingListThreadPayload]
129 ) -> [PatchsetSummary] {
130 var seenIDs = Set<Int>()
131 var results: [PatchsetSummary] = []
132
133 for thread in threads {
134 guard let payload = thread.root.patchset, !seenIDs.contains(payload.id) else { continue }
135 seenIDs.insert(payload.id)
136 results.append(
137 PatchsetSummary(
138 id: payload.id,
139 subject: payload.subject,
140 version: payload.version,
141 prefix: payload.prefix,
142 status: payload.status
143 )
144 )
145 }
146
147 return results
148 }
149
95 func markThreadRead(_ thread: InboxThreadSummary) { 150 func markThreadRead(_ thread: InboxThreadSummary) {
96 let viewedAt = max(Date(), thread.lastActivityAt) 151 let viewedAt = max(Date(), thread.lastActivityAt)
97 InboxReadStateStore.markViewed(viewedAt, for: thread.threadGroupingKey, defaults: defaults) 152 InboxReadStateStore.markViewed(viewedAt, for: thread.threadGroupingKey, defaults: defaults)
@@ -257,12 +312,25 @@ final class MailingListDetailViewModel {
257 } 312 }
258} 313}
259 314
315enum MailingListScope: String, CaseIterable, Hashable {
316 case threads
317 case patches
318
319 var displayName: String {
320 switch self {
321 case .threads: "Threads"
322 case .patches: "Patches"
323 }
324 }
325}
326
260struct MailingListDetailView: View { 327struct MailingListDetailView: View {
261 let mailingList: InboxMailingListReference 328 let mailingList: InboxMailingListReference
262 329
263 @Environment(AppState.self) private var appState 330 @Environment(AppState.self) private var appState
264 @State private var viewModel: MailingListDetailViewModel? 331 @State private var viewModel: MailingListDetailViewModel?
265 @State private var pinChangeCount = 0 332 @State private var pinChangeCount = 0
333 @State private var scope: MailingListScope = .threads
266 334
267 private var currentUserKey: String? { 335 private var currentUserKey: String? {
268 appState.currentUser?.canonicalName 336 appState.currentUser?.canonicalName
@@ -337,6 +405,27 @@ struct MailingListDetailView: View {
337 @Bindable var vm = viewModel 405 @Bindable var vm = viewModel
338 406
339 List { 407 List {
408 // Only offered when the list actually carries patches, so discussion
409 // lists do not grow an empty tab.
410 if !viewModel.patchsets.isEmpty {
411 Picker("Scope", selection: $scope) {
412 ForEach(MailingListScope.allCases, id: \.self) { scope in
413 Text(scope.displayName).tag(scope)
414 }
415 }
416 .pickerStyle(.segmented)
417 .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
418 .themedRow()
419 }
420
421 if showingPatches(viewModel) {
422 ForEach(viewModel.filteredPatchsets) { patchset in
423 NavigationLink(value: MoreRoute.patchset(id: patchset.id, listName: mailingList.name)) {
424 PatchsetRow(patchset: patchset)
425 }
426 .themedRow()
427 }
428 } else {
340 ForEach(viewModel.filteredThreads) { thread in 429 ForEach(viewModel.filteredThreads) { thread in
341 NavigationLink { 430 NavigationLink {
342 ThreadDetailView( 431 ThreadDetailView(
@@ -373,13 +462,14 @@ struct MailingListDetailView: View {
373 } 462 }
374 } 463 }
375 .themedRow() 464 .themedRow()
465 }
376 } 466 }
377 .themedList() 467 .themedList()
378 .listStyle(.plain) 468 .listStyle(.plain)
379 .searchable( 469 .searchable(
380 text: $vm.searchText, 470 text: $vm.searchText,
381 placement: .navigationBarDrawer(displayMode: .always), 471 placement: .navigationBarDrawer(displayMode: .always),
382 prompt: "Search messages" 472 prompt: showingPatches(viewModel) ? "Search patches" : "Search messages"
383 ) 473 )
384 .overlay { 474 .overlay {
385 if viewModel.isLoading, viewModel.threads.isEmpty { 475 if viewModel.isLoading, viewModel.threads.isEmpty {
@@ -390,6 +480,10 @@ struct MailingListDetailView: View {
390 message: error, 480 message: error,
391 retryAction: { await viewModel.loadThreads() } 481 retryAction: { await viewModel.loadThreads() }
392 ) 482 )
483 } else if showingPatches(viewModel) {
484 if !viewModel.patchsets.isEmpty, viewModel.filteredPatchsets.isEmpty {
485 ContentUnavailableView.search(text: viewModel.searchText)
486 }
393 } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty { 487 } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
394 ContentUnavailableView.search(text: viewModel.searchText) 488 ContentUnavailableView.search(text: viewModel.searchText)
395 } else if viewModel.threads.isEmpty { 489 } else if viewModel.threads.isEmpty {
@@ -405,6 +499,34 @@ struct MailingListDetailView: View {
405 } 499 }
406 .srhtErrorBanner(error: $vm.error) 500 .srhtErrorBanner(error: $vm.error)
407 } 501 }
502
503 private func showingPatches(_ viewModel: MailingListDetailViewModel) -> Bool {
504 scope == .patches && !viewModel.patchsets.isEmpty
505 }
506}
507
508struct PatchsetRow: View {
509 let patchset: PatchsetSummary
510
511 var body: some View {
512 VStack(alignment: .leading, spacing: 6) {
513 Text(patchset.subject)
514 .font(.subheadline.weight(.medium))
515 .lineLimit(2)
516
517 HStack(spacing: 8) {
518 PatchsetStatusBadge(status: patchset.status)
519 if let versionLabel = patchset.versionLabel {
520 Text(versionLabel)
521 .font(.caption.weight(.medium))
522 .foregroundStyle(.secondary)
523 }
524 }
525 }
526 .padding(.vertical, 2)
527 .accessibilityElement(children: .combine)
528 .accessibilityLabel("\(patchset.subject), \(patchset.status.displayName)")
529 }
408} 530}
409 531
410struct ProjectMailingListView: View { 532struct ProjectMailingListView: View {
HutchTests/PatchsetTests.swift added +141
@@ -0,0 +1,141 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct PatchsetStatusTests {
6
7 @Test
8 func statusRawValuesMatchTheGraphQLEnum() {
9 // lists.sr.ht's PatchsetStatus enum values, which are sent verbatim to
10 // updatePatchset.
11 #expect(PatchsetStatus.unknown.rawValue == "UNKNOWN")
12 #expect(PatchsetStatus.proposed.rawValue == "PROPOSED")
13 #expect(PatchsetStatus.needsRevision.rawValue == "NEEDS_REVISION")
14 #expect(PatchsetStatus.superseded.rawValue == "SUPERSEDED")
15 #expect(PatchsetStatus.approved.rawValue == "APPROVED")
16 #expect(PatchsetStatus.rejected.rawValue == "REJECTED")
17 #expect(PatchsetStatus.applied.rawValue == "APPLIED")
18 }
19
20 @Test
21 func assignableStatusesExcludeServerManagedOnes() {
22 // UNKNOWN is a sentinel and SUPERSEDED is set by the server when a newer
23 // version lands, so neither should be offered as a reviewer choice.
24 #expect(!PatchsetStatus.assignable.contains(.unknown))
25 #expect(!PatchsetStatus.assignable.contains(.superseded))
26 #expect(PatchsetStatus.assignable.contains(.approved))
27 #expect(PatchsetStatus.assignable.contains(.rejected))
28 #expect(PatchsetStatus.assignable.contains(.applied))
29 #expect(PatchsetStatus.assignable.contains(.needsRevision))
30 #expect(PatchsetStatus.assignable.contains(.proposed))
31 }
32
33 @Test
34 func openStatusesAreThoseAwaitingADecision() {
35 #expect(PatchsetStatus.proposed.isOpen)
36 #expect(PatchsetStatus.needsRevision.isOpen)
37 #expect(!PatchsetStatus.applied.isOpen)
38 #expect(!PatchsetStatus.rejected.isOpen)
39 #expect(!PatchsetStatus.superseded.isOpen)
40 }
41
42 @Test
43 func statusDecodesFromTheWireFormat() throws {
44 let decoded = try JSONDecoder().decode(PatchsetStatus.self, from: Data("\"NEEDS_REVISION\"".utf8))
45 #expect(decoded == .needsRevision)
46 }
47}
48
49struct PatchsetSummaryTests {
50
51 @Test
52 func versionLabelIsHiddenForFirstVersion() {
53 let summary = PatchsetSummary(
54 id: 1,
55 subject: "[PATCH] fix the thing",
56 version: 1,
57 prefix: nil,
58 status: .proposed
59 )
60
61 #expect(summary.versionLabel == nil)
62 }
63
64 @Test
65 func versionLabelIsShownForRevisions() {
66 let summary = PatchsetSummary(
67 id: 1,
68 subject: "[PATCH v3] fix the thing",
69 version: 3,
70 prefix: nil,
71 status: .proposed
72 )
73
74 #expect(summary.versionLabel == "v3")
75 }
76}
77
78@MainActor
79struct PatchsetOrderingTests {
80
81 private func makePatch(id: Int, index: Int?, count: Int?) -> PatchsetEmail {
82 PatchsetEmail(
83 id: id,
84 subject: "patch \(id)",
85 date: nil,
86 sender: Entity(canonicalName: "~someone"),
87 contentBlocks: [],
88 index: index,
89 count: count
90 )
91 }
92
93 @Test
94 func patchesAreOrderedBySeriesIndexNotReceiptOrder() {
95 let patches = [
96 makePatch(id: 30, index: 3, count: 3),
97 makePatch(id: 10, index: 1, count: 3),
98 makePatch(id: 20, index: 2, count: 3)
99 ]
100
101 let ordered = PatchsetDetailViewModel.orderPatches(patches)
102
103 #expect(ordered.map(\.index) == [1, 2, 3])
104 }
105
106 @Test
107 func unindexedPatchesAreKeptAtTheEndRatherThanDropped() {
108 let patches = [
109 makePatch(id: 99, index: nil, count: nil),
110 makePatch(id: 20, index: 2, count: 2),
111 makePatch(id: 10, index: 1, count: 2)
112 ]
113
114 let ordered = PatchsetDetailViewModel.orderPatches(patches)
115
116 #expect(ordered.count == 3)
117 #expect(ordered.map(\.index) == [1, 2, nil])
118 }
119
120 @Test
121 func orderingIsStableForASingleUnindexedPatch() {
122 // A lone patch with no [PATCH n/m] prefix is the common one-off case.
123 let patches = [makePatch(id: 1, index: nil, count: nil)]
124
125 let ordered = PatchsetDetailViewModel.orderPatches(patches)
126
127 #expect(ordered.map(\.id) == [1])
128 }
129
130 @Test
131 func seriesLabelIsHiddenForSinglePatchSeries() {
132 let patch = makePatch(id: 1, index: 1, count: 1)
133 #expect(patch.seriesLabel == nil)
134 }
135
136 @Test
137 func seriesLabelShowsPositionForMultiPatchSeries() {
138 let patch = makePatch(id: 1, index: 2, count: 5)
139 #expect(patch.seriesLabel == "2/5")
140 }
141}