summaryrefslogtreecommitdiff
path: root/Hutch/Views
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-15 22:41:33 -0500
committerGitHub <[email protected]>2026-07-15 22:41:33 -0500
commit922502a2c74c66034f3ec2db6612f2a36d242042 (patch)
tree0867730c955e308437b8c86dd37638c9bff6c9c5 /Hutch/Views
parentb9ec80716ea015de5b6b31395fdc5ff03191398c (diff)
parent1d2769fc7a347939275e9130ee174d61d96ea401 (diff)
downloadhutch-922502a2c74c66034f3ec2db6612f2a36d242042.tar.gz
hutch-922502a2c74c66034f3ec2db6612f2a36d242042.tar.bz2
hutch-922502a2c74c66034f3ec2db6612f2a36d242042.zip
Merge pull request #4 from zerolabsco/phase-2-patchsets
Phase 2: patchset review
Diffstat (limited to 'Hutch/Views')
-rw-r--r--Hutch/Views/Inbox/InboxThreadUtilities.swift88
-rw-r--r--Hutch/Views/Inbox/ThreadViewModel.swift91
-rw-r--r--Hutch/Views/Patchsets/PatchsetDetailView.swift336
-rw-r--r--Hutch/Views/Patchsets/PatchsetDetailViewModel.swift293
-rw-r--r--Hutch/Views/Projects/ProjectMailingListView.swift128
5 files changed, 846 insertions, 90 deletions
diff --git a/Hutch/Views/Inbox/InboxThreadUtilities.swift b/Hutch/Views/Inbox/InboxThreadUtilities.swift
index 1dd88a3..6958b50 100644
--- a/Hutch/Views/Inbox/InboxThreadUtilities.swift
+++ b/Hutch/Views/Inbox/InboxThreadUtilities.swift
@@ -8,4 +8,92 @@ enum InboxThreadUtilities {
}
return nil
}
+
+ /// Splits an email body into its commit message and diff, so patch mail can be
+ /// rendered as prose plus a diff rather than one undifferentiated blob.
+ ///
+ /// Shared by the inbox thread view and patchset review: sr.ht's `Patch` type
+ /// carries no diff, so the diff has to be recovered from the email body.
+ nonisolated static func segmentMessageBody(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] {
+ guard isPatch else {
+ let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)]
+ }
+
+ let normalizedBody = normalizeLineEndings(in: body)
+ let lines = normalizedBody.components(separatedBy: "\n")
+ guard let diffStartIndex = actualDiffStartIndex(in: lines) else {
+ let trimmedBody = normalizedBody.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)]
+ }
+
+ var blocks: [InboxMessageContentBlock] = []
+ let leadingPlainText = lines[..<diffStartIndex]
+ .joined(separator: "\n")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if !leadingPlainText.isEmpty {
+ blocks.append(.plainText(leadingPlainText))
+ }
+
+ let remainingLines = Array(lines[diffStartIndex...])
+ let signatureIndex = remainingLines.firstIndex(where: isEmailSignatureSeparator)
+
+ let diffLines: ArraySlice<String>
+ let trailingPlainText: String
+ if let signatureIndex {
+ diffLines = remainingLines[..<signatureIndex]
+ trailingPlainText = remainingLines[signatureIndex...]
+ .joined(separator: "\n")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ } else {
+ diffLines = remainingLines[...]
+ trailingPlainText = ""
+ }
+
+ let diff = diffLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
+ if !diff.isEmpty {
+ blocks.append(.diff(diff))
+ }
+
+ if !trailingPlainText.isEmpty {
+ blocks.append(.plainText(trailingPlainText))
+ }
+ return blocks
+ }
+
+ nonisolated static func actualDiffStartIndex(in lines: [String]) -> Int? {
+ if let explicitDiffIndex = lines.firstIndex(where: { $0.hasPrefix("diff --git ") }) {
+ return explicitDiffIndex
+ }
+
+ for index in lines.indices {
+ let line = lines[index]
+ guard line.hasPrefix("--- ") else { continue }
+ let nextIndex = lines.index(after: index)
+ guard nextIndex < lines.endIndex else { continue }
+ let nextLine = lines[nextIndex]
+ guard nextLine.hasPrefix("+++ ") else { continue }
+
+ let oldPath = String(line.dropFirst(4))
+ let newPath = String(nextLine.dropFirst(4))
+ let looksLikeUnifiedDiff = (oldPath.hasPrefix("a/") || oldPath == "/dev/null") &&
+ (newPath.hasPrefix("b/") || newPath == "/dev/null")
+
+ if looksLikeUnifiedDiff {
+ return index
+ }
+ }
+
+ return nil
+ }
+
+ nonisolated static func isEmailSignatureSeparator(_ line: String) -> Bool {
+ line == "-- " || line == "--"
+ }
+
+ nonisolated static func normalizeLineEndings(in text: String) -> String {
+ text
+ .replacingOccurrences(of: "\r\n", with: "\n")
+ .replacingOccurrences(of: "\r", with: "\n")
+ }
}
diff --git a/Hutch/Views/Inbox/ThreadViewModel.swift b/Hutch/Views/Inbox/ThreadViewModel.swift
index 850b83c..f422fe3 100644
--- a/Hutch/Views/Inbox/ThreadViewModel.swift
+++ b/Hutch/Views/Inbox/ThreadViewModel.swift
@@ -446,7 +446,7 @@ final class ThreadViewModel {
let normalizedIdentity = normalizedSenderIdentity(from: body, fallbackAuthor: author)
let displayBody = sanitizedDisplayBody(from: body)
- let contentBlocks = segmentMessageBody(displayBody, isPatch: payload.patch != nil)
+ let contentBlocks = InboxThreadUtilities.segmentMessageBody(displayBody, isPatch: payload.patch != nil)
return InboxMessage(
id: id,
@@ -540,7 +540,7 @@ final class ThreadViewModel {
}
private static func sanitizedDisplayBody(from body: String) -> String {
- let normalizedBody = normalizeLineEndings(in: body)
+ let normalizedBody = InboxThreadUtilities.normalizeLineEndings(in: body)
let lines = normalizedBody.components(separatedBy: "\n")
let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"]
var headerCount = 0
@@ -565,93 +565,6 @@ final class ThreadViewModel {
return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n")
}
- nonisolated static func segmentMessageBodyForTesting(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] {
- segmentMessageBody(body, isPatch: isPatch)
- }
-
- private nonisolated static func segmentMessageBody(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] {
- guard isPatch else {
- let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
- return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)]
- }
-
- let normalizedBody = normalizeLineEndings(in: body)
- let lines = normalizedBody.components(separatedBy: "\n")
- guard let diffStartIndex = actualDiffStartIndex(in: lines) else {
- let trimmedBody = normalizedBody.trimmingCharacters(in: .whitespacesAndNewlines)
- return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)]
- }
-
- var blocks: [InboxMessageContentBlock] = []
- let leadingPlainText = lines[..<diffStartIndex]
- .joined(separator: "\n")
- .trimmingCharacters(in: .whitespacesAndNewlines)
- if !leadingPlainText.isEmpty {
- blocks.append(.plainText(leadingPlainText))
- }
-
- let remainingLines = Array(lines[diffStartIndex...])
- let signatureIndex = remainingLines.firstIndex(where: isEmailSignatureSeparator)
-
- let diffLines: ArraySlice<String>
- let trailingPlainText: String
- if let signatureIndex {
- diffLines = remainingLines[..<signatureIndex]
- trailingPlainText = remainingLines[signatureIndex...]
- .joined(separator: "\n")
- .trimmingCharacters(in: .whitespacesAndNewlines)
- } else {
- diffLines = remainingLines[...]
- trailingPlainText = ""
- }
-
- let diff = diffLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
- if !diff.isEmpty {
- blocks.append(.diff(diff))
- }
-
- if !trailingPlainText.isEmpty {
- blocks.append(.plainText(trailingPlainText))
- }
- return blocks
- }
-
- private nonisolated static func actualDiffStartIndex(in lines: [String]) -> Int? {
- if let explicitDiffIndex = lines.firstIndex(where: { $0.hasPrefix("diff --git ") }) {
- return explicitDiffIndex
- }
-
- for index in lines.indices {
- let line = lines[index]
- guard line.hasPrefix("--- ") else { continue }
- let nextIndex = lines.index(after: index)
- guard nextIndex < lines.endIndex else { continue }
- let nextLine = lines[nextIndex]
- guard nextLine.hasPrefix("+++ ") else { continue }
-
- let oldPath = String(line.dropFirst(4))
- let newPath = String(nextLine.dropFirst(4))
- let looksLikeUnifiedDiff = (oldPath.hasPrefix("a/") || oldPath == "/dev/null") &&
- (newPath.hasPrefix("b/") || newPath == "/dev/null")
-
- if looksLikeUnifiedDiff {
- return index
- }
- }
-
- return nil
- }
-
- private nonisolated static func isEmailSignatureSeparator(_ line: String) -> Bool {
- line == "-- " || line == "--"
- }
-
- private nonisolated static func normalizeLineEndings(in text: String) -> String {
- text
- .replacingOccurrences(of: "\r\n", with: "\n")
- .replacingOccurrences(of: "\r", with: "\n")
- }
-
private static func stripLeadingFromLineIfPresent(in body: String) -> String {
let lines = body.components(separatedBy: "\n")
guard let firstLine = lines.first, firstLine.hasPrefix("From:") else {
diff --git a/Hutch/Views/Patchsets/PatchsetDetailView.swift b/Hutch/Views/Patchsets/PatchsetDetailView.swift
new file mode 100644
index 0000000..f560326
--- /dev/null
+++ b/Hutch/Views/Patchsets/PatchsetDetailView.swift
@@ -0,0 +1,336 @@
+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
+ @State private var expandedPatchIDs: Set<Int> = []
+
+ 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()
+ // Inset grouped lays cells out at a rounded width while the content
+ // measures itself at the unrounded one, so a long Text reflows to a
+ // different height than the cell was sized for and the two chase each
+ // other into a layout loop. ThreadDetailView renders the same bodies
+ // through the same DiffView on a plain list without that fight.
+ .listStyle(.plain)
+ .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.
+ //
+ // Pushed directly rather than by value, for the same reason as the rows
+ // that lead here — this view inherits whatever stack presented it, and
+ // not all of them declare a MoreRoute destination.
+ if let supersededBy = patchset.supersededBy {
+ NavigationLink {
+ PatchsetDetailView(patchsetID: supersededBy, listName: listName)
+ } label: {
+ SwiftUI.Label("Superseded by a newer version", systemImage: "arrow.right.circle")
+ .font(.subheadline)
+ }
+ .themedRow()
+ }
+
+ if let supersedes = patchset.supersedes {
+ NavigationLink {
+ PatchsetDetailView(patchsetID: supersedes, listName: listName)
+ } label: {
+ 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()
+ }
+ }
+ }
+
+ /// Patches start collapsed.
+ ///
+ /// A diff is tall, and a series is many of them. Rendering every patch expanded
+ /// puts a dozen self-sizing diffs in one List, which drives UICollectionView
+ /// into a recursive layout loop and wedges the app. The inbox thread view
+ /// collapses all but the last message for the same reason.
+ @ViewBuilder
+ private func patchesSection(_ patchset: PatchsetDetail) -> some View {
+ Section("Patches") {
+ ForEach(patchset.patches) { patch in
+ PatchRow(
+ patch: patch,
+ isExpanded: expandedPatchIDs.contains(patch.id),
+ onToggle: {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ if expandedPatchIDs.contains(patch.id) {
+ expandedPatchIDs.remove(patch.id)
+ } else {
+ expandedPatchIDs.insert(patch.id)
+ }
+ }
+ }
+ )
+ .themedRow()
+ }
+ }
+ }
+
+ @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)
+
+ PatchsetContentBlocks(blocks: email.contentBlocks)
+ }
+ .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: - Patch Row
+
+private struct PatchRow: View {
+ let patch: PatchsetEmail
+ let isExpanded: Bool
+ let onToggle: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: isExpanded ? 10 : 0) {
+ Button(action: onToggle) {
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ .padding(.top, 3)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(patch.subject)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(isExpanded ? nil : 2)
+ .multilineTextAlignment(.leading)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ if let seriesLabel = patch.seriesLabel {
+ Text(seriesLabel)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ .accessibilityHint(isExpanded ? "Collapses this patch" : "Expands this patch")
+
+ if isExpanded {
+ PatchsetContentBlocks(blocks: patch.contentBlocks)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+}
+
+// MARK: - Content Blocks
+
+private struct PatchsetContentBlocks: View {
+ let blocks: [InboxMessageContentBlock]
+
+ var body: some View {
+ ForEach(Array(blocks.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)
+ }
+ }
+ }
+}
+
+// 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..696cf6b 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,31 @@ 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
+ // Pushed directly rather than by value: this view is also shown
+ // from a project, whose stack declares no MoreRoute destination.
+ NavigationLink {
+ PatchsetDetailView(patchsetID: patchset.id, listName: mailingList.name)
+ } label: {
+ PatchsetRow(patchset: patchset)
+ }
+ .themedRow()
+ }
+ } else {
ForEach(viewModel.filteredThreads) { thread in
NavigationLink {
ThreadDetailView(
@@ -373,13 +466,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 +484,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 +503,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 {