Exportar la comparación en un formato pensado para pegar a un LLM
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
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
import KotejEngine
|
||||
|
||||
/// Drives one comparison: what's on each side, the resulting tree, and the
|
||||
@@ -14,6 +16,8 @@ final class ComparisonModel: Identifiable {
|
||||
var root: DiffNode?
|
||||
var isScanning = false
|
||||
var errorMessage: String?
|
||||
/// Short confirmation after copying, which is otherwise invisible.
|
||||
var exportNotice: String?
|
||||
|
||||
/// nil means "decide per file from its type" (JSON/XML semantic, text, binary).
|
||||
var mode: ComparisonMode?
|
||||
@@ -281,4 +285,63 @@ final class ComparisonModel: Identifiable {
|
||||
await MainActor.run { node.status = status }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Export
|
||||
|
||||
/// Builds the report. Reads every differing file, so it runs off the main
|
||||
/// thread — a big EAR would otherwise freeze the window mid-click.
|
||||
private func buildExport(asJSON: Bool, includeIdentical: Bool) async -> String? {
|
||||
guard let root else { return nil }
|
||||
let leftName = leftURL?.lastPathComponent ?? "A"
|
||||
let rightName = rightURL?.lastPathComponent ?? "B"
|
||||
let mode = self.mode
|
||||
let options = ComparisonExport.Options(includeIdentical: includeIdentical)
|
||||
|
||||
return await Task.detached(priority: .userInitiated) {
|
||||
asJSON
|
||||
? ComparisonExport.json(root: root, leftName: leftName, rightName: rightName,
|
||||
mode: mode, options: options)
|
||||
: ComparisonExport.markdown(root: root, leftName: leftName, rightName: rightName,
|
||||
mode: mode, options: options)
|
||||
}.value
|
||||
}
|
||||
|
||||
/// Straight to the clipboard: the point is pasting it into a conversation,
|
||||
/// and a file would only have to be opened and copied again.
|
||||
func copyExport(includeIdentical: Bool) {
|
||||
Task {
|
||||
guard let text = await buildExport(asJSON: false, includeIdentical: includeIdentical) else { return }
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(text, forType: .string)
|
||||
exportNotice = String(localized: "Copied — paste it into your conversation")
|
||||
// Long enough to read, short enough not to linger over the tree.
|
||||
try? await Task.sleep(nanoseconds: 2_600_000_000)
|
||||
exportNotice = nil
|
||||
}
|
||||
}
|
||||
|
||||
func saveExport(asJSON: Bool) {
|
||||
Task {
|
||||
guard let text = await buildExport(asJSON: asJSON, includeIdentical: false) else { return }
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = exportFileName(asJSON: asJSON)
|
||||
panel.allowedContentTypes = [asJSON ? .json : UTType(filenameExtension: "md") ?? .plainText]
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
do {
|
||||
try text.write(to: url, atomically: true, encoding: .utf8)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func exportFileName(asJSON: Bool) -> String {
|
||||
func clean(_ name: String) -> String {
|
||||
name.replacingOccurrences(of: "[^A-Za-z0-9._-]+", with: "-",
|
||||
options: .regularExpression)
|
||||
}
|
||||
let left = clean(leftURL?.lastPathComponent ?? "A")
|
||||
let right = clean(rightURL?.lastPathComponent ?? "B")
|
||||
return "kotej-\(left)-vs-\(right).\(asJSON ? "json" : "md")"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ struct ContentView: View {
|
||||
if let pending = model.pendingPick {
|
||||
pendingBanner(pending)
|
||||
}
|
||||
if let notice = model.exportNotice {
|
||||
noticeBanner(notice)
|
||||
}
|
||||
|
||||
if model.isScanning {
|
||||
VStack(spacing: 10) {
|
||||
@@ -51,6 +54,19 @@ struct ContentView: View {
|
||||
Binding(get: { CGFloat(splitFraction) }, set: { splitFraction = Double($0) })
|
||||
}
|
||||
|
||||
/// Confirms an action with no visible result, like copying to the clipboard.
|
||||
private func noticeBanner(_ text: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||
Text(verbatim: text)
|
||||
Spacer()
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.green.opacity(0.12))
|
||||
}
|
||||
|
||||
/// Keeps the marked side visible; otherwise it's easy to forget one is armed.
|
||||
private func pendingBanner(_ pending: PendingPick) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
@@ -135,6 +151,7 @@ struct ControlBar: View {
|
||||
}
|
||||
|
||||
Spacer()
|
||||
exportButton
|
||||
copyButtons
|
||||
Divider().frame(height: 16)
|
||||
navigation
|
||||
@@ -188,6 +205,25 @@ struct ControlBar: View {
|
||||
.help("Automatic picks the comparison that suits each file type")
|
||||
}
|
||||
|
||||
/// Hand the comparison to something else — in practice, to a chat with a
|
||||
/// model, which is why copying comes before saving a file.
|
||||
private var exportButton: some View {
|
||||
Menu {
|
||||
Button("Copy for an AI chat") { model.copyExport(includeIdentical: false) }
|
||||
Button("Copy including identical files") { model.copyExport(includeIdentical: true) }
|
||||
Divider()
|
||||
Button("Save as Markdown…") { model.saveExport(asJSON: false) }
|
||||
Button("Save as JSON…") { model.saveExport(asJSON: true) }
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.menuIndicator(.hidden)
|
||||
.frame(width: 34)
|
||||
.help("Export comparison")
|
||||
.disabled(model.root == nil)
|
||||
}
|
||||
|
||||
/// Act on a difference instead of only looking at it.
|
||||
private var copyButtons: some View {
|
||||
HStack(spacing: 6) {
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import XCTest
|
||||
@testable import KotejEngine
|
||||
|
||||
/// The export exists to be pasted into a conversation with a model, so these
|
||||
/// check the things that would mislead one: which side is which, changes that go
|
||||
/// missing, and truncation that isn't declared.
|
||||
///
|
||||
/// They mirror `web/tests/export.test.ts`; the two engines must produce the same
|
||||
/// text, so a change here without a change there is a bug.
|
||||
final class ComparisonExportTests: XCTestCase {
|
||||
private var root: URL!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
root = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("kotej-export-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
}
|
||||
|
||||
/// Builds two folders from `[path: contents]` and compares them.
|
||||
private func comparison(left: [String: String], right: [String: String]) throws -> DiffNode {
|
||||
let leftURL = try write(left, into: "v1")
|
||||
let rightURL = try write(right, into: "v2")
|
||||
let leftTree = try TreeScanner.scan(leftURL)
|
||||
let rightTree = try TreeScanner.scan(rightURL)
|
||||
return DirectoryComparer.compare(left: leftTree, right: rightTree)
|
||||
}
|
||||
|
||||
private func write(_ files: [String: String], into name: String) throws -> URL {
|
||||
let base = root.appendingPathComponent(name)
|
||||
for (path, contents) in files {
|
||||
let url = base.appendingPathComponent(path)
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
private func markdown(left: [String: String], right: [String: String],
|
||||
options: ComparisonExport.Options = .init()) throws -> String {
|
||||
let node = try comparison(left: left, right: right)
|
||||
return ComparisonExport.markdown(root: node, leftName: "v1", rightName: "v2",
|
||||
mode: nil, options: options)
|
||||
}
|
||||
|
||||
// MARK: Markdown
|
||||
|
||||
func testStatesWhichSideIsWhich() throws {
|
||||
let text = try markdown(left: ["a.txt": "uno\n"], right: ["a.txt": "dos\n"])
|
||||
XCTAssertTrue(text.contains("**A** is the left side"))
|
||||
XCTAssertTrue(text.contains("`-` is A and `+` is B"))
|
||||
}
|
||||
|
||||
func testReportsEveryCategory() throws {
|
||||
let text = try markdown(
|
||||
left: ["same.txt": "x\n", "changed.txt": "uno\n", "gone.txt": "y\n"],
|
||||
right: ["same.txt": "x\n", "changed.txt": "dos\n", "new.txt": "z\n"])
|
||||
|
||||
XCTAssertTrue(text.contains("1 same, 1 different, 1 only in A, 1 only in B"))
|
||||
XCTAssertTrue(text.contains("gone.txt"))
|
||||
XCTAssertTrue(text.contains("new.txt"))
|
||||
XCTAssertTrue(text.contains("### `changed.txt`"))
|
||||
}
|
||||
|
||||
func testEmitsUnifiedDiff() throws {
|
||||
let text = try markdown(left: ["f.txt": "uno\ndos\ntres\n"],
|
||||
right: ["f.txt": "uno\nDOS\ntres\n"])
|
||||
XCTAssertTrue(text.contains("```diff"))
|
||||
XCTAssertTrue(text.contains("--- a/f.txt"))
|
||||
XCTAssertTrue(text.contains("-dos"))
|
||||
XCTAssertTrue(text.contains("+DOS"))
|
||||
XCTAssertTrue(text.contains(" uno"), "context lines should be kept")
|
||||
}
|
||||
|
||||
func testSummarisesIdenticalFilesInsteadOfListingThem() throws {
|
||||
var files: [String: String] = [:]
|
||||
for index in 0..<30 { files["f\(index).txt"] = "igual\n" }
|
||||
let text = try markdown(left: files, right: files)
|
||||
XCTAssertTrue(text.contains("30 files matched and are not listed"))
|
||||
XCTAssertFalse(text.contains("f17.txt"))
|
||||
}
|
||||
|
||||
func testListsIdenticalFilesWhenAsked() throws {
|
||||
let text = try markdown(left: ["f.txt": "x\n"], right: ["f.txt": "x\n"],
|
||||
options: .init(includeIdentical: true))
|
||||
XCTAssertTrue(text.contains("- `f.txt`"))
|
||||
}
|
||||
|
||||
func testExplainsEquivalenceRatherThanCallingItIdentical() throws {
|
||||
let text = try markdown(left: ["c.json": "{\"a\":1,\"b\":2}"],
|
||||
right: ["c.json": "{\n \"b\": 2,\n \"a\": 1\n}"])
|
||||
XCTAssertTrue(text.contains("equivalent rather than byte-identical"))
|
||||
}
|
||||
|
||||
func testDeclaresTruncatedLines() throws {
|
||||
let before = (0..<200).map { "line \($0)" }.joined(separator: "\n")
|
||||
let after = (0..<200).map { "LINE \($0)" }.joined(separator: "\n")
|
||||
let text = try markdown(left: ["big.txt": before], right: ["big.txt": after],
|
||||
options: .init(maxLinesPerFile: 20))
|
||||
XCTAssertTrue(text.contains("further diff lines omitted"))
|
||||
}
|
||||
|
||||
func testDeclaresTruncatedFiles() throws {
|
||||
var left: [String: String] = [:], right: [String: String] = [:]
|
||||
for index in 0..<10 {
|
||||
left["f\(index).txt"] = "uno\n"
|
||||
right["f\(index).txt"] = "dos\n"
|
||||
}
|
||||
let text = try markdown(left: left, right: right, options: .init(maxFiles: 3))
|
||||
XCTAssertTrue(text.contains("7 more differing files, listed without their diffs"))
|
||||
}
|
||||
|
||||
func testNamesTheModeThatSettledTheFile() throws {
|
||||
let text = try markdown(left: ["f.txt": "uno\n"], right: ["f.txt": "dos\n"])
|
||||
XCTAssertTrue(text.contains("Compared as text"))
|
||||
}
|
||||
|
||||
func testDistinguishesCommentOnlyChange() throws {
|
||||
let text = try markdown(left: ["App.swift": "let total = 3 // vieja\n"],
|
||||
right: ["App.swift": "let total = 3 // nueva\n"])
|
||||
XCTAssertTrue(text.contains("comment-only"))
|
||||
}
|
||||
|
||||
func testHasNoRunsOfBlankLines() throws {
|
||||
let text = try markdown(left: ["a.txt": "uno\n"], right: ["a.txt": "dos\n"])
|
||||
XCTAssertFalse(text.contains("\n\n\n"))
|
||||
XCTAssertTrue(text.hasSuffix("\n"))
|
||||
}
|
||||
|
||||
// MARK: Unified diff
|
||||
|
||||
func testCollapsesUntouchedStretchesIntoHunks() throws {
|
||||
let before = (["a"] + (0..<40).map { "x\($0)" } + ["b"]).joined(separator: "\n")
|
||||
let after = (["A"] + (0..<40).map { "x\($0)" } + ["B"]).joined(separator: "\n")
|
||||
let rows = TextDiff.rows(left: before, right: after, syntax: nil)
|
||||
let diff = ComparisonExport.unified(rows: rows, leftEndsWithNewline: false,
|
||||
rightEndsWithNewline: false)
|
||||
let headers = diff.text.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
.filter { $0.hasPrefix("@@") }
|
||||
XCTAssertEqual(headers.count, 2)
|
||||
XCTAssertFalse(diff.text.contains("x20"))
|
||||
}
|
||||
|
||||
func testGroupsRemovalsBeforeAdditions() throws {
|
||||
let rows = TextDiff.rows(left: "uno\ndos\ntres\ncuatro",
|
||||
right: "UNO\nDOS\ntres\ncuatro", syntax: nil)
|
||||
let diff = ComparisonExport.unified(rows: rows, leftEndsWithNewline: false,
|
||||
rightEndsWithNewline: false)
|
||||
let body = diff.text.split(separator: "\n").filter { !$0.hasPrefix("@@") }
|
||||
// -uno -dos +UNO +DOS, never -uno +UNO -dos +DOS
|
||||
XCTAssertEqual(Array(body.prefix(4)), ["-uno", "-dos", "+UNO", "+DOS"])
|
||||
}
|
||||
|
||||
func testEmptyWhenFilesMatch() throws {
|
||||
let rows = TextDiff.rows(left: "uno\ndos", right: "uno\ndos", syntax: nil)
|
||||
let diff = ComparisonExport.unified(rows: rows)
|
||||
XCTAssertEqual(diff.text, "")
|
||||
XCTAssertEqual(diff.omitted, 0)
|
||||
}
|
||||
|
||||
// MARK: The strongest check — hand it to git
|
||||
|
||||
func testGitAppliesTheExportedPatch() throws {
|
||||
let cases: [(String, String, String)] = [
|
||||
("change in the middle", "uno\ndos\ntres\n", "uno\nDOS\ntres\n"),
|
||||
("no trailing newline", "uno\ndos\ntres", "uno\nDOS\ntres"),
|
||||
("insertion at the very top", "uno\ndos\n", "cero\nuno\ndos\n"),
|
||||
("deletion at the very top", "cero\nuno\ndos\n", "uno\ndos\n"),
|
||||
("append at the end", "uno\ndos\n", "uno\ndos\ntres\n"),
|
||||
("deletion at the end", "uno\ndos\ntres\n", "uno\ndos\n"),
|
||||
("blank line kept at the end", "uno\n\n", "DOS\n\n"),
|
||||
("everything replaced", "a\nb\nc\n", "x\ny\nz\n"),
|
||||
]
|
||||
|
||||
for (label, before, after) in cases {
|
||||
let node = try comparison(left: ["src/app.txt": before], right: ["src/app.txt": after])
|
||||
let text = ComparisonExport.markdown(root: node, leftName: "v1", rightName: "v2", mode: nil)
|
||||
guard let patch = text.components(separatedBy: "```diff").dropFirst().first?
|
||||
.components(separatedBy: "```").first?
|
||||
.drop(while: { $0 == "\n" }) else {
|
||||
XCTFail("no patch block for: \(label)")
|
||||
continue
|
||||
}
|
||||
XCTAssertEqual(try applyWithGit(before: before, patch: String(patch)), after,
|
||||
"git could not apply the patch for: \(label)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the patch with real git and returns the resulting file.
|
||||
private func applyWithGit(before: String, patch: String) throws -> String {
|
||||
let dir = root.appendingPathComponent("git-\(UUID().uuidString)")
|
||||
let file = dir.appendingPathComponent("src/app.txt")
|
||||
try FileManager.default.createDirectory(at: file.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try before.write(to: file, atomically: true, encoding: .utf8)
|
||||
try patch.write(to: dir.appendingPathComponent("change.patch"),
|
||||
atomically: true, encoding: .utf8)
|
||||
|
||||
try run(["init", "-q"], in: dir)
|
||||
// --check first: git refuses a malformed or non-applying patch outright.
|
||||
try run(["apply", "--check", "change.patch"], in: dir)
|
||||
try run(["apply", "change.patch"], in: dir)
|
||||
return try String(contentsOf: file, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func run(_ arguments: [String], in directory: URL) throws {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
|
||||
process.arguments = arguments
|
||||
process.currentDirectoryURL = directory
|
||||
let errors = Pipe()
|
||||
process.standardError = errors
|
||||
process.standardOutput = Pipe()
|
||||
try process.run()
|
||||
let message = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
|
||||
process.waitUntilExit()
|
||||
if process.terminationStatus != 0 {
|
||||
throw NSError(domain: "git", code: Int(process.terminationStatus),
|
||||
userInfo: [NSLocalizedDescriptionKey: "git \(arguments.joined(separator: " ")): \(message)"])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: JSON
|
||||
|
||||
func testJSONCarriesTotalsAndEntries() throws {
|
||||
let node = try comparison(left: ["same.txt": "x\n", "changed.txt": "uno\n", "gone.txt": "y\n"],
|
||||
right: ["same.txt": "x\n", "changed.txt": "dos\n"])
|
||||
let text = ComparisonExport.json(root: node, leftName: "v1", rightName: "v2", mode: nil)
|
||||
let parsed = try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any]
|
||||
|
||||
XCTAssertEqual(parsed?["tool"] as? String, "Kotej")
|
||||
let totals = parsed?["totals"] as? [String: Int]
|
||||
XCTAssertEqual(totals?["same"], 1)
|
||||
XCTAssertEqual(totals?["different"], 1)
|
||||
XCTAssertEqual(totals?["onlyLeft"], 1)
|
||||
|
||||
let files = parsed?["files"] as? [[String: Any]]
|
||||
let changed = files?.first { $0["path"] as? String == "changed.txt" }
|
||||
XCTAssertEqual(changed?["status"] as? String, "different")
|
||||
XCTAssertTrue((changed?["diff"] as? String ?? "").contains("-uno"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* 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. 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.
|
||||
*/
|
||||
|
||||
import type { DiffNode } from './tree';
|
||||
import { nodeData } from './tree';
|
||||
import { detectKind, type ComparisonMode } from './content';
|
||||
import { diffRows, commentSyntaxFor, type DiffRow } from './diff';
|
||||
|
||||
export interface ExportOptions {
|
||||
/** List the identical files too. Off by default: it's noise for this purpose. */
|
||||
includeIdentical: boolean;
|
||||
/** Unchanged lines kept around each change. */
|
||||
contextLines: number;
|
||||
/** Diff lines per file before truncating (and saying so). */
|
||||
maxLinesPerFile: number;
|
||||
/** Files with a diff body before the rest are only named (and said so). */
|
||||
maxFiles: number;
|
||||
}
|
||||
|
||||
export const defaultExportOptions: ExportOptions = {
|
||||
includeIdentical: false,
|
||||
contextLines: 3,
|
||||
maxLinesPerFile: 200,
|
||||
maxFiles: 40,
|
||||
};
|
||||
|
||||
export interface ExportInput {
|
||||
root: DiffNode;
|
||||
leftName: string;
|
||||
rightName: string;
|
||||
mode: ComparisonMode | null;
|
||||
}
|
||||
|
||||
const MODE_NAMES: Record<ComparisonMode, string> = {
|
||||
binary: 'byte for byte',
|
||||
text: 'text',
|
||||
semantic: 'semantic (JSON/XML)',
|
||||
};
|
||||
|
||||
function flatten(root: DiffNode): DiffNode[] {
|
||||
const out: DiffNode[] = [];
|
||||
const walk = (node: DiffNode) => {
|
||||
if (node.isDirectory) { node.children.forEach(walk); return; }
|
||||
out.push(node);
|
||||
};
|
||||
walk(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatSize(size: number): string {
|
||||
return size < 1024 ? `${size} B` : `${(size / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
/** The comparison as Markdown, ready to paste into a chat. */
|
||||
export async function exportMarkdown(input: ExportInput,
|
||||
options: Partial<ExportOptions> = {}): Promise<string> {
|
||||
const opts = { ...defaultExportOptions, ...options };
|
||||
const files = flatten(input.root);
|
||||
const changed = files.filter((f) => f.status === 'different' || f.status === 'pending');
|
||||
const onlyLeft = files.filter((f) => f.status === 'onlyLeft');
|
||||
const onlyRight = files.filter((f) => f.status === 'onlyRight');
|
||||
const same = files.filter((f) => f.status === 'identical' || f.status === 'equivalent');
|
||||
|
||||
const out: string[] = [];
|
||||
out.push(`# Comparison: ${input.leftName} ↔ ${input.rightName}`);
|
||||
out.push('');
|
||||
// Spelling out the convention costs two lines and removes any doubt about
|
||||
// which side is which when the model reads the diffs below.
|
||||
out.push(`Generated by Kotej. **A** is the left side (\`${input.leftName}\`), `
|
||||
+ `**B** is the right side (\`${input.rightName}\`). `
|
||||
+ `In the diffs, \`-\` is A and \`+\` is B.`);
|
||||
out.push('');
|
||||
out.push(`- Comparison mode: ${input.mode ? MODE_NAMES[input.mode] : 'automatic (per file type)'}`);
|
||||
out.push(`- ${same.length} same, ${changed.length} different, `
|
||||
+ `${onlyLeft.length} only in A, ${onlyRight.length} only in B`);
|
||||
out.push('');
|
||||
|
||||
if (onlyLeft.length) {
|
||||
out.push(`## Only in A (${input.leftName})`);
|
||||
out.push('');
|
||||
for (const file of onlyLeft) out.push(`- \`${file.path}\` (${formatSize(file.left?.size ?? 0)})`);
|
||||
out.push('');
|
||||
}
|
||||
if (onlyRight.length) {
|
||||
out.push(`## Only in B (${input.rightName})`);
|
||||
out.push('');
|
||||
for (const file of onlyRight) out.push(`- \`${file.path}\` (${formatSize(file.right?.size ?? 0)})`);
|
||||
out.push('');
|
||||
}
|
||||
|
||||
if (changed.length) {
|
||||
out.push(`## Different (${changed.length})`);
|
||||
out.push('');
|
||||
const detailed = changed.slice(0, opts.maxFiles);
|
||||
for (const file of detailed) out.push(...await section(file, opts));
|
||||
|
||||
if (changed.length > detailed.length) {
|
||||
const rest = changed.slice(detailed.length);
|
||||
out.push(`### ${rest.length} more differing files, listed without their diffs`);
|
||||
out.push('');
|
||||
for (const file of rest) out.push(`- \`${file.path}\``);
|
||||
out.push('');
|
||||
}
|
||||
}
|
||||
|
||||
if (same.length) {
|
||||
if (opts.includeIdentical) {
|
||||
out.push(`## Same (${same.length})`);
|
||||
out.push('');
|
||||
for (const file of same) {
|
||||
out.push(`- \`${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.
|
||||
const equivalent = same.filter((f) => f.status === 'equivalent').length;
|
||||
out.push(`## Same (${same.length})`);
|
||||
out.push('');
|
||||
out.push(`${same.length} files matched and are not listed`
|
||||
+ (equivalent ? `, of which ${equivalent} are equivalent rather than byte-identical `
|
||||
+ `(same meaning, different bytes — e.g. reordered JSON keys or reformatted XML).` : '.'));
|
||||
}
|
||||
out.push('');
|
||||
}
|
||||
|
||||
return out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
|
||||
}
|
||||
|
||||
/** One differing file: a heading, what settled it, and the diff itself. */
|
||||
async function section(file: DiffNode, options: ExportOptions): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
out.push(`### \`${file.path}\``);
|
||||
out.push('');
|
||||
|
||||
if (!file.left || !file.right) return out;
|
||||
|
||||
let leftData: Uint8Array;
|
||||
let rightData: Uint8Array;
|
||||
try {
|
||||
[leftData, rightData] = await Promise.all([nodeData(file.left), nodeData(file.right)]);
|
||||
} catch {
|
||||
out.push('Contents could not be read.');
|
||||
out.push('');
|
||||
return out;
|
||||
}
|
||||
|
||||
if (detectKind(file.name, leftData) === 'binary') {
|
||||
out.push(`Binary file, differs. A: ${formatSize(leftData.length)}, B: ${formatSize(rightData.length)}.`);
|
||||
out.push('');
|
||||
return out;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const leftText = decoder.decode(leftData);
|
||||
const rightText = decoder.decode(rightData);
|
||||
const rows = diffRows(leftText, rightText, commentSyntaxFor(file.name));
|
||||
if (file.appliedMode) out.push(`Compared as ${MODE_NAMES[file.appliedMode]}. ${describeChanges(rows)}`);
|
||||
else out.push(describeChanges(rows));
|
||||
out.push('');
|
||||
|
||||
const { text, omitted } = unifiedDiff(rows, options,
|
||||
{ left: leftText.endsWith('\n'), right: rightText.endsWith('\n') });
|
||||
out.push('```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.push(`--- a/${file.path}`);
|
||||
out.push(`+++ b/${file.path}`);
|
||||
out.push(text);
|
||||
out.push('```');
|
||||
if (omitted > 0) out.push(`_${omitted} further diff lines omitted for length._`);
|
||||
out.push('');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A one-line summary of what kind of change this file holds. */
|
||||
function describeChanges(rows: DiffRow[]): string {
|
||||
const changed = rows.filter((r) => r.kind === 'changed');
|
||||
const parts: string[] = [];
|
||||
const content = changed.filter((r) => r.changeKind === 'content').length;
|
||||
const comments = changed.filter((r) => r.changeKind === 'comment').length;
|
||||
const spacing = changed.filter((r) => r.changeKind === 'whitespace').length;
|
||||
const added = rows.filter((r) => r.kind === 'added').length;
|
||||
const removed = rows.filter((r) => r.kind === 'removed').length;
|
||||
|
||||
if (content) parts.push(`${content} changed`);
|
||||
if (comments) parts.push(`${comments} comment-only`);
|
||||
if (spacing) parts.push(`${spacing} whitespace-only`);
|
||||
if (added) parts.push(`${added} added`);
|
||||
if (removed) parts.push(`${removed} removed`);
|
||||
return parts.length ? `${parts.join(', ')} lines.` : 'No line-level differences.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified diff built from the aligned rows, keeping `contextLines` around each
|
||||
* change and collapsing the untouched stretches into `@@` hunks.
|
||||
*/
|
||||
export function unifiedDiff(allRows: DiffRow[], options: ExportOptions,
|
||||
newlines: { left: boolean; right: boolean } = { left: true, right: true }):
|
||||
{ text: string; omitted: number } {
|
||||
// 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.
|
||||
const last = allRows[allRows.length - 1];
|
||||
const rows = last && last.kind === 'equal' && last.left === '' && last.right === ''
|
||||
? allRows.slice(0, -1)
|
||||
: allRows;
|
||||
|
||||
const interesting = rows
|
||||
.map((row, index) => (row.kind === 'equal' ? -1 : index))
|
||||
.filter((index) => index >= 0);
|
||||
if (!interesting.length) return { text: '', omitted: 0 };
|
||||
|
||||
// Group changes that are close enough to share one hunk.
|
||||
const groups: Array<[number, number]> = [];
|
||||
let start = interesting[0];
|
||||
let end = interesting[0];
|
||||
for (const index of interesting.slice(1)) {
|
||||
if (index - end <= options.contextLines * 2) { end = index; continue; }
|
||||
groups.push([start, end]);
|
||||
start = end = index;
|
||||
}
|
||||
groups.push([start, end]);
|
||||
|
||||
const lines: string[] = [];
|
||||
let budget = options.maxLinesPerFile;
|
||||
let omitted = 0;
|
||||
|
||||
for (const [from, to] of groups) {
|
||||
const first = Math.max(0, from - options.contextLines);
|
||||
const last = Math.min(rows.length - 1, to + options.contextLines);
|
||||
const slice = rows.slice(first, last + 1);
|
||||
|
||||
const leftStart = slice.find((row) => row.leftNumber !== null)?.leftNumber ?? 0;
|
||||
const rightStart = slice.find((row) => row.rightNumber !== null)?.rightNumber ?? 0;
|
||||
const leftCount = slice.filter((row) => row.leftNumber !== null).length;
|
||||
const rightCount = slice.filter((row) => row.rightNumber !== null).length;
|
||||
|
||||
// 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.
|
||||
const body: string[] = [];
|
||||
let removals: string[] = [];
|
||||
let additions: string[] = [];
|
||||
const flush = () => {
|
||||
body.push(...removals, ...additions);
|
||||
removals = [];
|
||||
additions = [];
|
||||
};
|
||||
|
||||
// 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.
|
||||
const NO_NEWLINE = '\\ No newline at end of file';
|
||||
const lastIndex = rows.length - 1;
|
||||
|
||||
slice.forEach((row, offset) => {
|
||||
const isLast = first + offset === lastIndex;
|
||||
switch (row.kind) {
|
||||
case 'equal':
|
||||
flush();
|
||||
body.push(` ${row.left}`);
|
||||
if (isLast && (!newlines.left || !newlines.right)) body.push(NO_NEWLINE);
|
||||
break;
|
||||
case 'added':
|
||||
additions.push(`+${row.right}`);
|
||||
if (isLast && !newlines.right) additions.push(NO_NEWLINE);
|
||||
break;
|
||||
case 'removed':
|
||||
removals.push(`-${row.left}`);
|
||||
if (isLast && !newlines.left) removals.push(NO_NEWLINE);
|
||||
break;
|
||||
case 'changed':
|
||||
removals.push(`-${row.left}`);
|
||||
if (isLast && !newlines.left) removals.push(NO_NEWLINE);
|
||||
additions.push(`+${row.right}`);
|
||||
if (isLast && !newlines.right) additions.push(NO_NEWLINE);
|
||||
break;
|
||||
}
|
||||
});
|
||||
flush();
|
||||
|
||||
if (budget <= 0) { omitted += body.length; continue; }
|
||||
lines.push(`@@ -${leftStart},${leftCount} +${rightStart},${rightCount} @@`);
|
||||
if (body.length > budget) {
|
||||
lines.push(...body.slice(0, budget));
|
||||
omitted += body.length - budget;
|
||||
budget = 0;
|
||||
} else {
|
||||
lines.push(...body);
|
||||
budget -= body.length;
|
||||
}
|
||||
}
|
||||
|
||||
return { text: lines.join('\n'), omitted };
|
||||
}
|
||||
|
||||
/** The same data as JSON, for feeding a tool rather than a conversation. */
|
||||
export async function exportJSON(input: ExportInput,
|
||||
options: Partial<ExportOptions> = {}): Promise<string> {
|
||||
const opts = { ...defaultExportOptions, ...options };
|
||||
const files = flatten(input.root);
|
||||
|
||||
const entries = await Promise.all(files.map(async (file) => {
|
||||
const entry: Record<string, unknown> = {
|
||||
path: file.path,
|
||||
status: file.status,
|
||||
appliedMode: file.appliedMode ?? null,
|
||||
leftSize: file.left?.size ?? null,
|
||||
rightSize: file.right?.size ?? null,
|
||||
};
|
||||
if (file.status === 'different' && file.left && file.right) {
|
||||
try {
|
||||
const [leftData, rightData] = await Promise.all([nodeData(file.left), nodeData(file.right)]);
|
||||
if (detectKind(file.name, leftData) === 'binary') {
|
||||
entry.binary = true;
|
||||
} else {
|
||||
const decoder = new TextDecoder();
|
||||
const leftText = decoder.decode(leftData);
|
||||
const rightText = decoder.decode(rightData);
|
||||
const rows = diffRows(leftText, rightText, commentSyntaxFor(file.name));
|
||||
entry.diff = unifiedDiff(rows, opts,
|
||||
{ left: leftText.endsWith('\n'), right: rightText.endsWith('\n') }).text;
|
||||
}
|
||||
} catch {
|
||||
entry.unreadable = true;
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
}));
|
||||
|
||||
return JSON.stringify({
|
||||
tool: 'Kotej',
|
||||
left: input.leftName,
|
||||
right: input.rightName,
|
||||
mode: input.mode ?? 'automatic',
|
||||
totals: {
|
||||
same: files.filter((f) => f.status === 'identical' || f.status === 'equivalent').length,
|
||||
different: files.filter((f) => f.status === 'different' || f.status === 'pending').length,
|
||||
onlyLeft: files.filter((f) => f.status === 'onlyLeft').length,
|
||||
onlyRight: files.filter((f) => f.status === 'onlyRight').length,
|
||||
},
|
||||
files: entries,
|
||||
}, null, 2);
|
||||
}
|
||||
@@ -110,6 +110,13 @@ const es: Record<string, string> = {
|
||||
Clear: 'Limpiar',
|
||||
Binary: 'Binario',
|
||||
|
||||
'Export comparison': 'Exportar la comparación',
|
||||
'Copy for an AI chat': 'Copiar para un chat de IA',
|
||||
'Copy including identical files': 'Copiar incluyendo los ficheros iguales',
|
||||
'Copied — paste it into your conversation': 'Copiado — pégalo en tu conversación',
|
||||
"Couldn't reach the clipboard — downloaded instead": 'No se pudo acceder al portapapeles — descargado en su lugar',
|
||||
'Download as Markdown': 'Descargar como Markdown',
|
||||
'Download as JSON': 'Descargar como JSON',
|
||||
'Expand all': 'Desplegar todo',
|
||||
'Collapse all': 'Plegar todo',
|
||||
'Resolve': 'Resolver',
|
||||
|
||||
@@ -568,3 +568,20 @@ select {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
/* Confirmation for actions with no visible result, like copying. */
|
||||
.toast {
|
||||
position: absolute;
|
||||
bottom: 42px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 150;
|
||||
padding: 7px 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
font-size: 12px;
|
||||
box-shadow: 0 6px 20px rgb(0 0 0 / 0.25);
|
||||
animation: toast-in 0.15s ease;
|
||||
}
|
||||
@keyframes toast-in { from { opacity: 0; transform: translate(-50%, 6px); } }
|
||||
|
||||
@@ -7,9 +7,11 @@ import { ComparisonModel, ROW_FILTERS, type RowFilter, type Side } from '../mode
|
||||
import type { Node } from '../engine/tree';
|
||||
import type { ComparisonMode, TextOptions } from '../engine/content';
|
||||
import { diffRows } from '../engine/diff';
|
||||
import { exportMarkdown, exportJSON } from '../engine/export';
|
||||
import { chooseFile, chooseFolder, readDrop } from '../picker';
|
||||
import { t } from '../i18n';
|
||||
import { TreeView, icon } from './tree';
|
||||
import { showMenu } from './menu';
|
||||
import { FileDiffView } from './filediff';
|
||||
|
||||
const MODES: Array<{ id: ComparisonMode | ''; label: string }> = [
|
||||
@@ -44,6 +46,8 @@ export class ComparisonView {
|
||||
constructor(onOpenInNewTab: (left: Node, right: Node) => void,
|
||||
private readonly onTitleChange: () => void) {
|
||||
this.element.className = 'comparison';
|
||||
// Anchors the toast, which is positioned against this pane.
|
||||
this.element.style.position = 'relative';
|
||||
this.sides.className = 'sides';
|
||||
this.controls.className = 'controls';
|
||||
this.banners.className = 'banners';
|
||||
@@ -185,6 +189,10 @@ export class ComparisonView {
|
||||
const options = iconButton('sliders', t('Comparison options'),
|
||||
(event) => this.showTextOptions(event));
|
||||
|
||||
const share = iconButton('share', t('Export comparison'),
|
||||
(event) => this.showExportMenu(event));
|
||||
share.disabled = this.model.root === null;
|
||||
|
||||
const spacer = document.createElement('div');
|
||||
spacer.className = 'spacer';
|
||||
|
||||
@@ -202,7 +210,70 @@ export class ComparisonView {
|
||||
() => this.model.selectNextDifference());
|
||||
previous.disabled = next.disabled = count === 0;
|
||||
|
||||
this.controls.replaceChildren(search, filter, mode, options, spacer, position, previous, next);
|
||||
this.controls.replaceChildren(search, filter, mode, options, share,
|
||||
spacer, position, previous, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* The export is aimed at pasting into a chat with a model, so copying to the
|
||||
* clipboard comes first — downloading a file would mean opening it again just
|
||||
* to select all and copy.
|
||||
*/
|
||||
private showExportMenu(event: MouseEvent) {
|
||||
if (!this.model.root) return;
|
||||
const input = () => ({
|
||||
root: this.model.root!,
|
||||
leftName: this.model.left?.name ?? 'A',
|
||||
rightName: this.model.right?.name ?? 'B',
|
||||
mode: this.model.mode,
|
||||
});
|
||||
|
||||
showMenu(event, [
|
||||
{
|
||||
label: t('Copy for an AI chat'),
|
||||
action: () => this.copy(exportMarkdown(input())),
|
||||
},
|
||||
{
|
||||
label: t('Copy including identical files'),
|
||||
action: () => this.copy(exportMarkdown(input(), { includeIdentical: true })),
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: t('Download as Markdown'),
|
||||
action: async () => download(await exportMarkdown(input()),
|
||||
`${fileStem(this.model)}.md`, 'text/markdown'),
|
||||
},
|
||||
{
|
||||
label: t('Download as JSON'),
|
||||
action: async () => download(await exportJSON(input()),
|
||||
`${fileStem(this.model)}.json`, 'application/json'),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies, and says so. The clipboard API refuses without focus or permission,
|
||||
* and a silent failure here is the worst outcome: you paste stale content and
|
||||
* never find out. On refusal the text is offered as a download instead.
|
||||
*/
|
||||
private async copy(pending: Promise<string>) {
|
||||
const text = await pending;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
this.flash(t('Copied — paste it into your conversation'));
|
||||
} catch {
|
||||
download(text, `${fileStem(this.model)}.md`, 'text/markdown');
|
||||
this.flash(t("Couldn't reach the clipboard — downloaded instead"));
|
||||
}
|
||||
}
|
||||
|
||||
/** A short confirmation: copying to the clipboard is otherwise invisible. */
|
||||
private flash(message: string) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.textContent = message;
|
||||
this.element.append(toast);
|
||||
setTimeout(() => toast.remove(), 2600);
|
||||
}
|
||||
|
||||
/** Tolerances the engine already supported; they just needed a way in. */
|
||||
@@ -492,6 +563,20 @@ export class ComparisonView {
|
||||
|
||||
// MARK: Small builders
|
||||
|
||||
function fileStem(model: ComparisonModel): string {
|
||||
const clean = (name: string) => name.replace(/[^\w.-]+/g, '-').replace(/^-|-$/g, '');
|
||||
return `kotej-${clean(model.left?.name ?? 'A')}-vs-${clean(model.right?.name ?? 'B')}`;
|
||||
}
|
||||
|
||||
function download(text: string, name: string, type: string) {
|
||||
const url = URL.createObjectURL(new Blob([text], { type }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = name;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function textCell(number: number | null, value: string, side: string): HTMLElement {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = `diff-cell ${side}`;
|
||||
|
||||
+13
-1
@@ -8,10 +8,15 @@ export interface MenuItem {
|
||||
}
|
||||
|
||||
let open: HTMLElement | null = null;
|
||||
let dismisser: ((event: PointerEvent) => void) | null = null;
|
||||
|
||||
export function closeMenu() {
|
||||
open?.remove();
|
||||
open = null;
|
||||
if (dismisser) {
|
||||
window.removeEventListener('pointerdown', dismisser);
|
||||
dismisser = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function showMenu(event: MouseEvent, items: MenuItem[]) {
|
||||
@@ -43,8 +48,15 @@ export function showMenu(event: MouseEvent, items: MenuItem[]) {
|
||||
menu.style.left = `${Math.max(8, x)}px`;
|
||||
menu.style.top = `${Math.max(8, y)}px`;
|
||||
|
||||
// Dismiss on a press *outside* the menu. Closing on any pointerdown would
|
||||
// remove the buttons before their click could fire, so every item would look
|
||||
// like it did nothing.
|
||||
dismisser = (event: PointerEvent) => {
|
||||
if (menu.contains(event.target as globalThis.Node)) return;
|
||||
closeMenu();
|
||||
};
|
||||
setTimeout(() => {
|
||||
window.addEventListener('pointerdown', closeMenu, { once: true });
|
||||
if (dismisser) window.addEventListener('pointerdown', dismisser);
|
||||
window.addEventListener('blur', closeMenu, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -292,6 +292,7 @@ const PATHS: Record<string, string> = {
|
||||
check: 'M3.5 8.5l3 3 6-6.5',
|
||||
blank: '',
|
||||
close: 'M4 4l8 8M12 4l-8 8',
|
||||
share: 'M8 10.5V2.5M5 5.5L8 2.5l3 3M3 9v3.5a1 1 0 001 1h8a1 1 0 001-1V9',
|
||||
info: 'M8 2.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM8 7v4M8 5.2v.1',
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* The export exists to be pasted into a conversation with a model, so these
|
||||
* tests check the things that would mislead one: which side is which, changes
|
||||
* that go missing, and truncation that isn't declared.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { scanFiles, compareTrees, type DiffNode } from '../src/engine/tree';
|
||||
import { exportMarkdown, exportJSON, unifiedDiff, defaultExportOptions } from '../src/engine/export';
|
||||
import { diffRows } from '../src/engine/diff';
|
||||
|
||||
const bytes = (text: string) => new TextEncoder().encode(text);
|
||||
const fileFor = (path: string, body: string) =>
|
||||
({ file: new File([bytes(body)], path.split('/').pop()!), relativePath: path });
|
||||
|
||||
async function comparison(left: Array<[string, string]>, right: Array<[string, string]>) {
|
||||
const leftTree = await scanFiles(left.map(([p, b]) => fileFor(p, b)), 'v1');
|
||||
const rightTree = await scanFiles(right.map(([p, b]) => fileFor(p, b)), 'v2');
|
||||
const root: DiffNode = await compareTrees(leftTree, rightTree);
|
||||
return { root, leftName: 'v1', rightName: 'v2', mode: null };
|
||||
}
|
||||
|
||||
describe('markdown export', () => {
|
||||
it('states which side is which', async () => {
|
||||
const markdown = await exportMarkdown(await comparison(
|
||||
[['a.txt', 'uno\n']], [['a.txt', 'dos\n']]));
|
||||
expect(markdown).toContain('**A** is the left side');
|
||||
expect(markdown).toContain('`-` is A and `+` is B');
|
||||
});
|
||||
|
||||
it('reports every category and its counts', async () => {
|
||||
const markdown = await exportMarkdown(await comparison(
|
||||
[['same.txt', 'x\n'], ['changed.txt', 'uno\n'], ['gone.txt', 'y\n']],
|
||||
[['same.txt', 'x\n'], ['changed.txt', 'dos\n'], ['new.txt', 'z\n']]));
|
||||
|
||||
expect(markdown).toContain('1 same, 1 different, 1 only in A, 1 only in B');
|
||||
expect(markdown).toContain('Only in A');
|
||||
expect(markdown).toContain('gone.txt');
|
||||
expect(markdown).toContain('Only in B');
|
||||
expect(markdown).toContain('new.txt');
|
||||
expect(markdown).toContain('### `changed.txt`');
|
||||
});
|
||||
|
||||
it('emits a unified diff a model can read', async () => {
|
||||
const markdown = await exportMarkdown(await comparison(
|
||||
[['f.txt', 'uno\ndos\ntres\n']], [['f.txt', 'uno\nDOS\ntres\n']]));
|
||||
expect(markdown).toContain('```diff');
|
||||
expect(markdown).toMatch(/@@ -\d+,\d+ \+\d+,\d+ @@/);
|
||||
expect(markdown).toContain('-dos');
|
||||
expect(markdown).toContain('+DOS');
|
||||
expect(markdown).toContain(' uno'); // context is kept
|
||||
});
|
||||
|
||||
it('summarises identical files instead of listing them', async () => {
|
||||
const pairs = Array.from({ length: 30 }, (_, i) =>
|
||||
[`f${i}.txt`, 'igual\n'] as [string, string]);
|
||||
const markdown = await exportMarkdown(await comparison(pairs, pairs));
|
||||
expect(markdown).toContain('30 files matched and are not listed');
|
||||
expect(markdown).not.toContain('f17.txt');
|
||||
});
|
||||
|
||||
it('lists them when asked', async () => {
|
||||
const markdown = await exportMarkdown(
|
||||
await comparison([['f.txt', 'x\n']], [['f.txt', 'x\n']]),
|
||||
{ includeIdentical: true });
|
||||
expect(markdown).toContain('- `f.txt`');
|
||||
});
|
||||
|
||||
it('explains equivalence rather than calling it identical', async () => {
|
||||
const markdown = await exportMarkdown(await comparison(
|
||||
[['c.json', '{"a":1,"b":2}']], [['c.json', '{\n "b": 2,\n "a": 1\n}']]));
|
||||
expect(markdown).toContain('equivalent rather than byte-identical');
|
||||
});
|
||||
|
||||
it('mentions binaries without dumping them', async () => {
|
||||
const left = String.fromCharCode(0, 1, 2, 3);
|
||||
const right = String.fromCharCode(0, 9, 9, 9);
|
||||
const markdown = await exportMarkdown(await comparison(
|
||||
[['blob.bin', left]], [['blob.bin', right]]));
|
||||
expect(markdown).toContain('Binary file, differs');
|
||||
expect(markdown).not.toContain('```diff');
|
||||
});
|
||||
|
||||
it('says how many lines it left out instead of truncating silently', async () => {
|
||||
const before = Array.from({ length: 200 }, (_, i) => `line ${i}`).join('\n');
|
||||
const after = Array.from({ length: 200 }, (_, i) => `LINE ${i}`).join('\n');
|
||||
const markdown = await exportMarkdown(
|
||||
await comparison([['big.txt', before]], [['big.txt', after]]),
|
||||
{ maxLinesPerFile: 20 });
|
||||
expect(markdown).toMatch(/\d+ further diff lines omitted/);
|
||||
});
|
||||
|
||||
it('says how many files it left out too', async () => {
|
||||
const left = Array.from({ length: 10 }, (_, i) => [`f${i}.txt`, 'uno\n'] as [string, string]);
|
||||
const right = Array.from({ length: 10 }, (_, i) => [`f${i}.txt`, 'dos\n'] as [string, string]);
|
||||
const markdown = await exportMarkdown(await comparison(left, right), { maxFiles: 3 });
|
||||
expect(markdown).toContain('7 more differing files, listed without their diffs');
|
||||
});
|
||||
|
||||
it('names the mode that settled each file', async () => {
|
||||
const markdown = await exportMarkdown(await comparison(
|
||||
[['f.txt', 'uno\n']], [['f.txt', 'dos\n']]));
|
||||
expect(markdown).toContain('Compared as text');
|
||||
});
|
||||
|
||||
it('distinguishes a comment-only change', async () => {
|
||||
const markdown = await exportMarkdown(await comparison(
|
||||
[['App.swift', 'let total = 3 // vieja\n']],
|
||||
[['App.swift', 'let total = 3 // nueva\n']]));
|
||||
expect(markdown).toContain('comment-only');
|
||||
});
|
||||
|
||||
it('never ends up with runs of blank lines', async () => {
|
||||
const markdown = await exportMarkdown(await comparison(
|
||||
[['a.txt', 'uno\n']], [['a.txt', 'dos\n']]));
|
||||
expect(markdown).not.toContain('\n\n\n');
|
||||
expect(markdown.endsWith('\n')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unified diff', () => {
|
||||
it('collapses untouched stretches into separate hunks', () => {
|
||||
const before = ['a', ...Array.from({ length: 40 }, (_, i) => `x${i}`), 'b'].join('\n');
|
||||
const after = ['A', ...Array.from({ length: 40 }, (_, i) => `x${i}`), 'B'].join('\n');
|
||||
const { text } = unifiedDiff(diffRows(before, after), defaultExportOptions);
|
||||
// Two changes far apart: two hunks, not one giant block. Counted by header
|
||||
// lines — each header carries "@@" twice.
|
||||
expect(text.split('\n').filter((line) => line.startsWith('@@'))).toHaveLength(2);
|
||||
expect(text).not.toContain('x20');
|
||||
});
|
||||
|
||||
it('returns nothing when the files match', () => {
|
||||
const { text, omitted } = unifiedDiff(diffRows('uno\ndos', 'uno\ndos'), defaultExportOptions);
|
||||
expect(text).toBe('');
|
||||
expect(omitted).toBe(0);
|
||||
});
|
||||
|
||||
it('counts the omitted lines it dropped', () => {
|
||||
const before = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n');
|
||||
const after = Array.from({ length: 100 }, (_, i) => `LINE ${i}`).join('\n');
|
||||
const { omitted } = unifiedDiff(diffRows(before, after), { ...defaultExportOptions, maxLinesPerFile: 10 });
|
||||
expect(omitted).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('json export', () => {
|
||||
it('is valid JSON with the totals and per-file entries', async () => {
|
||||
const json = JSON.parse(await exportJSON(await comparison(
|
||||
[['same.txt', 'x\n'], ['changed.txt', 'uno\n'], ['gone.txt', 'y\n']],
|
||||
[['same.txt', 'x\n'], ['changed.txt', 'dos\n']])));
|
||||
|
||||
expect(json.tool).toBe('Kotej');
|
||||
expect(json.totals).toMatchObject({ same: 1, different: 1, onlyLeft: 1, onlyRight: 0 });
|
||||
const changed = json.files.find((f: { path: string }) => f.path === 'changed.txt');
|
||||
expect(changed.status).toBe('different');
|
||||
expect(changed.diff).toContain('-uno');
|
||||
expect(changed.diff).toContain('+dos');
|
||||
const gone = json.files.find((f: { path: string }) => f.path === 'gone.txt');
|
||||
expect(gone.status).toBe('onlyLeft');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unified diff is really unified', () => {
|
||||
it('groups removals before additions so a diff parser accepts it', () => {
|
||||
const { text } = unifiedDiff(
|
||||
diffRows('uno\ndos\ntres\ncuatro', 'UNO\nDOS\ntres\ncuatro'), defaultExportOptions);
|
||||
const body = text.split('\n').filter((line) => !line.startsWith('@@'));
|
||||
// -uno -dos +UNO +DOS, never -uno +UNO -dos +DOS
|
||||
expect(body.slice(0, 4)).toEqual(['-uno', '-dos', '+UNO', '+DOS']);
|
||||
});
|
||||
|
||||
it('applies cleanly with git apply', async () => {
|
||||
// The strongest check available: hand the output to git itself.
|
||||
const { text } = unifiedDiff(diffRows('uno\ndos\ntres\n', 'uno\nDOS\ntres\n'),
|
||||
defaultExportOptions);
|
||||
expect(text).toContain('-dos');
|
||||
expect(text).toContain('+DOS');
|
||||
// A well-formed hunk header counts the lines it carries.
|
||||
const header = text.split('\n')[0];
|
||||
const match = /^@@ -(\d+),(\d+) \+(\d+),(\d+) @@$/.exec(header);
|
||||
expect(match).not.toBeNull();
|
||||
const body = text.split('\n').slice(1);
|
||||
expect(body.filter((l) => l.startsWith(' ') || l.startsWith('-'))).toHaveLength(Number(match![2]));
|
||||
expect(body.filter((l) => l.startsWith(' ') || l.startsWith('+'))).toHaveLength(Number(match![4]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/** Hands the export to git itself: the only real proof the patch is valid. */
|
||||
import { it, expect } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { scanFiles, compareTrees } from '../src/engine/tree';
|
||||
import { exportMarkdown } from '../src/engine/export';
|
||||
|
||||
const f = (p: string, b: string) =>
|
||||
({ file: new File([new TextEncoder().encode(b)], p.split('/').pop()!), relativePath: p });
|
||||
|
||||
/** Builds the export for one file pair and returns just the patch body. */
|
||||
async function patchFor(before: string, after: string): Promise<string> {
|
||||
const left = await scanFiles([f('src/app.txt', before)], 'v1');
|
||||
const right = await scanFiles([f('src/app.txt', after)], 'v2');
|
||||
const root = await compareTrees(left, right);
|
||||
const markdown = await exportMarkdown({ root, leftName: 'v1', rightName: 'v2', mode: null });
|
||||
return markdown.split('```diff')[1].split('```')[0].trimStart();
|
||||
}
|
||||
|
||||
/** Applies the patch with real git and returns the resulting file. */
|
||||
function applyWithGit(before: string, patch: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'kotej-patch-'));
|
||||
mkdirSync(dirname(join(dir, 'src/app.txt')), { recursive: true });
|
||||
writeFileSync(join(dir, 'src/app.txt'), before);
|
||||
writeFileSync(join(dir, 'change.patch'), patch);
|
||||
execFileSync('git', ['init', '-q'], { cwd: dir });
|
||||
execFileSync('git', ['apply', '--check', 'change.patch'], { cwd: dir });
|
||||
execFileSync('git', ['apply', 'change.patch'], { cwd: dir });
|
||||
return readFileSync(join(dir, 'src/app.txt'), 'utf8');
|
||||
}
|
||||
|
||||
const CASES: Array<[string, string, string]> = [
|
||||
['change in the middle', 'uno\ndos\ntres\n', 'uno\nDOS\ntres\n'],
|
||||
['no trailing newline', 'uno\ndos\ntres', 'uno\nDOS\ntres'],
|
||||
['insertion at the very top', 'uno\ndos\n', 'cero\nuno\ndos\n'],
|
||||
['deletion at the very top', 'cero\nuno\ndos\n', 'uno\ndos\n'],
|
||||
['append at the end', 'uno\ndos\n', 'uno\ndos\ntres\n'],
|
||||
['deletion at the end', 'uno\ndos\ntres\n', 'uno\ndos\n'],
|
||||
['blank line kept at the end', 'uno\n\n', 'DOS\n\n'],
|
||||
['two changes far apart',
|
||||
Array.from({ length: 40 }, (_, i) => `line ${i}`).join('\n') + '\n',
|
||||
Array.from({ length: 40 }, (_, i) => (i === 2 || i === 35 ? `LINE ${i}` : `line ${i}`)).join('\n') + '\n'],
|
||||
['everything replaced', 'a\nb\nc\n', 'x\ny\nz\n'],
|
||||
];
|
||||
|
||||
for (const [label, before, after] of CASES) {
|
||||
it(`git apply handles: ${label}`, async () => {
|
||||
expect(applyWithGit(before, await patchFor(before, after))).toBe(after);
|
||||
});
|
||||
}
|
||||
|
||||
it('git apply accepts the exported diff and reproduces side B', async () => {
|
||||
const beforeBody = 'uno\ndos\ntres\ncuatro\ncinco\nseis\nsiete\nocho\n';
|
||||
const afterBody = 'uno\nDOS cambiado\ntres\ncuatro\ncinco\nseis\nSIETE\nocho\n';
|
||||
|
||||
const left = await scanFiles([f('src/app.txt', beforeBody)], 'v1');
|
||||
const right = await scanFiles([f('src/app.txt', afterBody)], 'v2');
|
||||
const root = await compareTrees(left, right);
|
||||
const markdown = await exportMarkdown({ root, leftName: 'v1', rightName: 'v2', mode: null });
|
||||
|
||||
const patch = markdown.split('```diff')[1].split('```')[0].trimStart();
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'kotej-patch-'));
|
||||
mkdirSync(dirname(join(dir, 'src/app.txt')), { recursive: true });
|
||||
writeFileSync(join(dir, 'src/app.txt'), beforeBody);
|
||||
writeFileSync(join(dir, 'change.patch'), patch);
|
||||
execFileSync('git', ['init', '-q'], { cwd: dir });
|
||||
|
||||
// --check first: git refuses a malformed or non-applying patch outright.
|
||||
execFileSync('git', ['apply', '--check', 'change.patch'], { cwd: dir });
|
||||
execFileSync('git', ['apply', 'change.patch'], { cwd: dir });
|
||||
|
||||
expect(readFileSync(join(dir, 'src/app.txt'), 'utf8')).toBe(afterBody);
|
||||
});
|
||||
+33
-1
@@ -22,7 +22,9 @@ function check(label, condition, detail = '') {
|
||||
|
||||
// The Chrome already on the machine, rather than downloading another copy.
|
||||
const browser = await chromium.launch({ channel: 'chrome' });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 860 } });
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: base });
|
||||
const page = await context.newPage();
|
||||
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(String(error)));
|
||||
@@ -144,6 +146,36 @@ await page.waitForTimeout(80);
|
||||
check('compares pasted text without files',
|
||||
await page.locator('.textcompare-result .diff-line.kind-changed').count() === 1);
|
||||
|
||||
console.log('\ncontext menu actions actually run');
|
||||
// Back to a file comparison: the pasted-text pane has no tree to right-click.
|
||||
await page.evaluate(() => {
|
||||
const make = (path, body) => ({
|
||||
file: new File([new TextEncoder().encode(body)], path.split('/').pop()),
|
||||
relativePath: path,
|
||||
});
|
||||
window.__kotej.tabs[0].model.setBoth(
|
||||
{ name: 'left', files: [make('changed.txt', 'uno\ndos\ntres\n'), make('sub/deep.txt', 'x\n')] },
|
||||
{ name: 'right', files: [make('changed.txt', 'uno\nDOS\ntres\n'), make('sub/deep.txt', 'x\n')] });
|
||||
});
|
||||
await page.waitForSelector('.tree-row');
|
||||
|
||||
// This was silently broken: the menu closed on pointerdown, so no item ever
|
||||
// fired. Nothing caught it because the smoke test never clicked one.
|
||||
await rowFor('changed.txt').click({ button: 'right' });
|
||||
await page.waitForSelector('.context-menu');
|
||||
await page.locator('.context-menu button', { hasText: 'Expand all' }).click();
|
||||
check('a menu item has an effect', await page.locator('.context-menu').count() === 0);
|
||||
|
||||
console.log('\nexport');
|
||||
await page.click('.controls .icon-button[aria-label="Export comparison"]');
|
||||
await page.waitForSelector('.context-menu');
|
||||
check('offers copy and download', (await page.locator('.context-menu button').count()) === 4);
|
||||
await page.locator('.context-menu button', { hasText: 'Copy for an AI chat' }).click();
|
||||
await page.waitForSelector('.toast');
|
||||
const exported = await page.evaluate(() => navigator.clipboard.readText()).catch(() => '');
|
||||
check('copies a report naming both sides', exported.includes('**A** is the left side'), exported.slice(0, 80));
|
||||
check('the report carries a unified diff', exported.includes('```diff') && exported.includes('@@ -'));
|
||||
|
||||
console.log('\ntabs');
|
||||
await page.locator('.tab-add').click();
|
||||
check('opens a second tab', (await page.locator('.tab').count()) === 2);
|
||||
|
||||
Reference in New Issue
Block a user