Files
kotej/App/FileDiffView.swift
alexandrev-tibco 4c0f55c32d Say what the difference actually is, and expand from either side
A changed line only said 'changed'. Now the engine classifies it and the view
colours it accordingly: whitespace-only (teal), comment-only (indigo) or real
content (orange), with a legend counting each kind present in the file. Comment
detection uses the syntax inferred from the extension and only claims a comment
change when the code either side of it is identical; an unknown file type claims
nothing, since hiding a real change is worse than showing a cosmetic one.

Within a changed line the differing characters are now highlighted rather than
tinting the whole row, so a 21 -> 18 reads as a one-character edit instead of
looking like a rewritten line.

The tree no longer uses List's outline, which only ever puts a disclosure
triangle on the leading column: rows are flattened with their own expansion
state, so both sides carry a chevron and the tree can be driven from whichever
side you're reading. Expand all / collapse all added to the context menu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
2026-07-28 10:08:54 +02:00

260 lines
9.8 KiB
Swift

import SwiftUI
import KotejEngine
/// Side-by-side contents of the selected row. Text (and JSON/XML) is aligned line
/// by line; binaries just report their verdict, since a hex view would be noise.
struct FileDiffView: View {
@Bindable var model: ComparisonModel
@State private var rows: [TextDiff.Row] = []
@State private var notice: String?
@State private var isLoading = false
/// Long lines wrap by default so nothing is hidden; turning it off gives one
/// line per row, which reads better for code.
@AppStorage("kotej.wrapLines") private var wrapLines = true
var body: some View {
VStack(spacing: 0) {
if let node = model.selection {
header(node)
Divider()
content(node)
} else {
Spacer()
Text("Select a file to see its differences")
.foregroundStyle(.secondary)
Spacer()
}
}
.task(id: model.selection?.path) { await load() }
}
private func header(_ node: DiffNode) -> some View {
HStack {
Image(systemName: node.isDirectory ? "folder.fill" : "doc.text")
Text(verbatim: node.path.isEmpty ? node.name : node.path)
.font(.callout.weight(.medium))
.lineLimit(1)
.truncationMode(.head)
Spacer()
if isLoading { ProgressView().controlSize(.small) }
if !rows.isEmpty {
DiffLegend(rows: rows)
Toggle("Wrap lines", isOn: $wrapLines)
.toggleStyle(.checkbox)
.font(.caption)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
@ViewBuilder private func content(_ node: DiffNode) -> some View {
if let notice {
Text(verbatim: notice)
.foregroundStyle(.secondary)
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if rows.isEmpty {
Group { if !isLoading { Text("Nothing to show") } }
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
// Vertical scrolling only: each side takes half the width and long
// lines wrap, so the panes fill the window instead of sitting in a
// narrow column with empty space around them.
ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
DiffLine(row: row, wrapLines: wrapLines)
}
}
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
private func load() async {
rows = []
notice = nil
guard let node = model.selection else { return }
if node.isDirectory {
notice = String(localized: node.isArchive
? "Archive: expand it in the tree to see its contents."
: "Folder: select a file inside it.")
return
}
guard let left = node.left, let right = node.right else {
notice = String(localized: node.left == nil ? "Only exists on the right." : "Only exists on the left.")
return
}
isLoading = true
defer { isLoading = false }
let loaded: (Data, Data)? = await Task.detached(priority: .userInitiated) {
guard let l = try? left.data(), let r = try? right.data() else { return nil }
return (l, r)
}.value
guard let (leftData, rightData) = loaded else {
notice = String(localized: "Couldn't read the contents.")
return
}
let kind = ContentKind.detect(path: node.name, data: leftData)
guard kind != .binary else {
notice = leftData == rightData
? String(localized: "Identical binary (\(leftData.count) bytes).")
: String(localized: "Different binary (\(leftData.count) vs \(rightData.count) bytes).")
return
}
let leftText = String(decoding: leftData, as: UTF8.self)
let rightText = String(decoding: rightData, as: UTF8.self)
let syntax = CommentSyntax.forPath(node.name)
rows = await Task.detached(priority: .userInitiated) {
TextDiff.rows(left: leftText, right: rightText, syntax: syntax)
}.value
if node.status == .equivalent {
notice = nil
}
}
}
/// One aligned line pair. Both sides get exactly half the width, so the diff
/// spans the whole window.
struct DiffLine: View {
let row: TextDiff.Row
var wrapLines: Bool = true
var body: some View {
HStack(alignment: .top, spacing: 0) {
cell(number: row.leftNumber, text: row.left, highlight: row.leftHighlight, tint: leftTint)
Divider()
cell(number: row.rightNumber, text: row.right, highlight: row.rightHighlight, tint: rightTint)
}
.font(.system(.caption, design: .monospaced))
}
private func cell(number: Int?, text: String, highlight: Range<Int>?, tint: Color) -> some View {
HStack(alignment: .top, spacing: 8) {
Text(verbatim: number.map(String.init) ?? "")
.frame(width: 44, alignment: .trailing)
.foregroundStyle(.tertiary)
Text(attributed(text, highlight: highlight))
.lineLimit(wrapLines ? nil : 1)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: wrapLines)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
.padding(.horizontal, 6)
.padding(.vertical, 1)
.frame(maxWidth: .infinity, alignment: .leading)
.background(tint)
}
/// Paints just the characters that differ, so a one-character change doesn't
/// look the same as a rewritten line.
private func attributed(_ text: String, highlight: Range<Int>?) -> AttributedString {
var result = AttributedString(text.isEmpty ? " " : text)
guard let highlight, !highlight.isEmpty, !text.isEmpty else { return result }
// Split the line so the differing middle can be styled on its own; the
// offsets come from the engine as character counts.
let characters = Array(text)
let lower = min(highlight.lowerBound, characters.count)
let upper = min(highlight.upperBound, characters.count)
guard lower < upper else { return result }
var before = AttributedString(String(characters[0..<lower]))
var middle = AttributedString(String(characters[lower..<upper]))
let after = AttributedString(String(characters[upper...]))
middle.backgroundColor = accent.opacity(0.55)
middle.font = .system(.caption, design: .monospaced).bold()
before.append(middle)
before.append(after)
result = before
return result
}
/// A changed line is tinted by what actually changed, so cosmetic edits are
/// visibly not the same thing as a change in behaviour.
var accent: Color {
switch row.kind {
case .added: return .green
case .removed: return .red
case .equal: return .clear
case .changed:
switch row.changeKind {
case .whitespace: return .teal
case .comment: return .indigo
case .content: return .orange
}
}
}
private var leftTint: Color {
switch row.kind {
case .equal: return .clear
case .changed: return accent.opacity(0.12)
case .removed: return .red.opacity(0.16)
case .added: return .secondary.opacity(0.06)
}
}
private var rightTint: Color {
switch row.kind {
case .equal: return .clear
case .changed: return accent.opacity(0.12)
case .added: return .green.opacity(0.16)
case .removed: return .secondary.opacity(0.06)
}
}
}
/// Tells you at a glance what kind of differences this file has.
struct DiffLegend: View {
let rows: [TextDiff.Row]
var body: some View {
HStack(spacing: 10) {
ForEach(present, id: \.0) { item in
HStack(spacing: 3) {
RoundedRectangle(cornerRadius: 2)
.fill(item.1.opacity(0.55))
.frame(width: 8, height: 8)
Text(item.2) + Text(verbatim: " \(item.3)")
}
}
}
.font(.caption2)
.foregroundStyle(.secondary)
}
/// Only the kinds actually present, with how many lines each accounts for.
private var present: [(String, Color, LocalizedStringKey, Int)] {
let changed = rows.filter { $0.kind == .changed }
var out: [(String, Color, LocalizedStringKey, Int)] = []
let content = changed.filter { $0.changeKind == .content }.count
let comments = changed.filter { $0.changeKind == .comment }.count
let spacing = changed.filter { $0.changeKind == .whitespace }.count
let added = rows.filter { $0.kind == .added }.count
let removed = rows.filter { $0.kind == .removed }.count
if content > 0 { out.append(("content", .orange, "content", content)) }
if comments > 0 { out.append(("comment", .indigo, "comments", comments)) }
if spacing > 0 { out.append(("space", .teal, "whitespace", spacing)) }
if added > 0 { out.append(("added", .green, "added", added)) }
if removed > 0 { out.append(("removed", .red, "removed", removed)) }
return out
}
}