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:
alexandrev-tibco
2026-08-02 09:19:59 +02:00
parent 43ab3a6c04
commit 83d61df13b
13 changed files with 1490 additions and 3 deletions
+63
View File
@@ -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")"
}
}
+36
View File
@@ -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) {
+373
View File
@@ -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"))
}
}
+353
View File
@@ -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);
}
+7
View File
@@ -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',
+17
View File
@@ -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); } }
+86 -1
View File
@@ -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
View File
@@ -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 });
});
}
+1
View File
@@ -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',
};
+186
View File
@@ -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]));
});
});
+76
View File
@@ -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
View File
@@ -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);