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
+6 -1
View File
@@ -82,7 +82,12 @@ public enum BoardLoader: Sendable {
/// Board-level key for `BoardModel.template` not schema-owned in the engine's sense
/// (`FrontmatterKeys.schemaOwned`), because its value is opaque and read raw here rather
/// than through a typed `FrontmatterDocument` accessor.
private static let templateKey = "template"
///
/// Internal rather than `private` for `indexFileName`'s reason: `TemplateEngine` writes this
/// key on a Save as Template copy "the one writer of keyed files is Save as Template"
/// (09-templates.md Storage) and the reader and that one writer must never disagree about
/// how it is spelled.
static let templateKey = "template"
/// Internal rather than `private`: `BoardWriter` names the same file, and the loader and
/// the writer must never disagree about which file a folder's content lives in.
+10
View File
@@ -2328,6 +2328,14 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
/// the command nor the thing. `title` is the board's display name.
case duplicateBoard(title: String?)
/// File Save as Template the whole-board copy into the user templates store, and the
/// `template:` key stamped on the copy (09-templates.md Save as Template). Its own case beside
/// `.duplicateBoard`, on that case's own reasoning: both copy a board wholesale, but the user
/// pressed a different command with a different outcome, and a banner saying the app could not
/// "duplicate" a board when no duplicate was asked for would name a gesture that never happened.
/// `title` is the board's display name.
case saveAsTemplate(title: String?)
case importAttachment(filename: String)
case listAttachments
@@ -2412,6 +2420,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case .resize: .resize(title: title)
case .rename: .rename(title: title)
case .duplicateBoard: .duplicateBoard(title: title)
case .saveAsTemplate: .saveAsTemplate(title: title)
case .toggleTask: .toggleTask(title: title)
case .editBody: .editBody(title: title)
case .rawSource: .rawSource(title: title)
@@ -2439,6 +2448,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .resize(title): Self.phrase("resize", title)
case let .rename(title): Self.phrase("rename", title)
case let .duplicateBoard(title): Self.phrase("duplicate board", title)
case let .saveAsTemplate(title): Self.phrase("save as template", title)
case let .importAttachment(filename): "import attachment '\(filename)'"
case .listAttachments: "list attachments"
case let .removeAttachment(filename): "move attachment '\(filename)' to the Trash"