Files
kotej/App/DiffTreeView.swift
T
alexandrev-tibco f85c746af0 Rename to Kotej and add the app icon
"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
2026-07-27 17:24:05 +02:00

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