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
+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)