The kind: comment field table lands in IntegrityRules (the per-kind hook's first exercise), CommentThread reads one card's thread window-scoped (the board walk stays O(cards)), and CommentWriter gains the five gestures: draft save, post (rename .draft to a fresh UUID, created/modified restamped in the bracket), edit, delete into comments/.trash/, and the purge with its crash-residue memo. Post and delete register move-based undo steps; draft saves, edits, and the purge deliberately register nothing (13's no-capture rule). Copy boundaries strip comments/.trash, carry .draft verbatim, and remint threads; comments graduates to a displacing claimed name, with .draft, .trash, and a comment's attachments claimed one level down. CommentPath classifies changed paths into the 06 verb family for later announcer/composer wiring. One stated narrowing pending a ruling (filed on the findings board): the copy transaction's refuse-whole preflight stays cards-and-lanes — an unstampable copied comment copies verbatim with a log line, because comment defects never refuse. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
209 lines
11 KiB
Swift
209 lines
11 KiB
Swift
import Foundation
|
|
|
|
/// The **board-scale tree copy**: a per-item file walk that checks cancellation between items,
|
|
/// shared by File ▸ Duplicate (`BoardDuplicator`) and template instantiation (`TemplateEngine`).
|
|
///
|
|
/// ### Why the walk is a walk
|
|
///
|
|
/// 03-board-ui.md settles the shape for Duplicate and the reason generalizes to every copy of a
|
|
/// whole board: "the copy runs as a per-item file walk that checks cancellation between items —
|
|
/// **never one monolithic `copyItem`** — and Cancel removes the partial sibling before dismissing
|
|
/// the banner (the attachment partial-cleanup precedent)". A monolithic `copyItem` is uncancellable
|
|
/// and indivisible; a walk is both, and it is also the only shape that can *exclude* something
|
|
/// (09-templates.md's `.git` and `.trash/`).
|
|
///
|
|
/// So the walk is the promise: one `FileManager.copyItem` per file — bytes and their metadata
|
|
/// copied by the file system, never re-encoded, **symlinks copied as symlinks** (01-storage-format.md
|
|
/// § Fractal layout ▸ Rules: "Copy flows copy the link itself, never its target: Duplicate, Save as
|
|
/// Template, instantiation, cross-board copies … preserve the link verbatim") — folders recreated by
|
|
/// hand, and a cancellation read **between** items, never mid-file (a copy interrupted inside a
|
|
/// 200 MB pack file leaves rubble that is harder to reason about than one more file's wait). The
|
|
/// entries of every folder are walked in name order, so the same cancellation removes the same
|
|
/// partial tree every time and a failure names the same file twice running.
|
|
///
|
|
/// The accepted cost of building folders by hand rather than handing the tree to one `copyItem`:
|
|
/// **extended attributes on folders do not carry** (files keep theirs — each is still copied by
|
|
/// `copyItem`). Nothing the app, the storage format, or git keeps lives in a folder xattr.
|
|
///
|
|
/// ### What it does not do
|
|
///
|
|
/// It never creates the destination root, never removes a partial, and never classifies a failure.
|
|
/// Those are the caller's, because they are exactly where the two flows differ: Duplicate answers a
|
|
/// permission failure with a save panel, instantiation with a banner, and each owns its own cleanup
|
|
/// promise. This type only knows how to move a tree, one item at a time, and where to stop.
|
|
///
|
|
/// ### Not `@MainActor`
|
|
///
|
|
/// Copying a board with a year of `.git` behind it is real I/O and runs off the main actor so an
|
|
/// in-progress banner can actually spin (02-architecture.md § The banner surface). Safe by
|
|
/// construction: it touches only the two URLs it is handed, and a board's security-scoped access is
|
|
/// a process-wide grant, not a per-thread one.
|
|
enum BoardTreeCopy {
|
|
|
|
/// Why a walk stopped and on which item — the recursion's currency, which each caller converts
|
|
/// into its own failure vocabulary the moment it surfaces.
|
|
enum Stop: Error {
|
|
case cancelled
|
|
case failed(url: URL, error: any Error)
|
|
}
|
|
|
|
/// Copies everything inside `source` into the already-created `destination`, one item at a time.
|
|
///
|
|
/// Name order, hidden entries included (no `.skipsHiddenFiles`): `.DS_Store`, `.gitignore`,
|
|
/// `CLAUDE.user.md` and every other dotfile or stray is part of the copy — "the copy is literal
|
|
/// apart from the stated exclusions" (09-templates.md ▸ Save as Template) — and a deterministic
|
|
/// order is what makes a cancellation reproducible.
|
|
///
|
|
/// `excludedTopLevelNames` is compared **lowercased against the top level only**, which is the
|
|
/// exclusions' actual scope rather than a shortcut: `.git` and `.trash/` mean something at a
|
|
/// board root and nowhere else (06-history-undo.md's nearest-`.git`-wins detection starts at the
|
|
/// board root; `BoardLoader.trashCandidates` looks in exactly one place), so a `.git` a template
|
|
/// author left inside a card folder is an ordinary stray and copies verbatim like any other.
|
|
///
|
|
/// `carriesFolderAttributes` decides whether a recreated folder gets the source folder's POSIX
|
|
/// permissions and timestamps back once its contents have landed. Duplicate says yes — it is a
|
|
/// fork, down to the mode bits. Instantiation says no: a template folder is content the app
|
|
/// ships or the user dropped, and carrying a read-only mode (or a two-year-old date) out of it
|
|
/// would mint a board that is read-only, or born older than itself, from a copy whose whole
|
|
/// premise is "born today" (09-templates.md ▸ Instantiation).
|
|
///
|
|
/// Each entry's type comes from `attributesOfItem`, which does **not** traverse symlinks — so a
|
|
/// link is copied as a link (by `copyItem`, which does not follow it either) rather than being
|
|
/// mistaken for the folder it points at and walked into.
|
|
static func copy(
|
|
contentsOf source: URL,
|
|
into destination: URL,
|
|
excludingTopLevel excludedTopLevelNames: Set<String> = [],
|
|
carryingFolderAttributes carriesFolderAttributes: Bool = true,
|
|
isCancelled: () -> Bool
|
|
) throws(Stop) {
|
|
try copyContents(
|
|
of: source,
|
|
into: destination,
|
|
excluding: excludedTopLevelNames,
|
|
carryingFolderAttributes: carriesFolderAttributes,
|
|
isCancelled: isCancelled
|
|
)
|
|
}
|
|
|
|
/// The recursion. `excluded` is emptied one level down, which is what makes the exclusions
|
|
/// top-level-only without the walk having to count its own depth.
|
|
private static func copyContents(
|
|
of source: URL,
|
|
into destination: URL,
|
|
excluding excluded: Set<String>,
|
|
carryingFolderAttributes carriesFolderAttributes: Bool,
|
|
isCancelled: () -> Bool
|
|
) throws(Stop) {
|
|
let entries: [URL]
|
|
do {
|
|
entries = try FileManager.default.contentsOfDirectory(
|
|
at: source,
|
|
includingPropertiesForKeys: nil,
|
|
options: []
|
|
)
|
|
} catch {
|
|
throw .failed(url: source, error: error)
|
|
}
|
|
|
|
// The one exclusion this walk makes without being asked (01-storage-format.md § Enhanced
|
|
// schema: `comments/.trash/` is "**stripped at every copy boundary** (clipboard staging,
|
|
// Duplicate, Save as Template) — a copy must not carry ghosts no window session will ever
|
|
// purge"). Unconditional because all three of this walk's callers *are* copy boundaries, and
|
|
// an opt-in flag would be three call sites agreeing to one rule instead of one rule.
|
|
//
|
|
// It is the one depth-sensitive exclusion, hence the parent check rather than a name in
|
|
// `excluded`: `.trash` at a board root is the board's own and carries or not per each flow's
|
|
// stated exclusions, while `.trash` inside a `comments/` is undo's backing store and never
|
|
// travels.
|
|
let isCommentThread = source.lastPathComponent.lowercased() == IntegrityRules.commentsFolderName
|
|
|
|
for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
|
|
guard !excluded.contains(entry.lastPathComponent.lowercased()) else { continue }
|
|
guard !isCommentThread
|
|
|| entry.lastPathComponent.lowercased() != IntegrityRules.commentTrashFolderName
|
|
else { continue }
|
|
|
|
// Between items, never mid-item: this is the whole of "checks cancellation between
|
|
// items", and the reason the copy is a walk at all.
|
|
if isCancelled() { throw .cancelled }
|
|
|
|
let attributes: [FileAttributeKey: Any]
|
|
do {
|
|
attributes = try FileManager.default.attributesOfItem(atPath: entry.path)
|
|
} catch {
|
|
throw .failed(url: entry, error: error)
|
|
}
|
|
|
|
let isDirectory = attributes[.type] as? FileAttributeType == .typeDirectory
|
|
let target = destination.appendingPathComponent(entry.lastPathComponent, isDirectory: isDirectory)
|
|
|
|
guard isDirectory else {
|
|
// Files, symlinks, and whatever else the file system holds: `copyItem` lands the
|
|
// bytes and the metadata that rides with them, byte-for-byte, unread.
|
|
do {
|
|
try FileManager.default.copyItem(at: entry, to: target)
|
|
} catch {
|
|
throw .failed(url: entry, error: error)
|
|
}
|
|
continue
|
|
}
|
|
|
|
do {
|
|
try createDirectory(at: target)
|
|
} catch {
|
|
throw .failed(url: entry, error: error)
|
|
}
|
|
try copyContents(
|
|
of: entry,
|
|
into: target,
|
|
excluding: [],
|
|
carryingFolderAttributes: carriesFolderAttributes,
|
|
isCancelled: isCancelled
|
|
)
|
|
if carriesFolderAttributes {
|
|
restoreAttributes(from: attributes, onto: target)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Creates `url` as a plain directory, wearing the process's own default permissions until its
|
|
/// contents have landed (`restoreAttributes(from:onto:)` puts the source's back afterwards,
|
|
/// where the caller asked for them).
|
|
///
|
|
/// **Default permissions first, the source's last**, because a folder is not only a thing being
|
|
/// copied but the thing being copied *into*: a source folder that is read-only, or unreadable,
|
|
/// would otherwise be reproduced as a destination this walk cannot write its own children into —
|
|
/// and, when something later fails, as a partial tree the cleanup cannot remove either. `cp -R`
|
|
/// defers the mode for the same reason.
|
|
///
|
|
/// `withIntermediateDirectories: false` throughout: every parent either exists already (the walk
|
|
/// just made it) or is the one the user pointed at, and inventing a missing folder would be this
|
|
/// function deciding where a board lives.
|
|
static func createDirectory(at url: URL) throws {
|
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false)
|
|
}
|
|
|
|
/// Puts a folder's POSIX permissions and its creation and modification dates back, **after** its
|
|
/// contents have landed — writing into a folder is itself a modification, and its mode may be
|
|
/// what stops the writing (above), so both wait for the subtree to be finished.
|
|
///
|
|
/// Best effort by design: a volume that will not take a date back (or a file system with no
|
|
/// creation dates at all) is not a reason to fail a copy that otherwise worked, and neither a
|
|
/// folder's timestamp nor its mode is something the storage format reads.
|
|
static func restoreAttributes(from attributes: [FileAttributeKey: Any], onto url: URL) {
|
|
var carried: [FileAttributeKey: Any] = [:]
|
|
if let permissions = attributes[.posixPermissions] {
|
|
carried[.posixPermissions] = permissions
|
|
}
|
|
if let created = attributes[.creationDate] {
|
|
carried[.creationDate] = created
|
|
}
|
|
if let modified = attributes[.modificationDate] {
|
|
carried[.modificationDate] = modified
|
|
}
|
|
guard !carried.isEmpty else { return }
|
|
try? FileManager.default.setAttributes(carried, ofItemAtPath: url.path)
|
|
}
|
|
}
|