f85c746af0
"Cotejo" means nothing outside Spanish and is hard to pronounce elsewhere, so the project is now Kotej: same root, but it reads and sounds the same in English and Spanish. Renamed throughout — engine, targets, bundle ids and the URL scheme, which is now kotej://compare. Icon generated with Codex: two facing panels split by a seam, content lines on each side and one amber line marking a difference — the tool's job at a glance, still legible at 16pt. 34 tests still green; kotej:// verified end to end against the demo JARs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
118 lines
4.2 KiB
Swift
118 lines
4.2 KiB
Swift
import SwiftUI
|
|
import KotejEngine
|
|
|
|
/// A row ready to render: the filter is applied up front so the outline can use a
|
|
/// plain key path for its children.
|
|
struct DisplayNode: Identifiable {
|
|
let id: String
|
|
let node: DiffNode
|
|
let children: [DisplayNode]?
|
|
|
|
/// Builds the display tree, optionally keeping only what differs. Leaves get
|
|
/// nil children so SwiftUI doesn't draw a disclosure triangle on files.
|
|
static func build(_ node: DiffNode, onlyDifferences: Bool) -> DisplayNode? {
|
|
if onlyDifferences && !node.status.isDifference && !node.isDirectory { return nil }
|
|
|
|
guard node.isDirectory else {
|
|
return DisplayNode(id: node.path, node: node, children: nil)
|
|
}
|
|
let kids = node.children.compactMap { build($0, onlyDifferences: onlyDifferences) }
|
|
// Hide folders that end up empty under the filter.
|
|
if onlyDifferences && kids.isEmpty { return nil }
|
|
return DisplayNode(id: node.path.isEmpty ? "__root__" : node.path, node: node, children: kids)
|
|
}
|
|
}
|
|
|
|
/// The side-by-side tree. Each row shows a path with its verdict; archives appear
|
|
/// as folders.
|
|
struct DiffTreeView: View {
|
|
@Bindable var model: ComparisonModel
|
|
let root: DiffNode
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
Divider()
|
|
List(rows, children: \.children, selection: selectionBinding) { row in
|
|
DiffRow(node: row.node)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { select(row.node) }
|
|
}
|
|
.listStyle(.inset)
|
|
}
|
|
}
|
|
|
|
private var rows: [DisplayNode] {
|
|
DisplayNode.build(root, onlyDifferences: model.showOnlyDifferences)?.children ?? []
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 0) {
|
|
Group {
|
|
if let name = model.leftURL?.lastPathComponent { Text(verbatim: name) } else { Text("Left") }
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
Group {
|
|
if let name = model.rightURL?.lastPathComponent { Text(verbatim: name) } else { Text("Right") }
|
|
}
|
|
.frame(width: 220, alignment: .leading)
|
|
Text("Status").frame(width: 92, alignment: .trailing)
|
|
}
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 5)
|
|
}
|
|
|
|
private var selectionBinding: Binding<DisplayNode.ID?> {
|
|
Binding(get: { model.selection?.path }, set: { _ in })
|
|
}
|
|
|
|
private func select(_ node: DiffNode) {
|
|
model.selection = node
|
|
if node.status == .pending { model.resolvePending(node) }
|
|
}
|
|
}
|
|
|
|
/// One row: name, the other side, and the verdict.
|
|
struct DiffRow: View {
|
|
let node: DiffNode
|
|
|
|
var body: some View {
|
|
HStack(spacing: 0) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: icon)
|
|
.foregroundStyle(node.isArchive ? Color.orange : Color.secondary)
|
|
.frame(width: 16)
|
|
Text(verbatim: node.name)
|
|
.foregroundStyle(node.status == .onlyRight ? .secondary : .primary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
Text(verbatim: node.right == nil ? "—" : node.name)
|
|
.foregroundStyle(node.status == .onlyLeft ? .secondary : .primary)
|
|
.frame(width: 220, alignment: .leading)
|
|
|
|
statusTag.frame(width: 92, alignment: .trailing)
|
|
}
|
|
.lineLimit(1)
|
|
.padding(.vertical, 1)
|
|
}
|
|
|
|
private var icon: String {
|
|
if node.isArchive { return "shippingbox.fill" }
|
|
return node.isDirectory ? "folder.fill" : "doc"
|
|
}
|
|
|
|
@ViewBuilder private var statusTag: some View {
|
|
switch node.status {
|
|
case .identical: Text("same").foregroundStyle(.secondary)
|
|
case .equivalent: Text("equivalent").foregroundStyle(.green)
|
|
case .different: Text("different").foregroundStyle(.orange)
|
|
case .onlyLeft: Text("only left").foregroundStyle(.blue)
|
|
case .onlyRight: Text("only right").foregroundStyle(.purple)
|
|
case .pending: Text("pending").foregroundStyle(.gray)
|
|
}
|
|
}
|
|
}
|