f85c746af0
"Cotejo" means nothing outside Spanish and is hard to pronounce elsewhere, so the project is now Kotej: same root, but it reads and sounds the same in English and Spanish. Renamed throughout — engine, targets, bundle ids and the URL scheme, which is now kotej://compare. Icon generated with Codex: two facing panels split by a seam, content lines on each side and one amber line marking a difference — the tool's job at a glance, still legible at 16pt. 34 tests still green; kotej:// verified end to end against the demo JARs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
159 lines
5.2 KiB
Swift
159 lines
5.2 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
|
|
|
|
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) }
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
@ViewBuilder private func content(_ node: DiffNode) -> some View {
|
|
if let notice {
|
|
Spacer()
|
|
Text(verbatim: notice).foregroundStyle(.secondary).padding()
|
|
Spacer()
|
|
} else if rows.isEmpty {
|
|
Spacer()
|
|
Group { if !isLoading { Text("Nothing to show") } }.foregroundStyle(.secondary)
|
|
Spacer()
|
|
} else {
|
|
ScrollView([.vertical, .horizontal]) {
|
|
LazyVStack(alignment: .leading, spacing: 0) {
|
|
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
|
DiffLine(row: row)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
rows = await Task.detached(priority: .userInitiated) {
|
|
TextDiff.rows(left: leftText, right: rightText)
|
|
}.value
|
|
|
|
if node.status == .equivalent {
|
|
notice = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One aligned line pair.
|
|
struct DiffLine: View {
|
|
let row: TextDiff.Row
|
|
|
|
var body: some View {
|
|
HStack(spacing: 0) {
|
|
cell(number: row.leftNumber, text: row.left, tint: leftTint)
|
|
Divider()
|
|
cell(number: row.rightNumber, text: row.right, tint: rightTint)
|
|
}
|
|
.font(.system(.caption, design: .monospaced))
|
|
}
|
|
|
|
private func cell(number: Int?, text: String, tint: Color) -> some View {
|
|
HStack(spacing: 8) {
|
|
Text(verbatim: number.map(String.init) ?? "")
|
|
.frame(width: 44, alignment: .trailing)
|
|
.foregroundStyle(.tertiary)
|
|
Text(verbatim: text.isEmpty ? " " : text)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.textSelection(.enabled)
|
|
}
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 1)
|
|
.frame(minWidth: 320, alignment: .leading)
|
|
.background(tint)
|
|
}
|
|
|
|
private var leftTint: Color {
|
|
switch row.kind {
|
|
case .equal: return .clear
|
|
case .changed: return .yellow.opacity(0.18)
|
|
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 .yellow.opacity(0.18)
|
|
case .added: return .green.opacity(0.16)
|
|
case .removed: return .secondary.opacity(0.06)
|
|
}
|
|
}
|
|
}
|