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
+20 -2
View File
@@ -531,6 +531,17 @@ public final class AppModel {
return (lanes, cards)
}
/// The folder name, extension stripped (01-storage-format.md § Board naming) `displayName`'s
/// own fallback, and (02-architecture.md § Per-board app state) the registry record's
/// *provisional* display name for a board recorded before its load has run: "fail-fast means the
/// frontmatter can't be trusted, and the folder name is the Finder document name the user just
/// picked". A first successful load replaces it with the cached title through the ordinary
/// `displayName(of:)` path there is no separate provisional vocabulary, just this one fallback
/// used a moment earlier than usual.
public static func folderDisplayName(of url: URL) -> String {
url.deletingPathExtension().lastPathComponent
}
/// A board's display name: its `title`, falling back to the folder name sans extension
/// (01-storage-format.md § Board naming).
///
@@ -540,7 +551,7 @@ public final class AppModel {
if let title = store.snapshot.title.value, !title.isEmpty {
return title
}
return store.rootURL.deletingPathExtension().lastPathComponent
return folderDisplayName(of: store.rootURL)
}
// MARK: - Closing
@@ -617,7 +628,14 @@ public final class AppModel {
recordClose: { [weak self] in
guard let self, let session = sessions[ref] else { return }
let counts = Self.liveCounts(of: session.store.snapshot)
boardRegistry.recordClose(id: session.recordID, laneCount: counts.lanes, cardCount: counts.cards)
boardRegistry.recordClose(
id: session.recordID,
displayName: Self.displayName(of: session.store),
laneCount: counts.lanes,
cardCount: counts.cards,
icon: session.store.snapshot.icon.value,
iconColor: session.store.snapshot.iconColor.value
)
},
clearOpenNow: { [weak self] in
guard let self, let session = sessions[ref] else { return }
+51 -11
View File
@@ -15,12 +15,13 @@ import os
/// it is `BoardView`'s (03-board-ui.md); this file hands it the store and the window and stays out
/// of the way.
///
/// ### Failure opens welcome
/// ### Failure opens welcome, on a row that already exists
///
/// A board that will not load has nothing to show, so its window never appears: the failure joins
/// `AppModel.launchFailures`, welcome comes up, and this window dismisses itself. That is the same
/// path a failed restoration takes "welcome appears alongside whatever did restore, the failed
/// board's recents row carrying fail-fast's specifics" with the row-level rendering still owed.
/// A board that will not load has nothing to show, so its window never appears. But its registry
/// record is created **before** the load runs (02-architecture.md § Per-board app state, "a first
/// open that fails fail-fast still records"), so the failure that joins `AppModel.launchFailures`
/// always has a recents row waiting for it `WelcomeRow.derive` matches the two by path, uniform
/// with the failed-restoration row. This window dismisses itself and welcome comes up.
struct BoardWindowHost: View {
let ref: BoardWindowRef
@@ -124,11 +125,14 @@ struct BoardWindowHost: View {
/// Acquires the board and starts its session, or fails it out to welcome.
///
/// The order is load-bearing. Security-scoped access is claimed **before** `acquire`, because
/// acquire's first act is a full tree walk and a sandboxed read outside the scope is exactly the
/// one that gets refused. `setOpenNow` comes **after** the record exists and after the window has
/// demonstrably opened a flag set on a board that never appeared would hand the next launch a
/// restoration set describing a failure.
/// The order is load-bearing, and it now has one more step than the load itself does. Security-
/// scoped access is claimed **before** anything else, because the record and the load both need
/// it. The registry record comes **before** `acquire` "the registry record is created before
/// loading" (02 § Per-board app state) so a fail-fast failure always has a row to land on;
/// `acquire`'s own first act is the tree walk, and a sandboxed read outside the claimed scope is
/// exactly the one that gets refused. `setOpenNow` comes **after** the load succeeds and after
/// the window has demonstrably opened a flag set on a board that never appeared would hand the
/// next launch a restoration set describing a failure.
private func start() async {
guard case .opening = phase else { return }
@@ -136,6 +140,12 @@ struct BoardWindowHost: View {
let access = appModel.claimPendingAccess(for: ref)
let url = access?.url ?? ref.url
// Record before load (settled, 02 § Per-board app state). `displayName` is omitted an
// existing record's cached title survives untouched, and a brand-new one takes the folder
// name, both `recordOpen`'s own rule now. This is also this open's one bookmark mint: a
// successful load below replaces the name through `syncDisplayState`, which never re-mints.
let recordID = appModel.boardRegistry.recordOpen(of: url)
let store: BoardStore
do throws(BoardLoadError) {
store = try appModel.storeRegistry.acquire(url)
@@ -144,12 +154,26 @@ struct BoardWindowHost: View {
access?.stop()
phase = .failed
appModel.recordLaunchFailure(path: ref.path, message: error.description)
// The record above just changed the registry welcome, about to appear, must not
// render the stale list `AppModel` cached before this open began, or the failure would
// fall through to the unmatched-failures list for want of a row that already exists.
appModel.refreshRecents()
openWindow(id: WindowID.welcome)
dismissWindow(id: WindowID.board, value: ref)
return
}
let recordID = appModel.boardRegistry.recordOpen(of: url, displayName: AppModel.displayName(of: store))
// The load succeeded the frontmatter can be trusted now, so it replaces whatever
// provisional or stale name the record above was carrying. Through `syncDisplayState`,
// deliberately not a second `recordOpen`: this is a display-state refresh, not a second
// open, and it must not mint this board's bookmark again (02 § Per-board app state, "one
// bookmark per open board").
appModel.boardRegistry.syncDisplayState(
id: recordID,
title: AppModel.displayName(of: store),
icon: store.snapshot.icon.value,
iconColor: store.snapshot.iconColor.value
)
appModel.boardRegistry.setOpenNow(id: recordID)
appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access)
phase = .open(store)
@@ -187,6 +211,22 @@ struct BoardWindowHost: View {
)
}
// The registry's live write-through (02-architecture.md § Per-board app state) wired
// the same way `onFrameChanged` just was: a closure that reaches into the registry,
// captured weakly on both sides so neither the store nor this closure's own home keeps
// the other alive past its window. `syncDisplayState` in `start()` already stamped the
// values current as of this open, so nothing is fired here immediately; this only fires
// on the reloads that follow.
store.displayStateDelegate = { [weak appModel, weak store] in
guard let appModel, let store else { return }
appModel.boardRegistry.syncDisplayState(
id: recordID,
title: AppModel.displayName(of: store),
icon: store.snapshot.icon.value,
iconColor: store.snapshot.iconColor.value
)
}
windowController.onCloseRequested = {
Task { @MainActor in
await appModel.closeBoard(ref: ref, cause: .userClose)
+11
View File
@@ -31,6 +31,15 @@ struct WelcomeRow: Identifiable, Equatable {
/// the cached string rather than to a board this window must never open).
let displayName: String
/// The board's cached `icon`/`iconColor` (02-architecture.md § Per-board app state: "The row's
/// title and icon are registry-cached too with live write-through"). `nil` reads as "draw the
/// board-default glyph" the same lenient fallback the board window's own icon field gets, and
/// exactly what an unstamped or override-free record means. The row never resolves these itself;
/// that is the renderer's job (`ItemSymbol`, `Palette`), so the same record decodes into one
/// answer for both the welcome row and File Open Recent.
let icon: String?
let iconColor: String?
/// Where the board is **now**, or `nil` when its bookmark no longer resolves. The single source
/// of the row's availability: Open and Reveal need a URL, and an orphan has none.
let url: URL?
@@ -131,6 +140,8 @@ struct WelcomeRow: Identifiable, Equatable {
displayName: record.displayName.isEmpty
? URL(fileURLWithPath: record.lastKnownPath).deletingPathExtension().lastPathComponent
: record.displayName,
icon: record.icon,
iconColor: record.iconColor,
url: recent.url,
location: location(of: recent.url?.path ?? record.lastKnownPath),
laneCount: record.laneCount,
+27 -10
View File
@@ -264,17 +264,15 @@ private struct RecentBoardRow: View {
var body: some View {
HStack(spacing: 12) {
// The board default symbol, on every row.
//
// The record carries no icon. 02 § Per-board app state settles that it should "the
// row's title and icon are registry-cached too with live write-through" and today it
// holds only the display name and the counts. An `icon`/`iconColor` stamp joining
// `recordClose` (and the store's reload path, which is where the write-through half
// lives) is what turns this into the board's own glyph; until then a row that guessed
// would be worse than one that is honestly generic.
Image(systemName: ItemSymbol.board)
// The board's own icon and tint registry-cached with live write-through (02 §
// Per-board app state: "the row's title and icon are registry-cached too with live
// write-through"). A record with no override, or one naming a symbol this system
// cannot draw, falls back to the board-default glyph in secondary the same
// lenient-fallback shape every other icon site in the app uses (`ItemSymbol`,
// `Palette`), applied here to the registry's cached string instead of a live snapshot.
Image(systemName: iconName)
.font(.system(size: 22))
.foregroundStyle(.secondary)
.foregroundStyle(iconTint)
.frame(width: 34, height: 34)
.accessibilityHidden(true)
@@ -301,6 +299,25 @@ private struct RecentBoardRow: View {
.accessibilityElement(children: .combine)
}
/// `row.icon`'s symbol if it names one this system can draw, the board default otherwise
/// `ItemSymbol.name(_:fallback:)`'s rule, restated for a plain cached string rather than a
/// `FieldValue`: a record carries no `FieldValue`, so `missing`/`malformed`/`unrecognized`
/// have already folded into one `nil` by the time it reaches here.
private var iconName: String {
guard let icon = row.icon, ItemSymbol.exists(icon) else { return ItemSymbol.board }
return icon
}
/// `row.iconColor`'s tint, or the standard secondary one `LaneView`'s card-face `iconTint`,
/// same fallback, same reason: an uncoloured icon is chrome, and chrome is secondary.
private var iconTint: AnyShapeStyle {
if let iconColor = row.iconColor, let color = Palette.color(named: iconColor) {
AnyShapeStyle(color)
} else {
AnyShapeStyle(.secondary)
}
}
@ViewBuilder
private var caption: some View {
switch row.caption {
+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 }
}
+22
View File
@@ -229,6 +229,22 @@ public final class BoardStore {
@ObservationIgnored
public var rootChangeDelegate: (@MainActor () -> Void)?
/// The registry's live write-through for this board's title, icon, and iconColor
/// (02-architecture.md § Per-board app state, "these three refresh whenever an open board's
/// reload changes them"). Injected the same way `rootChangeDelegate` is, and for the same
/// reason: the write-through needs this board's **registry record id**, which this store does
/// not own `BoardWindowHost.configureWindow` wires it once a session's recordID exists,
/// mirroring how it wires `windowController.onFrameChanged` right beside it.
///
/// Called on **every** successful reload, whether or not the board-level display state
/// actually changed. The "did it change" comparison is against the registry's *cached*
/// record, not against this store's own previous snapshot the registry is the only side
/// that knows the cached value, so `BoardRegistry.syncDisplayState` is what turns a call that
/// changed nothing into a no-op. `nil` (no watcher-backed session, a storeless test) simply
/// means nothing is listening, exactly like `rootChangeDelegate`'s `nil`.
@ObservationIgnored
public var displayStateDelegate: (@MainActor () -> Void)?
// MARK: Reload machinery
/// Monotonic id of the most recently *started* reload and therefore also the number of tree
@@ -452,6 +468,12 @@ public final class BoardStore {
// this one did not.
reloadFailure = nil
clearLockIfDisproved(by: origin)
// The registry write-through, for the same "not board structure" reason the lock
// clearing sits out here: whether this board's row needs a new title, icon, or
// iconColor is the registry's question to answer (`syncDisplayState`'s own no-op
// guard), not a decision this store makes by comparing against its own prior
// snapshot.
displayStateDelegate?()
case let .failure(error):
// `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload