Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions Example/Example/SectionedDiffExample.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
49 changes: 49 additions & 0 deletions Example/Example/SectionedDiffViewController.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
22 changes: 14 additions & 8 deletions Example/Example/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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)
}
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading