4c0f55c32d
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
260 lines
9.8 KiB
Swift
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
|
|
}
|
|
}
|