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,16 +92,30 @@ 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 {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Choose a Template")
|
||||
.font(.title3.weight(.semibold))
|
||||
@@ -81,7 +123,18 @@ struct TemplateChooserView: View {
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
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.")
|
||||
}
|
||||
.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 ?? "")
|
||||
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`.
|
||||
|
||||
@@ -164,7 +164,7 @@ struct KanbanApp: App {
|
||||
Divider()
|
||||
|
||||
DuplicateBoardCommand(appModel: appModel)
|
||||
SaveAsTemplateCommand()
|
||||
SaveAsTemplateCommand(appModel: appModel)
|
||||
RevealInFinderCommand()
|
||||
AddAttachmentCommand()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -140,6 +140,93 @@ struct BoardTemplateTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The chooser's rows
|
||||
|
||||
/// 09-templates.md ▸ Storage's **three-tier chooser order**, and the unloadable row that sorts with
|
||||
/// its last tier:
|
||||
///
|
||||
/// > Chooser order: bundled templates by `template.order`, then keyed user templates by
|
||||
/// > `template.order`, then keyless user boards last, sorted by display name (`title` ?? folder
|
||||
/// > name). An unloadable user template sorts with the keyless tier, by folder name.
|
||||
@Suite("TemplateEngine — chooser rows")
|
||||
struct TemplateChooserRowTests {
|
||||
|
||||
@Test("The user tier is keyed by order first, then keyless boards by display name")
|
||||
func userTierIsKeyedThenNamed() throws {
|
||||
let fixture = try TemplateFixture(named: "Tiers")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.freeStandingBoard(named: "zebra", title: "Zebra")
|
||||
try fixture.freeStandingBoard(named: "second", title: "Second", extraKeys: "template: {order: 200}")
|
||||
try fixture.freeStandingBoard(named: "apple", title: "Apple")
|
||||
try fixture.freeStandingBoard(named: "first", title: "First", extraKeys: "template: {order: 100}")
|
||||
|
||||
let rows = TemplateEngine.userRows(in: fixture.store)
|
||||
|
||||
#expect(rows.map(\.name) == ["First", "Second", "Apple", "Zebra"])
|
||||
}
|
||||
|
||||
@Test("An unloadable folder sorts with the keyless tier, by folder name")
|
||||
func unloadableSortsWithTheKeylessTier() throws {
|
||||
let fixture = try TemplateFixture(named: "Broken sorting")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.freeStandingBoard(named: "keyed", title: "Keyed", extraKeys: "template: {order: 100}")
|
||||
try fixture.freeStandingBoard(named: "Zebra", title: "Zebra")
|
||||
try fixture.malformedBoard(named: "Muddle")
|
||||
|
||||
let rows = TemplateEngine.userRows(in: fixture.store)
|
||||
|
||||
// Keyed first, then the keyless tier by name — where the broken folder takes its place under
|
||||
// its own folder name, because "the failed load can supply neither `template.order` nor
|
||||
// `title`".
|
||||
#expect(rows.map(\.name) == ["Keyed", "Muddle", "Zebra"])
|
||||
#expect(rows.map { $0.template == nil } == [false, true, false])
|
||||
}
|
||||
|
||||
@Test("Both tiers, in order: bundled, then keyed user templates, then keyless ones")
|
||||
func bundledTierComesFirstWhateverTheUserOrdersSay() throws {
|
||||
let fixture = try TemplateFixture(named: "Both tiers")
|
||||
defer { fixture.tearDown() }
|
||||
// Deliberately *lower* than every bundled order (Basic is 100): the tier is the outermost
|
||||
// key, so a user template can never sort itself in among the bundled ones.
|
||||
try fixture.freeStandingBoard(named: "mine", title: "Mine", extraKeys: "template: {order: 1}")
|
||||
try fixture.freeStandingBoard(named: "hand-dropped", title: "Hand Dropped")
|
||||
|
||||
let rows = TemplateEngine.chooserRows(userStore: fixture.store)
|
||||
let bundled = TemplateEngine.bundledTemplates()
|
||||
|
||||
#expect(rows.count == bundled.count + 2)
|
||||
#expect(rows.prefix(bundled.count).map(\.name) == bundled.map(\.name))
|
||||
#expect(rows.suffix(2).map(\.name) == ["Mine", "Hand Dropped"])
|
||||
}
|
||||
|
||||
@Test("A malformed template lists, carries the loader's error, and leaves the others alone")
|
||||
func oneBadTemplateNeverFailsTheChooser() throws {
|
||||
let fixture = try TemplateFixture(named: "Resilience")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.freeStandingBoard(named: "Good", title: "Good")
|
||||
try fixture.malformedBoard(named: "Bad")
|
||||
|
||||
let rows = TemplateEngine.userRows(in: fixture.store)
|
||||
|
||||
#expect(rows.count == 2, "the broken folder is listed, not skipped")
|
||||
let bad = try #require(rows.first { $0.name == "Bad" }?.unloadable)
|
||||
#expect(bad.error.reason == .boardRootMissingIndex, "the loader's own fail-fast specifics, unreworded")
|
||||
#expect(bad.url == fixture.store.appendingPathComponent("Bad.kanban", isDirectory: true))
|
||||
#expect(rows.first { $0.name == "Good" }?.template != nil, "one bad template never fails the chooser")
|
||||
}
|
||||
|
||||
@Test("A missing store is an empty list, and listing never creates one")
|
||||
func aMissingStoreIsEmpty() throws {
|
||||
let fixture = try TemplateFixture(named: "Absent")
|
||||
defer { fixture.tearDown() }
|
||||
let absent = fixture.store.appendingPathComponent("not-there", isDirectory: true)
|
||||
|
||||
#expect(TemplateEngine.userRows(in: absent).isEmpty)
|
||||
#expect(!FileManager.default.fileExists(atPath: absent.path),
|
||||
"discovery of a store that does not exist is an empty list, not a directory the app made")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fixture
|
||||
|
||||
/// A temp store holding hand-written template board folders — the user store's shape, minus
|
||||
@@ -166,12 +253,24 @@ struct TemplateFixture {
|
||||
}
|
||||
|
||||
/// A second board in the same store — the sorting tests need several.
|
||||
@discardableResult
|
||||
func freeStandingBoard(named name: String, title: String, extraKeys: String = "") throws -> BoardTemplate {
|
||||
let url = store.appendingPathComponent("\(name).kanban", isDirectory: true)
|
||||
try write(board: url, title: title, icon: nil, extraKeys: extraKeys)
|
||||
return try loadTemplate(at: url)
|
||||
}
|
||||
|
||||
/// A folder in the store the loader rejects — the hand-editable store's one-edit-away state,
|
||||
/// which the chooser has to survive. No `index.md` at all, which is 09's own example of a board
|
||||
/// that cannot be read at all rather than one with a bad field.
|
||||
@discardableResult
|
||||
func malformedBoard(named name: String) throws -> URL {
|
||||
let url = store.appendingPathComponent("\(name).kanban", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
try Data("not a board\n".utf8).write(to: url.appendingPathComponent("notes.md"))
|
||||
return url
|
||||
}
|
||||
|
||||
func template() throws -> BoardTemplate {
|
||||
try loadTemplate(at: boardURL)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **Save as Template** — the board copied into the user templates store, on disk (09-templates.md
|
||||
/// ▸ Save as Template).
|
||||
///
|
||||
/// Every promise 09 makes about that copy is an assertion about bytes in a store:
|
||||
///
|
||||
/// - **`.git` and `.trash/` are dropped**, and nothing else is — strays, `CLAUDE.user.md`, a seeded
|
||||
/// `.gitignore`, attachments and bodies all carry through;
|
||||
/// - **GUIDs and timestamps are kept** — a template is a fork, and both are inert anyway because
|
||||
/// instantiation remints and restamps at its own boundary;
|
||||
/// - **a `template:` key lands on the copy**, with an order appended after the existing user
|
||||
/// templates, overwriting a stale one the board carried in;
|
||||
/// - **collisions auto-rename, Finder-style** — never overwritten, never refused;
|
||||
/// - and **the app never stamps a key into store files it didn't write itself**: listing and
|
||||
/// instantiating a hand-dropped board leave it exactly as its author left it.
|
||||
///
|
||||
/// Plus the promise that makes the copy safe to run at all: a cancelled save leaves nothing behind.
|
||||
///
|
||||
/// Every test drives an explicit store URL. **No test may touch Application Support** — `userStore`
|
||||
/// is named here only to prove the engine never creates it on its own.
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func saveFailure(_ operation: () throws -> Void) -> TemplateEngine.Failure? {
|
||||
do {
|
||||
try operation()
|
||||
Issue.record("expected the save to fail, but it succeeded")
|
||||
return nil
|
||||
} catch let failure as TemplateEngine.Failure {
|
||||
return failure
|
||||
} catch {
|
||||
Issue.record("expected a TemplateEngine.Failure, got \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Every path under `root`, root-relative, hidden entries included.
|
||||
private func storeTree(of root: URL) -> Set<String> {
|
||||
var paths: Set<String> = []
|
||||
let walker = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil, options: [])
|
||||
while let url = walker?.nextObject() as? URL {
|
||||
paths.insert(url.path.replacingOccurrences(of: root.path + "/", with: ""))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
private func names(in folder: URL) throws -> Set<String> {
|
||||
Set(try FileManager.default.contentsOfDirectory(atPath: folder.path))
|
||||
}
|
||||
|
||||
// MARK: - The board being saved
|
||||
|
||||
/// A board carrying everything 09 has a rule about: the two exclusions, board- and lane-level
|
||||
/// strays, an attachment, a stale `template:` key from its own instantiation, and old timestamps
|
||||
/// with someone else's attribution on them.
|
||||
private struct SavableBoard {
|
||||
|
||||
let fixture: WriterFixture
|
||||
let root: URL
|
||||
let store: URL
|
||||
|
||||
static let name = "Roadmap.kanban"
|
||||
|
||||
init() throws {
|
||||
fixture = try WriterFixture()
|
||||
root = fixture.url(Self.name)
|
||||
store = fixture.url("Templates")
|
||||
let path = Self.name
|
||||
|
||||
try fixture.item(path, """
|
||||
---
|
||||
schema: 1
|
||||
title: Roadmap
|
||||
icon: map
|
||||
iconColor: fern
|
||||
template: {order: 800}
|
||||
project: lanework
|
||||
created: 2026-01-01T09:00:00Z
|
||||
modified: 2026-02-02T09:00:00Z
|
||||
modified-by: claude
|
||||
---
|
||||
The board's description, which becomes the template's blurb.
|
||||
""")
|
||||
|
||||
// Strays, at board and lane level — "the copy is literal apart from the stated exclusions".
|
||||
try fixture.file("\(path)/CLAUDE.user.md", Data("board instructions\n".utf8))
|
||||
try fixture.file("\(path)/.gitignore", Data(".DS_Store\n".utf8))
|
||||
|
||||
// The two exclusions.
|
||||
try fixture.file("\(path)/.git/HEAD", Data("ref: refs/heads/main\n".utf8))
|
||||
try fixture.file("\(path)/.git/objects/pack/pack-1.pack", Data([0x01, 0x02]))
|
||||
try fixture.item("\(path)/\(BoardLoader.trashFolderName)/\(Ident.card4)",
|
||||
Item.rich(order: "1024", title: "Thrown Away"))
|
||||
|
||||
try fixture.item("\(path)/\(Ident.lane1)", Item.rich(order: "1024", title: "To Do"))
|
||||
try fixture.item("\(path)/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Starter"))
|
||||
try fixture.file("\(path)/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x01, 0x02, 0x03]))
|
||||
try fixture.item("\(path)/\(Ident.lane2)", Item.rich(order: "2048", title: "Done"))
|
||||
try fixture.file("\(path)/\(Ident.lane2)/notes/scratch.md", Data("scratch\n".utf8))
|
||||
}
|
||||
|
||||
func tearDown() { fixture.tearDown() }
|
||||
|
||||
@discardableResult
|
||||
func save(isCancelled: () -> Bool = { false }) throws -> URL {
|
||||
try TemplateEngine.saveAsTemplate(boardAt: root, titled: "Roadmap", into: store, isCancelled: isCancelled)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The copy
|
||||
|
||||
@Suite("Save as Template — the copy")
|
||||
struct SaveAsTemplateCopyTests {
|
||||
|
||||
@Test("The store is created by the first save — the engine names it, it doesn't make it early")
|
||||
func theFirstSaveMintsTheStore() throws {
|
||||
let board = try SavableBoard()
|
||||
defer { board.tearDown() }
|
||||
#expect(!FileManager.default.fileExists(atPath: board.store.path))
|
||||
|
||||
let landed = try board.save()
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: board.store.path))
|
||||
#expect(landed == board.store.appendingPathComponent("Roadmap.kanban", isDirectory: true))
|
||||
}
|
||||
|
||||
@Test("`.git` and `.trash/` are the two exclusions, and they are the only ones")
|
||||
func theTwoExclusionsAreExcluded() throws {
|
||||
let board = try SavableBoard()
|
||||
defer { board.tearDown() }
|
||||
|
||||
let landed = try board.save()
|
||||
|
||||
let paths = storeTree(of: landed)
|
||||
#expect(!paths.contains { $0 == ".git" || $0.hasPrefix(".git/") },
|
||||
"a template is content, not history — the board's whole repo must not land in the store")
|
||||
#expect(!paths.contains { $0 == ".trash" || $0.hasPrefix(".trash/") },
|
||||
"a template isn't a fork; the board's trash is not part of what was saved")
|
||||
#expect(paths.contains("CLAUDE.user.md"))
|
||||
#expect(paths.contains(".gitignore"), "the exclusions are exact names, not prefixes")
|
||||
#expect(paths.contains { $0.hasSuffix("notes/scratch.md") })
|
||||
#expect(paths.contains { $0.hasSuffix("attachments/shot.png") })
|
||||
}
|
||||
|
||||
@Test("Strays and attachments arrive byte for byte")
|
||||
func straysAreVerbatim() throws {
|
||||
let board = try SavableBoard()
|
||||
defer { board.tearDown() }
|
||||
|
||||
let landed = try board.save()
|
||||
|
||||
#expect(try Data(contentsOf: landed.appendingPathComponent("CLAUDE.user.md"))
|
||||
== Data("board instructions\n".utf8))
|
||||
#expect(try Data(contentsOf: landed.appendingPathComponent(".gitignore")) == Data(".DS_Store\n".utf8))
|
||||
#expect(try Data(contentsOf: landed.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png"))
|
||||
== Data([0x01, 0x02, 0x03]))
|
||||
}
|
||||
|
||||
@Test("GUIDs are kept — a whole-board copy is a new namespace, and instantiation remints anyway")
|
||||
func identitiesAreKept() throws {
|
||||
let board = try SavableBoard()
|
||||
defer { board.tearDown() }
|
||||
|
||||
let landed = try board.save()
|
||||
|
||||
#expect(try names(in: landed).isSuperset(of: [Ident.lane1, Ident.lane2]))
|
||||
#expect(try names(in: landed.appendingPathComponent(Ident.lane1)).contains(Ident.card1))
|
||||
}
|
||||
|
||||
@Test("Timestamps and foreign attributions are kept — the copies-keep-created rule, inertly")
|
||||
func timestampsAreUntouched() throws {
|
||||
let board = try SavableBoard()
|
||||
defer { board.tearDown() }
|
||||
let cardBefore = try Data(contentsOf: board.root
|
||||
.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)/index.md"))
|
||||
|
||||
let landed = try board.save()
|
||||
|
||||
// A card's index.md is copied by the file system and never rewritten: identical bytes, so
|
||||
// `created`, `modified` and `modified-by` are all exactly as they were.
|
||||
#expect(try Data(contentsOf: landed.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)/index.md"))
|
||||
== cardBefore)
|
||||
|
||||
// The board's own index.md *is* rewritten (the `template:` key), so it is asserted by value.
|
||||
let model = try BoardLoader.load(boardRoot: landed).model
|
||||
#expect(model.created.value == ISO8601DateFormatter().date(from: "2026-01-01T09:00:00Z"))
|
||||
#expect(model.title.value == "Roadmap")
|
||||
}
|
||||
|
||||
@Test("The board being saved is never written to")
|
||||
func theSourceBoardIsUntouched() throws {
|
||||
let board = try SavableBoard()
|
||||
defer { board.tearDown() }
|
||||
let before = storeTree(of: board.root)
|
||||
let indexBefore = try Data(contentsOf: board.root.appendingPathComponent("index.md"))
|
||||
|
||||
try board.save()
|
||||
|
||||
#expect(storeTree(of: board.root) == before)
|
||||
#expect(try Data(contentsOf: board.root.appendingPathComponent("index.md")) == indexBefore,
|
||||
"the key is stamped on the copy — the user's board never gains one")
|
||||
}
|
||||
|
||||
@Test("The saved template loads, and instantiates into an ordinary board")
|
||||
func theSavedTemplateRoundTrips() throws {
|
||||
let board = try SavableBoard()
|
||||
defer { board.tearDown() }
|
||||
|
||||
let landed = try board.save()
|
||||
let rows = TemplateEngine.userRows(in: board.store)
|
||||
let template = try #require(rows.first?.template)
|
||||
#expect(template.url == landed)
|
||||
#expect(template.name == "Roadmap")
|
||||
#expect(template.blurb == "The board's description, which becomes the template's blurb.")
|
||||
|
||||
let destination = board.fixture.url("New Board.kanban")
|
||||
try TemplateEngine.instantiate(template: template, to: destination, title: "New Board")
|
||||
let result = try BoardLoader.load(boardRoot: destination)
|
||||
#expect(result.model.lanes.map { $0.title.value } == ["To Do", "Done"])
|
||||
#expect(result.model.lanes.flatMap(\.cards).map { $0.title.value } == ["Starter"])
|
||||
#expect(result.model.title.value == "New Board")
|
||||
// The board's own lane-level stray (`Done/notes/`) is still a stray after two copies — which
|
||||
// is the point: it survived both, unread and unrendered, exactly as 09 promises.
|
||||
#expect(result.warnings.count == 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The key
|
||||
|
||||
@Suite("Save as Template — the template: key")
|
||||
struct SaveAsTemplateKeyTests {
|
||||
|
||||
@Test("The first user template takes the first order")
|
||||
func firstSaveTakesTheFirstOrder() throws {
|
||||
let board = try SavableBoard()
|
||||
defer { board.tearDown() }
|
||||
|
||||
let landed = try board.save()
|
||||
|
||||
#expect(try BoardLoader.load(boardRoot: landed).model.template
|
||||
== .mapping([YAMLValue.Pair(key: .string("order"), value: .int(100))]))
|
||||
}
|
||||
|
||||
@Test("A stale order carried in from the board's own instantiation is overwritten")
|
||||
func staleOrderIsOverwritten() throws {
|
||||
let board = try SavableBoard()
|
||||
defer { board.tearDown() }
|
||||
// The fixture board carries `template: {order: 800}` — Roadmap's bundled position, which is
|
||||
// exactly the residue 09 describes ("it was itself instantiated from a template").
|
||||
#expect(try BoardLoader.load(boardRoot: board.root).model.template
|
||||
== .mapping([YAMLValue.Pair(key: .string("order"), value: .int(800))]))
|
||||
|
||||
let landed = try board.save()
|
||||
|
||||
let order = try #require(TemplateEngine.rows(in: board.store, origin: .user).first?.order)
|
||||
#expect(order == 100, "the stale order is replaced, not respected")
|
||||
#expect(try BoardLoader.load(boardRoot: landed).model.document.value(for: "project") == .string("lanework"),
|
||||
"the surgical edit leaves every other key where it was")
|
||||
}
|
||||
|
||||
@Test("Each save is appended after the existing user templates")
|
||||
func ordersAreAppended() throws {
|
||||
let board = try SavableBoard()
|
||||
defer { board.tearDown() }
|
||||
|
||||
try board.save()
|
||||
try board.save()
|
||||
try board.save()
|
||||
|
||||
let rows = TemplateEngine.userRows(in: board.store)
|
||||
#expect(rows.map(\.order) == [100, 200, 300])
|
||||
#expect(rows.map(\.name) == ["Roadmap", "Roadmap", "Roadmap"])
|
||||
}
|
||||
|
||||
@Test("A hand-dropped keyless board is counted as existing but never given a key")
|
||||
func aKeylessNeighbourIsNeverStamped() throws {
|
||||
let board = try SavableBoard()
|
||||
defer { board.tearDown() }
|
||||
try FileManager.default.createDirectory(at: board.store, withIntermediateDirectories: true)
|
||||
let dropped = board.store.appendingPathComponent("Hand Dropped.kanban", isDirectory: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: dropped.appendingPathComponent(Ident.lane3, isDirectory: true),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try Data("---\nschema: 1\ntitle: Hand Dropped\n---\nMine.\n".utf8)
|
||||
.write(to: dropped.appendingPathComponent("index.md"))
|
||||
try Data("---\nschema: 1\ntitle: Lane\norder: 1024\n---\n".utf8)
|
||||
.write(to: dropped.appendingPathComponent("\(Ident.lane3)/index.md"))
|
||||
let droppedBytes = try Data(contentsOf: dropped.appendingPathComponent("index.md"))
|
||||
|
||||
// Listing it, saving beside it, and instantiating from it: three reads, no writes.
|
||||
let template = try #require(TemplateEngine.userRows(in: board.store).first?.template)
|
||||
try board.save()
|
||||
try TemplateEngine.instantiate(
|
||||
template: template,
|
||||
to: board.fixture.url("From Dropped.kanban"),
|
||||
title: "From Dropped"
|
||||
)
|
||||
|
||||
#expect(try Data(contentsOf: dropped.appendingPathComponent("index.md")) == droppedBytes,
|
||||
"the app never stamps a key into store files it didn't write itself")
|
||||
#expect(TemplateEngine.userRows(in: board.store).first { $0.name == "Hand Dropped" }?.order == nil)
|
||||
}
|
||||
|
||||
@Test("A keyless neighbour contributes no order, so the first save still takes the first one")
|
||||
func keylessNeighboursDoNotMoveTheLadder() throws {
|
||||
let board = try SavableBoard()
|
||||
defer { board.tearDown() }
|
||||
try FileManager.default.createDirectory(at: board.store, withIntermediateDirectories: true)
|
||||
let dropped = board.store.appendingPathComponent("Hand Dropped.kanban", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: dropped, withIntermediateDirectories: true)
|
||||
try Data("---\nschema: 1\ntitle: Hand Dropped\n---\n".utf8)
|
||||
.write(to: dropped.appendingPathComponent("index.md"))
|
||||
|
||||
let landed = try board.save()
|
||||
|
||||
#expect(try BoardLoader.load(boardRoot: landed).model.template
|
||||
== .mapping([YAMLValue.Pair(key: .string("order"), value: .int(100))]))
|
||||
}
|
||||
|
||||
@Test("nextUserOrder reads the store's own highest key, not a counter")
|
||||
func nextOrderFollowsHandEdits() throws {
|
||||
let fixture = try TemplateFixture(named: "Ladder")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.freeStandingBoard(named: "a", title: "A", extraKeys: "template: {order: 100}")
|
||||
try fixture.freeStandingBoard(named: "b", title: "B", extraKeys: "template: {order: 4200}")
|
||||
|
||||
#expect(TemplateEngine.nextUserOrder(in: fixture.store) == 4300)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Collisions
|
||||
|
||||
@Suite("Save as Template — store collisions")
|
||||
struct SaveAsTemplateCollisionTests {
|
||||
|
||||
@Test("A name already in the store auto-renames, Finder-style — never overwritten, never refused")
|
||||
func collisionsAutoRename() throws {
|
||||
let board = try SavableBoard()
|
||||
defer { board.tearDown() }
|
||||
|
||||
let first = try board.save()
|
||||
let second = try board.save()
|
||||
let third = try board.save()
|
||||
|
||||
#expect(first.lastPathComponent == "Roadmap.kanban")
|
||||
#expect(second.lastPathComponent == "Roadmap 2.kanban")
|
||||
#expect(third.lastPathComponent == "Roadmap 3.kanban")
|
||||
#expect(try names(in: board.store) == ["Roadmap.kanban", "Roadmap 2.kanban", "Roadmap 3.kanban"])
|
||||
}
|
||||
|
||||
@Test("The template already in the store is left exactly as it was")
|
||||
func theExistingTemplateIsUntouched() throws {
|
||||
let board = try SavableBoard()
|
||||
defer { board.tearDown() }
|
||||
let first = try board.save()
|
||||
let before = try Data(contentsOf: first.appendingPathComponent("index.md"))
|
||||
|
||||
try board.save()
|
||||
|
||||
#expect(try Data(contentsOf: first.appendingPathComponent("index.md")) == before,
|
||||
"saving never overwrites an existing template and never refuses")
|
||||
}
|
||||
|
||||
@Test("An extension-less board folder ladders on its whole name")
|
||||
func extensionlessNamesLadderToo() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let store = fixture.url("Templates")
|
||||
let root = fixture.url("Plain")
|
||||
try fixture.item("Plain", "---\nschema: 1\ntitle: Plain\n---\nBlurb.\n")
|
||||
|
||||
let first = try TemplateEngine.saveAsTemplate(boardAt: root, titled: "Plain", into: store)
|
||||
let second = try TemplateEngine.saveAsTemplate(boardAt: root, titled: "Plain", into: store)
|
||||
|
||||
#expect(first.lastPathComponent == "Plain")
|
||||
#expect(second.lastPathComponent == "Plain 2")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Nothing half-made
|
||||
|
||||
@Suite("Save as Template — the store entry is all or nothing")
|
||||
struct SaveAsTemplateAtomicityTests {
|
||||
|
||||
@Test("Cancelling mid-walk removes the partial store entry")
|
||||
func cancellingRemovesThePartial() throws {
|
||||
let board = try SavableBoard()
|
||||
defer { board.tearDown() }
|
||||
|
||||
// Trips on the fourth read: the entry exists by then and the walk is inside it, so there is
|
||||
// a genuine partial tree to remove.
|
||||
var reads = 0
|
||||
let failure = saveFailure {
|
||||
try board.save(isCancelled: {
|
||||
reads += 1
|
||||
return reads > 3
|
||||
})
|
||||
}
|
||||
|
||||
#expect(failure == TemplateEngine.Failure.cancelled)
|
||||
#expect(try names(in: board.store).isEmpty, "a cancelled save never happened")
|
||||
}
|
||||
|
||||
@Test("Cancelling before the first item leaves the store empty but minted")
|
||||
func cancellingBeforeTheWalkCreatesNoEntry() throws {
|
||||
let board = try SavableBoard()
|
||||
defer { board.tearDown() }
|
||||
|
||||
let failure = saveFailure { try board.save(isCancelled: { true }) }
|
||||
|
||||
#expect(failure == TemplateEngine.Failure.cancelled)
|
||||
#expect(try names(in: board.store).isEmpty)
|
||||
}
|
||||
|
||||
@Test("A board that is not there fails as a save, naming the operation, and leaves nothing behind")
|
||||
func aMissingBoardFailsCleanly() throws {
|
||||
let board = try SavableBoard()
|
||||
defer { board.tearDown() }
|
||||
try FileManager.default.removeItem(at: board.root)
|
||||
|
||||
let failure = saveFailure { try board.save() }
|
||||
|
||||
guard case let .failed(error) = failure else {
|
||||
Issue.record("expected an ordinary failure, got \(String(describing: failure))")
|
||||
return
|
||||
}
|
||||
#expect(error.operation == .saveAsTemplate(title: "Roadmap"))
|
||||
#expect(try names(in: board.store).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The store's location
|
||||
|
||||
@Suite("Save as Template — the user store")
|
||||
struct UserTemplateStoreTests {
|
||||
|
||||
@Test("The user store is named inside the app container and is never created by naming it")
|
||||
func theStoreIsNamedNotCreated() {
|
||||
let store = TemplateEngine.userStore
|
||||
|
||||
#expect(store.lastPathComponent == TemplateEngine.storeFolderName)
|
||||
#expect(store.deletingLastPathComponent().lastPathComponent
|
||||
== (Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Kanban"))
|
||||
#expect(store.path.contains("Application Support"))
|
||||
// Nothing here creates it, and no test may: `createUserStore(at:)` is Save as Template's and
|
||||
// Reveal in Finder's, and both are driven with an explicit store in this suite.
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ Lanework is in early development. This list tracks what has actually shipped and
|
||||
|
||||
- **The welcome screen** — branding and two actions on the left, recents on the right: board icon, name, containing folder, and the lane/card counts stamped at last close, newest first. The list never opens a board to build itself, so a huge board or an offline volume costs nothing. Single click selects, double click or Return opens, and a context menu carries Open, Reveal in Finder, and Forget. A board that failed to open or restore says so **on its own row**, in the warning tint, carrying the loader's specifics rather than a modal at launch; a board whose bookmark no longer resolves dims to Unavailable with Open and Reveal off and Forget still live; and a failure naming no known board keeps a list of its own rather than vanishing. File ▸ Open Recent lists the same boards — unavailable ones disabled — with Clear Menu at the bottom, which forgets every record because here the registry *is* the menu.
|
||||
|
||||
- **New Board and Duplicate** — File ▸ New Board… (⌥⌘N) opens a Pages-style template chooser with a mini per-lane preview per template, then a save panel seeded with the template's own name; the new board's frontmatter title is the document name you chose, so the window title and the Finder name start out matching. **A template is itself a board** — an ordinary schema-valid folder read by the ordinary loader, so its display name, icon, blurb and lanes all come off its own `index.md`, and authoring one is adding a folder rather than writing code. Creating from one copies that folder: fresh GUIDs for every lane and card, `created`/`modified` stamped today (born, not forked from the template), the blurb becoming the new board's description, attachments and card bodies byte for byte, strays and symlinks carried verbatim — and `.git` and `.trash/` deliberately left behind, so a new board is never silently in git mode and never born with trash. Nothing half-made is ever left where you pointed: a create that fails or is cancelled removes its own partial, and a name already taken is refused rather than replaced. File ▸ Duplicate (⇧⌘S) forks the frontmost board to a Finder-style "Board copy" sibling (then "copy 2", "copy 3"), preceded by the pending-work flush so the copy misses nothing and with the original left open beside it. The copy is literal: every GUID kept — a whole-board copy is a new identity namespace — `.trash/` carried along so the copy matches its own copied history, strays and timestamps untouched.
|
||||
- **New Board and Duplicate** — File ▸ New Board… (⌥⌘N) opens a Pages-style template chooser with a mini per-lane preview per template, then a save panel seeded with the template's own name; the new board's frontmatter title is the document name you chose, so the window title and the Finder name start out matching. **A template is itself a board** — an ordinary schema-valid folder read by the ordinary loader, so its display name, icon, blurb and lanes all come off its own `index.md`, and authoring one is adding a folder rather than writing code. Creating from one copies that folder: fresh GUIDs for every lane and card, `created`/`modified` stamped today (born, not forked from the template), the blurb becoming the new board's description, attachments and card bodies byte for byte, strays and symlinks carried verbatim — and `.git` and `.trash/` deliberately left behind, so a new board is never silently in git mode and never born with trash. Nothing half-made is ever left where you pointed: a create that fails or is cancelled removes its own partial, and a name already taken is refused rather than replaced. The chooser lists **your own templates** after the bundled ten — keyed ones in the order they carry, then keyless boards by name — and a button beside its heading reveals the templates folder in the Finder, creating it if you've never used one: they're plain board folders in there, so dropping a board in makes it a template, no key required, and the app never edits what it didn't write. A folder it can't read is **still listed**, marked and carrying the loader's own sentence, because one bad template must never take the chooser down with it. **File ▸ Save as Template** copies the frontmost board into that folder — pending work flushed first so it misses no keystroke, `.git` and `.trash/` left behind (a template is content, not history, and not a fork), everything else including `CLAUDE.user.md` and every GUID and timestamp carried verbatim, and a chooser position appended after your existing templates. A name already in the folder auto-renames Finder-style ("Roadmap 2") rather than overwriting or refusing, the copy is cancellable from its progress row (cancelling removes the partial), and a quiet line tells you which template you just made. File ▸ Duplicate (⇧⌘S) forks the frontmost board to a Finder-style "Board copy" sibling (then "copy 2", "copy 3"), preceded by the pending-work flush so the copy misses nothing and with the original left open beside it. The copy is literal: every GUID kept — a whole-board copy is a new identity namespace — `.trash/` carried along so the copy matches its own copied history, strays and timestamps untouched.
|
||||
|
||||
- **The card window** — ⌘↩ or a double-click opens a card in its own window: two full-height, independently scrolling columns — a wide body column and a narrow attributes sidebar whose fixed width is derived from font metrics, so the window's resize flex all goes to the body. The title bar carries the card's title live and subtitles it "⟨board⟩ › ⟨lane⟩", following the card as it moves between lanes and re-reading the board's name as it's renamed. The body column shows the title, a quiet created/modified/by line built from whichever frontmatter keys exist, and the body itself; the sidebar leads with the Attachments section (below) and its remaining sections are stacked headers awaiting their content. Reopening a card focuses the window it already has, and the window closes itself the moment its card stops being on the board — moved to the trash (entering the trash counts as deleted), gone with its deleted lane, purged, or moved to another board; a dirty Edit buffer flushes into the card's new location first, so the keystrokes survive a later restore.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user