83d61df13b
Pedido: poder llevar la comparativa a una sesión abierta con un modelo para que la use. Eso, y no un informe para imprimir, decide el formato: los ficheros iguales se resumen en una linea en vez de listarse, los binarios solo se mencionan, y las diferencias van en diff unificado, que los modelos leen de forma nativa. Lo que se omite se dice en voz alta — un informe que se deja la mitad en silencio es peor que ninguno, porque se lee como completo. Las cabeceras `--- a/ +++ b/` cuestan dos lineas y convierten cada bloque en un parche de verdad, asi que el modelo puede devolverlo por `git apply` en vez de reescribir el cambio a mano. Que lo sea de verdad esta comprobado ejecutando git sobre la salida en 10 escenarios (sin salto final, insercion al principio, borrado al final...); dos fallos aparecieron asi: interlineaba `-` y `+` en vez de agrupar, y contaba la linea fantasma que deja el ultimo `\n`. Implementado en los dos motores (Swift y TypeScript) con la misma semantica, y verificado que producen el mismo texto **byte a byte** para un mismo caso. Tambien hay salida JSON, para alimentar una herramienta en vez de una charla. UI en las dos apps: copiar al portapapeles primero (que es el gesto real), y guardar/descargar despues. En web, si el portapapeles se niega, se descarga y se avisa: un fallo silencioso ahi acaba en pegar contenido viejo sin saberlo. De paso, un bug que esto destapó: el menu contextual de la web se cerraba en `pointerdown`, quitando los botones antes de que su click llegara — ninguna accion del menu funcionaba (tampoco "Set as base" ni "Expand all"). El smoke test nunca habia pulsado una; ahora si. Closes #9 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hEAYuHRKMYz9sSa9zmbzz
374 lines
16 KiB
Swift
374 lines
16 KiB
Swift
import Foundation
|
|
|
|
/// Turns a comparison into text meant to be pasted into an open conversation with
|
|
/// a language model, so it can reason about what changed.
|
|
///
|
|
/// That goal drives every decision here. The output has to fit in a message, so
|
|
/// identical files are summarised on one line instead of listed one by one, and
|
|
/// binaries only get a mention. Differences use **unified diff** with context,
|
|
/// which models read natively — and which `git apply` accepts, so a change can be
|
|
/// handed straight back. Anything left out is stated out loud: a report that
|
|
/// silently drops half the changes is worse than no report, because it reads as
|
|
/// complete.
|
|
///
|
|
/// Kept in step with `web/src/engine/export.ts`; both must produce the same text.
|
|
public enum ComparisonExport {
|
|
public struct Options: Sendable {
|
|
/// List the identical files too. Off by default: it's noise for this purpose.
|
|
public var includeIdentical: Bool
|
|
/// Unchanged lines kept around each change.
|
|
public var contextLines: Int
|
|
/// Diff lines per file before truncating (and saying so).
|
|
public var maxLinesPerFile: Int
|
|
/// Files with a diff body before the rest are only named (and said so).
|
|
public var maxFiles: Int
|
|
|
|
public init(includeIdentical: Bool = false, contextLines: Int = 3,
|
|
maxLinesPerFile: Int = 200, maxFiles: Int = 40) {
|
|
self.includeIdentical = includeIdentical
|
|
self.contextLines = contextLines
|
|
self.maxLinesPerFile = maxLinesPerFile
|
|
self.maxFiles = maxFiles
|
|
}
|
|
}
|
|
|
|
static func modeName(_ mode: ComparisonMode) -> String {
|
|
switch mode {
|
|
case .binary: return "byte for byte"
|
|
case .text: return "text"
|
|
case .semantic: return "semantic (JSON/XML)"
|
|
}
|
|
}
|
|
|
|
private static func flatten(_ root: DiffNode) -> [DiffNode] {
|
|
var out: [DiffNode] = []
|
|
func walk(_ node: DiffNode) {
|
|
if node.isDirectory {
|
|
node.children.forEach(walk)
|
|
return
|
|
}
|
|
out.append(node)
|
|
}
|
|
walk(root)
|
|
return out
|
|
}
|
|
|
|
private static func formatSize(_ size: UInt64) -> String {
|
|
size < 1024 ? "\(size) B" : String(format: "%.1f KB", Double(size) / 1024)
|
|
}
|
|
|
|
// MARK: - Markdown
|
|
|
|
/// The comparison as Markdown, ready to paste into a chat.
|
|
public static func markdown(root: DiffNode, leftName: String, rightName: String,
|
|
mode: ComparisonMode?, options: Options = Options()) -> String {
|
|
let files = flatten(root)
|
|
let changed = files.filter { $0.status == .different || $0.status == .pending }
|
|
let onlyLeft = files.filter { $0.status == .onlyLeft }
|
|
let onlyRight = files.filter { $0.status == .onlyRight }
|
|
let same = files.filter { $0.status == .identical || $0.status == .equivalent }
|
|
|
|
var out: [String] = []
|
|
out.append("# Comparison: \(leftName) ↔ \(rightName)")
|
|
out.append("")
|
|
// Spelling out the convention costs two lines and removes any doubt about
|
|
// which side is which when the model reads the diffs below.
|
|
out.append("Generated by Kotej. **A** is the left side (`\(leftName)`), "
|
|
+ "**B** is the right side (`\(rightName)`). "
|
|
+ "In the diffs, `-` is A and `+` is B.")
|
|
out.append("")
|
|
out.append("- Comparison mode: " + (mode.map(modeName) ?? "automatic (per file type)"))
|
|
out.append("- \(same.count) same, \(changed.count) different, "
|
|
+ "\(onlyLeft.count) only in A, \(onlyRight.count) only in B")
|
|
out.append("")
|
|
|
|
if !onlyLeft.isEmpty {
|
|
out.append("## Only in A (\(leftName))")
|
|
out.append("")
|
|
for file in onlyLeft { out.append("- `\(file.path)` (\(formatSize(file.left?.size ?? 0)))") }
|
|
out.append("")
|
|
}
|
|
if !onlyRight.isEmpty {
|
|
out.append("## Only in B (\(rightName))")
|
|
out.append("")
|
|
for file in onlyRight { out.append("- `\(file.path)` (\(formatSize(file.right?.size ?? 0)))") }
|
|
out.append("")
|
|
}
|
|
|
|
if !changed.isEmpty {
|
|
out.append("## Different (\(changed.count))")
|
|
out.append("")
|
|
let detailed = Array(changed.prefix(options.maxFiles))
|
|
for file in detailed { out.append(contentsOf: section(file, options: options)) }
|
|
|
|
if changed.count > detailed.count {
|
|
let rest = changed.dropFirst(detailed.count)
|
|
out.append("### \(rest.count) more differing files, listed without their diffs")
|
|
out.append("")
|
|
for file in rest { out.append("- `\(file.path)`") }
|
|
out.append("")
|
|
}
|
|
}
|
|
|
|
if !same.isEmpty {
|
|
out.append("## Same (\(same.count))")
|
|
out.append("")
|
|
if options.includeIdentical {
|
|
for file in same {
|
|
out.append("- `\(file.path)`"
|
|
+ (file.status == .equivalent ? " (equivalent, not byte-identical)" : ""))
|
|
}
|
|
} else {
|
|
// One line rather than a list: the model rarely needs the names,
|
|
// but it does need to know they were compared and came out equal.
|
|
let equivalent = same.filter { $0.status == .equivalent }.count
|
|
out.append("\(same.count) files matched and are not listed"
|
|
+ (equivalent > 0
|
|
? ", of which \(equivalent) are equivalent rather than byte-identical "
|
|
+ "(same meaning, different bytes — e.g. reordered JSON keys or reformatted XML)."
|
|
: "."))
|
|
}
|
|
out.append("")
|
|
}
|
|
|
|
var text = out.joined(separator: "\n")
|
|
while text.contains("\n\n\n") { text = text.replacingOccurrences(of: "\n\n\n", with: "\n\n") }
|
|
return text.trimmingCharacters(in: .whitespacesAndNewlines) + "\n"
|
|
}
|
|
|
|
/// One differing file: a heading, what settled it, and the diff itself.
|
|
private static func section(_ file: DiffNode, options: Options) -> [String] {
|
|
var out: [String] = []
|
|
out.append("### `\(file.path)`")
|
|
out.append("")
|
|
|
|
guard let left = file.left, let right = file.right else { return out }
|
|
guard let leftData = try? left.data(), let rightData = try? right.data() else {
|
|
out.append("Contents could not be read.")
|
|
out.append("")
|
|
return out
|
|
}
|
|
|
|
if ContentKind.detect(path: file.name, data: leftData) == .binary {
|
|
out.append("Binary file, differs. A: \(formatSize(UInt64(leftData.count))), "
|
|
+ "B: \(formatSize(UInt64(rightData.count))).")
|
|
out.append("")
|
|
return out
|
|
}
|
|
|
|
let leftText = String(decoding: leftData, as: UTF8.self)
|
|
let rightText = String(decoding: rightData, as: UTF8.self)
|
|
let rows = TextDiff.rows(left: leftText, right: rightText,
|
|
syntax: CommentSyntax.forPath(file.name))
|
|
|
|
let summary = describeChanges(rows)
|
|
out.append(file.appliedMode.map { "Compared as \(modeName($0)). \(summary)" } ?? summary)
|
|
out.append("")
|
|
|
|
let diff = unified(rows: rows, options: options,
|
|
leftEndsWithNewline: leftText.hasSuffix("\n"),
|
|
rightEndsWithNewline: rightText.hasSuffix("\n"))
|
|
out.append("```diff")
|
|
// The a/ b/ headers cost two lines and make the block a real patch, so the
|
|
// model can hand it straight to `git apply` instead of retyping the change.
|
|
out.append("--- a/\(file.path)")
|
|
out.append("+++ b/\(file.path)")
|
|
out.append(diff.text)
|
|
out.append("```")
|
|
if diff.omitted > 0 { out.append("_\(diff.omitted) further diff lines omitted for length._") }
|
|
out.append("")
|
|
return out
|
|
}
|
|
|
|
/// A one-line summary of what kind of change this file holds.
|
|
static func describeChanges(_ rows: [TextDiff.Row]) -> String {
|
|
let changed = rows.filter { $0.kind == .changed }
|
|
var parts: [String] = []
|
|
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 { parts.append("\(content) changed") }
|
|
if comments > 0 { parts.append("\(comments) comment-only") }
|
|
if spacing > 0 { parts.append("\(spacing) whitespace-only") }
|
|
if added > 0 { parts.append("\(added) added") }
|
|
if removed > 0 { parts.append("\(removed) removed") }
|
|
return parts.isEmpty ? "No line-level differences." : parts.joined(separator: ", ") + " lines."
|
|
}
|
|
|
|
// MARK: - Unified diff
|
|
|
|
/// Unified diff built from the aligned rows, keeping `contextLines` around each
|
|
/// change and collapsing the untouched stretches into `@@` hunks.
|
|
public static func unified(rows allRows: [TextDiff.Row], options: Options = Options(),
|
|
leftEndsWithNewline: Bool = true,
|
|
rightEndsWithNewline: Bool = true) -> (text: String, omitted: Int) {
|
|
// Splitting on "\n" leaves a phantom empty row after a trailing newline.
|
|
// The viewer can show it, but a patch must not count it: the file has 8
|
|
// lines, and claiming 9 makes git reject the whole thing.
|
|
var rows = allRows
|
|
if let last = rows.last, last.kind == .equal, last.left.isEmpty, last.right.isEmpty {
|
|
rows.removeLast()
|
|
}
|
|
|
|
let interesting = rows.indices.filter { rows[$0].kind != .equal }
|
|
guard !interesting.isEmpty else { return ("", 0) }
|
|
|
|
// Group changes that are close enough to share one hunk.
|
|
var groups: [(Int, Int)] = []
|
|
var start = interesting[0]
|
|
var end = interesting[0]
|
|
for index in interesting.dropFirst() {
|
|
if index - end <= options.contextLines * 2 {
|
|
end = index
|
|
continue
|
|
}
|
|
groups.append((start, end))
|
|
start = index
|
|
end = index
|
|
}
|
|
groups.append((start, end))
|
|
|
|
var lines: [String] = []
|
|
var budget = options.maxLinesPerFile
|
|
var omitted = 0
|
|
// A file that doesn't end in a newline needs git's marker after its last
|
|
// line; without it git reads the line as newline-terminated and the patch
|
|
// won't apply.
|
|
let noNewline = "\\ No newline at end of file"
|
|
let lastIndex = rows.count - 1
|
|
|
|
for (from, to) in groups {
|
|
let first = max(0, from - options.contextLines)
|
|
let last = min(rows.count - 1, to + options.contextLines)
|
|
let slice = Array(rows[first...last])
|
|
|
|
let leftStart = slice.first(where: { $0.leftNumber != nil })?.leftNumber ?? 0
|
|
let rightStart = slice.first(where: { $0.rightNumber != nil })?.rightNumber ?? 0
|
|
let leftCount = slice.filter { $0.leftNumber != nil }.count
|
|
let rightCount = slice.filter { $0.rightNumber != nil }.count
|
|
|
|
// Real unified diff groups a run of removals before its additions,
|
|
// rather than interleaving them pair by pair — otherwise `git apply`
|
|
// and friends reject it, and it stops being a format anything can
|
|
// consume.
|
|
var body: [String] = []
|
|
var removals: [String] = []
|
|
var additions: [String] = []
|
|
func flush() {
|
|
body.append(contentsOf: removals)
|
|
body.append(contentsOf: additions)
|
|
removals.removeAll()
|
|
additions.removeAll()
|
|
}
|
|
|
|
for (offset, row) in slice.enumerated() {
|
|
let isLast = first + offset == lastIndex
|
|
switch row.kind {
|
|
case .equal:
|
|
flush()
|
|
body.append(" \(row.left)")
|
|
if isLast && !(leftEndsWithNewline && rightEndsWithNewline) { body.append(noNewline) }
|
|
case .added:
|
|
additions.append("+\(row.right)")
|
|
if isLast && !rightEndsWithNewline { additions.append(noNewline) }
|
|
case .removed:
|
|
removals.append("-\(row.left)")
|
|
if isLast && !leftEndsWithNewline { removals.append(noNewline) }
|
|
case .changed:
|
|
removals.append("-\(row.left)")
|
|
if isLast && !leftEndsWithNewline { removals.append(noNewline) }
|
|
additions.append("+\(row.right)")
|
|
if isLast && !rightEndsWithNewline { additions.append(noNewline) }
|
|
}
|
|
}
|
|
flush()
|
|
|
|
if budget <= 0 {
|
|
omitted += body.count
|
|
continue
|
|
}
|
|
lines.append("@@ -\(leftStart),\(leftCount) +\(rightStart),\(rightCount) @@")
|
|
if body.count > budget {
|
|
lines.append(contentsOf: body.prefix(budget))
|
|
omitted += body.count - budget
|
|
budget = 0
|
|
} else {
|
|
lines.append(contentsOf: body)
|
|
budget -= body.count
|
|
}
|
|
}
|
|
|
|
return (lines.joined(separator: "\n"), omitted)
|
|
}
|
|
|
|
// MARK: - JSON
|
|
|
|
/// The same data as JSON, for feeding a tool rather than a conversation.
|
|
public static func json(root: DiffNode, leftName: String, rightName: String,
|
|
mode: ComparisonMode?, options: Options = Options()) -> String {
|
|
let files = flatten(root)
|
|
|
|
var entries: [[String: Any]] = []
|
|
for file in files {
|
|
var entry: [String: Any] = [
|
|
"path": file.path,
|
|
"status": statusName(file.status),
|
|
"appliedMode": file.appliedMode.map(modeName) ?? NSNull(),
|
|
"leftSize": file.left.map { Int($0.size) } ?? NSNull(),
|
|
"rightSize": file.right.map { Int($0.size) } ?? NSNull(),
|
|
]
|
|
if file.status == .different, let left = file.left, let right = file.right {
|
|
if let leftData = try? left.data(), let rightData = try? right.data() {
|
|
if ContentKind.detect(path: file.name, data: leftData) == .binary {
|
|
entry["binary"] = true
|
|
} else {
|
|
let leftText = String(decoding: leftData, as: UTF8.self)
|
|
let rightText = String(decoding: rightData, as: UTF8.self)
|
|
let rows = TextDiff.rows(left: leftText, right: rightText,
|
|
syntax: CommentSyntax.forPath(file.name))
|
|
entry["diff"] = unified(rows: rows, options: options,
|
|
leftEndsWithNewline: leftText.hasSuffix("\n"),
|
|
rightEndsWithNewline: rightText.hasSuffix("\n")).text
|
|
}
|
|
} else {
|
|
entry["unreadable"] = true
|
|
}
|
|
}
|
|
entries.append(entry)
|
|
}
|
|
|
|
let payload: [String: Any] = [
|
|
"tool": "Kotej",
|
|
"left": leftName,
|
|
"right": rightName,
|
|
"mode": mode.map(modeName) ?? "automatic",
|
|
"totals": [
|
|
"same": files.filter { $0.status == .identical || $0.status == .equivalent }.count,
|
|
"different": files.filter { $0.status == .different || $0.status == .pending }.count,
|
|
"onlyLeft": files.filter { $0.status == .onlyLeft }.count,
|
|
"onlyRight": files.filter { $0.status == .onlyRight }.count,
|
|
],
|
|
"files": entries,
|
|
]
|
|
|
|
guard let data = try? JSONSerialization.data(withJSONObject: payload,
|
|
options: [.prettyPrinted, .sortedKeys]),
|
|
let text = String(data: data, encoding: .utf8) else { return "{}" }
|
|
return text
|
|
}
|
|
|
|
private static func statusName(_ status: DiffStatus) -> String {
|
|
switch status {
|
|
case .identical: return "identical"
|
|
case .equivalent: return "equivalent"
|
|
case .different: return "different"
|
|
case .onlyLeft: return "onlyLeft"
|
|
case .onlyRight: return "onlyRight"
|
|
case .pending: return "pending"
|
|
}
|
|
}
|
|
}
|