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 | return nil | 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 | let normalizedIdentity = normalizedSenderIdentity(from: body, fallbackAuthor: author) | 447 | let normalizedIdentity = normalizedSenderIdentity(from: body, fallbackAuthor: author) |
| 448 | let displayBody = sanitizedDisplayBody(from: body) | 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 | return InboxMessage( | 451 | return InboxMessage( |
| 452 | id: id, | 452 | id: id, |
| @@ -540,7 +540,7 @@ final class ThreadViewModel { | |||
| 540 | } | 540 | } |
| 541 | 541 | ||
| 542 | private static func sanitizedDisplayBody(from body: String) -> String { | 542 | private static func sanitizedDisplayBody(from body: String) -> String { |
| 543 | let normalizedBody = normalizeLineEndings(in: body) | 543 | let normalizedBody = InboxThreadUtilities.normalizeLineEndings(in: body) |
| 544 | let lines = normalizedBody.components(separatedBy: "\n") | 544 | let lines = normalizedBody.components(separatedBy: "\n") |
| 545 | let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"] | 545 | let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"] |
| 546 | var headerCount = 0 | 546 | var headerCount = 0 |
| @@ -565,93 +565,6 @@ final class ThreadViewModel { | |||
| 565 | return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n") | 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 | private static func stripLeadingFromLineIfPresent(in body: String) -> String { | 568 | private static func stripLeadingFromLineIfPresent(in body: String) -> String { |
| 656 | let lines = body.components(separatedBy: "\n") | 569 | let lines = body.components(separatedBy: "\n") |
| 657 | guard let firstLine = lines.first, firstLine.hasPrefix("From:") else { | 570 | guard let firstLine = lines.first, firstLine.hasPrefix("From:") else { |
HutchTests/InboxViewModelTests.swift +1 −1
| @@ -186,7 +186,7 @@ struct InboxViewModelTests { | |||
| 186 | 2.50.1 (Apple Git-155) | 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 | #expect(segments.count == 3) | 191 | #expect(segments.count == 3) |
| 192 | 192 | ||