krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

ccec322f8eac4d14638f5abdae7dae7abc95eb7e

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-07-16T02:42:38Z

refactor: share the email body diff splitter

segmentMessageBody and its helpers were private to ThreadViewModel, reachable
from tests only through a segmentMessageBodyForTesting shim. Patchset review
needs the same splitting, because sr.ht's Patch type carries no diff — the diff
only exists inside the email body — so this has to be shared rather than
duplicated.

Moved to InboxThreadUtilities. The shim is gone; the existing test calls the
real function directly now.

Also adds the Patchset model layer that the coming views build on.
 Hutch/Models/Patchset.swift                  | 132 +++++++++++++++++++++++++++
 Hutch/Views/Inbox/InboxThreadUtilities.swift |  88 ++++++++++++++++++
 Hutch/Views/Inbox/ThreadViewModel.swift      |  91 +-----------------
 HutchTests/InboxViewModelTests.swift         |   2 +-
 4 files changed, 223 insertions(+), 90 deletions(-)

diff --git a/Hutch/Models/Patchset.swift b/Hutch/Models/Patchset.swift
new file mode 100644
index 0000000..330bbbc
--- /dev/null
+++ b/Hutch/Models/Patchset.swift
@@ -0,0 +1,132 @@
+import Foundation
+
+/// Review state of a patchset on lists.sr.ht.
+enum PatchsetStatus: String, Codable, Sendable, CaseIterable {
+    case unknown = "UNKNOWN"
+    case proposed = "PROPOSED"
+    case needsRevision = "NEEDS_REVISION"
+    case superseded = "SUPERSEDED"
+    case approved = "APPROVED"
+    case rejected = "REJECTED"
+    case applied = "APPLIED"
+
+    var displayName: String {
+        switch self {
+        case .unknown: "Unknown"
+        case .proposed: "Proposed"
+        case .needsRevision: "Needs Revision"
+        case .superseded: "Superseded"
+        case .approved: "Approved"
+        case .rejected: "Rejected"
+        case .applied: "Applied"
+        }
+    }
+
+    var systemImage: String {
+        switch self {
+        case .unknown: "questionmark.circle"
+        case .proposed: "paperplane"
+        case .needsRevision: "exclamationmark.arrow.circlepath"
+        case .superseded: "arrow.triangle.branch"
+        case .approved: "checkmark.seal"
+        case .rejected: "xmark.circle"
+        case .applied: "checkmark.circle.fill"
+        }
+    }
+
+    /// Whether the patchset is still awaiting a decision.
+    var isOpen: Bool {
+        switch self {
+        case .unknown, .proposed, .needsRevision: true
+        case .superseded, .approved, .rejected, .applied: false
+        }
+    }
+
+    /// Statuses a reviewer can set directly.
+    ///
+    /// `unknown` is a sentinel for patchsets sr.ht could not classify, and
+    /// `superseded` is set by the server when a later version arrives, so neither
+    /// is offered as a choice.
+    static var assignable: [PatchsetStatus] {
+        [.proposed, .needsRevision, .approved, .rejected, .applied]
+    }
+}
+
+/// A patchset as it appears in a mailing list listing, derived from the thread's
+/// root email rather than a dedicated patchsets query — `MailingList` exposes no
+/// such field.
+struct PatchsetSummary: Identifiable, Hashable, Sendable {
+    let id: Int
+    let subject: String
+    let version: Int
+    let prefix: String?
+    let status: PatchsetStatus
+
+    /// The `[PATCH v2]`-style prefix sr.ht parsed from the subject, if any.
+    var versionLabel: String? {
+        guard version > 1 else { return nil }
+        return "v\(version)"
+    }
+}
+
+/// One email within a patchset: either the cover letter or a single patch.
+struct PatchsetEmail: Identifiable, Hashable, Sendable {
+    let id: Int
+    let subject: String
+    let date: Date?
+    let sender: Entity
+    /// Split into commit message and diff blocks for rendering.
+    let contentBlocks: [InboxMessageContentBlock]
+    /// Position within the series, from the `[PATCH 2/5]` prefix.
+    let index: Int?
+    let count: Int?
+
+    var seriesLabel: String? {
+        guard let index, let count, count > 1 else { return nil }
+        return "\(index)/\(count)"
+    }
+}
+
+/// A build or check reported against a patchset.
+struct PatchsetToolResult: Identifiable, Hashable, Sendable {
+    let id: Int
+    let icon: PatchsetToolIcon
+    let details: String
+}
+
+enum PatchsetToolIcon: String, Codable, Sendable {
+    case pending = "PENDING"
+    case waiting = "WAITING"
+    case success = "SUCCESS"
+    case failed = "FAILED"
+    case cancelled = "CANCELLED"
+
+    var systemImage: String {
+        switch self {
+        case .pending, .waiting: "clock"
+        case .success: "checkmark.circle.fill"
+        case .failed: "xmark.circle.fill"
+        case .cancelled: "minus.circle"
+        }
+    }
+}
+
+/// A patchset with its cover letter, patches, and review context.
+struct PatchsetDetail: 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: PatchsetEmail?
+    let patches: [PatchsetEmail]
+    /// Set when a newer version of this series exists.
+    let supersededBy: Int?
+    /// Set when this series revises an earlier one.
+    let supersedes: Int?
+    let tools: [PatchsetToolResult]
+    let mbox: URL?
+}
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/HutchTests/InboxViewModelTests.swift b/HutchTests/InboxViewModelTests.swift
index 7bfc199..e67d317 100644
--- a/HutchTests/InboxViewModelTests.swift
+++ b/HutchTests/InboxViewModelTests.swift
@@ -186,7 +186,7 @@ struct InboxViewModelTests {
         2.50.1 (Apple Git-155)
         """
 
-        let segments = ThreadViewModel.segmentMessageBodyForTesting(body, isPatch: true)
+        let segments = InboxThreadUtilities.segmentMessageBody(body, isPatch: true)
 
         #expect(segments.count == 3)