Give the registry live display-state write-through and record-before-load

BoardRecord carries icon/iconColor; recordOpen/recordClose stamp them with the
display name, and a displayStateDelegate on the store (wired in BoardWindowHost
beside onFrameChanged) syncs all three through BoardRegistry.syncDisplayState on
every successful reload — welcome rows now wear the board's own icon and follow
in-app renames live. recordOpen now runs before the load with the folder name as
a brand-new record's provisional display name, so a first open that fails
fail-fast still lands in recents carrying the failure row-level (02's rule); an
existing record's cached name survives a failing retry, and the welcome fallback
list remains only for failures naming no record at all.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-27 20:44:30 -04:00
parent cacd48cb0f
commit f2d9f3ad07
10 changed files with 608 additions and 41 deletions
+121 -8
View File
@@ -99,6 +99,24 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
public var windowFrame: WindowFrame?
/// The board's `icon` registry-cached with live write-through, beside `displayName`
/// (02-architecture.md § Per-board app state: "The row's title and icon are registry-cached
/// too with live write-through"). `nil` when the board's `icon` key is missing or
/// malformed, which the welcome row reads exactly as the board window itself does: draw the
/// level default, never a guess (`ItemSymbol`). The value round-trips verbatim as the
/// frontmatter carries it an SF Symbol name, unvalidated here; resolving it against what
/// the running system can draw is the renderer's job, not this record's.
///
/// Stamped at the same moments `displayName` is (`recordOpen`, `recordClose`), and unlike
/// the counts refreshed *live* while the board is open: `BoardRegistry.syncDisplayState`
/// is the write-through `BoardStore`'s reload pipeline calls into.
public var icon: String?
/// The board's `iconColor`, cached beside `icon` for the same reason and at the same
/// moments. A palette name or a `#RRGGBB[AA]` hex, resolved through `Palette` at render
/// time never here.
public var iconColor: String?
/// Whether this board's window is open **right now** the restoration set, as a live marker
/// rather than an at-quit write (02-architecture.md § Launch and window lifecycle, settled).
///
@@ -134,7 +152,9 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
windowFrame: WindowFrame? = nil,
isOpenNow: Bool? = nil,
pushOnCommit: Bool = false,
remoteLocationWarned: Bool = false
remoteLocationWarned: Bool = false,
icon: String? = nil,
iconColor: String? = nil
) {
self.id = id
self.bookmark = bookmark
@@ -147,6 +167,8 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
self.isOpenNow = isOpenNow
self.pushOnCommit = pushOnCommit
self.remoteLocationWarned = remoteLocationWarned
self.icon = icon
self.iconColor = iconColor
}
}
@@ -251,16 +273,47 @@ public final class BoardRegistry {
/// recents row with Forget, not a candidate for a board that is demonstrably somewhere else.
///
/// A match is updated in place with a **fresh bookmark** (subsuming the stale-refresh case), the
/// caller's `displayName`, the path it was opened at, and `lastOpened` = now. No match creates a
/// record. Either way the file is saved before returning.
/// path it was opened at, and `lastOpened` = now. No match creates a record. Either way the
/// file is saved before returning.
///
/// **`isOpenNow` is deliberately untouched here.** Recording an open and *being* open are two
/// different facts: this method is called before a window exists (and, later, by flows that
/// record a board without showing one), so the flag is set by `setOpenNow(id:)` once the window
/// has actually opened. Folding it in would flag boards that never made it onto screen and hand
/// the next launch a restoration set describing failures.
///
/// ### `displayName` is `nil` before a load has run that is the whole of "record before load"
///
/// 02-architecture.md § Per-board app state (settled): **"the registry record is created before
/// loading"**, and **"the record's provisional display name is the folder name the first
/// successful load replaces it with the cached title."** This method is the one open-time stamp
/// both moments share, told apart by whether the caller has anything authoritative to say yet:
///
/// - `nil` (the "before load" call, `BoardWindowHost.start()`'s first act): the bookmark,
/// `lastKnownPath`, and `lastOpened` refresh as always, but `displayName`/`icon`/`iconColor`
/// are left **untouched** on a record that already has them the last successful open's
/// cached title is still the best information this board's row has, and a load that is about
/// to fail must not regress it to the bare folder name. A **brand-new** record has nothing
/// cached yet, so it takes the folder name "a never-successfully-opened record is not a
/// special class; it lingers in recents like any other."
/// - Non-`nil` (a caller with real, loaded values): overwrites `displayName`/`icon`/`iconColor`
/// unconditionally, exactly as before this distinction existed. Nothing in this app calls it
/// that way any more a successful load's replacement goes through `syncDisplayState`
/// instead, which shares its no-op-skip discipline with every reload afterward but the
/// parameter stays meaningful on its own rather than folding into a second method, and every
/// existing test naming a concrete string exercises exactly this branch.
///
/// **Why this does not cost a second bookmark mint.** `recordOpen` is called exactly once per
/// open attempt (the "before load" call), so the one bookmark it mints here is the *whole* of
/// this open's mint (02 § Per-board app state, "one bookmark per open board"). The successful-load
/// follow-up is `syncDisplayState`, which never touches `bookmark` at all.
@discardableResult
public func recordOpen(of rootURL: URL, displayName: String) -> UUID {
public func recordOpen(
of rootURL: URL,
displayName: String? = nil,
icon: String? = nil,
iconColor: String? = nil
) -> UUID {
let bookmark = Self.makeBookmark(for: rootURL)?.data ?? Data()
if bookmark.isEmpty {
// Both the security-scoped and the plain attempt failed vanishingly unlikely for a
@@ -271,7 +324,11 @@ public final class BoardRegistry {
if let index = indexOfRecord(matching: rootURL) {
records[index].bookmark = bookmark
records[index].displayName = displayName
if let displayName {
records[index].displayName = displayName
records[index].icon = icon
records[index].iconColor = iconColor
}
records[index].lastKnownPath = rootURL.path
records[index].lastOpened = Self.stamp()
save()
@@ -280,23 +337,48 @@ public final class BoardRegistry {
let record = BoardRecord(
bookmark: bookmark,
displayName: displayName,
displayName: displayName ?? Self.folderName(of: rootURL),
lastKnownPath: rootURL.path,
lastOpened: Self.stamp()
lastOpened: Self.stamp(),
icon: icon,
iconColor: iconColor
)
records.append(record)
save()
return record.id
}
/// The folder name, extension stripped `AppModel.folderDisplayName(of:)`'s own rule, restated
/// here rather than reached for: this file is `Foundation`-only and must not import the app
/// layer to borrow four words of `URL` math.
private static func folderName(of url: URL) -> String {
url.deletingPathExtension().lastPathComponent
}
/// Stamps the counts the welcome window will render for this board until it is opened again
/// (§ Per-board app state, "Recents counts are registry-cached ... stamped at last close").
///
/// Called from the close-flush sequence (02-architecture.md § Launch and window lifecycle),
/// where the store's last snapshot is still in hand which is the whole reason the counts are
/// free here and expensive anywhere else.
public func recordClose(id: UUID, laneCount: Int, cardCount: Int) {
///
/// `displayName`/`icon`/`iconColor` are re-stamped here too, from the same last-held snapshot.
/// Unlike the counts, these three are already kept current while the board is open every
/// reload write-throughs via `syncDisplayState` so this is the belt-and-braces close-time
/// stamp rather than their only update path: a final, cheap guarantee that closing a board
/// never leaves its row one edit behind, whatever wired the live path.
public func recordClose(
id: UUID,
displayName: String,
laneCount: Int,
cardCount: Int,
icon: String? = nil,
iconColor: String? = nil
) {
update(id) { record in
record.displayName = displayName
record.icon = icon
record.iconColor = iconColor
record.laneCount = laneCount
record.cardCount = cardCount
}
@@ -352,6 +434,37 @@ public final class BoardRegistry {
update(id) { $0.windowFrame = frame }
}
/// The live write-through for an *open* board's title, icon, and iconColor
/// (02-architecture.md § Per-board app state, "these three refresh whenever an open board's
/// reload changes them"). `BoardStore` calls into this indirectly, through the delegate
/// `BoardWindowHost.configureWindow` wires on every successful reload, so an in-app rename
/// or restyle lands in the welcome row the instant the store's snapshot shows it, and a
/// foreign edit of an *open* board's root rides the same reload for free.
///
/// **A no-op, and no save, when nothing differs from what is already cached.** A reload fires
/// on every tree change anywhere in the board, most of which touch no board-level field at
/// all, so calling this unconditionally must not churn the registry file on an unrelated card
/// edit the same "an unchanged value writes nothing" rule `setLaneWidth` and `commitRename`
/// already keep.
///
/// An unknown id is `update`'s own no-op (a board closed and forgotten mid-reload), for the
/// same reason every other setter here tolerates one.
public func syncDisplayState(id: UUID, title: String, icon: String?, iconColor: String?) {
guard let index = indexOfRecord(id) else {
Self.logger.debug("syncDisplayState: no record for this id — ignored")
return
}
guard records[index].displayName != title
|| records[index].icon != icon
|| records[index].iconColor != iconColor
else { return }
records[index].displayName = title
records[index].icon = icon
records[index].iconColor = iconColor
save()
}
public func setPushOnCommit(id: UUID, _ value: Bool) {
update(id) { $0.pushOnCommit = value }
}