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:
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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.";
|
||||
|
||||
Reference in New Issue
Block a user