Files
kotej/App/ComparisonModel.swift
alexandrev-tibco 83d61df13b 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
2026-08-02 09:19:59 +02:00

348 lines
13 KiB
Swift

import Foundation
import Observation
import AppKit
import UniformTypeIdentifiers
import KotejEngine
/// Drives one comparison: what's on each side, the resulting tree, and the
/// options. Scanning runs off the main actor so big trees don't freeze the UI.
@MainActor
@Observable
final class ComparisonModel: Identifiable {
let id = UUID()
var leftURL: URL?
var rightURL: URL?
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?
var textOptions = TextOptions()
/// Which rows the tree shows: everything, only differences, only orphans on
/// one side Reviewing a big comparison means slicing it.
var filter: RowFilter = .differences
/// Free-text name filter applied on top of `filter`.
var searchText = ""
/// Rows currently shown, flattened in display order. Kept so "next/previous
/// difference" can walk them without the view rebuilding the tree.
private(set) var visibleRows: [DiffNode] = []
/// Which folders are open. Held here rather than by List's outline so both
/// sides of a row can carry a disclosure control.
private var expanded: Set<String> = []
/// Stamps each scan so a stale one can't overwrite a newer result.
private var scanGeneration = 0
/// One side of a manual pairing, held while the other is chosen. Lets two
/// files with different names be compared, which the tree alone can't express.
var pendingPick: PendingPick?
// MARK: Pasted text
/// A comparison doesn't have to involve files: paste on both sides instead.
/// Shows the paste-two-snippets view instead of the drop targets.
var showsTextCompare = false
var leftText = ""
var rightText = ""
private var textData: (Data, Data) { (Data(leftText.utf8), Data(rightText.utf8)) }
/// Detected from the content itself, since pasted text has no filename.
var textKind: ContentKind {
ContentKind.detect(path: "", data: Data(leftText.utf8))
}
var textKindLabel: String {
switch textKind {
case .json: return "JSON"
case .xml: return "XML"
case .text: return String(localized: "Text")
case .binary: return String(localized: "Binary")
}
}
var textMode: ComparisonMode { mode ?? textKind.defaultMode }
var textStatus: DiffStatus {
let (l, r) = textData
guard !(leftText.isEmpty && rightText.isEmpty) else { return .identical }
switch ContentComparer.compare(l, r, mode: textMode, kind: textKind, textOptions: textOptions) {
case .equal: return .identical
case .equivalent: return .equivalent
case .different, .unsupported: return .different
}
}
var textRows: [TextDiff.Row] {
guard leftText != rightText else { return [] }
return TextDiff.rows(left: leftText, right: rightText, syntax: nil)
}
func isExpanded(_ id: String) -> Bool { expanded.contains(id) }
func toggleExpansion(_ id: String) {
if expanded.contains(id) { expanded.remove(id) } else { expanded.insert(id) }
}
func collapseAll() { expanded.removeAll() }
var selection: DiffNode?
var isReady: Bool { leftURL != nil && rightURL != nil }
/// Nothing chosen yet, so this tab can be reused instead of opening another.
var isPristine: Bool { leftURL == nil && rightURL == nil }
/// Tab label: both sides when known, otherwise a placeholder.
var title: String {
switch (leftURL, rightURL) {
case let (left?, right?):
return "\(left.lastPathComponent)\(right.lastPathComponent)"
case let (left?, nil):
return left.lastPathComponent
case let (nil, right?):
return right.lastPathComponent
default:
return String(localized: "New comparison")
}
}
var totals: DiffNode.Totals { root?.totals() ?? DiffNode.Totals() }
/// Records what the tree is showing so navigation and the counter agree with
/// what's on screen.
func setVisibleRows(_ rows: [DiffNode]) { visibleRows = rows }
/// Files (not folders) currently shown that count as a difference.
private var navigableRows: [DiffNode] {
visibleRows.filter { !$0.isDirectory && $0.status.isDifference }
}
var differenceCount: Int { navigableRows.count }
var currentDifferenceIndex: Int? {
guard let selection else { return nil }
return navigableRows.firstIndex { $0.path == selection.path }
}
func selectNextDifference() { step(by: 1) }
func selectPreviousDifference() { step(by: -1) }
private func step(by delta: Int) {
let rows = navigableRows
guard !rows.isEmpty else { return }
let next: Int
if let current = currentDifferenceIndex {
next = (current + delta + rows.count) % rows.count
} else {
next = delta > 0 ? 0 : rows.count - 1
}
selection = rows[next]
if selection?.status == .pending, let node = selection { resolvePending(node) }
}
/// Copies the selected row across and re-compares, so the tree reflects it.
func copySelection(_ direction: FileSync.Direction) {
guard let node = selection else { return }
do {
try FileSync.copy(node, direction, leftRoot: leftURL, rightRoot: rightURL)
compare()
} catch {
errorMessage = error.localizedDescription
}
}
func canCopySelection(_ direction: FileSync.Direction) -> Bool {
guard let node = selection else { return false }
return FileSync.canCopy(node, direction, leftRoot: leftURL, rightRoot: rightURL)
}
/// Re-runs the comparison when the text tolerances change.
func applyTextOptions(_ newValue: TextOptions) {
guard newValue != textOptions else { return }
textOptions = newValue
if isReady { compare() }
}
/// Sets both sides at once. Doing it with two `setSide` calls starts a
/// comparison against the *old* other side, and since scans run concurrently
/// that stale result can land last and win which showed up as one side
/// zooming in while the other kept its previous contents.
func setBoth(left: URL, right: URL) {
leftURL = left
rightURL = right
selection = nil
compare()
}
func setSide(_ side: Side, to url: URL) {
switch side {
case .left: leftURL = url
case .right: rightURL = url
}
selection = nil
if isReady { compare() }
}
enum Side { case left, right }
/// Handles a drop of any size.
///
/// Two items at once fill both sides in the order they were dropped, which is
/// the quickest way to start. A single item goes to the side it was dropped
/// on, or when dropped anywhere else to the first free side, so dropping
/// one file then another just works.
func acceptDrop(_ urls: [URL], preferring side: Side? = nil) {
guard !urls.isEmpty else { return }
if urls.count >= 2 {
setBoth(left: urls[0], right: urls[1])
return
}
let target: Side
if let side {
target = side
} else if leftURL == nil {
target = .left
} else if rightURL == nil {
target = .right
} else {
target = .left
}
setSide(target, to: urls[0])
}
func swapSides() {
swap(&leftURL, &rightURL)
selection = nil
if isReady { compare() }
}
/// Opens every folder currently shown.
func expandAll(from root: Any?) {
guard let root = root as? DisplayNodeExpanding else { return }
expanded.formUnion(root.expandableIDs())
}
func clear() {
leftURL = nil
rightURL = nil
root = nil
selection = nil
errorMessage = nil
visibleRows = []
searchText = ""
expanded.removeAll()
pendingPick = nil
}
func compare() {
guard let leftURL, let rightURL else { return }
isScanning = true
errorMessage = nil
selection = nil
// Scans run off the main actor and a slow one can finish after a newer
// one; stamping each run means only the latest is allowed to land.
scanGeneration &+= 1
let generation = scanGeneration
let options = DirectoryComparer.Options(mode: mode, textOptions: textOptions)
Task.detached(priority: .userInitiated) {
do {
let left = try TreeScanner.scan(leftURL)
let right = try TreeScanner.scan(rightURL)
let diff = DirectoryComparer.compare(left: left, right: right, options: options)
await MainActor.run {
guard generation == self.scanGeneration else { return }
self.root = diff
self.isScanning = false
}
} catch {
await MainActor.run {
guard generation == self.scanGeneration else { return }
self.errorMessage = error.localizedDescription
self.isScanning = false
self.root = nil
}
}
}
}
/// Resolves a row that the cheap pass left undecided (large files in lazy mode).
func resolvePending(_ node: DiffNode) {
guard node.status == .pending, let left = node.left, let right = node.right else { return }
let options = DirectoryComparer.Options(mode: mode, textOptions: textOptions)
Task.detached(priority: .userInitiated) {
let status = DirectoryComparer.resolve(left: left, right: right, options: options)
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")"
}
}