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
|
||||
|
||||
Reference in New Issue
Block a user