83d61df13b
Pedido: poder llevar la comparativa a una sesión abierta con un modelo para que la use. Eso, y no un informe para imprimir, decide el formato: los ficheros iguales se resumen en una linea en vez de listarse, los binarios solo se mencionan, y las diferencias van en diff unificado, que los modelos leen de forma nativa. Lo que se omite se dice en voz alta — un informe que se deja la mitad en silencio es peor que ninguno, porque se lee como completo. Las cabeceras `--- a/ +++ b/` cuestan dos lineas y convierten cada bloque en un parche de verdad, asi que el modelo puede devolverlo por `git apply` en vez de reescribir el cambio a mano. Que lo sea de verdad esta comprobado ejecutando git sobre la salida en 10 escenarios (sin salto final, insercion al principio, borrado al final...); dos fallos aparecieron asi: interlineaba `-` y `+` en vez de agrupar, y contaba la linea fantasma que deja el ultimo `\n`. Implementado en los dos motores (Swift y TypeScript) con la misma semantica, y verificado que producen el mismo texto **byte a byte** para un mismo caso. Tambien hay salida JSON, para alimentar una herramienta en vez de una charla. UI en las dos apps: copiar al portapapeles primero (que es el gesto real), y guardar/descargar despues. En web, si el portapapeles se niega, se descarga y se avisa: un fallo silencioso ahi acaba en pegar contenido viejo sin saberlo. De paso, un bug que esto destapó: el menu contextual de la web se cerraba en `pointerdown`, quitando los botones antes de que su click llegara — ninguna accion del menu funcionaba (tampoco "Set as base" ni "Expand all"). El smoke test nunca habia pulsado una; ahora si. Closes #9 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hEAYuHRKMYz9sSa9zmbzz
348 lines
13 KiB
Swift
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")"
|
|
}
|
|
}
|