Build the template chooser and Save as Template

The chooser completes its three tiers: bundled by template order, then
keyed user templates, then keyless boards by display name — and a
malformed user template still lists, by folder name with the loader's
own sentence on the row, never failing its neighbours. The store is
re-scanned on every presentation and on app activation, the Reveal
round trip made honest without watching a folder 09 deliberately
leaves unwatched; Reveal lives in the chooser's header and mints the
store on first press. Save as Template repeats Duplicate's sequence —
progress row with Cancel, flush, detached cancellable copy — through
the engine: mint the store, read the next user order before the copy
can count itself, Finder-ladder the name, copy excluding .git and
.trash/, then stamp the whole template: mapping on the landed copy
through updateIndex, with no bracket because the copy lives outside
every watched board. Folder attributes deliberately don't carry — the
one lock the command stays live under is the read-only-DMG one, and
carrying its mode bits would mint a read-only template in the user's
own store; the command gates instead on the real hazard, unsaved card
content. A signpost names the template only when the ladder renamed
it. One name ladder now serves Duplicate and the store.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 19:39:18 -04:00
parent e7b48d2d53
commit 3aa80db2a4
17 changed files with 1358 additions and 90 deletions
+27 -5
View File
@@ -100,18 +100,40 @@ enum BoardDuplicator {
/// the sandbox will not let us write is usually one we cannot list either, so the ladder simply
/// finds no collision and suggests `"Board copy"` the right pre-fill, arrived at honestly.
static func copyDestination(for rootURL: URL) -> URL {
let parent = rootURL.deletingLastPathComponent()
let base = rootURL.deletingPathExtension().lastPathComponent
let ext = rootURL.pathExtension
uncollidedURL(
named: "\(rootURL.deletingPathExtension().lastPathComponent) copy",
extension: rootURL.pathExtension,
in: rootURL.deletingLastPathComponent()
)
}
/// **Finder's counting ladder, in one place**: `name`, then `name 2`, `name 3`, counting up
/// from 2 against what is on disk at decision time, one collision at a time.
///
/// Shared rather than spelled twice, because two flows want the same ladder from different
/// starting names: Duplicate seeds it with `"Board copy"` (above), and Save as Template seeds it
/// with the board's own name 09-templates.md Save as Template's "**Store collisions
/// auto-rename, Finder-style** (`Board.kanban` `Board 2.kanban`) the 01 import precedent:
/// saving never overwrites an existing template and never refuses". One ladder, two seeds, so
/// the two can never disagree about what "Finder-style" means.
///
/// The extension rides on the end (`Board copy.kanban`) and an extension-less board folder
/// simply has none to carry both are shapes a board is allowed to be (01-storage-format.md
/// § Document packaging).
///
/// `fileExists` is the one test, and it is true for a file as much as a folder: anything already
/// wearing the name blocks it, which is what keeps either flow from overwriting something. It is
/// a decision-time answer, not a reservation the caller still creates the folder itself and
/// still fails rather than clobbers if the name was taken in between.
static func uncollidedURL(named base: String, extension ext: String, in parent: URL) -> URL {
func candidate(_ name: String) -> URL {
parent.appendingPathComponent(ext.isEmpty ? name : "\(name).\(ext)", isDirectory: true)
}
var name = "\(base) copy"
var name = base
var counter = 2
while FileManager.default.fileExists(atPath: candidate(name).path) {
name = "\(base) copy \(counter)"
name = "\(base) \(counter)"
counter += 1
}
return candidate(name)