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
+135 -2
View File
@@ -279,8 +279,141 @@ struct DuplicateBoardCommand: View {
}
}
/// The Cancel button's end of a running duplicate: the one piece of state the banner row's `cancel`
/// closure and the copy task have to share.
// MARK: - Save as Template
/// File Save as Template the board, into the user templates store (11-command-nexus.md: "Board
/// window; 09-templates.md").
///
/// ### It is Duplicate's sequence with a different destination
///
/// 09-templates.md Save as Template states the rule and names Duplicate in the same breath: "**The
/// copy is preceded by the close flush** so the template never misses the last keystrokes; the same
/// rule covers File Duplicate". So the shape here is `DuplicateBoardCommand`'s, step for step
/// flush, then a cancellable copy off the main actor under an in-progress row and the differences
/// are all in the engine (`TemplateEngine.saveAsTemplate(boardAt:titled:into:)`): the destination is
/// Application Support rather than a sibling, `.git` and `.trash/` are dropped rather than forked, a
/// collision auto-renames rather than failing, and a `template:` key lands on the copy.
///
/// **No save panel, ever.** The store is the app's own container "friction-free sandbox writes, no
/// location ceremony" (09 Storage) so there is no location question to ask, and therefore no
/// `.refused` outcome to answer: a permission failure writing inside our own container is an
/// ordinary failure with an ordinary banner.
///
/// ### The ending that speaks is the quiet one
///
/// A duplicate opens in a window, so it announces itself. A template lands in a folder nobody is
/// looking at, so this posts a **passive signpost** the info tone's calm half (02-architecture.md
/// § The banner surface: "Passive info rows rank last and may collapse calm by design"). It names
/// the template rather than only the board, because that is where a Finder-style auto-rename becomes
/// visible: "Saved 'Roadmap' as the template 'Roadmap 2'" is the only place the user is told which
/// one they just made. A cancel says nothing (the partial is gone, the duplicate rule verbatim), and
/// a failure is the ordinary one-shot banner.
///
/// ### Validation is Duplicate's, minus 09's one carve-out
///
/// Board window only, and disabled under the read-only lock with the exception 09 spells out and
/// 02-architecture.md Live-reload resilience scopes: **under the unwritable-location lock alone it
/// stays live**, because it "reads the board and writes Application Support" (archiving the
/// read-only DMG board being inspected is a legitimate errand), *unless* an open Edit or raw-source
/// session holds unsaved content content that lock's suspended saves cannot flush, and which the
/// template would therefore silently miss. The other two locks disable it outright: a vanished root
/// has nothing to copy, and a board whose last reload failed is a tree whose state is least known.
///
/// The focused-editor half of `acceptsBoardMutations` is kept in every branch, for
/// `DuplicateBoardCommand`'s reason: an open inline title editor holds the one pending change no
/// flush can reach, and a template taken mid-rename would miss the edit being made.
struct SaveAsTemplateCommand: View {
let appModel: AppModel
@FocusedValue(\.boardStore) private var store
@FocusedValue(\.boardWindowRef) private var ref
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "templates")
var body: some View {
Button("Save as Template") {
save()
}
.disabled(!canSave)
}
private var canSave: Bool {
guard let store, let ref, !store.isEditingInline else { return false }
switch store.readOnlyLock {
case .none:
return true
case .unwritableLocation:
return !appModel.hasUnsavedCardContent(for: ref)
case .vanishedRoot, .bracketedReloadFailed:
return false
}
}
private func save() {
guard canSave, let store, let ref else { return }
let name = AppModel.displayName(of: store)
let source = store.rootURL
Task { @MainActor in
let cancellation = DuplicateCancellation()
// Copy-shaped work, so the row carries Cancel "remove the partial copy, nothing lost"
// (02 § The banner surface), which the engine honors by removing the partial store entry.
let operation = store.banners.beginOperation(
label: "Saving '\(name)' as a template…",
cancel: { cancellation.cancel() }
)
defer { store.banners.endOperation(operation) }
await appModel.flushPendingWork(for: ref)
// Cancelled during the flush: the copy never starts, rather than starting and being told
// to stop (`DuplicateBoardCommand.copy(_:titled:into:cancellation:)`'s guard, verbatim).
guard !cancellation.isCancelled else { return }
// Detached, for the two halves of Duplicate's reason: the copy is real I/O on a board
// that may carry a large `.git` a spinner drawn by a blocked main thread is a still
// picture and a detached task's cancellation is only ever this row's Cancel.
let task = Task.detached(priority: .userInitiated) {
try TemplateEngine.saveAsTemplate(boardAt: source, titled: name)
}
cancellation.attach(task)
do {
let landed = try await task.value
store.banners.postSignpost(
Self.savedMessage(board: name, template: TemplateEngine.documentName(of: landed))
)
} catch TemplateEngine.Failure.cancelled {
Self.logger.notice("save as template cancelled — the partial template was removed")
} catch let TemplateEngine.Failure.failed(error) {
Self.logger.error("save as template failed: \(error.description, privacy: .public)")
store.banners.post(error)
} catch {
let write = BoardWriteError(
operation: .saveAsTemplate(title: name),
path: source.path,
reason: .io(message: error.localizedDescription)
)
Self.logger.error("save as template failed: \(write.description, privacy: .public)")
store.banners.post(write)
}
}
}
/// The signpost's line. The template is named only when the store's collision ladder gave it a
/// different one saying "Saved 'Roadmap' as the template 'Roadmap'" would be noise, while
/// leaving the rename unsaid would hide the one thing about this save the user could not predict.
static func savedMessage(board: String, template: String) -> String {
board == template
? "Saved '\(board)' as a template"
: "Saved '\(board)' as the template '\(template)'"
}
}
/// The Cancel button's end of a running board copy File Duplicate's and File Save as
/// Template's alike: the one piece of state the banner row's `cancel` closure and the copy task have
/// to share.
///
/// **A main-actor box rather than a lock**, because there is nothing here to race over: the row's
/// `cancel` is `@MainActor @Sendable`, and the task is created and attached on the same actor. The