From 7815978cd1a9d056f3569c355a98c398425e1685 Mon Sep 17 00:00:00 2001 From: Gary Tokman <12258850+gtokman@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:32:41 +0000 Subject: [PATCH 1/3] Add sectioned diff view controller with expandable context Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Example/Example/SectionedDiffExample.swift | 154 ++++++ .../Example/SectionedDiffViewController.swift | 49 ++ Example/Example/ViewController.swift | 22 +- README.md | 21 + .../DiffFilesView/DiffExpanderCell.swift | 175 ++++++ .../DiffFilesView/DiffFileHeaderView.swift | 114 ++++ .../DiffFilesViewConfiguration.swift | 36 ++ .../DiffFilesViewController.swift | 383 +++++++++++++ .../DiffFilesView/DiffHunkCell.swift | 44 ++ .../DiffFilesView/DiffPatchDocument.swift | 523 ++++++++++++++++++ .../Supplements/UnifiedDiff.swift | 17 + .../DiffPatchDocumentTests.swift | 208 +++++++ 12 files changed, 1738 insertions(+), 8 deletions(-) create mode 100644 Example/Example/SectionedDiffExample.swift create mode 100644 Example/Example/SectionedDiffViewController.swift create mode 100644 Sources/MarkdownView/Components/DiffFilesView/DiffExpanderCell.swift create mode 100644 Sources/MarkdownView/Components/DiffFilesView/DiffFileHeaderView.swift create mode 100644 Sources/MarkdownView/Components/DiffFilesView/DiffFilesViewConfiguration.swift create mode 100644 Sources/MarkdownView/Components/DiffFilesView/DiffFilesViewController.swift create mode 100644 Sources/MarkdownView/Components/DiffFilesView/DiffHunkCell.swift create mode 100644 Sources/MarkdownView/Components/DiffFilesView/DiffPatchDocument.swift create mode 100644 Tests/MarkdownViewTests/DiffPatchDocumentTests.swift diff --git a/Example/Example/SectionedDiffExample.swift b/Example/Example/SectionedDiffExample.swift new file mode 100644 index 0000000..a8c11e5 --- /dev/null +++ b/Example/Example/SectionedDiffExample.swift @@ -0,0 +1,154 @@ +// +// SectionedDiffExample.swift +// Example +// + +import Foundation + +/// A stand-in "repository": full file contents plus the patch describing the +/// pull request, built from the same lines so expanding context lines up with +/// what the diff shows. +enum SectionedDiffFixture { + struct Change { + /// 1-based line in the original file where the change starts. + let oldStart: Int + /// Lines replaced, which must match the original file. + let removed: [String] + let added: [String] + } + + struct File { + let path: String + let originalLines: [String] + let changes: [Change] + } + + static let contextLines = 3 + + static let files: [File] = [ + .init( + path: "Sources/Networking/APIClient.swift", + originalLines: originalLines( + type: "APIClient", + methods: ["send", "upload", "download", "cancel", "invalidate"] + ), + changes: [ + .init( + oldStart: 8, + removed: [" let request = makeRequest(for: send)"], + added: [ + " var request = makeRequest(for: send)", + " request.timeoutInterval = configuration.timeout", + ] + ), + .init( + oldStart: 63, + removed: [" return try decoder.decode(Response.self, from: data)"], + added: [ + " do {", + " return try decoder.decode(Response.self, from: data)", + " } catch {", + " throw APIError.decoding(error)", + " }", + ] + ), + ] + ), + .init( + path: "Sources/Networking/RetryPolicy.swift", + originalLines: originalLines( + type: "RetryPolicy", + methods: ["shouldRetry", "delay", "reset"] + ), + changes: [ + .init( + oldStart: 39, + removed: [" private let delayStep8 = Step(id: 8)"], + added: [ + " private let delayStep8 = Step(id: 8, jitter: 0.2)", + " private let delayStep9 = Step(id: 9, jitter: 0.4)", + ] + ), + ] + ), + ] + + static var patch: String { + files.map(patch(for:)).joined(separator: "\n") + } + + /// Original contents of a file, keyed by the path shown in the section header. + static func originalLines(for path: String) -> [String]? { + files.first { $0.path == path }?.originalLines + } + + private static func patch(for file: File) -> String { + var lines = [ + "diff --git a/\(file.path) b/\(file.path)", + "--- a/\(file.path)", + "+++ b/\(file.path)", + ] + + // The patch is generated left to right, so the running offset keeps the + // `+` side line numbers in step with the edits already applied. + var newOffset = 0 + for change in file.changes { + let leading = Array( + file.originalLines[ + max(change.oldStart - 1 - contextLines, 0) ..< (change.oldStart - 1) + ] + ) + let trailingStart = change.oldStart - 1 + change.removed.count + let trailing = Array( + file.originalLines[ + trailingStart ..< min(trailingStart + contextLines, file.originalLines.count) + ] + ) + + let oldStart = change.oldStart - leading.count + let oldCount = leading.count + change.removed.count + trailing.count + let newCount = leading.count + change.added.count + trailing.count + lines.append("@@ -\(oldStart),\(oldCount) +\(oldStart + newOffset),\(newCount) @@") + lines += leading.map { " \($0)" } + lines += change.removed.map { "-\($0)" } + lines += change.added.map { "+\($0)" } + lines += trailing.map { " \($0)" } + newOffset += change.added.count - change.removed.count + } + + return lines.joined(separator: "\n") + } + + private static func originalLines(type: String, methods: [String]) -> [String] { + var lines = [ + "import Foundation", + "", + "/// Generated stand-in source used by the sectioned diff example.", + "struct \(type) {", + " let configuration: Configuration", + "", + ] + + for (index, method) in methods.enumerated() { + lines += [ + " func \(method)(_ request: Request) throws -> Response {", + " let request = makeRequest(for: \(method))", + " try validate(request, index: \(index))", + " let data = try transport.perform(request)", + " try log(\"\(method)\", bytes: data.count)", + " return try decoder.decode(Response.self, from: data)", + " }", + "", + ] + // Pad each method so the hunks sit far enough apart for the + // expanders between them to be interesting. + for step in 1 ... 8 { + lines.append(" private let \(method)Step\(step) = Step(id: \(step))") + } + lines.append("") + } + + lines.append("}") + return lines + } +} diff --git a/Example/Example/SectionedDiffViewController.swift b/Example/Example/SectionedDiffViewController.swift new file mode 100644 index 0000000..3e590fd --- /dev/null +++ b/Example/Example/SectionedDiffViewController.swift @@ -0,0 +1,49 @@ +// +// SectionedDiffViewController.swift +// Example +// + +import MarkdownView +import UIKit + +/// Shows a multi-file patch with one sticky section per file, backed by an +/// async context provider that pretends to hit a server so the expander's +/// loading state is visible. +final class SectionedDiffViewController: UIViewController { + private let diffController = DiffFilesViewController( + patch: SectionedDiffFixture.patch, + language: "swift" + ) + + override func viewDidLoad() { + super.viewDidLoad() + title = "Files Changed" + view.backgroundColor = .systemBackground + + diffController.fileLineCountProvider = { path in + SectionedDiffFixture.originalLines(for: path)?.count + } + diffController.contextProvider = { request in + try await Task.sleep(nanoseconds: 600_000_000) + guard let lines = SectionedDiffFixture.originalLines(for: request.filePath) else { + return [] + } + let range = request.oldLineRange.clamped(to: 1 ... lines.count) + return range.map { lines[$0 - 1] } + } + diffController.expansionFailureHandler = { request, error in + print("Failed to expand \(request.filePath): \(error)") + } + + addChild(diffController) + diffController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(diffController.view) + NSLayoutConstraint.activate([ + diffController.view.topAnchor.constraint(equalTo: view.topAnchor), + diffController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + diffController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + diffController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + diffController.didMove(toParent: self) + } +} diff --git a/Example/Example/ViewController.swift b/Example/Example/ViewController.swift index b634cc7..492b6a9 100644 --- a/Example/Example/ViewController.swift +++ b/Example/Example/ViewController.swift @@ -16,15 +16,19 @@ class ViewController: UITableViewController { } override func numberOfSections(in tableView: UITableView) -> Int { - 2 + 3 } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - section == 0 ? "Streaming" : "Diff & Selection" + switch section { + case 0: "Streaming" + case 1: "Pull Request" + default: "Diff & Selection" + } } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - section == 0 ? 1 : examples.count + section == 2 ? examples.count : 1 } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { @@ -33,6 +37,9 @@ class ViewController: UITableViewController { if indexPath.section == 0 { config.text = "Streaming Reveal" config.secondaryText = "Per-character fade-in as text streams" + } else if indexPath.section == 1 { + config.text = "Files Changed" + config.secondaryText = "Sticky file sections · Expandable context" } else { let example = examples[indexPath.row] config.text = example.title @@ -44,11 +51,10 @@ class ViewController: UITableViewController { } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let destination: UIViewController - if indexPath.section == 0 { - destination = StreamingRevealViewController() - } else { - destination = DetailViewController(example: examples[indexPath.row]) + let destination: UIViewController = switch indexPath.section { + case 0: StreamingRevealViewController() + case 1: SectionedDiffViewController() + default: DetailViewController(example: examples[indexPath.row]) } navigationController?.pushViewController(destination, animated: true) } diff --git a/README.md b/README.md index 2be71cc..481736f 100644 --- a/README.md +++ b/README.md @@ -432,6 +432,27 @@ markdownView.setMarkdown(content) Pass the patch string itself, not the surrounding JSON object. Avoid calling `parser.parse(patch)` directly for raw unified diffs, because that bypasses the normalizer that wraps the patch for diff rendering. +### Sectioned Diffs (Files Changed) + +`DiffFilesViewController` (iOS/visionOS) renders a multi-file patch as a collection view with **one section per file**: the file's header pins to the top of the viewport while its hunks scroll past, and the gaps the patch omits become expander rows. + +```swift +let controller = DiffFilesViewController(patch: patch, language: "swift") + +// Enables the expander below the last hunk — without the file's length there +// is no way to know whether more lines follow. +controller.fileLineCountProvider = { path in sources[path]?.count } + +// Called when an arrow is tapped; the row shows a spinner until it returns. +controller.contextProvider = { request in + try await api.lines(of: request.filePath, in: request.oldLineRange) +} +``` + +An expander offers an up arrow (lines above the next hunk), a down arrow (lines below the previous hunk), or both when the gap is bounded on both sides and larger than `expansionChunkSize` (20 lines by default). A gap smaller than one chunk collapses to a single control that reveals all of it, and hunks that meet after an expansion are merged into one. + +Expanders only appear once `contextProvider` is set. Errors it throws are reported through `expansionFailureHandler`, and tapping a file header collapses the file and calls `fileCollapseHandler`. + ## Architecture The library is split into two modules: diff --git a/Sources/MarkdownView/Components/DiffFilesView/DiffExpanderCell.swift b/Sources/MarkdownView/Components/DiffFilesView/DiffExpanderCell.swift new file mode 100644 index 0000000..9e5531e --- /dev/null +++ b/Sources/MarkdownView/Components/DiffFilesView/DiffExpanderCell.swift @@ -0,0 +1,175 @@ +import Foundation + +#if canImport(UIKit) + import UIKit + + /// The row standing in for the lines a patch omits. Depending on the gap it + /// offers an up arrow (lines above the next hunk), a down arrow (lines below + /// the previous hunk) or both, and swaps the tapped arrow for a spinner + /// while the host fetches the lines. + final class DiffExpanderCell: UICollectionViewCell { + private lazy var upControl: ExpanderControl = .init(direction: .up) + private lazy var downControl: ExpanderControl = .init(direction: .down) + private lazy var label: UILabel = .init() + private lazy var separator: UIView = .init() + private var tapHandler: ((DiffExpander.Direction) -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + configureSubviews() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure( + expander: DiffExpander, + theme: MarkdownTheme, + loadingDirections: Set, + onTap: @escaping (DiffExpander.Direction) -> Void + ) { + tapHandler = onTap + contentView.backgroundColor = theme.diff.collapsedContextBackground + separator.backgroundColor = theme.diff.borderColor + + label.font = theme.fonts.footnote + label.textColor = theme.diff.collapsedContextText + label.text = expander.coversEntireGap + ? "Show \(expander.hiddenLineCount) hidden lines" + : "\(expander.hiddenLineCount) hidden lines" + + // One tap covers the whole gap, so a single control is enough; + // otherwise each end of the gap gets its own arrow. + let directions: [DiffExpander.Direction] = expander.coversEntireGap + ? [expander.direction == .down ? .down : .up] + : (expander.direction == .both ? [.up, .down] : [expander.direction]) + + upControl.isHidden = !directions.contains(.up) + downControl.isHidden = !directions.contains(.down) + upControl.apply( + theme: theme, + isLoading: loadingDirections.contains(.up), + coversEntireGap: expander.coversEntireGap + ) + downControl.apply( + theme: theme, + isLoading: loadingDirections.contains(.down), + coversEntireGap: expander.coversEntireGap + ) + + for control in [upControl, downControl] { + control.tapHandler = { [weak self] direction in + guard let self else { return } + // `.both` fetches the gap in one request when it fits in a + // single chunk, whichever arrow is tapped. + tapHandler?(expander.coversEntireGap ? expander.direction : direction) + } + } + } + + private func configureSubviews() { + let heightConstraint = contentView.heightAnchor.constraint( + equalToConstant: DiffFilesViewConfiguration.expanderRowHeight + ) + heightConstraint.priority = .required - 1 + + let stack = UIStackView(arrangedSubviews: [upControl, downControl, label]) + stack.axis = .horizontal + stack.alignment = .fill + stack.spacing = 4 + stack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(stack) + + separator.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separator) + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + stack.trailingAnchor.constraint( + lessThanOrEqualTo: contentView.trailingAnchor, + constant: -DiffFilesViewConfiguration.headerPadding + ), + stack.topAnchor.constraint(equalTo: contentView.topAnchor), + stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + heightConstraint, + + separator.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + separator.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separator.heightAnchor.constraint( + equalToConstant: DiffFilesViewConfiguration.hairlineWidth + ), + ]) + } + } + + /// A single arrow inside an expander row, showing a spinner in place of the + /// arrow while its lines are in flight. + private final class ExpanderControl: UIView { + let direction: DiffExpander.Direction + var tapHandler: ((DiffExpander.Direction) -> Void)? + + private lazy var button: UIButton = .init(type: .system) + private lazy var spinner: UIActivityIndicatorView = .init(style: .medium) + + init(direction: DiffExpander.Direction) { + self.direction = direction + super.init(frame: .zero) + + button.addTarget(self, action: #selector(handleTap), for: .touchUpInside) + + for subview in [button, spinner] as [UIView] { + subview.translatesAutoresizingMaskIntoConstraints = false + addSubview(subview) + NSLayoutConstraint.activate([ + subview.centerXAnchor.constraint(equalTo: centerXAnchor), + subview.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + spinner.hidesWhenStopped = true + + widthAnchor.constraint( + equalToConstant: DiffFilesViewConfiguration.expanderControlWidth + ).isActive = true + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(theme: MarkdownTheme, isLoading: Bool, coversEntireGap: Bool) { + let symbol = if coversEntireGap { + "arrow.up.and.down" + } else { + direction == .up ? "arrow.up.to.line" : "arrow.down.to.line" + } + button.setImage( + UIImage( + systemName: symbol, + withConfiguration: UIImage.SymbolConfiguration(scale: .small) + ), + for: .normal + ) + button.accessibilityLabel = if coversEntireGap { + "Show all hidden lines" + } else { + direction == .up ? "Show lines above" : "Show lines below" + } + button.tintColor = theme.diff.hunkHeaderText + spinner.color = theme.diff.hunkHeaderText + button.isHidden = isLoading + if isLoading { + spinner.startAnimating() + } else { + spinner.stopAnimating() + } + } + + @objc private func handleTap() { + tapHandler?(direction) + } + } +#endif diff --git a/Sources/MarkdownView/Components/DiffFilesView/DiffFileHeaderView.swift b/Sources/MarkdownView/Components/DiffFilesView/DiffFileHeaderView.swift new file mode 100644 index 0000000..87dcdf8 --- /dev/null +++ b/Sources/MarkdownView/Components/DiffFilesView/DiffFileHeaderView.swift @@ -0,0 +1,114 @@ +import Foundation + +#if canImport(UIKit) + import UIKit + + /// Sticky section header naming the file a diff section renders, with its + /// added/removed counts and a chevron that collapses the file. + final class DiffFileHeaderView: UICollectionReusableView { + private lazy var chevronView: UIImageView = .init() + private lazy var pathLabel: UILabel = .init() + private lazy var additionsLabel: UILabel = .init() + private lazy var deletionsLabel: UILabel = .init() + private lazy var separator: UIView = .init() + private var tapHandler: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + configureSubviews() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure( + file: DiffFilePatch, + theme: MarkdownTheme, + isCollapsed: Bool, + onToggle: @escaping () -> Void + ) { + tapHandler = onToggle + backgroundColor = theme.diff.fileHeaderBackground + separator.backgroundColor = theme.diff.borderColor + + pathLabel.font = theme.fonts.code + pathLabel.textColor = theme.diff.fileHeaderText + pathLabel.text = file.displayPath + + additionsLabel.font = theme.fonts.footnote + additionsLabel.textColor = theme.diff.addedIndicatorText + additionsLabel.text = "+\(file.additions)" + + deletionsLabel.font = theme.fonts.footnote + deletionsLabel.textColor = theme.diff.removedIndicatorText + deletionsLabel.text = "-\(file.deletions)" + + chevronView.tintColor = theme.diff.fileMetadataText + chevronView.image = UIImage( + systemName: isCollapsed ? "chevron.right" : "chevron.down", + withConfiguration: UIImage.SymbolConfiguration(scale: .small) + ) + + accessibilityLabel = "\(file.displayPath), \(file.additions) added, \(file.deletions) removed" + accessibilityHint = isCollapsed ? "Expands the file" : "Collapses the file" + } + + private func configureSubviews() { + isAccessibilityElement = true + accessibilityTraits = .button + + chevronView.contentMode = .center + chevronView.setContentHuggingPriority(.required, for: .horizontal) + + pathLabel.lineBreakMode = .byTruncatingHead + pathLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + for label in [additionsLabel, deletionsLabel] { + label.setContentHuggingPriority(.required, for: .horizontal) + label.setContentCompressionResistancePriority(.required, for: .horizontal) + } + + let stack = UIStackView(arrangedSubviews: [ + chevronView, pathLabel, additionsLabel, deletionsLabel, + ]) + stack.axis = .horizontal + stack.alignment = .center + stack.spacing = 8 + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + + separator.translatesAutoresizingMaskIntoConstraints = false + addSubview(separator) + + let padding = DiffFilesViewConfiguration.headerPadding + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding), + stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -padding), + stack.topAnchor.constraint(equalTo: topAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor), + + separator.leadingAnchor.constraint(equalTo: leadingAnchor), + separator.trailingAnchor.constraint(equalTo: trailingAnchor), + separator.bottomAnchor.constraint(equalTo: bottomAnchor), + separator.heightAnchor.constraint( + equalToConstant: DiffFilesViewConfiguration.hairlineWidth + ), + ]) + + addGestureRecognizer( + UITapGestureRecognizer(target: self, action: #selector(handleTap)) + ) + } + + @objc private func handleTap() { + tapHandler?() + } + + override func accessibilityActivate() -> Bool { + tapHandler?() + return tapHandler != nil + } + } +#endif diff --git a/Sources/MarkdownView/Components/DiffFilesView/DiffFilesViewConfiguration.swift b/Sources/MarkdownView/Components/DiffFilesView/DiffFilesViewConfiguration.swift new file mode 100644 index 0000000..53a0e67 --- /dev/null +++ b/Sources/MarkdownView/Components/DiffFilesView/DiffFilesViewConfiguration.swift @@ -0,0 +1,36 @@ +import Foundation + +#if canImport(UIKit) + import UIKit + + enum DiffFilesViewConfiguration { + static let sectionSpacing: CGFloat = 16 + static let headerPadding: CGFloat = 12 + static let expanderRowHeight: CGFloat = 36 + static let expanderControlWidth: CGFloat = 44 + static let hairlineWidth: CGFloat = 1 + + static func backgroundColor(theme: MarkdownTheme) -> UIColor { + theme.diff.backgroundColor ?? theme.colors.codeBackground.withAlphaComponent(0.08) + } + + static func headerHeight(theme: MarkdownTheme) -> CGFloat { + max(theme.fonts.code.lineHeight + headerPadding * 2, 44) + } + + static func estimatedItemHeight(theme: MarkdownTheme) -> CGFloat { + theme.fonts.code.lineHeight * 8 + } + + /// Theme for the diff inside a section: the section header already names + /// the file, and the hunks are stacked edge to edge, so the per-block + /// chrome is dropped. + static func hunkTheme(from theme: MarkdownTheme) -> MarkdownTheme { + var hunkTheme = theme + hunkTheme.showsBlockHeaders = false + hunkTheme.diff.cornerRadius = 0 + hunkTheme.diff.borderWidth = 0 + return hunkTheme + } + } +#endif diff --git a/Sources/MarkdownView/Components/DiffFilesView/DiffFilesViewController.swift b/Sources/MarkdownView/Components/DiffFilesView/DiffFilesViewController.swift new file mode 100644 index 0000000..7e752d2 --- /dev/null +++ b/Sources/MarkdownView/Components/DiffFilesView/DiffFilesViewController.swift @@ -0,0 +1,383 @@ +import Foundation + +#if canImport(UIKit) + import UIKit + + /// Where the lines requested by a context expansion sit relative to the + /// hunk that is being grown. + public enum DiffContextDirection: Hashable, Sendable { + /// Lines directly above a hunk. + case up + /// Lines directly below a hunk. + case down + /// The whole gap between two hunks, revealed by a single tap. + case all + } + + /// A request for the pre-image lines hidden between (or around) two hunks. + public struct DiffContextRequest: Hashable, Sendable { + /// Path of the file the section renders, as shown in its header. + public let filePath: String + /// 1-based, inclusive line numbers in the *old* file. + public let oldLineRange: ClosedRange + public let direction: DiffContextDirection + + public init( + filePath: String, + oldLineRange: ClosedRange, + direction: DiffContextDirection + ) { + self.filePath = filePath + self.oldLineRange = oldLineRange + self.direction = direction + } + } + + /// Renders a unified patch as a collection view with one **section per + /// file**: the file's header pins to the top while its hunks scroll, and + /// the gaps the patch omits become expander rows that pull in more of the + /// original file on tap. + /// + /// ```swift + /// let controller = DiffFilesViewController(patch: patch, language: "swift") + /// controller.fileLineCountProvider = { path in sources[path]?.count } + /// controller.contextProvider = { request in + /// try await api.lines(of: request.filePath, in: request.oldLineRange) + /// } + /// ``` + /// + /// Expanders only appear once a `contextProvider` is set, and the expander + /// below the last hunk additionally needs `fileLineCountProvider` — without + /// the file's length there is no way to know whether more lines follow. + public final class DiffFilesViewController: UIViewController { + /// Theme used for every hunk. Block headers are always suppressed + /// inside sections, since the section header names the file. + public var theme: MarkdownTheme = .default { + didSet { + collectionView.backgroundColor = DiffFilesViewConfiguration.backgroundColor(theme: theme) + collectionView.collectionViewLayout.invalidateLayout() + reloadEverything() + } + } + + /// Lines revealed by one tap on an expander arrow. + public var expansionChunkSize: Int = DiffPatchDocument.defaultExpansionChunkSize { + didSet { applySnapshot(animated: false) } + } + + /// Supplies the hidden pre-image lines for an expander. Expanders are + /// hidden while this is `nil`. + public var contextProvider: (@MainActor (DiffContextRequest) async throws -> [String])? { + didSet { applySnapshot(animated: false) } + } + + /// Total line count of a file's pre-image, keyed by the path shown in + /// the section header. Enables the expander below the last hunk. + public var fileLineCountProvider: (@MainActor (String) -> Int?)? { + didSet { applySnapshot(animated: false) } + } + + /// Called when `contextProvider` throws, so hosts can surface the error. + public var expansionFailureHandler: ((DiffContextRequest, any Error) -> Void)? + + /// Called when a file's section header is tapped to collapse or expand + /// the file. + public var fileCollapseHandler: ((String, Bool) -> Void)? + + public private(set) var patch: String = "" + + private var document = DiffPatchDocument(files: [], language: nil) + private var collapsedFileIDs: Set = [] + private var loadingExpansions: Set = [] + + private struct ExpansionKey: Hashable { + let expander: DiffExpander + let direction: DiffExpander.Direction + } + + private lazy var collectionView: UICollectionView = .init( + frame: .zero, + collectionViewLayout: makeLayout() + ) + private var dataSource: UICollectionViewDiffableDataSource! + + public init(patch: String, language: String? = nil, theme: MarkdownTheme = .default) { + self.theme = theme + super.init(nibName: nil, bundle: nil) + setPatch(patch, language: language) + } + + @available(*, unavailable) + public required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Replaces the rendered patch, resetting collapse and expansion state. + public func setPatch(_ patch: String, language: String? = nil) { + self.patch = patch + document = DiffPatchDocument(patch: patch, language: language) + ?? .init(files: [], language: language) + collapsedFileIDs = [] + loadingExpansions = [] + guard isViewLoaded else { return } + applySnapshot(animated: false) + } + + /// Paths of the rendered files, in patch order. + public var filePaths: [String] { + document.files.map(\.displayPath) + } + + override public func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = DiffFilesViewConfiguration.backgroundColor(theme: theme) + configureCollectionView() + configureDataSource() + applySnapshot(animated: false) + } + + /// Scrolls the file with the given path to the top of the viewport. + public func scrollToFile(at path: String, animated: Bool = true) { + guard let index = document.files.firstIndex(where: { $0.displayPath == path }), + dataSource.snapshot().numberOfItems(inSection: document.files[index].id) > 0 + else { return } + collectionView.scrollToItem( + at: IndexPath(item: 0, section: index), + at: .top, + animated: animated + ) + } + + // MARK: - Setup + + private func configureCollectionView() { + collectionView.backgroundColor = DiffFilesViewConfiguration.backgroundColor(theme: theme) + collectionView.alwaysBounceVertical = true + collectionView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(collectionView) + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + private func makeLayout() -> UICollectionViewLayout { + let configuration = UICollectionViewCompositionalLayoutConfiguration() + configuration.interSectionSpacing = DiffFilesViewConfiguration.sectionSpacing + + return UICollectionViewCompositionalLayout( + sectionProvider: { [weak self] _, _ in + let theme = self?.theme ?? .default + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .estimated(DiffFilesViewConfiguration.estimatedItemHeight(theme: theme)) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item]) + let section = NSCollectionLayoutSection(group: group) + + let header = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: .init( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(DiffFilesViewConfiguration.headerHeight(theme: theme)) + ), + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .top + ) + // Sticky file headers: the header of the file being scrolled + // stays put until the next file pushes it off. + header.pinToVisibleBounds = true + header.zIndex = 2 + section.boundarySupplementaryItems = [header] + return section + }, + configuration: configuration + ) + } + + private func configureDataSource() { + let hunkRegistration = UICollectionView.CellRegistration { + [weak self] cell, _, item in + guard let self, + case let .hunk(fileID, hunkID) = item, + let file = document.file(withID: fileID), + let hunkIndex = file.hunks.firstIndex(where: { $0.id == hunkID }) + else { return } + cell.configure( + renderBlock: file.renderBlock(forHunkAt: hunkIndex), + theme: theme + ) + } + + let expanderRegistration = UICollectionView.CellRegistration { + [weak self] cell, _, item in + guard let self, case let .expander(expander) = item else { return } + cell.configure( + expander: expander, + theme: theme, + loadingDirections: loadingDirections(for: expander) + ) { [weak self] direction in + self?.expandContext(for: expander, direction: direction) + } + } + + dataSource = .init(collectionView: collectionView) { collectionView, indexPath, item in + switch item { + case .hunk: + collectionView.dequeueConfiguredReusableCell( + using: hunkRegistration, + for: indexPath, + item: item + ) + case .expander: + collectionView.dequeueConfiguredReusableCell( + using: expanderRegistration, + for: indexPath, + item: item + ) + } + } + + let headerRegistration = UICollectionView.SupplementaryRegistration( + elementKind: UICollectionView.elementKindSectionHeader + ) { [weak self] header, _, indexPath in + guard let self, document.files.indices.contains(indexPath.section) else { return } + let file = document.files[indexPath.section] + header.configure( + file: file, + theme: theme, + isCollapsed: collapsedFileIDs.contains(file.id) + ) { [weak self] in + self?.toggleCollapse(fileID: file.id) + } + } + + dataSource.supplementaryViewProvider = { collectionView, _, indexPath in + collectionView.dequeueConfiguredReusableSupplementary( + using: headerRegistration, + for: indexPath + ) + } + } + + // MARK: - Snapshots + + private func applySnapshot(animated: Bool, reconfiguringFileWithID fileID: Int? = nil) { + guard isViewLoaded, dataSource != nil else { return } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections(document.files.map(\.id)) + for file in document.files { + guard !collapsedFileIDs.contains(file.id) else { continue } + snapshot.appendItems(items(for: file), toSection: file.id) + } + + if let fileID { + let hunkItems = snapshot.itemIdentifiers(inSection: fileID).filter { + if case .hunk = $0 { return true } + return false + } + snapshot.reconfigureItems(hunkItems) + } + dataSource.apply(snapshot, animatingDifferences: animated) + } + + private func reloadEverything() { + guard isViewLoaded, dataSource != nil else { return } + var snapshot = dataSource.snapshot() + snapshot.reloadSections(snapshot.sectionIdentifiers) + dataSource.apply(snapshot, animatingDifferences: false) + } + + private func items(for file: DiffFilePatch) -> [DiffFileItem] { + let hunkItems = file.hunks.map { DiffFileItem.hunk(fileID: file.id, hunkID: $0.id) } + guard contextProvider != nil else { return hunkItems } + return document.items( + forFileWithID: file.id, + chunkSize: expansionChunkSize, + totalOldLineCount: fileLineCountProvider?(file.displayPath) + ) + } + + private func reconfigure(item: DiffFileItem) { + var snapshot = dataSource.snapshot() + guard snapshot.indexOfItem(item) != nil else { return } + snapshot.reconfigureItems([item]) + dataSource.apply(snapshot, animatingDifferences: false) + } + + private func toggleCollapse(fileID: Int) { + let isCollapsed = collapsedFileIDs.contains(fileID) + if isCollapsed { + collapsedFileIDs.remove(fileID) + } else { + collapsedFileIDs.insert(fileID) + } + applySnapshot(animated: true) + if let file = document.file(withID: fileID) { + fileCollapseHandler?(file.displayPath, !isCollapsed) + } + } + + // MARK: - Context expansion + + private func loadingDirections(for expander: DiffExpander) -> Set { + Set( + loadingExpansions + .filter { $0.expander == expander } + .map(\.direction) + ) + } + + private func expandContext(for expander: DiffExpander, direction: DiffExpander.Direction) { + guard let provider = contextProvider, + let file = document.file(withID: expander.fileID) + else { return } + + let key = ExpansionKey(expander: expander, direction: direction) + guard !loadingExpansions.contains(key) else { return } + loadingExpansions.insert(key) + reconfigure(item: .expander(expander)) + + let range = expander.requestedRange(for: direction, chunkSize: expansionChunkSize) + let request = DiffContextRequest( + filePath: file.displayPath, + oldLineRange: range, + direction: expander.coversEntireGap ? .all : DiffContextDirection(direction) + ) + + Task { [weak self] in + do { + let lines = try await provider(request) + guard let self else { return } + loadingExpansions.remove(key) + document.insertContext( + lines: lines, + forOldLineRange: range, + fileID: expander.fileID, + direction: direction, + expander: expander + ) + applySnapshot(animated: false, reconfiguringFileWithID: expander.fileID) + } catch { + guard let self else { return } + loadingExpansions.remove(key) + reconfigure(item: .expander(expander)) + expansionFailureHandler?(request, error) + } + } + } + } + + private extension DiffContextDirection { + init(_ direction: DiffExpander.Direction) { + switch direction { + case .up: self = .up + case .down: self = .down + case .both: self = .all + } + } + } +#endif diff --git a/Sources/MarkdownView/Components/DiffFilesView/DiffHunkCell.swift b/Sources/MarkdownView/Components/DiffFilesView/DiffHunkCell.swift new file mode 100644 index 0000000..e70abdf --- /dev/null +++ b/Sources/MarkdownView/Components/DiffFilesView/DiffHunkCell.swift @@ -0,0 +1,44 @@ +import Foundation + +#if canImport(UIKit) + import UIKit + + /// Renders one hunk of a file section with the shared `DiffView`, sized to + /// the hunk's rendered height so the collection view can lay it out without + /// a self-sizing pass through Core Text. + final class DiffHunkCell: UICollectionViewCell { + private lazy var diffView: DiffView = .init() + private var heightConstraint: NSLayoutConstraint? + + override init(frame: CGRect) { + super.init(frame: frame) + diffView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(diffView) + let height = diffView.heightAnchor.constraint(equalToConstant: 0) + height.priority = .required - 1 + heightConstraint = height + NSLayoutConstraint.activate([ + diffView.topAnchor.constraint(equalTo: contentView.topAnchor), + diffView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + diffView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + diffView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + height, + ]) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(renderBlock: DiffRenderBlock, theme: MarkdownTheme) { + let hunkTheme = DiffFilesViewConfiguration.hunkTheme(from: theme) + diffView.theme = hunkTheme + diffView.renderBlock = renderBlock + heightConstraint?.constant = DiffViewConfiguration.intrinsicHeight( + for: renderBlock, + theme: hunkTheme + ) + } + } +#endif diff --git a/Sources/MarkdownView/Components/DiffFilesView/DiffPatchDocument.swift b/Sources/MarkdownView/Components/DiffFilesView/DiffPatchDocument.swift new file mode 100644 index 0000000..5be2612 --- /dev/null +++ b/Sources/MarkdownView/Components/DiffFilesView/DiffPatchDocument.swift @@ -0,0 +1,523 @@ +import Foundation + +/// One hunk of a file's patch. Rows exclude the `@@` header row, which is +/// synthesized from the current line ranges so it stays correct after context +/// expansion grows the hunk. +nonisolated struct DiffFileHunk: Identifiable { + let id: Int + var oldStart: Int + var newStart: Int + var rows: [DiffRenderBlock.Row] + + var oldCount: Int { + rows.reduce(into: 0) { partialResult, row in + switch row.kind { + case .context, .removed: + partialResult += 1 + case .added, .annotation, .fileHeader, .fileMetadata, .hunkHeader: + break + } + } + } + + var newCount: Int { + rows.reduce(into: 0) { partialResult, row in + switch row.kind { + case .context, .added: + partialResult += 1 + case .removed, .annotation, .fileHeader, .fileMetadata, .hunkHeader: + break + } + } + } + + /// Last line of the hunk in the old file, or `oldStart - 1` when the hunk + /// covers no old lines (a pure insertion). + var oldEnd: Int { + oldStart + oldCount - 1 + } + + var headerText: String { + "@@ -\(oldStart),\(oldCount) +\(newStart),\(newCount) @@" + } + + var headerRow: DiffRenderBlock.Row { + .init( + kind: .hunkHeader, + oldLineNumber: nil, + newLineNumber: nil, + text: headerText, + syntaxHighlights: [:], + emphasizedRanges: [] + ) + } +} + +/// A single file inside a unified patch. A sectioned diff renders one section +/// per file, with the file's hunks (and the expanders between them) as items. +nonisolated struct DiffFilePatch: Identifiable { + let id: Int + let displayPath: String + let oldPath: String? + let newPath: String? + let language: String? + /// `diff --git` / `---` / `+++` / `index` rows, kept so the raw patch text + /// can be reproduced for copy. + let headerRows: [DiffRenderBlock.Row] + var hunks: [DiffFileHunk] + + var additions: Int { + hunks.reduce(into: 0) { $0 += $1.rows.filter { $0.kind == .added }.count } + } + + var deletions: Int { + hunks.reduce(into: 0) { $0 += $1.rows.filter { $0.kind == .removed }.count } + } + + /// The render block for a single hunk, including its `@@` header row. + func renderBlock(forHunkAt index: Int) -> DiffRenderBlock { + guard hunks.indices.contains(index) else { + return .init(language: language, rows: []) + } + let hunk = hunks[index] + return .init(language: language, rows: [hunk.headerRow] + hunk.rows) + } +} + +/// A tappable "show more lines" row sitting in the gap between two hunks (or +/// above the first / below the last hunk) of a file. +nonisolated struct DiffExpander: Hashable { + enum Direction: Hashable { + /// Reveal the lines directly above the hunk that follows the gap. + case up + /// Reveal the lines directly below the hunk that precedes the gap. + case down + /// The gap is bounded on both sides and larger than one chunk, so both + /// arrows are offered. + case both + } + + let fileID: Int + /// Hunk preceding the gap, expanded downwards. + let hunkAbove: Int? + /// Hunk following the gap, expanded upwards. + let hunkBelow: Int? + /// Hidden old-file lines, 1-based and inclusive. + let gapLowerBound: Int + let gapUpperBound: Int + let direction: Direction + /// The whole gap fits inside a single expansion chunk, so one tap reveals + /// all of it and the row collapses to a single control. + let coversEntireGap: Bool + + var gap: ClosedRange { + gapLowerBound ... gapUpperBound + } + + var hiddenLineCount: Int { + gapUpperBound - gapLowerBound + 1 + } + + /// Old-file line range fetched when the expander is tapped in `direction`. + func requestedRange(for direction: Direction, chunkSize: Int) -> ClosedRange { + guard !coversEntireGap, chunkSize > 0 else { return gap } + switch direction { + case .up: + return max(gapLowerBound, gapUpperBound - chunkSize + 1) ... gapUpperBound + case .down: + return gapLowerBound ... min(gapUpperBound, gapLowerBound + chunkSize - 1) + case .both: + return gap + } + } +} + +/// An item rendered inside a file's section. +nonisolated enum DiffFileItem: Hashable { + case expander(DiffExpander) + case hunk(fileID: Int, hunkID: Int) +} + +/// The parsed patch backing a sectioned diff: files, their hunks, and the +/// expanders derived from the gaps between hunks. +nonisolated struct DiffPatchDocument { + /// Number of lines revealed by a single tap on an expander. + static let defaultExpansionChunkSize = 20 + + var files: [DiffFilePatch] + let language: String? + + init(files: [DiffFilePatch], language: String?) { + self.files = files + self.language = language + } + + init?(patch: String, language: String? = nil) { + let fenceInfo = DiffFenceInfo(language: language) + guard let block = UnifiedDiffParser.renderBlock(content: patch, fenceInfo: fenceInfo) else { + return nil + } + self.init(block: block) + } + + init(block: DiffRenderBlock) { + files = DiffPatchDocument.splitFiles(in: block) + language = block.language + } + + func file(withID id: Int) -> DiffFilePatch? { + files.first { $0.id == id } + } + + func fileIndex(withID id: Int) -> Int? { + files.firstIndex { $0.id == id } + } + + /// Section items for a file: hunks interleaved with the expanders that + /// stand in for the lines the patch left out. + /// + /// - Parameter totalOldLineCount: total line count of the pre-image, when + /// known. Without it the trailing expander is omitted, since there is no + /// way to tell whether the last hunk reaches the end of the file. + func items( + forFileWithID fileID: Int, + chunkSize: Int = defaultExpansionChunkSize, + totalOldLineCount: Int? = nil + ) -> [DiffFileItem] { + guard let file = file(withID: fileID) else { return [] } + var items: [DiffFileItem] = [] + + for (index, hunk) in file.hunks.enumerated() { + if index == 0 { + if hunk.oldStart > 1 { + items.append( + .expander( + makeExpander( + fileID: fileID, + hunkAbove: nil, + hunkBelow: index, + gap: 1 ... (hunk.oldStart - 1), + chunkSize: chunkSize + ) + ) + ) + } + } else { + let previous = file.hunks[index - 1] + let gapLowerBound = previous.oldEnd + 1 + let gapUpperBound = hunk.oldStart - 1 + if gapLowerBound <= gapUpperBound { + items.append( + .expander( + makeExpander( + fileID: fileID, + hunkAbove: index - 1, + hunkBelow: index, + gap: gapLowerBound ... gapUpperBound, + chunkSize: chunkSize + ) + ) + ) + } + } + + items.append(.hunk(fileID: fileID, hunkID: hunk.id)) + } + + if let last = file.hunks.last, + let totalOldLineCount, + last.oldEnd < totalOldLineCount + { + items.append( + .expander( + makeExpander( + fileID: fileID, + hunkAbove: file.hunks.count - 1, + hunkBelow: nil, + gap: (last.oldEnd + 1) ... totalOldLineCount, + chunkSize: chunkSize + ) + ) + ) + } + + return items + } + + /// Splices fetched pre-image lines into the file, growing the hunk the + /// expander points at and merging hunks that meet as a result. + /// + /// - Parameters: + /// - lines: the pre-image lines for `range`, in order. + /// - range: old-file line numbers the lines belong to. + mutating func insertContext( + lines: [String], + forOldLineRange range: ClosedRange, + fileID: Int, + direction: DiffExpander.Direction, + expander: DiffExpander + ) { + guard !lines.isEmpty, let fileIndex = fileIndex(withID: fileID) else { return } + + // `.both` only reaches here when one tap covers the gap; attach it to + // the hunk above so the merge below folds the two hunks together. + let hunkIndex: Int? = switch direction { + case .up: expander.hunkBelow + case .down: expander.hunkAbove + case .both: expander.hunkAbove ?? expander.hunkBelow + } + guard let hunkIndex, files[fileIndex].hunks.indices.contains(hunkIndex) else { return } + + let attachesAbove = direction == .up + || (direction == .both && expander.hunkAbove == nil) + if attachesAbove { + prependContext(lines: lines, range: range, fileIndex: fileIndex, hunkIndex: hunkIndex) + } else { + appendContext(lines: lines, range: range, fileIndex: fileIndex, hunkIndex: hunkIndex) + } + mergeAdjacentHunks(fileIndex: fileIndex) + } +} + +private nonisolated extension DiffPatchDocument { + static func isHunkBodyKind(_ kind: DiffRenderBlock.RowKind) -> Bool { + switch kind { + case .context, .removed, .added, .annotation: + true + case .fileHeader, .fileMetadata, .hunkHeader: + false + } + } + + func makeExpander( + fileID: Int, + hunkAbove: Int?, + hunkBelow: Int?, + gap: ClosedRange, + chunkSize: Int + ) -> DiffExpander { + let coversEntireGap = chunkSize <= 0 || gap.count <= chunkSize + let direction: DiffExpander.Direction = if coversEntireGap { + hunkAbove == nil ? .up : (hunkBelow == nil ? .down : .both) + } else if hunkAbove == nil { + .up + } else if hunkBelow == nil { + .down + } else { + .both + } + return .init( + fileID: fileID, + hunkAbove: hunkAbove, + hunkBelow: hunkBelow, + gapLowerBound: gap.lowerBound, + gapUpperBound: gap.upperBound, + direction: direction, + coversEntireGap: coversEntireGap + ) + } + + func contextRows( + lines: [String], + oldStart: Int, + newStart: Int, + language: String? + ) -> [DiffRenderBlock.Row] { + lines.enumerated().map { offset, line in + .init( + kind: .context, + oldLineNumber: oldStart + offset, + newLineNumber: newStart + offset, + text: line, + syntaxHighlights: UnifiedDiffParser.contextHighlights(for: line, language: language), + emphasizedRanges: [] + ) + } + } + + mutating func prependContext( + lines: [String], + range: ClosedRange, + fileIndex: Int, + hunkIndex: Int + ) { + let language = files[fileIndex].language + var hunk = files[fileIndex].hunks[hunkIndex] + // Align the fetched lines against the hunk's top edge and trim anything + // it already shows, so a stale or over-long response cannot duplicate + // lines. + let usable = Array(lines.suffix(max(hunk.oldStart - range.lowerBound, 0))) + guard !usable.isEmpty else { return } + + let oldStart = hunk.oldStart - usable.count + let newStart = hunk.newStart - usable.count + hunk.rows = contextRows( + lines: usable, + oldStart: oldStart, + newStart: newStart, + language: language + ) + hunk.rows + hunk.oldStart = oldStart + hunk.newStart = max(newStart, 1) + files[fileIndex].hunks[hunkIndex] = hunk + } + + mutating func appendContext( + lines: [String], + range: ClosedRange, + fileIndex: Int, + hunkIndex: Int + ) { + let language = files[fileIndex].language + var hunk = files[fileIndex].hunks[hunkIndex] + let oldStart = hunk.oldEnd + 1 + let skipCount = max(oldStart - range.lowerBound, 0) + guard skipCount < lines.count else { return } + let usable = Array(lines.dropFirst(skipCount)) + + hunk.rows += contextRows( + lines: usable, + oldStart: oldStart, + newStart: hunk.newStart + hunk.newCount, + language: language + ) + files[fileIndex].hunks[hunkIndex] = hunk + } + + /// Folds hunks whose ranges now touch or overlap into one, dropping rows + /// the preceding hunk already covers. + mutating func mergeAdjacentHunks(fileIndex: Int) { + var merged: [DiffFileHunk] = [] + for hunk in files[fileIndex].hunks { + guard var previous = merged.last, previous.oldEnd + 1 >= hunk.oldStart else { + merged.append(hunk) + continue + } + + var rows = hunk.rows + var oldLine = hunk.oldStart + while let first = rows.first, oldLine <= previous.oldEnd { + switch first.kind { + case .context, .removed: + oldLine += 1 + rows.removeFirst() + case .added, .annotation, .fileHeader, .fileMetadata, .hunkHeader: + // Added rows carry no old-file line, so they can never be a + // duplicate of the preceding hunk's trailing context. + oldLine = previous.oldEnd + 1 + } + } + + previous.rows += rows + merged[merged.count - 1] = previous + } + files[fileIndex].hunks = merged + } + + static func splitFiles(in block: DiffRenderBlock) -> [DiffFilePatch] { + var files: [DiffFilePatch] = [] + var headerRows: [DiffRenderBlock.Row] = [] + var hunks: [DiffFileHunk] = [] + var hunkID = 0 + var index = 0 + + func flush() { + guard !headerRows.isEmpty || !hunks.isEmpty else { return } + let paths = filePaths(in: headerRows) + files.append( + .init( + id: files.count, + displayPath: paths.display ?? "Patch \(files.count + 1)", + oldPath: paths.old, + newPath: paths.new, + language: block.language, + headerRows: headerRows, + hunks: hunks + ) + ) + headerRows = [] + hunks = [] + } + + while index < block.rows.count { + let row = block.rows[index] + switch row.kind { + case .fileHeader, .fileMetadata: + // A file header after a hunk starts the next file. + if !hunks.isEmpty { flush() } + headerRows.append(row) + index += 1 + case .hunkHeader: + let header = UnifiedDiffParser.hunkRange(fromHeader: row.text) + var rows: [DiffRenderBlock.Row] = [] + index += 1 + while index < block.rows.count, isHunkBodyKind(block.rows[index].kind) { + rows.append(block.rows[index]) + index += 1 + } + hunks.append( + .init( + id: hunkID, + oldStart: header?.oldStart ?? (rows.first?.oldLineNumber ?? 1), + newStart: header?.newStart ?? (rows.first?.newLineNumber ?? 1), + rows: rows + ) + ) + hunkID += 1 + case .context, .removed, .added, .annotation: + // Rows outside a hunk are not produced by the parser; skip + // defensively rather than dropping the file. + index += 1 + } + } + + flush() + return files + } + + static func filePaths( + in headerRows: [DiffRenderBlock.Row] + ) -> (old: String?, new: String?, display: String?) { + var old: String? + var new: String? + + for row in headerRows where row.kind == .fileHeader { + if row.text.hasPrefix("--- ") { + old = normalizedPath(String(row.text.dropFirst(4))) + } else if row.text.hasPrefix("+++ ") { + new = normalizedPath(String(row.text.dropFirst(4))) + } else if row.text.hasPrefix("diff --git ") { + let components = row.text.dropFirst("diff --git ".count) + .split(separator: " ", maxSplits: 1) + .map(String.init) + if components.count == 2 { + old = old ?? normalizedPath(components[0]) + new = new ?? normalizedPath(components[1]) + } + } + } + + let display: String? = if let new, new != "/dev/null" { + new + } else if let old, old != "/dev/null" { + old + } else { + nil + } + return (old, new, display) + } + + static func normalizedPath(_ raw: String) -> String? { + var path = raw.trimmingCharacters(in: .whitespaces) + // Drop a trailing tab-separated timestamp, as emitted by `diff -u`. + if let tabIndex = path.firstIndex(of: "\t") { + path = String(path[path.startIndex ..< tabIndex]) + } + guard !path.isEmpty else { return nil } + if path == "/dev/null" { return path } + if path.hasPrefix("a/") || path.hasPrefix("b/") { + path = String(path.dropFirst(2)) + } + return path.isEmpty ? nil : path + } +} diff --git a/Sources/MarkdownView/Supplements/UnifiedDiff.swift b/Sources/MarkdownView/Supplements/UnifiedDiff.swift index b5765b0..8a76eeb 100644 --- a/Sources/MarkdownView/Supplements/UnifiedDiff.swift +++ b/Sources/MarkdownView/Supplements/UnifiedDiff.swift @@ -121,6 +121,23 @@ nonisolated enum UnifiedDiffParser { rows: buildRenderedRows(from: parsed) ) } + + /// Line ranges encoded in an `@@ -a,b +c,d @@` header. + static func hunkRange( + fromHeader text: String + ) -> (oldStart: Int, oldCount: Int, newStart: Int, newCount: Int)? { + guard let header = parseHunkHeader(text) else { return nil } + return (header.oldStart, header.oldCount, header.newStart, header.newCount) + } + + /// Syntax highlights for a context line pulled in after the fact, e.g. when + /// a sectioned diff expands the hidden lines around a hunk. + static func contextHighlights( + for text: String, + language: String? + ) -> CodeHighlighter.HighlightMap { + highlightMap(for: text, language: language) + } } private nonisolated extension UnifiedDiffParser { diff --git a/Tests/MarkdownViewTests/DiffPatchDocumentTests.swift b/Tests/MarkdownViewTests/DiffPatchDocumentTests.swift new file mode 100644 index 0000000..77bfbe1 --- /dev/null +++ b/Tests/MarkdownViewTests/DiffPatchDocumentTests.swift @@ -0,0 +1,208 @@ +import XCTest +@testable import MarkdownView + +final class DiffPatchDocumentTests: XCTestCase { + private let patch = """ + diff --git a/Sources/A.swift b/Sources/A.swift + --- a/Sources/A.swift + +++ b/Sources/A.swift + @@ -10,3 +10,4 @@ + line10 + -line11 + +line11b + +line11c + line12 + @@ -100,2 +101,2 @@ + line100 + -line101 + +line101b + diff --git a/B.swift b/B.swift + --- a/B.swift + +++ b/B.swift + @@ -1,2 +1,3 @@ + one + +two + three + """ + + private func makeDocument() -> DiffPatchDocument { + guard let document = DiffPatchDocument(patch: patch, language: "swift") else { + fatalError("patch should parse") + } + return document + } + + func testSplitsFilesAndHunks() { + let document = makeDocument() + XCTAssertEqual(document.files.map(\.displayPath), ["Sources/A.swift", "B.swift"]) + + let a = document.files[0] + XCTAssertEqual(a.hunks.count, 2) + XCTAssertEqual(a.hunks[0].oldStart, 10) + XCTAssertEqual(a.hunks[0].oldCount, 3) + XCTAssertEqual(a.hunks[0].newCount, 4) + XCTAssertEqual(a.hunks[0].oldEnd, 12) + XCTAssertEqual(a.hunks[1].oldStart, 100) + XCTAssertEqual(a.additions, 3) + XCTAssertEqual(a.deletions, 2) + + let b = document.files[1] + XCTAssertEqual(b.hunks.count, 1) + XCTAssertEqual(b.hunks[0].oldStart, 1) + } + + func testItemsInterleaveExpanders() { + let document = makeDocument() + let items = document.items(forFileWithID: 0, chunkSize: 20, totalOldLineCount: 200) + + guard items.count == 5, + case let .expander(leading) = items[0], + case .hunk = items[1], + case let .expander(middle) = items[2], + case .hunk = items[3], + case let .expander(trailing) = items[4] + else { return XCTFail("unexpected items: \(items)") } + + XCTAssertEqual(leading.gap, 1 ... 9) + XCTAssertEqual(leading.direction, .up) + XCTAssertTrue(leading.coversEntireGap) + + XCTAssertEqual(middle.gap, 13 ... 99) + XCTAssertEqual(middle.direction, .both) + XCTAssertFalse(middle.coversEntireGap) + + XCTAssertEqual(trailing.gap, 102 ... 200) + XCTAssertEqual(trailing.direction, .down) + } + + func testTrailingExpanderNeedsFileLength() { + let document = makeDocument() + let items = document.items(forFileWithID: 0, chunkSize: 20) + XCTAssertEqual(items.count, 4) + if case .expander = items[3] { XCTFail("no trailing expander without a line count") } + } + + func testNoExpanderWhenFileStartsAtFirstLine() { + let document = makeDocument() + let items = document.items(forFileWithID: 1, chunkSize: 20) + XCTAssertEqual(items.count, 1) + if case .expander = items[0] { XCTFail("first hunk starts at line 1") } + } + + func testRequestedRangesForPartialGap() { + let document = makeDocument() + let items = document.items(forFileWithID: 0, chunkSize: 20, totalOldLineCount: 200) + guard case let .expander(middle) = items[2] else { return XCTFail("expected expander") } + + XCTAssertEqual(middle.requestedRange(for: .up, chunkSize: 20), 80 ... 99) + XCTAssertEqual(middle.requestedRange(for: .down, chunkSize: 20), 13 ... 32) + + guard case let .expander(leading) = items[0] else { return XCTFail("expected expander") } + XCTAssertEqual(leading.requestedRange(for: .up, chunkSize: 20), 1 ... 9) + } + + func testExpandingUpwardsGrowsFollowingHunk() { + var document = makeDocument() + let items = document.items(forFileWithID: 0, chunkSize: 20, totalOldLineCount: 200) + guard case let .expander(middle) = items[2] else { return XCTFail("expected expander") } + + let range = middle.requestedRange(for: .up, chunkSize: 20) + document.insertContext( + lines: range.map { "line\($0)" }, + forOldLineRange: range, + fileID: 0, + direction: .up, + expander: middle + ) + + let hunk = document.files[0].hunks[1] + XCTAssertEqual(hunk.oldStart, 80) + XCTAssertEqual(hunk.newStart, 81) + XCTAssertEqual(hunk.rows.first?.text, "line80") + XCTAssertEqual(hunk.rows.first?.oldLineNumber, 80) + XCTAssertEqual(hunk.rows.first?.newLineNumber, 81) + XCTAssertEqual(hunk.oldCount, 22) + XCTAssertEqual(document.files[0].hunks.count, 2) + } + + func testExpandingDownwardsGrowsPrecedingHunk() { + var document = makeDocument() + let items = document.items(forFileWithID: 0, chunkSize: 20, totalOldLineCount: 200) + guard case let .expander(middle) = items[2] else { return XCTFail("expected expander") } + + let range = middle.requestedRange(for: .down, chunkSize: 20) + document.insertContext( + lines: range.map { "line\($0)" }, + forOldLineRange: range, + fileID: 0, + direction: .down, + expander: middle + ) + + let hunk = document.files[0].hunks[0] + XCTAssertEqual(hunk.oldStart, 10) + XCTAssertEqual(hunk.oldEnd, 32) + XCTAssertEqual(hunk.rows.last?.text, "line32") + XCTAssertEqual(hunk.rows.last?.newLineNumber, 33) + } + + func testFillingGapMergesAdjacentHunks() { + var document = makeDocument() + let items = document.items(forFileWithID: 0, chunkSize: 200, totalOldLineCount: 200) + guard case let .expander(middle) = items[2] else { return XCTFail("expected expander") } + XCTAssertTrue(middle.coversEntireGap) + + document.insertContext( + lines: middle.gap.map { "line\($0)" }, + forOldLineRange: middle.gap, + fileID: 0, + direction: .both, + expander: middle + ) + + XCTAssertEqual(document.files[0].hunks.count, 1) + let hunk = document.files[0].hunks[0] + XCTAssertEqual(hunk.oldStart, 10) + XCTAssertEqual(hunk.oldEnd, 101) + XCTAssertEqual(document.files[0].additions, 3) + XCTAssertEqual(document.files[0].deletions, 2) + } + + func testLeadingExpansionReachesTopOfFile() { + var document = makeDocument() + let items = document.items(forFileWithID: 0, chunkSize: 20, totalOldLineCount: 200) + guard case let .expander(leading) = items[0] else { return XCTFail("expected expander") } + + document.insertContext( + lines: leading.gap.map { "line\($0)" }, + forOldLineRange: leading.gap, + fileID: 0, + direction: .both, + expander: leading + ) + + XCTAssertEqual(document.files[0].hunks[0].oldStart, 1) + XCTAssertEqual(document.files[0].hunks[0].newStart, 1) + let remaining = document.items(forFileWithID: 0, chunkSize: 20, totalOldLineCount: 200) + if case .expander = remaining[0] { XCTFail("gap above the first hunk is gone") } + } + + func testOverlappingResponseDoesNotDuplicateLines() { + var document = makeDocument() + let items = document.items(forFileWithID: 0, chunkSize: 20, totalOldLineCount: 200) + guard case let .expander(middle) = items[2] else { return XCTFail("expected expander") } + + // Provider returns more than was asked for, overlapping both hunks. + document.insertContext( + lines: (1 ... 120).map { "line\($0)" }, + forOldLineRange: 1 ... 120, + fileID: 0, + direction: .down, + expander: middle + ) + + let hunk = document.files[0].hunks[0] + XCTAssertEqual(hunk.oldStart, 10) + XCTAssertEqual(hunk.rows.map(\.oldLineNumber).compactMap { $0 }, Array(10 ... 12) + Array(13 ... 120)) + } +} From f8484f7e8f475db95fdc371ef7f00bb337ddd38f Mon Sep 17 00:00:00 2001 From: Gary Tokman <12258850+gtokman@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:40:55 +0000 Subject: [PATCH 2/3] Stop collapsing context inside sectioned diff hunks Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../DiffFilesView/DiffFilesViewConfiguration.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/MarkdownView/Components/DiffFilesView/DiffFilesViewConfiguration.swift b/Sources/MarkdownView/Components/DiffFilesView/DiffFilesViewConfiguration.swift index 53a0e67..855f50f 100644 --- a/Sources/MarkdownView/Components/DiffFilesView/DiffFilesViewConfiguration.swift +++ b/Sources/MarkdownView/Components/DiffFilesView/DiffFilesViewConfiguration.swift @@ -24,12 +24,15 @@ import Foundation /// Theme for the diff inside a section: the section header already names /// the file, and the hunks are stacked edge to edge, so the per-block - /// chrome is dropped. + /// chrome is dropped. Context collapsing is disabled as well, since the + /// expander rows are what hide context here — leaving it on would fold + /// freshly revealed lines back into a "… unchanged lines …" row. static func hunkTheme(from theme: MarkdownTheme) -> MarkdownTheme { var hunkTheme = theme hunkTheme.showsBlockHeaders = false hunkTheme.diff.cornerRadius = 0 hunkTheme.diff.borderWidth = 0 + hunkTheme.diff.contextCollapseThreshold = 0 return hunkTheme } } From 1cf95f61be227b06382522072e37ca82091ed62f Mon Sep 17 00:00:00 2001 From: Gary Tokman <12258850+gtokman@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:48:51 +0000 Subject: [PATCH 3/3] Move diff file header chevron to trailing edge with rotation Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../DiffFilesView/DiffFileHeaderView.swift | 25 ++++++++++++++++--- .../DiffFilesViewController.swift | 19 ++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/Sources/MarkdownView/Components/DiffFilesView/DiffFileHeaderView.swift b/Sources/MarkdownView/Components/DiffFilesView/DiffFileHeaderView.swift index 87dcdf8..d82f04b 100644 --- a/Sources/MarkdownView/Components/DiffFilesView/DiffFileHeaderView.swift +++ b/Sources/MarkdownView/Components/DiffFilesView/DiffFileHeaderView.swift @@ -4,14 +4,18 @@ import Foundation import UIKit /// Sticky section header naming the file a diff section renders, with its - /// added/removed counts and a chevron that collapses the file. + /// added/removed counts and a trailing chevron that collapses the file, + /// rotating a quarter turn to point at the collapsed section. final class DiffFileHeaderView: UICollectionReusableView { + private static let collapsedChevronRotation = -CGFloat.pi / 2 + private lazy var chevronView: UIImageView = .init() private lazy var pathLabel: UILabel = .init() private lazy var additionsLabel: UILabel = .init() private lazy var deletionsLabel: UILabel = .init() private lazy var separator: UIView = .init() private var tapHandler: (() -> Void)? + private var isCollapsed = false override init(frame: CGRect) { super.init(frame: frame) @@ -47,9 +51,10 @@ import Foundation chevronView.tintColor = theme.diff.fileMetadataText chevronView.image = UIImage( - systemName: isCollapsed ? "chevron.right" : "chevron.down", + systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(scale: .small) ) + setCollapsed(isCollapsed, animated: self.isCollapsed != isCollapsed && window != nil) accessibilityLabel = "\(file.displayPath), \(file.additions) added, \(file.deletions) removed" accessibilityHint = isCollapsed ? "Expands the file" : "Collapses the file" @@ -71,7 +76,7 @@ import Foundation } let stack = UIStackView(arrangedSubviews: [ - chevronView, pathLabel, additionsLabel, deletionsLabel, + pathLabel, additionsLabel, deletionsLabel, chevronView, ]) stack.axis = .horizontal stack.alignment = .center @@ -102,6 +107,20 @@ import Foundation ) } + private func setCollapsed(_ collapsed: Bool, animated: Bool) { + isCollapsed = collapsed + let transform: CGAffineTransform = collapsed + ? .init(rotationAngle: Self.collapsedChevronRotation) + : .identity + guard animated else { + chevronView.transform = transform + return + } + UIView.animate(withDuration: 0.2) { [chevronView] in + chevronView.transform = transform + } + } + @objc private func handleTap() { tapHandler?() } diff --git a/Sources/MarkdownView/Components/DiffFilesView/DiffFilesViewController.swift b/Sources/MarkdownView/Components/DiffFilesView/DiffFilesViewController.swift index 7e752d2..ff01a7f 100644 --- a/Sources/MarkdownView/Components/DiffFilesView/DiffFilesViewController.swift +++ b/Sources/MarkdownView/Components/DiffFilesView/DiffFilesViewController.swift @@ -316,11 +316,30 @@ import Foundation collapsedFileIDs.insert(fileID) } applySnapshot(animated: true) + // Headers are supplementary views, so no item update reaches them; + // reconfigure the visible one so its chevron follows the state. + reconfigureHeader(fileID: fileID) if let file = document.file(withID: fileID) { fileCollapseHandler?(file.displayPath, !isCollapsed) } } + private func reconfigureHeader(fileID: Int) { + guard let section = document.files.firstIndex(where: { $0.id == fileID }), + let header = collectionView.supplementaryView( + forElementKind: UICollectionView.elementKindSectionHeader, + at: IndexPath(item: 0, section: section) + ) as? DiffFileHeaderView + else { return } + header.configure( + file: document.files[section], + theme: theme, + isCollapsed: collapsedFileIDs.contains(fileID) + ) { [weak self] in + self?.toggleCollapse(fileID: fileID) + } + } + // MARK: - Context expansion private func loadingDirections(for expander: DiffExpander) -> Set {