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:
@@ -121,6 +121,51 @@ struct BoardRegistryTests {
|
||||
#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
|
||||
|
||||
@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 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
|
||||
// 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))
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@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 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()
|
||||
|
||||
let rows = registry.recents()
|
||||
@@ -311,7 +504,7 @@ struct BoardRegistryTests {
|
||||
let firstID = registry.recordOpen(of: first.root, displayName: "First")
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
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.setPushOnCommit(id: firstID, true)
|
||||
registry.setRemoteLocationWarned(id: firstID)
|
||||
@@ -377,7 +570,7 @@ struct BoardRegistryTests {
|
||||
|
||||
let registry = BoardRegistry(storageURL: storage.url)
|
||||
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.setPushOnCommit(id: id, true)
|
||||
registry.setRemoteLocationWarned(id: id)
|
||||
@@ -443,7 +636,7 @@ struct BoardRegistryTests {
|
||||
// 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.
|
||||
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)
|
||||
#expect(afterRelaunch.record(id: id)?.isOpenNow == true, "the flags describe what was open at quit")
|
||||
|
||||
Reference in New Issue
Block a user