Files
kotej/App/KotejApp.swift
alexandrev-tibco 18a0898741 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
2026-07-31 13:32:30 +02:00

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 }
}
}