Files
kotej/App/DiffTreeView.swift
T
alexandrev-tibco 8e4a3e5f62 Fix Set as base leaving one side on its old contents
Setting a new base called setSide twice, so the first call kicked off a
comparison against the *old* other side. Scans run concurrently, so that stale
result could land last and win: the left had zoomed into the archive while the
right still showed the parent, which is exactly what it looked like on screen.

Both sides are now set in one go before comparing, and every scan is stamped so a
slower, older run can no longer overwrite a newer result.

Also renamed the pairing entries to mention 'set as base', since marking one side
and picking the other achieves the same thing for items whose names differ and
that wasn't obvious from the wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
2026-07-31 14:46:59 +02:00

412 lines
15 KiB
Swift

import SwiftUI
import AppKit
import KotejEngine
/// A row ready to render: filters are applied up front.
struct DisplayNode: Identifiable {
let id: String
let node: DiffNode
let children: [DisplayNode]?
/// Builds the display tree under the filter and the name search. A folder
/// survives only when something inside it does, so filtering never leaves
/// empty branches behind.
static func build(_ node: DiffNode, filter: RowFilter, search: String) -> DisplayNode? {
guard node.isDirectory else {
guard filter.matches(node.status), matchesSearch(node, search) else { return nil }
return DisplayNode(id: node.path, node: node, children: nil)
}
let kids = node.children.compactMap { build($0, filter: filter, search: search) }
if (filter.hidesRows || !search.isEmpty) && kids.isEmpty { return nil }
return DisplayNode(id: node.path.isEmpty ? "__root__" : node.path, node: node, children: kids)
}
private static func matchesSearch(_ node: DiffNode, _ search: String) -> Bool {
guard !search.isEmpty else { return true }
return node.path.localizedCaseInsensitiveContains(search)
}
func flattened() -> [DiffNode] {
[node] + (children ?? []).flatMap { $0.flattened() }
}
}
/// Lets the model collect the ids to open without knowing about the view layer.
protocol DisplayNodeExpanding {
func expandableIDs() -> Set<String>
}
extension DisplayNode: DisplayNodeExpanding {
func expandableIDs() -> Set<String> {
guard let children, !children.isEmpty else { return [] }
return children.reduce(into: Set([id])) { $0.formUnion($1.expandableIDs()) }
}
}
/// A row as actually drawn: the tree is flattened with its own expansion state
/// rather than using List's outline, because that only ever puts a disclosure
/// triangle on the leading column and here either side should expand.
struct FlatRow: Identifiable {
let id: String
let node: DiffNode
let depth: Int
let isExpandable: Bool
let isExpanded: Bool
}
/// The side-by-side tree. Archives appear as folders.
struct DiffTreeView: View {
@Bindable var model: ComparisonModel
/// Opens an arbitrary pair as a new comparison; supplied by the window.
var openComparison: ((URL, URL, Bool) -> Void)?
private let sizeWidth: CGFloat = 76
private let modeWidth: CGFloat = 26
private let statusWidth: CGFloat = 104
var body: some View {
VStack(spacing: 0) {
header
Divider()
content
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onChange(of: model.filter) { _, _ in publishVisibleRows() }
.onChange(of: model.searchText) { _, _ in publishVisibleRows() }
.onAppear { publishVisibleRows() }
}
@ViewBuilder private var content: some View {
if flatRows.isEmpty {
VStack(spacing: 6) {
Image(systemName: "line.3.horizontal.decrease.circle")
.font(.largeTitle)
.foregroundStyle(.tertiary)
Text("Nothing matches this filter").foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List(flatRows, selection: selectionBinding) { row in
DiffRow(row: row,
sizeWidth: sizeWidth,
modeWidth: modeWidth,
statusWidth: statusWidth,
onToggle: { model.toggleExpansion(row.id) })
.contentShape(Rectangle())
.onTapGesture { select(row.node) }
.contextMenu { rowMenu(row.node) }
}
.listStyle(.inset(alternatesRowBackgrounds: true))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private var displayRoot: DisplayNode? {
guard let root = model.root else { return nil }
return DisplayNode.build(root, filter: model.filter, search: model.searchText)
}
/// Depth-first walk that stops at collapsed folders.
private var flatRows: [FlatRow] {
var out: [FlatRow] = []
func walk(_ display: DisplayNode, depth: Int) {
let expandable = (display.children?.isEmpty == false)
let expanded = model.isExpanded(display.id)
out.append(FlatRow(id: display.id, node: display.node, depth: depth,
isExpandable: expandable, isExpanded: expanded))
guard expandable, expanded else { return }
for child in display.children ?? [] { walk(child, depth: depth + 1) }
}
for child in displayRoot?.children ?? [] { walk(child, depth: 0) }
return out
}
private func publishVisibleRows() {
model.setVisibleRows((displayRoot?.children ?? []).flatMap { $0.flattened() })
}
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)
Text("Size").frame(width: sizeWidth, alignment: .trailing)
Group {
if let name = model.rightURL?.lastPathComponent { Text(verbatim: name) } else { Text("Right") }
}
.frame(maxWidth: .infinity, alignment: .leading)
Text("Size").frame(width: sizeWidth, alignment: .trailing)
Color.clear.frame(width: modeWidth, height: 1)
Text("Status").frame(width: statusWidth, alignment: .trailing)
}
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.lineLimit(1)
.padding(.horizontal, 14)
.padding(.vertical, 6)
.background(.bar)
}
private var selectionBinding: Binding<FlatRow.ID?> {
Binding(get: { model.selection?.path }, set: { _ in })
}
private func select(_ node: DiffNode) {
model.selection = node
if node.status == .pending { model.resolvePending(node) }
}
@ViewBuilder private func rowMenu(_ node: DiffNode) -> some View {
// Descend: both sides of this row become the new base. The common case,
// and it works for folders and archives, not just files.
if node.left != nil && node.right != nil {
Button("Set as base") { setBase(node, newTab: false) }
Button("Set as base in a new tab") { setBase(node, newTab: true) }
Divider()
}
// Pair up two things whose names don't match: mark one side, pick the
// other. Works for folders too, so a folder inside a JAR can be set
// against one on disk.
if let left = node.left {
Button("Set as base: mark left") { mark(left, path: node.path) }
}
if let right = node.right {
Button("Set as base: mark right") { mark(right, path: node.path) }
}
if let pending = model.pendingPick {
Divider()
if let left = node.left {
Button("Set as base with “\(pending.name)” (left)") { compare(pending, with: left, newTab: false) }
}
if let right = node.right {
Button("Set as base with “\(pending.name)” (right)") { compare(pending, with: right, newTab: false) }
Button("Set as base with “\(pending.name)” in a new tab") {
compare(pending, with: right, newTab: true)
}
}
Button("Forget “\(pending.name)") { model.pendingPick = nil }
}
Divider()
Button("Copy left → right") { copy(node, .leftToRight) }
.disabled(!FileSync.canCopy(node, .leftToRight, leftRoot: model.leftURL, rightRoot: model.rightURL))
Button("Copy right → left") { copy(node, .rightToLeft) }
.disabled(!FileSync.canCopy(node, .rightToLeft, leftRoot: model.leftURL, rightRoot: model.rightURL))
Divider()
Button("Expand all") { model.expandAll(from: displayRoot) }
Button("Collapse all") { model.collapseAll() }
Divider()
Button("Reveal left in Finder") { reveal(node.left) }
.disabled(!canReveal(node.left))
Button("Reveal right in Finder") { reveal(node.right) }
.disabled(!canReveal(node.right))
Divider()
Button("Copy path") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(node.path, forType: .string)
}
}
/// Restarts the comparison at this row, using its two sides as the new base.
private func setBase(_ node: DiffNode, newTab: Bool) {
guard let left = node.left, let right = node.right,
let leftURL = try? NodeExporter.url(for: left),
let rightURL = try? NodeExporter.url(for: right) else { return }
openComparison?(leftURL, rightURL, newTab)
}
private func copy(_ node: DiffNode, _ direction: FileSync.Direction) {
model.selection = node
model.copySelection(direction)
}
private func mark(_ node: Node, path: String) {
guard let url = try? NodeExporter.url(for: node) else { return }
model.pendingPick = PendingPick(path: path, name: node.name, url: url,
isDirectory: node.isDirectory)
}
/// Extracts the second side if needed and hands the pair to the window.
private func compare(_ pending: PendingPick, with node: Node, newTab: Bool) {
guard let url = try? NodeExporter.url(for: node) else { return }
model.pendingPick = nil
openComparison?(pending.url, url, newTab)
}
/// Only nodes backed by a real file can be shown in Finder; entries living
/// inside an archive have no path of their own.
private func canReveal(_ node: Node?) -> Bool {
guard let node else { return false }
if case .fileSystem = node.source { return true }
return false
}
private func reveal(_ node: Node?) {
guard let node, case .fileSystem(let url) = node.source else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
}
}
/// One row: each side carries its own disclosure control, so the tree can be
/// navigated from whichever side you're reading.
struct DiffRow: View {
let row: FlatRow
let sizeWidth: CGFloat
let modeWidth: CGFloat
let statusWidth: CGFloat
let onToggle: () -> Void
private var node: DiffNode { row.node }
var body: some View {
HStack(spacing: 0) {
side(present: node.left != nil)
sizeCell(node.left?.size, differs: sizesDiffer)
side(present: node.right != nil)
sizeCell(node.right?.size, differs: sizesDiffer)
ModeIcon(mode: node.appliedMode)
.frame(width: modeWidth, alignment: .center)
StatusBadge(status: node.status)
.frame(width: statusWidth, alignment: .trailing)
}
.lineLimit(1)
.truncationMode(.middle)
.padding(.vertical, 2)
}
private func side(present: Bool) -> some View {
HStack(spacing: 4) {
Color.clear.frame(width: CGFloat(row.depth) * 14, height: 1)
Group {
if row.isExpandable {
Button(action: onToggle) {
Image(systemName: row.isExpanded ? "chevron.down" : "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 12)
}
.buttonStyle(.borderless)
} else {
Color.clear.frame(width: 12, height: 1)
}
}
Image(systemName: present ? icon : "minus")
.foregroundStyle(present ? (node.isArchive ? Color.orange : Color.secondary)
: Color.secondary.opacity(0.5))
.frame(width: 16)
Text(verbatim: present ? node.name : "")
.foregroundStyle(present ? Color.primary : Color.secondary)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var icon: String {
if node.isArchive { return "shippingbox.fill" }
return node.isDirectory ? "folder.fill" : "doc"
}
/// A size per side, so they can be compared directly. Highlighted when they
/// disagree, which is often the quickest hint of what changed.
private func sizeCell(_ bytes: UInt64?, differs: Bool) -> some View {
Text(verbatim: sizeText(bytes))
.font(.caption.monospacedDigit())
.foregroundStyle(differs ? Color.orange : Color.secondary)
.frame(width: sizeWidth, alignment: .trailing)
.padding(.trailing, 8)
}
private func sizeText(_ bytes: UInt64?) -> String {
guard !node.isDirectory, let bytes else { return "" }
return ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
}
private var sizesDiffer: Bool {
guard !node.isDirectory, let l = node.left?.size, let r = node.right?.size else { return false }
return l != r
}
}
/// Coloured pill so the verdict reads at a glance instead of as plain text.
struct StatusBadge: View {
let status: DiffStatus
var body: some View {
Text(label)
.font(.caption2.weight(.medium))
.padding(.horizontal, 7)
.padding(.vertical, 2)
.foregroundStyle(tint)
.background(tint.opacity(0.14), in: Capsule())
}
private var label: LocalizedStringKey {
switch status {
case .identical: return "same"
case .equivalent: return "equivalent"
case .different: return "different"
case .onlyLeft: return "only left"
case .onlyRight: return "only right"
case .pending: return "pending"
}
}
private var tint: Color {
switch status {
case .identical: return .secondary
case .equivalent: return .green
case .different: return .orange
case .onlyLeft: return .blue
case .onlyRight: return .purple
case .pending: return .gray
}
}
}
/// Shows which comparison settled a row, which is what makes "Automatic"
/// trustworthy: you can see when it went semantic rather than assuming.
struct ModeIcon: View {
let mode: ComparisonMode?
var body: some View {
if let mode {
Image(systemName: symbol(mode))
.font(.caption2)
.foregroundStyle(tint(mode))
.help(helpText(mode))
}
}
private func symbol(_ mode: ComparisonMode) -> String {
switch mode {
case .binary: return "number"
case .text: return "text.alignleft"
case .semantic: return "curlybraces"
}
}
private func tint(_ mode: ComparisonMode) -> Color {
switch mode {
case .binary: return .secondary
case .text: return .blue
case .semantic: return .purple
}
}
private func helpText(_ mode: ComparisonMode) -> LocalizedStringKey {
switch mode {
case .binary: return "Compared byte for byte"
case .text: return "Compared as text"
case .semantic: return "Compared structurally (JSON/XML)"
}
}
}