diff --git a/App/ComparisonModel.swift b/App/ComparisonModel.swift index 684d781..d1660d7 100644 --- a/App/ComparisonModel.swift +++ b/App/ComparisonModel.swift @@ -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")" + } } diff --git a/App/ContentView.swift b/App/ContentView.swift index 3f6fa0e..242be98 100644 --- a/App/ContentView.swift +++ b/App/ContentView.swift @@ -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) { diff --git a/Sources/KotejEngine/ComparisonExport.swift b/Sources/KotejEngine/ComparisonExport.swift new file mode 100644 index 0000000..d6767a6 --- /dev/null +++ b/Sources/KotejEngine/ComparisonExport.swift @@ -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" + } + } +} diff --git a/Tests/KotejEngineTests/ComparisonExportTests.swift b/Tests/KotejEngineTests/ComparisonExportTests.swift new file mode 100644 index 0000000..00f81ce --- /dev/null +++ b/Tests/KotejEngineTests/ComparisonExportTests.swift @@ -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")) + } +} diff --git a/web/src/engine/export.ts b/web/src/engine/export.ts new file mode 100644 index 0000000..e6f502f --- /dev/null +++ b/web/src/engine/export.ts @@ -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 = { + 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 = {}): Promise { + 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 { + 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 = {}): Promise { + const opts = { ...defaultExportOptions, ...options }; + const files = flatten(input.root); + + const entries = await Promise.all(files.map(async (file) => { + const entry: Record = { + 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); +} diff --git a/web/src/i18n.ts b/web/src/i18n.ts index cf9a149..8d7502d 100644 --- a/web/src/i18n.ts +++ b/web/src/i18n.ts @@ -110,6 +110,13 @@ const es: Record = { 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', diff --git a/web/src/styles.css b/web/src/styles.css index 66ea77b..565f7eb 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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); } } diff --git a/web/src/ui/comparison.ts b/web/src/ui/comparison.ts index 1fbcf28..a1a6203 100644 --- a/web/src/ui/comparison.ts +++ b/web/src/ui/comparison.ts @@ -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) { + 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}`; diff --git a/web/src/ui/menu.ts b/web/src/ui/menu.ts index 4d75a4e..844e229 100644 --- a/web/src/ui/menu.ts +++ b/web/src/ui/menu.ts @@ -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 }); }); } diff --git a/web/src/ui/tree.ts b/web/src/ui/tree.ts index e710e7c..368b64c 100644 --- a/web/src/ui/tree.ts +++ b/web/src/ui/tree.ts @@ -292,6 +292,7 @@ const PATHS: Record = { 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', }; diff --git a/web/tests/export.test.ts b/web/tests/export.test.ts new file mode 100644 index 0000000..6c82dc0 --- /dev/null +++ b/web/tests/export.test.ts @@ -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])); + }); +}); diff --git a/web/tests/patch.test.ts b/web/tests/patch.test.ts new file mode 100644 index 0000000..19bb55d --- /dev/null +++ b/web/tests/patch.test.ts @@ -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 { + 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); +}); diff --git a/web/tests/smoke.mjs b/web/tests/smoke.mjs index 54a34e8..d69f194 100644 --- a/web/tests/smoke.mjs +++ b/web/tests/smoke.mjs @@ -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);