18a0898741
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
162 lines
6.1 KiB
Swift
162 lines
6.1 KiB
Swift
import SwiftUI
|
|
import KotejEngine
|
|
|
|
@main
|
|
struct KotejApp: App {
|
|
@State private var tabs = TabsModel()
|
|
|
|
var body: some Scene {
|
|
// A single window on purpose: comparisons live in tabs, so a request
|
|
// arriving from Finder joins the window you already have open instead of
|
|
// spawning another one.
|
|
Window("Kotej", id: "main") {
|
|
RootView(tabs: tabs)
|
|
.frame(minWidth: 900, minHeight: 560)
|
|
// kotej://compare?left=…&right=… — how the Finder extension asks
|
|
// for a comparison; it always lands in a tab.
|
|
.onOpenURL { url in handle(url) }
|
|
}
|
|
.commands {
|
|
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() }
|
|
.keyboardShortcut("w")
|
|
Divider()
|
|
Button("Copy left → right") { tabs.selected.copySelection(.leftToRight) }
|
|
.keyboardShortcut(.rightArrow, modifiers: [.command, .option])
|
|
.disabled(!tabs.selected.canCopySelection(.leftToRight))
|
|
Button("Copy right → left") { tabs.selected.copySelection(.rightToLeft) }
|
|
.keyboardShortcut(.leftArrow, modifiers: [.command, .option])
|
|
.disabled(!tabs.selected.canCopySelection(.rightToLeft))
|
|
}
|
|
CommandGroup(after: .toolbar) {
|
|
Button("Swap sides") { tabs.selected.swapSides() }
|
|
.keyboardShortcut("s", modifiers: [.command, .shift])
|
|
.disabled(!tabs.selected.isReady)
|
|
Button("Compare again") { tabs.selected.compare() }
|
|
.keyboardShortcut("r")
|
|
.disabled(!tabs.selected.isReady)
|
|
Divider()
|
|
Button("Next difference") { tabs.selected.selectNextDifference() }
|
|
.keyboardShortcut("g", modifiers: .command)
|
|
.disabled(tabs.selected.differenceCount == 0)
|
|
Button("Previous difference") { tabs.selected.selectPreviousDifference() }
|
|
.keyboardShortcut("g", modifiers: [.command, .shift])
|
|
.disabled(tabs.selected.differenceCount == 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handle(_ url: URL) {
|
|
guard url.scheme == "kotej", url.host == "compare",
|
|
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return }
|
|
func path(_ name: String) -> URL? {
|
|
items.first { $0.name == name }?.value.map { URL(fileURLWithPath: $0) }
|
|
}
|
|
tabs.open(left: path("left"), right: path("right"))
|
|
}
|
|
}
|
|
|
|
/// Tab strip plus the selected comparison.
|
|
struct RootView: View {
|
|
@Bindable var tabs: TabsModel
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
TabStrip(tabs: tabs)
|
|
Divider()
|
|
ContentView(model: tabs.selected,
|
|
openComparison: { left, right, newTab in
|
|
tabs.openPair(left: left, right: right, inNewTab: newTab)
|
|
})
|
|
// Rebuild the body when the tab changes so per-tab state
|
|
// (selection, loaded diff) doesn't leak across tabs.
|
|
.id(tabs.selectedID)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct TabStrip: View {
|
|
@Bindable var tabs: TabsModel
|
|
|
|
var body: some View {
|
|
HStack(spacing: 0) {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 1) {
|
|
ForEach(tabs.tabs) { tab in
|
|
TabChip(title: tab.title,
|
|
isSelected: tab.id == tabs.selectedID,
|
|
canClose: tabs.tabs.count > 1 || !tab.isPristine,
|
|
onSelect: { tabs.selectedID = tab.id },
|
|
onClose: { tabs.close(tab) })
|
|
}
|
|
}
|
|
.padding(.horizontal, 6)
|
|
}
|
|
Button {
|
|
tabs.newTab()
|
|
} label: {
|
|
Image(systemName: "plus")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.help("New tab")
|
|
.padding(.horizontal, 8)
|
|
}
|
|
.frame(height: 30)
|
|
.background(Color(nsColor: .windowBackgroundColor))
|
|
}
|
|
}
|
|
|
|
struct TabChip: View {
|
|
let title: String
|
|
let isSelected: Bool
|
|
let canClose: Bool
|
|
let onSelect: () -> Void
|
|
let onClose: () -> Void
|
|
|
|
@State private var isHovering = false
|
|
|
|
private var background: Color {
|
|
isSelected ? Color(nsColor: .controlBackgroundColor) : Color.clear
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 6) {
|
|
Text(title)
|
|
.font(.caption)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
.foregroundStyle(isSelected ? Color.primary : Color.secondary)
|
|
if canClose && (isHovering || isSelected) {
|
|
Button(action: onClose) {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 8, weight: .bold))
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.foregroundStyle(Color.secondary)
|
|
.help("Close tab")
|
|
}
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 5)
|
|
.frame(maxWidth: 220)
|
|
.background(background, in: RoundedRectangle(cornerRadius: 6))
|
|
.overlay(alignment: .bottom) {
|
|
Rectangle()
|
|
.fill(isSelected ? Color.accentColor : Color.clear)
|
|
.frame(height: 2)
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture(perform: onSelect)
|
|
.onHover { isHovering = $0 }
|
|
}
|
|
}
|