Compare pasted text without any files

Checking two blobs of JSON meant saving them to disk first, which is friction for
the most common quick comparison there is. A tab can now be a pair of editors:
paste on each side and the diff appears underneath.

It goes through the same engine as files, so JSON and XML are still compared
structurally — the kind is detected from the pasted content itself, since there's
no filename to go on. Reachable from the empty state or cmd-shift-T.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
This commit is contained in:
alexandrev-tibco
2026-07-31 13:32:30 +02:00
parent 82525da917
commit 18a0898741
6 changed files with 173 additions and 0 deletions
+41
View File
@@ -37,6 +37,47 @@ final class ComparisonModel: Identifiable {
/// 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) {
+7
View File
@@ -38,6 +38,8 @@ struct ContentView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
SummaryBar(model: model)
} else if model.showsTextCompare {
TextCompareView(model: model)
} else {
EmptyComparison(model: model)
}
@@ -328,6 +330,11 @@ struct EmptyComparison: View {
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) { model.setSide(.left, to: $0) }
DropWell(title: "Right", url: model.rightURL) { model.setSide(.right, to: $0) }
+5
View File
@@ -20,6 +20,11 @@ struct KotejApp: App {
CommandGroup(replacing: .newItem) {
Button("New tab") { tabs.newTab() }
.keyboardShortcut("t")
Button("New text comparison") {
let tab = tabs.selected.isPristine ? tabs.selected : tabs.newTab()
tab.showsTextCompare = true
}
.keyboardShortcut("t", modifiers: [.command, .shift])
}
CommandGroup(after: .newItem) {
Button("Close tab") { tabs.closeSelected() }
+104
View File
@@ -0,0 +1,104 @@
import SwiftUI
import KotejEngine
/// Compare two snippets without touching the filesystem paste on each side and
/// see the diff. The most common quick comparison there is, and needing to save
/// files first just to check two blobs of JSON is friction.
struct TextCompareView: View {
@Bindable var model: ComparisonModel
var body: some View {
VStack(spacing: 0) {
editors
Divider()
summary
Divider()
diff
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var editors: some View {
HStack(spacing: 0) {
editor(title: "Left", text: $model.leftText)
Divider()
editor(title: "Right", text: $model.rightText)
}
.frame(minHeight: 140, idealHeight: 190)
}
private func editor(title: LocalizedStringKey, text: Binding<String>) -> some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text(title).font(.caption.weight(.semibold)).foregroundStyle(.secondary)
Spacer()
Button {
text.wrappedValue = ""
} label: {
Image(systemName: "xmark.circle").font(.caption)
}
.buttonStyle(.borderless)
.disabled(text.wrappedValue.isEmpty)
.help("Clear")
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
TextEditor(text: text)
.font(.system(.caption, design: .monospaced))
.scrollContentBackground(.hidden)
.background(Color(nsColor: .textBackgroundColor))
.overlay(alignment: .topLeading) {
if text.wrappedValue.isEmpty {
Text("Paste here…")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.tertiary)
.padding(.horizontal, 5)
.padding(.vertical, 8)
.allowsHitTesting(false)
}
}
}
.frame(maxWidth: .infinity)
}
/// The verdict, using the same engine as files: JSON and XML are compared
/// structurally here too.
private var summary: some View {
HStack(spacing: 10) {
if model.leftText.isEmpty && model.rightText.isEmpty {
Text("Paste something on each side to compare.")
.font(.caption).foregroundStyle(.secondary)
} else {
StatusBadge(status: model.textStatus)
ModeIcon(mode: model.textMode)
Text(verbatim: model.textKindLabel)
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 5)
.background(.bar)
}
@ViewBuilder private var diff: some View {
let rows = model.textRows
if rows.isEmpty {
Spacer()
Text("No differences").foregroundStyle(.secondary)
Spacer()
} else {
ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
DiffLine(row: row, wrapLines: true)
}
}
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
}
+8
View File
@@ -102,3 +102,11 @@
"Copy right → left" = "Copy right → left";
"That side lives inside an archive, so it can't be written to." = "That side lives inside an archive, so it can't be written to.";
"There's nothing to copy on that side." = "There's nothing to copy on that side.";
/* Pasted text comparison */
"Compare pasted text instead" = "Compare pasted text instead";
"New text comparison" = "New text comparison";
"Paste here…" = "Paste here…";
"Paste something on each side to compare." = "Paste something on each side to compare.";
"Clear" = "Clear";
"Binary" = "Binary";
+8
View File
@@ -102,3 +102,11 @@
"Copy right → left" = "Copiar derecha → izquierda";
"That side lives inside an archive, so it can't be written to." = "Ese lado está dentro de un archivo comprimido, no se puede escribir.";
"There's nothing to copy on that side." = "No hay nada que copiar en ese lado.";
/* Comparar texto pegado */
"Compare pasted text instead" = "Comparar texto pegado";
"New text comparison" = "Comparación de texto nueva";
"Paste here…" = "Pega aquí…";
"Paste something on each side to compare." = "Pega algo en cada lado para comparar.";
"Clear" = "Limpiar";
"Binary" = "Binario";