Commit aa850ed86d

aa850ed86de73cd0c35afc546afea7cf080ff003

parent: 54c0b1dcd4

Unsigned

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

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.

Layout: unified · split

Hutch/Models/Patchset.swift added +132
@@ -0,0 +1,132 @@
1import Foundation
2
3/// Review state of a patchset on lists.sr.ht.
4enum PatchsetStatus: String, Codable, Sendable, CaseIterable {
5 case unknown = "UNKNOWN"
6 case proposed = "PROPOSED"
7 case needsRevision = "NEEDS_REVISION"
8 case superseded = "SUPERSEDED"
9 case approved = "APPROVED"
10 case rejected = "REJECTED"
11 case applied = "APPLIED"
12
13 var displayName: String {
14 switch self {
15 case .unknown: "Unknown"
16 case .proposed: "Proposed"
17 case .needsRevision: "Needs Revision"
18 case .superseded: "Superseded"
19 case .approved: "Approved"
20 case .rejected: "Rejected"
21 case .applied: "Applied"
22 }
23 }
24
25 var systemImage: String {
26 switch self {
27 case .unknown: "questionmark.circle"
28 case .proposed: "paperplane"
29 case .needsRevision: "exclamationmark.arrow.circlepath"
30 case .superseded: "arrow.triangle.branch"
31 case .approved: "checkmark.seal"
32 case .rejected: "xmark.circle"
33 case .applied: "checkmark.circle.fill"
34 }
35 }
36
37 /// Whether the patchset is still awaiting a decision.
38 var isOpen: Bool {
39 switch self {
40 case .unknown, .proposed, .needsRevision: true
41 case .superseded, .approved, .rejected, .applied: false
42 }
43 }
44
45 /// Statuses a reviewer can set directly.
46 ///
47 /// `unknown` is a sentinel for patchsets sr.ht could not classify, and
48 /// `superseded` is set by the server when a later version arrives, so neither
49 /// is offered as a choice.
50 static var assignable: [PatchsetStatus] {
51 [.proposed, .needsRevision, .approved, .rejected, .applied]
52 }
53}
54
55/// A patchset as it appears in a mailing list listing, derived from the thread's
56/// root email rather than a dedicated patchsets query — `MailingList` exposes no
57/// such field.
58struct PatchsetSummary: Identifiable, Hashable, Sendable {
59 let id: Int
60 let subject: String
61 let version: Int
62 let prefix: String?
63 let status: PatchsetStatus
64
65 /// The `[PATCH v2]`-style prefix sr.ht parsed from the subject, if any.
66 var versionLabel: String? {
67 guard version > 1 else { return nil }
68 return "v\(version)"
69 }
70}
71
72/// One email within a patchset: either the cover letter or a single patch.
73struct PatchsetEmail: Identifiable, Hashable, Sendable {
74 let id: Int
75 let subject: String
76 let date: Date?
77 let sender: Entity
78 /// Split into commit message and diff blocks for rendering.
79 let contentBlocks: [InboxMessageContentBlock]
80 /// Position within the series, from the `[PATCH 2/5]` prefix.
81 let index: Int?
82 let count: Int?
83
84 var seriesLabel: String? {
85 guard let index, let count, count > 1 else { return nil }
86 return "\(index)/\(count)"
87 }
88}
89
90/// A build or check reported against a patchset.
91struct PatchsetToolResult: Identifiable, Hashable, Sendable {
92 let id: Int
93 let icon: PatchsetToolIcon
94 let details: String
95}
96
97enum PatchsetToolIcon: String, Codable, Sendable {
98 case pending = "PENDING"
99 case waiting = "WAITING"
100 case success = "SUCCESS"
101 case failed = "FAILED"
102 case cancelled = "CANCELLED"
103
104 var systemImage: String {
105 switch self {
106 case .pending, .waiting: "clock"
107 case .success: "checkmark.circle.fill"
108 case .failed: "xmark.circle.fill"
109 case .cancelled: "minus.circle"
110 }
111 }
112}
113
114/// A patchset with its cover letter, patches, and review context.
115struct PatchsetDetail: Sendable {
116 let id: Int
117 let created: Date
118 let updated: Date
119 let subject: String
120 let version: Int
121 let prefix: String?
122 let status: PatchsetStatus
123 let submitter: Entity
124 let coverLetter: PatchsetEmail?
125 let patches: [PatchsetEmail]
126 /// Set when a newer version of this series exists.
127 let supersededBy: Int?
128 /// Set when this series revises an earlier one.
129 let supersedes: Int?
130 let tools: [PatchsetToolResult]
131 let mbox: URL?
132}
Hutch/Views/Inbox/InboxThreadUtilities.swift +88
@@ -8,4 +8,92 @@ enum InboxThreadUtilities {
88 }
99 return nil
1010 }
11
12 /// Splits an email body into its commit message and diff, so patch mail can be
13 /// rendered as prose plus a diff rather than one undifferentiated blob.
14 ///
15 /// Shared by the inbox thread view and patchset review: sr.ht's `Patch` type
16 /// carries no diff, so the diff has to be recovered from the email body.
17 nonisolated static func segmentMessageBody(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] {
18 guard isPatch else {
19 let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
20 return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)]
21 }
22
23 let normalizedBody = normalizeLineEndings(in: body)
24 let lines = normalizedBody.components(separatedBy: "\n")
25 guard let diffStartIndex = actualDiffStartIndex(in: lines) else {
26 let trimmedBody = normalizedBody.trimmingCharacters(in: .whitespacesAndNewlines)
27 return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)]
28 }
29
30 var blocks: [InboxMessageContentBlock] = []
31 let leadingPlainText = lines[..<diffStartIndex]
32 .joined(separator: "\n")
33 .trimmingCharacters(in: .whitespacesAndNewlines)
34 if !leadingPlainText.isEmpty {
35 blocks.append(.plainText(leadingPlainText))
36 }
37
38 let remainingLines = Array(lines[diffStartIndex...])
39 let signatureIndex = remainingLines.firstIndex(where: isEmailSignatureSeparator)
40
41 let diffLines: ArraySlice<String>
42 let trailingPlainText: String
43 if let signatureIndex {
44 diffLines = remainingLines[..<signatureIndex]
45 trailingPlainText = remainingLines[signatureIndex...]
46 .joined(separator: "\n")
47 .trimmingCharacters(in: .whitespacesAndNewlines)
48 } else {
49 diffLines = remainingLines[...]
50 trailingPlainText = ""
51 }
52
53 let diff = diffLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
54 if !diff.isEmpty {
55 blocks.append(.diff(diff))
56 }
57
58 if !trailingPlainText.isEmpty {
59 blocks.append(.plainText(trailingPlainText))
60 }
61 return blocks
62 }
63
64 nonisolated static func actualDiffStartIndex(in lines: [String]) -> Int? {
65 if let explicitDiffIndex = lines.firstIndex(where: { $0.hasPrefix("diff --git ") }) {
66 return explicitDiffIndex
67 }
68
69 for index in lines.indices {
70 let line = lines[index]
71 guard line.hasPrefix("--- ") else { continue }
72 let nextIndex = lines.index(after: index)
73 guard nextIndex < lines.endIndex else { continue }
74 let nextLine = lines[nextIndex]
75 guard nextLine.hasPrefix("+++ ") else { continue }
76
77 let oldPath = String(line.dropFirst(4))
78 let newPath = String(nextLine.dropFirst(4))
79 let looksLikeUnifiedDiff = (oldPath.hasPrefix("a/") || oldPath == "/dev/null") &&
80 (newPath.hasPrefix("b/") || newPath == "/dev/null")
81
82 if looksLikeUnifiedDiff {
83 return index
84 }
85 }
86
87 return nil
88 }
89
90 nonisolated static func isEmailSignatureSeparator(_ line: String) -> Bool {
91 line == "-- " || line == "--"
92 }
93
94 nonisolated static func normalizeLineEndings(in text: String) -> String {
95 text
96 .replacingOccurrences(of: "\r\n", with: "\n")
97 .replacingOccurrences(of: "\r", with: "\n")
98 }
1199}
Hutch/Views/Inbox/ThreadViewModel.swift +2 −89
@@ -446,7 +446,7 @@ final class ThreadViewModel {
446446
447447 let normalizedIdentity = normalizedSenderIdentity(from: body, fallbackAuthor: author)
448448 let displayBody = sanitizedDisplayBody(from: body)
449 let contentBlocks = segmentMessageBody(displayBody, isPatch: payload.patch != nil)
449 let contentBlocks = InboxThreadUtilities.segmentMessageBody(displayBody, isPatch: payload.patch != nil)
450450
451451 return InboxMessage(
452452 id: id,
@@ -540,7 +540,7 @@ final class ThreadViewModel {
540540 }
541541
542542 private static func sanitizedDisplayBody(from body: String) -> String {
543 let normalizedBody = normalizeLineEndings(in: body)
543 let normalizedBody = InboxThreadUtilities.normalizeLineEndings(in: body)
544544 let lines = normalizedBody.components(separatedBy: "\n")
545545 let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"]
546546 var headerCount = 0
@@ -565,93 +565,6 @@ final class ThreadViewModel {
565565 return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n")
566566 }
567567
568 nonisolated static func segmentMessageBodyForTesting(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] {
569 segmentMessageBody(body, isPatch: isPatch)
570 }
571
572 private nonisolated static func segmentMessageBody(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] {
573 guard isPatch else {
574 let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
575 return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)]
576 }
577
578 let normalizedBody = normalizeLineEndings(in: body)
579 let lines = normalizedBody.components(separatedBy: "\n")
580 guard let diffStartIndex = actualDiffStartIndex(in: lines) else {
581 let trimmedBody = normalizedBody.trimmingCharacters(in: .whitespacesAndNewlines)
582 return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)]
583 }
584
585 var blocks: [InboxMessageContentBlock] = []
586 let leadingPlainText = lines[..<diffStartIndex]
587 .joined(separator: "\n")
588 .trimmingCharacters(in: .whitespacesAndNewlines)
589 if !leadingPlainText.isEmpty {
590 blocks.append(.plainText(leadingPlainText))
591 }
592
593 let remainingLines = Array(lines[diffStartIndex...])
594 let signatureIndex = remainingLines.firstIndex(where: isEmailSignatureSeparator)
595
596 let diffLines: ArraySlice<String>
597 let trailingPlainText: String
598 if let signatureIndex {
599 diffLines = remainingLines[..<signatureIndex]
600 trailingPlainText = remainingLines[signatureIndex...]
601 .joined(separator: "\n")
602 .trimmingCharacters(in: .whitespacesAndNewlines)
603 } else {
604 diffLines = remainingLines[...]
605 trailingPlainText = ""
606 }
607
608 let diff = diffLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
609 if !diff.isEmpty {
610 blocks.append(.diff(diff))
611 }
612
613 if !trailingPlainText.isEmpty {
614 blocks.append(.plainText(trailingPlainText))
615 }
616 return blocks
617 }
618
619 private nonisolated static func actualDiffStartIndex(in lines: [String]) -> Int? {
620 if let explicitDiffIndex = lines.firstIndex(where: { $0.hasPrefix("diff --git ") }) {
621 return explicitDiffIndex
622 }
623
624 for index in lines.indices {
625 let line = lines[index]
626 guard line.hasPrefix("--- ") else { continue }
627 let nextIndex = lines.index(after: index)
628 guard nextIndex < lines.endIndex else { continue }
629 let nextLine = lines[nextIndex]
630 guard nextLine.hasPrefix("+++ ") else { continue }
631
632 let oldPath = String(line.dropFirst(4))
633 let newPath = String(nextLine.dropFirst(4))
634 let looksLikeUnifiedDiff = (oldPath.hasPrefix("a/") || oldPath == "/dev/null") &&
635 (newPath.hasPrefix("b/") || newPath == "/dev/null")
636
637 if looksLikeUnifiedDiff {
638 return index
639 }
640 }
641
642 return nil
643 }
644
645 private nonisolated static func isEmailSignatureSeparator(_ line: String) -> Bool {
646 line == "-- " || line == "--"
647 }
648
649 private nonisolated static func normalizeLineEndings(in text: String) -> String {
650 text
651 .replacingOccurrences(of: "\r\n", with: "\n")
652 .replacingOccurrences(of: "\r", with: "\n")
653 }
654
655568 private static func stripLeadingFromLineIfPresent(in body: String) -> String {
656569 let lines = body.components(separatedBy: "\n")
657570 guard let firstLine = lines.first, firstLine.hasPrefix("From:") else {
HutchTests/InboxViewModelTests.swift +1 −1
@@ -186,7 +186,7 @@ struct InboxViewModelTests {
186186 2.50.1 (Apple Git-155)
187187 """
188188
189 let segments = ThreadViewModel.segmentMessageBodyForTesting(body, isPatch: true)
189 let segments = InboxThreadUtilities.segmentMessageBody(body, isPatch: true)
190190
191191 #expect(segments.count == 3)
192192