From f2d9f3ad072989b46fb831ed31edd626d6bdbe3e Mon Sep 17 00:00:00 2001 From: rzen Date: Mon, 27 Jul 2026 20:44:30 -0400 Subject: [PATCH] Give the registry live display-state write-through and record-before-load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/App/AppModel.swift | 22 ++- Kanban/App/BoardWindowHost.swift | 62 ++++++-- Kanban/App/WelcomeRow.swift | 11 ++ Kanban/App/WelcomeView.swift | 37 +++-- Kanban/LiveStore/BoardRegistry.swift | 129 +++++++++++++++-- Kanban/LiveStore/BoardStore.swift | 22 +++ KanbanTests/AppModelTests.swift | 85 ++++++++++- KanbanTests/BoardRegistryTests.swift | 203 ++++++++++++++++++++++++++- KanbanTests/BoardStoreTests.swift | 50 +++++++ KanbanTests/WelcomeRowTests.swift | 28 +++- 10 files changed, 608 insertions(+), 41 deletions(-) diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index e046ec9..61cec3c 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -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 } diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift index a27a8f1..4575615 100644 --- a/Kanban/App/BoardWindowHost.swift +++ b/Kanban/App/BoardWindowHost.swift @@ -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) diff --git a/Kanban/App/WelcomeRow.swift b/Kanban/App/WelcomeRow.swift index 4a4d0d8..2a72c79 100644 --- a/Kanban/App/WelcomeRow.swift +++ b/Kanban/App/WelcomeRow.swift @@ -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, diff --git a/Kanban/App/WelcomeView.swift b/Kanban/App/WelcomeView.swift index 0f62c78..60d9302 100644 --- a/Kanban/App/WelcomeView.swift +++ b/Kanban/App/WelcomeView.swift @@ -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 { diff --git a/Kanban/LiveStore/BoardRegistry.swift b/Kanban/LiveStore/BoardRegistry.swift index db9ac26..11adfa8 100644 --- a/Kanban/LiveStore/BoardRegistry.swift +++ b/Kanban/LiveStore/BoardRegistry.swift @@ -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 } } diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 3fe8919..5482c0d 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -229,6 +229,22 @@ public final class BoardStore { @ObservationIgnored 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 /// 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. reloadFailure = nil 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): // `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload diff --git a/KanbanTests/AppModelTests.swift b/KanbanTests/AppModelTests.swift index 867ca5f..06f951f 100644 --- a/KanbanTests/AppModelTests.swift +++ b/KanbanTests/AppModelTests.swift @@ -54,14 +54,15 @@ private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) { return (model, { try? FileManager.default.removeItem(at: folder) }) } -/// Opens a board the way `BoardWindowHost` does — acquire, record, flag, begin — so the close tests -/// are closing something the app would recognise. +/// Opens a board the way `BoardWindowHost` does — record, acquire, name it for real, flag, begin — +/// so the close tests are closing something the app would recognise. @MainActor @discardableResult private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef { let ref = BoardWindowRef(url: url) + let recordID = model.boardRegistry.recordOpen(of: 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.beginSession(ref: ref, store: store, recordID: recordID, access: nil) return ref @@ -141,6 +142,84 @@ struct AppModelTests { #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 @Test("A user close stamps live counts, unflags the board, and lets the store go") diff --git a/KanbanTests/BoardRegistryTests.swift b/KanbanTests/BoardRegistryTests.swift index 1d31681..f72957c 100644 --- a/KanbanTests/BoardRegistryTests.swift +++ b/KanbanTests/BoardRegistryTests.swift @@ -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") diff --git a/KanbanTests/BoardStoreTests.swift b/KanbanTests/BoardStoreTests.swift index cd068be..306314a 100644 --- a/KanbanTests/BoardStoreTests.swift +++ b/KanbanTests/BoardStoreTests.swift @@ -245,6 +245,56 @@ struct BoardStoreTests { #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 @Test("A wholesale operation whose reload succeeds leaves no lock and no leaked expectation") diff --git a/KanbanTests/WelcomeRowTests.swift b/KanbanTests/WelcomeRowTests.swift index 19be3cb..ea763aa 100644 --- a/KanbanTests/WelcomeRowTests.swift +++ b/KanbanTests/WelcomeRowTests.swift @@ -23,7 +23,9 @@ private func record( at path: String, lanes: Int? = nil, cards: Int? = nil, - opened: Date = Date() + opened: Date = Date(), + icon: String? = nil, + iconColor: String? = nil ) -> BoardRecord { BoardRecord( bookmark: Data(), @@ -31,7 +33,9 @@ private func record( lastKnownPath: path, lastOpened: opened, 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") } + @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") func displayNameFallsBackToTheFolderName() { let recent = available(record(name: "", at: "/Boards/Untitled.kanban"))