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 /Hutch/Views | |
| 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.
Diffstat (limited to 'Hutch/Views')
| -rw-r--r-- | Hutch/Views/Inbox/InboxThreadUtilities.swift | 88 | ||||
| -rw-r--r-- | Hutch/Views/Inbox/ThreadViewModel.swift | 91 |
2 files changed, 90 insertions, 89 deletions
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 { |
