diff options
| author | Christian Cleberg <[email protected]> | 2026-07-15 21:42:38 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-15 21:42:38 -0500 |
| commit | ccec322f8eac4d14638f5abdae7dae7abc95eb7e (patch) | |
| tree | 7eb92e38d5dc88ba839b94c294ae9be0ea21a6d0 | |
| parent | b9ec80716ea015de5b6b31395fdc5ff03191398c (diff) | |
| download | hutch-ccec322f8eac4d14638f5abdae7dae7abc95eb7e.tar.gz hutch-ccec322f8eac4d14638f5abdae7dae7abc95eb7e.tar.bz2 hutch-ccec322f8eac4d14638f5abdae7dae7abc95eb7e.zip | |
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.
| -rw-r--r-- | Hutch/Models/Patchset.swift | 132 | ||||
| -rw-r--r-- | Hutch/Views/Inbox/InboxThreadUtilities.swift | 88 | ||||
| -rw-r--r-- | Hutch/Views/Inbox/ThreadViewModel.swift | 91 | ||||
| -rw-r--r-- | 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) |
