139f650d64
- 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
82 lines
3.2 KiB
Swift
82 lines
3.2 KiB
Swift
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
|
|
}
|
|
}
|