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:
@@ -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
|
||||
|
||||
@@ -710,6 +710,22 @@ public final class AppModel {
|
||||
await coordinator(for: ref).flushPendingWork()
|
||||
}
|
||||
|
||||
/// Whether any of this board's card windows is holding content the files do not have — a dirty
|
||||
/// Edit buffer or a typed-in raw-source outlet (`CardSessionFlushing.holdsUnsavedContent`).
|
||||
///
|
||||
/// **One caller, one rule**: File ▸ Save as Template's carve-out from the read-only lock. Under
|
||||
/// the unwritable-location lock the item stays live — "reads the board, writes Application
|
||||
/// Support" — but only while no such session exists, because the lock has suspended exactly the
|
||||
/// saves that would flush one and 09-templates.md's never-misses-keystrokes guarantee outranks
|
||||
/// the item's availability (02-architecture.md ▸ Live-reload resilience, settled scoping).
|
||||
///
|
||||
/// It asks the sessions rather than the store: the content in question is in *memory*, in the
|
||||
/// card windows, which is the whole reason the flush cannot reach it.
|
||||
public func hasUnsavedCardContent(for ref: BoardWindowRef) -> Bool {
|
||||
guard let session = sessions[ref] else { return false }
|
||||
return session.cardRefs.contains { cardSessions[$0]?.holdsUnsavedContent == true }
|
||||
}
|
||||
|
||||
/// Quit: the same sequence, once per open board, **sequentially**.
|
||||
///
|
||||
/// Sequential rather than concurrent so each board's ordering is the one 02 fixes rather than
|
||||
|
||||
@@ -100,18 +100,40 @@ enum BoardDuplicator {
|
||||
/// the sandbox will not let us write is usually one we cannot list either, so the ladder simply
|
||||
/// finds no collision and suggests `"Board copy"` — the right pre-fill, arrived at honestly.
|
||||
static func copyDestination(for rootURL: URL) -> URL {
|
||||
let parent = rootURL.deletingLastPathComponent()
|
||||
let base = rootURL.deletingPathExtension().lastPathComponent
|
||||
let ext = rootURL.pathExtension
|
||||
uncollidedURL(
|
||||
named: "\(rootURL.deletingPathExtension().lastPathComponent) copy",
|
||||
extension: rootURL.pathExtension,
|
||||
in: rootURL.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
/// **Finder's counting ladder, in one place**: `name`, then `name 2`, `name 3`, … counting up
|
||||
/// from 2 against what is on disk at decision time, one collision at a time.
|
||||
///
|
||||
/// Shared rather than spelled twice, because two flows want the same ladder from different
|
||||
/// starting names: Duplicate seeds it with `"Board copy"` (above), and Save as Template seeds it
|
||||
/// with the board's own name — 09-templates.md ▸ Save as Template's "**Store collisions
|
||||
/// auto-rename, Finder-style** (`Board.kanban` → `Board 2.kanban`) — the 01 import precedent:
|
||||
/// saving never overwrites an existing template and never refuses". One ladder, two seeds, so
|
||||
/// the two can never disagree about what "Finder-style" means.
|
||||
///
|
||||
/// The extension rides on the end (`Board copy.kanban`) and an extension-less board folder
|
||||
/// simply has none to carry — both are shapes a board is allowed to be (01-storage-format.md
|
||||
/// § Document packaging).
|
||||
///
|
||||
/// `fileExists` is the one test, and it is true for a file as much as a folder: anything already
|
||||
/// wearing the name blocks it, which is what keeps either flow from overwriting something. It is
|
||||
/// a decision-time answer, not a reservation — the caller still creates the folder itself and
|
||||
/// still fails rather than clobbers if the name was taken in between.
|
||||
static func uncollidedURL(named base: String, extension ext: String, in parent: URL) -> URL {
|
||||
func candidate(_ name: String) -> URL {
|
||||
parent.appendingPathComponent(ext.isEmpty ? name : "\(name).\(ext)", isDirectory: true)
|
||||
}
|
||||
|
||||
var name = "\(base) copy"
|
||||
var name = base
|
||||
var counter = 2
|
||||
while FileManager.default.fileExists(atPath: candidate(name).path) {
|
||||
name = "\(base) copy \(counter)"
|
||||
name = "\(base) \(counter)"
|
||||
counter += 1
|
||||
}
|
||||
return candidate(name)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,19 @@ final class CardWindowSession: CardSessionFlushing {
|
||||
/// Write-failure surfacing: "the one modal moment on the write-failure path").
|
||||
let bufferGuard: DirtyBufferGuard
|
||||
|
||||
/// The window's raw-source outlet, wired in by the host once the window exists.
|
||||
///
|
||||
/// A closure rather than a stored reference, `CardBodyEditSession.save`'s precedent: the outlet
|
||||
/// is window state living beside this object (`CardWindowHost.rawSource`) rather than inside it,
|
||||
/// and a session that reached into the view's state would be the wrong direction. `nil` — a
|
||||
/// window that has not joined its board — holds nothing, which is true.
|
||||
var rawSourceHoldsUnsavedText: (@MainActor () -> Bool)?
|
||||
|
||||
/// The Edit buffer's dirty text or a typed-in raw-source outlet — see `CardSessionFlushing`.
|
||||
var holdsUnsavedContent: Bool {
|
||||
body.isDirty || rawSourceHoldsUnsavedText?() == true
|
||||
}
|
||||
|
||||
private var hasEnded = false
|
||||
|
||||
/// Both `let`s, wired to each other through a local — the guard's two closures need the buffer,
|
||||
@@ -430,6 +443,9 @@ struct CardWindowHost: View {
|
||||
store: store,
|
||||
cardID: cardID
|
||||
)
|
||||
// The other half of the outlet's wiring: the session answers for this window's unsaved
|
||||
// content, and the outlet is the half that does not live inside it (`CardWindowSession`).
|
||||
session.rawSourceHoldsUnsavedText = { [rawSource] in rawSource.holdsUnsavedText }
|
||||
Self.configureAttachments(attachments, store: store, cardID: cardID)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,23 @@ public enum BoardCloseCause: Sendable, Equatable {
|
||||
@MainActor
|
||||
public protocol CardSessionFlushing: AnyObject {
|
||||
func endSession() async
|
||||
|
||||
/// Whether this window is holding content the file does not have — a dirty Edit buffer, or an
|
||||
/// open raw-source outlet that has been typed in.
|
||||
///
|
||||
/// Beside `endSession()` because it is the same fact from the other end: this is what the flush
|
||||
/// *would* write, asked before running it. Its one caller is File ▸ Save as Template's
|
||||
/// validation, which under the unwritable-location read-only lock stays live only "unless an
|
||||
/// open Edit or raw-source session holds unsaved content the suspended saves can't flush"
|
||||
/// (02-architecture.md ▸ Live-reload resilience; 09-templates.md ▸ Save as Template) — a
|
||||
/// template that silently missed those keystrokes would break 09's never-misses-keystrokes
|
||||
/// guarantee, which outranks the item's availability.
|
||||
var holdsUnsavedContent: Bool { get }
|
||||
}
|
||||
|
||||
public extension CardSessionFlushing {
|
||||
func endSession() async {}
|
||||
var holdsUnsavedContent: Bool { false }
|
||||
}
|
||||
|
||||
// MARK: - CloseFlushCoordinator
|
||||
|
||||
@@ -36,22 +36,13 @@ struct FutureCommand: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File ▸ Save as Template
|
||||
|
||||
/// File ▸ Save as Template — no default chord (11-command-nexus.md; 09-templates.md).
|
||||
///
|
||||
// m9-templates: copies the open board into the user templates store, close-flushed first exactly as
|
||||
// Duplicate is (09 ▸ Save as Template: "The copy is preceded by the close flush"), `.git` stripped,
|
||||
// `.trash/` excluded, a `template:` key stamped. Validation will be `acceptsBoardMutations` plus 09's
|
||||
// one carve-out from the read-only lock — live under the unwritable-location state unless an open
|
||||
// Edit/raw-source session holds unsaved content — so it cannot simply borrow
|
||||
// `DuplicateBoardCommand`'s predicate outright.
|
||||
struct SaveAsTemplateCommand: View {
|
||||
var body: some View {
|
||||
FutureCommand(title: "Save as Template")
|
||||
}
|
||||
}
|
||||
|
||||
// File ▸ Save as Template was the scaffold here and is now live, beside the command it mirrors
|
||||
// (`SaveAsTemplateCommand`, in `AppCommands.swift` next to `DuplicateBoardCommand`, whose flush →
|
||||
// cancellable copy → banner sequence it repeats with a different destination). The diff this file
|
||||
// predicts, once more: the title did not move, the chord stayed absent, the validation and the
|
||||
// action filled in — the last of them being 09's carve-out from the read-only lock, which is why it
|
||||
// could not simply borrow Duplicate's predicate.
|
||||
//
|
||||
// File ▸ Add Attachment… (⇧⌘A) was the scaffold here and is now live, beside the focused value it
|
||||
// reads (`AddAttachmentCommand`, in `CardAttachments.swift`) — the diff this file predicts: the
|
||||
// title and the chord did not move, the validation and the action filled in. The attachment row's
|
||||
|
||||
@@ -4,21 +4,40 @@ import os
|
||||
|
||||
/// The template chooser — File ▸ New Board… (⌥⌘N), 09-templates.md's picker.
|
||||
///
|
||||
/// ### Pages' shape, and the templates are real board folders now
|
||||
/// ### Pages' shape, over real board folders
|
||||
///
|
||||
/// 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
|
||||
/// 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 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.
|
||||
/// `TemplateEngine.chooserRows()` — the app bundle's `Templates/` folder, then the user store — 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.
|
||||
/// **The order is 09's, and it is the engine's** (09 ▸ Storage: "bundled templates by
|
||||
/// `template.order`, then keyed user templates by `template.order`, then keyless user boards last,
|
||||
/// sorted by display name"). This view renders the list it is handed and never re-sorts it.
|
||||
///
|
||||
/// ### One bad template never fails the chooser
|
||||
///
|
||||
/// A user-store folder the loader rejects is **listed anyway** — by folder name, marked unloadable,
|
||||
/// carrying the loader's fail-fast specifics, not previewable and not choosable (09 ▸ Why this
|
||||
/// format). The store is hand-editable, so a malformed board there is one edit away; a chooser that
|
||||
/// refused to open, or that silently dropped the row, would leave the user with no way to see which
|
||||
/// folder is broken or why. Selecting the row shows the loader's own sentence where a blurb would be.
|
||||
///
|
||||
/// ### Reveal in Finder, and how fresh the list is
|
||||
///
|
||||
/// 09 keeps the Application Support store honest with "a **Reveal in Finder** affordance in the
|
||||
/// template chooser": the button beside the header, which **creates the store and then reveals it**
|
||||
/// (`TemplateEngine.createUserStore`) — the store's two minters are Save as Template and this, so a
|
||||
/// user who has never saved one still gets a folder to drop a board into rather than a Finder window
|
||||
/// full of nothing.
|
||||
///
|
||||
/// **The store is not watched** — 09 asks for a Reveal affordance, not a live folder — so the list is
|
||||
/// re-read on every presentation and again whenever the app comes back to the front. The second is
|
||||
/// the Reveal round trip made honest: the user reveals the folder, drops a board in, comes back, and
|
||||
/// the row is there. A folder dropped in with this window already frontmost appears on the next
|
||||
/// activation or the next opening, which is the documented minimum rather than an oversight.
|
||||
///
|
||||
/// ### Choosing is three steps, and the middle one is a save panel
|
||||
///
|
||||
@@ -28,6 +47,13 @@ import os
|
||||
/// panel's suggested name"). The chooser stays open if the panel is cancelled — a cancelled location
|
||||
/// is not a cancelled choice.
|
||||
///
|
||||
// m9-templates: the in-progress row with Cancel that copy-shaped work is owed (02-architecture.md
|
||||
// § The banner surface) still has nowhere to live here — this window has no banner surface, and the
|
||||
// board that would host one does not exist yet. The engine takes the cancellation seam
|
||||
// (`TemplateEngine.instantiate(…, isCancelled:)`) and this view runs the copy off the main actor, so
|
||||
// the row has something to spin over when the window grows a strip. Save as Template, whose copy
|
||||
// *does* have a board window behind it, already carries its row.
|
||||
///
|
||||
/// ### Failure is an alert here, deliberately
|
||||
///
|
||||
/// Everywhere else in the app a failed write is a banner in the window that produced it
|
||||
@@ -43,17 +69,19 @@ struct TemplateChooserView: View {
|
||||
@Environment(AppModel.self) private var appModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// 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] = []
|
||||
/// The chooser's rows — both tiers, in 09's order, re-read on presentation and on activation
|
||||
/// (see the type's doc). `@State` rather than a computed property because discovery *loads every
|
||||
/// template board*, and a computed one would re-run that on every SwiftUI rebuild.
|
||||
@State private var rows: [TemplateRow] = []
|
||||
|
||||
@State private var selection: BoardTemplate.ID?
|
||||
@State private var selection: TemplateRow.ID?
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "templates")
|
||||
|
||||
private var selected: BoardTemplate? {
|
||||
templates.first { $0.id == selection } ?? templates.first
|
||||
/// The selected row, defaulting to the first — which is Basic, the bundled tier's lowest order,
|
||||
/// so the chooser always opens with something choosable in hand.
|
||||
private var selected: TemplateRow? {
|
||||
rows.first { $0.id == selection } ?? rows.first
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -64,24 +92,49 @@ struct TemplateChooserView: View {
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.frame(width: 620, height: 460)
|
||||
.onAppear {
|
||||
guard templates.isEmpty else { return }
|
||||
templates = TemplateEngine.bundledTemplates()
|
||||
.frame(width: 620, height: 480)
|
||||
.task {
|
||||
rescan()
|
||||
// Returning to the foreground is when a folder dropped into the revealed store becomes
|
||||
// this window's problem — `ClipboardStore`'s activation observer, in the shape a view can
|
||||
// hold: the sequence ends with the task, which ends with the window.
|
||||
for await _ in NotificationCenter.default.notifications(named: NSApplication.didBecomeActiveNotification) {
|
||||
rescan()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-reads both stores, keeping the selection if the row it named is still there.
|
||||
private func rescan() {
|
||||
rows = TemplateEngine.chooserRows()
|
||||
if let selection, !rows.contains(where: { $0.id == selection }) {
|
||||
self.selection = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Header
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Choose a Template")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Every template is an ordinary board — lanes and cards you can change afterwards.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Choose a Template")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Every template is an ordinary board — lanes and cards you can change afterwards.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 16)
|
||||
|
||||
// 09's honesty affordance: the store is plain board folders, and this is where the user
|
||||
// is shown that. It mints the folder on the way — see the type's doc.
|
||||
Button {
|
||||
revealUserStore()
|
||||
} label: {
|
||||
Label("My Templates", systemImage: "folder")
|
||||
}
|
||||
.help("Reveal your templates folder in the Finder. Any board folder you put there becomes a template.")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
@@ -90,13 +143,13 @@ struct TemplateChooserView: View {
|
||||
private var grid: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 170), spacing: 20)], spacing: 20) {
|
||||
ForEach(templates) { template in
|
||||
TemplateCard(template: template, isSelected: template.id == selected?.id)
|
||||
.onTapGesture { selection = template.id }
|
||||
ForEach(rows) { row in
|
||||
TemplateCard(row: row, isSelected: row.id == selected?.id)
|
||||
.onTapGesture { selection = row.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 == selected?.id ? [.isSelected] : [])
|
||||
.accessibilityAddTraits(row.id == selected?.id ? [.isSelected] : [])
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
@@ -107,12 +160,22 @@ struct TemplateChooserView: View {
|
||||
|
||||
// MARK: Footer
|
||||
|
||||
/// The blurb — or, for an unloadable row, **the loader's own sentence**: fail-fast's specifics,
|
||||
/// unreworded, in the place the description would have been. It is the whole of what the user
|
||||
/// needs to go and fix the file.
|
||||
private var footer: some View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(selected?.blurb ?? "")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
if let unloadable = selected?.unloadable {
|
||||
Label(BannerCenter.headline(for: unloadable.error), systemImage: "exclamationmark.triangle")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else {
|
||||
Text(selected?.template?.blurb ?? "")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer(minLength: 16)
|
||||
|
||||
@@ -121,11 +184,30 @@ struct TemplateChooserView: View {
|
||||
|
||||
Button("Choose") { choose() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(selected == nil)
|
||||
// An unloadable row "can't be instantiated or previewed" (09), which is this line.
|
||||
.disabled(selected?.template == nil)
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
// MARK: - Reveal
|
||||
|
||||
/// Creates the user store if it isn't there, then reveals it — 09's affordance, and one of the
|
||||
/// store's two minters (`TemplateEngine.createUserStore`).
|
||||
///
|
||||
/// A store that cannot be created is logged and *still* revealed at its parent by
|
||||
/// `activateFileViewerSelecting`, which is the honest failure: something is wrong with
|
||||
/// Application Support, and the user is standing where they can see it.
|
||||
private func revealUserStore() {
|
||||
let store = TemplateEngine.userStore
|
||||
do {
|
||||
try TemplateEngine.createUserStore(at: store)
|
||||
} catch {
|
||||
Self.logger.error("could not create the user template store: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
NSWorkspace.shared.activateFileViewerSelecting([store])
|
||||
}
|
||||
|
||||
// MARK: - Choosing
|
||||
|
||||
/// Panel, instantiate, open — and only then dismiss, so a cancelled panel leaves the chooser
|
||||
@@ -136,7 +218,7 @@ struct TemplateChooserView: View {
|
||||
/// 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 }
|
||||
guard let template = selected?.template, let url = Self.chooseLocation(for: template) else { return }
|
||||
let title = TemplateEngine.documentName(of: url)
|
||||
|
||||
Task { @MainActor in
|
||||
@@ -204,16 +286,22 @@ struct TemplateChooserView: View {
|
||||
|
||||
// MARK: - Template card
|
||||
|
||||
/// One template in the grid: its mini per-lane preview, its name, and the selection ring.
|
||||
/// One row in the grid: its preview, its name, and the selection ring — or, for a folder that did not
|
||||
/// load, the same frame with an unloadable badge where the preview would be.
|
||||
///
|
||||
/// The two cases share a frame deliberately: an unloadable template is **the same kind of thing** as
|
||||
/// the ones beside it, one edit away from working (09), so hiding it in a separate list would say the
|
||||
/// opposite of what 09 means by "still listed".
|
||||
private struct TemplateCard: View {
|
||||
|
||||
let template: BoardTemplate
|
||||
let row: TemplateRow
|
||||
let isSelected: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
TemplatePreview(template: template)
|
||||
content
|
||||
.frame(height: 96)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color(nsColor: .textBackgroundColor)))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
@@ -221,43 +309,83 @@ private struct TemplateCard: View {
|
||||
lineWidth: isSelected ? 3 : 1)
|
||||
)
|
||||
|
||||
Label(template.name, systemImage: template.icon)
|
||||
Label(row.name, systemImage: icon)
|
||||
.font(.callout)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(template.name)
|
||||
.accessibilityHint(template.blurb)
|
||||
.accessibilityLabel(row.name)
|
||||
.accessibilityHint(hint)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch row {
|
||||
case let .template(template):
|
||||
TemplatePreview(template: template)
|
||||
case .unloadable:
|
||||
// No preview, because there is no board to preview — the badge says why the tile is
|
||||
// empty rather than leaving it looking like a template with no lanes.
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.title2)
|
||||
Text("Can't be read")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var icon: String {
|
||||
switch row {
|
||||
case let .template(template): template.icon
|
||||
case .unloadable: "exclamationmark.triangle"
|
||||
}
|
||||
}
|
||||
|
||||
private var hint: String {
|
||||
switch row {
|
||||
case let .template(template): template.blurb
|
||||
case let .unloadable(unloadable): BannerCenter.headline(for: unloadable.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mini per-lane preview: one column per lane, each a title bar over a couple of card shapes.
|
||||
/// The mini per-lane preview: one column per lane, a tinted title bar over the lane's **own cards**.
|
||||
///
|
||||
/// 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 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.
|
||||
/// 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"), and every
|
||||
/// mark in it is read off that model rather than decorated in: the number of columns is the lane
|
||||
/// count, each column's tint is the lane's own `iconColor` through `Palette`, and the number of card
|
||||
/// shapes is how many cards the lane actually holds — templates "may contain starter cards", so a
|
||||
/// board with a "How this board works" card in its first lane looks different here from one without.
|
||||
///
|
||||
// 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.
|
||||
/// Deliberately **textless**: legible lane names are not available at this size, and the point of the
|
||||
/// preview is the shape of the board. A lane with no cards draws an empty column, which is the honest
|
||||
/// picture of an empty lane rather than a decorative one.
|
||||
private struct TemplatePreview: View {
|
||||
|
||||
let template: BoardTemplate
|
||||
|
||||
/// How many lanes and cards a tile can show before the marks stop being distinguishable. A
|
||||
/// template with more of either is truncated rather than shrunk to threads — the preview is an
|
||||
/// impression, and every bundled template fits inside both.
|
||||
private static let laneLimit = 6
|
||||
private static let cardLimit = 4
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
ForEach(Array(template.lanes.enumerated()), id: \.element.id) { index, _ in
|
||||
HStack(alignment: .top, spacing: 5) {
|
||||
ForEach(template.lanes.prefix(Self.laneLimit)) { lane in
|
||||
VStack(spacing: 4) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.accentColor.opacity(0.65))
|
||||
.fill(Self.tint(of: lane))
|
||||
.frame(height: 5)
|
||||
// A descending number of cards, so the preview reads as work in flight rather
|
||||
// than as three identical columns.
|
||||
ForEach(0..<max(1, 3 - index), id: \.self) { _ in
|
||||
ForEach(0 ..< min(lane.cards.count, Self.cardLimit), id: \.self) { _ in
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(.quaternary)
|
||||
.frame(height: 14)
|
||||
.frame(height: 12)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
@@ -266,4 +394,11 @@ private struct TemplatePreview: View {
|
||||
.padding(10)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
/// The lane's palette tint, falling back to the accent colour — `Palette` is the one place a
|
||||
/// colour name is resolved, and a lane that names none looks like the chrome default here exactly
|
||||
/// as it does on a board.
|
||||
private static func tint(of lane: Lane) -> Color {
|
||||
(Palette.color(for: lane.iconColor) ?? .accentColor).opacity(0.65)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user