Space in Finder opens the board — a Quick Look preview extension that outlines a .kanban package
A board is a folder, and a folder previews as a folder. `KanbanQuickLook.appex` gives it a document's preview instead: the board's name, its tint and its symbol, then its lanes in display order with each one's card count and its first few card titles. The reading is `BoardOutline` (Kanban/Storage), deliberately not `BoardLoader.load`. The loader throws on a half-broken board — right for opening one, wrong for pressing Space, where the honest answer is the part that reads; it visits every card and lists `attachments/` and counts `comments/` inside each; and it carries trash, tombstone migration and the defect stream, none of which renders. This walk never throws and is capped at every level (`BoardOutlineLimits`): 12 lanes shown of at most 100 considered, 6 card titles per lane of at most 200 parsed, counts by readdir-plus-stat up to 2000 per lane and never a parse. It re-derives nothing that decides *what* the answer is — `FrontmatterDocument` parses, `IntegrityRules.isIdentityShaped` says what a lane or a card is, `BoardLoader.directoryCandidates` supplies the stray tolerance, `Ranks` supplies display order, `Palette` resolves colours — only *how far to look*. The reply is HTML, the one data-based reply that reflows: a Quick Look panel is resized by the user and a board outline is a wrapping row of columns, so a drawing block baked at a fixed `contentSize` would be the wrong size a moment later. It gets vector text, its own scrolling and light/dark for free. The board tint is a wash under the title and a lane's edge accent — never under text, because a preview has none of `ContrastMath`'s ink-picking machinery and should not grow one. The extension compiles `Kanban/Storage` whole, the `KanbanMobile` arrangement — the directory is one unit in practice, so a narrower list is not on offer. `STORAGE_ONLY` is new: EchoLedger's consumer sections speak the live store's vocabulary, and the phone's `#if os(macOS)` cannot exclude them from a target that *is* macOS. Platform, and layer. Nothing else defines it. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -433,7 +433,10 @@ public final class EchoLedger: Sendable {
|
|||||||
// here speaks `CommentPath` (LiveStore's comment-thread vocabulary), and its one caller is
|
// here speaks `CommentPath` (LiveStore's comment-thread vocabulary), and its one caller is
|
||||||
// the Mac store's comment reload. The phone's writers stamp comment receipts identically;
|
// the Mac store's comment reload. The phone's writers stamp comment receipts identically;
|
||||||
// nothing on the phone retires them yet.
|
// nothing on the phone retires them yet.
|
||||||
#if os(macOS)
|
//
|
||||||
|
// `STORAGE_ONLY` is that same gate one axis over — see the verdicts section below, which states
|
||||||
|
// it in full.
|
||||||
|
#if os(macOS) && !STORAGE_ONLY
|
||||||
func vouchedComments(inCard cardFolder: URL, cardPath: String) -> Set<ItemID> {
|
func vouchedComments(inCard cardFolder: URL, cardPath: String) -> Set<ItemID> {
|
||||||
let root = Self.key(cardFolder)
|
let root = Self.key(cardFolder)
|
||||||
let paths = receiptPaths(under: root)
|
let paths = receiptPaths(under: root)
|
||||||
@@ -554,7 +557,19 @@ extension EchoLedger.Receipt {
|
|||||||
// phone records receipts through the surface above (BoardWriter stamps identically on every
|
// phone records receipts through the surface above (BoardWriter stamps identically on every
|
||||||
// platform); its metadata-query observer has no verdict surface yet, and when it grows one this
|
// platform); its metadata-query observer has no verdict surface yet, and when it grows one this
|
||||||
// gate is the seam it lands behind.
|
// gate is the seam it lands behind.
|
||||||
#if os(macOS)
|
//
|
||||||
|
// **`STORAGE_ONLY` is the same seam on a second axis** (added 2026-08-09 with the Quick Look preview
|
||||||
|
// extension, project.yml ▸ KanbanQuickLook): `os(macOS)` says which *platform* compiles this, and
|
||||||
|
// that flag says which *layer* does. A target that wants the storage engine and nothing above it —
|
||||||
|
// a preview extension, and any importer or tool that follows — compiles `Kanban/Storage` whole,
|
||||||
|
// because the directory is one unit in practice (`FrontmatterDocument` → `IntegrityRules` →
|
||||||
|
// `BoardLoader`/`BoardWriter` → this file's recording surface). What it cannot compile is *this*
|
||||||
|
// section, which reaches back up into the live store; without the flag the whole of `Kanban/LiveStore`
|
||||||
|
// would have to come with it, which is the tail wagging a read-only preview.
|
||||||
|
//
|
||||||
|
// Nothing in the app or the phone defines it, so both are unchanged by construction: the flag only
|
||||||
|
// ever *subtracts*, and only from a target that has no caller for any of it.
|
||||||
|
#if os(macOS) && !STORAGE_ONLY
|
||||||
|
|
||||||
/// One landing reload's provenance answers, in the two shapes the announcer needs.
|
/// One landing reload's provenance answers, in the two shapes the announcer needs.
|
||||||
public struct EchoVerdicts: Sendable, Equatable {
|
public struct EchoVerdicts: Sendable, Equatable {
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// **Everything a Quick Look preview knows about a board** — its name, its tint, and each lane's
|
||||||
|
/// first few card titles — read straight off the folder tree with no store, no watcher and no
|
||||||
|
/// window behind it.
|
||||||
|
///
|
||||||
|
/// ### Why this is not `BoardLoader.load`
|
||||||
|
///
|
||||||
|
/// The snapshot the app opens a board with is the wrong shape for a preview in three ways, and each
|
||||||
|
/// one is a reason rather than a preference:
|
||||||
|
///
|
||||||
|
/// - **It fails.** `BoardLoader` collects fail-fast defects and throws a `BoardLoadFailure` for a
|
||||||
|
/// missing root `index.md`, an unreadable `schema`, a lane whose YAML will not parse
|
||||||
|
/// (01-storage-format.md § Malformed input). That is exactly right for opening a board — the user
|
||||||
|
/// is about to edit it — and exactly wrong for pressing Space in Finder, where the honest answer to
|
||||||
|
/// a half-broken board is *the part that reads*. Nothing in this file throws: every failure
|
||||||
|
/// degrades to the empty answer for the thing that failed and the walk carries on.
|
||||||
|
/// - **It is unbounded.** The loader visits every card, and inside each one it lists `attachments/`
|
||||||
|
/// and counts `comments/` (`BoardModel.Card`) — three directory reads per card, plus a parse, on
|
||||||
|
/// a board that may hold thousands. A preview has a few hundred milliseconds and no second
|
||||||
|
/// chance, so this walk is *capped at every level* (`BoardOutlineLimits`) and opens no file it
|
||||||
|
/// does not put on screen.
|
||||||
|
/// - **It carries what a preview cannot use.** Trash, tombstone migration, the identity dedupe, the
|
||||||
|
/// parse memo, the integrity defect stream, the coerce-tier trace — none of it renders, and all of
|
||||||
|
/// it costs.
|
||||||
|
///
|
||||||
|
/// What this does reuse is everything that decides *what the answer is*: `FrontmatterDocument` for
|
||||||
|
/// the parse and every field reading, `IntegrityRules.isIdentityShaped` for what counts as a lane or
|
||||||
|
/// a card, `BoardLoader.directoryCandidates` for the stray tolerance (hidden entries and symlinks
|
||||||
|
/// out, folder-name order in), and `Ranks` for display order. A second walker that re-derived any of
|
||||||
|
/// those would be a second storage format; this one only decides *how far to look*.
|
||||||
|
///
|
||||||
|
/// ### The name
|
||||||
|
///
|
||||||
|
/// "Outline", not "summary": `KanbanMobile/Cloud/BoardSummary.swift` already owns that word for the
|
||||||
|
/// phone's board *list* row — a name, a size, a download state, nothing about the contents — and the
|
||||||
|
/// phone compiles this directory into its own module, so the two would collide outright. The words
|
||||||
|
/// are worth keeping apart anyway: that one summarizes a board's *file*, and this one outlines its
|
||||||
|
/// *contents*.
|
||||||
|
public struct BoardOutline: Sendable, Equatable {
|
||||||
|
|
||||||
|
/// The board's name — its `title`, falling back to the folder name with `.kanban` stripped
|
||||||
|
/// (01-storage-format.md § Board naming). Non-optional, deliberately: this is
|
||||||
|
/// `AppModel.displayName(of:)`'s rule read one layer down, and "Untitled" appears nowhere on a
|
||||||
|
/// board because a board always has a folder (BoardInfoPopover ▸ Rename — "boards do not have
|
||||||
|
/// it"). Contrast `LaneOutline.title`.
|
||||||
|
public let title: String
|
||||||
|
|
||||||
|
/// The `background` mapping's colour as written — a palette name or a `#RRGGBB[AA]` hex — or
|
||||||
|
/// `nil` for a board that carries none.
|
||||||
|
///
|
||||||
|
/// **The value, not a colour**: resolving it is the renderer's job through `Palette`, exactly as
|
||||||
|
/// it is in the app, so an unrecognized value degrades to "no tint" at the one place that knows
|
||||||
|
/// what no tint looks like (Palette.swift — "Lenient, never an error").
|
||||||
|
public let background: String?
|
||||||
|
|
||||||
|
/// The board's `icon` (an SF Symbol name) and `iconColor`, both as written and both lenient in
|
||||||
|
/// the same way `background` is: a name the running system cannot draw is the renderer's problem
|
||||||
|
/// to degrade, not this walk's to validate.
|
||||||
|
public let icon: String?
|
||||||
|
public let iconColor: String?
|
||||||
|
|
||||||
|
/// The lanes the preview shows, in display order — **a prefix**, capped by
|
||||||
|
/// `BoardOutlineLimits.lanes`. `hiddenLaneCount` is the rest.
|
||||||
|
public let lanes: [LaneOutline]
|
||||||
|
|
||||||
|
/// Every lane the walk found, shown or not — the number the preview's header line states.
|
||||||
|
///
|
||||||
|
/// `laneCountIsCapped` marks the walk that stopped counting (`BoardOutlineLimits.laneScan`), so
|
||||||
|
/// a renderer can say "100+" rather than a number it would be lying about. On every board that
|
||||||
|
/// has ever existed this is `false`; it is here because a preview must have a bounded worst case
|
||||||
|
/// even on a folder nobody meant to be a board.
|
||||||
|
public let laneCount: Int
|
||||||
|
public let laneCountIsCapped: Bool
|
||||||
|
|
||||||
|
/// The lanes counted but not shown — the "+N more lanes" the preview ends on, and `0` on a board
|
||||||
|
/// whose lanes all fit.
|
||||||
|
public var hiddenLaneCount: Int { max(0, laneCount - lanes.count) }
|
||||||
|
|
||||||
|
public init(
|
||||||
|
title: String,
|
||||||
|
background: String? = nil,
|
||||||
|
icon: String? = nil,
|
||||||
|
iconColor: String? = nil,
|
||||||
|
lanes: [LaneOutline] = [],
|
||||||
|
laneCount: Int = 0,
|
||||||
|
laneCountIsCapped: Bool = false
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.background = background
|
||||||
|
self.icon = icon
|
||||||
|
self.iconColor = iconColor
|
||||||
|
self.lanes = lanes
|
||||||
|
self.laneCount = laneCount
|
||||||
|
self.laneCountIsCapped = laneCountIsCapped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One lane in a board summary: a title, a colour, a count, and the first few card titles.
|
||||||
|
public struct LaneOutline: Sendable, Equatable {
|
||||||
|
|
||||||
|
/// The lane's `title` as written, or `nil` where it carries none — **"Untitled" is a rendering,
|
||||||
|
/// never a value** (03-board-ui.md § Card face; the same rule `Lane`/`Card` follow throughout the
|
||||||
|
/// app), so the placeholder is applied where the text is drawn and never stored here. An empty
|
||||||
|
/// or whitespace-only title reads as `nil` for the same reason a blank lane header does.
|
||||||
|
public let title: String?
|
||||||
|
|
||||||
|
/// The lane's `background` colour as written — see `BoardOutline.background`; a lane's is an edge
|
||||||
|
/// accent rather than a backdrop (03-board-ui.md § Styling ▸ Capabilities).
|
||||||
|
public let background: String?
|
||||||
|
|
||||||
|
/// The first few cards' titles, in display order, each `nil` where the card carries no title.
|
||||||
|
/// Capped by `BoardOutlineLimits.cardTitles`.
|
||||||
|
///
|
||||||
|
/// **In display order over the *scanned* cards**, which is every card in the overwhelming case
|
||||||
|
/// and the first `BoardOutlineLimits.cardScan` of them otherwise — see that field for the cost
|
||||||
|
/// the cap accepts.
|
||||||
|
public let cardTitles: [String?]
|
||||||
|
|
||||||
|
/// How many cards the lane holds — a `readdir` plus one `stat` per child, never a parse, which is
|
||||||
|
/// `BoardLoader.commentCount(in:)`'s own arrangement one level up: a card is an identity-shaped
|
||||||
|
/// child carrying its own `index.md`, and whether that file *parses* costs a read this count does
|
||||||
|
/// not pay.
|
||||||
|
public let cardCount: Int
|
||||||
|
|
||||||
|
/// Whether the count stopped at `BoardOutlineLimits.cardCount` — a lane holding more cards than
|
||||||
|
/// the walk was willing to stat, which a renderer states as "2000+" rather than as a number.
|
||||||
|
public let cardCountIsCapped: Bool
|
||||||
|
|
||||||
|
/// The cards counted but not titled — the "+N more" under a lane's last shown title.
|
||||||
|
public var hiddenCardCount: Int { max(0, cardCount - cardTitles.count) }
|
||||||
|
|
||||||
|
public init(
|
||||||
|
title: String? = nil,
|
||||||
|
background: String? = nil,
|
||||||
|
cardTitles: [String?] = [],
|
||||||
|
cardCount: Int = 0,
|
||||||
|
cardCountIsCapped: Bool = false
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.background = background
|
||||||
|
self.cardTitles = cardTitles
|
||||||
|
self.cardCount = cardCount
|
||||||
|
self.cardCountIsCapped = cardCountIsCapped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **How far the summary walk looks** — the whole of what makes a preview bounded.
|
||||||
|
///
|
||||||
|
/// Quick Look gives an extension a short budget and no way to say "still working"; a board is a
|
||||||
|
/// folder tree of unknown size that a user can point Finder at. So every level of the walk has a
|
||||||
|
/// ceiling, and the ceilings are here rather than scattered through the walk so the answer to "what
|
||||||
|
/// does a preview cost, worst case?" is one struct: at most `laneScan` lane `index.md` parses, plus
|
||||||
|
/// per shown lane at most `cardCount` stats and `cardScan` card `index.md` parses.
|
||||||
|
///
|
||||||
|
/// The defaults are `preview`'s. Every one is generous enough that a real board is summarized
|
||||||
|
/// exactly — the caps exist for the pathological folder, not for the ordinary one.
|
||||||
|
public struct BoardOutlineLimits: Sendable, Equatable {
|
||||||
|
|
||||||
|
/// Lanes rendered, in display order. The rest become `BoardOutline.hiddenLaneCount`.
|
||||||
|
public var lanes: Int
|
||||||
|
|
||||||
|
/// Root children the walk will consider a lane at all. Past this the count itself stops
|
||||||
|
/// (`BoardOutline.laneCountIsCapped`) — it is the one cap that bounds the lane parse, since
|
||||||
|
/// display order cannot be known without reading every sibling's `order`.
|
||||||
|
public var laneScan: Int
|
||||||
|
|
||||||
|
/// Card titles shown per lane. The rest become `LaneOutline.hiddenCardCount`.
|
||||||
|
public var cardTitles: Int
|
||||||
|
|
||||||
|
/// Card `index.md` files parsed per lane, taken in folder-name order.
|
||||||
|
///
|
||||||
|
/// **The one cap with a visible cost**, stated plainly: a card's rank lives in its own file, so
|
||||||
|
/// the true first-`cardTitles` of a lane cannot be known without reading all of them. A lane
|
||||||
|
/// holding more cards than this shows the top of the *scanned* subset instead — its **count
|
||||||
|
/// stays exact**, and the titles are still cards in that lane, just not provably the topmost.
|
||||||
|
/// Set high enough that no lane a person has ever built reaches it.
|
||||||
|
public var cardScan: Int
|
||||||
|
|
||||||
|
/// Children stat'd per lane while counting. Past this the count stops
|
||||||
|
/// (`LaneOutline.cardCountIsCapped`).
|
||||||
|
public var cardCount: Int
|
||||||
|
|
||||||
|
public init(lanes: Int, laneScan: Int, cardTitles: Int, cardScan: Int, cardCount: Int) {
|
||||||
|
self.lanes = lanes
|
||||||
|
self.laneScan = laneScan
|
||||||
|
self.cardTitles = cardTitles
|
||||||
|
self.cardScan = cardScan
|
||||||
|
self.cardCount = cardCount
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Quick Look preview's own ceilings — the only set anything but a test uses.
|
||||||
|
public static let preview = BoardOutlineLimits(
|
||||||
|
lanes: 12, laneScan: 100, cardTitles: 6, cardScan: 200, cardCount: 2000
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The walk
|
||||||
|
|
||||||
|
extension BoardOutline {
|
||||||
|
|
||||||
|
/// Summarizes the board rooted at `boardRoot` — **total, and never throwing**.
|
||||||
|
///
|
||||||
|
/// A preview has no error surface: there is no alert to raise, no Repair sheet to offer and no
|
||||||
|
/// second attempt, so every failure this walk can meet degrades in place. A root that is not a
|
||||||
|
/// directory, an `index.md` that is missing or unparseable or not UTF-8, a lane folder that
|
||||||
|
/// vanishes mid-walk, a permissions race on a directory listing — each yields the empty answer
|
||||||
|
/// for that one thing and nothing else changes. The floor is a summary carrying the folder's own
|
||||||
|
/// name and no lanes, which is still a truthful preview of a folder that is not a board.
|
||||||
|
///
|
||||||
|
/// A lane or card whose `index.md` will not parse is **kept, not skipped**: it reads as untitled
|
||||||
|
/// and order-less (append-at-end, `Ranks.resolvedOrders`), so a board with one broken file
|
||||||
|
/// previews with one blank title rather than with a lane silently missing and a count that
|
||||||
|
/// disagrees with Finder.
|
||||||
|
public static func read(boardRoot: URL, limits: BoardOutlineLimits = .preview) -> BoardOutline {
|
||||||
|
let document = parsedIndex(in: boardRoot)
|
||||||
|
|
||||||
|
// The lane ceiling is applied to the *name-shaped* children, before the per-child `stat` that
|
||||||
|
// asks whether each holds an `index.md` — `readLane`'s ordering, and for its reason.
|
||||||
|
let identityShaped = directoryCandidates(in: boardRoot)
|
||||||
|
.filter { IntegrityRules.isIdentityShaped($0.lastPathComponent) }
|
||||||
|
let considered = identityShaped.prefix(max(0, limits.laneScan))
|
||||||
|
let candidates = considered.filter(hasIndex)
|
||||||
|
|
||||||
|
let walked = candidates.map { (url: $0, document: parsedIndex(in: $0)) }
|
||||||
|
let orders = Ranks.resolvedOrders(
|
||||||
|
of: walked,
|
||||||
|
stored: { $0.document?.order.value },
|
||||||
|
name: { $0.url.lastPathComponent }
|
||||||
|
)
|
||||||
|
let ranked = Array(zip(walked, orders))
|
||||||
|
let ordered = Ranks.sortedForDisplay(
|
||||||
|
ranked,
|
||||||
|
order: { $0.1 },
|
||||||
|
name: { $0.0.url.lastPathComponent }
|
||||||
|
)
|
||||||
|
|
||||||
|
let shown = ordered.prefix(max(0, limits.lanes))
|
||||||
|
return BoardOutline(
|
||||||
|
title: text(document?.title) ?? boardRoot.deletingPathExtension().lastPathComponent,
|
||||||
|
background: text(document?.background),
|
||||||
|
icon: text(document?.icon),
|
||||||
|
iconColor: text(document?.iconColor),
|
||||||
|
lanes: shown.map { readLane(at: $0.0.url, document: $0.0.document, limits: limits) },
|
||||||
|
laneCount: ordered.count,
|
||||||
|
laneCountIsCapped: identityShaped.count > considered.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One lane: its own fields off an already-parsed `index.md`, then its cards — counted by
|
||||||
|
/// listing, titled by parsing, and both capped.
|
||||||
|
private static func readLane(
|
||||||
|
at laneURL: URL,
|
||||||
|
document: FrontmatterDocument?,
|
||||||
|
limits: BoardOutlineLimits
|
||||||
|
) -> LaneOutline {
|
||||||
|
// The count's two halves, in this order on purpose: the name-shape filter is a string test
|
||||||
|
// and free, so the ceiling is applied to *it* and the per-child `stat` below never runs more
|
||||||
|
// than `limits.cardCount` times.
|
||||||
|
let identityShaped = directoryCandidates(in: laneURL)
|
||||||
|
.filter { IntegrityRules.isIdentityShaped($0.lastPathComponent) }
|
||||||
|
let considered = identityShaped.prefix(max(0, limits.cardCount))
|
||||||
|
let cards = considered.filter(hasIndex)
|
||||||
|
|
||||||
|
let scanned = cards.prefix(max(0, limits.cardScan))
|
||||||
|
let walked = scanned.map { (url: $0, document: parsedIndex(in: $0)) }
|
||||||
|
let orders = Ranks.resolvedOrders(
|
||||||
|
of: walked,
|
||||||
|
stored: { $0.document?.order.value },
|
||||||
|
name: { $0.url.lastPathComponent }
|
||||||
|
)
|
||||||
|
let ordered = Ranks.sortedForDisplay(
|
||||||
|
Array(zip(walked, orders)),
|
||||||
|
order: { $0.1 },
|
||||||
|
name: { $0.0.url.lastPathComponent }
|
||||||
|
)
|
||||||
|
|
||||||
|
return LaneOutline(
|
||||||
|
title: text(document?.title),
|
||||||
|
background: text(document?.background),
|
||||||
|
cardTitles: ordered.prefix(max(0, limits.cardTitles)).map { text($0.0.document?.title) },
|
||||||
|
cardCount: cards.count,
|
||||||
|
cardCountIsCapped: identityShaped.count > considered.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Filesystem helpers
|
||||||
|
|
||||||
|
/// `BoardLoader.directoryCandidates(in:)` with its typed throw flattened to the empty answer —
|
||||||
|
/// the function never actually throws (an unlistable folder already degrades to `[]` inside it),
|
||||||
|
/// and this walk has nowhere to put an error even if it did.
|
||||||
|
private static func directoryCandidates(in folder: URL) -> [URL] {
|
||||||
|
(try? BoardLoader.directoryCandidates(in: folder)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hasIndex(_ folder: URL) -> Bool {
|
||||||
|
FileManager.default.fileExists(
|
||||||
|
atPath: folder.appendingPathComponent(IntegrityRules.indexFileName).path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A folder's `index.md`, parsed — `nil` for every way that can fail: no file, unreadable bytes,
|
||||||
|
/// not UTF-8, unparseable YAML. The strict UTF-8 decode is `BoardLoader.parseDocument`'s, so a
|
||||||
|
/// BOM'd file reads here exactly as it loads there: as nothing.
|
||||||
|
private static func parsedIndex(in folder: URL) -> FrontmatterDocument? {
|
||||||
|
let url = folder.appendingPathComponent(IntegrityRules.indexFileName)
|
||||||
|
guard let data = try? Data(contentsOf: url),
|
||||||
|
let text = String(validating: data, as: UTF8.self)
|
||||||
|
else { return nil }
|
||||||
|
return try? FrontmatterDocument.parse(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A lenient string field's text, with **blank read as absent** — a title of `""` or of spaces is
|
||||||
|
/// a key that says nothing, and the summary's whole vocabulary for "says nothing" is `nil`
|
||||||
|
/// (`AppModel.displayName(of:)` applies the same `!title.isEmpty` test to a board's own name).
|
||||||
|
/// A missing key and a malformed one collapse to `nil` here too, exactly as they do at every
|
||||||
|
/// other lenient call site in the app.
|
||||||
|
private static func text(_ field: FieldValue<String>?) -> String? {
|
||||||
|
guard let value = field?.value else { return nil }
|
||||||
|
return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import Quartz
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// **The preview's whole appearance** — a `BoardOutline` turned into one self-contained HTML
|
||||||
|
/// document plus the images it references.
|
||||||
|
///
|
||||||
|
/// ### Why the styling values are resolved here and not in the walk
|
||||||
|
///
|
||||||
|
/// `BoardOutline` carries `background`, `icon` and `iconColor` **as written** — a palette name, a
|
||||||
|
/// hex, an SF Symbol name — exactly as `BoardModel` does, because "an unrecognized value renders as
|
||||||
|
/// the default and the bytes stay as written" is a *rendering* rule and this file is the renderer
|
||||||
|
/// (Palette.swift ▸ "Lenient, never an error"; ItemSymbol.swift ▸ the same for symbols). Resolution
|
||||||
|
/// goes through `Palette` itself rather than through a second copy of the twelve-and-twelve table,
|
||||||
|
/// so a board tinted `smokey-ocean` is the same colour in the preview as it is in the app.
|
||||||
|
///
|
||||||
|
/// ### Why the board tint is a wash and not a band
|
||||||
|
///
|
||||||
|
/// A board's colour is chosen to sit *behind a whole window* of app chrome, where the app picks
|
||||||
|
/// readable ink for it against the window backdrop (`ContrastMath` ▸ `BoardTextInk`). A preview has
|
||||||
|
/// no such machinery and should not grow one for a header strip, so the tint is applied at low alpha
|
||||||
|
/// under the title and at full strength only as a lane's edge accent — narrow marks that carry no
|
||||||
|
/// text. Contrast is then never in question, in either appearance, for any of the 16.7 million
|
||||||
|
/// values a hand-written hex can be.
|
||||||
|
enum BoardPreviewPage {
|
||||||
|
|
||||||
|
/// The size Quick Look draws its loading state at, and its opening guess at the panel. Only a
|
||||||
|
/// hint: HTML has no intrinsic size, so the panel is the user's to resize and the page reflows.
|
||||||
|
static let contentSizeHint = CGSize(width: 820, height: 560)
|
||||||
|
|
||||||
|
/// The board's default symbol when it carries no `icon` — `ItemSymbol.board`'s value
|
||||||
|
/// (03-board-ui.md § Styling ▸ Capabilities: "`icon`: SF Symbol per item with per-level
|
||||||
|
/// defaults").
|
||||||
|
///
|
||||||
|
/// Restated rather than shared: `ItemSymbol` reaches `StyleLevel` for its per-level lookup, which
|
||||||
|
/// reaches the live store, which has no business inside a preview extension. One string is the
|
||||||
|
/// cheaper coupling, and it is the board's default — the only one of the three this file can ever
|
||||||
|
/// need.
|
||||||
|
private static let defaultSymbol = "rectangle.split.3x1"
|
||||||
|
|
||||||
|
/// The mark's two bakings. A PNG has one colour and a preview has two appearances, so the page
|
||||||
|
/// carries both and lets CSS choose — see `attachments(for:)`.
|
||||||
|
private static let lightMarkID = "board-mark-light"
|
||||||
|
private static let darkMarkID = "board-mark-dark"
|
||||||
|
|
||||||
|
// MARK: - The page
|
||||||
|
|
||||||
|
static func html(for outline: BoardOutline) -> Data {
|
||||||
|
var out = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n"
|
||||||
|
out += "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
|
||||||
|
out += "<title>\(escape(outline.title))</title>\n"
|
||||||
|
out += "<style>\n\(stylesheet(for: outline))\n</style>\n</head>\n<body>\n"
|
||||||
|
out += "<main>\n"
|
||||||
|
out += header(for: outline)
|
||||||
|
out += lanes(of: outline)
|
||||||
|
out += "</main>\n</body>\n</html>\n"
|
||||||
|
return Data(out.utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func header(for outline: BoardOutline) -> String {
|
||||||
|
var out = "<header class=\"board\">\n"
|
||||||
|
out += "<img class=\"mark light-only\" src=\"cid:\(lightMarkID)\" alt=\"\">"
|
||||||
|
out += "<img class=\"mark dark-only\" src=\"cid:\(darkMarkID)\" alt=\"\">\n"
|
||||||
|
out += "<div class=\"board-text\">\n"
|
||||||
|
out += "<h1>\(escape(outline.title))</h1>\n"
|
||||||
|
out += "<p class=\"meta\">\(escape(laneCountPhrase(for: outline)))</p>\n"
|
||||||
|
out += "</div>\n</header>\n"
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "3 lanes", "1 lane", "No lanes yet" — and "100+ lanes" for the board whose count the walk
|
||||||
|
/// stopped taking (`BoardOutline.laneCountIsCapped`), because a number that is not the number is
|
||||||
|
/// worse than an honest floor.
|
||||||
|
private static func laneCountPhrase(for outline: BoardOutline) -> String {
|
||||||
|
guard outline.laneCount > 0 else { return "No lanes yet" }
|
||||||
|
let count = outline.laneCountIsCapped ? "\(outline.laneCount)+" : "\(outline.laneCount)"
|
||||||
|
return "\(count) \(outline.laneCount == 1 && !outline.laneCountIsCapped ? "lane" : "lanes")"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func lanes(of outline: BoardOutline) -> String {
|
||||||
|
// A board with nothing in it, and a folder that is not a board at all, reach here the same
|
||||||
|
// way and say so once — the header's "No lanes yet" is the count, this is the invitation.
|
||||||
|
guard outline.laneCount > 0 else {
|
||||||
|
return "<p class=\"empty\">This board has no lanes yet.</p>\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
var out = "<div class=\"lanes\">\n"
|
||||||
|
for lane in outline.lanes {
|
||||||
|
let accent = cssColor(lane.background).map { " style=\"--accent: \($0)\"" } ?? ""
|
||||||
|
out += "<section class=\"lane\"\(accent)>\n"
|
||||||
|
out += "<h2><span class=\"lane-title\">\(escape(lane.title ?? "Untitled"))</span>"
|
||||||
|
out += "<span class=\"count\">\(escape(cardCountPhrase(for: lane)))</span></h2>\n"
|
||||||
|
if lane.cardTitles.isEmpty {
|
||||||
|
out += "<p class=\"empty-lane\">Empty</p>\n"
|
||||||
|
} else {
|
||||||
|
out += "<ul>\n"
|
||||||
|
for title in lane.cardTitles {
|
||||||
|
out += "<li>\(escape(title ?? "Untitled"))</li>\n"
|
||||||
|
}
|
||||||
|
out += "</ul>\n"
|
||||||
|
}
|
||||||
|
if lane.hiddenCardCount > 0 {
|
||||||
|
out += "<p class=\"more\">+\(lane.hiddenCardCount) more</p>\n"
|
||||||
|
}
|
||||||
|
out += "</section>\n"
|
||||||
|
}
|
||||||
|
out += "</div>\n"
|
||||||
|
|
||||||
|
if outline.hiddenLaneCount > 0 {
|
||||||
|
let suffix = outline.laneCountIsCapped ? "+" : ""
|
||||||
|
out += "<p class=\"more-lanes\">+\(outline.hiddenLaneCount)\(suffix) more lanes</p>\n"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A lane's badge: its exact card count, or the floor the count stopped at
|
||||||
|
/// (`LaneOutline.cardCountIsCapped`).
|
||||||
|
private static func cardCountPhrase(for lane: LaneOutline) -> String {
|
||||||
|
lane.cardCountIsCapped ? "\(lane.cardCount)+" : "\(lane.cardCount)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Style
|
||||||
|
|
||||||
|
private static func stylesheet(for outline: BoardOutline) -> String {
|
||||||
|
let wash = cssColor(outline.background, alpha: 0.16) ?? "transparent"
|
||||||
|
let rule = cssColor(outline.background, alpha: 0.55) ?? "var(--hairline)"
|
||||||
|
return """
|
||||||
|
:root {
|
||||||
|
--ink: #1d1d1f;
|
||||||
|
--ink-quiet: #6e6e73;
|
||||||
|
--page: #ffffff;
|
||||||
|
--plate: #f5f5f7;
|
||||||
|
--hairline: rgba(0, 0, 0, 0.12);
|
||||||
|
--wash: \(wash);
|
||||||
|
--rule: \(rule);
|
||||||
|
--accent: var(--hairline);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--ink: #f5f5f7;
|
||||||
|
--ink-quiet: #98989d;
|
||||||
|
--page: #1e1e1e;
|
||||||
|
--plate: #2a2a2c;
|
||||||
|
--hairline: rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--page);
|
||||||
|
color: var(--ink);
|
||||||
|
font: 13px/1.45 -apple-system, "SF Pro Text", "Helvetica Neue", sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
main { padding: 20px 22px 26px; }
|
||||||
|
header.board {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--wash);
|
||||||
|
border-bottom: 2px solid var(--rule);
|
||||||
|
/* The wash and the rule are both the board's own colour, so a board tinted the same shade
|
||||||
|
as the page it is previewed on paints nothing at all — a `#1E1E1E` board in the dark
|
||||||
|
appearance. This hairline is what keeps the block a block regardless: the tint is the
|
||||||
|
board's, the edge is the page's. */
|
||||||
|
box-shadow: inset 0 0 0 1px var(--hairline);
|
||||||
|
}
|
||||||
|
.mark { width: 34px; height: 34px; flex: none; object-fit: contain; }
|
||||||
|
.light-only { display: block; }
|
||||||
|
.dark-only { display: none; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.light-only { display: none; }
|
||||||
|
.dark-only { display: block; }
|
||||||
|
}
|
||||||
|
.board-text { min-width: 0; }
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.meta { margin: 2px 0 0; color: var(--ink-quiet); font-size: 12px; }
|
||||||
|
.lanes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.lane {
|
||||||
|
flex: 1 1 190px;
|
||||||
|
min-width: 170px;
|
||||||
|
max-width: 300px;
|
||||||
|
padding: 10px 12px 10px 13px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--plate);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
}
|
||||||
|
.lane h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.lane-title { overflow-wrap: anywhere; }
|
||||||
|
.count {
|
||||||
|
flex: none;
|
||||||
|
color: var(--ink-quiet);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.lane ul { margin: 0; padding: 0; list-style: none; }
|
||||||
|
.lane li {
|
||||||
|
padding: 4px 0;
|
||||||
|
border-top: 1px solid var(--hairline);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.lane li:first-child { border-top: none; padding-top: 0; }
|
||||||
|
.more, .empty-lane { margin: 6px 0 0; color: var(--ink-quiet); font-size: 12px; }
|
||||||
|
.more-lanes, .empty { margin: 14px 0 0; color: var(--ink-quiet); font-size: 12px; }
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Colour
|
||||||
|
|
||||||
|
/// A stored styling value as a CSS colour — `Palette`'s resolution (a palette name or a
|
||||||
|
/// `#RRGGBB[AA]` hex), re-emitted as `rgba()` so the value's own alpha and the caller's can be
|
||||||
|
/// combined. `nil` for every shape `Palette` declines to read, which every call site treats as
|
||||||
|
/// "there is no colour" rather than as an error.
|
||||||
|
private static func cssColor(_ value: String?, alpha: Double = 1) -> String? {
|
||||||
|
guard let value,
|
||||||
|
let color = Palette.nsColor(for: value)?.usingColorSpace(.sRGB)
|
||||||
|
else { return nil }
|
||||||
|
let channel = { (component: CGFloat) in Int((component * 255).rounded()) }
|
||||||
|
let combined = Double(color.alphaComponent) * alpha
|
||||||
|
return "rgba(\(channel(color.redComponent)), \(channel(color.greenComponent)), "
|
||||||
|
+ "\(channel(color.blueComponent)), \(String(format: "%.3f", combined)))"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The mark
|
||||||
|
|
||||||
|
/// The board's symbol, baked twice: once in the light appearance's ink and once in the dark
|
||||||
|
/// one's, so a page rendered in either reads correctly. A board that names its own `iconColor`
|
||||||
|
/// gets that colour in both — a chosen tint is not an appearance-dependent value.
|
||||||
|
///
|
||||||
|
/// Both entries are always present, even when the symbol will not resolve: an attachment
|
||||||
|
/// dictionary missing a `cid:` the page references is a broken-image glyph, and this way a bad
|
||||||
|
/// `icon:` degrades to the *default* symbol exactly as it does in the app.
|
||||||
|
static func attachments(for outline: BoardOutline) -> [String: QLPreviewReplyAttachment] {
|
||||||
|
let symbol = outline.icon.flatMap(resolvedSymbol) ?? defaultSymbol
|
||||||
|
let chosen = outline.iconColor.flatMap(Palette.nsColor(for:))
|
||||||
|
var attachments: [String: QLPreviewReplyAttachment] = [:]
|
||||||
|
let inks: [(id: String, fallback: NSColor)] = [
|
||||||
|
(lightMarkID, NSColor(srgbRed: 0.11, green: 0.11, blue: 0.12, alpha: 1)),
|
||||||
|
(darkMarkID, NSColor(srgbRed: 0.96, green: 0.96, blue: 0.97, alpha: 1)),
|
||||||
|
]
|
||||||
|
for ink in inks {
|
||||||
|
guard let png = markPNG(symbol: symbol, tint: chosen ?? ink.fallback) else { continue }
|
||||||
|
attachments[ink.id] = QLPreviewReplyAttachment(data: png, contentType: .png)
|
||||||
|
}
|
||||||
|
return attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `name` if the running system can draw it as an SF Symbol — `ItemSymbol.exists(_:)`'s rule and
|
||||||
|
/// its reasoning ("`NSImage(systemSymbolName:)` is the only honest test").
|
||||||
|
private static func resolvedSymbol(_ name: String) -> String? {
|
||||||
|
guard !name.isEmpty, NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil
|
||||||
|
else { return nil }
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One symbol as tinted PNG bytes.
|
||||||
|
///
|
||||||
|
/// Drawn at twice the size the page displays it at (34 CSS pixels), which is the whole reason for
|
||||||
|
/// the point size below: a PNG has no vector fallback, and a Retina panel would show a 34-pixel
|
||||||
|
/// image soft. `nil` for a symbol the system declines to draw or an image that will not encode —
|
||||||
|
/// the page then simply shows no mark, which is `Palette`'s "there is no colour, so show none"
|
||||||
|
/// one medium over.
|
||||||
|
private static func markPNG(symbol: String, tint: NSColor) -> Data? {
|
||||||
|
let configuration = NSImage.SymbolConfiguration(pointSize: 68, weight: .regular)
|
||||||
|
guard let image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)?
|
||||||
|
.withSymbolConfiguration(configuration)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
// `sourceAtop` over the drawn glyph rather than a palette configuration: it flattens a
|
||||||
|
// multicolour symbol to the one tint, which is what the app's own `foregroundStyle` does to
|
||||||
|
// it anyway, and it cannot come out black the way a template image can.
|
||||||
|
let tinted = NSImage(size: image.size, flipped: false) { rect in
|
||||||
|
image.draw(in: rect)
|
||||||
|
tint.setFill()
|
||||||
|
rect.fill(using: .sourceAtop)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let tiff = tinted.tiffRepresentation,
|
||||||
|
let bitmap = NSBitmapImageRep(data: tiff)
|
||||||
|
else { return nil }
|
||||||
|
return bitmap.representation(using: .png, properties: [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Escaping
|
||||||
|
|
||||||
|
/// Every character that could end an attribute, open a tag or start an entity — the page is
|
||||||
|
/// assembled from board text that is entirely the user's, so nothing reaches the document without
|
||||||
|
/// passing through here.
|
||||||
|
private static func escape(_ text: String) -> String {
|
||||||
|
var escaped = ""
|
||||||
|
escaped.reserveCapacity(text.count)
|
||||||
|
for character in text {
|
||||||
|
switch character {
|
||||||
|
case "&": escaped += "&"
|
||||||
|
case "<": escaped += "<"
|
||||||
|
case ">": escaped += ">"
|
||||||
|
case "\"": escaped += """
|
||||||
|
case "'": escaped += "'"
|
||||||
|
default: escaped.append(character)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return escaped
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Foundation
|
||||||
|
import Quartz
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// **Space in Finder, on a `.kanban` package** — the whole of this extension's job.
|
||||||
|
///
|
||||||
|
/// A board is a folder, and a folder previews as a folder: an icon and nothing else. This gives it a
|
||||||
|
/// document's preview instead — its name, its tint, its lanes in order, each lane's card count and
|
||||||
|
/// its first few card titles — built from `BoardOutline.read`, the capped read-only walk that lives
|
||||||
|
/// beside the storage format it reads (Kanban/Storage/BoardOutline.swift).
|
||||||
|
///
|
||||||
|
/// ### Data-based, not view-based
|
||||||
|
///
|
||||||
|
/// `QLPreviewProvider` is the data-based half of the Quick Look preview API: the extension answers
|
||||||
|
/// with *content* (`QLPreviewReply`) and Quick Look owns the window. The view-based half
|
||||||
|
/// (`QLPreviewingController` on an `NSViewController`) would put a SwiftUI hierarchy on screen, and
|
||||||
|
/// this preview has no use for one — it is static by design (v1: no interactivity, no attachment or
|
||||||
|
/// image loading), so a view controller would be a lifecycle to keep honest in exchange for nothing.
|
||||||
|
///
|
||||||
|
/// The reply is **HTML**, which is the one supported data type that reflows: a Quick Look panel is
|
||||||
|
/// resized by the user and a board summary is a wrapping row of columns, so a fixed `contentSize`
|
||||||
|
/// drawing block or a rendered image would be the wrong shape the moment the panel is not the size
|
||||||
|
/// it was baked at. HTML also gets vector text, selectable text, its own scrolling, and light/dark
|
||||||
|
/// through `prefers-color-scheme` — all of which a `CGContext` reply would have to reinvent.
|
||||||
|
///
|
||||||
|
/// ### Where the work happens
|
||||||
|
///
|
||||||
|
/// Everything is inside the reply's data-creation block, which is where Apple's own documentation
|
||||||
|
/// puts it ("Heavy lifting should be done inside of the dataCreationBlock instead of when creating
|
||||||
|
/// the QLPreviewReply"): `providePreview` returns immediately with a size hint, Quick Look draws its
|
||||||
|
/// loading state at the right size, and the walk runs while it does.
|
||||||
|
///
|
||||||
|
/// ### Reading the board
|
||||||
|
///
|
||||||
|
/// The extension is sandboxed with read access to the URL it was handed and nothing else, which is
|
||||||
|
/// exactly what the walk needs — it opens `index.md` files under `request.fileURL` and never looks
|
||||||
|
/// outside the package. `BoardOutline.read` does not throw: a folder that is not a board, a board
|
||||||
|
/// with a broken `index.md`, a permissions race mid-walk all yield an outline of whatever *did* read,
|
||||||
|
/// so this method has no error path of its own and Quick Look never sees a failed preview where it
|
||||||
|
/// could have shown a name.
|
||||||
|
final class BoardPreviewProvider: QLPreviewProvider, QLPreviewingController {
|
||||||
|
|
||||||
|
func providePreview(for request: QLFilePreviewRequest) async throws -> QLPreviewReply {
|
||||||
|
let boardRoot = request.fileURL
|
||||||
|
return QLPreviewReply(
|
||||||
|
dataOfContentType: .html,
|
||||||
|
contentSize: BoardPreviewPage.contentSizeHint
|
||||||
|
) { reply in
|
||||||
|
let outline = BoardOutline.read(boardRoot: boardRoot)
|
||||||
|
reply.stringEncoding = .utf8
|
||||||
|
// The panel's own title bar. Left empty, Quick Look uses the file name — which is the
|
||||||
|
// folder name, and therefore *wrong* for every board whose `title:` differs from it.
|
||||||
|
reply.title = outline.title
|
||||||
|
reply.attachments = BoardPreviewPage.attachments(for: outline)
|
||||||
|
return BoardPreviewPage.html(for: outline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Lanework Board Preview</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>Lanework Board Preview</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>© 2026 rzen</string>
|
||||||
|
<!-- The classic app-extension shape, matching Xcode's own "Quick Look Preview Extension"
|
||||||
|
template: Quick Look's preview point is an NSExtension point, not an ExtensionKit one.
|
||||||
|
|
||||||
|
`QLIsDataBasedPreview` is what selects the data-based half of the API — the principal
|
||||||
|
class is a `QLPreviewProvider` answering with content (`QLPreviewReply`) rather than an
|
||||||
|
NSViewController putting a view on screen. See BoardPreviewProvider.swift for why.
|
||||||
|
|
||||||
|
`QLSupportedContentTypes` names the board type the *host app* exports (Kanban/Info.plist ▸
|
||||||
|
UTExportedTypeDeclarations, `LSHandlerRank: Owner`). The extension deliberately re-declares
|
||||||
|
nothing: it ships inside that app's bundle, so LaunchServices already knows the type, and a
|
||||||
|
second declaration of one identifier is how two bundles end up disagreeing about it.
|
||||||
|
|
||||||
|
`QLSupportsSearchableItems` is false — this previews files, and there is no Spotlight
|
||||||
|
searchable-item vocabulary behind a board. -->
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.quicklook.preview</string>
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).BoardPreviewProvider</string>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>QLSupportedContentTypes</key>
|
||||||
|
<array>
|
||||||
|
<string>dev.rzen.indie.kanban-board</string>
|
||||||
|
</array>
|
||||||
|
<key>QLSupportsSearchableItems</key>
|
||||||
|
<false/>
|
||||||
|
<key>QLIsDataBasedPreview</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<!-- **Read-only, and that is the whole of it** — the two keys Xcode's own Quick Look extension
|
||||||
|
template sets (`ENABLE_APP_SANDBOX`, `ENABLE_USER_SELECTED_FILES = readonly`), written out
|
||||||
|
here because this project declares entitlements in files rather than in build settings.
|
||||||
|
|
||||||
|
Quick Look hands the extension a sandbox extension for the one URL it was asked to preview;
|
||||||
|
this key is what lets that extension be consumed. Nothing else is needed and nothing else is
|
||||||
|
asked for: the walk reads `index.md` files under that URL and writes nothing, anywhere.
|
||||||
|
|
||||||
|
Deliberately absent, and each for a stated reason: no `read-write` (a preview must never be
|
||||||
|
able to change the board it is describing), no `bookmarks.app-scope` (the extension resolves
|
||||||
|
no bookmark — it is handed a live URL per request and keeps nothing between them), no
|
||||||
|
`network.client` (the page is self-contained HTML with its images inlined as reply
|
||||||
|
attachments; there is nothing to fetch), and no `print`. -->
|
||||||
|
<key>com.apple.security.files.user-selected.read-only</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
// The Quick Look preview's reading of a board (`BoardOutline`, Kanban/Storage/BoardOutline.swift):
|
||||||
|
// what a `.kanban` package says about itself when Space is pressed on it in Finder. The extension
|
||||||
|
// shell around it — `QLPreviewProvider`, the HTML page, the symbol PNGs — is untested by design and
|
||||||
|
// by nature: it is a bundle the system loads out of process, and everything in it that could be
|
||||||
|
// wrong about a *board* is decided here.
|
||||||
|
//
|
||||||
|
// The walk is deliberately total (it never throws), so these tests assert the degrade as hard as they
|
||||||
|
// assert the happy path: a folder that is not a board, a lane whose YAML is broken, a card with no
|
||||||
|
// title, and every cap.
|
||||||
|
|
||||||
|
// MARK: - Fixture builders
|
||||||
|
|
||||||
|
/// A synthetic board tree under a temp directory — `BoardLoaderTests`' builder, one suite over,
|
||||||
|
/// carrying only the two shapes this walk can see (an `index.md`, and a folder without one).
|
||||||
|
private struct OutlineFixture {
|
||||||
|
let root: URL
|
||||||
|
|
||||||
|
init(named name: String = "Board.kanban") throws {
|
||||||
|
root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("BoardOutlineTests-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
.appendingPathComponent(name, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tearDown() {
|
||||||
|
try? FileManager.default.removeItem(at: root.deletingLastPathComponent())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `index.md` at `relativePath` (`""` for the board root), with `frontmatter` between the
|
||||||
|
/// delimiters. `frontmatter` must end in a newline, exactly as the loader's own builder requires.
|
||||||
|
@discardableResult
|
||||||
|
func index(_ relativePath: String, _ frontmatter: String, body: String = "") throws -> URL {
|
||||||
|
let folder = relativePath.isEmpty
|
||||||
|
? root
|
||||||
|
: root.appendingPathComponent(relativePath, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
try "---\n\(frontmatter)---\n\(body)"
|
||||||
|
.write(to: folder.appendingPathComponent("index.md"), atomically: true, encoding: .utf8)
|
||||||
|
return folder
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A folder with no `index.md` — the interrupted-create shape, which is not a lane and not a card.
|
||||||
|
@discardableResult
|
||||||
|
func emptyFolder(_ relativePath: String) throws -> URL {
|
||||||
|
let folder = root.appendingPathComponent(relativePath, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
return folder
|
||||||
|
}
|
||||||
|
|
||||||
|
func file(_ relativePath: String, contents: String) throws {
|
||||||
|
let url = root.appendingPathComponent(relativePath)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A UUID-shaped folder name whose *lexicographic* order is the test's to choose — the walk's
|
||||||
|
/// tie-break is the folder name, so a test about ordering needs names it controls.
|
||||||
|
private func identity(_ nibble: String) -> String {
|
||||||
|
"\(nibble)0000000-0000-4000-8000-000000000000"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `Fixtures/` folder reference in the test bundle — `FixtureBoardTests`' resolution, restated
|
||||||
|
/// here because that file keeps its own private (there is no `Bundle.module` in an xcodeproj target).
|
||||||
|
private final class OutlineFixtureBundleAnchor {}
|
||||||
|
|
||||||
|
private func fixtureBoard(_ relativePath: String) -> URL {
|
||||||
|
guard let resources = Bundle(for: OutlineFixtureBundleAnchor.self).resourceURL else {
|
||||||
|
fatalError("test bundle has no resourceURL")
|
||||||
|
}
|
||||||
|
return resources
|
||||||
|
.appendingPathComponent("Fixtures", isDirectory: true)
|
||||||
|
.appendingPathComponent(relativePath, isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The golden board
|
||||||
|
|
||||||
|
/// `Fixtures/Valid/rich-board.kanban` is the disk-backed case: a real tree, hand-authored, with two
|
||||||
|
/// lanes, three cards, styling at every level and unknown keys throughout. If the preview reads this
|
||||||
|
/// board correctly it reads the format correctly.
|
||||||
|
struct BoardOutlineFixtureTests {
|
||||||
|
|
||||||
|
@Test func readsTheRichBoardsNameStylingAndLanes() {
|
||||||
|
let summary = BoardOutline.read(boardRoot: fixtureBoard("Valid/rich-board.kanban"))
|
||||||
|
|
||||||
|
#expect(summary.title == "Rich Demo Board")
|
||||||
|
#expect(summary.background == "#1E1E1E")
|
||||||
|
#expect(summary.icon == "rectangle.stack.fill")
|
||||||
|
#expect(summary.iconColor == "purple")
|
||||||
|
#expect(summary.laneCount == 2)
|
||||||
|
#expect(summary.laneCountIsCapped == false)
|
||||||
|
#expect(summary.hiddenLaneCount == 0)
|
||||||
|
|
||||||
|
// Lane display order is `order` ascending — Doing (1024) then Done (2048).
|
||||||
|
#expect(summary.lanes.map(\.title) == ["Doing", "Done"])
|
||||||
|
#expect(summary.lanes.map(\.cardCount) == [2, 1])
|
||||||
|
#expect(summary.lanes.map(\.cardCountIsCapped) == [false, false])
|
||||||
|
#expect(summary.lanes.map(\.background) == ["#3478F6", "green"])
|
||||||
|
|
||||||
|
// Cards in display order within each lane, and `attachments/`/`comments/` beside one of them
|
||||||
|
// are not cards: they are not identity-shaped, so the count stays at two.
|
||||||
|
#expect(summary.lanes[0].cardTitles == [
|
||||||
|
"Design the fixture taxonomy", "Wire up the loader's stray tolerance",
|
||||||
|
])
|
||||||
|
#expect(summary.lanes[1].cardTitles == ["Ship v1"])
|
||||||
|
#expect(summary.lanes.map(\.hiddenCardCount) == [0, 0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The board's own name
|
||||||
|
|
||||||
|
struct BoardOutlineTitleTests {
|
||||||
|
|
||||||
|
@Test func fallsBackToTheFolderNameWithoutTheExtension() throws {
|
||||||
|
let fixture = try OutlineFixture(named: "Quarterly Plan.kanban")
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
|
||||||
|
// **A board never reads "Untitled"** — `AppModel.displayName(of:)`'s rule, one layer down:
|
||||||
|
// a board always has a folder, and the folder name is the Finder document name.
|
||||||
|
#expect(BoardOutline.read(boardRoot: fixture.root).title == "Quarterly Plan")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func aBlankTitleIsAnAbsentOne() throws {
|
||||||
|
let fixture = try OutlineFixture(named: "Roadmap.kanban")
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.index("", "schema: 1\ntitle: \" \"\n")
|
||||||
|
|
||||||
|
#expect(BoardOutline.read(boardRoot: fixture.root).title == "Roadmap")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func aFolderThatIsNotABoardStillPreviewsAsItself() throws {
|
||||||
|
let fixture = try OutlineFixture(named: "Not A Board.kanban")
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.file("notes.txt", contents: "nothing to see")
|
||||||
|
|
||||||
|
// No `index.md` at all: the walk cannot throw, so the floor is the folder's own name and no
|
||||||
|
// lanes — a truthful preview of a folder that is not a board.
|
||||||
|
let summary = BoardOutline.read(boardRoot: fixture.root)
|
||||||
|
#expect(summary.title == "Not A Board")
|
||||||
|
#expect(summary.lanes.isEmpty)
|
||||||
|
#expect(summary.laneCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func aRootThatDoesNotExistIsNotAnError() {
|
||||||
|
let missing = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("BoardOutlineTests-absent-\(UUID().uuidString).kanban")
|
||||||
|
|
||||||
|
let summary = BoardOutline.read(boardRoot: missing)
|
||||||
|
#expect(summary.title.hasPrefix("BoardOutlineTests-absent-"))
|
||||||
|
#expect(summary.lanes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func anUnreadableRootIndexFallsBackWithoutLosingTheLanes() throws {
|
||||||
|
let fixture = try OutlineFixture(named: "Broken Root.kanban")
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
// Not UTF-8: `BoardLoader` fails the whole board on this (01-storage-format.md § Malformed
|
||||||
|
// input). Here it costs the board's own fields and nothing else — the lanes are enumerated by
|
||||||
|
// folder shape and read their own files, so nothing below the root depends on it.
|
||||||
|
try Data([0xFF, 0xFE, 0x00]).write(to: fixture.root.appendingPathComponent("index.md"))
|
||||||
|
try fixture.index(identity("a"), "schema: 1\norder: 1024\ntitle: Doing\n")
|
||||||
|
|
||||||
|
let summary = BoardOutline.read(boardRoot: fixture.root)
|
||||||
|
#expect(summary.title == "Broken Root")
|
||||||
|
#expect(summary.background == nil)
|
||||||
|
#expect(summary.lanes.map(\.title) == ["Doing"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Untitled lanes and cards
|
||||||
|
|
||||||
|
struct BoardOutlineUntitledTests {
|
||||||
|
|
||||||
|
@Test func anUntitledLaneOrCardCarriesNilRatherThanAPlaceholder() throws {
|
||||||
|
let fixture = try OutlineFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.index("", "schema: 1\ntitle: Board\n")
|
||||||
|
let lane = identity("a")
|
||||||
|
try fixture.index(lane, "schema: 1\norder: 1024\n")
|
||||||
|
try fixture.index("\(lane)/\(identity("1"))", "schema: 1\norder: 1024\n")
|
||||||
|
try fixture.index("\(lane)/\(identity("2"))", "schema: 1\norder: 2048\ntitle: \"\"\n")
|
||||||
|
|
||||||
|
// **"Untitled" is a rendering, never a value** (03-board-ui.md § Card face): the model says
|
||||||
|
// nothing, and the page is where the placeholder appears. A blank title reads the same as an
|
||||||
|
// absent one.
|
||||||
|
let summary = BoardOutline.read(boardRoot: fixture.root)
|
||||||
|
#expect(summary.lanes.count == 1)
|
||||||
|
#expect(summary.lanes[0].title == nil)
|
||||||
|
#expect(summary.lanes[0].cardTitles == [nil, nil])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func aLaneWithBrokenFrontmatterIsKeptRatherThanDropped() throws {
|
||||||
|
let fixture = try OutlineFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.index("", "schema: 1\ntitle: Board\n")
|
||||||
|
try fixture.index(identity("a"), "schema: 1\norder: 1024\ntitle: Doing\n")
|
||||||
|
// Not UTF-8 — the strict decode refuses it, exactly as `BoardLoader.parseDocument` does.
|
||||||
|
let broken = try fixture.emptyFolder(identity("b"))
|
||||||
|
try Data([0xFF, 0xFE, 0x00]).write(to: broken.appendingPathComponent("index.md"))
|
||||||
|
try fixture.index("\(identity("b"))/\(identity("1"))", "schema: 1\ntitle: Orphan\n")
|
||||||
|
|
||||||
|
// The unreadable lane is order-less, so it appends past every ranked sibling — and its cards
|
||||||
|
// are still counted, because a preview that silently dropped a lane would disagree with
|
||||||
|
// Finder about what is in the folder.
|
||||||
|
let summary = BoardOutline.read(boardRoot: fixture.root)
|
||||||
|
#expect(summary.laneCount == 2)
|
||||||
|
#expect(summary.lanes.map(\.title) == ["Doing", nil])
|
||||||
|
#expect(summary.lanes[1].cardCount == 1)
|
||||||
|
#expect(summary.lanes[1].cardTitles == ["Orphan"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - What counts as a lane or a card
|
||||||
|
|
||||||
|
struct BoardOutlineCandidateTests {
|
||||||
|
|
||||||
|
@Test func straysAreNeitherLanesNorCards() throws {
|
||||||
|
let fixture = try OutlineFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.index("", "schema: 1\ntitle: Board\n")
|
||||||
|
let lane = identity("a")
|
||||||
|
try fixture.index(lane, "schema: 1\norder: 1024\ntitle: Doing\n")
|
||||||
|
try fixture.index("\(lane)/\(identity("1"))", "schema: 1\norder: 1024\ntitle: Real\n")
|
||||||
|
|
||||||
|
// Four shapes that are not levels, each for the reason the loader states:
|
||||||
|
try fixture.index("notes", "schema: 1\ntitle: Not a lane\n") // not identity-shaped
|
||||||
|
try fixture.emptyFolder(identity("b")) // no index.md
|
||||||
|
try fixture.emptyFolder("\(lane)/\(identity("2"))") // no index.md
|
||||||
|
try fixture.file("\(lane)/stray.md", contents: "loose") // a file, not a folder
|
||||||
|
try fixture.index(".trash/\(identity("9"))", "schema: 1\ntitle: Gone\n") // hidden
|
||||||
|
|
||||||
|
let summary = BoardOutline.read(boardRoot: fixture.root)
|
||||||
|
#expect(summary.laneCount == 1)
|
||||||
|
#expect(summary.lanes[0].cardCount == 1)
|
||||||
|
#expect(summary.lanes[0].cardTitles == ["Real"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ordering
|
||||||
|
|
||||||
|
struct BoardOutlineOrderingTests {
|
||||||
|
|
||||||
|
@Test func lanesAndCardsFollowTheBoardsOwnDisplayOrder() throws {
|
||||||
|
let fixture = try OutlineFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.index("", "schema: 1\ntitle: Board\n")
|
||||||
|
// Folder names ascend a, b, c while `order` descends — so a walk that trusted the listing
|
||||||
|
// rather than the rank would read backwards.
|
||||||
|
try fixture.index(identity("a"), "schema: 1\norder: 3000\ntitle: Third\n")
|
||||||
|
try fixture.index(identity("b"), "schema: 1\norder: 2000\ntitle: Second\n")
|
||||||
|
try fixture.index(identity("c"), "schema: 1\norder: 1000\ntitle: First\n")
|
||||||
|
try fixture.index("\(identity("c"))/\(identity("1"))", "schema: 1\norder: 20\ntitle: B\n")
|
||||||
|
try fixture.index("\(identity("c"))/\(identity("2"))", "schema: 1\norder: 10\ntitle: A\n")
|
||||||
|
|
||||||
|
let summary = BoardOutline.read(boardRoot: fixture.root)
|
||||||
|
#expect(summary.lanes.map(\.title) == ["First", "Second", "Third"])
|
||||||
|
#expect(summary.lanes[0].cardTitles == ["A", "B"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func anOrderlessSiblingAppendsAtTheEndByFolderName() throws {
|
||||||
|
let fixture = try OutlineFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.index("", "schema: 1\ntitle: Board\n")
|
||||||
|
let lane = identity("a")
|
||||||
|
try fixture.index(lane, "schema: 1\norder: 1024\ntitle: Doing\n")
|
||||||
|
try fixture.index("\(lane)/\(identity("c"))", "schema: 1\ntitle: NoOrderC\n")
|
||||||
|
try fixture.index("\(lane)/\(identity("b"))", "schema: 1\ntitle: NoOrderB\n")
|
||||||
|
try fixture.index("\(lane)/\(identity("a"))", "schema: 1\norder: 5000\ntitle: Ranked\n")
|
||||||
|
|
||||||
|
// `Ranks.resolvedOrders`' reading: past every ordered sibling, then by folder name
|
||||||
|
// (01-storage-format.md § Ordering, re-ruled 2026-07-31).
|
||||||
|
let summary = BoardOutline.read(boardRoot: fixture.root)
|
||||||
|
#expect(summary.lanes[0].cardTitles == ["Ranked", "NoOrderB", "NoOrderC"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The caps
|
||||||
|
|
||||||
|
struct BoardOutlineLimitTests {
|
||||||
|
|
||||||
|
/// A board of `lanes` lanes, each holding `cards` cards, all ranked so display order is the
|
||||||
|
/// order they were made in.
|
||||||
|
private func board(lanes: Int, cards: Int) throws -> OutlineFixture {
|
||||||
|
let fixture = try OutlineFixture()
|
||||||
|
try fixture.index("", "schema: 1\ntitle: Board\n")
|
||||||
|
for lane in 0..<lanes {
|
||||||
|
let name = String(format: "%08x-0000-4000-8000-000000000000", lane)
|
||||||
|
try fixture.index(name, "schema: 1\norder: \(1024 * (lane + 1))\ntitle: Lane \(lane)\n")
|
||||||
|
for card in 0..<cards {
|
||||||
|
let cardName = String(format: "%08x-0000-4000-8000-%012x", lane, card)
|
||||||
|
try fixture.index(
|
||||||
|
"\(name)/\(cardName)", "schema: 1\norder: \(10 * (card + 1))\ntitle: Card \(card)\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func cardTitlesStopAtTheCapAndTheRestAreCounted() throws {
|
||||||
|
let fixture = try board(lanes: 1, cards: 10)
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let limits = BoardOutlineLimits(lanes: 12, laneScan: 100, cardTitles: 3, cardScan: 200, cardCount: 2000)
|
||||||
|
let lane = BoardOutline.read(boardRoot: fixture.root, limits: limits).lanes[0]
|
||||||
|
#expect(lane.cardTitles == ["Card 0", "Card 1", "Card 2"])
|
||||||
|
#expect(lane.cardCount == 10)
|
||||||
|
#expect(lane.cardCountIsCapped == false)
|
||||||
|
#expect(lane.hiddenCardCount == 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func lanesStopAtTheCapAndTheRestAreCounted() throws {
|
||||||
|
let fixture = try board(lanes: 5, cards: 1)
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let limits = BoardOutlineLimits(lanes: 2, laneScan: 100, cardTitles: 6, cardScan: 200, cardCount: 2000)
|
||||||
|
let summary = BoardOutline.read(boardRoot: fixture.root, limits: limits)
|
||||||
|
#expect(summary.lanes.map(\.title) == ["Lane 0", "Lane 1"])
|
||||||
|
#expect(summary.laneCount == 5)
|
||||||
|
#expect(summary.laneCountIsCapped == false)
|
||||||
|
#expect(summary.hiddenLaneCount == 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func theCardCountStopsCountingAndSaysSo() throws {
|
||||||
|
let fixture = try board(lanes: 1, cards: 5)
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let limits = BoardOutlineLimits(lanes: 12, laneScan: 100, cardTitles: 6, cardScan: 200, cardCount: 2)
|
||||||
|
let lane = BoardOutline.read(boardRoot: fixture.root, limits: limits).lanes[0]
|
||||||
|
// "2+", not "5" and not "2": the walk stopped, and the flag is how the page avoids stating a
|
||||||
|
// number it would be lying about.
|
||||||
|
#expect(lane.cardCount == 2)
|
||||||
|
#expect(lane.cardCountIsCapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func theLaneCountStopsCountingAndSaysSo() throws {
|
||||||
|
let fixture = try board(lanes: 4, cards: 1)
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let limits = BoardOutlineLimits(lanes: 12, laneScan: 2, cardTitles: 6, cardScan: 200, cardCount: 2000)
|
||||||
|
let summary = BoardOutline.read(boardRoot: fixture.root, limits: limits)
|
||||||
|
#expect(summary.laneCount == 2)
|
||||||
|
#expect(summary.laneCountIsCapped)
|
||||||
|
#expect(summary.lanes.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func theScanCapKeepsTheCountExactWhileNarrowingTheTitles() throws {
|
||||||
|
let fixture = try board(lanes: 1, cards: 6)
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let limits = BoardOutlineLimits(lanes: 12, laneScan: 100, cardTitles: 6, cardScan: 2, cardCount: 2000)
|
||||||
|
let lane = BoardOutline.read(boardRoot: fixture.root, limits: limits).lanes[0]
|
||||||
|
// The stated cost of `cardScan` (BoardOutlineLimits ▸ cardScan): the titles come from the
|
||||||
|
// first two cards **in folder-name order** rather than from the lane's true top, while the
|
||||||
|
// count stays exact and `hiddenCardCount` still adds up.
|
||||||
|
#expect(lane.cardCount == 6)
|
||||||
|
#expect(lane.cardCountIsCapped == false)
|
||||||
|
#expect(lane.cardTitles == ["Card 0", "Card 1"])
|
||||||
|
#expect(lane.hiddenCardCount == 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func anEmptyLaneShowsNoTitlesAndHidesNothing() throws {
|
||||||
|
let fixture = try board(lanes: 1, cards: 0)
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let lane = BoardOutline.read(boardRoot: fixture.root).lanes[0]
|
||||||
|
#expect(lane.cardCount == 0)
|
||||||
|
#expect(lane.cardTitles.isEmpty)
|
||||||
|
#expect(lane.hiddenCardCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func theShippedLimitsAreTheOnesThePreviewUses() {
|
||||||
|
// Stated as a test because they are a product decision, not an implementation detail: the
|
||||||
|
// preview shows twelve lanes and six titles each, and nothing else in the repo says so.
|
||||||
|
#expect(BoardOutlineLimits.preview.lanes == 12)
|
||||||
|
#expect(BoardOutlineLimits.preview.cardTitles == 6)
|
||||||
|
#expect(BoardOutlineLimits.preview.laneScan == 100)
|
||||||
|
#expect(BoardOutlineLimits.preview.cardScan == 200)
|
||||||
|
#expect(BoardOutlineLimits.preview.cardCount == 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -73,6 +73,10 @@ targets:
|
|||||||
- package: swift-markdown
|
- package: swift-markdown
|
||||||
product: Markdown
|
product: Markdown
|
||||||
- package: IndieAbout
|
- package: IndieAbout
|
||||||
|
# The Quick Look preview extension, embedded into `Contents/PlugIns/` — an appex is only ever
|
||||||
|
# found through the app that carries it, so this line is what makes pressing Space on a
|
||||||
|
# `.kanban` package in Finder reach `BoardPreviewProvider` at all.
|
||||||
|
- target: KanbanQuickLook
|
||||||
postBuildScripts:
|
postBuildScripts:
|
||||||
- script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"'
|
- script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"'
|
||||||
name: Update Build Info
|
name: Update Build Info
|
||||||
@@ -106,6 +110,68 @@ targets:
|
|||||||
SWIFT_STRICT_CONCURRENCY: complete
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
|
|
||||||
|
# MARK: - Quick Look preview extension
|
||||||
|
#
|
||||||
|
# **Space on a `.kanban` package in Finder** shows the board rather than a folder icon: its name,
|
||||||
|
# its tint, its lanes in order with each one's card count and first few card titles. A classic
|
||||||
|
# NSExtension appex (`com.apple.quicklook.preview`), embedded into the app above — the same
|
||||||
|
# arrangement Xcode's own "Quick Look Preview Extension" template produces, since Quick Look's
|
||||||
|
# preview point has no ExtensionKit spelling.
|
||||||
|
#
|
||||||
|
# **It compiles the shared storage engine verbatim, exactly as `KanbanMobile` does below** — the
|
||||||
|
# same source paths, not a copy and not a package — and for the same reason: a preview that read
|
||||||
|
# the format through its own parser would be a second reading of the storage contract, free to
|
||||||
|
# drift. `Kanban/Storage` is one compilation unit in practice (`FrontmatterDocument` reaches
|
||||||
|
# `IntegrityRules`, which reaches `BoardLoader` and `BoardWriter`), so it is listed whole; the
|
||||||
|
# paired `LiveStore/EchoLedger.swift` is `BoardWriter`'s one upward dependency, riding along here
|
||||||
|
# for the phone target's own stated reason. The write half is dead weight in a preview and stays
|
||||||
|
# dead: the entitlements are read-only, so nothing in here *could* write even if something called
|
||||||
|
# it.
|
||||||
|
#
|
||||||
|
# `STORAGE_ONLY` is what stops that one file dragging the whole live store in behind it. Its
|
||||||
|
# consumer-side sections speak `BoardDiff`/`ItemPath`/`CommentPath`, which reach `BoardStore` and
|
||||||
|
# everything under it; the phone excludes them with `#if os(macOS)` and cannot help here, because
|
||||||
|
# this target *is* macOS. So EchoLedger.swift carries the same gate on a second axis — platform,
|
||||||
|
# and layer — and this is the one target that sets it. See that file's "The reload's verdicts"
|
||||||
|
# comment for the full statement.
|
||||||
|
#
|
||||||
|
# `Kanban/UI/Palette.swift` is the one UI file it takes: the twelve-and-twelve name→hex table, so a
|
||||||
|
# board tinted `smokey-ocean` is the same colour in the preview as in the app. It is self-contained
|
||||||
|
# — AppKit, SwiftUI and `FieldValue`, nothing else in `Kanban/UI` — which is what makes a single
|
||||||
|
# file listable here at all. Nothing else from `Kanban/UI` compiles in: the preview is HTML, not a
|
||||||
|
# view hierarchy, and every other file up there reaches the store.
|
||||||
|
#
|
||||||
|
# The walk itself is `Kanban/Storage/BoardOutline.swift` — capped at every level, read-only, and
|
||||||
|
# unit-tested in `KanbanTests` where it can be, which is the whole reason it lives in the shared
|
||||||
|
# directory rather than in `KanbanQuickLook/`.
|
||||||
|
|
||||||
|
KanbanQuickLook:
|
||||||
|
type: app-extension
|
||||||
|
platform: macOS
|
||||||
|
sources:
|
||||||
|
- KanbanQuickLook
|
||||||
|
- path: Kanban/Storage
|
||||||
|
- path: Kanban/LiveStore/EchoLedger.swift
|
||||||
|
- path: Kanban/UI/Palette.swift
|
||||||
|
dependencies:
|
||||||
|
- package: Yams
|
||||||
|
- package: swift-markdown
|
||||||
|
product: Markdown
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
# Prefixed by the host app's identifier, as the sandbox requires of an embedded extension.
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Kanban.QuickLook
|
||||||
|
MARKETING_VERSION: "2.0"
|
||||||
|
INFOPLIST_FILE: KanbanQuickLook/Info.plist
|
||||||
|
CODE_SIGN_ENTITLEMENTS: KanbanQuickLook/KanbanQuickLook.entitlements
|
||||||
|
GENERATE_INFOPLIST_FILE: false
|
||||||
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
|
# The Info.plist's `NSExtensionPrincipalClass` is `$(PRODUCT_MODULE_NAME).BoardPreviewProvider`
|
||||||
|
# — stated here so the two can never drift apart silently.
|
||||||
|
PRODUCT_MODULE_NAME: KanbanQuickLook
|
||||||
|
# `$(inherited)` keeps the configuration's own `DEBUG` — this only *adds* the layer gate.
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS: $(inherited) STORAGE_ONLY
|
||||||
|
|
||||||
# MARK: - Lanework for iPhone
|
# MARK: - Lanework for iPhone
|
||||||
#
|
#
|
||||||
# **The mobile app is a second product, not a second edition** (distinct bundle id, ruled
|
# **The mobile app is a second product, not a second edition** (distinct bundle id, ruled
|
||||||
|
|||||||
Reference in New Issue
Block a user