Skip to content
Merged
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
85 changes: 80 additions & 5 deletions Shared/Sources/TermioShared/DiffModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,41 @@ public struct DiffBandControls: OptionSet, Sendable, Equatable {
public init(rawValue: Int) { self.rawValue = rawValue }
public static let up = DiffBandControls(rawValue: 1 << 0)
public static let down = DiffBandControls(rawValue: 1 << 1)
/// One control that opens the whole run at once, for a gap no longer than a single
/// step — GitHub Desktop's "Expand All", where two ends would each swallow the gap.
public static let all = DiffBandControls(rawValue: 1 << 2)
}

/// The file's own text for lines a fixed-context patch never carried, keyed by new-side
/// line number. A `git diff` termio runs itself asks for the whole file as context, so its
/// gaps are already in the rows; GitHub's PR `patch` is fixed at three lines, and a hunk
/// boundary there can only be opened by reading the file. Empty means the surface has no
/// file to read, and the band draws inert — GitHub Desktop's placeholder state.
public struct DiffGapText: Sendable, Equatable {
private let lines: [Int: String]

public static let unavailable = DiffGapText(lines: [:])

public init(lines: [Int: String]) { self.lines = lines }

/// The new-side text of a whole file, `first` being the line number of `text`'s first
/// line (1 for a complete file).
public init(fileLines text: [String], startingAt first: Int = 1) {
var lines: [Int: String] = [:]
lines.reserveCapacity(text.count)
for (offset, line) in text.enumerated() { lines[first + offset] = line }
self.lines = lines
}

public var isEmpty: Bool { lines.isEmpty }

public func line(_ number: Int) -> String? { lines[number] }

/// Whether every line of `range` is on hand — a partial read must not offer a control
/// that would splice a hole into the file.
public func covers(_ range: ClosedRange<Int>) -> Bool {
!lines.isEmpty && range.allSatisfy { lines[$0] != nil }
}
}

/// How much of each folded run the reader has revealed, keyed by the run's anchor line id.
Expand Down Expand Up @@ -142,12 +177,18 @@ public enum DiffParser {
return rows
}

/// The id of a line spliced in from the file instead of parsed out of the patch.
/// Negative, so it can never collide with a parsed row's id, and stable per line
/// number, so re-folding keeps the syntax pass and the selection anchored.
public static func gapLineID(forNewLine number: Int) -> Int { -(number + 1) }

