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)
|
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
|
/// A board's display name: its `title`, falling back to the folder name sans extension
|
||||||
/// (01-storage-format.md § Board naming).
|
/// (01-storage-format.md § Board naming).
|
||||||
///
|
///
|
||||||
@@ -540,7 +551,7 @@ public final class AppModel {
|
|||||||
if let title = store.snapshot.title.value, !title.isEmpty {
|
if let title = store.snapshot.title.value, !title.isEmpty {
|
||||||
return title
|
return title
|
||||||
}
|
}
|
||||||
return store.rootURL.deletingPathExtension().lastPathComponent
|
return folderDisplayName(of: store.rootURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Closing
|
// MARK: - Closing
|
||||||
@@ -617,7 +628,14 @@ public final class AppModel {
|
|||||||
recordClose: { [weak self] in
|
recordClose: { [weak self] in
|
||||||
guard let self, let session = sessions[ref] else { return }
|
guard let self, let session = sessions[ref] else { return }
|
||||||
let counts = Self.liveCounts(of: session.store.snapshot)
|
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
|
clearOpenNow: { [weak self] in
|
||||||
guard let self, let session = sessions[ref] else { return }
|
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
|
/// it — is `BoardView`'s (03-board-ui.md); this file hands it the store and the window and stays out
|
||||||
/// of the way.
|
/// 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
|
/// A board that will not load has nothing to show, so its window never appears. But its registry
|
||||||
/// `AppModel.launchFailures`, welcome comes up, and this window dismisses itself. That is the same
|
/// record is created **before** the load runs (02-architecture.md § Per-board app state, "a first
|
||||||
/// path a failed restoration takes — "welcome appears alongside whatever did restore, the failed
|
/// open that fails fail-fast still records"), so the failure that joins `AppModel.launchFailures`
|
||||||
/// board's recents row carrying fail-fast's specifics" — with the row-level rendering still owed.
|
/// 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 {
|
struct BoardWindowHost: View {
|
||||||
|
|
||||||
let ref: BoardWindowRef
|
let ref: BoardWindowRef
|
||||||
@@ -124,11 +125,14 @@ struct BoardWindowHost: View {
|
|||||||
|
|
||||||
/// Acquires the board and starts its session, or fails it out to welcome.
|
/// 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
|
/// The order is load-bearing, and it now has one more step than the load itself does. Security-
|
||||||
/// acquire's first act is a full tree walk and a sandboxed read outside the scope is exactly the
|
/// scoped access is claimed **before** anything else, because the record and the load both need
|
||||||
/// one that gets refused. `setOpenNow` comes **after** the record exists and after the window has
|
/// it. The registry record comes **before** `acquire` — "the registry record is created before
|
||||||
/// demonstrably opened — a flag set on a board that never appeared would hand the next launch a
|
/// loading" (02 § Per-board app state) — so a fail-fast failure always has a row to land on;
|
||||||
/// restoration set describing a failure.
|
/// `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 {
|
private func start() async {
|
||||||
guard case .opening = phase else { return }
|
guard case .opening = phase else { return }
|
||||||
|
|
||||||
@@ -136,6 +140,12 @@ struct BoardWindowHost: View {
|
|||||||
let access = appModel.claimPendingAccess(for: ref)
|
let access = appModel.claimPendingAccess(for: ref)
|
||||||
let url = access?.url ?? ref.url
|
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
|
let store: BoardStore
|
||||||
do throws(BoardLoadError) {
|
do throws(BoardLoadError) {
|
||||||
store = try appModel.storeRegistry.acquire(url)
|
store = try appModel.storeRegistry.acquire(url)
|
||||||
@@ -144,12 +154,26 @@ struct BoardWindowHost: View {
|
|||||||
access?.stop()
|
access?.stop()
|
||||||
phase = .failed
|
phase = .failed
|
||||||
appModel.recordLaunchFailure(path: ref.path, message: error.description)
|
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)
|
openWindow(id: WindowID.welcome)
|
||||||
dismissWindow(id: WindowID.board, value: ref)
|
dismissWindow(id: WindowID.board, value: ref)
|
||||||
return
|
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.boardRegistry.setOpenNow(id: recordID)
|
||||||
appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access)
|
appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access)
|
||||||
phase = .open(store)
|
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 = {
|
windowController.onCloseRequested = {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
await appModel.closeBoard(ref: ref, cause: .userClose)
|
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).
|
/// the cached string rather than to a board this window must never open).
|
||||||
let displayName: String
|
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
|
/// 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.
|
/// of the row's availability: Open and Reveal need a URL, and an orphan has none.
|
||||||
let url: URL?
|
let url: URL?
|
||||||
@@ -131,6 +140,8 @@ struct WelcomeRow: Identifiable, Equatable {
|
|||||||
displayName: record.displayName.isEmpty
|
displayName: record.displayName.isEmpty
|
||||||
? URL(fileURLWithPath: record.lastKnownPath).deletingPathExtension().lastPathComponent
|
? URL(fileURLWithPath: record.lastKnownPath).deletingPathExtension().lastPathComponent
|
||||||
: record.displayName,
|
: record.displayName,
|
||||||
|
icon: record.icon,
|
||||||
|
iconColor: record.iconColor,
|
||||||
url: recent.url,
|
url: recent.url,
|
||||||
location: location(of: recent.url?.path ?? record.lastKnownPath),
|
location: location(of: recent.url?.path ?? record.lastKnownPath),
|
||||||
laneCount: record.laneCount,
|
laneCount: record.laneCount,
|
||||||
|
|||||||
@@ -264,17 +264,15 @@ private struct RecentBoardRow: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
// The board default symbol, on every row.
|
// 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
|
||||||
// The record carries no icon. 02 § Per-board app state settles that it should — "the
|
// write-through"). A record with no override, or one naming a symbol this system
|
||||||
// row's title and icon are registry-cached too — with live write-through" — and today it
|
// cannot draw, falls back to the board-default glyph in secondary — the same
|
||||||
// holds only the display name and the counts. An `icon`/`iconColor` stamp joining
|
// lenient-fallback shape every other icon site in the app uses (`ItemSymbol`,
|
||||||
// `recordClose` (and the store's reload path, which is where the write-through half
|
// `Palette`), applied here to the registry's cached string instead of a live snapshot.
|
||||||
// lives) is what turns this into the board's own glyph; until then a row that guessed
|
Image(systemName: iconName)
|
||||||
// would be worse than one that is honestly generic.
|
|
||||||
Image(systemName: ItemSymbol.board)
|
|
||||||
.font(.system(size: 22))
|
.font(.system(size: 22))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(iconTint)
|
||||||
.frame(width: 34, height: 34)
|
.frame(width: 34, height: 34)
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
@@ -301,6 +299,25 @@ private struct RecentBoardRow: View {
|
|||||||
.accessibilityElement(children: .combine)
|
.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
|
@ViewBuilder
|
||||||
private var caption: some View {
|
private var caption: some View {
|
||||||
switch row.caption {
|
switch row.caption {
|
||||||
|
|||||||
@@ -99,6 +99,24 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
|||||||
|
|
||||||
public var windowFrame: WindowFrame?
|
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
|
/// 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).
|
/// 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,
|
windowFrame: WindowFrame? = nil,
|
||||||
isOpenNow: Bool? = nil,
|
isOpenNow: Bool? = nil,
|
||||||
pushOnCommit: Bool = false,
|
pushOnCommit: Bool = false,
|
||||||
remoteLocationWarned: Bool = false
|
remoteLocationWarned: Bool = false,
|
||||||
|
icon: String? = nil,
|
||||||
|
iconColor: String? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.bookmark = bookmark
|
self.bookmark = bookmark
|
||||||
@@ -147,6 +167,8 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
|||||||
self.isOpenNow = isOpenNow
|
self.isOpenNow = isOpenNow
|
||||||
self.pushOnCommit = pushOnCommit
|
self.pushOnCommit = pushOnCommit
|
||||||
self.remoteLocationWarned = remoteLocationWarned
|
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.
|
/// 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
|
/// 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
|
/// path it was opened at, and `lastOpened` = now. No match creates a record. Either way the
|
||||||
/// record. Either way the file is saved before returning.
|
/// file is saved before returning.
|
||||||
///
|
///
|
||||||
/// **`isOpenNow` is deliberately untouched here.** Recording an open and *being* open are two
|
/// **`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
|
/// 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
|
/// 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
|
/// 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.
|
/// 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
|
@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()
|
let bookmark = Self.makeBookmark(for: rootURL)?.data ?? Data()
|
||||||
if bookmark.isEmpty {
|
if bookmark.isEmpty {
|
||||||
// Both the security-scoped and the plain attempt failed — vanishingly unlikely for a
|
// 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) {
|
if let index = indexOfRecord(matching: rootURL) {
|
||||||
records[index].bookmark = bookmark
|
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].lastKnownPath = rootURL.path
|
||||||
records[index].lastOpened = Self.stamp()
|
records[index].lastOpened = Self.stamp()
|
||||||
save()
|
save()
|
||||||
@@ -280,23 +337,48 @@ public final class BoardRegistry {
|
|||||||
|
|
||||||
let record = BoardRecord(
|
let record = BoardRecord(
|
||||||
bookmark: bookmark,
|
bookmark: bookmark,
|
||||||
displayName: displayName,
|
displayName: displayName ?? Self.folderName(of: rootURL),
|
||||||
lastKnownPath: rootURL.path,
|
lastKnownPath: rootURL.path,
|
||||||
lastOpened: Self.stamp()
|
lastOpened: Self.stamp(),
|
||||||
|
icon: icon,
|
||||||
|
iconColor: iconColor
|
||||||
)
|
)
|
||||||
records.append(record)
|
records.append(record)
|
||||||
save()
|
save()
|
||||||
return record.id
|
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
|
/// 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").
|
/// (§ 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),
|
/// 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
|
/// where the store's last snapshot is still in hand — which is the whole reason the counts are
|
||||||
/// free here and expensive anywhere else.
|
/// 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
|
update(id) { record in
|
||||||
|
record.displayName = displayName
|
||||||
|
record.icon = icon
|
||||||
|
record.iconColor = iconColor
|
||||||
record.laneCount = laneCount
|
record.laneCount = laneCount
|
||||||
record.cardCount = cardCount
|
record.cardCount = cardCount
|
||||||
}
|
}
|
||||||
@@ -352,6 +434,37 @@ public final class BoardRegistry {
|
|||||||
update(id) { $0.windowFrame = frame }
|
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) {
|
public func setPushOnCommit(id: UUID, _ value: Bool) {
|
||||||
update(id) { $0.pushOnCommit = value }
|
update(id) { $0.pushOnCommit = value }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,22 @@ public final class BoardStore {
|
|||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
public var rootChangeDelegate: (@MainActor () -> Void)?
|
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
|
// MARK: Reload machinery
|
||||||
|
|
||||||
/// Monotonic id of the most recently *started* reload — and therefore also the number of tree
|
/// 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.
|
// this one did not.
|
||||||
reloadFailure = nil
|
reloadFailure = nil
|
||||||
clearLockIfDisproved(by: origin)
|
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):
|
case let .failure(error):
|
||||||
// `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload
|
// `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload
|
||||||
|
|||||||
@@ -54,14 +54,15 @@ private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
|
|||||||
return (model, { try? FileManager.default.removeItem(at: folder) })
|
return (model, { try? FileManager.default.removeItem(at: folder) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens a board the way `BoardWindowHost` does — acquire, record, flag, begin — so the close tests
|
/// Opens a board the way `BoardWindowHost` does — record, acquire, name it for real, flag, begin —
|
||||||
/// are closing something the app would recognise.
|
/// so the close tests are closing something the app would recognise.
|
||||||
@MainActor
|
@MainActor
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef {
|
private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef {
|
||||||
let ref = BoardWindowRef(url: url)
|
let ref = BoardWindowRef(url: url)
|
||||||
|
let recordID = model.boardRegistry.recordOpen(of: url)
|
||||||
let store = try model.storeRegistry.acquire(url)
|
let store = try model.storeRegistry.acquire(url)
|
||||||
let recordID = model.boardRegistry.recordOpen(of: url, displayName: AppModel.displayName(of: store))
|
model.boardRegistry.syncDisplayState(id: recordID, title: AppModel.displayName(of: store), icon: nil, iconColor: nil)
|
||||||
model.boardRegistry.setOpenNow(id: recordID)
|
model.boardRegistry.setOpenNow(id: recordID)
|
||||||
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
|
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
|
||||||
return ref
|
return ref
|
||||||
@@ -141,6 +142,84 @@ struct AppModelTests {
|
|||||||
#expect(AppModel.displayName(of: untitled) == "Untitled Board", "sans extension, per 01 § Board naming")
|
#expect(AppModel.displayName(of: untitled) == "Untitled Board", "sans extension, per 01 § Board naming")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Record before load
|
||||||
|
|
||||||
|
/// `BoardWindowHost.start()`'s own sequence for a board whose load fails fail-fast: record
|
||||||
|
/// first, then attempt the load, then (on failure) file the launch failure and refresh the
|
||||||
|
/// cached recents this test reads back through `WelcomeRow.derive`, exactly as welcome would.
|
||||||
|
@Test("A first open that fails fail-fast still records — a folder-name row carrying the failure")
|
||||||
|
func failFastOpenStillRecordsAndSurfacesRowLevel() async throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
// The board root's own `index.md` fails to parse — fail-fast's own case, thrown the moment
|
||||||
|
// `BoardStore.init` (behind `storeRegistry.acquire`) walks it.
|
||||||
|
try fixture.item("", "---\nschema: 1\nlabels: [a, b\n---\nbody\n")
|
||||||
|
let (model, tearDown) = try makeModel()
|
||||||
|
defer { tearDown() }
|
||||||
|
|
||||||
|
let ref = BoardWindowRef(url: fixture.root)
|
||||||
|
let recordID = model.boardRegistry.recordOpen(of: fixture.root)
|
||||||
|
|
||||||
|
var failureMessage: String?
|
||||||
|
do throws(BoardLoadError) {
|
||||||
|
_ = try model.storeRegistry.acquire(fixture.root)
|
||||||
|
Issue.record("expected the load to fail fail-fast")
|
||||||
|
} catch {
|
||||||
|
failureMessage = error.description
|
||||||
|
model.recordLaunchFailure(path: ref.path, message: error.description)
|
||||||
|
}
|
||||||
|
model.refreshRecents()
|
||||||
|
|
||||||
|
// The record exists, provisionally named after the folder — nothing loaded, so nothing else
|
||||||
|
// was there to trust (02 § Per-board app state).
|
||||||
|
let record = try #require(model.boardRegistry.record(id: recordID))
|
||||||
|
#expect(record.displayName == fixture.root.deletingPathExtension().lastPathComponent)
|
||||||
|
#expect(record.laneCount == nil, "never opened successfully, so nothing was ever counted")
|
||||||
|
|
||||||
|
// And the failure lands on that very row — uniform with a failed restoration's row, never
|
||||||
|
// the separate list of failures naming no record.
|
||||||
|
let derived = WelcomeRow.derive(recents: model.recents, failures: model.launchFailures)
|
||||||
|
let row = try #require(derived.rows.first { $0.id == recordID })
|
||||||
|
guard case let .failed(message) = row.caption else {
|
||||||
|
Issue.record("expected the row to carry the failure, got \(row.caption)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(message == failureMessage)
|
||||||
|
#expect(derived.unmatched.isEmpty, "the failure landed on a row it already had, not the fallback list")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Retrying a fail-fast open reuses the same record, and a later success replaces its name")
|
||||||
|
func retryingAFailFastOpenReusesTheRecord() async throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let brokenIndex = "---\nschema: 1\nlabels: [a, b\n---\nbody\n"
|
||||||
|
try fixture.item("", brokenIndex)
|
||||||
|
let (model, tearDown) = try makeModel()
|
||||||
|
defer { tearDown() }
|
||||||
|
|
||||||
|
let ref = BoardWindowRef(url: fixture.root)
|
||||||
|
let firstAttempt = model.boardRegistry.recordOpen(of: fixture.root)
|
||||||
|
do throws(BoardLoadError) {
|
||||||
|
_ = try model.storeRegistry.acquire(fixture.root)
|
||||||
|
Issue.record("expected the load to fail fail-fast")
|
||||||
|
} catch {
|
||||||
|
model.recordLaunchFailure(path: ref.path, message: error.description)
|
||||||
|
}
|
||||||
|
model.refreshRecents()
|
||||||
|
#expect(model.boardRegistry.recents().count == 1, "a retry updates the one record, it does not add one")
|
||||||
|
|
||||||
|
// The user fixes the file and retries — `BoardWindowHost.start()`'s exact sequence again.
|
||||||
|
try fixture.item("", "---\nschema: 1\ntitle: Fixed Board\n---\nbody\n")
|
||||||
|
let secondAttempt = model.boardRegistry.recordOpen(of: fixture.root)
|
||||||
|
#expect(secondAttempt == firstAttempt, "the same folder is the same board, failed or not")
|
||||||
|
|
||||||
|
let store = try model.storeRegistry.acquire(fixture.root)
|
||||||
|
model.boardRegistry.syncDisplayState(id: secondAttempt, title: AppModel.displayName(of: store), icon: nil, iconColor: nil)
|
||||||
|
|
||||||
|
let record = try #require(model.boardRegistry.record(id: secondAttempt))
|
||||||
|
#expect(record.displayName == "Fixed Board", "the first success replaces the provisional folder name")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Sessions
|
// MARK: Sessions
|
||||||
|
|
||||||
@Test("A user close stamps live counts, unflags the board, and lets the store go")
|
@Test("A user close stamps live counts, unflags the board, and lets the store go")
|
||||||
|
|||||||
@@ -121,6 +121,51 @@ struct BoardRegistryTests {
|
|||||||
#expect(registry.record(id: id)?.lastKnownPath == renamed.path)
|
#expect(registry.record(id: id)?.lastKnownPath == renamed.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Recording an open with no displayName gives a brand-new record the folder name")
|
||||||
|
func recordOpenWithNoDisplayNameUsesTheFolderName() async throws {
|
||||||
|
let storage = try RegistryStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let registry = BoardRegistry(storageURL: storage.url)
|
||||||
|
|
||||||
|
// The "record before load" call (02-architecture.md § Per-board app state): nothing
|
||||||
|
// authoritative is known yet, so the provisional name is the folder's own — "the folder
|
||||||
|
// name is the Finder document name the user just picked."
|
||||||
|
let id = registry.recordOpen(of: fixture.root)
|
||||||
|
let record = try #require(registry.record(id: id))
|
||||||
|
#expect(record.displayName == fixture.root.deletingPathExtension().lastPathComponent)
|
||||||
|
#expect(!record.bookmark.isEmpty, "the bookmark still mints on the before-load call")
|
||||||
|
#expect(record.icon == nil)
|
||||||
|
#expect(record.iconColor == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Recording an open with no displayName never regresses an existing record's cached name")
|
||||||
|
func recordOpenWithNoDisplayNamePreservesAnExistingRecord() async throws {
|
||||||
|
let storage = try RegistryStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let registry = BoardRegistry(storageURL: storage.url)
|
||||||
|
|
||||||
|
// A first, successful-looking open: real values, as a caller with a loaded snapshot passes.
|
||||||
|
let id = registry.recordOpen(of: fixture.root, displayName: "Todo Board", icon: "star", iconColor: "fern")
|
||||||
|
let firstOpened = try #require(registry.record(id: id)).lastOpened
|
||||||
|
try await Task.sleep(for: .milliseconds(5))
|
||||||
|
|
||||||
|
// A later open attempt whose load has not run yet — or never gets the chance to, because it
|
||||||
|
// fails fail-fast. Either way, this call alone must not know that, so the cached title and
|
||||||
|
// style survive untouched; only the bits every open refreshes regardless move.
|
||||||
|
let again = registry.recordOpen(of: fixture.root)
|
||||||
|
#expect(again == id)
|
||||||
|
let record = try #require(registry.record(id: id))
|
||||||
|
#expect(record.displayName == "Todo Board", "the last successful open's title is still the best information this row has")
|
||||||
|
#expect(record.icon == "star")
|
||||||
|
#expect(record.iconColor == "fern")
|
||||||
|
#expect(record.lastOpened > firstOpened, "the bookmark and lastOpened still refresh on every open attempt")
|
||||||
|
#expect(!record.bookmark.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Counts
|
// MARK: Counts
|
||||||
|
|
||||||
@Test("Counts are stamped at close and read back without a scan")
|
@Test("Counts are stamped at close and read back without a scan")
|
||||||
@@ -132,7 +177,7 @@ struct BoardRegistryTests {
|
|||||||
let registry = BoardRegistry(storageURL: storage.url)
|
let registry = BoardRegistry(storageURL: storage.url)
|
||||||
|
|
||||||
let id = registry.recordOpen(of: fixture.root, displayName: "Todo Board")
|
let id = registry.recordOpen(of: fixture.root, displayName: "Todo Board")
|
||||||
registry.recordClose(id: id, laneCount: 2, cardCount: 7)
|
registry.recordClose(id: id, displayName: "Todo Board", laneCount: 2, cardCount: 7)
|
||||||
|
|
||||||
// The board grows after the close — an agent filing cards, a colleague's pull. A welcome
|
// The board grows after the close — an agent filing cards, a colleague's pull. A welcome
|
||||||
// window that scanned would notice; this one must not, because scanning is what makes
|
// window that scanned would notice; this one must not, because scanning is what makes
|
||||||
@@ -152,6 +197,154 @@ struct BoardRegistryTests {
|
|||||||
#expect(FileIdentity(of: url) == FileIdentity(of: fixture.root))
|
#expect(FileIdentity(of: url) == FileIdentity(of: fixture.root))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Title, icon, and iconColor
|
||||||
|
|
||||||
|
@Test("Icon and iconColor are stamped at open and re-stamped at close, alongside the title")
|
||||||
|
func openAndCloseStampIconAndIconColor() async throws {
|
||||||
|
let storage = try RegistryStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let registry = BoardRegistry(storageURL: storage.url)
|
||||||
|
|
||||||
|
let id = registry.recordOpen(of: fixture.root, displayName: "Todo Board", icon: "star", iconColor: "fern")
|
||||||
|
let opened = try #require(registry.record(id: id))
|
||||||
|
#expect(opened.icon == "star")
|
||||||
|
#expect(opened.iconColor == "fern")
|
||||||
|
|
||||||
|
// The board is restyled while open — an in-app change or a foreign edit, it makes no
|
||||||
|
// difference here since `recordClose` re-stamps from whatever the store last held.
|
||||||
|
registry.recordClose(
|
||||||
|
id: id,
|
||||||
|
displayName: "Todo Board",
|
||||||
|
laneCount: 2,
|
||||||
|
cardCount: 7,
|
||||||
|
icon: "heart",
|
||||||
|
iconColor: "carnation"
|
||||||
|
)
|
||||||
|
let closed = try #require(registry.record(id: id))
|
||||||
|
#expect(closed.icon == "heart")
|
||||||
|
#expect(closed.iconColor == "carnation")
|
||||||
|
|
||||||
|
// A board can also lose its override entirely — `nil` closes back over a previously
|
||||||
|
// stamped value rather than being mistaken for "leave it alone".
|
||||||
|
registry.recordClose(id: id, displayName: "Todo Board", laneCount: 2, cardCount: 7)
|
||||||
|
#expect(registry.record(id: id)?.icon == nil)
|
||||||
|
#expect(registry.record(id: id)?.iconColor == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The live write-through updates title, icon, and iconColor when they differ from the cached record")
|
||||||
|
func syncDisplayStateWritesThroughOnlyWhatChanged() async throws {
|
||||||
|
let storage = try RegistryStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let registry = BoardRegistry(storageURL: storage.url)
|
||||||
|
|
||||||
|
let id = registry.recordOpen(of: fixture.root, displayName: "Todo Board")
|
||||||
|
#expect(registry.record(id: id)?.icon == nil, "no override yet")
|
||||||
|
|
||||||
|
// An in-app rename/restyle's reload lands new values — the seam `BoardStore`'s
|
||||||
|
// `displayStateDelegate` calls into.
|
||||||
|
registry.syncDisplayState(id: id, title: "Renamed", icon: "star.fill", iconColor: "deep-sky-blue")
|
||||||
|
let afterFirstSync = try #require(registry.record(id: id))
|
||||||
|
#expect(afterFirstSync.displayName == "Renamed")
|
||||||
|
#expect(afterFirstSync.icon == "star.fill")
|
||||||
|
#expect(afterFirstSync.iconColor == "deep-sky-blue")
|
||||||
|
|
||||||
|
// A later reload whose display state is unchanged from the cached record leaves it exactly
|
||||||
|
// as it was — the no-op-skip rule every other setter in this file already keeps.
|
||||||
|
registry.syncDisplayState(id: id, title: "Renamed", icon: "star.fill", iconColor: "deep-sky-blue")
|
||||||
|
#expect(registry.record(id: id) == afterFirstSync)
|
||||||
|
|
||||||
|
// The values persist like any other mutation here.
|
||||||
|
let reloaded = BoardRegistry(storageURL: storage.url)
|
||||||
|
#expect(reloaded.record(id: id)?.displayName == "Renamed")
|
||||||
|
#expect(reloaded.record(id: id)?.icon == "star.fill")
|
||||||
|
#expect(reloaded.record(id: id)?.iconColor == "deep-sky-blue")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The live write-through on an id with no record is a no-op, not a crash")
|
||||||
|
func syncDisplayStateOnUnknownIDIsANoOp() async throws {
|
||||||
|
let storage = try RegistryStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
let registry = BoardRegistry(storageURL: storage.url)
|
||||||
|
|
||||||
|
registry.syncDisplayState(id: UUID(), title: "Ghost", icon: "star", iconColor: "fern")
|
||||||
|
|
||||||
|
#expect(registry.recents().isEmpty, "a window that outlived its record must not resurrect one")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A registry file written before the icon/iconColor keys existed still decodes, with both nil")
|
||||||
|
func oldRegistryFilesDecodeWithoutIconKeys() async throws {
|
||||||
|
let storage = try RegistryStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
|
||||||
|
// Byte-for-byte the shape this file had before icon/iconColor existed — the same evolution
|
||||||
|
// rule `oldRegistryFilesDecodeWithoutTheOpenNowKey` pins for `isOpenNow`: a key added here
|
||||||
|
// must be optional, or every existing user's recents empties on upgrade.
|
||||||
|
let id = UUID()
|
||||||
|
let garbage = Data("not a bookmark".utf8).base64EncodedString()
|
||||||
|
let json = """
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"bookmark" : "\(garbage)",
|
||||||
|
"cardCount" : 9,
|
||||||
|
"displayName" : "Archive",
|
||||||
|
"id" : "\(id.uuidString)",
|
||||||
|
"laneCount" : 4,
|
||||||
|
"lastKnownPath" : "/Volumes/Archive/Boards/Archive",
|
||||||
|
"lastOpened" : "2026-01-01T09:00:00.000Z",
|
||||||
|
"pushOnCommit" : true,
|
||||||
|
"remoteLocationWarned" : true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
try Data(json.utf8).write(to: storage.url)
|
||||||
|
|
||||||
|
let registry = BoardRegistry(storageURL: storage.url)
|
||||||
|
#expect(registry.recents().count == 1, "the file decoded; nothing was quarantined")
|
||||||
|
#expect(registry.record(id: id)?.icon == nil, "a missing key reads as 'no override'")
|
||||||
|
#expect(registry.record(id: id)?.iconColor == nil)
|
||||||
|
|
||||||
|
// And both keys write through from here on.
|
||||||
|
registry.syncDisplayState(id: id, title: "Archive", icon: "archivebox", iconColor: "aluminum")
|
||||||
|
let updated = BoardRegistry(storageURL: storage.url).record(id: id)
|
||||||
|
#expect(updated?.icon == "archivebox")
|
||||||
|
#expect(updated?.iconColor == "aluminum")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A registry file with icon and iconColor present decodes them")
|
||||||
|
func registryFileWithIconKeysDecodes() async throws {
|
||||||
|
let storage = try RegistryStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
|
||||||
|
let id = UUID()
|
||||||
|
let garbage = Data("not a bookmark".utf8).base64EncodedString()
|
||||||
|
let json = """
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"bookmark" : "\(garbage)",
|
||||||
|
"cardCount" : 9,
|
||||||
|
"displayName" : "Archive",
|
||||||
|
"icon" : "archivebox",
|
||||||
|
"iconColor" : "aluminum",
|
||||||
|
"id" : "\(id.uuidString)",
|
||||||
|
"laneCount" : 4,
|
||||||
|
"lastKnownPath" : "/Volumes/Archive/Boards/Archive",
|
||||||
|
"lastOpened" : "2026-01-01T09:00:00.000Z",
|
||||||
|
"pushOnCommit" : true,
|
||||||
|
"remoteLocationWarned" : true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
try Data(json.utf8).write(to: storage.url)
|
||||||
|
|
||||||
|
let registry = BoardRegistry(storageURL: storage.url)
|
||||||
|
#expect(registry.record(id: id)?.icon == "archivebox")
|
||||||
|
#expect(registry.record(id: id)?.iconColor == "aluminum")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Orphaning
|
// MARK: Orphaning
|
||||||
|
|
||||||
@Test("A deleted board is orphaned in recents and can be forgotten")
|
@Test("A deleted board is orphaned in recents and can be forgotten")
|
||||||
@@ -162,7 +355,7 @@ struct BoardRegistryTests {
|
|||||||
let registry = BoardRegistry(storageURL: storage.url)
|
let registry = BoardRegistry(storageURL: storage.url)
|
||||||
|
|
||||||
let id = registry.recordOpen(of: fixture.root, displayName: "Doomed")
|
let id = registry.recordOpen(of: fixture.root, displayName: "Doomed")
|
||||||
registry.recordClose(id: id, laneCount: 1, cardCount: 1)
|
registry.recordClose(id: id, displayName: "Doomed", laneCount: 1, cardCount: 1)
|
||||||
fixture.tearDown()
|
fixture.tearDown()
|
||||||
|
|
||||||
let rows = registry.recents()
|
let rows = registry.recents()
|
||||||
@@ -311,7 +504,7 @@ struct BoardRegistryTests {
|
|||||||
let firstID = registry.recordOpen(of: first.root, displayName: "First")
|
let firstID = registry.recordOpen(of: first.root, displayName: "First")
|
||||||
try await Task.sleep(for: .milliseconds(5))
|
try await Task.sleep(for: .milliseconds(5))
|
||||||
let secondID = registry.recordOpen(of: second.root, displayName: "Second")
|
let secondID = registry.recordOpen(of: second.root, displayName: "Second")
|
||||||
registry.recordClose(id: firstID, laneCount: 3, cardCount: 11)
|
registry.recordClose(id: firstID, displayName: "First", laneCount: 3, cardCount: 11)
|
||||||
registry.updateWindowFrame(id: firstID, frame: WindowFrame(x: 120, y: 60, width: 1440, height: 900))
|
registry.updateWindowFrame(id: firstID, frame: WindowFrame(x: 120, y: 60, width: 1440, height: 900))
|
||||||
registry.setPushOnCommit(id: firstID, true)
|
registry.setPushOnCommit(id: firstID, true)
|
||||||
registry.setRemoteLocationWarned(id: firstID)
|
registry.setRemoteLocationWarned(id: firstID)
|
||||||
@@ -377,7 +570,7 @@ struct BoardRegistryTests {
|
|||||||
|
|
||||||
let registry = BoardRegistry(storageURL: storage.url)
|
let registry = BoardRegistry(storageURL: storage.url)
|
||||||
let id = registry.recordOpen(of: fixture.root, displayName: "Untouched")
|
let id = registry.recordOpen(of: fixture.root, displayName: "Untouched")
|
||||||
registry.recordClose(id: id, laneCount: 1, cardCount: 1)
|
registry.recordClose(id: id, displayName: "Untouched", laneCount: 1, cardCount: 1)
|
||||||
registry.updateWindowFrame(id: id, frame: WindowFrame(x: 0, y: 0, width: 800, height: 600))
|
registry.updateWindowFrame(id: id, frame: WindowFrame(x: 0, y: 0, width: 800, height: 600))
|
||||||
registry.setPushOnCommit(id: id, true)
|
registry.setPushOnCommit(id: id, true)
|
||||||
registry.setRemoteLocationWarned(id: id)
|
registry.setRemoteLocationWarned(id: id)
|
||||||
@@ -443,7 +636,7 @@ struct BoardRegistryTests {
|
|||||||
// whole restoration mechanism, so it is asserted rather than assumed, and asserted across a
|
// whole restoration mechanism, so it is asserted rather than assumed, and asserted across a
|
||||||
// reload of the file because a relaunch is what consumes it.
|
// reload of the file because a relaunch is what consumes it.
|
||||||
registry.setOpenNow(id: id)
|
registry.setOpenNow(id: id)
|
||||||
registry.recordClose(id: id, laneCount: 2, cardCount: 5)
|
registry.recordClose(id: id, displayName: "Work", laneCount: 2, cardCount: 5)
|
||||||
|
|
||||||
let afterRelaunch = BoardRegistry(storageURL: storage.url)
|
let afterRelaunch = BoardRegistry(storageURL: storage.url)
|
||||||
#expect(afterRelaunch.record(id: id)?.isOpenNow == true, "the flags describe what was open at quit")
|
#expect(afterRelaunch.record(id: id)?.isOpenNow == true, "the flags describe what was open at quit")
|
||||||
|
|||||||
@@ -245,6 +245,56 @@ struct BoardStoreTests {
|
|||||||
#expect(fixture.exists("\(Ident.lane2)/\(created.rawValue)/index.md"))
|
#expect(fixture.exists("\(Ident.lane2)/\(created.rawValue)/index.md"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Display-state write-through
|
||||||
|
|
||||||
|
@Test("A successful reload calls the display-state delegate, whether or not anything actually changed")
|
||||||
|
func successfulReloadCallsDisplayStateDelegate() async throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
var calls = 0
|
||||||
|
store.displayStateDelegate = { calls += 1 }
|
||||||
|
|
||||||
|
// An ordinary foreign change with no board-level field touched at all — the "did title,
|
||||||
|
// icon, or iconColor actually change" comparison is the registry's own no-op guard
|
||||||
|
// (`BoardRegistry.syncDisplayState`), not something this store decides by diffing itself.
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
|
||||||
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||||
|
await store.awaitQuiescence()
|
||||||
|
|
||||||
|
#expect(calls == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A failed reload does not call the display-state delegate — there is no new snapshot to report")
|
||||||
|
func failedReloadDoesNotCallDisplayStateDelegate() async throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
var calls = 0
|
||||||
|
store.displayStateDelegate = { calls += 1 }
|
||||||
|
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", brokenIndex)
|
||||||
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||||
|
await store.awaitQuiescence()
|
||||||
|
|
||||||
|
#expect(calls == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A reload with no display-state delegate wired is harmless")
|
||||||
|
func reloadWithoutADisplayStateDelegateIsANoOp() async throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
|
||||||
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||||
|
await store.awaitQuiescence()
|
||||||
|
|
||||||
|
#expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Second", "Third"])
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Wholesale operations
|
// MARK: Wholesale operations
|
||||||
|
|
||||||
@Test("A wholesale operation whose reload succeeds leaves no lock and no leaked expectation")
|
@Test("A wholesale operation whose reload succeeds leaves no lock and no leaked expectation")
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ private func record(
|
|||||||
at path: String,
|
at path: String,
|
||||||
lanes: Int? = nil,
|
lanes: Int? = nil,
|
||||||
cards: Int? = nil,
|
cards: Int? = nil,
|
||||||
opened: Date = Date()
|
opened: Date = Date(),
|
||||||
|
icon: String? = nil,
|
||||||
|
iconColor: String? = nil
|
||||||
) -> BoardRecord {
|
) -> BoardRecord {
|
||||||
BoardRecord(
|
BoardRecord(
|
||||||
bookmark: Data(),
|
bookmark: Data(),
|
||||||
@@ -31,7 +33,9 @@ private func record(
|
|||||||
lastKnownPath: path,
|
lastKnownPath: path,
|
||||||
lastOpened: opened,
|
lastOpened: opened,
|
||||||
laneCount: lanes,
|
laneCount: lanes,
|
||||||
cardCount: cards
|
cardCount: cards,
|
||||||
|
icon: icon,
|
||||||
|
iconColor: iconColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +212,26 @@ struct WelcomeRowTests {
|
|||||||
"the sort rule lives in BoardRegistry.recents() and must not be duplicated here")
|
"the sort rule lives in BoardRegistry.recents() and must not be duplicated here")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("A record's cached icon and iconColor ride straight through to its row")
|
||||||
|
func iconAndIconColorPassThrough() throws {
|
||||||
|
let recent = available(record(name: "Roadmap", at: "/Boards/Roadmap.kanban", icon: "star.fill", iconColor: "fern"))
|
||||||
|
|
||||||
|
let row = try #require(WelcomeRow.derive(recents: [recent], failures: []).rows.first)
|
||||||
|
|
||||||
|
#expect(row.icon == "star.fill")
|
||||||
|
#expect(row.iconColor == "fern")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A record with no cached icon carries nil through to its row — the renderer's default to draw")
|
||||||
|
func noIconIsNilNotAGuess() throws {
|
||||||
|
let recent = available(record(name: "Roadmap", at: "/Boards/Roadmap.kanban"))
|
||||||
|
|
||||||
|
let row = try #require(WelcomeRow.derive(recents: [recent], failures: []).rows.first)
|
||||||
|
|
||||||
|
#expect(row.icon == nil)
|
||||||
|
#expect(row.iconColor == nil)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("A record with no display name falls back to its folder name, extension stripped")
|
@Test("A record with no display name falls back to its folder name, extension stripped")
|
||||||
func displayNameFallsBackToTheFolderName() {
|
func displayNameFallsBackToTheFolderName() {
|
||||||
let recent = available(record(name: "", at: "/Boards/Untitled.kanban"))
|
let recent = available(record(name: "", at: "/Boards/Untitled.kanban"))
|
||||||
|
|||||||
Reference in New Issue
Block a user