Commit 023f46c63e
Unsigned
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 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct 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 | |||
| 224 | struct 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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | // MARK: - Response types (file-private to avoid @MainActor Decodable issues) | ||
| 4 | |||
| 5 | private struct PatchsetDetailResponse: Decodable, Sendable { | ||
| 6 | let patchset: PatchsetDetailPayload? | ||
| 7 | } | ||
| 8 | |||
| 9 | private 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 | |||
| 26 | private struct PatchsetReferencePayload: Decodable, Sendable { | ||
| 27 | let id: Int | ||
| 28 | } | ||
| 29 | |||
| 30 | private struct PatchsetPatchPage: Decodable, Sendable { | ||
| 31 | let results: [PatchsetEmailPayload] | ||
| 32 | let cursor: String? | ||
| 33 | } | ||
| 34 | |||
| 35 | private 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 | |||
| 44 | private struct PatchIndexPayload: Decodable, Sendable { | ||
| 45 | let index: Int? | ||
| 46 | let count: Int? | ||
| 47 | } | ||
| 48 | |||
| 49 | private struct PatchsetToolPayload: Decodable, Sendable { | ||
| 50 | let id: Int | ||
| 51 | let icon: PatchsetToolIcon | ||
| 52 | let details: String | ||
| 53 | } | ||
| 54 | |||
| 55 | private struct UpdatePatchsetResponse: Decodable, Sendable { | ||
| 56 | let patchset: UpdatedPatchsetPayload? | ||
| 57 | } | ||
| 58 | |||
| 59 | private struct UpdatedPatchsetPayload: Decodable, Sendable { | ||
| 60 | let status: PatchsetStatus | ||
| 61 | } | ||
| 62 | |||
| 63 | // MARK: - View Model | ||
| 64 | |||
| 65 | @Observable | ||
| 66 | @MainActor | ||
| 67 | final 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 | |||
| 32 | private 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 |
| 31 | final class MailingListDetailViewModel { | 42 | final 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 | ||
| 315 | enum 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 | |||
| 260 | struct MailingListDetailView: View { | 327 | struct 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 | |||
| 508 | struct 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 | ||
| 410 | struct ProjectMailingListView: View { | 532 | struct ProjectMailingListView: View { |
HutchTests/PatchsetTests.swift added +141
| @@ -0,0 +1,141 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Hutch | ||
| 4 | |||
| 5 | struct 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 | |||
| 49 | struct 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 | ||
| 79 | struct 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 | } | ||