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
4 changes: 2 additions & 2 deletions Packages/Sources/DiffView/DiffLine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import SwiftUI
/// two-column gutter. Either gutter column may be `nil` — added rows have no
/// pre-edit number, removed rows have no post-edit number, hunk/meta rows have
/// neither.
public struct DiffLine: Hashable, Sendable {
public enum Kind: Hashable, Sendable {
public nonisolated struct DiffLine: Hashable, Sendable {
public nonisolated enum Kind: Hashable, Sendable {
case added
case removed
case context
Expand Down
213 changes: 204 additions & 9 deletions Packages/Sources/DiffView/DiffView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,28 +25,102 @@ public struct DiffView: View {
case scroll
}

public enum ChangeNavigationDirection: Equatable, Sendable {
case previous
case next
}

public struct ChangeNavigationRequest: Equatable, Sendable {
public let direction: ChangeNavigationDirection
public let token: Int

public init(direction: ChangeNavigationDirection, token: Int) {
self.direction = direction
self.token = token
}
}

public struct ChangeNavigationState: Equatable, Sendable {
public let changeCount: Int
public let currentIndex: Int?

public init(changeCount: Int, currentIndex: Int?) {
self.changeCount = changeCount
self.currentIndex = currentIndex
}

public static let empty = ChangeNavigationState(changeCount: 0, currentIndex: nil)
}

private let lines: [DiffLine]
private let showsBackground: Bool
private let showsDiffMarkers: Bool
private let display: LineDisplay
private let language: String?
private let navigationRequest: ChangeNavigationRequest?
private let onNavigationStateChange: ((ChangeNavigationState) -> Void)?

@State private var selectedChangeBlockIndex: Int?
@State private var verticalScrollPosition: String?

public init(
lines: [DiffLine],
showsBackground: Bool = true,
showsDiffMarkers: Bool = true,
display: LineDisplay = .wrap,
language: String? = nil
language: String? = nil,
navigationRequest: ChangeNavigationRequest? = nil,
onNavigationStateChange: ((ChangeNavigationState) -> Void)? = nil
) {
self.lines = lines
self.showsBackground = showsBackground
self.showsDiffMarkers = showsDiffMarkers
self.display = display
self.language = language
self.navigationRequest = navigationRequest
self.onNavigationStateChange = onNavigationStateChange
}

public var body: some View {
let layout = GutterLayout(lines: lines)
content(layout: layout)
.background(showsBackground ? ClaudeTheme.codeBackground : Color.clear)
.textSelection(.enabled)
let changeBlocks = Self.changeBlockOffsets(in: lines)
ScrollViewReader { proxy in
content(layout: layout)
.background(showsBackground ? ClaudeTheme.codeBackground : Color.clear)
.textSelection(.enabled)
.onAppear {
publishNavigationState(changeBlocks: changeBlocks)
}
.onChange(of: lines) { _, newLines in
selectedChangeBlockIndex = nil
verticalScrollPosition = nil
publishNavigationState(changeBlocks: Self.changeBlockOffsets(in: newLines))
}
.onChange(of: navigationRequest) { _, request in
guard let request else { return }
navigateChanges(
direction: request.direction,
changeBlocks: changeBlocks,
proxy: proxy
)
}
}
}

public nonisolated static func changeBlockOffsets(in lines: [DiffLine]) -> [Int] {
var offsets: [Int] = []
var previousWasChange = false
for (offset, line) in lines.enumerated() {
let isChange = switch line.kind {
case .added, .removed: true
case .context, .hunk, .meta: false
}
if isChange, !previousWasChange {
offsets.append(offset)
}
previousWasChange = isChange
}
return offsets
}

@ViewBuilder
Expand All @@ -55,24 +129,43 @@ public struct DiffView: View {
case .wrap:
ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
DiffRow(line: line, layout: layout, wraps: true, language: language)
ForEach(Array(lines.enumerated()), id: \.offset) { offset, line in
DiffRow(
line: line,
layout: layout,
showsDiffMarkers: showsDiffMarkers,
wraps: true,
language: language
)
.id(rowID(for: offset))
}
}
.scrollTargetLayout()
.frame(maxWidth: .infinity, alignment: .leading)
}
.scrollPosition(id: $verticalScrollPosition, anchor: .top)
case .scroll:
GeometryReader { proxy in
let viewportWidth = max(0, proxy.size.width - layout.width)
let bodyWidth = max(
viewportWidth,
Self.horizontalScrollBodyWidth(
for: lines,
layout: layout,
showsDiffMarkers: showsDiffMarkers
)
)
ScrollView(.vertical) {
HStack(alignment: .top, spacing: 0) {
// Sticky gutter column — sits outside the horizontal
// scroll so line numbers stay anchored on the left.
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
ForEach(Array(lines.enumerated()), id: \.offset) { offset, line in
DiffRowGutter(line: line, layout: layout)
.id(rowID(for: offset))
}
}
.scrollTargetLayout()
// Horizontally scrollable body. Constrain the viewport
// to the visible remainder; otherwise the vertical
// scroll layout can offer an effectively unbounded
Expand All @@ -83,20 +176,108 @@ public struct DiffView: View {
DiffRowBody(
line: line,
layout: layout,
showsDiffMarkers: showsDiffMarkers,
wraps: false,
language: language
)
.frame(width: bodyWidth, alignment: .leading)
}
}
.frame(minWidth: viewportWidth, alignment: .leading)
.frame(width: bodyWidth, alignment: .leading)
}
.frame(width: viewportWidth, alignment: .leading)
}
.frame(width: proxy.size.width, alignment: .leading)
}
.scrollPosition(id: $verticalScrollPosition, anchor: .top)
}
}
}

