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
|
||||
// the Mac store's comment reload. The phone's writers stamp comment receipts identically;
|
||||
// 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> {
|
||||
let root = Self.key(cardFolder)
|
||||
let paths = receiptPaths(under: root)
|
||||
@@ -554,7 +557,19 @@ extension EchoLedger.Receipt {
|
||||
// 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
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user