A template is a board folder the ordinary loader reads — no second schema, no Swift catalog. BoardTemplate became exactly that: a loaded BoardModel with chooser-facing derivations, the lane-title stub gone. TemplateEngine instantiates by the copy-remint-restamp walk: .git and .trash excluded at top level only — both names mean something at a board root and nowhere else, and .gitignore must survive — every materialized folder reminted, created/modified stamped fresh (born today, not forked), modified-by cleared, the template: key carried inert, the blurb and style inherited, and loose card files normalized at this import boundary per the paste precedent so a new board never opens with a notice about a mess its own birth made. Legacy deleted: keys copy through verbatim to the one migrator — stripping would resurrect, skipping would destroy. Atomicity is construct-then-clean: a sibling temp can be sandbox-refused and a cross-volume rename is just a second copy, so the call removes what it created on every non-board exit and never touches an occupied destination. The cancellable per-item walk extracted into BoardTreeCopy serves Duplicate and instantiation with two parameters — top-level exclusions and folder-attribute carriage, the only axes they differ on. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
270 lines
12 KiB
Swift
270 lines
12 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
import os
|
|
|
|
/// The template chooser — File ▸ New Board… (⌥⌘N), 09-templates.md's picker.
|
|
///
|
|
/// ### Pages' shape, and the templates are real board folders now
|
|
///
|
|
/// 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.
|
|
///
|
|
// 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.
|
|
///
|
|
/// ### Choosing is three steps, and the middle one is a save panel
|
|
///
|
|
/// Choose runs `NSSavePanel`, instantiates into the chosen location, and opens the result. The panel
|
|
/// is not ceremony: a sandboxed app cannot write anywhere the user has not pointed at, so the panel
|
|
/// *is* how a new board gets a location it is allowed to occupy (09 files this under "seed the save
|
|
/// panel's suggested name"). The chooser stays open if the panel is cancelled — a cancelled location
|
|
/// is not a cancelled choice.
|
|
///
|
|
/// ### Failure is an alert here, deliberately
|
|
///
|
|
/// Everywhere else in the app a failed write is a banner in the window that produced it
|
|
/// (02-architecture.md § Write-failure surfacing). A failed *create* has no such window: the board
|
|
/// that would host the banner is the one that did not get created, welcome may not be open (⌥⌘N
|
|
/// works from a board window), and filing it under `AppModel.launchFailures` would put it in a
|
|
/// recents-adjacent list belonging to boards the registry knows — which this one, having never
|
|
/// existed, is not. So it is an `NSAlert`, continuing the modal conversation the user is already in
|
|
/// with the save panel, in the same sentence the banner would have used
|
|
/// (`BannerCenter.headline(for:)`) so the app has one vocabulary for a failed write rather than two.
|
|
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] = []
|
|
|
|
@State private var selection: BoardTemplate.ID?
|
|
|
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "templates")
|
|
|
|
private var selected: BoardTemplate? {
|
|
templates.first { $0.id == selection } ?? templates.first
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
Divider()
|
|
grid
|
|
Divider()
|
|
footer
|
|
}
|
|
.frame(width: 620, height: 460)
|
|
.onAppear {
|
|
guard templates.isEmpty else { return }
|
|
templates = TemplateEngine.bundledTemplates()
|
|
}
|
|
}
|
|
|
|
// MARK: Header
|
|
|
|
private var header: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Choose a Template")
|
|
.font(.title3.weight(.semibold))
|
|
Text("Every template is an ordinary board — lanes and cards you can change afterwards.")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(20)
|
|
}
|
|
|
|
// MARK: Grid
|
|
|
|
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 }
|
|
// 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] : [])
|
|
}
|
|
}
|
|
.padding(20)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.background(Color(nsColor: .controlBackgroundColor))
|
|
}
|
|
|
|
// MARK: Footer
|
|
|
|
private var footer: some View {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
Text(selected?.blurb ?? "")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
|
|
Spacer(minLength: 16)
|
|
|
|
Button("Cancel", role: .cancel) { dismiss() }
|
|
.keyboardShortcut(.cancelAction)
|
|
|
|
Button("Choose") { choose() }
|
|
.keyboardShortcut(.defaultAction)
|
|
.disabled(selected == nil)
|
|
}
|
|
.padding(20)
|
|
}
|
|
|
|
// MARK: - Choosing
|
|
|
|
/// Panel, instantiate, open — and only then dismiss, so a cancelled panel leaves the chooser
|
|
/// exactly as the user left it.
|
|
///
|
|
/// The copy runs in a detached task, `DuplicateBoardCommand`'s reasoning at a smaller scale: a
|
|
/// user template can be a real board with real attachments, and a main thread blocked inside a
|
|
/// 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 }
|
|
let title = TemplateEngine.documentName(of: url)
|
|
|
|
Task { @MainActor in
|
|
let outcome = await Task.detached(priority: .userInitiated) {
|
|
() -> Result<URL, TemplateEngine.Failure> in
|
|
do throws(TemplateEngine.Failure) {
|
|
return .success(try TemplateEngine.instantiate(template: template, to: url, title: title))
|
|
} catch {
|
|
return .failure(error)
|
|
}
|
|
}.value
|
|
|
|
switch outcome {
|
|
case .success:
|
|
dismiss()
|
|
// The ordinary open path, so the new board joins recents, gets its bookmark, and
|
|
// closes welcome on the way in exactly like a board opened from a row.
|
|
appModel.openBoard(at: url)
|
|
case .failure(.cancelled):
|
|
// Nothing was created and nothing failed, so nothing is said — the duplicate rule.
|
|
Self.logger.notice("template instantiation cancelled — the partial board was removed")
|
|
case let .failure(.failed(error)):
|
|
Self.logger.error("template instantiation failed: \(error.description, privacy: .public)")
|
|
Self.present(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The save panel — where the board goes and what it is called.
|
|
///
|
|
/// **The suggestion is the template's own title** (`"Basic.kanban"`, `"Bug Tracker.kanban"`) —
|
|
/// 09 ▸ Instantiation: "seed the save panel's suggested name from the template title". Whatever
|
|
/// the user types instead becomes the new board's `title` as well as its folder name, so the two
|
|
/// start out matching (01-storage-format.md § Board naming). The package extension is visible and
|
|
/// editable, because an extension-less board folder is equally legal (§ Document packaging) and
|
|
/// deleting the suffix should therefore work rather than be silently undone.
|
|
///
|
|
/// A name that already exists gets the panel's own replace prompt; agreeing to it does not delete
|
|
/// anything (the panel never does), so the engine's refusal to clobber an existing board is what
|
|
/// the user sees — as an alert, naming the path. That is the honest outcome: this flow is a
|
|
/// *create*, and quietly replacing a board with an empty one is not a thing it should be able to
|
|
/// do.
|
|
private static func chooseLocation(for template: BoardTemplate) -> URL? {
|
|
let panel = NSSavePanel()
|
|
panel.nameFieldStringValue = TemplateEngine.suggestedFileName(for: template)
|
|
panel.canCreateDirectories = true
|
|
panel.isExtensionHidden = false
|
|
panel.allowsOtherFileTypes = true
|
|
panel.prompt = "Create"
|
|
panel.message = "Choose where to keep the new board."
|
|
|
|
guard panel.runModal() == .OK, let url = panel.url else { return nil }
|
|
return url
|
|
}
|
|
|
|
private static func present(_ error: BoardWriteError) {
|
|
let alert = NSAlert()
|
|
alert.alertStyle = .warning
|
|
alert.messageText = BannerCenter.headline(for: error)
|
|
alert.informativeText = error.path
|
|
alert.addButton(withTitle: "OK")
|
|
alert.runModal()
|
|
}
|
|
}
|
|
|
|
// MARK: - Template card
|
|
|
|
/// One template in the grid: its mini per-lane preview, its name, and the selection ring.
|
|
private struct TemplateCard: View {
|
|
|
|
let template: BoardTemplate
|
|
let isSelected: Bool
|
|
|
|
var body: some View {
|
|
VStack(spacing: 8) {
|
|
TemplatePreview(template: template)
|
|
.frame(height: 96)
|
|
.background(RoundedRectangle(cornerRadius: 8).fill(Color(nsColor: .textBackgroundColor)))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.strokeBorder(isSelected ? Color.accentColor : Color(nsColor: .separatorColor),
|
|
lineWidth: isSelected ? 3 : 1)
|
|
)
|
|
|
|
Label(template.name, systemImage: template.icon)
|
|
.font(.callout)
|
|
.labelStyle(.titleAndIcon)
|
|
}
|
|
.contentShape(Rectangle())
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityLabel(template.name)
|
|
.accessibilityHint(template.blurb)
|
|
}
|
|
}
|
|
|
|
/// The mini per-lane preview: one column per lane, each a title bar over a couple of card shapes.
|
|
///
|
|
/// 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.
|
|
///
|
|
// 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.
|
|
private struct TemplatePreview: View {
|
|
|
|
let template: BoardTemplate
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 6) {
|
|
ForEach(Array(template.lanes.enumerated()), id: \.element.id) { index, _ in
|
|
VStack(spacing: 4) {
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.fill(Color.accentColor.opacity(0.65))
|
|
.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
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.fill(.quaternary)
|
|
.frame(height: 14)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
}
|
|
.padding(10)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|