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
23 changes: 20 additions & 3 deletions Sources/termio/Git/DiffDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ final class DiffDocument {
static let bandFont = NSFont.systemFont(ofSize: 10.5, weight: .medium)
/// Extra breathing room drawn around a band row (the fill is expanded to match).
static let bandPadding: CGFloat = 3
/// Leading added between every line — a touch of air so the diff doesn't read as a dense wall.
static let codeLineSpacing: CGFloat = 4

// MARK: Building

Expand All @@ -65,8 +67,16 @@ 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 `expanded` bands splice their lines back in.
static func build(rows: [DiffRow], expanded: Set<Int>, codeFont: NSFont) -> DiffDocument {
let items = displayItems(rows: rows, expanded: expanded)
build(items: displayItems(rows: rows, expanded: expanded), allRows: rows, codeFont: codeFont)
}

/// Composes several files into one stacked document (github.com "Files changed"): each file's
/// folded rows, prefixed by a full-width header row. Row ids **must** already be globally unique
/// across files (offset per file upstream) so band expansion and the syntax pass stay
/// unambiguous. One document means one scroll, and selection / ⌘F run continuously across files.
/// The shared assembly: lays the display items down as one attributed string with per-paragraph
/// metadata. `allRows` sizes the gutter columns (which sides carry numbers, and the widest).
private static func build(items: [DisplayItem], allRows: [DiffRow], codeFont: NSFont) -> DiffDocument {
var text = String()
text.reserveCapacity(items.reduce(0) { $0 + $1.textLength + 1 })
var lines: [Line] = []
Expand Down Expand Up @@ -98,13 +108,19 @@ final class DiffDocument {
.font: codeFont,
.foregroundColor: NSColor.labelColor,
])
// A little air between lines — the diff reads tighter than prose, so a few points of leading
// lift the whole document. Bands and headers re-set their own styles below, with the same lift.
let baseStyle = NSMutableParagraphStyle()
baseStyle.lineSpacing = codeLineSpacing
attributed.addAttribute(.paragraphStyle, value: baseStyle,
range: NSRange(location: 0, length: attributed.length))
styleBandsAndEmphasis(attributed, items: items, lines: lines)

return DiffDocument(
attributed: attributed,
lines: lines,
hasOldGutter: rows.contains { $0.kind != .hunk && $0.oldLine != nil },
hasNewGutter: rows.contains { $0.kind != .hunk && $0.newLine != nil },
hasOldGutter: allRows.contains { $0.kind != .hunk && $0.oldLine != nil },
hasNewGutter: allRows.contains { $0.kind != .hunk && $0.newLine != nil },
maxLineNumber: maxLineNumber
)
}
Expand Down Expand Up @@ -137,6 +153,7 @@ final class DiffDocument {
items: [DisplayItem], lines: [Line]) {
let bandStyle = NSMutableParagraphStyle()
bandStyle.alignment = .center
bandStyle.lineSpacing = codeLineSpacing
bandStyle.paragraphSpacingBefore = bandPadding
bandStyle.paragraphSpacing = bandPadding

Expand Down
64 changes: 56 additions & 8 deletions Sources/termio/Git/DiffTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ struct DiffTextPane: NSViewRepresentable {
/// Bumped when the find bar closes, so the text view reclaims first responder and its
/// ← / → walk and Esc work again.
var reclaimFocus: Int = 0
/// Embedded mode: the pane is stacked inside an outer scroll (the multi-file card list), so it
/// must not scroll or grab focus itself — it grows to its content and reports that height back so
/// the SwiftUI card can size to it, letting the outer list own the scroll.
var embedded: Bool = false
var onContentHeight: ((CGFloat) -> Void)? = nil

func makeCoordinator() -> Coordinator { Coordinator() }

Expand Down Expand Up @@ -68,6 +73,13 @@ struct DiffTextPane: NSViewRepresentable {
scrollView.documentView = textView
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
// Embedded panes are sized to their content by the SwiftUI card, so they never actually scroll
// — but keep the scroll view otherwise standard (only auto-hide the overlay scroller) so its
// ruler still lays out to the full height. Disabling the scroller knocks the gutter short.
if embedded {
scrollView.autohidesScrollers = true
scrollView.scrollerStyle = .overlay
}
scrollView.borderType = .noBorder
scrollView.drawsBackground = true
scrollView.backgroundColor = backgroundColor
Expand All @@ -89,20 +101,27 @@ struct DiffTextPane: NSViewRepresentable {
// exposed strip, so without a full invalidation the gutter's absolutely
// positioned numbers desync into a garbled smear (the editor's ruler learned
// this the hard way).
context.coordinator.scrollView = scrollView
context.coordinator.embedded = embedded
context.coordinator.onContentHeight = onContentHeight

textView.postsFrameChangedNotifications = true
context.coordinator.observeFrame(of: textView)
scrollView.contentView.postsBoundsChangedNotifications = true
context.coordinator.observeScroll(of: scrollView)

apply(to: textView, layoutManager: layoutManager, ruler: ruler,
coordinator: context.coordinator)

// Keys (← → walk, Esc) should work the moment the overlay lands, without
// a click first. Deferred one turn — at make time the view has no window yet.
DispatchQueue.main.async { [weak textView] in
guard let textView, let window = textView.window else { return }
if window.firstResponder === window || window.firstResponder is NSTextView == false {
window.makeFirstResponder(textView)
context.coordinator.reportHeightIfNeeded()

// Keys (← → walk, Esc) should work the moment the overlay lands, without a click first —
// but an embedded pane must not steal focus (many stacked panes would fight over it).
if !embedded {
DispatchQueue.main.async { [weak textView] in
guard let textView, let window = textView.window else { return }
if window.firstResponder === window || window.firstResponder is NSTextView == false {
window.makeFirstResponder(textView)
}
}
}
return scrollView
Expand All @@ -127,6 +146,10 @@ struct DiffTextPane: NSViewRepresentable {
context.coordinator.updateFind(query: findQuery, options: findOptions,
focusedIndex: findFocusedIndex, in: textView)
context.coordinator.reclaimFocusIfNeeded(reclaimFocus, in: textView)

context.coordinator.onContentHeight = onContentHeight
// A band-expand or a syntax pass changes the content height; re-measure so the card resizes.
context.coordinator.reportHeightIfNeeded()
}

/// Swaps the document in when it changed (initial load, band expand) and lays the
Expand Down Expand Up @@ -180,6 +203,27 @@ struct DiffTextPane: NSViewRepresentable {
var appliedStyled: Int?
weak var ruler: DiffGutterRulerView?
var onMatchesChanged: ((Int) -> Void)?
// Embedded (card-stacked) sizing: measure the laid-out content and hand its height to SwiftUI.
weak var scrollView: NSScrollView?
var embedded = false
var onContentHeight: ((CGFloat) -> Void)?
private var reportedHeight: CGFloat = -1

/// Measure the text's laid-out height and report it (once, on change) so the SwiftUI card sizes
/// to fit and the outer list scrolls. No-op unless embedded.
func reportHeightIfNeeded() {
guard embedded, let scrollView,
let textView = scrollView.documentView as? DiffTextView,
let layoutManager = textView.layoutManager,
let container = textView.textContainer else { return }
layoutManager.ensureLayout(for: container)
let height = layoutManager.usedRect(for: container).height
+ textView.textContainerInset.height * 2
guard abs(height - reportedHeight) > 0.5 else { return }
reportedHeight = height
let callback = onContentHeight
DispatchQueue.main.async { callback?(height) }
}
/// The same incremental-find engine the code editor uses — highlights and semantics
/// stay identical across the two ⌘F surfaces.
private let find = TextFindEngine()
Expand Down Expand Up @@ -231,7 +275,11 @@ struct DiffTextPane: NSViewRepresentable {
NotificationCenter.default.addObserver(
forName: NSView.frameDidChangeNotification, object: textView, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.ruler?.needsDisplay = true }
MainActor.assumeIsolated {
self?.ruler?.needsDisplay = true
// A width change re-wraps the text, so the content height moves — re-measure.
self?.reportHeightIfNeeded()
}
}
}

Expand Down
Loading