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