Files
kotej/App/ContentView.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

500 lines
18 KiB
Swift

import SwiftUI
import UniformTypeIdentifiers
import KotejEngine
struct ContentView: View {
@Bindable var model: ComparisonModel
/// Opens an arbitrary pair (left, right, inNewTab); provided by RootView.
var openComparison: ((URL, URL, Bool) -> Void)?
/// How much of the height the tree takes; dragging the divider keeps it.
@AppStorage("kotej.splitFraction") private var splitFraction: Double = 0.45
var body: some View {
VStack(spacing: 0) {
SidePickers(model: model)
Divider()
ControlBar(model: model)
Divider()
if let message = model.errorMessage {
banner(message)
}
if let pending = model.pendingPick {
pendingBanner(pending)
}
if let notice = model.exportNotice {
noticeBanner(notice)
}
if model.isScanning {
VStack(spacing: 10) {
ProgressView()
Text("Comparing…").foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if model.root != nil {
SplitPane(fraction: splitBinding) {
DiffTreeView(model: model, openComparison: openComparison)
} bottom: {
FileDiffView(model: model)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
SummaryBar(model: model)
} else if model.showsTextCompare {
TextCompareView(model: model)
} else {
EmptyComparison(model: model)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var splitBinding: Binding<CGFloat> {
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) {
Image(systemName: "pin.fill").foregroundStyle(.tint)
Text("Marked for comparison:") + Text(verbatim: " \(pending.name)")
Spacer()
Button("Forget") { model.pendingPick = nil }
.buttonStyle(.borderless)
}
.font(.caption)
.padding(.horizontal, 12)
.padding(.vertical, 5)
.background(Color.accentColor.opacity(0.1))
}
private func banner(_ text: String) -> some View {
Label(text, systemImage: "exclamationmark.triangle.fill")
.font(.callout)
.foregroundStyle(.orange)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(Color.orange.opacity(0.1))
}
}
/// Reads every dropped item, preserving the order they were given in, so the
/// first lands on the left and the second on the right.
@MainActor
func loadDroppedURLs(_ providers: [NSItemProvider], completion: @escaping ([URL]) -> Void) {
var urls = [URL?](repeating: nil, count: providers.count)
let group = DispatchGroup()
for (index, provider) in providers.enumerated() {
group.enter()
_ = provider.loadObject(ofClass: URL.self) { url, _ in
urls[index] = url
group.leave()
}
}
group.notify(queue: .main) { completion(urls.compactMap { $0 }) }
}
// MARK: - Sides
struct SidePickers: View {
@Bindable var model: ComparisonModel
var body: some View {
HStack(spacing: 10) {
SideField(title: "Left", url: model.leftURL,
onPick: { model.setSide(.left, to: $0) },
onDrop: { model.acceptDrop($0, preferring: .left) })
Button { model.swapSides() } label: { Image(systemName: "arrow.left.arrow.right") }
.help("Swap sides")
.disabled(!model.isReady)
SideField(title: "Right", url: model.rightURL,
onPick: { model.setSide(.right, to: $0) },
onDrop: { model.acceptDrop($0, preferring: .right) })
Button { model.compare() } label: { Image(systemName: "arrow.clockwise") }
.help("Compare again")
.disabled(!model.isReady)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
}
/// Search, filter, comparison mode, options and difference navigation.
struct ControlBar: View {
@Bindable var model: ComparisonModel
@State private var showOptions = false
var body: some View {
HStack(spacing: 10) {
searchField
filterPicker
modePicker
Button { showOptions.toggle() } label: { Image(systemName: "slider.horizontal.3") }
.help("Comparison options")
.popover(isPresented: $showOptions, arrowEdge: .bottom) {
TextOptionsView(model: model)
}
Spacer()
exportButton
copyButtons
Divider().frame(height: 16)
navigation
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.bar)
}
private var searchField: some View {
HStack(spacing: 5) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary).font(.caption)
TextField("Filter by name", text: $model.searchText)
.textFieldStyle(.plain)
.font(.callout)
if !model.searchText.isEmpty {
Button { model.searchText = "" } label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
}
.buttonStyle(.borderless)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.frame(width: 220)
.background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color.secondary.opacity(0.25)))
}
private var filterPicker: some View {
Picker("", selection: $model.filter) {
ForEach(RowFilter.allCases) { option in Text(option.label).tag(option) }
}
.frame(width: 180)
.help("Which rows the tree shows")
}
private var modeBinding: Binding<ComparisonMode?> {
Binding(get: { model.mode },
set: { model.mode = $0; if model.isReady { model.compare() } })
}
private var modePicker: some View {
Picker("", selection: modeBinding) {
Text("Automatic").tag(ComparisonMode?.none)
ForEach(ComparisonMode.allCases, id: \.self) { mode in
Text(mode.localizedName).tag(ComparisonMode?.some(mode))
}
}
.frame(width: 175)
.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) {
Button { model.copySelection(.rightToLeft) } label: {
Image(systemName: "arrow.left.circle")
}
.help("Copy right → left")
.disabled(!model.canCopySelection(.rightToLeft))
Button { model.copySelection(.leftToRight) } label: {
Image(systemName: "arrow.right.circle")
}
.help("Copy left → right")
.disabled(!model.canCopySelection(.leftToRight))
}
}
/// Walk the differences without hunting through the tree by hand.
private var navigation: some View {
HStack(spacing: 6) {
if model.differenceCount > 0 {
Text(verbatim: positionLabel)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
Button { model.selectPreviousDifference() } label: { Image(systemName: "chevron.up") }
.help("Previous difference")
.disabled(model.differenceCount == 0)
Button { model.selectNextDifference() } label: { Image(systemName: "chevron.down") }
.help("Next difference")
.disabled(model.differenceCount == 0)
}
}
private var positionLabel: String {
if let index = model.currentDifferenceIndex {
return "\(index + 1)/\(model.differenceCount)"
}
return "—/\(model.differenceCount)"
}
}
/// Tolerances for text comparison: the engine already supported these, they just
/// had no way in.
struct TextOptionsView: View {
@Bindable var model: ComparisonModel
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Text comparison").font(.headline)
toggle("Ignore trailing whitespace", \.ignoreTrailingWhitespace)
toggle("Ignore all whitespace", \.ignoreAllWhitespace)
toggle("Ignore case", \.ignoreCase)
toggle("Ignore blank lines", \.ignoreBlankLines)
toggle("Treat CRLF and LF as equal", \.normaliseLineEndings)
Divider()
Text("Applies to text files; JSON and XML also compare structurally.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.frame(width: 280, alignment: .leading)
}
.padding(14)
}
private func toggle(_ label: LocalizedStringKey,
_ keyPath: WritableKeyPath<TextOptions, Bool>) -> some View {
Toggle(label, isOn: Binding(
get: { model.textOptions[keyPath: keyPath] },
set: { newValue in
var options = model.textOptions
options[keyPath: keyPath] = newValue
model.applyTextOptions(options)
}))
.toggleStyle(.checkbox)
}
}
/// One side: shows the chosen path, accepts a drop and offers a file picker.
struct SideField: View {
let title: LocalizedStringKey
let url: URL?
let onPick: (URL) -> Void
/// Receives every dropped item so two at once can fill both sides.
let onDrop: ([URL]) -> Void
@State private var isTargeted = false
private var iconColor: Color { url == nil ? Color.secondary : Color.accentColor }
private var textColor: Color { url == nil ? Color.secondary : Color.primary }
var body: some View {
HStack(spacing: 8) {
Image(systemName: icon)
.foregroundStyle(iconColor)
VStack(alignment: .leading, spacing: 1) {
Text(title).font(.caption2).foregroundStyle(Color.secondary)
Group {
if let name = url?.lastPathComponent {
Text(verbatim: name)
} else {
Text("Drag or choose…")
}
}
.font(.callout)
.lineLimit(1)
.truncationMode(.head)
.foregroundStyle(textColor)
}
Spacer(minLength: 4)
Button { choose() } label: { Image(systemName: "folder") }
.buttonStyle(.borderless)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
.background(isTargeted ? Color.accentColor.opacity(0.15) : Color(nsColor: .controlBackgroundColor),
in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(isTargeted ? Color.accentColor : Color.secondary.opacity(0.25),
style: StrokeStyle(lineWidth: 1, dash: url == nil ? [4] : []))
)
.onDrop(of: [.fileURL], isTargeted: $isTargeted) { providers in
loadDroppedURLs(providers) { onDrop($0) }
return true
}
.help(url?.path ?? "")
}
private var icon: String {
guard let url else { return "square.dashed" }
if url.hasDirectoryPath { return "folder.fill" }
return ZipReader.isArchive(url) ? "shippingbox.fill" : "doc.fill"
}
private func choose() {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.prompt = String(localized: "Choose")
if panel.runModal() == .OK, let picked = panel.url { onPick(picked) }
}
}
/// The first-run / cleared state: two big drop zones.
struct EmptyComparison: View {
@Bindable var model: ComparisonModel
var body: some View {
VStack(spacing: 18) {
Spacer()
Image(systemName: "rectangle.split.2x1")
.font(.system(size: 52))
.foregroundStyle(.tint)
Text("Empty comparison").font(.title2.weight(.semibold))
Text("Drag two folders, files or archives (ZIP, JAR, EAR)\nto start comparing.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
Button {
model.showsTextCompare = true
} label: {
Label("Compare pasted text instead", systemImage: "text.alignleft")
}
HStack(spacing: 14) {
DropWell(title: "Left", url: model.leftURL,
onPick: { model.setSide(.left, to: $0) },
onDrop: { model.acceptDrop($0, preferring: .left) })
DropWell(title: "Right", url: model.rightURL,
onPick: { model.setSide(.right, to: $0) },
onDrop: { model.acceptDrop($0, preferring: .right) })
}
.frame(maxWidth: 560)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
// The whole empty area is a target: dropping two files anywhere fills
// both sides without having to aim at the wells.
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
loadDroppedURLs(providers) { model.acceptDrop($0) }
return true
}
}
}
struct DropWell: View {
let title: LocalizedStringKey
let url: URL?
let onPick: (URL) -> Void
let onDrop: ([URL]) -> Void
@State private var isTargeted = false
var body: some View {
VStack(spacing: 8) {
Image(systemName: url == nil ? "plus" : "checkmark.circle.fill")
.font(.title)
.foregroundStyle(url == nil ? Color.secondary : Color.green)
Group {
if let name = url?.lastPathComponent { Text(verbatim: name) } else { Text(title) }
}
.font(.callout)
.lineLimit(1)
.truncationMode(.middle)
}
.frame(maxWidth: .infinity, minHeight: 130)
.background(isTargeted ? Color.accentColor.opacity(0.15) : Color(nsColor: .controlBackgroundColor),
in: RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(isTargeted ? Color.accentColor : Color.secondary.opacity(0.3),
style: StrokeStyle(lineWidth: 1.5, dash: [6]))
)
.onDrop(of: [.fileURL], isTargeted: $isTargeted) { providers in
loadDroppedURLs(providers) { onDrop($0) }
return true
}
.onTapGesture {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let picked = panel.url { onPick(picked) }
}
}
}
// MARK: - Summary
struct SummaryBar: View {
@Bindable var model: ComparisonModel
var body: some View {
let totals = model.totals
HStack(spacing: 14) {
counter("Same", totals.identical, .secondary)
counter("Different", totals.different, .orange)
counter("Only left", totals.onlyLeft, .blue)
counter("Only right", totals.onlyRight, .purple)
if totals.pending > 0 { counter("Unresolved", totals.pending, .gray) }
Spacer()
if totals.differences == 0 {
Label("No differences", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
}
}
.font(.caption)
.padding(.horizontal, 14)
.padding(.vertical, 6)
.background(.bar)
}
private func counter(_ label: LocalizedStringKey, _ value: Int, _ tint: Color) -> some View {
HStack(spacing: 4) {
Circle().fill(tint).frame(width: 7, height: 7)
Text(label) + Text(verbatim: ": \(value)")
}
.foregroundStyle(.secondary)
}
}
extension ComparisonMode {
/// Localised in the app: the engine stays UI-free and English.
var localizedName: LocalizedStringKey {
switch self {
case .binary: return "Byte for byte"
case .text: return "Text"
case .semantic: return "Semantic (JSON/XML)"
}
}
}