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
+264 -4
View File
@@ -59,6 +59,15 @@ import os
/// about a mess the instantiation itself made. Board- and lane-level strays keep the verbatim
/// posture, exactly as the carve-out is scoped.
///
/// ### What a save is
///
/// Save as Template is the same walk pointed the other way a board copied *into* the store, minus
/// the same two exclusions plus one write the app owes: the `template:` key, stamped on the copy
/// through `BoardWriter.updateIndex`. That write is the whole of "the app never stamps a key into
/// store files it didn't write itself the one writer of keyed files is Save as Template" (09
/// Storage): discovery, listing and instantiation are all reads, so a hand-dropped board in the
/// store can never gain a key by being looked at. See `saveAsTemplate(boardAt:titled:into:)`.
///
/// ### Atomicity: construct-then-clean, not stage-then-rename
///
/// "A half-instantiated board must never be left at the destination." Two ways to promise that, and
@@ -167,22 +176,85 @@ enum TemplateEngine {
return sortedForChooser(templates)
}
/// Every folder in `store` as a **chooser row** loaded templates and unloadable ones alike,
/// in folder-name order (the caller sorts).
///
/// This is 09's one-bad-template-never-fails-the-chooser clause, and it is the whole of it: the
/// walk cannot fail as a walk (a missing store is an empty list), and a folder the loader rejects
/// becomes a row carrying the error rather than an omission or a thrown failure. Nothing here
/// writes: **listing a store never stamps a key into it** "a hand-dropped board is never
/// touched" (09 Storage).
static func rows(in store: URL, origin: BoardTemplate.Origin) -> [TemplateRow] {
templateFolders(in: store).map { folder in
switch load(templateAt: folder, origin: origin) {
case let .success(template):
.template(template)
case let .failure(error):
.unloadable(TemplateRow.Unloadable(url: folder, error: error))
}
}
}
/// The user tier, in 09's within-tier order every board folder in the user store, whether or
/// not it loads and whether or not it carries a `template:` key ("a board folder dropped in
/// becomes a template **no `template:` key required**").
static func userRows(in store: URL = userStore) -> [TemplateRow] {
sortedForChooser(rows(in: store, origin: .user))
}
/// **The chooser's whole list**, in 09 Storage's three-tier order:
///
/// > Chooser order: bundled templates by `template.order`, then keyed user templates by
/// > `template.order`, then keyless user boards last, sorted by display name.
///
/// The tiers are concatenated rather than sorted together, which is what makes "then" mean
/// *then*: a user template carrying `order: 1` still lists after every bundled one, because the
/// store it came from is the sort's outermost key. Within each tier the same two-step rule runs
/// (`sortedForChooser`), so the tier boundary is the only thing this function decides.
///
/// **Re-read on every call**, and the chooser calls it on every presentation: the store is not
/// watched (09 asks for a Reveal in Finder affordance, not a live folder), so a board dropped in
/// while the chooser is open appears the next time the chooser is opened or, since the drop
/// usually happens in the Finder window Reveal just opened, when the app comes back to the
/// front. `TemplateChooserView` wires both.
static func chooserRows(userStore store: URL = userStore) -> [TemplateRow] {
bundledTemplates().map(TemplateRow.template) + userRows(in: store)
}
/// 09's chooser order within one tier: keyed templates by `template.order`, then keyless ones by
/// display name. `localizedStandardCompare` for the names, the same Finder ordering every other
/// name listing in the app uses.
static func sortedForChooser(_ templates: [BoardTemplate]) -> [BoardTemplate] {
templates.sorted { left, right in
switch (left.order, right.order) {
chooserSorted(templates, order: \.order, name: \.name)
}
/// The same rule over chooser rows, which is where it actually meets 09's keyless tier: an
/// unloadable row reports no order at all, so it sorts by folder name among the keyless boards
/// without this comparison having to know what an unloadable row is.
static func sortedForChooser(_ rows: [TemplateRow]) -> [TemplateRow] {
chooserSorted(rows, order: \.order, name: \.name)
}
/// The comparison itself, once: keyed before keyless, `order` ascending among the keyed, name
/// among the rest and name as the tie-break between equal orders, so a hand-edited store with
/// two `order: 100`s still lists in a stable, explicable sequence.
private static func chooserSorted<Item>(
_ items: [Item],
order: (Item) -> Double?,
name: (Item) -> String
) -> [Item] {
items.sorted { left, right in
switch (order(left), order(right)) {
case let (leftOrder?, rightOrder?):
leftOrder == rightOrder
? left.name.localizedStandardCompare(right.name) == .orderedAscending
? name(left).localizedStandardCompare(name(right)) == .orderedAscending
: leftOrder < rightOrder
case (.some, .none):
true
case (.none, .some):
false
case (.none, .none):
left.name.localizedStandardCompare(right.name) == .orderedAscending
name(left).localizedStandardCompare(name(right)) == .orderedAscending
}
}
}
@@ -345,6 +417,194 @@ enum TemplateEngine {
}
}
// MARK: - Save as Template
/// The spacing between user templates' `template.order` values the bundled store's own
/// spacing (100, 200, 1000), so a hand-editor moving one template between two others has room
/// to write a number in the gap.
static let userOrderStep: Double = 100
/// The order the next Save as Template takes: **appended after existing user templates** (09
/// Save as Template), which is the highest `template.order` in the store plus one step.
///
/// Read off the store's own rows rather than off a counter, because the store is hand-editable
/// and a counter would be a second opinion about it. Keyless boards contribute nothing they
/// sort by name in their own tier and have no position to be appended after and neither does
/// an unloadable folder, which has no key to read.
static func nextUserOrder(in store: URL = userStore) -> Double {
guard let highest = rows(in: store, origin: .user).compactMap(\.order).max() else {
return userOrderStep
}
return highest + userOrderStep
}
/// Creates the user store if it is not there, and answers it.
///
/// **The store's two minters are Save as Template and Reveal in Finder** (see `userStore`, which
/// only names it): a store that exists because the app made it on the off-chance would be an
/// empty folder in Application Support for a user who never used the feature, while a Reveal
/// that opened nothing or a save that failed because its own home was missing would be the
/// app being pedantic about a directory it owns.
@discardableResult
static func createUserStore(at store: URL = userStore) throws -> URL {
try FileManager.default.createDirectory(at: store, withIntermediateDirectories: true)
return store
}
/// Copies the board at `rootURL` into the user templates store and answers where it landed
/// 09 Save as Template, whose whole contract is the four rules below.
///
/// - **`.git` and `.trash/` are dropped** the same two top-level exclusions instantiation
/// uses, for two different halves of one reason: a template is content, not history ("copying
/// it would embed the board's full repo, every attachment version included, in the template
/// store"), and a template is not a fork, so the board's trash is not part of what is being
/// saved. This is where Save as Template and File Duplicate part company Duplicate carries
/// both, because a duplicate *is* a fork (03-board-ui.md).
/// - **Everything else copies verbatim**: "Strays copy through `CLAUDE.user.md`, a seeded
/// `.gitignore`, and other non-schema files carry through Save as Template *and* instantiation
/// alike", along with GUIDs and timestamps both inert, since instantiation remints and
/// restamps at its own boundary.
/// - **A `template:` key is written on the copy** with an order appended after the existing user
/// templates, overwriting a stale one the board carried in from its own instantiation.
/// - **Store collisions auto-rename, Finder-style**, never overwrite and never refuse
/// (`BoardDuplicator.uncollidedURL(named:extension:in:)`, the ladder Duplicate seeds
/// differently).
///
/// **The close flush is the caller's**, not this function's: it needs a window session, and 09
/// states the rule where the command lives (`SaveAsTemplateCommand`, mirroring Duplicate's
/// sequence exactly).
///
/// `isCancelled` is read between items and nowhere else `BoardDuplicator`'s seam, and the
/// in-progress row's Cancel at the other end of it. Cancelled or failed, the partial store entry
/// goes: nothing was there before, so there is no true state for a half-copied template to be.
@discardableResult
static func saveAsTemplate(
boardAt rootURL: URL,
titled title: String?,
into store: URL = userStore,
isCancelled: () -> Bool = { Task.isCancelled }
) throws(Failure) -> URL {
let operation = WriteOperation.saveAsTemplate(title: title)
func failure(at url: URL, _ message: String) -> Failure {
.failed(BoardWriteError(operation: operation, path: url.path, reason: .io(message: message)))
}
// The store is minted here first save, first folder.
do {
try createUserStore(at: store)
} catch {
throw failure(at: store, "could not create the templates folder: \(error.localizedDescription)")
}
// **Read before the copy lands**, so the scan that decides "after the existing user
// templates" cannot see the template being appended and count it as existing.
let order = nextUserOrder(in: store)
let destination = BoardDuplicator.uncollidedURL(
named: rootURL.deletingPathExtension().lastPathComponent,
extension: rootURL.pathExtension,
in: store
)
// Cancelled before it began is still cancelled answered before anything is created.
if isCancelled() { throw .cancelled }
do {
try BoardTreeCopy.createDirectory(at: destination)
} catch {
throw failure(at: destination, "could not create the template folder: \(error.localizedDescription)")
}
do {
try copyBoard(at: rootURL, into: destination, operation: operation, isCancelled: isCancelled)
try stampTemplateKey(at: destination, order: order, operation: operation)
} catch {
// The ladder made this name and this call made this folder, so removing it destroys
// nothing that was the user's the instantiation cleanup's reasoning, at the store.
try? FileManager.default.removeItem(at: destination)
throw error
}
return destination
}
/// The copy half of a save: the board's tree minus the two exclusions.
///
/// **Folder attributes are not carried** (unlike Duplicate, which forks them). The store is
/// specified as "plain board folders, hand-editable and agent-writable" (09 Storage), and the
/// one lock Save as Template stays live under is the *unwritable-location* one a board on a
/// read-only DMG being archived (02-architecture.md Live-reload resilience). Carrying that
/// board's mode bits inward would mint a read-only template in the user's own store, which is
/// precisely the thing the store is not. Folder timestamps go with them and are inert: the
/// timestamps 09 keeps are the frontmatter's, and those ride inside files copied byte for byte.
private static func copyBoard(
at rootURL: URL,
into destination: URL,
operation: WriteOperation,
isCancelled: () -> Bool
) throws(Failure) {
do throws(BoardTreeCopy.Stop) {
try BoardTreeCopy.copy(
contentsOf: rootURL,
into: destination,
excludingTopLevel: [BoardLoader.trashFolderName, ".git"],
carryingFolderAttributes: false,
isCancelled: isCancelled
)
} catch {
switch error {
case .cancelled:
throw .cancelled
case let .failed(url, underlying):
throw .failed(BoardWriteError(
operation: operation,
path: url.path,
reason: .io(message: "could not copy the board: \(underlying.localizedDescription)")
))
}
}
}
/// Writes `template: {order: N}` on the copy's own `index.md`, through the Writer's ordinary
/// `updateIndex` the round-trip guarantee, the unknown-key preservation and the atomic replace
/// all come along, and the body (the board's description, which is about to be the chooser's
/// blurb) is never rewritten.
///
/// ### On the copy, after it lands
///
/// The only alternative stamping the source board and copying the result would write a
/// `template:` key into the user's *board*, which is not what was asked for. So the write
/// happens here, on a tree that is already in the store, and it needs **no write bracket**:
/// brackets exist to keep a watched board's live snapshot honest (02-architecture.md), and this
/// path is outside every watched board the store is not watched and the source was only read.
///
/// ### The whole mapping is rewritten, and that is 09's shape
///
/// "**`order` (display position in the chooser) is its only subkey**" (09 Definition format),
/// so replacing the mapping loses nothing that can exist today; a stale order from the board's
/// own instantiation is overwritten, which is exactly what 09 asks for. If the key ever grows a
/// second subkey, this is the one place that has to learn to merge nowhere else writes it.
private static func stampTemplateKey(
at root: URL,
order: Double,
operation: WriteOperation
) throws(Failure) {
do throws(BoardWriteError) {
try BoardWriter.updateIndex(inItemFolder: root, operation: operation) { document in
document.set(BoardLoader.templateKey, to: .raw("{order: \(orderText(order))}"))
}
} catch {
throw .failed(error)
}
}
/// `100` rather than `100.0` for a whole number, so the file reads like the bundled templates'
/// own `template: {order: 100}` the same rounding `FrontmatterValue` applies to a `.double`.
private static func orderText(_ order: Double) -> String {
order == order.rounded() && abs(order) < 1e15 ? String(Int64(order)) : String(order)
}
// MARK: - Naming
/// The document name behind a chosen URL `~/Boards/Roadmap.kanban` `Roadmap`.