private func navigateChanges(
direction: ChangeNavigationDirection,
changeBlocks: [Int],
proxy: ScrollViewProxy
) {
guard !changeBlocks.isEmpty else {
selectedChangeBlockIndex = nil
publishNavigationState(changeBlocks: changeBlocks)
return
}

let nextIndex: Int
switch direction {
case .previous:
nextIndex = ((selectedChangeBlockIndex ?? changeBlocks.count) - 1 + changeBlocks.count) % changeBlocks.count
case .next:
nextIndex = ((selectedChangeBlockIndex ?? -1) + 1) % changeBlocks.count
}

selectedChangeBlockIndex = nextIndex
let targetID = rowID(for: changeBlocks[nextIndex])
verticalScrollPosition = targetID
withAnimation(.easeInOut(duration: 0.18)) {
proxy.scrollTo(targetID, anchor: .top)
}
publishNavigationState(changeBlocks: changeBlocks)
}

private func publishNavigationState(changeBlocks: [Int]) {
let current = selectedChangeBlockIndex.flatMap { index in
changeBlocks.indices.contains(index) ? index : nil
}
onNavigationStateChange?(.init(changeCount: changeBlocks.count, currentIndex: current))
}

private func rowID(for offset: Int) -> String {
"diff-line-\(offset)"
}

static func horizontalScrollBodyWidth(
for lines: [DiffLine],
layout: GutterLayout,
showsDiffMarkers: Bool = true
) -> CGFloat {
let maxColumns = lines.map {
horizontalColumnCount(
for: $0,
layout: layout,
showsDiffMarkers: showsDiffMarkers
)
}.max() ?? 0
let estimatedMonospaceWidth = ClaudeTheme.messageSize(12) * 0.72
return ceil(CGFloat(maxColumns) * estimatedMonospaceWidth) + 12
}

private static func horizontalColumnCount(
for line: DiffLine,
layout: GutterLayout,
showsDiffMarkers: Bool
) -> Int {
let prefixColumns: Int
let body: String
switch line.kind {
case .added:
prefixColumns = showsDiffMarkers ? 2 : 0
body = line.text.hasPrefix("+") ? String(line.text.dropFirst()) : line.text
case .removed:
prefixColumns = showsDiffMarkers ? 2 : 0
body = line.text.hasPrefix("-") ? String(line.text.dropFirst()) : line.text
case .context:
prefixColumns = showsDiffMarkers ? 2 : 0
body = line.text.hasPrefix(" ") ? String(line.text.dropFirst()) : line.text
case .hunk, .meta:
prefixColumns = showsDiffMarkers && layout.columnCount > 0 ? 2 : 0
body = line.text
}
return prefixColumns + visualColumnCount(body)
}

private static func visualColumnCount(_ text: String) -> Int {
text.reduce(0) { count, character in
count + (character == "\t" ? 4 : 1)
}
}
}

