summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/Models/Patchset.swift132
-rw-r--r--Hutch/Views/Inbox/InboxThreadUtilities.swift88
-rw-r--r--Hutch/Views/Inbox/ThreadViewModel.swift91
3 files changed, 222 insertions, 89 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 {