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:
@@ -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) {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user