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
500 lines
18 KiB
Swift
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)"
|
|
}
|
|
}
|
|
}
|