Set as base, folder pairing, copy between sides, single window

- Set as base: both sides of a row become the new comparison, which is how you
  descend into a folder pair. Works on folders and archives, not just files.
- Marking for comparison no longer refuses folders, so a folder inside a JAR can
  be paired with one on disk; archive subtrees are extracted whole to do it.
- Copy left → right and right → left, from the toolbar, the context menu or
  cmd-option-arrow, re-comparing afterwards. Only offered where the destination
  is a real folder: an entry inside an archive has no path to write to, and the
  button is disabled rather than failing silently.
- The scene is now a single Window instead of a WindowGroup: a comparison opened
  from Finder joins the window already open as a new tab instead of spawning a
  second window. Verified with two consecutive kotej:// opens — one window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
This commit is contained in:
alexandrev-tibco
2026-07-31 13:27:59 +02:00
parent 33f48edc11
commit 139f650d64
8 changed files with 216 additions and 19 deletions
+16
View File
@@ -100,6 +100,22 @@ final class ComparisonModel: Identifiable {
if selection?.status == .pending, let node = selection { resolvePending(node) }
}
/// Copies the selected row across and re-compares, so the tree reflects it.
func copySelection(_ direction: FileSync.Direction) {
guard let node = selection else { return }
do {
try FileSync.copy(node, direction, leftRoot: leftURL, rightRoot: rightURL)
compare()
} catch {
errorMessage = error.localizedDescription
}
}
func canCopySelection(_ direction: FileSync.Direction) -> Bool {
guard let node = selection else { return false }
return FileSync.canCopy(node, direction, leftRoot: leftURL, rightRoot: rightURL)
}
/// Re-runs the comparison when the text tolerances change.
func applyTextOptions(_ newValue: TextOptions) {
guard newValue != textOptions else { return }
+19
View File
@@ -113,6 +113,8 @@ struct ControlBar: View {
}
Spacer()
copyButtons
Divider().frame(height: 16)
navigation
}
.padding(.horizontal, 12)
@@ -164,6 +166,23 @@ struct ControlBar: View {
.help("Automatic picks the comparison that suits each file type")
}
/// Act on a difference instead of only looking at it.
private var copyButtons: some View {
HStack(spacing: 6) {
Button { model.copySelection(.rightToLeft) } label: {
Image(systemName: "arrow.left.circle")
}
.help("Copy right → left")
.disabled(!model.canCopySelection(.rightToLeft))
Button { model.copySelection(.leftToRight) } label: {
Image(systemName: "arrow.right.circle")
}
.help("Copy left → right")
.disabled(!model.canCopySelection(.leftToRight))
}
}
/// Walk the differences without hunting through the tree by hand.
private var navigation: some View {
HStack(spacing: 6) {
+37 -9
View File
@@ -158,29 +158,43 @@ struct DiffTreeView: View {
}
@ViewBuilder private func rowMenu(_ node: DiffNode) -> some View {
// Pair up two files whose names don't match: mark one side, then pick the
// other. The marked side survives changing filter or folder.
if let left = node.left, !node.isDirectory {
// 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("Mark left for comparison") { mark(left, path: node.path) }
}
if let right = node.right, !node.isDirectory {
if let right = node.right {
Button("Mark right for comparison") { mark(right, path: node.path) }
}
if let pending = model.pendingPick {
Divider()
if let left = node.left, !node.isDirectory {
if let left = node.left {
Button("Compare left with “\(pending.name)") { compare(pending, with: left, newTab: false) }
}
if let right = node.right, !node.isDirectory {
if let right = node.right {
Button("Compare right with “\(pending.name)") { compare(pending, with: right, newTab: false) }
}
if let right = node.right, !node.isDirectory {
Button("Compare right 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() }
@@ -196,9 +210,23 @@ struct DiffTreeView: View {
}
}
/// 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)
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.
+81
View File
@@ -0,0 +1,81 @@
import Foundation
import SwiftUI
import KotejEngine
/// Copies a row from one side to the other, which is what turns a comparison into
/// something you can act on.
///
/// Only plain folders on disk can be written to: an entry inside a JAR has no
/// path of its own and rewriting the archive is a different job entirely, so the
/// direction is simply unavailable there rather than silently doing nothing.
enum FileSync {
enum Direction {
case leftToRight, rightToLeft
var label: LocalizedStringKey {
self == .leftToRight ? "Copy left → right" : "Copy right → left"
}
var symbol: String {
self == .leftToRight ? "arrow.right.circle" : "arrow.left.circle"
}
}
enum SyncError: LocalizedError {
case notWritable
case nothingToCopy
var errorDescription: String? {
switch self {
case .notWritable:
return String(localized: "That side lives inside an archive, so it can't be written to.")
case .nothingToCopy:
return String(localized: "There's nothing to copy on that side.")
}
}
}
/// Whether the row can be copied in this direction: the source must exist and
/// the destination root must be a real folder on disk.
static func canCopy(_ node: DiffNode, _ direction: Direction,
leftRoot: URL?, rightRoot: URL?) -> Bool {
let source = direction == .leftToRight ? node.left : node.right
let destinationRoot = direction == .leftToRight ? rightRoot : leftRoot
guard source != nil, let destinationRoot else { return false }
return destinationRoot.hasDirectoryPath && !ZipReader.isArchive(destinationRoot)
}
/// Copies the row across, overwriting whatever is there. Returns the file it
/// wrote so the caller can report it.
@discardableResult
static func copy(_ node: DiffNode, _ direction: Direction,
leftRoot: URL?, rightRoot: URL?) throws -> URL {
guard let source = direction == .leftToRight ? node.left : node.right else {
throw SyncError.nothingToCopy
}
guard let destinationRoot = direction == .leftToRight ? rightRoot : leftRoot,
destinationRoot.hasDirectoryPath, !ZipReader.isArchive(destinationRoot) else {
throw SyncError.notWritable
}
let destination = destinationRoot.appendingPathComponent(node.path)
let fm = FileManager.default
try fm.createDirectory(at: destination.deletingLastPathComponent(),
withIntermediateDirectories: true)
// Replace rather than merge: the point is to make this side match.
if fm.fileExists(atPath: destination.path) {
try fm.removeItem(at: destination)
}
if case .fileSystem(let sourceURL) = source.source {
try fm.copyItem(at: sourceURL, to: destination)
} else if source.isDirectory {
// Coming out of an archive: write the subtree out.
let extracted = try NodeExporter.url(for: source)
try fm.copyItem(at: extracted, to: destination)
} else {
try source.data().write(to: destination)
}
return destination
}
}
+11 -1
View File
@@ -6,7 +6,10 @@ struct KotejApp: App {
@State private var tabs = TabsModel()
var body: some Scene {
WindowGroup {
// A single window on purpose: comparisons live in tabs, so a request
// arriving from Finder joins the window you already have open instead of
// spawning another one.
Window("Kotej", id: "main") {
RootView(tabs: tabs)
.frame(minWidth: 900, minHeight: 560)
// kotej://compare?left=&right= how the Finder extension asks
@@ -21,6 +24,13 @@ struct KotejApp: App {
CommandGroup(after: .newItem) {
Button("Close tab") { tabs.closeSelected() }
.keyboardShortcut("w")
Divider()
Button("Copy left → right") { tabs.selected.copySelection(.leftToRight) }
.keyboardShortcut(.rightArrow, modifiers: [.command, .option])
.disabled(!tabs.selected.canCopySelection(.leftToRight))
Button("Copy right → left") { tabs.selected.copySelection(.rightToLeft) }
.keyboardShortcut(.leftArrow, modifiers: [.command, .option])
.disabled(!tabs.selected.canCopySelection(.rightToLeft))
}
CommandGroup(after: .toolbar) {
Button("Swap sides") { tabs.selected.swapSides() }
+36 -9
View File
@@ -4,29 +4,55 @@ import KotejEngine
/// Gets a real file path for any node, so an arbitrary pair can be re-compared as
/// a new base.
///
/// Entries living inside an archive have no path of their own, so they're written
/// out to a temporary file first that's what makes it possible to pick a file
/// inside a JAR on one side and a loose file on the other.
/// Nodes living inside an archive have no path of their own, so they're written
/// out to a temporary location first that's what makes it possible to set a
/// folder inside a JAR as one side of a comparison and something on disk as the
/// other.
enum NodeExporter {
/// Returns a URL for the node, extracting it if it only exists inside an
/// archive. Nil when the node has no readable content.
/// archive. Directories are extracted whole, preserving their layout.
static func url(for node: Node) throws -> URL {
switch node.source {
case .fileSystem(let url), .extracted(let url, _):
return url
case .archiveEntry:
return try extract(node)
return try node.isDirectory ? extractTree(node) : extractFile(node)
}
}
private static func extract(_ node: Node) throws -> URL {
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
private static func scratchDirectory() throws -> URL {
let url = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("kotej-pick-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent(node.name)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
private static func extractFile(_ node: Node) throws -> URL {
let url = try scratchDirectory().appendingPathComponent(node.name)
try node.data().write(to: url)
return url
}
/// Writes a whole subtree out of an archive, so a folder inside a JAR can be
/// compared like any other folder.
private static func extractTree(_ node: Node) throws -> URL {
let root = try scratchDirectory().appendingPathComponent(node.name)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
try write(node, into: root)
return root
}
private static func write(_ node: Node, into directory: URL) throws {
for child in node.children {
let destination = directory.appendingPathComponent(child.name)
if child.isDirectory {
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
try write(child, into: destination)
} else if let data = try? child.data() {
try data.write(to: destination)
}
}
}
}
/// One side of a manual pairing, kept while the other side is chosen.
@@ -34,4 +60,5 @@ struct PendingPick: Equatable {
let path: String
let name: String
let url: URL
let isDirectory: Bool
}
+8
View File
@@ -94,3 +94,11 @@
"Compared byte for byte" = "Compared byte for byte";
"Compared as text" = "Compared as text";
"Compared structurally (JSON/XML)" = "Compared structurally (JSON/XML)";
/* Base and copy */
"Set as base" = "Set as base";
"Set as base in a new tab" = "Set as base in a new tab";
"Copy left → right" = "Copy left → right";
"Copy right → left" = "Copy right → left";
"That side lives inside an archive, so it can't be written to." = "That side lives inside an archive, so it can't be written to.";
"There's nothing to copy on that side." = "There's nothing to copy on that side.";
+8
View File
@@ -94,3 +94,11 @@
"Compared byte for byte" = "Comparado byte a byte";
"Compared as text" = "Comparado como texto";
"Compared structurally (JSON/XML)" = "Comparado estructuralmente (JSON/XML)";
/* Base y copia */
"Set as base" = "Usar como base";
"Set as base in a new tab" = "Usar como base en pestaña nueva";
"Copy left → right" = "Copiar izquierda → derecha";
"Copy right → left" = "Copiar derecha → izquierda";
"That side lives inside an archive, so it can't be written to." = "Ese lado está dentro de un archivo comprimido, no se puede escribir.";
"There's nothing to copy on that side." = "No hay nada que copiar en ese lado.";