e3cf1df73e
Archives are detected by their signature, not just their extension, so a ZIP renamed to anything still opens as a folder — and nesting keeps going, verified on EAR -> JAR -> renamed ZIP. Entries with clearly non-container extensions are skipped without inflating, since checking the signature inside an archive means decompressing, and an EAR holds thousands of .class files. The comparison now records which mode settled each file, so Automatic shows an icon per row (byte / text / structural) instead of leaving you to guess when it went semantic. Sizes are shown on both sides and highlighted when they disagree, which is often the fastest hint of what changed. Two files whose names don't match can now be compared: mark one side, pick the other, and it becomes the new base in this tab or a new one. Entries living inside an archive are extracted to a temporary file first, since they have no path of their own — that's what lets a file inside a JAR be paired with a loose one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
223 lines
9.6 KiB
Swift
223 lines
9.6 KiB
Swift
import Foundation
|
|
|
|
/// Outcome of comparing one pair of nodes.
|
|
public enum DiffStatus: Equatable, Sendable {
|
|
/// Byte-identical (proved by size + CRC or by content).
|
|
case identical
|
|
/// Bytes differ but the documents mean the same under the chosen rules.
|
|
case equivalent
|
|
case different
|
|
case onlyLeft
|
|
case onlyRight
|
|
/// Cheap checks couldn't prove equality; content hasn't been read yet.
|
|
case pending
|
|
|
|
public var isDifference: Bool {
|
|
switch self {
|
|
case .identical, .equivalent: return false
|
|
case .different, .onlyLeft, .onlyRight, .pending: return true
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A row in the side-by-side tree: the same path on both sides, plus a verdict.
|
|
public final class DiffNode: @unchecked Sendable {
|
|
public let name: String
|
|
public let path: String
|
|
public let isDirectory: Bool
|
|
public let isArchive: Bool
|
|
public let left: Node?
|
|
public let right: Node?
|
|
public var status: DiffStatus
|
|
/// Which comparison actually settled this file, so "Automatic" can show what
|
|
/// it decided rather than leaving you to guess.
|
|
public var appliedMode: ComparisonMode?
|
|
public internal(set) var children: [DiffNode]
|
|
|
|
init(name: String, path: String, isDirectory: Bool, isArchive: Bool,
|
|
left: Node?, right: Node?, status: DiffStatus, children: [DiffNode] = []) {
|
|
self.name = name
|
|
self.path = path
|
|
self.isDirectory = isDirectory
|
|
self.isArchive = isArchive
|
|
self.left = left
|
|
self.right = right
|
|
self.status = status
|
|
self.children = children
|
|
}
|
|
|
|
/// Counts, for the summary bar.
|
|
public struct Totals: Equatable, Sendable {
|
|
public init() {}
|
|
public var identical = 0, different = 0, onlyLeft = 0, onlyRight = 0, pending = 0
|
|
public var differences: Int { different + onlyLeft + onlyRight + pending }
|
|
}
|
|
|
|
public func totals() -> Totals {
|
|
var totals = Totals()
|
|
func walk(_ node: DiffNode) {
|
|
if node.isDirectory {
|
|
node.children.forEach(walk)
|
|
return
|
|
}
|
|
switch node.status {
|
|
case .identical, .equivalent: totals.identical += 1
|
|
case .different: totals.different += 1
|
|
case .onlyLeft: totals.onlyLeft += 1
|
|
case .onlyRight: totals.onlyRight += 1
|
|
case .pending: totals.pending += 1
|
|
}
|
|
}
|
|
walk(self)
|
|
return totals
|
|
}
|
|
}
|
|
|
|
/// Pairs two trees and decides what differs.
|
|
///
|
|
/// The comparison is layered so big trees stay fast: sizes and (for archive
|
|
/// entries) CRC32s prove equality without reading a byte, and only what those
|
|
/// checks can't settle is read. Note the asymmetry — cheap checks can prove two
|
|
/// files *identical*, but never that they *differ*, because a JSON or XML with
|
|
/// different bytes may still be semantically equal.
|
|
public enum DirectoryComparer {
|
|
public struct Options: Sendable {
|
|
/// Read file contents during the scan. When false the undecided pairs are
|
|
/// left `.pending` and resolved on demand, which keeps huge trees snappy.
|
|
public var resolveContentEagerly: Bool
|
|
/// Cap on eagerly-read files; larger ones stay `.pending`.
|
|
public var eagerSizeLimit: UInt64
|
|
public var mode: ComparisonMode?
|
|
public var textOptions: TextOptions
|
|
|
|
public init(resolveContentEagerly: Bool = true,
|
|
eagerSizeLimit: UInt64 = 4 * 1024 * 1024,
|
|
mode: ComparisonMode? = nil,
|
|
textOptions: TextOptions = TextOptions()) {
|
|
self.resolveContentEagerly = resolveContentEagerly
|
|
self.eagerSizeLimit = eagerSizeLimit
|
|
self.mode = mode
|
|
self.textOptions = textOptions
|
|
}
|
|
}
|
|
|
|
public static func compare(left: Node, right: Node, options: Options = Options()) -> DiffNode {
|
|
let root = pair(left: left, right: right, name: left.name, path: "", options: options)
|
|
return root
|
|
}
|
|
|
|
private static func pair(left: Node?, right: Node?, name: String, path: String,
|
|
options: Options) -> DiffNode {
|
|
// Present on one side only: the whole subtree is a difference.
|
|
guard let left, let right else {
|
|
let present = left ?? right!
|
|
let node = DiffNode(name: name, path: path, isDirectory: present.isDirectory,
|
|
isArchive: present.isArchive, left: left, right: right,
|
|
status: left == nil ? .onlyRight : .onlyLeft)
|
|
node.children = present.children.map { child in
|
|
pair(left: left == nil ? nil : child, right: left == nil ? child : nil,
|
|
name: child.name, path: child.path, options: options)
|
|
}
|
|
return node
|
|
}
|
|
|
|
if left.isDirectory && right.isDirectory {
|
|
let node = DiffNode(name: name, path: path, isDirectory: true,
|
|
isArchive: left.isArchive || right.isArchive,
|
|
left: left, right: right, status: .identical)
|
|
node.children = pairChildren(left: left, right: right, options: options)
|
|
// A folder differs when anything inside it does.
|
|
node.status = node.children.contains { $0.status.isDifference } ? .different : .identical
|
|
return node
|
|
}
|
|
|
|
// A folder on one side and a file on the other is a difference in itself.
|
|
if left.isDirectory != right.isDirectory {
|
|
return DiffNode(name: name, path: path, isDirectory: left.isDirectory,
|
|
isArchive: left.isArchive || right.isArchive,
|
|
left: left, right: right, status: .different)
|
|
}
|
|
|
|
let outcome = fileOutcome(left: left, right: right, options: options)
|
|
let node = DiffNode(name: name, path: path, isDirectory: false,
|
|
isArchive: false, left: left, right: right,
|
|
status: outcome.status)
|
|
node.appliedMode = outcome.mode
|
|
return node
|
|
}
|
|
|
|
private static func pairChildren(left: Node, right: Node, options: Options) -> [DiffNode] {
|
|
var rightByName = Dictionary(grouping: right.children, by: \.name).mapValues { $0[0] }
|
|
var rows: [DiffNode] = []
|
|
|
|
for leftChild in left.children {
|
|
let match = rightByName.removeValue(forKey: leftChild.name)
|
|
rows.append(pair(left: leftChild, right: match, name: leftChild.name,
|
|
path: leftChild.path, options: options))
|
|
}
|
|
for orphan in rightByName.values {
|
|
rows.append(pair(left: nil, right: orphan, name: orphan.name,
|
|
path: orphan.path, options: options))
|
|
}
|
|
|
|
rows.sort { a, b in
|
|
if a.isDirectory != b.isDirectory { return a.isDirectory }
|
|
return a.name.localizedStandardCompare(b.name) == .orderedAscending
|
|
}
|
|
return rows
|
|
}
|
|
|
|
/// The status plus which comparison produced it.
|
|
static func fileOutcome(left: Node, right: Node,
|
|
options: Options) -> (status: DiffStatus, mode: ComparisonMode?) {
|
|
// Level 1 — CRC32 straight from the archive directory: free and decisive.
|
|
if let leftCRC = left.crc32, let rightCRC = right.crc32 {
|
|
if leftCRC == rightCRC && left.size == right.size { return (.identical, nil) }
|
|
} else if left.size == right.size, left.size == 0 {
|
|
return (.identical, nil)
|
|
}
|
|
|
|
let mustRead = options.resolveContentEagerly && max(left.size, right.size) <= options.eagerSizeLimit
|
|
guard mustRead else { return (.pending, nil) }
|
|
return resolveDetailed(left: left, right: right, options: options)
|
|
}
|
|
|
|
/// The layered check for a single pair of files.
|
|
static func fileStatus(left: Node, right: Node, options: Options) -> DiffStatus {
|
|
// Level 1 — CRC32 straight from the archive directory: free and decisive.
|
|
if let leftCRC = left.crc32, let rightCRC = right.crc32 {
|
|
if leftCRC == rightCRC && left.size == right.size { return .identical }
|
|
// Different CRCs still might be semantically equal, so fall through.
|
|
} else if left.size == right.size, left.size == 0 {
|
|
return .identical
|
|
}
|
|
|
|
// Level 2 — read and compare, unless we're staying lazy.
|
|
let mustRead = options.resolveContentEagerly && max(left.size, right.size) <= options.eagerSizeLimit
|
|
guard mustRead else { return .pending }
|
|
return resolve(left: left, right: right, options: options)
|
|
}
|
|
|
|
/// Reads both sides and applies the chosen (or inferred) comparison mode.
|
|
public static func resolve(left: Node, right: Node, options: Options = Options()) -> DiffStatus {
|
|
resolveDetailed(left: left, right: right, options: options).status
|
|
}
|
|
|
|
/// Same, but also reports which mode was used.
|
|
public static func resolveDetailed(left: Node, right: Node,
|
|
options: Options = Options()) -> (status: DiffStatus, mode: ComparisonMode?) {
|
|
guard let leftData = try? left.data(), let rightData = try? right.data() else { return (.different, nil) }
|
|
|
|
let kind = ContentKind.detect(path: left.name, data: leftData)
|
|
let mode = options.mode ?? kind.defaultMode
|
|
if leftData == rightData { return (.identical, mode) }
|
|
|
|
switch ContentComparer.compare(leftData, rightData, mode: mode, kind: kind,
|
|
textOptions: options.textOptions) {
|
|
case .equal: return (.identical, mode)
|
|
case .equivalent: return (.equivalent, mode)
|
|
case .different, .unsupported: return (.different, mode)
|
|
}
|
|
}
|
|
}
|