Files
kotej/App/FileSync.swift
alexandrev-tibco 139f650d64 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
2026-07-31 13:27:59 +02:00

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
}
}