// MARK: - Gutter Layout
Expand Down Expand Up @@ -146,13 +327,20 @@ struct GutterLayout {
private struct DiffRow: View {
let line: DiffLine
let layout: GutterLayout
let showsDiffMarkers: Bool
let wraps: Bool
let language: String?

var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 0) {
DiffRowGutter(line: line, layout: layout)
DiffRowBody(line: line, layout: layout, wraps: wraps, language: language)
DiffRowBody(
line: line,
layout: layout,
showsDiffMarkers: showsDiffMarkers,
wraps: wraps,
language: language
)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
Expand Down Expand Up @@ -204,6 +392,7 @@ enum DiffMetrics {
private struct DiffRowBody: View {
let line: DiffLine
let layout: GutterLayout
let showsDiffMarkers: Bool
let wraps: Bool
/// File-extension hint (e.g. "swift", "ts") used to syntax-highlight the
/// body text. `nil` skips highlighting and falls back to a flat color.
Expand Down Expand Up @@ -245,6 +434,10 @@ private struct DiffRowBody: View {
/// flush with the `+` / `-` itself, instead of sitting in a phantom column
/// past the marker like they do when marker and body are separate views.
private var combinedText: Text {
guard showsDiffMarkers else {
return bodyTextView
}

let prefix: Text
switch line.kind {
case .added:
Expand Down Expand Up @@ -302,6 +495,8 @@ private struct DiffRowBody: View {
}

private var rowBackground: Color {
guard showsDiffMarkers else { return .clear }

switch line.kind {
case .added: return ClaudeTheme.statusSuccess.opacity(0.12)
case .removed: return ClaudeTheme.statusError.opacity(0.12)
Expand Down
42 changes: 38 additions & 4 deletions Packages/Sources/RxCodeChatKit/ChangeDiffView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,55 @@ public struct ChangeDiffView: View {
private let lines: [DiffLine]
private let display: DiffView.LineDisplay
private let language: String?
private let navigationRequest: DiffView.ChangeNavigationRequest?
private let onNavigationStateChange: ((DiffView.ChangeNavigationState) -> Void)?

/// Renders a raw unified diff, e.g. `git diff` output.
public init(unifiedDiff: String, display: DiffView.LineDisplay = .wrap, language: String? = nil) {
public init(
unifiedDiff: String,
display: DiffView.LineDisplay = .wrap,
language: String? = nil,
navigationRequest: DiffView.ChangeNavigationRequest? = nil,
onNavigationStateChange: ((DiffView.ChangeNavigationState) -> Void)? = nil
) {
self.lines = DiffComputation.parseUnifiedDiff(unifiedDiff)
self.display = display
self.language = language
self.navigationRequest = navigationRequest
self.onNavigationStateChange = onNavigationStateChange
}

/// Renders old/new replacement pairs as a removed-then-added diff.
public init(hunks: [PreviewFile.EditHunk], display: DiffView.LineDisplay = .wrap, language: String? = nil) {
public init(
hunks: [PreviewFile.EditHunk],
display: DiffView.LineDisplay = .wrap,
language: String? = nil,
navigationRequest: DiffView.ChangeNavigationRequest? = nil,
onNavigationStateChange: ((DiffView.ChangeNavigationState) -> Void)? = nil
) {
self.lines = DiffComputation.buildEditDiffLines(from: hunks)
self.display = display
self.language = language
self.navigationRequest = navigationRequest
self.onNavigationStateChange = onNavigationStateChange
}

/// Renders the diff between a pre-edit snapshot and a post-edit snapshot.
/// Preferred when both snapshots are available — gives an exact, thread-
/// isolated diff with no dependence on hunk anchoring or disk state.
public init(original: String, modified: String, display: DiffView.LineDisplay = .wrap, language: String? = nil) {
public init(
original: String,
modified: String,
display: DiffView.LineDisplay = .wrap,
language: String? = nil,
navigationRequest: DiffView.ChangeNavigationRequest? = nil,
onNavigationStateChange: ((DiffView.ChangeNavigationState) -> Void)? = nil
) {
self.lines = DiffComputation.buildSnapshotDiffLines(original: original, current: modified)
self.display = display
self.language = language
self.navigationRequest = navigationRequest
self.onNavigationStateChange = onNavigationStateChange
}

/// `+/-` counts derived from the same snapshot diff that
Expand All @@ -57,6 +84,13 @@ public struct ChangeDiffView: View {
}

public var body: some View {
DiffView(lines: lines, showsBackground: false, display: display, language: language)
DiffView(
lines: lines,
showsBackground: false,
display: display,
language: language,
navigationRequest: navigationRequest,
onNavigationStateChange: onNavigationStateChange
)
}
}
Loading
Loading