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:
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user