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
+16
View File
@@ -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
+27 -5
View File
@@ -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)
+90
View File
@@ -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
}
}
}
+16
View File
@@ -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)
}
+13
View File
@@ -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
+7 -16
View File
@@ -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
+195 -60
View File
@@ -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)
}
}
+264 -4
View File
@@ -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`.
+1 -1
View File
@@ -164,7 +164,7 @@ struct KanbanApp: App {
Divider()
DuplicateBoardCommand(appModel: appModel)
SaveAsTemplateCommand()
SaveAsTemplateCommand(appModel: appModel)
RevealInFinderCommand()
AddAttachmentCommand()
+5
View File
@@ -671,6 +671,11 @@ public final class BannerCenter {
if let title { "Couldn't rename '\(title)'" } else { "Couldn't rename the item" }
case let .duplicateBoard(title):
if let title { "Couldn't duplicate '\(title)'" } else { "Couldn't duplicate the board" }
case let .saveAsTemplate(title):
// The command's own words (File Save as Template), because that is the button they
// pressed and the board is still exactly as it was: nothing was saved *over*, and the
// failure is about the copy in the templates folder, not about this board's own files.
if let title { "Couldn't save '\(title)' as a template" } else { "Couldn't save the board as a template" }
case let .importAttachment(filename):
"Couldn't import '\(filename)'"
case .listAttachments:
+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"
+21
View File
@@ -54,6 +54,25 @@ public final class CardRawSourceSession {
/// neither blocks on the open buffer nor invalidates it Apply stays last-writer-wins" (05).
public var text = ""
/// The file as `enter()` read it the baseline behind `holdsUnsavedText`, and never shown.
///
/// `CardBodyEditSession.disk`'s counterpart, with the same job and none of its reconciliation:
/// this one is set once, when the outlet opens, and is not followed by a watcher reload (the
/// buffer above is deliberately never reconciled while it is open), so it answers exactly one
/// question has the user typed anything since the file was read.
private var loadedText = ""
/// **Whether an open outlet is holding bytes the file does not** the raw-source half of
/// 09-templates.md Save as Template's carve-out, which stays live under the
/// unwritable-location lock "unless an open Edit or raw-source session holds unsaved content the
/// suspended saves can't flush" (02-architecture.md Live-reload resilience).
///
/// A closed outlet holds nothing (the buffer is emptied on the way out), and an open one that
/// has not been typed in holds nothing either it is showing the file. Apply is the only thing
/// that writes it, and under the lock Apply is refused, which is precisely why a template taken
/// past this text would silently miss it.
public var holdsUnsavedText: Bool { isActive && text != loadedText }
/// The alert waiting to be shown, if any a validation refusal on Apply, or a file that could
/// not be opened as source. Cleared by the OK that dismisses it, which returns the user to the
/// text they were editing (05: "a failed validation keeps source mode open (toggle stays
@@ -109,6 +128,7 @@ public final class CardRawSourceSession {
switch read() {
case let .read(source):
text = source
loadedText = source
alert = nil
isActive = true
return true
@@ -175,6 +195,7 @@ public final class CardRawSourceSession {
isActive = false
alert = nil
text = ""
loadedText = ""
}
}