Build the template engine — board-as-template instantiation

A template is a board folder the ordinary loader reads — no second
schema, no Swift catalog. BoardTemplate became exactly that: a loaded
BoardModel with chooser-facing derivations, the lane-title stub gone.
TemplateEngine instantiates by the copy-remint-restamp walk: .git and
.trash excluded at top level only — both names mean something at a
board root and nowhere else, and .gitignore must survive — every
materialized folder reminted, created/modified stamped fresh (born
today, not forked), modified-by cleared, the template: key carried
inert, the blurb and style inherited, and loose card files normalized
at this import boundary per the paste precedent so a new board never
opens with a notice about a mess its own birth made. Legacy deleted:
keys copy through verbatim to the one migrator — stripping would
resurrect, skipping would destroy. Atomicity is construct-then-clean:
a sibling temp can be sandbox-refused and a cross-volume rename is
just a second copy, so the call removes what it created on every
non-board exit and never touches an occupied destination. The
cancellable per-item walk extracted into BoardTreeCopy serves
Duplicate and instantiation with two parameters — top-level exclusions
and folder-attribute carriage, the only axes they differ on.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 18:46:23 -04:00
parent 797d020d01
commit b6f559375b
13 changed files with 1446 additions and 316 deletions
+73 -36
View File
@@ -4,13 +4,21 @@ import os
/// The template chooser File New Board (N), 09-templates.md's picker.
///
/// ### Pages' shape, one card in it
/// ### Pages' shape, and the templates are real board folders now
///
/// A grid of template cards, each showing a **mini per-lane preview** above its name, one selected
/// at a time, with Cancel and Choose at the bottom (03-board-ui.md § Welcome screen & templates: "a
/// Pages-style chooser with a mini per-lane preview per template"). The grid holds exactly one card
/// today because exactly one template exists (`BoardTemplate`); everything about the layout is
/// already the plural case, so the m9 inventory drops in without the surface changing shape.
/// Pages-style chooser with a mini per-lane preview per template"). The grid is filled by
/// `TemplateEngine.bundledTemplates()` the app bundle's `Templates/` folder, each entry loaded
/// through the ordinary `BoardLoader` so name, blurb, icon and preview all come off the template
/// board's own `index.md` rather than from a Swift catalog.
///
// m9-templates: the full chooser is its own card. What is still missing here is the *user* tier
// (`TemplateEngine.userStore`, listed after the bundled ones), the unloadable-template row, Reveal
// in Finder, and the in-progress row with Cancel that copy-shaped work is owed (02-architecture.md
// § The banner surface) this window has no banner surface to host one yet. The engine already
// takes the cancellation seam (`TemplateEngine.instantiate(, isCancelled:)`); this view runs the
// copy off the main actor so that row has something to spin over when it arrives.
///
/// ### Choosing is three steps, and the middle one is a save panel
///
@@ -35,12 +43,17 @@ struct TemplateChooserView: View {
@Environment(AppModel.self) private var appModel
@Environment(\.dismiss) private var dismiss
@State private var selection: BoardTemplate.ID = BoardTemplate.basic.id
/// Discovered when the window appears, and not again: the bundle's `Templates/` folder cannot
/// change under a running app, and discovery *loads every template board* a default-value
/// initializer would re-run it each time SwiftUI rebuilt this struct.
@State private var templates: [BoardTemplate] = []
@State private var selection: BoardTemplate.ID?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "templates")
private var selected: BoardTemplate? {
BoardTemplate.all.first { $0.id == selection }
templates.first { $0.id == selection } ?? templates.first
}
var body: some View {
@@ -52,6 +65,10 @@ struct TemplateChooserView: View {
footer
}
.frame(width: 620, height: 460)
.onAppear {
guard templates.isEmpty else { return }
templates = TemplateEngine.bundledTemplates()
}
}
// MARK: Header
@@ -73,13 +90,13 @@ struct TemplateChooserView: View {
private var grid: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 170), spacing: 20)], spacing: 20) {
ForEach(BoardTemplate.all) { template in
TemplateCard(template: template, isSelected: template.id == selection)
ForEach(templates) { template in
TemplateCard(template: template, isSelected: template.id == selected?.id)
.onTapGesture { selection = template.id }
// The list convention welcome's recents use, for the same reason: a
// double click is how a chooser is answered without reaching for a button.
.onTapGesture(count: 2) { choose() }
.accessibilityAddTraits(template.id == selection ? [.isSelected] : [])
.accessibilityAddTraits(template.id == selected?.id ? [.isSelected] : [])
}
}
.padding(20)
@@ -113,42 +130,58 @@ struct TemplateChooserView: View {
/// Panel, instantiate, open and only then dismiss, so a cancelled panel leaves the chooser
/// exactly as the user left it.
///
/// The copy runs in a detached task, `DuplicateBoardCommand`'s reasoning at a smaller scale: a
/// user template can be a real board with real attachments, and a main thread blocked inside a
/// tree copy is a frozen window. Detached rather than a child task so its cancellation is only
/// ever the one a Cancel affordance hands it, never something inherited.
private func choose() {
guard let template = selected, let url = Self.chooseLocation(for: template) else { return }
let title = TemplateEngine.documentName(of: url)
do {
try template.instantiate(at: url)
} catch {
Self.logger.error("template instantiation failed: \(error.description, privacy: .public)")
Self.present(error)
return
Task { @MainActor in
let outcome = await Task.detached(priority: .userInitiated) {
() -> Result<URL, TemplateEngine.Failure> in
do throws(TemplateEngine.Failure) {
return .success(try TemplateEngine.instantiate(template: template, to: url, title: title))
} catch {
return .failure(error)
}
}.value
switch outcome {
case .success:
dismiss()
// The ordinary open path, so the new board joins recents, gets its bookmark, and
// closes welcome on the way in exactly like a board opened from a row.
appModel.openBoard(at: url)
case .failure(.cancelled):
// Nothing was created and nothing failed, so nothing is said the duplicate rule.
Self.logger.notice("template instantiation cancelled — the partial board was removed")
case let .failure(.failed(error)):
Self.logger.error("template instantiation failed: \(error.description, privacy: .public)")
Self.present(error)
}
}
dismiss()
// The ordinary open path, so the new board joins recents, gets its bookmark, and closes
// welcome on the way in exactly like a board opened from a row.
appModel.openBoard(at: url)
}
/// The save panel where the board goes and what it is called.
///
/// `"Untitled.kanban"` is the suggestion; the package extension is visible and editable, because
/// an extension-less board folder is equally legal (01-storage-format.md § Document packaging)
/// and deleting the suffix should therefore work rather than be silently undone.
///
// m9-templates: 09 Instantiation seeds this name from the template's own title once templates
// have titles of their own ("Basic.kanban", "Bug Tracker.kanban"). With one stub template a
// suggestion of "Basic" would name the *template*, not the user's board, which is worse than
// Untitled.
/// **The suggestion is the template's own title** (`"Basic.kanban"`, `"Bug Tracker.kanban"`)
/// 09 Instantiation: "seed the save panel's suggested name from the template title". Whatever
/// the user types instead becomes the new board's `title` as well as its folder name, so the two
/// start out matching (01-storage-format.md § Board naming). The package extension is visible and
/// editable, because an extension-less board folder is equally legal (§ Document packaging) and
/// deleting the suffix should therefore work rather than be silently undone.
///
/// A name that already exists gets the panel's own replace prompt; agreeing to it does not delete
/// anything (the panel never does), so `BoardWriter.createBoard`'s refusal to clobber an existing
/// board is what the user sees as an alert, naming the path. That is the honest outcome: this
/// flow is a *create*, and quietly replacing a board with an empty one is not a thing it should
/// be able to do.
/// anything (the panel never does), so the engine's refusal to clobber an existing board is what
/// the user sees as an alert, naming the path. That is the honest outcome: this flow is a
/// *create*, and quietly replacing a board with an empty one is not a thing it should be able to
/// do.
private static func chooseLocation(for template: BoardTemplate) -> URL? {
let panel = NSSavePanel()
panel.nameFieldStringValue = "Untitled.kanban"
panel.nameFieldStringValue = TemplateEngine.suggestedFileName(for: template)
panel.canCreateDirectories = true
panel.isExtensionHidden = false
panel.allowsOtherFileTypes = true
@@ -202,15 +235,19 @@ private struct TemplateCard: View {
/// The mini per-lane preview: one column per lane, each a title bar over a couple of card shapes.
///
/// Deliberately abstract no text, because the point is the *shape* of the board and legible lane
/// names at this size are not available. It renders from `laneTitles` only for the count and the
/// stable identity of each column.
/// names at this size are not available. It renders **from the template's loaded `BoardModel`**
/// (09-templates.md Why this format: "The picker's mini per-lane preview renders from a real
/// `BoardModel` via the normal loader"), taking the lane count and each lane's identity from it.
///
// m9-templates: the card shapes are still decoration a lane's *real* starter cards (templates
// "may contain starter cards") should be what the column draws, once the chooser card gets to it.
private struct TemplatePreview: View {
let template: BoardTemplate
var body: some View {
HStack(alignment: .top, spacing: 6) {
ForEach(Array(template.laneTitles.enumerated()), id: \.offset) { index, _ in
ForEach(Array(template.lanes.enumerated()), id: \.element.id) { index, _ in
VStack(spacing: 4) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.accentColor.opacity(0.65))