/// Folds parsed lines into the display list: hunk plumbing disappears (its gap
/// becomes a band), unchanged runs longer than a handful of lines collapse to a
/// band keeping 3 lines of context on the side(s) facing a change, and ids in
/// `expanded` splice their hidden lines back in.
public static func displayItems(lines rows: [DiffLine],
expansion: DiffExpansion) -> [DiffItem] {
expansion: DiffExpansion,
gapText: DiffGapText = .unavailable) -> [DiffItem] {
var items: [DiffItem] = []
var run: [DiffLine] = []
var sawChange = false
Expand Down Expand Up @@ -190,10 +231,44 @@ public enum DiffParser {
case .hunk:
flush(isLast: false)
if let start = row.newLine, start > lastNewLine + 1 {
items.append(.band(
id: row.id, lines: (lastNewLine + 1)...(start - 1),
controls: [], heading: hunkHeading(row.text)
))
let gap = (lastNewLine + 1)...(start - 1)
let heading = hunkHeading(row.text)
// The gap's own lines are never in the patch, so the reveal splices them
// from the file. Without the file the band stays inert, the way GitHub
// Desktop draws a placeholder where it cannot expand.
guard gapText.covers(gap) else {
items.append(.band(id: row.id, lines: gap, controls: [], heading: heading))
break
}
// Unchanged through the gap, so both sides advance together: this hunk's
// own numbers give the offset back to the old side.
let oldOffset = (row.oldLine ?? start) - start
let revealed = expansion.revealed(row.id, of: gap.count)
func spliced(_ numbers: some Sequence<Int>) -> [DiffItem] {
numbers.compactMap { number in
gapText.line(number).map { text in
.line(DiffLine(id: gapLineID(forNewLine: number), kind: .context,
text: text, oldLine: number + oldOffset,
newLine: number))
}
}
}
items += spliced(gap.prefix(revealed.head))
let hidden = gap.dropFirst(revealed.head).dropLast(revealed.tail)
if let first = hidden.first, let last = hidden.last {
// GitHub Desktop's `getHunkExpansionType`: the file's first hunk can
// only be read upward, a gap within one step opens in a single jump,
// and anything longer offers both ends.
var controls: DiffBandControls = [.up, .down]
if items.isEmpty {
controls = .up
} else if hidden.count <= DiffExpansion.step {
controls = .all
}
items.append(.band(id: row.id, lines: first...last,
controls: controls, heading: heading))
}
items += spliced(gap.suffix(revealed.tail))
}
case .context:
run.append(row)
Expand Down
12 changes: 7 additions & 5 deletions Sources/termio/Git/DiffDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,10 @@ final class DiffDocument {
/// than a handful of lines collapse to a band keeping 3 lines of context on the
/// side(s) that face a change, and whatever `expansion` has revealed is spliced back in.
static func build(rows: [DiffRow], expansion: DiffExpansion, palette: DiffPalette,
codeFont: NSFont, lineSpacing: CGFloat) -> DiffDocument {
build(items: displayItems(rows: rows, expansion: expansion), allRows: rows,
palette: palette, codeFont: codeFont, lineSpacing: lineSpacing)
codeFont: NSFont, lineSpacing: CGFloat,
gapText: DiffGapText = .unavailable) -> DiffDocument {
build(items: displayItems(rows: rows, expansion: expansion, gapText: gapText),
allRows: rows, palette: palette, codeFont: codeFont, lineSpacing: lineSpacing)
}

/// The shared assembly: lays the display items down as one attributed string with
Expand Down Expand Up @@ -233,8 +234,9 @@ final class DiffDocument {
/// skipped lines are, not how many there are: a line range tells the reader what they
/// are jumping over, while "137 unchanged lines" only describes the widget. git's
/// section heading rides along when the gap came from a hunk boundary.
private static func displayItems(rows: [DiffRow], expansion: DiffExpansion) -> [DisplayItem] {
DiffParser.displayItems(lines: rows, expansion: expansion).map { item in
private static func displayItems(rows: [DiffRow], expansion: DiffExpansion,
gapText: DiffGapText) -> [DisplayItem] {
DiffParser.displayItems(lines: rows, expansion: expansion, gapText: gapText).map { item in
switch item {
case .line(let row):
return .line(row)
Expand Down
49 changes: 41 additions & 8 deletions Sources/termio/Git/DiffGutterRulerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -205,20 +205,24 @@ final class DiffGutterRulerView: NSRulerView {
guard !controls.isEmpty else { return }

var directions: [DiffBandDirection] = []
if controls.contains(.all) { directions.append(.all) }
if controls.contains(.down) { directions.append(.down) }
if controls.contains(.up) { directions.append(.up) }

// Side by side rather than github.com's stacked pair: its expander is double
// height to fit two 16pt icons, and a diff row here is barely taller than its text.
// Stacked, GitHub Desktop's shape: one gutter cell split in half, the downward
// reveal on top and the upward one below, each reading toward the code it pulls
// from. A lone control (a first hunk, or a gap short enough to open at once) takes
// the whole cell.
let padding = DiffDocument.bandPadding
let cell = (ruleThickness - Self.leadingPad - Self.trailingPad)
/ CGFloat(directions.count)
var x = Self.leadingPad
for direction in directions {
let hit = NSRect(x: x, y: y - padding, width: cell, height: height + padding * 2)
let width = ruleThickness - Self.leadingPad - Self.trailingPad
let full = NSRect(x: Self.leadingPad, y: y - padding,
width: width, height: height + padding * 2)
let slice = full.height / CGFloat(directions.count)
for (index, direction) in directions.enumerated() {
let hit = NSRect(x: full.minX, y: full.minY + slice * CGFloat(index),
width: full.width, height: slice)
drawRevealIcon(direction, in: hit, ink: numberColor)
buttonHits.append(ButtonHit(rect: hit, anchor: line.rowId, direction: direction))
x += cell
}
}

Expand All @@ -228,6 +232,35 @@ final class DiffGutterRulerView: NSRulerView {
let gap: CGFloat = 2.5
let dotWidth: CGFloat = 1.2
let originX = (rect.midX - width / 2).rounded()
// "Open the whole gap" reads as both arrows meeting on the dots — GitHub Desktop's
// fold glyph — rather than either single direction, neither of which is what a
// click does here.
if direction == .all {
let dotsY = (rect.midY - dotWidth / 2).rounded()
ink.setFill()
var dotX = originX
while dotX < originX + width {
NSRect(x: dotX, y: dotsY, width: dotWidth, height: dotWidth).fill()
dotX += dotWidth * 2
}
ink.setStroke()
for pointsDown in [true, false] {
let tipY = pointsDown ? dotsY - gap : dotsY + dotWidth + gap
let tailY = pointsDown ? tipY - arrowHeight : tipY + arrowHeight
let barbY = pointsDown ? tipY - 2.4 : tipY + 2.4
let arrow = NSBezierPath()
arrow.lineWidth = 1.2
arrow.lineCapStyle = .round
arrow.lineJoinStyle = .round
arrow.move(to: NSPoint(x: rect.midX, y: tailY))
arrow.line(to: NSPoint(x: rect.midX, y: tipY))
arrow.move(to: NSPoint(x: originX + 1, y: barbY))
arrow.line(to: NSPoint(x: rect.midX, y: tipY))
arrow.line(to: NSPoint(x: originX + width - 1, y: barbY))
arrow.stroke()
}
return
}
// The arrow points the way the reveal walks — the same reading github.com and
// GitHub Desktop use — and the dots trail behind it, on the side still hidden.
// A vertical NSRulerView is flipped, so `minY` is the block's *top* edge: an
Expand Down
42 changes: 38 additions & 4 deletions Sources/termio/Git/PRFilesDiffView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ struct PRFilesSplitView: View {
let files: [GitChange]
/// Each file's inline unified-diff `patch` from the GitHub API, keyed by path.
let patches: [String: String]
/// Reads a file at the PR head, for expanding a hunk boundary the patch left out.
let fileText: (String) async -> String?
/// Only used to resolve each file's language for syntax coloring.
let repoRoot: String
@ObservedObject var settings: AppSettings
Expand Down Expand Up @@ -73,7 +75,7 @@ struct PRFilesSplitView: View {
/// you see *all* the files, not one at a time, and selection / ⌘F run across the whole set.
private var narrow: some View {
StackedDiffBody(
files: files, patches: patches, repoRoot: repoRoot,
files: files, patches: patches, fileText: fileText, repoRoot: repoRoot,
settings: settings, onClose: onClose
)
}
Expand Down Expand Up @@ -112,6 +114,7 @@ struct PRFilesSplitView: View {
PRFileDiffBody(
change: change,
patch: patches[change.path],
fileText: fileText,
repoRoot: repoRoot,
settings: settings,
onClose: onClose,
Expand Down Expand Up @@ -149,6 +152,10 @@ struct PRFilesSplitView: View {
private struct PRFileDiffBody: View {
let change: GitChange
let patch: String?
/// Reads the file at the PR head. GitHub's patch carries three lines of context, so a
/// hunk boundary can only be opened from the file itself; until this lands the bands
/// draw inert, the way GitHub Desktop draws a gap it cannot expand.
let fileText: (String) async -> String?
let repoRoot: String
@ObservedObject var settings: AppSettings
let onClose: () -> Void
Expand All @@ -157,6 +164,7 @@ private struct PRFileDiffBody: View {
@Environment(\.colorScheme) private var colorScheme

@State private var rows: [DiffRow] = []
@State private var gapText: DiffGapText = .unavailable
@State private var document: DiffDocument?
@State private var styledLines: [Int: NSAttributedString] = [:]
@State private var expansion = DiffExpansion()
Expand Down Expand Up @@ -258,6 +266,7 @@ private struct PRFileDiffBody: View {
rows = parsed
rebuildDocument()
isLoading = false
await loadGapText()
await buildStyledLines(parsed)
}

Expand All @@ -269,7 +278,17 @@ private struct PRFileDiffBody: View {
: DiffDocument.build(rows: rows, expansion: expansion,
palette: settings.diffPalette(for: colorScheme),
codeFont: settings.resolvedTerminalFont(),
lineSpacing: settings.codeLineSpacing(for: settings.resolvedTerminalFont()))
lineSpacing: settings.codeLineSpacing(for: settings.resolvedTerminalFont()),
gapText: gapText)
}


/// One read per opened file, behind the diff so the patch renders immediately. A file
/// that comes back empty (binary, too large, no access) leaves `gapText` unavailable.
private func loadGapText() async {
guard gapText.isEmpty, let text = await fileText(change.path) else { return }
gapText = DiffGapText(fileLines: text.components(separatedBy: "\n"))
rebuildDocument()
}

private func buildStyledLines(_ rows: [DiffRow]) async {
Expand Down Expand Up @@ -299,6 +318,8 @@ private struct PRFileDiffBody: View {
private struct StackedDiffBody: View {
let files: [GitChange]
let patches: [String: String]
/// Reads a file at the PR head, for expanding a hunk boundary the patch left out.
let fileText: (String) async -> String?
let repoRoot: String
@ObservedObject var settings: AppSettings
let onClose: () -> Void
Expand All @@ -313,6 +334,7 @@ private struct StackedDiffBody: View {
FileDiffCard(
change: change,
patch: patches[change.path],
fileText: fileText,
repoRoot: repoRoot,
settings: settings,
collapsed: collapsed.contains(change.path),
Expand All @@ -337,6 +359,7 @@ private struct StackedDiffBody: View {
private struct FileDiffCard: View {
let change: GitChange
let patch: String?
let fileText: (String) async -> String?
let repoRoot: String
@ObservedObject var settings: AppSettings
let collapsed: Bool
Expand All @@ -345,6 +368,7 @@ private struct FileDiffCard: View {

@Environment(\.colorScheme) private var colorScheme
@State private var rows: [DiffRow] = []
@State private var gapText: DiffGapText = .unavailable
@State private var document: DiffDocument?
@State private var styledLines: [Int: NSAttributedString] = [:]
@State private var expansion = DiffExpansion()
Expand Down Expand Up @@ -453,14 +477,24 @@ private struct FileDiffCard: View {
: DiffDocument.build(rows: parsed, expansion: expansion,
palette: settings.diffPalette(for: colorScheme),
codeFont: settings.resolvedTerminalFont(),
lineSpacing: settings.codeLineSpacing(for: settings.resolvedTerminalFont()))
lineSpacing: settings.codeLineSpacing(for: settings.resolvedTerminalFont()),
gapText: gapText)
await loadGapText()
await buildStyled(parsed)
}

private func rebuildDocument() {
document = DiffDocument.build(rows: rows, expansion: expansion, palette: settings.diffPalette(for: colorScheme),
codeFont: settings.resolvedTerminalFont(),
lineSpacing: settings.codeLineSpacing(for: settings.resolvedTerminalFont()))
lineSpacing: settings.codeLineSpacing(for: settings.resolvedTerminalFont()),
gapText: gapText)
}

/// One read per opened card, behind the diff so the patch renders immediately.
private func loadGapText() async {
guard gapText.isEmpty, let text = await fileText(change.path) else { return }
gapText = DiffGapText(fileLines: text.components(separatedBy: "\n"))
rebuildDocument()
}

private func buildStyled(_ parsed: [DiffRow]) async {
Expand Down
21 changes: 20 additions & 1 deletion Sources/termio/Issues/GitHubIssueProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ struct GitHubIssueProvider: IssueProvider {
let deletions: Int
let previousFilename: String?
let patch: String?
let contentsUrl: String?
}
let raw: [RawFile] = try await get(
URL(string: "https://api.github.com/repos/\(container.id)/pulls/\(number)/files?per_page=100")!)
Expand All @@ -124,10 +125,28 @@ struct GitHubIssueProvider: IssueProvider {
change.originalPath = file.previousFilename
// No `patch` on a binary file; GitHub also drops it past its inline-size cap.
change.isBinary = file.patch == nil && file.additions == 0 && file.deletions == 0
return PullRequestFile(change: change, patch: file.patch)
return PullRequestFile(change: change, patch: file.patch,
contentsURL: file.contentsUrl.flatMap(URL.init(string:)))
}
}

/// One PR file's text at the PR head, for expanding the context its patch left out.
/// `contents_url` is already pinned to the head sha, and the raw media type returns the
/// file itself rather than base64 in JSON — so this stays one request with no decode.
/// Binary or over-large files come back as something other than UTF-8 text and are
/// reported as missing, leaving the diff's bands inert.
func fileText(at url: URL) async throws -> String? {
var request = URLRequest(url: url, timeoutInterval: 15)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/vnd.github.raw", forHTTPHeaderField: "Accept")
request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version")
let (data, response) = try await URLSession.shared.data(for: request)
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
throw APIError.status(http.statusCode)
}
return String(data: data, encoding: .utf8)
}

/// The connected account's login, fetched once per app run — the `assignee`
/// filter needs a username, not a token.
private func login() async throws -> String? {
Expand Down
Loading
Loading