Commit aa850ed86d
Unsigned
Layout: unified · split
Hutch/Models/Patchset.swift added +132
| @@ -0,0 +1,132 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// Review state of a patchset on lists.sr.ht. | |
| 4 | enum 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. | |
| 58 | struct 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. | |
| 73 | struct 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. | |
| 91 | struct PatchsetToolResult: Identifiable, Hashable, Sendable { | |
| 92 | let id: Int | |
| 93 | let icon: PatchsetToolIcon | |
| 94 | let details: String | |
| 95 | } | |
| 96 | ||
| 97 | enum 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. | |
| 115 | struct 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 { | ||
| 8 | 8 | } |
| 9 | 9 | return nil |
| 10 | 10 | } |
| 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 | } | |
| 11 | 99 | } |
Hutch/Views/Inbox/ThreadViewModel.swift +2 −89
| @@ -446,7 +446,7 @@ final class ThreadViewModel { | ||
| 446 | 446 | |
| 447 | 447 | let normalizedIdentity = normalizedSenderIdentity(from: body, fallbackAuthor: author) |
| 448 | 448 | let displayBody = sanitizedDisplayBody(from: body) |
| 449 | let contentBlocks = segmentMessageBody(displayBody, isPatch: payload.patch != nil) | |
| 449 | let contentBlocks = InboxThreadUtilities.segmentMessageBody(displayBody, isPatch: payload.patch != nil) | |
| 450 | 450 | |
| 451 | 451 | return InboxMessage( |
| 452 | 452 | id: id, |
| @@ -540,7 +540,7 @@ final class ThreadViewModel { | ||
| 540 | 540 | } |
| 541 | 541 | |
| 542 | 542 | private static func sanitizedDisplayBody(from body: String) -> String { |
| 543 | let normalizedBody = normalizeLineEndings(in: body) | |
| 543 | let normalizedBody = InboxThreadUtilities.normalizeLineEndings(in: body) | |
| 544 | 544 | let lines = normalizedBody.components(separatedBy: "\n") |
| 545 | 545 | let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"] |
| 546 | 546 | var headerCount = 0 |
| @@ -565,93 +565,6 @@ final class ThreadViewModel { | ||
| 565 | 565 | return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n") |
| 566 | 566 | } |
| 567 | 567 | |
| 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 | ||
| 655 | 568 | private static func stripLeadingFromLineIfPresent(in body: String) -> String { |
| 656 | 569 | let lines = body.components(separatedBy: "\n") |
| 657 | 570 | guard let firstLine = lines.first, firstLine.hasPrefix("From:") else { |
HutchTests/InboxViewModelTests.swift +1 −1
| @@ -186,7 +186,7 @@ struct InboxViewModelTests { | ||
| 186 | 186 | 2.50.1 (Apple Git-155) |
| 187 | 187 | """ |
| 188 | 188 | |
| 189 | let segments = ThreadViewModel.segmentMessageBodyForTesting(body, isPatch: true) | |
| 189 | let segments = InboxThreadUtilities.segmentMessageBody(body, isPatch: true) | |
| 190 | 190 | |
| 191 | 191 | #expect(segments.count == 3) |
| 192 | 192 | |