8e4a3e5f62
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
412 lines
15 KiB
Swift
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)"
|
|
}
|
|
}
|
|
}
|