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:
@@ -98,3 +98,93 @@ struct BoardTemplate: Identifiable, Sendable, Equatable {
|
||||
/// `BoardModel`'s lanes, not a list of strings the app maintains by hand.
|
||||
var lanes: [Lane] { model.lanes }
|
||||
}
|
||||
|
||||
// MARK: - A chooser row
|
||||
|
||||
/// One row of the template chooser: **a template that loaded, or a folder that didn't**.
|
||||
///
|
||||
/// This is the either-shape 09-templates.md requires of the chooser and deliberately not of
|
||||
/// `BoardTemplate`:
|
||||
///
|
||||
/// > **One bad template never fails the chooser** (the user store is hand-editable, so a malformed
|
||||
/// > board there is one edit away): a user template the loader rejects is still listed — by folder
|
||||
/// > name, marked unloadable, carrying the loader's fail-fast specifics — but can't be instantiated
|
||||
/// > or previewed; fix the files and it comes back. (09 ▸ Why this format)
|
||||
///
|
||||
/// So the discovery walk answers with rows and `TemplateEngine.load` keeps answering with a
|
||||
/// `Result`: anything holding a `BoardTemplate` is still holding something instantiable, and the one
|
||||
/// place a failure is *rendered* is the one place that can carry it — the row.
|
||||
///
|
||||
/// **The unloadable half is a user-store shape only.** A bundled template that does not load is a
|
||||
/// build defect, logged and skipped (`TemplateEngine.bundledTemplates()`), because nobody looking at
|
||||
/// the chooser can fix an app's own resources.
|
||||
enum TemplateRow: Identifiable, Sendable {
|
||||
|
||||
case template(BoardTemplate)
|
||||
case unloadable(Unloadable)
|
||||
|
||||
/// A store folder the loader rejected — the URL and the loader's error, whole and unreworded.
|
||||
struct Unloadable: Identifiable, Sendable, Equatable {
|
||||
|
||||
let url: URL
|
||||
let error: BoardLoadError
|
||||
|
||||
var id: String { url.path }
|
||||
|
||||
/// **The folder name, sans extension** — "the failed load can supply neither
|
||||
/// `template.order` nor `title`, so the folder name is the only identity it has" (09
|
||||
/// ▸ Storage). Spelled the way every other board display name falls back
|
||||
/// (`AppModel.folderDisplayName(of:)`, `BoardTemplate.slug`), so a broken `Notes.kanban` and
|
||||
/// a working one sort and read alike rather than differing by four characters.
|
||||
var name: String { url.deletingPathExtension().lastPathComponent }
|
||||
}
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .template(template): template.id
|
||||
case let .unloadable(unloadable): unloadable.id
|
||||
}
|
||||
}
|
||||
|
||||
var url: URL {
|
||||
switch self {
|
||||
case let .template(template): template.url
|
||||
case let .unloadable(unloadable): unloadable.url
|
||||
}
|
||||
}
|
||||
|
||||
/// The name the chooser shows and the keyless tier sorts by.
|
||||
var name: String {
|
||||
switch self {
|
||||
case let .template(template): template.name
|
||||
case let .unloadable(unloadable): unloadable.name
|
||||
}
|
||||
}
|
||||
|
||||
/// `template.order`, and **always `nil` for an unloadable row** — 09 sorts it with the keyless
|
||||
/// tier for the reason that it has no key to read.
|
||||
var order: Double? {
|
||||
switch self {
|
||||
case let .template(template): template.order
|
||||
case .unloadable: nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The template behind the row, or `nil` — the whole of "can't be instantiated or previewed",
|
||||
/// expressed as the absence of the value both of those need.
|
||||
var template: BoardTemplate? {
|
||||
switch self {
|
||||
case let .template(template): template
|
||||
case .unloadable: nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The failure behind the row, or `nil` — the other half of the same either, so a caller holding
|
||||
/// an optional row can ask both questions without a nested `case .some(.unloadable(…))`.
|
||||
var unloadable: Unloadable? {
|
||||
switch self {
|
||||
case .template: nil
|
||||
case let .unloadable(unloadable): unloadable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user