The decision surface — a refused open becomes a live repair, in place
Phase 3 of the decision surface, completing the card (01 ▸ Malformed input, settled 2026-07-31). An attended open's fail-fast walk transforms the loading window's content into one aggregated surface — never a sheet, never a chain: defects grouped by class, each class stated once with its files listed (Reveal in Finder + Open in Editor per row), a class-level default preselected, per-item override behind a disclosure. Only honest choices: YAML and malformed-schema get Editor + Re-check (Skip below the root); newer-than-app gets Skip alone and blocks the board at the root; the two root repairs — minted index, schema: 1 stamp — are defaults. Repair and Open applies fixes in one store-less write bracket and re-walks: clean proceeds, remainder re-aggregates into the same surface. Cancel and ⌘W retire to welcome's row; restored opens never see the surface at all (OpenOrigin rides the PendingOpen carrier). Skips are per-open consent that rides the session — the store retains the skip set and every reload passes it — and the opened board posts a warning-tone notice naming what was left out, each item's Reveal riding the banner strip's new reveal control. On Pro boards the repair bracket binds its own EchoLedger, heal-marks everything, and the store adopts it before the committer starts, so repairs land as one separate commit authored Lanework Integrity — pinned end to end. Also fixed en route: a retired loading window left its close interception installed and returned false from windowShouldClose forever, blocking quit. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -34,6 +34,16 @@ A hand-made card file no longer needs an order or schema line — the card simpl
|
|||||||
|
|
||||||
Every board carries a .gitignore naming which files count as noise, so system files like .DS_Store stay put instead of being gathered into a card's attachments.
|
Every board carries a .gitignore naming which files count as noise, so system files like .DS_Store stay put instead of being gathered into a card's attachments.
|
||||||
|
|
||||||
|
Boards open into their own window right away, with a quiet spinner while a large one is read, and ⌘W cancels an open in progress.
|
||||||
|
|
||||||
|
A board that won't open now explains itself in that window, listing every problem file by file and grouped by what's wrong.
|
||||||
|
|
||||||
|
Each listed file offers Reveal in Finder and Open in Editor, so you can fix it yourself and press Re-check.
|
||||||
|
|
||||||
|
Repair and Open makes the fixes that are safe to make — a folder missing its board file, a board missing its format line — and opens the board.
|
||||||
|
|
||||||
|
You can skip a file Lanework can't fix and open the board without it; the board then names what was left out, and asks again next time.
|
||||||
|
|
||||||
Every card can carry a comment thread, shown beside or below the card's text in its window.
|
Every card can carry a comment thread, shown beside or below the card's text in its window.
|
||||||
|
|
||||||
The comment composer keeps its draft inside the board itself, so a half-written comment is waiting whenever and wherever you reopen the card.
|
The comment composer keeps its draft inside the board itself, so a half-written comment is waiting whenever and wherever you reopen the card.
|
||||||
|
|||||||
+85
-15
@@ -190,6 +190,32 @@ public final class ScopedAccess {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - OpenOrigin
|
||||||
|
|
||||||
|
/// **Whether a person asked for this board right now** (01-storage-format.md § Malformed input, the
|
||||||
|
/// decision surface, settled 2026-07-31):
|
||||||
|
///
|
||||||
|
/// > It appears on **attended opens only** (welcome click, File ▸ Open…, Finder): restoration
|
||||||
|
/// > failures keep the retire-to-welcome-row landing, and the row's retry click is the attended open
|
||||||
|
/// > that then shows the surface — repair is an attended act, and launch never chains dialogs.
|
||||||
|
///
|
||||||
|
/// So this is not a description of *where* an open came from — it is the one bit that decides what a
|
||||||
|
/// failed one does. A closed two-case vocabulary rather than a `Bool` because the sentence a reader
|
||||||
|
/// needs at the branch is "restored boards retire", not "`isAttended` is false".
|
||||||
|
///
|
||||||
|
/// **Attended is the default everywhere**, and that is load-bearing: welcome's rows, File ▸ Open…,
|
||||||
|
/// the Finder open, Duplicate's follow-on open and the template chooser's are all somebody clicking
|
||||||
|
/// something. Exactly one caller says otherwise — launch restoration (`RestoreBootstrapView`) — which
|
||||||
|
/// makes "did a person ask for this" a question one place answers rather than a flag every call site
|
||||||
|
/// has to get right.
|
||||||
|
public enum OpenOrigin: Sendable, Equatable {
|
||||||
|
/// A person just asked for this board.
|
||||||
|
case attended
|
||||||
|
/// Launch restoration reopening what was open last time. Nobody is waiting on it, and a failure
|
||||||
|
/// lands on welcome's row rather than in a surface.
|
||||||
|
case restored
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - AppModel
|
// MARK: - AppModel
|
||||||
|
|
||||||
/// The app's one piece of cross-window state: which boards are open, which card windows belong to
|
/// The app's one piece of cross-window state: which boards are open, which card windows belong to
|
||||||
@@ -595,15 +621,31 @@ public final class AppModel {
|
|||||||
|
|
||||||
// MARK: Pending opens
|
// MARK: Pending opens
|
||||||
|
|
||||||
/// The security-scoped URL a board window is about to be built from, stashed between
|
/// Everything `openBoard(at:origin:)` knows that a `BoardWindowRef` cannot carry.
|
||||||
/// `openBoard(at:)` and the host's first appearance.
|
|
||||||
///
|
///
|
||||||
/// The handoff exists because a window value has to be `Codable` and a scoped URL is not a
|
/// **One struct rather than two parallel dictionaries** (the shape this replaced was
|
||||||
/// string: by the time `BoardWindowHost` receives its `BoardWindowRef` the access token is gone
|
/// `pendingAccess` alone): both facts are stashed by the same call, claimed by the same call, and
|
||||||
/// unless something carried it across. The host claims it on appear; an unclaimed entry (a window
|
/// meaningless apart — a window that found an origin but no access, or the reverse, would be a
|
||||||
/// that never opened) leaks one scope until quit, which is the cheapest failure available here.
|
/// bug with no honest reading. Keeping them in one value makes "they are always in step" true by
|
||||||
|
/// construction instead of by two `removeValue`s that must not drift.
|
||||||
|
private struct PendingOpen {
|
||||||
|
/// The security-scoped URL the board will be built from, or `nil` where the open needed no
|
||||||
|
/// scope. See `ScopedAccess`: a `URL` rebuilt from `ref.path` grants nothing.
|
||||||
|
let access: ScopedAccess?
|
||||||
|
/// Whether a person asked for this board — what a failed open branches on (`OpenOrigin`).
|
||||||
|
let origin: OpenOrigin
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a board window is about to be built from, stashed between `openBoard(at:origin:)` and the
|
||||||
|
/// host's first appearance.
|
||||||
|
///
|
||||||
|
/// The handoff exists because a window value has to be `Codable` and neither of these is a
|
||||||
|
/// string: by the time `BoardWindowHost` receives its `BoardWindowRef` the access token is gone,
|
||||||
|
/// and the origin was never in the ref at all. The host claims both on appear; an unclaimed entry
|
||||||
|
/// (a window that never opened) leaks one scope until quit, which is the cheapest failure
|
||||||
|
/// available here.
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var pendingAccess: [BoardWindowRef: ScopedAccess] = [:]
|
private var pendingOpens: [BoardWindowRef: PendingOpen] = [:]
|
||||||
|
|
||||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-model")
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-model")
|
||||||
|
|
||||||
@@ -677,8 +719,19 @@ public final class AppModel {
|
|||||||
/// **Called before any scene has appeared, and that's fine.** A cold launch's Finder-open can
|
/// **Called before any scene has appeared, and that's fine.** A cold launch's Finder-open can
|
||||||
/// reach here before `windowOpener` is captured; the URL joins `pendingOpenURLs` and this same
|
/// reach here before `windowOpener` is captured; the URL joins `pendingOpenURLs` and this same
|
||||||
/// method runs again for it once `captureWindowActions` has something to open it with.
|
/// method runs again for it once `captureWindowActions` has something to open it with.
|
||||||
public func openBoard(at url: URL) {
|
///
|
||||||
|
/// - Parameter origin: whether a person asked for this board right now (`OpenOrigin`) — the one
|
||||||
|
/// bit a *failed* open branches on (01-storage-format.md § Malformed input: the decision
|
||||||
|
/// surface "appears on attended opens only"). `.attended` by default, which is every caller but
|
||||||
|
/// launch restoration: welcome's rows, File ▸ Open…, the Finder open, the template chooser's
|
||||||
|
/// follow-on, Duplicate's. The default is the rule stated once rather than repeated five times.
|
||||||
|
public func openBoard(at url: URL, origin: OpenOrigin = .attended) {
|
||||||
guard let windowOpener else {
|
guard let windowOpener else {
|
||||||
|
// **The queue carries no origin, and needs none**: it is reachable only *before any scene
|
||||||
|
// exists*, which is the cold-launch Finder/URL open and nothing else — restoration
|
||||||
|
// captures the window actions as its first act (`RestoreBootstrapView.restore`) and so can
|
||||||
|
// never queue. Everything in here is therefore attended, which is what the replay's
|
||||||
|
// default gives it.
|
||||||
pendingOpenURLs.append(url)
|
pendingOpenURLs.append(url)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -689,11 +742,22 @@ public final class AppModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let ref = BoardWindowRef(url: url)
|
let ref = BoardWindowRef(url: url)
|
||||||
|
stashPendingOpen(for: ref, url: url, origin: origin)
|
||||||
|
windowOpener(id: WindowID.board, value: ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stashes what the window about to appear will claim — the handoff's write half.
|
||||||
|
///
|
||||||
|
/// A method of its own rather than two lines inside `openBoard(at:origin:)` because that method
|
||||||
|
/// cannot run without SwiftUI's `OpenWindowAction`, which is not a thing a test can construct: the
|
||||||
|
/// carrier would otherwise be the one part of the attendance plumbing with no headless proof, and
|
||||||
|
/// an origin that quietly stopped travelling would look exactly like the app before this
|
||||||
|
/// milestone.
|
||||||
|
func stashPendingOpen(for ref: BoardWindowRef, url: URL, origin: OpenOrigin) {
|
||||||
// Replacing a stash for the same ref would strand the old scope; there is no such case today
|
// Replacing a stash for the same ref would strand the old scope; there is no such case today
|
||||||
// (an unopened window's ref is not reachable), but stopping the loser is free.
|
// (an unopened window's ref is not reachable), but stopping the loser is free.
|
||||||
pendingAccess.removeValue(forKey: ref)?.stop()
|
pendingOpens.removeValue(forKey: ref)?.access?.stop()
|
||||||
pendingAccess[ref] = ScopedAccess(url)
|
pendingOpens[ref] = PendingOpen(access: ScopedAccess(url), origin: origin)
|
||||||
windowOpener(id: WindowID.board, value: ref)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows — or focuses — the welcome window. Its own scene id, so this works with no windows at
|
/// Shows — or focuses — the welcome window. Its own scene id, so this works with no windows at
|
||||||
@@ -748,10 +812,16 @@ public final class AppModel {
|
|||||||
sessions[ref]
|
sessions[ref]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Claims the scoped URL `openBoard(at:)` stashed for this window, or `nil` if it opened by some
|
/// Claims what `openBoard(at:origin:)` stashed for this window. Claiming removes it: the session
|
||||||
/// other route. Claiming removes it: the session owns the balance from here.
|
/// owns the scope's balance from here.
|
||||||
func claimPendingAccess(for ref: BoardWindowRef) -> ScopedAccess? {
|
///
|
||||||
pendingAccess.removeValue(forKey: ref)
|
/// A window that opened by some other route — a route that never went through `openBoard` — gets
|
||||||
|
/// no scope and reads as **attended**, which is the safe direction: the worst an attended reading
|
||||||
|
/// can do to a failed open is offer the user a repair they did not ask for, where the reverse
|
||||||
|
/// would silently retire a board somebody just double-clicked.
|
||||||
|
func claimPendingOpen(for ref: BoardWindowRef) -> (access: ScopedAccess?, origin: OpenOrigin) {
|
||||||
|
guard let pending = pendingOpens.removeValue(forKey: ref) else { return (nil, .attended) }
|
||||||
|
return (pending.access, pending.origin)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts a board's session — the board window's host calls this once its load has succeeded.
|
/// Starts a board's session — the board window's host calls this once its load has succeeded.
|
||||||
|
|||||||
@@ -86,10 +86,39 @@ struct BoardWindowHost: View {
|
|||||||
/// that precedes `start()`.
|
/// that precedes `start()`.
|
||||||
@State private var recordID: UUID?
|
@State private var recordID: UUID?
|
||||||
|
|
||||||
|
/// This open's security-scoped access, claimed in `start()` and held until the session takes it
|
||||||
|
/// over or the open ends.
|
||||||
|
///
|
||||||
|
/// `@State` rather than a local in `start()` because the decision surface outlives that call: a
|
||||||
|
/// repair *writes into the board*, and a scope released when `start()` returned would be released
|
||||||
|
/// exactly before the one write that needs it. Every exit balances it — the session adopts it,
|
||||||
|
/// or Cancel and the failure path stop it.
|
||||||
|
@State private var access: ScopedAccess?
|
||||||
|
|
||||||
|
/// Whether a person asked for this board (`OpenOrigin`) — claimed beside the access, and read by
|
||||||
|
/// exactly one branch: what a failed walk does.
|
||||||
|
@State private var origin: OpenOrigin = .attended
|
||||||
|
|
||||||
|
/// The board's URL as this open resolved it — the scoped one where there is one. Held for the
|
||||||
|
/// surface's sake, which re-walks and repairs against it long after `start()` has returned.
|
||||||
|
@State private var boardURL: URL?
|
||||||
|
|
||||||
|
/// **The repair bracket's ledger**, held between Repair and Open's writes and the store that the
|
||||||
|
/// following walk builds (`BoardRepairRun`, `EchoLedger.adopt`).
|
||||||
|
///
|
||||||
|
/// It cannot live anywhere else: the repairs run before a store exists and the receipts have to
|
||||||
|
/// reach that store's ledger before `beginSession` composes Pro's committer, or the app's own
|
||||||
|
/// repair commits as `Lanework External`. Cleared once adopted.
|
||||||
|
@State private var repairLedger: EchoLedger?
|
||||||
|
|
||||||
@State private var phase: Phase = .opening
|
@State private var phase: Phase = .opening
|
||||||
|
|
||||||
private enum Phase {
|
private enum Phase {
|
||||||
case opening
|
case opening
|
||||||
|
/// **The walk refused and a person is looking at it** — the decision surface, in the loading
|
||||||
|
/// window's own content area (01-storage-format.md § Malformed input, settled 2026-07-31:
|
||||||
|
/// "the loading content transforms in place, never a sheet over a spinner").
|
||||||
|
case deciding(BoardDecisionSurfaceModel)
|
||||||
case open(BoardStore)
|
case open(BoardStore)
|
||||||
/// The load failed; this window is on its way out and must not try again.
|
/// The load failed; this window is on its way out and must not try again.
|
||||||
case failed
|
case failed
|
||||||
@@ -122,6 +151,15 @@ struct BoardWindowHost: View {
|
|||||||
// when `phase` becomes `.open` — a snap, which is what assigning outside `withAnimation`
|
// when `phase` becomes `.open` — a snap, which is what assigning outside `withAnimation`
|
||||||
// means here.
|
// means here.
|
||||||
BoardLoadingView(indicator: loading)
|
BoardLoadingView(indicator: loading)
|
||||||
|
case let .deciding(model):
|
||||||
|
// **In place.** The same content area the spinner was in, with no transition of its own:
|
||||||
|
// 02's snap, read for the surface that arrives instead of a snapshot.
|
||||||
|
BoardDecisionSurface(
|
||||||
|
model: model,
|
||||||
|
onRepairAndOpen: { repairAndOpen(model) },
|
||||||
|
onRecheck: { recheck(model) },
|
||||||
|
onCancel: { cancelDecision(model, closingWindow: false) }
|
||||||
|
)
|
||||||
case .failed:
|
case .failed:
|
||||||
// Nothing to render and nothing worth animating: this window is dismissing itself.
|
// Nothing to render and nothing worth animating: this window is dismissing itself.
|
||||||
Color.clear
|
Color.clear
|
||||||
@@ -246,9 +284,13 @@ struct BoardWindowHost: View {
|
|||||||
private func start() async {
|
private func start() async {
|
||||||
guard case .opening = phase else { return }
|
guard case .opening = phase else { return }
|
||||||
|
|
||||||
// Claimed even on the failure path: an unclaimed stash is a scope nobody balances.
|
// Claimed even on the failure path: an unclaimed stash is a scope nobody balances. The origin
|
||||||
let access = appModel.claimPendingAccess(for: ref)
|
// rides along — one claim, one dictionary (`AppModel.claimPendingOpen`).
|
||||||
let url = access?.url ?? ref.url
|
let claimed = appModel.claimPendingOpen(for: ref)
|
||||||
|
access = claimed.access
|
||||||
|
origin = claimed.origin
|
||||||
|
let url = claimed.access?.url ?? ref.url
|
||||||
|
boardURL = url
|
||||||
|
|
||||||
// Record before load (settled, 02 § Per-board app state). `displayName` is omitted — an
|
// 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
|
// existing record's cached title survives untouched, and a brand-new one takes the folder
|
||||||
@@ -263,9 +305,23 @@ struct BoardWindowHost: View {
|
|||||||
configureLoadingWindow(recordID: recordID)
|
configureLoadingWindow(recordID: recordID)
|
||||||
loading.begin()
|
loading.begin()
|
||||||
|
|
||||||
|
await attemptOpen(url: url, recordID: recordID, skipping: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **One walk, and what it lands in** — the loop Repair and Open and Re-check re-enter.
|
||||||
|
///
|
||||||
|
/// It is a method rather than the tail of `start()` because the surface's two buttons "re-run the
|
||||||
|
/// whole walk" (01-storage-format.md § Malformed input) and must land in exactly the places this
|
||||||
|
/// lands: a clean walk proceeds into the ordinary open, and a walk that still refuses
|
||||||
|
/// re-aggregates into the *same* surface. Sharing the body is what makes "never a chained second
|
||||||
|
/// dialog" structural rather than remembered.
|
||||||
|
///
|
||||||
|
/// - Parameter skipping: the surface's consented skips, empty on a first attempt. It reaches the
|
||||||
|
/// walk *and* the store, which retains it for the session (`BoardStore.skippedPaths`).
|
||||||
|
private func attemptOpen(url: URL, recordID: UUID, skipping: Set<String>) async {
|
||||||
let store: BoardStore
|
let store: BoardStore
|
||||||
do throws(BoardLoadFailure) {
|
do throws(BoardLoadFailure) {
|
||||||
guard let acquired = try await appModel.storeRegistry.acquireOffMain(url) else {
|
guard let acquired = try await appModel.storeRegistry.acquireOffMain(url, skipping: skipping) else {
|
||||||
// ⌘W landed while the walk was running, and the walk has now finished into a result
|
// ⌘W landed while the walk was running, and the walk has now finished into a result
|
||||||
// nobody wants (`acquireOffMain`, discard-on-completion). The window is already
|
// nobody wants (`acquireOffMain`, discard-on-completion). The window is already
|
||||||
// closing and the registry kept nothing, so the only thing left to balance is this
|
// closing and the registry kept nothing, so the only thing left to balance is this
|
||||||
@@ -273,27 +329,39 @@ struct BoardWindowHost: View {
|
|||||||
// after the load, so a cancelled open never set one — the very reason it lives there.
|
// after the load, so a cancelled open never set one — the very reason it lives there.
|
||||||
Self.logger.debug("board open cancelled during its walk")
|
Self.logger.debug("board open cancelled during its walk")
|
||||||
loading.end()
|
loading.end()
|
||||||
access?.stop()
|
releaseAccess()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
store = acquired
|
store = acquired
|
||||||
} catch {
|
} catch {
|
||||||
Self.logger.error("board failed to open: \(error.description, privacy: .public)")
|
handleWalkFailure(error, url: url, recordID: recordID)
|
||||||
loading.end()
|
return
|
||||||
access?.stop()
|
}
|
||||||
phase = .failed
|
|
||||||
appModel.recordLaunchFailure(path: ref.path, message: error.description)
|
// **The window retired while this walk was in flight** — Cancel (or ⌘W) pressed during a
|
||||||
// The record above just changed the registry — welcome, about to appear, must not
|
// Re-check, whose walk then landed successfully. The board must not open behind a window that
|
||||||
// render the stale list `AppModel` cached before this open began, or the failure would
|
// has already gone to welcome, and the reference `acquireOffMain` took has to go back or the
|
||||||
// fall through to the unmatched-failures list for want of a row that already exists.
|
// registry would hold a watcher for a board nobody is showing.
|
||||||
appModel.refreshRecents()
|
if case .failed = phase {
|
||||||
openWindow(id: WindowID.welcome)
|
Self.logger.debug("a walk landed after the open was cancelled — releasing it")
|
||||||
dismissWindow(id: WindowID.board, value: ref)
|
appModel.storeRegistry.release(store)
|
||||||
|
releaseAccess()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.end()
|
loading.end()
|
||||||
|
|
||||||
|
// **The repair's receipts, into the board's own ledger — before the session composes**
|
||||||
|
// (01: "On Pro boards the repairs drop heal-marked receipts and commit separately as one
|
||||||
|
// repair commit"). `beginSession` is where Pro's committer is built and started, and the
|
||||||
|
// committer harvests the ledger it is handed; receipts adopted after that line would be
|
||||||
|
// receipts the repair commit never sees, and the app's own repair would be authored
|
||||||
|
// `Lanework External`.
|
||||||
|
if let repairLedger {
|
||||||
|
store.echoes.adopt(repairLedger)
|
||||||
|
self.repairLedger = nil
|
||||||
|
}
|
||||||
|
|
||||||
// The load succeeded — the frontmatter can be trusted now, so it replaces whatever
|
// The load succeeded — the frontmatter can be trusted now, so it replaces whatever
|
||||||
// provisional or stale name the record above was carrying. Through `syncDisplayState`,
|
// 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
|
// deliberately not a second `recordOpen`: this is a display-state refresh, not a second
|
||||||
@@ -307,15 +375,210 @@ struct BoardWindowHost: View {
|
|||||||
)
|
)
|
||||||
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)
|
||||||
|
// The session owns the balance from here (`AppModel.beginSession`), so this window must not
|
||||||
|
// stop it on any later path.
|
||||||
|
access = nil
|
||||||
phase = .open(store)
|
phase = .open(store)
|
||||||
|
|
||||||
configureWindow(store: store, recordID: recordID)
|
configureWindow(store: store, recordID: recordID)
|
||||||
|
postSkipNoticeIfNeeded(store: store, skipping: skipping)
|
||||||
|
|
||||||
// "Opening a board from welcome closes welcome" (02 § Launch and window lifecycle). Harmless
|
// "Opening a board from welcome closes welcome" (02 § Launch and window lifecycle). Harmless
|
||||||
// when welcome is not open, which is the ordinary case.
|
// when welcome is not open, which is the ordinary case.
|
||||||
dismissWindow(id: WindowID.welcome)
|
dismissWindow(id: WindowID.welcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The attendance branch** (01-storage-format.md § Malformed input, settled 2026-07-31): the
|
||||||
|
/// surface "appears on attended opens only … restoration failures keep the retire-to-welcome-row
|
||||||
|
/// landing".
|
||||||
|
///
|
||||||
|
/// Three outcomes, and the third is the new one:
|
||||||
|
///
|
||||||
|
/// - **A restored open retires**, exactly as it did before this milestone. Nobody is sitting in
|
||||||
|
/// front of a launch that reopened four boards, and "launch never chains dialogs".
|
||||||
|
/// - **An attended open whose failure is environmental retires too** — a root that is gone, or a
|
||||||
|
/// root that is a file. There is nothing on disk to repair, so a surface would offer the user a
|
||||||
|
/// decision with no choices in it; welcome's row says the same thing in one line, which is
|
||||||
|
/// where it belonged already. (The carve-out the ruling implies rather than states — Redesign
|
||||||
|
/// Gap 87cd782a.)
|
||||||
|
/// - **Anything else transforms into the surface**, in place, in this window.
|
||||||
|
///
|
||||||
|
/// A surface is *entered* rather than shown: it holds this window's access, its record, and its
|
||||||
|
/// URL for as long as the user is deciding, which is why none of the three is released here.
|
||||||
|
private func handleWalkFailure(_ error: BoardLoadFailure, url: URL, recordID: UUID) {
|
||||||
|
Self.logger.error("board failed to open: \(error.description, privacy: .public)")
|
||||||
|
|
||||||
|
// Retired while this walk ran (Cancel during a Re-check): the failure has already been
|
||||||
|
// recorded and the window is on its way out. A second `retire` would post the row twice.
|
||||||
|
if case .failed = phase { return }
|
||||||
|
|
||||||
|
guard Self.landing(for: error, origin: origin) == .decide else {
|
||||||
|
loading.end()
|
||||||
|
retire(message: error.description)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already deciding: this is a Re-check or a Repair and Open landing, and it re-aggregates in
|
||||||
|
// place — the same model, the same window, no second dialog.
|
||||||
|
if case let .deciding(model) = phase {
|
||||||
|
model.reaggregate(error)
|
||||||
|
model.isWorking = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.end()
|
||||||
|
phase = .deciding(BoardDecisionSurfaceModel(failure: error, boardRoot: url))
|
||||||
|
// ⌘W now means Cancel (01: the surface's own exit), replacing the loading half's
|
||||||
|
// cancel-the-walk closure — a slot rather than a branch, `configureWindow`'s posture.
|
||||||
|
windowController.onCloseRequested = {
|
||||||
|
guard case let .deciding(model) = phase else { return }
|
||||||
|
cancelDecision(model, closingWindow: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a refused walk lands.
|
||||||
|
enum FailureLanding: Equatable {
|
||||||
|
/// Welcome, on the board's own recents row — today's landing, unchanged.
|
||||||
|
case retire
|
||||||
|
/// The decision surface, in this window.
|
||||||
|
case decide
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The attendance branch as a pure rule** — two facts in, one landing out, provable without a
|
||||||
|
/// window (`BoardDecisionSurfaceTests`).
|
||||||
|
///
|
||||||
|
/// It is a static rather than an `if` inside `handleWalkFailure` because it is the ruling's own
|
||||||
|
/// sentence and the one thing about this milestone that a regression would make silently wrong:
|
||||||
|
/// a launch that started showing surfaces would chain dialogs across four restored boards, and an
|
||||||
|
/// attended open that stopped showing one would look exactly like the app before this milestone.
|
||||||
|
static func landing(for failure: BoardLoadFailure, origin: OpenOrigin) -> FailureLanding {
|
||||||
|
guard origin == .attended else { return .retire }
|
||||||
|
return isEnvironmental(failure) ? .retire : .decide
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Nothing on disk to repair** — the environmental carve-out's predicate.
|
||||||
|
///
|
||||||
|
/// A single defect, and that defect is a fact about the *root itself* rather than about a file
|
||||||
|
/// inside it: `BoardLoader` throws these immediately, before any walk, precisely because there is
|
||||||
|
/// nothing to walk. The single-defect check is stated rather than assumed — the loader's
|
||||||
|
/// environmental throws are single by construction, and a future aggregate carrying one *among*
|
||||||
|
/// repairable defects should show the surface, because the rest of it is still actionable.
|
||||||
|
static func isEnvironmental(_ failure: BoardLoadFailure) -> Bool {
|
||||||
|
guard failure.defects.count == 1 else { return false }
|
||||||
|
switch failure.primary.reason {
|
||||||
|
case .unreadableRoot, .notADirectory:
|
||||||
|
return true
|
||||||
|
case .boardRootMissingIndex, .unparseableYAML, .missingSchema, .malformedSchema,
|
||||||
|
.schemaNewerThanApp, .missingOrder, .malformedOrder:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The surface's three buttons
|
||||||
|
|
||||||
|
/// **Repair and Open**: apply every chosen fix in one write bracket, then re-run the whole walk
|
||||||
|
/// with the skip set (01-storage-format.md § Malformed input).
|
||||||
|
///
|
||||||
|
/// The repairs are store-less by necessity and heal-marked by rule (`BoardRepairRun`); the walk
|
||||||
|
/// that follows is the ordinary one, so a clean result proceeds into the ordinary open and a
|
||||||
|
/// dirty one re-aggregates here. A repair that failed does not abort anything — the batch stops,
|
||||||
|
/// the failure shows on the surface, and the walk runs anyway, because "a partial repair simply
|
||||||
|
/// re-aggregates on the next walk".
|
||||||
|
private func repairAndOpen(_ model: BoardDecisionSurfaceModel) {
|
||||||
|
guard let url = boardURL, let recordID, !model.isWorking else { return }
|
||||||
|
model.isWorking = true
|
||||||
|
model.repairFailure = nil
|
||||||
|
|
||||||
|
let outcome = BoardRepairRun.apply(model.plannedRepairs, boardRoot: url)
|
||||||
|
model.repairFailure = outcome.failure
|
||||||
|
// Held for the store the walk below may build — see `repairLedger`. Merged with anything an
|
||||||
|
// earlier pass left, so two rounds of repair both reach the commit.
|
||||||
|
if let existing = repairLedger {
|
||||||
|
existing.adopt(outcome.ledger)
|
||||||
|
} else {
|
||||||
|
repairLedger = outcome.ledger
|
||||||
|
}
|
||||||
|
|
||||||
|
let walk = Task { await attemptOpen(url: url, recordID: recordID, skipping: model.skipSet) }
|
||||||
|
openWalk.adopt(walk)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Re-check**: re-run the whole walk with the current skip set, changing nothing on disk
|
||||||
|
/// (01: "a disk changed underneath re-aggregates into the *same* surface with the fresh defect
|
||||||
|
/// list … a clean walk proceeds to the first snapshot").
|
||||||
|
private func recheck(_ model: BoardDecisionSurfaceModel) {
|
||||||
|
guard let url = boardURL, let recordID, !model.isWorking else { return }
|
||||||
|
model.isWorking = true
|
||||||
|
let walk = Task { await attemptOpen(url: url, recordID: recordID, skipping: model.skipSet) }
|
||||||
|
openWalk.adopt(walk)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Cancel** — and ⌘W, which means exactly this while the surface is up (01: "**Cancel** aborts
|
||||||
|
/// the open: the window retires and the board lands row-level on welcome, record-before-load
|
||||||
|
/// unchanged").
|
||||||
|
///
|
||||||
|
/// It is the failure path's own sequence, run against the aggregate the surface was showing: the
|
||||||
|
/// record was minted before the walk, so the row is already waiting for this message.
|
||||||
|
///
|
||||||
|
/// - Parameter closingWindow: true when AppKit asked (⌘W), where the interception has to be
|
||||||
|
/// released for the close to complete. The button's own press dismisses through SwiftUI.
|
||||||
|
private func cancelDecision(_ model: BoardDecisionSurfaceModel, closingWindow: Bool) {
|
||||||
|
// A walk may still be in flight behind the surface (Cancel during a Re-check). Its landing is
|
||||||
|
// guarded by `phase`, which this sets to `.failed`; cancelling as well means the open is not
|
||||||
|
// merely ignored but abandoned.
|
||||||
|
openWalk.cancel()
|
||||||
|
retire(message: model.failure.description)
|
||||||
|
if closingWindow {
|
||||||
|
windowController.closeAfterFlush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The retire-to-welcome landing, in one place: the sequence a failed restore has always had, and
|
||||||
|
/// the sequence Cancel now shares with it.
|
||||||
|
private func retire(message: String) {
|
||||||
|
releaseAccess()
|
||||||
|
phase = .failed
|
||||||
|
// **The interception has to go with the phase.** `windowShouldClose` returns `false` whenever
|
||||||
|
// a closure is installed — that is how the close flush gets its turn — so a retired window
|
||||||
|
// whose closure had nothing left to do would *refuse every close request for the rest of the
|
||||||
|
// app's life*, quit included. There is nothing to intercept once this window is on its way to
|
||||||
|
// welcome: no store, no session, no decision.
|
||||||
|
windowController.onCloseRequested = nil
|
||||||
|
appModel.recordLaunchFailure(path: ref.path, message: message)
|
||||||
|
// The record minted before the walk 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func releaseAccess() {
|
||||||
|
access?.stop()
|
||||||
|
access = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The skip notice** (01: "the opened board carries a warning-tone notice naming the skipped
|
||||||
|
/// items, each with Reveal in Finder").
|
||||||
|
///
|
||||||
|
/// Written from the walk's own `LoadWarning.userSkipped` entries rather than from the surface's
|
||||||
|
/// skip set, deliberately: what the notice owes the user is what actually left the board, and a
|
||||||
|
/// skip for a defect that repaired itself between the decision and the walk names nothing.
|
||||||
|
///
|
||||||
|
/// **Only the open that carried the skips posts it.** A second window onto the same board
|
||||||
|
/// acquires the store that already exists, whose warnings still describe the first open — and a
|
||||||
|
/// notice repeated per window would be the app reporting one decision twice. `skipping` is
|
||||||
|
/// non-empty for exactly the open that made the decision.
|
||||||
|
private func postSkipNoticeIfNeeded(store: BoardStore, skipping: Set<String>) {
|
||||||
|
guard !skipping.isEmpty else { return }
|
||||||
|
let root = store.rootURL
|
||||||
|
let items = store.loadWarnings.compactMap { warning -> RevealTarget? in
|
||||||
|
guard case let .userSkipped(path) = warning else { return nil }
|
||||||
|
return RevealTarget(path: path, url: root.appendingPathComponent(path))
|
||||||
|
}
|
||||||
|
store.banners.postSkippedOnOpen(items)
|
||||||
|
}
|
||||||
|
|
||||||
/// **The half of the wiring a window needs before it has a board** — everything here is about
|
/// **The half of the wiring a window needs before it has a board** — everything here is about
|
||||||
/// the *window*, and nothing here mentions the store, which is exactly the split
|
/// the *window*, and nothing here mentions the store, which is exactly the split
|
||||||
/// 02-architecture.md's loading state forces: this runs before the walk, and
|
/// 02-architecture.md's loading state forces: this runs before the walk, and
|
||||||
|
|||||||
@@ -88,7 +88,12 @@ struct RestoreBootstrapView: View {
|
|||||||
for board in appModel.boardRegistry.restorables() {
|
for board in appModel.boardRegistry.restorables() {
|
||||||
switch board {
|
switch board {
|
||||||
case let .available(_, url):
|
case let .available(_, url):
|
||||||
appModel.openBoard(at: url)
|
// **The one restored open in the app** (01-storage-format.md § Malformed input, the
|
||||||
|
// decision surface): a board that fails here keeps today's retire-to-welcome-row
|
||||||
|
// landing — "launch never chains dialogs", and nobody is sitting in front of a
|
||||||
|
// restoration waiting to repair four boards at once. The row's retry click is the
|
||||||
|
// attended open that then shows the surface.
|
||||||
|
appModel.openBoard(at: url, origin: .restored)
|
||||||
attempted += 1
|
attempted += 1
|
||||||
case let .unavailable(record):
|
case let .unavailable(record):
|
||||||
Self.logger.error("a flagged board could not be restored — its bookmark no longer resolves")
|
Self.logger.error("a flagged board could not be restored — its bookmark no longer resolves")
|
||||||
@@ -109,9 +114,11 @@ struct RestoreBootstrapView: View {
|
|||||||
///
|
///
|
||||||
/// **Which board is the launch arguments' to say** (`UITestLaunch.variant`), and this method does
|
/// **Which board is the launch arguments' to say** (`UITestLaunch.variant`), and this method does
|
||||||
/// not care: the malformed variant is built and opened exactly like the other two, and its
|
/// not care: the malformed variant is built and opened exactly like the other two, and its
|
||||||
/// failure arrives one layer down as the *loader's* — a board window that records fail-fast's own
|
/// failure arrives one layer down as the *loader's*. It opens **attended**, like every other
|
||||||
/// sentence and dismisses itself (`BoardWindowHost.start`). Special-casing it here would replace
|
/// board a person asks for, so its refusal transforms the loading window into the decision surface
|
||||||
/// the sentence under test with a sentence about the fixture.
|
/// (`BoardWindowHost.handleWalkFailure`) rather than retiring — which is precisely what the
|
||||||
|
/// fail-fast UI pass is there to see. Special-casing it here would replace the behaviour under
|
||||||
|
/// test with a behaviour about the fixture.
|
||||||
///
|
///
|
||||||
/// **A failure to *build* lands on welcome as an ordinary launch failure**, with the fixture's own
|
/// **A failure to *build* lands on welcome as an ordinary launch failure**, with the fixture's own
|
||||||
/// path on it. That is deliberate: a suite whose fixture failed to build would otherwise audit an
|
/// path on it. That is deliberate: a suite whose fixture failed to build would otherwise audit an
|
||||||
|
|||||||
@@ -300,6 +300,18 @@ public final class GitAutoCommitter {
|
|||||||
/// which is not a shortcut but the doctrine: the ledger is empty because the app was not running,
|
/// which is not a shortcut but the doctrine: the ledger is empty because the app was not running,
|
||||||
/// and "the app never vouches for changes it didn't witness".
|
/// and "the app never vouches for changes it didn't witness".
|
||||||
public func start() {
|
public func start() {
|
||||||
|
// **The ledger may already hold something, and exactly once it does.** An ordinary board's
|
||||||
|
// ledger is empty here — the app has not written to it, which is the whole of the
|
||||||
|
// launch-catch-up doctrine — so this harvest costs a dictionary copy of nothing.
|
||||||
|
//
|
||||||
|
// The exception is a board the **decision surface repaired** (01-storage-format.md
|
||||||
|
// § Malformed input): those writes happened before this board had a store at all, and their
|
||||||
|
// heal-marked receipts were adopted into the store's ledger a moment ago
|
||||||
|
// (`EchoLedger.adopt`, `BoardWindowHost`). Without this line the only harvest is at a write
|
||||||
|
// bracket's close, and no bracket has closed — so the debounce this arms would find the
|
||||||
|
// repaired files unvouched-for and author the app's own repair `Lanework External`, which is
|
||||||
|
// the one misattribution the mechanism exists to prevent.
|
||||||
|
harvest()
|
||||||
arm()
|
arm()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,10 +130,45 @@ public struct LossBanner: Identifiable, Sendable, Equatable {
|
|||||||
/// When the loss happened — the sort key for "newest first within a class".
|
/// When the loss happened — the sort key for "newest first within a class".
|
||||||
public let occurredAt: Date
|
public let occurredAt: Date
|
||||||
|
|
||||||
public init(id: UUID = UUID(), message: String, occurredAt: Date = Date()) {
|
/// **What this row can show the user in Finder**, empty for every loss row that has nothing to
|
||||||
|
/// point at — which is all of them but one.
|
||||||
|
///
|
||||||
|
/// It exists for the skip notice (01-storage-format.md § Malformed input, ruled 2026-07-31: "the
|
||||||
|
/// opened board carries a warning-tone notice naming the skipped items, **each with Reveal in
|
||||||
|
/// Finder**"). The affordance is per *item* while the row is one line, so the targets ride the
|
||||||
|
/// row's data and the strip renders one control over them (`BannerRowControl.reveal`) — a button
|
||||||
|
/// for a sole item, a menu for several. Carrying them here rather than in a row case of their own
|
||||||
|
/// keeps the skip notice in the loss class the ruling puts it in.
|
||||||
|
public let reveals: [RevealTarget]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
message: String,
|
||||||
|
occurredAt: Date = Date(),
|
||||||
|
reveals: [RevealTarget] = []
|
||||||
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.message = message
|
self.message = message
|
||||||
self.occurredAt = occurredAt
|
self.occurredAt = occurredAt
|
||||||
|
self.reveals = reveals
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One file a banner row can reveal in Finder.
|
||||||
|
///
|
||||||
|
/// `path` is what the user reads — the board-root-relative spelling `BoardLoadError.path` carries and
|
||||||
|
/// the decision surface's row already showed them — and `url` is what Finder selects. The two are
|
||||||
|
/// carried together rather than derived from each other because only the producer holds the board
|
||||||
|
/// root, and a row that rebuilt a URL from a string would be a second answer to where the board is.
|
||||||
|
public struct RevealTarget: Identifiable, Sendable, Equatable {
|
||||||
|
public let path: String
|
||||||
|
public let url: URL
|
||||||
|
|
||||||
|
public var id: String { path }
|
||||||
|
|
||||||
|
public init(path: String, url: URL) {
|
||||||
|
self.path = path
|
||||||
|
self.url = url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +362,7 @@ public enum BannerRow: Identifiable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **This row's buttons, in the order Tab visits them** — Cancel, then Dismiss.
|
/// **This row's buttons, in the order Tab visits them** — Cancel, then Reveal, then Dismiss.
|
||||||
///
|
///
|
||||||
/// It exists because 10-accessibility.md ▸ Full Keyboard Access rules the banner's buttons in by
|
/// It exists because 10-accessibility.md ▸ Full Keyboard Access rules the banner's buttons in by
|
||||||
/// name (2026-07-29): "'Every control' is literal and includes banner-row buttons — a Dismiss or
|
/// name (2026-07-29): "'Every control' is literal and includes banner-row buttons — a Dismiss or
|
||||||
@@ -338,14 +373,18 @@ public enum BannerRow: Identifiable, Sendable {
|
|||||||
/// posture the rest of this type already takes ("the per-kind affordances hang off the row's
|
/// posture the rest of this type already takes ("the per-kind affordances hang off the row's
|
||||||
/// data, not off separate views").
|
/// data, not off separate views").
|
||||||
///
|
///
|
||||||
/// No row has both today: the two conditions are disjoint by construction (only an in-progress
|
/// Cancel and Dismiss are disjoint by construction (only an in-progress row cancels, and an
|
||||||
/// row cancels, and an in-progress row is never dismissable). The order is stated anyway, since
|
/// in-progress row is never dismissable), so the pair that actually co-occurs is **Reveal then
|
||||||
/// it is the Tab order the moment one does.
|
/// Dismiss** — the skip notice's shape. Reveal comes first because it is the row's *content*
|
||||||
|
/// affordance and Dismiss is its lifecycle one: the same reason Cancel precedes Dismiss.
|
||||||
public var controls: [BannerRowControl] {
|
public var controls: [BannerRowControl] {
|
||||||
var controls: [BannerRowControl] = []
|
var controls: [BannerRowControl] = []
|
||||||
if case let .inProgress(operation) = self, let cancel = operation.cancel {
|
if case let .inProgress(operation) = self, let cancel = operation.cancel {
|
||||||
controls.append(.cancel(cancel))
|
controls.append(.cancel(cancel))
|
||||||
}
|
}
|
||||||
|
if case let .loss(loss) = self, !loss.reveals.isEmpty {
|
||||||
|
controls.append(.reveal(loss.reveals))
|
||||||
|
}
|
||||||
if let dismissID {
|
if let dismissID {
|
||||||
controls.append(.dismiss(dismissID))
|
controls.append(.dismiss(dismissID))
|
||||||
}
|
}
|
||||||
@@ -370,10 +409,26 @@ public enum BannerRowControl: Identifiable, Sendable {
|
|||||||
/// Clear this row, by the id `BannerCenter.dismiss(_:)` takes.
|
/// Clear this row, by the id `BannerCenter.dismiss(_:)` takes.
|
||||||
case dismiss(UUID)
|
case dismiss(UUID)
|
||||||
|
|
||||||
|
/// **Show the files this row is about in Finder** — the skip notice's per-item affordance
|
||||||
|
/// (01-storage-format.md § Malformed input: "each with Reveal in Finder").
|
||||||
|
///
|
||||||
|
/// **One control over N targets, not N controls**, and the reason is the strip's own shape: a
|
||||||
|
/// banner row is one line, three collapsible rows are all the strip shows, and a notice that grew
|
||||||
|
/// a button per skipped item would push the rows below it behind "+N more" on the very board that
|
||||||
|
/// just told the user something went wrong. So the row stays one row, the control stays one Tab
|
||||||
|
/// stop (10-accessibility.md ▸ Full Keyboard Access), and the plurality lives *inside* it — the
|
||||||
|
/// strip renders a plain button for a sole target and a menu naming each path for two or more.
|
||||||
|
///
|
||||||
|
/// Never empty: `BannerRow.controls` only produces it where there is something to reveal.
|
||||||
|
case reveal([RevealTarget])
|
||||||
|
|
||||||
public var label: String {
|
public var label: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .cancel: "Cancel"
|
case .cancel: "Cancel"
|
||||||
case .dismiss: "Dismiss"
|
case .dismiss: "Dismiss"
|
||||||
|
// One label for both renderings — it is the button's title *and* the menu's, and it is what
|
||||||
|
// 01 calls the affordance by name.
|
||||||
|
case .reveal: "Reveal in Finder"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,8 +533,11 @@ public final class BannerCenter {
|
|||||||
|
|
||||||
/// Posts a loss row — content that didn't arrive though nothing failed (settled 2026-07-28, see
|
/// Posts a loss row — content that didn't arrive though nothing failed (settled 2026-07-28, see
|
||||||
/// `LossBanner`). Newest first, like the one-shots it shares a lifecycle with.
|
/// `LossBanner`). Newest first, like the one-shots it shares a lifecycle with.
|
||||||
public func postLoss(_ message: String) {
|
///
|
||||||
losses.insert(LossBanner(message: message), at: 0)
|
/// - Parameter reveals: the files this row can show in Finder, empty for every producer but the
|
||||||
|
/// skip notice (`LossBanner.reveals`).
|
||||||
|
public func postLoss(_ message: String, reveals: [RevealTarget] = []) {
|
||||||
|
losses.insert(LossBanner(message: message, reveals: reveals), at: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Posts a passive notice — m6's remote-change signpost and whatever joins it. Newest first,
|
/// Posts a passive notice — m6's remote-change signpost and whatever joins it. Newest first,
|
||||||
@@ -655,6 +713,28 @@ public final class BannerCenter {
|
|||||||
postLoss(message)
|
postLoss(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The skip notice** (01-storage-format.md § Malformed input, ruled 2026-07-31): the decision
|
||||||
|
/// surface offered Skip on a defect the app has no honest repair for, the user consented, the
|
||||||
|
/// board opened without that item — "the file stays on disk untouched, tolerated-invisible like
|
||||||
|
/// strays" — and this is the row that says so.
|
||||||
|
///
|
||||||
|
/// > a skipped item loads the board without it … and the opened board carries a warning-tone
|
||||||
|
/// > notice naming the skipped items, each with Reveal in Finder. Skips are per-open decisions,
|
||||||
|
/// > never persisted: the next open of a still-broken board presents the surface again — the
|
||||||
|
/// > notice is the honest residue of this open, not a stored preference.
|
||||||
|
///
|
||||||
|
/// **A loss row, and the ruling names the tone**: the board on screen is not the whole board, and
|
||||||
|
/// that is exactly "content that didn't arrive though nothing failed". It must not evaporate
|
||||||
|
/// unread (the class's untimed lifecycle) and it must not rank as an error, because nothing
|
||||||
|
/// failed — the user chose this.
|
||||||
|
///
|
||||||
|
/// An open that skipped nothing posts nothing: a notice about no skips is not news, and it is
|
||||||
|
/// what every ordinary open passes here.
|
||||||
|
public func postSkippedOnOpen(_ items: [RevealTarget]) {
|
||||||
|
guard let message = Self.skippedOnOpenMessage(for: items) else { return }
|
||||||
|
postLoss(message, reveals: items)
|
||||||
|
}
|
||||||
|
|
||||||
/// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its
|
/// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its
|
||||||
/// folders (04-interactions.md ▸ Selection, drag & drop, "Folders are refused at hover"): "a
|
/// folders (04-interactions.md ▸ Selection, drag & drop, "Folders are refused at hover"): "a
|
||||||
/// mixed drag proposes for its files only, and the drop imports the files while a one-shot
|
/// mixed drag proposes for its files only, and the drop imports the files while a one-shot
|
||||||
@@ -1039,6 +1119,18 @@ public final class BannerCenter {
|
|||||||
// this one *does* have a name users know from git, and "the ignore list" would be the
|
// this one *does* have a name users know from git, and "the ignore list" would be the
|
||||||
// app inventing a word for something already called something.
|
// app inventing a word for something already called something.
|
||||||
"Couldn't write this board's .gitignore"
|
"Couldn't write this board's .gitignore"
|
||||||
|
case .mintBoardIndex:
|
||||||
|
// **Not "couldn't create the board"** — the board is on screen behind the surface, with
|
||||||
|
// its lanes and its cards; what could not be written is the one file that says the
|
||||||
|
// folder is a board. It names `index.md` rather than a role because the decision surface
|
||||||
|
// the user is looking at has just named that file itself, twice: in the class's own
|
||||||
|
// sentence and on the row's Reveal.
|
||||||
|
"Couldn't create this board's index.md"
|
||||||
|
case .stampSchema:
|
||||||
|
// The repair in the user's own words — the surface's choice reads "Stamp schema: 1", so
|
||||||
|
// its failure says the same thing negated. It names no file for `.mintBoardIndex`'s
|
||||||
|
// reason inverted: the file is right there and the surface just showed its path.
|
||||||
|
"Couldn't stamp this board's schema"
|
||||||
case let .displaceClaimedName(name):
|
case let .displaceClaimedName(name):
|
||||||
// **The name, quoted, and what the app wanted with it** — the failure's mirror of the
|
// **The name, quoted, and what the app wanted with it** — the failure's mirror of the
|
||||||
// success row ("Renamed '.trash' to '.trash 2' — Lanework needs that name"). It names
|
// success row ("Renamed '.trash' to '.trash 2' — Lanework needs that name"). It names
|
||||||
@@ -1195,6 +1287,30 @@ public final class BannerCenter {
|
|||||||
"Folders can't be attached — \(count) skipped"
|
"Folders can't be attached — \(count) skipped"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The skip notice's line, in the relocation family's voice — the act first, the subject after an
|
||||||
|
/// em dash, plurals folded, a sole item named.
|
||||||
|
///
|
||||||
|
/// - **One**: "Opened without 'todo/index.md' — you chose to skip it".
|
||||||
|
/// - **Several**: "Opened without 3 items — you chose to skip them".
|
||||||
|
///
|
||||||
|
/// **The plural fold is safe here in a way it is not elsewhere**, and that is the whole reason
|
||||||
|
/// the count is allowed to stand in for the names: the row carries a Reveal target per item
|
||||||
|
/// (`LossBanner.reveals`), so "which ones" is one click away rather than lost — which is what
|
||||||
|
/// 01's "each with Reveal in Finder" buys. The sole case still names its path, because it fits
|
||||||
|
/// and because a one-item row that said "1 item" would be the app declining to say what it knows.
|
||||||
|
///
|
||||||
|
/// **The tail names the cause**, the migration notice's rule: without it the sentence would read
|
||||||
|
/// as something that happened *to* the user, when it is the choice they just made on the surface.
|
||||||
|
///
|
||||||
|
/// `nil` when nothing was skipped — the ordinary open, and not news.
|
||||||
|
public nonisolated static func skippedOnOpenMessage(for items: [RevealTarget]) -> String? {
|
||||||
|
guard let only = items.first else { return nil }
|
||||||
|
guard items.count == 1 else {
|
||||||
|
return "Opened without \(items.count) items — you chose to skip them"
|
||||||
|
}
|
||||||
|
return "Opened without '\(only.path)' — you chose to skip it"
|
||||||
|
}
|
||||||
|
|
||||||
/// The loose-file relocation's line — 01-storage-format.md's own example sentence, "Moved
|
/// The loose-file relocation's line — 01-storage-format.md's own example sentence, "Moved
|
||||||
/// 'notes.txt' into attachments — 'Fix login'", generalized over the two axes it varies on.
|
/// 'notes.txt' into attachments — 'Fix login'", generalized over the two axes it varies on.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -244,6 +244,25 @@ public final class BoardStore: HealHost {
|
|||||||
/// describe the tree currently on screen.
|
/// describe the tree currently on screen.
|
||||||
public private(set) var loadWarnings: [LoadWarning]
|
public private(set) var loadWarnings: [LoadWarning]
|
||||||
|
|
||||||
|
/// **The skips this open consented to** — the decision surface's Skip set, riding the *session*
|
||||||
|
/// (01-storage-format.md § Malformed input, ruled 2026-07-31; the posture settled here rather
|
||||||
|
/// than left to each reload).
|
||||||
|
///
|
||||||
|
/// The ruling makes skips **per-open decisions, never persisted** — "the next open of a
|
||||||
|
/// still-broken board presents the surface again" — and this is what "per open" means once the
|
||||||
|
/// board is on screen: every reload of this session passes the same set, so the consent the user
|
||||||
|
/// gave when the window opened holds for as long as that window does. The alternative — a reload
|
||||||
|
/// that dropped the set — would blank the board on the first foreign filesystem event, because
|
||||||
|
/// the defect the user tolerated is still on disk and would fail the walk again.
|
||||||
|
///
|
||||||
|
/// Nothing writes it and nothing persists it: it arrives at `init` from the open that composed
|
||||||
|
/// it, and dies with the store, which is the ruling's own "not a stored preference".
|
||||||
|
///
|
||||||
|
/// Empty on every ordinary board — no surface, no skips — which is what makes the reload path
|
||||||
|
/// below byte-identical to what it was for every board that opens cleanly.
|
||||||
|
@ObservationIgnored
|
||||||
|
public let skippedPaths: Set<String>
|
||||||
|
|
||||||
/// **The pending work the load that produced `snapshot` found** — the typed defect stream
|
/// **The pending work the load that produced `snapshot` found** — the typed defect stream
|
||||||
/// (`IntegrityRules.Defect`, settled 2026-07-29). Replaced with the snapshot, like
|
/// (`IntegrityRules.Defect`, settled 2026-07-29). Replaced with the snapshot, like
|
||||||
/// `loadWarnings`, so it always describes the tree currently on screen.
|
/// `loadWarnings`, so it always describes the tree currently on screen.
|
||||||
@@ -602,9 +621,9 @@ public final class BoardStore: HealHost {
|
|||||||
/// with a reload behind it rather than a write into a board nothing is watching yet. A store
|
/// with a reload behind it rather than a write into a board nothing is watching yet. A store
|
||||||
/// built directly (a test, a storeless consumer) heals when it is asked to, and on every reload
|
/// built directly (a test, a storeless consumer) heals when it is asked to, and on every reload
|
||||||
/// thereafter.
|
/// thereafter.
|
||||||
public convenience init(rootURL: URL) throws(BoardLoadFailure) {
|
public convenience init(rootURL: URL, skipping: Set<String> = []) throws(BoardLoadFailure) {
|
||||||
let result = try BoardLoader.load(boardRoot: rootURL)
|
let result = try BoardLoader.load(boardRoot: rootURL, skipping: skipping)
|
||||||
self.init(rootURL: rootURL, loaded: result)
|
self.init(rootURL: rootURL, loaded: result, skipping: skipping)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The same board, from a walk that already happened somewhere else.
|
/// The same board, from a walk that already happened somewhere else.
|
||||||
@@ -618,11 +637,16 @@ public final class BoardStore: HealHost {
|
|||||||
/// `rootURL` is passed rather than read off `result.model` for the reason the property's own doc
|
/// `rootURL` is passed rather than read off `result.model` for the reason the property's own doc
|
||||||
/// comment gives — the store's root follows an absorbed rename ahead of the snapshot that will
|
/// comment gives — the store's root follows an absorbed rename ahead of the snapshot that will
|
||||||
/// carry it.
|
/// carry it.
|
||||||
public init(rootURL: URL, loaded result: LoadResult) {
|
///
|
||||||
|
/// - Parameter skipping: the skip set the walk was run with, retained for this session's reloads
|
||||||
|
/// (`skippedPaths`). Defaulted to none, which is every board that opened without a decision
|
||||||
|
/// surface.
|
||||||
|
public init(rootURL: URL, loaded result: LoadResult, skipping: Set<String> = []) {
|
||||||
self.rootURL = rootURL
|
self.rootURL = rootURL
|
||||||
self.snapshot = result.model
|
self.snapshot = result.model
|
||||||
self.loadWarnings = result.warnings
|
self.loadWarnings = result.warnings
|
||||||
self.defects = result.defects
|
self.defects = result.defects
|
||||||
|
self.skippedPaths = skipping
|
||||||
self.reloadFailure = nil
|
self.reloadFailure = nil
|
||||||
self.readOnlyLock = nil
|
self.readOnlyLock = nil
|
||||||
self.transient = TransientBoardState()
|
self.transient = TransientBoardState()
|
||||||
@@ -693,6 +717,9 @@ public final class BoardStore: HealHost {
|
|||||||
// `Sendable` value that touches libgit2 only if this walk finds a duplicate identity to
|
// `Sendable` value that touches libgit2 only if this walk finds a duplicate identity to
|
||||||
// break a tie for. `nil` everywhere the app manages no git.
|
// break a tie for. `nil` everywhere the app manages no git.
|
||||||
let historyRanker = makeIdentityHistoryRanker?()
|
let historyRanker = makeIdentityHistoryRanker?()
|
||||||
|
// **This session's consented skips, on every walk it runs** (`skippedPaths`): the open's
|
||||||
|
// decision stands for the session, so a reload sees the board the user chose to open.
|
||||||
|
let skipping = skippedPaths
|
||||||
reloadInFlight = true
|
reloadInFlight = true
|
||||||
Self.logger.debug("reload \(generation, privacy: .public) started (\(origin.rawValue, privacy: .public))")
|
Self.logger.debug("reload \(generation, privacy: .public) started (\(origin.rawValue, privacy: .public))")
|
||||||
|
|
||||||
@@ -701,7 +728,8 @@ public final class BoardStore: HealHost {
|
|||||||
// and the loader's typed failure is lost on the way into `Result`.
|
// and the loader's typed failure is lost on the way into `Result`.
|
||||||
let outcome: Result<LoadResult, BoardLoadFailure>
|
let outcome: Result<LoadResult, BoardLoadFailure>
|
||||||
do throws(BoardLoadFailure) {
|
do throws(BoardLoadFailure) {
|
||||||
outcome = .success(try BoardLoader.load(boardRoot: root, historyRanker: historyRanker))
|
outcome = .success(try BoardLoader.load(
|
||||||
|
boardRoot: root, skipping: skipping, historyRanker: historyRanker))
|
||||||
} catch {
|
} catch {
|
||||||
outcome = .failure(error)
|
outcome = .failure(error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,21 +197,37 @@ public final class BoardStoreRegistry {
|
|||||||
/// returns `nil` before constructing anything, so there is no store, no watcher, no entry and no
|
/// returns `nil` before constructing anything, so there is no store, no watcher, no entry and no
|
||||||
/// reference — and the caller's security-scoped access is its own to release.
|
/// reference — and the caller's security-scoped access is its own to release.
|
||||||
///
|
///
|
||||||
|
/// - Parameter skipping: **the decision surface's consented skips** (01-storage-format.md
|
||||||
|
/// § Malformed input, ruled 2026-07-31), passed to the walk and then *retained by the store*
|
||||||
|
/// so every reload of the resulting session runs with the same set (`BoardStore.skippedPaths`).
|
||||||
|
/// Empty — the default — is every board that opens without a surface.
|
||||||
|
///
|
||||||
/// - Returns: the board's store, or `nil` if this acquire was cancelled before its walk landed.
|
/// - Returns: the board's store, or `nil` if this acquire was cancelled before its walk landed.
|
||||||
/// `nil` is not a failure: nothing went wrong and nothing was opened.
|
/// `nil` is not a failure: nothing went wrong and nothing was opened.
|
||||||
public func acquireOffMain(_ rootURL: URL) async throws(BoardLoadFailure) -> BoardStore? {
|
public func acquireOffMain(
|
||||||
|
_ rootURL: URL,
|
||||||
|
skipping: Set<String> = []
|
||||||
|
) async throws(BoardLoadFailure) -> BoardStore? {
|
||||||
if let store = referenceExistingBoard(at: rootURL) { return store }
|
if let store = referenceExistingBoard(at: rootURL) { return store }
|
||||||
|
|
||||||
// `nil` for a root that does not exist or whose volume will not answer — there is nothing to
|
// `nil` for a root that does not exist or whose volume will not answer — there is nothing to
|
||||||
// coalesce on, so such an open walks alone and the loader produces the honest error for it.
|
// coalesce on, so such an open walks alone and the loader produces the honest error for it.
|
||||||
let identity = FileIdentity(of: rootURL)
|
//
|
||||||
|
// **A skip-carrying acquire also walks alone**, deliberately: the single flight exists so
|
||||||
|
// concurrent opens of one board share a walk, and two walks are only shareable when they
|
||||||
|
// would produce the same answer. A skip set changes what the walk *finds*, so joining one
|
||||||
|
// would hand a window a board someone else's consent composed — and a Repair-and-Open's
|
||||||
|
// re-walk could be answered by the very walk that failed. The condition is exactly "an
|
||||||
|
// ordinary open", which is every open the coalescing was built for (restoration, a Finder
|
||||||
|
// open racing it, a card window arriving first) and none of the surface's.
|
||||||
|
let identity = skipping.isEmpty ? FileIdentity(of: rootURL) : nil
|
||||||
|
|
||||||
let walk: Task<Result<LoadResult, BoardLoadFailure>, Never>
|
let walk: Task<Result<LoadResult, BoardLoadFailure>, Never>
|
||||||
if let identity, let joined = walksInFlight[identity] {
|
if let identity, let joined = walksInFlight[identity] {
|
||||||
Self.logger.debug("acquire: joining the walk already running for this board")
|
Self.logger.debug("acquire: joining the walk already running for this board")
|
||||||
walk = joined
|
walk = joined
|
||||||
} else {
|
} else {
|
||||||
walk = Self.walk(rootURL)
|
walk = Self.walk(rootURL, skipping: skipping)
|
||||||
if let identity { walksInFlight[identity] = walk }
|
if let identity { walksInFlight[identity] = walk }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +254,8 @@ public final class BoardStoreRegistry {
|
|||||||
case let .failure(failure):
|
case let .failure(failure):
|
||||||
throw failure
|
throw failure
|
||||||
case let .success(result):
|
case let .success(result):
|
||||||
return try adopt(BoardStore(rootURL: rootURL, loaded: result), rootURL: rootURL)
|
return try adopt(
|
||||||
|
BoardStore(rootURL: rootURL, loaded: result, skipping: skipping), rootURL: rootURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,10 +263,13 @@ public final class BoardStoreRegistry {
|
|||||||
/// `BoardStore.startReload`'s reason exactly: a task created inside a `@MainActor` method
|
/// `BoardStore.startReload`'s reason exactly: a task created inside a `@MainActor` method
|
||||||
/// inherits that isolation and would run the walk on the main actor, which is the whole thing
|
/// inherits that isolation and would run the walk on the main actor, which is the whole thing
|
||||||
/// this is avoiding.
|
/// this is avoiding.
|
||||||
private static func walk(_ rootURL: URL) -> Task<Result<LoadResult, BoardLoadFailure>, Never> {
|
private static func walk(
|
||||||
|
_ rootURL: URL,
|
||||||
|
skipping: Set<String>
|
||||||
|
) -> Task<Result<LoadResult, BoardLoadFailure>, Never> {
|
||||||
Task.detached(priority: .userInitiated) {
|
Task.detached(priority: .userInitiated) {
|
||||||
do throws(BoardLoadFailure) {
|
do throws(BoardLoadFailure) {
|
||||||
return .success(try BoardLoader.load(boardRoot: rootURL))
|
return .success(try BoardLoader.load(boardRoot: rootURL, skipping: skipping))
|
||||||
} catch {
|
} catch {
|
||||||
return .failure(error)
|
return .failure(error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,6 +246,54 @@ public final class EchoLedger: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Marks everything this ledger holds as a heal** — the whole-ledger form of `markHeal(at:)`,
|
||||||
|
/// for a ledger whose *every* receipt is a heal by construction.
|
||||||
|
///
|
||||||
|
/// Its one caller is the decision surface's repair bracket (01-storage-format.md § Malformed
|
||||||
|
/// input: "On Pro boards the repairs drop heal-marked receipts and commit separately as one
|
||||||
|
/// repair commit"). Repairs run **before** the board has a store — there is no `BoardStore` yet,
|
||||||
|
/// so no `performWrite` to bind — so the repair binds a ledger of its own for the duration of the
|
||||||
|
/// bracket. Everything that lands in it is a repair, which is exactly the condition that makes a
|
||||||
|
/// blanket mark honest here and would make it a lie on a session ledger.
|
||||||
|
///
|
||||||
|
/// Path-by-path marking would need the repair runner to enumerate the files each `BoardWriter`
|
||||||
|
/// call happened to touch — `createBoard` writes `index.md` *and* seeds `.gitignore` — which is
|
||||||
|
/// bookkeeping the Writer exists to keep call sites out of.
|
||||||
|
public func markAllAsHeal() {
|
||||||
|
receipts.withLock { store in
|
||||||
|
for path in store.keys {
|
||||||
|
store[path]?.isHeal = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Takes over another ledger's receipts, attributes and all** — the repair bracket's ledger
|
||||||
|
/// handed to the board's own once the board finally has one.
|
||||||
|
///
|
||||||
|
/// The decision surface repairs a board that has no store, then re-walks it; the walk succeeds,
|
||||||
|
/// the store is built, and only *then* does a session ledger exist. Without this the repair's
|
||||||
|
/// receipts would die with the temporary ledger and Pro's committer would author the app's own
|
||||||
|
/// repair as `Lanework External` — the one misattribution the whole mechanism exists to prevent.
|
||||||
|
///
|
||||||
|
/// **Safe because a receipt vouches against disk, not against a clock** (`Receipt.isSatisfied`):
|
||||||
|
/// the repaired files are on disk exactly as the repair left them, and the re-walk that just
|
||||||
|
/// succeeded read those very bytes. An adopted receipt is therefore satisfiable the moment it
|
||||||
|
/// arrives, which is the same standing a receipt dropped inside a write bracket has.
|
||||||
|
///
|
||||||
|
/// Plain overwrite, the supersession rule: the adopting ledger is brand new in the only case that
|
||||||
|
/// calls this, and a receipt it already held for the same path would be the newer of the two —
|
||||||
|
/// which is the one case the ledger's own `recordWrite` also resolves by keeping what it has been
|
||||||
|
/// told last. Nothing is removed from `other`; it is discarded whole by its caller.
|
||||||
|
public func adopt(_ other: EchoLedger) {
|
||||||
|
let entries = other.receipts.withLock { $0 }
|
||||||
|
guard !entries.isEmpty else { return }
|
||||||
|
receipts.withLock { store in
|
||||||
|
for (path, entry) in entries {
|
||||||
|
store[path] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func forget(_ store: inout [String: Entry], under path: String) {
|
private static func forget(_ store: inout [String: Entry], under path: String) {
|
||||||
let prefix = path + "/"
|
let prefix = path + "/"
|
||||||
for key in Array(store.keys) where key.hasPrefix(prefix) {
|
for key in Array(store.keys) where key.hasPrefix(prefix) {
|
||||||
|
|||||||
@@ -314,8 +314,17 @@ public enum BoardWriter: Sendable {
|
|||||||
/// `index.md`, after it, so the file that makes a folder a board is written first and a failure
|
/// `index.md`, after it, so the file that makes a folder a board is written first and a failure
|
||||||
/// to seed can never leave a half-made board. Seeding is `seedGitignoreIfAbsent`'s, so a
|
/// to seed can never leave a half-made board. Seeding is `seedGitignoreIfAbsent`'s, so a
|
||||||
/// creation into a folder that somehow already carries one leaves it alone.
|
/// creation into a folder that somehow already carries one leaves it alone.
|
||||||
public static func createBoard(at rootURL: URL, title: String?) throws(BoardWriteError) {
|
/// - Parameter operation: what the *caller* was doing, for the banner's sake — `.createBoard` for
|
||||||
let operation = WriteOperation.createBoard
|
/// every gesture that makes a board, and `.mintBoardIndex` for the decision surface's repair of
|
||||||
|
/// a board folder that has everything except the file that says it is one
|
||||||
|
/// (01-storage-format.md § Malformed input). The mechanics are identical, which is exactly why
|
||||||
|
/// the repair reuses this method rather than growing a second one; only the sentence a failure
|
||||||
|
/// would produce differs, and that sentence is the vocabulary's whole job.
|
||||||
|
public static func createBoard(
|
||||||
|
at rootURL: URL,
|
||||||
|
title: String?,
|
||||||
|
operation: WriteOperation = .createBoard
|
||||||
|
) throws(BoardWriteError) {
|
||||||
let indexURL = rootURL.appendingPathComponent(BoardLoader.indexFileName)
|
let indexURL = rootURL.appendingPathComponent(BoardLoader.indexFileName)
|
||||||
|
|
||||||
guard !FileManager.default.fileExists(atPath: indexURL.path) else {
|
guard !FileManager.default.fileExists(atPath: indexURL.path) else {
|
||||||
@@ -2952,6 +2961,34 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
|||||||
/// It never describes an *edit*: the app writes this file only when nothing holds the name.
|
/// It never describes an *edit*: the app writes this file only when nothing holds the name.
|
||||||
case seedGitignore
|
case seedGitignore
|
||||||
|
|
||||||
|
/// **The decision surface's minted board index** (01-storage-format.md § Malformed input, settled
|
||||||
|
/// 2026-07-31): "*Board root without `index.md`* — minted repair, the default: create a board
|
||||||
|
/// index (folder-name title, `schema: 1`)".
|
||||||
|
///
|
||||||
|
/// Its own case rather than a fold into `.createBoard`, on the vocabulary's standing reasoning:
|
||||||
|
/// nothing is being *created* — the board is right there, with its lanes and its cards, and the
|
||||||
|
/// user opened it — so a banner saying the app "couldn't create the board" would name a gesture
|
||||||
|
/// nobody made and a thing that already exists. What is missing is the one file that says the
|
||||||
|
/// folder is a board, and that is what this names.
|
||||||
|
///
|
||||||
|
/// **No payload**, for `.seedGitignore`'s reason: there is one such file per board, its name is
|
||||||
|
/// fixed, and the board it belongs to has no title yet — reading one is precisely what the
|
||||||
|
/// missing file prevents.
|
||||||
|
case mintBoardIndex
|
||||||
|
|
||||||
|
/// **The decision surface's `schema: 1` stamp** (01-storage-format.md § Malformed input): "*Board
|
||||||
|
/// root missing `schema`* — stamp `schema: 1`, the default: reliable exactly because the walk just
|
||||||
|
/// validated the file against schema 1."
|
||||||
|
///
|
||||||
|
/// Its own case beside `.mintBoardIndex` rather than a fold into it — the no-folding rule the
|
||||||
|
/// whole vocabulary is built on — because the two repairs have different outcomes a user could
|
||||||
|
/// care about: one writes a file that was not there, the other adds a key to a file that was. A
|
||||||
|
/// banner saying the app could not create this board's `index.md` when the file is sitting in
|
||||||
|
/// Finder would be actively wrong.
|
||||||
|
///
|
||||||
|
/// **No payload**, for `.mintBoardIndex`'s reason exactly.
|
||||||
|
case stampSchema
|
||||||
|
|
||||||
/// A wrong-kinded node being moved off a board-root name the app claims — a file or symlink
|
/// A wrong-kinded node being moved off a board-root name the app claims — a file or symlink
|
||||||
/// squatting `.trash` (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29: "moved
|
/// squatting `.trash` (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29: "moved
|
||||||
/// aside by a scheduled heal via the Finder-style rename ladder").
|
/// aside by a scheduled heal via the Finder-style rename ladder").
|
||||||
@@ -3074,8 +3111,13 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
|||||||
// The comment family is identity for `.repairDuplicateID`'s reason, doubled: a comment's
|
// The comment family is identity for `.repairDuplicateID`'s reason, doubled: a comment's
|
||||||
// `index.md` carries no `title` to enrich from, and the title these five hold is the *card's*,
|
// `index.md` carries no `title` to enrich from, and the title these five hold is the *card's*,
|
||||||
// filled in by the caller from the window the gesture came from.
|
// filled in by the caller from the window the gesture came from.
|
||||||
|
// The two decision-surface repairs join this list for `.createBoard`'s and
|
||||||
|
// `.seedGitignore`'s reasons at once: neither carries a title slot, and the board they
|
||||||
|
// repair has no readable title to enrich from — a root with no `index.md` has no document
|
||||||
|
// at all, and one with no `schema` is the file the walk just refused.
|
||||||
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
|
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
|
||||||
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .seedGitignore,
|
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .seedGitignore,
|
||||||
|
.mintBoardIndex, .stampSchema,
|
||||||
.displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment,
|
.displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment,
|
||||||
.editComment, .deleteComment, .purgeCommentTrash:
|
.editComment, .deleteComment, .purgeCommentTrash:
|
||||||
self
|
self
|
||||||
@@ -3139,6 +3181,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
|||||||
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
|
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
|
||||||
.style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment,
|
.style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment,
|
||||||
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore,
|
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore,
|
||||||
|
.mintBoardIndex, .stampSchema,
|
||||||
.displaceClaimedName,
|
.displaceClaimedName,
|
||||||
.repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment,
|
.repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment,
|
||||||
.editComment, .deleteComment, .purgeCommentTrash:
|
.editComment, .deleteComment, .purgeCommentTrash:
|
||||||
@@ -3176,6 +3219,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
|||||||
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
|
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
|
||||||
case .agentGuide: "update the agent guide"
|
case .agentGuide: "update the agent guide"
|
||||||
case .seedGitignore: "seed the board's .gitignore"
|
case .seedGitignore: "seed the board's .gitignore"
|
||||||
|
case .mintBoardIndex: "create this board's index.md"
|
||||||
|
case .stampSchema: "stamp this board's schema"
|
||||||
case let .displaceClaimedName(name): "move a stray '\(name)' aside"
|
case let .displaceClaimedName(name): "move a stray '\(name)' aside"
|
||||||
case let .repairDuplicateID(title): Self.phrase("repair the duplicate id of", title)
|
case let .repairDuplicateID(title): Self.phrase("repair the duplicate id of", title)
|
||||||
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
|
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
|
||||||
|
|||||||
@@ -43,6 +43,34 @@ enum AccessibilityPhrases {
|
|||||||
/// would leave that window silent.
|
/// would leave that window silent.
|
||||||
static let boardLoading = "Loading board"
|
static let boardLoading = "Loading board"
|
||||||
|
|
||||||
|
/// **What the decision surface is called** (01-storage-format.md § Malformed input, settled
|
||||||
|
/// 2026-07-31; `BoardDecisionSurface`) — its heading *and* the group label a VoiceOver user hears
|
||||||
|
/// on entering it, which are one string here for the banner row's reason exactly: the sentence
|
||||||
|
/// heard and the sentence read are one, or they are two descriptions of one thing waiting to
|
||||||
|
/// disagree.
|
||||||
|
///
|
||||||
|
/// It says the board did not open and stops there. What is wrong is the sections' to say — the
|
||||||
|
/// surface exists precisely because there is usually more than one answer to that.
|
||||||
|
static let decisionSurfaceLabel = "Lanework couldn't open this board"
|
||||||
|
|
||||||
|
/// The surface's own subtitle: how much is wrong, and what to do about it. The count is the
|
||||||
|
/// number of affected *files*, which is what the rows below it are.
|
||||||
|
static func decisionSurfaceSummary(defects count: Int) -> String {
|
||||||
|
let subject = count == 1 ? "One file needs" : "\(count) files need"
|
||||||
|
return "\(subject) a decision before this board can open."
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One affected file, as one utterance: **path then reason**, in that order, because the path is
|
||||||
|
/// what identifies the row and the reason is what the user is deciding about
|
||||||
|
/// (01: "lists the affected files (path + specifics …)").
|
||||||
|
///
|
||||||
|
/// The reason is the loader's own sentence, unrewritten — the same specifics the row shows — for
|
||||||
|
/// `BannerCenter.causePhrase`'s reason: fail-fast's diagnostics are specific in a way a
|
||||||
|
/// re-phrasing would not be, and the alternative to showing one is a shrug.
|
||||||
|
static func decisionRowLabel(path: String, reason: String) -> String {
|
||||||
|
"\(path): \(reason)"
|
||||||
|
}
|
||||||
|
|
||||||
/// "3 cards", "1 card" — the app's **one** plural folding for a card count, borrowed from
|
/// "3 cards", "1 card" — the app's **one** plural folding for a card count, borrowed from
|
||||||
/// `TrashModel.phrase` rather than restated so a lane's spoken count and the trash column's
|
/// `TrashModel.phrase` rather than restated so a lane's spoken count and the trash column's
|
||||||
/// cannot drift apart.
|
/// cannot drift apart.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// The banner strip: one window's standing conditions, unread failures, and work in flight, as a
|
/// The banner strip: one window's standing conditions, unread failures, and work in flight, as a
|
||||||
@@ -218,6 +219,26 @@ private struct BannerRowView: View {
|
|||||||
Button(control.label, action: cancel)
|
Button(control.label, action: cancel)
|
||||||
.buttonStyle(.link)
|
.buttonStyle(.link)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
|
case let .reveal(targets):
|
||||||
|
// **One control, two renderings** (`BannerRowControl.reveal`): a sole skipped item gets a
|
||||||
|
// plain button, several get a menu naming each path. The row stays one line and one Tab
|
||||||
|
// stop either way, which is what keeps the strip's three-row budget intact on the very
|
||||||
|
// board that just reported something wrong.
|
||||||
|
if targets.count == 1, let only = targets.first {
|
||||||
|
Button(control.label) { Self.reveal(only) }
|
||||||
|
.buttonStyle(.link)
|
||||||
|
.font(.callout)
|
||||||
|
} else {
|
||||||
|
Menu(control.label) {
|
||||||
|
ForEach(targets) { target in
|
||||||
|
Button(target.path) { Self.reveal(target) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.menuStyle(.borderlessButton)
|
||||||
|
.fixedSize()
|
||||||
|
.font(.callout)
|
||||||
|
.accessibilityLabel(control.label)
|
||||||
|
}
|
||||||
case let .dismiss(id):
|
case let .dismiss(id):
|
||||||
Button {
|
Button {
|
||||||
onDismiss(id)
|
onDismiss(id)
|
||||||
@@ -230,6 +251,14 @@ private struct BannerRowView: View {
|
|||||||
.help(control.label)
|
.help(control.label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Finder, with the file selected — `activateFileViewerSelecting` rather than `open`, so a
|
||||||
|
/// skipped `index.md` is shown *in its folder* rather than handed to whatever app claims `.md`.
|
||||||
|
/// The user's next act is looking at where the file sits; opening it is the surface's other
|
||||||
|
/// affordance, not this one.
|
||||||
|
private static func reveal(_ target: RevealTarget) {
|
||||||
|
NSWorkspace.shared.activateFileViewerSelecting([target.url])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Tone rendering
|
// MARK: - Tone rendering
|
||||||
|
|||||||
@@ -0,0 +1,831 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - The classes
|
||||||
|
|
||||||
|
/// **What kind of defect a row is** — the grouping the decision surface is built on
|
||||||
|
/// (01-storage-format.md § Malformed input, settled 2026-07-31: "The surface groups defects **by
|
||||||
|
/// class**: each class section states the defect once, lists the affected files … and carries one
|
||||||
|
/// class-level choice preselected to its default").
|
||||||
|
///
|
||||||
|
/// The class is what decides everything a section says and offers: its sentence, its choices, and its
|
||||||
|
/// default. A `BoardLoadError.Reason` is the *walk's* vocabulary — one case per thing that can be
|
||||||
|
/// wrong with a file — and this is the *surface's*: one case per honest answer the app has.
|
||||||
|
///
|
||||||
|
/// ### Two seatings worth stating
|
||||||
|
///
|
||||||
|
/// **`malformedSchema` sits with the YAML family** (Redesign Gap bcdd1942, filed): the ruling names
|
||||||
|
/// four classes and this reason is not one of them, but it is the same event to a user — a key they
|
||||||
|
/// typed that Lanework cannot read — and the same honest posture applies, because the app must not
|
||||||
|
/// guess what `schema: banana` was meant to be. So it takes the family's choices exactly: Open in
|
||||||
|
/// Editor + Re-check, plus Skip below the root.
|
||||||
|
///
|
||||||
|
/// **The retired `order` reasons sit there too.** Nothing throws them any more (01, re-ruled
|
||||||
|
/// 2026-07-31: below the root a missing or unusable `order` reads as append-at-end), and they survive
|
||||||
|
/// in the loader's vocabulary rather than being deleted. A surface that met one anyway would be
|
||||||
|
/// looking at a file only a person can fix, which is precisely what the family means.
|
||||||
|
public enum BoardDefectClass: String, Sendable, Hashable, CaseIterable {
|
||||||
|
|
||||||
|
/// *Unparseable YAML* — "no app-minted rewrite (the app would be guessing at content)". Plus the
|
||||||
|
/// two seatings above.
|
||||||
|
case unreadableFrontmatter = "unreadable-frontmatter"
|
||||||
|
|
||||||
|
/// *`schema` newer than the app* — "no honest fix (downgrading risks silent loss)".
|
||||||
|
case newerSchema = "newer-schema"
|
||||||
|
|
||||||
|
/// *Board root without `index.md`* — the minted repair.
|
||||||
|
case missingBoardIndex = "missing-board-index"
|
||||||
|
|
||||||
|
/// *Board root missing `schema`* — the stamp.
|
||||||
|
case missingRootSchema = "missing-root-schema"
|
||||||
|
|
||||||
|
/// The environmental failures — a root that is gone, or that is a file. **Not normally reachable
|
||||||
|
/// here at all**: an attended open whose single defect is environmental retires to welcome like a
|
||||||
|
/// restored one, because there is nothing on disk to repair (`BoardWindowHost`). It is in the
|
||||||
|
/// vocabulary because a *re-walk* can meet one — the surface is on screen, and the user (or their
|
||||||
|
/// agent) deletes the board folder underneath it — and a class with no case would be a crash
|
||||||
|
/// where a blocked surface is the honest answer.
|
||||||
|
case unreachableRoot = "unreachable-root"
|
||||||
|
|
||||||
|
/// The class a walk's defect belongs to. Exhaustive with no `default`, the vocabulary's standing
|
||||||
|
/// rule: a reason added to the loader without a seat here fails to compile.
|
||||||
|
public init(_ reason: BoardLoadError.Reason) {
|
||||||
|
switch reason {
|
||||||
|
case .unparseableYAML, .malformedSchema, .missingOrder, .malformedOrder:
|
||||||
|
self = .unreadableFrontmatter
|
||||||
|
case .schemaNewerThanApp:
|
||||||
|
self = .newerSchema
|
||||||
|
case .boardRootMissingIndex:
|
||||||
|
self = .missingBoardIndex
|
||||||
|
case .missingSchema:
|
||||||
|
self = .missingRootSchema
|
||||||
|
case .notADirectory, .unreadableRoot:
|
||||||
|
self = .unreachableRoot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The section's heading — what is wrong, said once for the whole class.
|
||||||
|
public var title: String {
|
||||||
|
switch self {
|
||||||
|
case .unreadableFrontmatter: "Frontmatter Lanework can't read"
|
||||||
|
case .newerSchema: "Made by a newer Lanework"
|
||||||
|
case .missingBoardIndex: "This folder has no board index"
|
||||||
|
case .missingRootSchema: "This board doesn't say which format it's in"
|
||||||
|
case .unreachableRoot: "This board can't be read"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The section's explanation — why the choices below it are the only honest ones. Each is the
|
||||||
|
/// ruling's own reasoning said to a user.
|
||||||
|
public var explanation: String {
|
||||||
|
switch self {
|
||||||
|
case .unreadableFrontmatter:
|
||||||
|
"Lanework won't rewrite these files — it would be guessing at your content. Open one, fix it, then Re-check."
|
||||||
|
case .newerSchema:
|
||||||
|
"These were written by a newer Lanework. There's no honest way to read them here — update the app."
|
||||||
|
case .missingBoardIndex:
|
||||||
|
"Lanework can create one, titled after the folder."
|
||||||
|
case .missingRootSchema:
|
||||||
|
"Lanework can stamp it — reliably, because it just read this board as schema 1."
|
||||||
|
case .unreachableRoot:
|
||||||
|
"Nothing here can be repaired from inside Lanework."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **What this class offers for a defect at `path`**, in the order the picker lists them, first
|
||||||
|
/// being the default (01: "one class-level choice preselected to its default").
|
||||||
|
///
|
||||||
|
/// **The root restriction is the whole reason `path` is a parameter.** Skip is user-consented
|
||||||
|
/// *tolerance* — the board loads without the item — and there is no board without its root
|
||||||
|
/// (`BoardLoader.unskippablePaths`). So a root defect never gets Skip, whatever its class, and a
|
||||||
|
/// class whose only offer is Skip therefore offers nothing at the root: that is the ruling's "on
|
||||||
|
/// the board root it blocks the whole board (Cancel is the only exit)", falling out of the
|
||||||
|
/// restriction rather than being a rule of its own.
|
||||||
|
public func choices(atPath path: String) -> [BoardDefectChoice] {
|
||||||
|
let isRoot = BoardLoader.unskippablePaths.contains(path)
|
||||||
|
switch self {
|
||||||
|
case .unreadableFrontmatter:
|
||||||
|
return isRoot ? [.editAndRecheck] : [.editAndRecheck, .skip]
|
||||||
|
case .newerSchema:
|
||||||
|
return isRoot ? [] : [.skip]
|
||||||
|
case .missingBoardIndex:
|
||||||
|
return [.repair(.mintBoardIndex)]
|
||||||
|
case .missingRootSchema:
|
||||||
|
return [.repair(.stampSchema)]
|
||||||
|
case .unreachableRoot:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The choices
|
||||||
|
|
||||||
|
/// **What the user decided about one defect** — the surface's whole vocabulary of answers.
|
||||||
|
///
|
||||||
|
/// Two of the three are *resolutions*: they let the board open. `.editAndRecheck` is deliberately not
|
||||||
|
/// one, and that is the honest half of the ruling rather than a gap — for unparseable frontmatter
|
||||||
|
/// there is no app-mediated outcome at all, so the row's answer is "a person will fix this", and the
|
||||||
|
/// way out of the surface is Re-check (or Cancel).
|
||||||
|
public enum BoardDefectChoice: Sendable, Equatable, Hashable {
|
||||||
|
|
||||||
|
/// Leave the file exactly as it is; the user opens it, fixes it, and presses Re-check.
|
||||||
|
case editAndRecheck
|
||||||
|
|
||||||
|
/// **User-consented tolerance** (01: "Skip is user-consented tolerance, loudly marked"): the item
|
||||||
|
/// leaves the board with its whole subtree, the file stays on disk untouched, and the opened
|
||||||
|
/// board carries a notice naming what left.
|
||||||
|
case skip
|
||||||
|
|
||||||
|
/// The app mints the fix, inside Repair and Open's one write bracket.
|
||||||
|
case repair(BoardRepair)
|
||||||
|
|
||||||
|
/// **Whether this answer can open the board** — the enabling rule behind Repair and Open: "every
|
||||||
|
/// defect has an actionable resolution (a minted repair or a consented Skip)".
|
||||||
|
public var isActionable: Bool {
|
||||||
|
switch self {
|
||||||
|
case .editAndRecheck: false
|
||||||
|
case .skip, .repair: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The picker's label — the user's own words for the choice, in the ruling's vocabulary.
|
||||||
|
public var label: String {
|
||||||
|
switch self {
|
||||||
|
case .editAndRecheck: "Fix it myself"
|
||||||
|
case .skip: "Skip it"
|
||||||
|
case .repair(.mintBoardIndex): "Create a board index"
|
||||||
|
case .repair(.stampSchema): "Stamp schema: 1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **A fix the app is willing to mint** — the two the ruling grants, and no others.
|
||||||
|
///
|
||||||
|
/// Both are content-lossless, which is the whole test a repair has to pass here: one writes a file
|
||||||
|
/// that was not there, the other adds a key whose value the walk has already proved. Anything that
|
||||||
|
/// would guess at content is not a repair, it is the `.editAndRecheck` answer above.
|
||||||
|
public enum BoardRepair: String, Sendable, Equatable, Hashable {
|
||||||
|
|
||||||
|
/// *Board root without `index.md`* — "create a board index (folder-name title, `schema: 1`) — the
|
||||||
|
/// user just opened this folder as a board, and the mint is content-lossless".
|
||||||
|
case mintBoardIndex = "mint-board-index"
|
||||||
|
|
||||||
|
/// *Board root missing `schema`* — "stamp `schema: 1`, the default: reliable exactly because the
|
||||||
|
/// walk just validated the file against schema 1".
|
||||||
|
case stampSchema = "stamp-schema"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rows and sections
|
||||||
|
|
||||||
|
/// One affected file on the surface: the defect, where it is, and what the user has decided about it.
|
||||||
|
public struct BoardDefectRow: Identifiable, Sendable, Equatable {
|
||||||
|
|
||||||
|
/// The walk's own record — `path` (root-relative) and `reason`, which is the specifics the row
|
||||||
|
/// shows verbatim rather than re-wording.
|
||||||
|
public let defect: BoardLoadError
|
||||||
|
|
||||||
|
/// What Reveal in Finder selects: the offending file where there is one, and the **folder**
|
||||||
|
/// where there is not (a root with no `index.md` — Finder cannot select a file that does not
|
||||||
|
/// exist, and the folder is what the user needs to look at anyway).
|
||||||
|
public let revealURL: URL
|
||||||
|
|
||||||
|
/// The file itself, for Open in Editor — `nil` where nothing is there to open. The missing-index
|
||||||
|
/// class is the only one that answers `nil`, and it is why the affordance is a row's fact rather
|
||||||
|
/// than a fixture of every row.
|
||||||
|
public let editURL: URL?
|
||||||
|
|
||||||
|
/// What this row may answer, first being the class default (`BoardDefectClass.choices(atPath:)`).
|
||||||
|
/// **Empty means blocked**: no repair, no tolerance, nothing the surface can do.
|
||||||
|
public let choices: [BoardDefectChoice]
|
||||||
|
|
||||||
|
/// The answer standing right now — the class default until the user overrides it, `nil` for a
|
||||||
|
/// blocked row, which has no answer to give.
|
||||||
|
public var choice: BoardDefectChoice?
|
||||||
|
|
||||||
|
public var defectClass: BoardDefectClass { BoardDefectClass(defect.reason) }
|
||||||
|
|
||||||
|
/// The path is unique within one walk — a file records at most one defect — so it is the identity
|
||||||
|
/// a re-aggregation matches choices by (`BoardDecisionSurfaceModel.reaggregate`).
|
||||||
|
public var id: String { defect.path }
|
||||||
|
|
||||||
|
/// Whether this row is what blocks the whole board: it is at an unskippable path and its class
|
||||||
|
/// has nothing to offer there.
|
||||||
|
public var isBlocking: Bool { choices.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One class's section: the sentence, the files, and the class-level choice over them.
|
||||||
|
public struct BoardDefectSection: Identifiable, Sendable, Equatable {
|
||||||
|
|
||||||
|
public let defectClass: BoardDefectClass
|
||||||
|
public var rows: [BoardDefectRow]
|
||||||
|
|
||||||
|
public var id: String { defectClass.rawValue }
|
||||||
|
public var title: String { defectClass.title }
|
||||||
|
public var explanation: String { defectClass.explanation }
|
||||||
|
|
||||||
|
/// Every choice any row here can take, in canonical order — what the class-level picker lists.
|
||||||
|
///
|
||||||
|
/// A union rather than one row's list, because a class can straddle the root: unparseable YAML in
|
||||||
|
/// the root's own `index.md` *and* in a lane's is one class with two offer sets. Applying a
|
||||||
|
/// class-level choice leaves any row that cannot take it alone (`choose(_:inClass:)`), which is
|
||||||
|
/// the honest behaviour — a root row silently gaining Skip would be the one thing the restriction
|
||||||
|
/// exists to prevent.
|
||||||
|
public var choices: [BoardDefectChoice] {
|
||||||
|
var seen: [BoardDefectChoice] = []
|
||||||
|
for row in rows {
|
||||||
|
for choice in row.choices where !seen.contains(choice) {
|
||||||
|
seen.append(choice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The class-level answer — the one every row is giving, or `nil` where they disagree (the user
|
||||||
|
/// has overridden one) or where there is nothing to answer.
|
||||||
|
public var classChoice: BoardDefectChoice? {
|
||||||
|
let answers = Set(rows.compactMap(\.choice))
|
||||||
|
return answers.count == 1 ? answers.first : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the per-item disclosure is worth showing: more than one file, and more than one thing
|
||||||
|
/// to say about them. One file's override *is* the class choice, and a class with a single offer
|
||||||
|
/// has no override to make.
|
||||||
|
public var offersPerItemOverride: Bool { rows.count > 1 && choices.count > 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The model
|
||||||
|
|
||||||
|
/// **The decision surface's state and rules** — everything about it that could go quietly wrong,
|
||||||
|
/// outside SwiftUI so it can be asked without a window (01-storage-format.md § Malformed input, the
|
||||||
|
/// decision surface, settled 2026-07-31).
|
||||||
|
///
|
||||||
|
/// ### What it is
|
||||||
|
///
|
||||||
|
/// One walk's `BoardLoadFailure`, grouped by class in walk order, each row preselected to its class
|
||||||
|
/// default, plus the three rules the buttons turn on: which paths a Skip set names, which repairs
|
||||||
|
/// Repair and Open would apply, and whether Repair and Open may be pressed at all.
|
||||||
|
///
|
||||||
|
/// ### What it deliberately is not
|
||||||
|
///
|
||||||
|
/// It runs nothing. It does not walk, does not write, and does not know what a window is: the host
|
||||||
|
/// owns the loop — apply the repairs, re-run the walk, re-aggregate or open — because that loop is
|
||||||
|
/// about a *window*, and this is about a decision. That split is what lets the whole vocabulary be
|
||||||
|
/// pinned from a fixture (`BoardDecisionSurfaceTests`) and what keeps "never a second dialog"
|
||||||
|
/// structural: there is one model per open, and Re-check refreshes it in place
|
||||||
|
/// (`reaggregate(_:)`).
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class BoardDecisionSurfaceModel {
|
||||||
|
|
||||||
|
/// The board this is about — what every row's URL is resolved against, and what the repairs
|
||||||
|
/// write into.
|
||||||
|
public let boardRoot: URL
|
||||||
|
|
||||||
|
/// The walk's whole aggregate, as it stands. Replaced by each re-aggregation, and it is what
|
||||||
|
/// Cancel's launch-failure message is written from.
|
||||||
|
public private(set) var failure: BoardLoadFailure
|
||||||
|
|
||||||
|
/// The sections, in **walk order**: classes ordered by where each first appeared in the walk, and
|
||||||
|
/// rows within a class in walk order too. The walk goes root, then lanes in folder-name order
|
||||||
|
/// with their cards inside them, then `.trash/` — so a grouped surface still reads top-down like
|
||||||
|
/// the tree does, which is the whole reason `BoardLoadFailure` keeps its order.
|
||||||
|
public private(set) var sections: [BoardDefectSection]
|
||||||
|
|
||||||
|
/// Whether a walk or a repair is running right now — the buttons' disabled state. The surface
|
||||||
|
/// stays on screen while it works, because the alternative (back to a spinner, then a surface
|
||||||
|
/// again) would be the chained dialog the ruling forbids, spelled as a flicker.
|
||||||
|
public var isWorking = false
|
||||||
|
|
||||||
|
/// **The last repair that didn't happen**, shown inline on the surface, or `nil`.
|
||||||
|
///
|
||||||
|
/// It has nowhere else to go: the banner strip belongs to a board window that has a store, and a
|
||||||
|
/// board being repaired has neither. A failed repair is not fatal — "interrupted batches are
|
||||||
|
/// accepted per the renumber precedent, every intermediate state valid, and a partial repair
|
||||||
|
/// simply re-aggregates on the next walk" — so this is a line, not an exit.
|
||||||
|
public var repairFailure: BoardWriteError?
|
||||||
|
|
||||||
|
public init(failure: BoardLoadFailure, boardRoot: URL) {
|
||||||
|
self.boardRoot = boardRoot
|
||||||
|
self.failure = failure
|
||||||
|
self.sections = Self.group(failure, boardRoot: boardRoot, keeping: [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Re-aggregation
|
||||||
|
|
||||||
|
/// **A fresh walk's defects, into the same surface** (01: "a disk changed underneath re-aggregates
|
||||||
|
/// into the *same* surface with the fresh defect list, never a chained second dialog").
|
||||||
|
///
|
||||||
|
/// Choices survive by path: a defect that is still there keeps the answer the user gave it, and
|
||||||
|
/// anything new takes its class default. That is what makes Repair and Open's partial-failure
|
||||||
|
/// story bearable — the user's other decisions are still standing when the surface comes back.
|
||||||
|
public func reaggregate(_ failure: BoardLoadFailure) {
|
||||||
|
var kept: [String: BoardDefectChoice] = [:]
|
||||||
|
for section in sections {
|
||||||
|
for row in section.rows where row.choice != nil {
|
||||||
|
kept[row.id] = row.choice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.failure = failure
|
||||||
|
self.sections = Self.group(failure, boardRoot: boardRoot, keeping: kept)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Choosing
|
||||||
|
|
||||||
|
/// Sets a whole class's answer — the class-level control. Rows that cannot take the choice (a
|
||||||
|
/// root row offered Skip) are left exactly as they were.
|
||||||
|
public func choose(_ choice: BoardDefectChoice, inClass defectClass: BoardDefectClass) {
|
||||||
|
guard let index = sections.firstIndex(where: { $0.defectClass == defectClass }) else { return }
|
||||||
|
for rowIndex in sections[index].rows.indices
|
||||||
|
where sections[index].rows[rowIndex].choices.contains(choice) {
|
||||||
|
sections[index].rows[rowIndex].choice = choice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets one file's answer — the per-item override behind the disclosure. A choice the row does
|
||||||
|
/// not offer is ignored rather than trusted.
|
||||||
|
public func choose(_ choice: BoardDefectChoice, forPath path: String) {
|
||||||
|
for sectionIndex in sections.indices {
|
||||||
|
guard let rowIndex = sections[sectionIndex].rows.firstIndex(where: { $0.id == path }) else { continue }
|
||||||
|
guard sections[sectionIndex].rows[rowIndex].choices.contains(choice) else { return }
|
||||||
|
sections[sectionIndex].rows[rowIndex].choice = choice
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: The rules the buttons turn on
|
||||||
|
|
||||||
|
/// The paths the user consented to skip — what the next walk is run with
|
||||||
|
/// (`BoardLoader.load(boardRoot:skipping:)`), and what the opened board's notice is written from.
|
||||||
|
public var skipSet: Set<String> {
|
||||||
|
var paths: Set<String> = []
|
||||||
|
for section in sections {
|
||||||
|
for row in section.rows where row.choice == .skip {
|
||||||
|
paths.insert(row.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The repairs Repair and Open would apply, **in walk order** — which is the order they run in,
|
||||||
|
/// so a board whose root needs both a minted index and… well, one of the two: the two repairs are
|
||||||
|
/// mutually exclusive today (a root with no `index.md` has no `schema` to be missing), and the
|
||||||
|
/// order is stated for the day a third joins them.
|
||||||
|
public var plannedRepairs: [PlannedRepair] {
|
||||||
|
var planned: [PlannedRepair] = []
|
||||||
|
for section in sections {
|
||||||
|
for row in section.rows {
|
||||||
|
guard case let .repair(repair) = row.choice else { continue }
|
||||||
|
planned.append(PlannedRepair(path: row.id, repair: repair))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return planned
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One repair, bound to the file it fixes.
|
||||||
|
public struct PlannedRepair: Sendable, Equatable {
|
||||||
|
/// Root-relative, the defect's own path.
|
||||||
|
public let path: String
|
||||||
|
public let repair: BoardRepair
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Whether a defect blocks the whole board** — a root defect its class cannot answer for
|
||||||
|
/// (01: "on the board root it blocks the whole board (Cancel is the only exit)").
|
||||||
|
///
|
||||||
|
/// The surface still shows everything: a user whose root was written by a newer Lanework is owed
|
||||||
|
/// the whole picture, not a single terse row, because what they do next is decided by how much
|
||||||
|
/// else is wrong.
|
||||||
|
public var isRootBlocked: Bool {
|
||||||
|
sections.contains { $0.rows.contains(where: \.isBlocking) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Repair and Open's enabling rule**: every defect has an actionable resolution — a minted
|
||||||
|
/// repair or a consented Skip — and nothing blocks the root.
|
||||||
|
///
|
||||||
|
/// An `.editAndRecheck` row disables it by construction: that answer is "a person will fix this",
|
||||||
|
/// and pressing Repair and Open would either write something the app promised not to write or
|
||||||
|
/// open a board that still refuses to load.
|
||||||
|
public var canRepairAndOpen: Bool {
|
||||||
|
guard !isRootBlocked else { return false }
|
||||||
|
return sections.allSatisfy { section in
|
||||||
|
section.rows.allSatisfy { $0.choice?.isActionable == true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every row on the surface, flattened — the count the header states, and what the suites walk.
|
||||||
|
public var rows: [BoardDefectRow] { sections.flatMap(\.rows) }
|
||||||
|
|
||||||
|
// MARK: Grouping
|
||||||
|
|
||||||
|
/// The grouping rule: **by class, in walk order** (`sections`).
|
||||||
|
///
|
||||||
|
/// `keeping` carries the answers a previous aggregation had, by path — empty on a first build.
|
||||||
|
private static func group(
|
||||||
|
_ failure: BoardLoadFailure,
|
||||||
|
boardRoot: URL,
|
||||||
|
keeping kept: [String: BoardDefectChoice]
|
||||||
|
) -> [BoardDefectSection] {
|
||||||
|
var sections: [BoardDefectSection] = []
|
||||||
|
for defect in failure.defects {
|
||||||
|
let defectClass = BoardDefectClass(defect.reason)
|
||||||
|
let choices = defectClass.choices(atPath: defect.path)
|
||||||
|
// A kept answer only survives if the row still offers it: a repaired root that came back
|
||||||
|
// as a *different* defect is a different question.
|
||||||
|
let choice = kept[defect.path].flatMap { choices.contains($0) ? $0 : nil } ?? choices.first
|
||||||
|
let row = BoardDefectRow(
|
||||||
|
defect: defect,
|
||||||
|
revealURL: revealURL(for: defect, boardRoot: boardRoot),
|
||||||
|
editURL: editURL(for: defect, boardRoot: boardRoot),
|
||||||
|
choices: choices,
|
||||||
|
choice: choice
|
||||||
|
)
|
||||||
|
if let index = sections.firstIndex(where: { $0.defectClass == defectClass }) {
|
||||||
|
sections[index].rows.append(row)
|
||||||
|
} else {
|
||||||
|
sections.append(BoardDefectSection(defectClass: defectClass, rows: [row]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The folder a defect's path names — the board root for a root defect, the item's folder
|
||||||
|
/// otherwise. `BoardLoadError.path` names the `index.md` (`"<lane>/index.md"`), and `"."` is the
|
||||||
|
/// environmental failures' spelling of the root itself.
|
||||||
|
public static func folder(forDefectAt path: String, under boardRoot: URL) -> URL {
|
||||||
|
guard path != "." else { return boardRoot }
|
||||||
|
let relative = (path as NSString).deletingLastPathComponent
|
||||||
|
guard !relative.isEmpty else { return boardRoot }
|
||||||
|
return boardRoot.appendingPathComponent(relative, isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file a defect names, whether or not it is there.
|
||||||
|
private static func fileURL(for defect: BoardLoadError, boardRoot: URL) -> URL? {
|
||||||
|
guard defect.path != "." else { return nil }
|
||||||
|
return boardRoot.appendingPathComponent(defect.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finder selects the file where there is one, and the folder where there is not.
|
||||||
|
private static func revealURL(for defect: BoardLoadError, boardRoot: URL) -> URL {
|
||||||
|
guard let file = fileURL(for: defect, boardRoot: boardRoot),
|
||||||
|
FileManager.default.fileExists(atPath: file.path)
|
||||||
|
else {
|
||||||
|
return folder(forDefectAt: defect.path, under: boardRoot)
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open in Editor needs a file that exists — a missing `index.md` has nothing to open, and
|
||||||
|
/// handing its URL to `NSWorkspace` would produce a system error instead of an explanation.
|
||||||
|
private static func editURL(for defect: BoardLoadError, boardRoot: URL) -> URL? {
|
||||||
|
guard let file = fileURL(for: defect, boardRoot: boardRoot),
|
||||||
|
FileManager.default.fileExists(atPath: file.path)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Applying the repairs
|
||||||
|
|
||||||
|
/// **Repair and Open's write half** — every chosen fix, in one bracket, store-less
|
||||||
|
/// (01-storage-format.md § Malformed input: "**Repair and Open** applies every chosen fix in one
|
||||||
|
/// write bracket — each repaired `index.md` is an ordinary app write (stamps `modified`, clears
|
||||||
|
/// `modified-by`)").
|
||||||
|
///
|
||||||
|
/// ### Why it is store-less, and what that costs
|
||||||
|
///
|
||||||
|
/// There is no `BoardStore` yet — that is the whole situation the surface exists in — so there is no
|
||||||
|
/// `performWrite` to run inside, and with it none of the machinery a write usually gets: no watcher
|
||||||
|
/// bracket (nothing is watching a board that never opened), no banner strip (the window is showing
|
||||||
|
/// this surface), no undo registration (there is no session and no stack), and no read-only lock to
|
||||||
|
/// consult (the probe runs at acquire, which has not happened). Each of those absences is correct
|
||||||
|
/// here rather than merely tolerable.
|
||||||
|
///
|
||||||
|
/// What it does keep is the **ledger**, bound by hand for the bracket's duration and marked
|
||||||
|
/// wholesale afterwards (`EchoLedger.markAllAsHeal`), because 01 asks for exactly that: "On Pro
|
||||||
|
/// boards the repairs drop heal-marked receipts and commit separately as one repair commit, never
|
||||||
|
/// folded into anyone else's work." The ledger travels to the store that the following walk builds
|
||||||
|
/// (`EchoLedger.adopt`), which is the only reason it can outlive the bracket.
|
||||||
|
///
|
||||||
|
/// ### Interrupted batches are accepted
|
||||||
|
///
|
||||||
|
/// "Interrupted batches are accepted per the renumber precedent, every intermediate state valid, and
|
||||||
|
/// a partial repair simply re-aggregates on the next walk." So a failure stops the batch and is
|
||||||
|
/// *returned* rather than thrown away or thrown up: the caller re-walks regardless, the surface comes
|
||||||
|
/// back with whatever is still wrong, and the receipts of what did land come back too.
|
||||||
|
///
|
||||||
|
/// `@MainActor` because that is where every app write in this app already happens — `performWrite` is
|
||||||
|
/// synchronous on a gesture's own path — and because a repair is one or two small file writes on a
|
||||||
|
/// board the user is looking at. There is nothing here worth an actor hop that the ordinary write
|
||||||
|
/// path does not already do without one.
|
||||||
|
@MainActor
|
||||||
|
public enum BoardRepairRun {
|
||||||
|
|
||||||
|
/// What one repair pass produced: the heal-marked receipts, and the failure that stopped it.
|
||||||
|
public struct Outcome: Sendable {
|
||||||
|
/// The repair bracket's own ledger — every receipt in it heal-marked, ready for the store's
|
||||||
|
/// ledger to adopt once the walk builds one.
|
||||||
|
public let ledger: EchoLedger
|
||||||
|
/// The repair that did not happen, or `nil`. Everything before it did.
|
||||||
|
public let failure: BoardWriteError?
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public static func apply(
|
||||||
|
_ repairs: [BoardDecisionSurfaceModel.PlannedRepair],
|
||||||
|
boardRoot: URL
|
||||||
|
) -> Outcome {
|
||||||
|
let ledger = EchoLedger()
|
||||||
|
var failure: BoardWriteError?
|
||||||
|
|
||||||
|
EchoLedger.$current.withValue(ledger) {
|
||||||
|
for planned in repairs {
|
||||||
|
let folder = BoardDecisionSurfaceModel.folder(forDefectAt: planned.path, under: boardRoot)
|
||||||
|
do throws(BoardWriteError) {
|
||||||
|
switch planned.repair {
|
||||||
|
case .mintBoardIndex:
|
||||||
|
// **Folder-name title, `schema: 1`** — the ruling's own parenthesis, and
|
||||||
|
// `createBoard` is already exactly that write. It refuses only when an
|
||||||
|
// `index.md` is already there, which is precisely the defect's negation; and
|
||||||
|
// it seeds `.gitignore`, which is the every-board heal ruled 2026-07-31 and
|
||||||
|
// therefore wanted here too (a board being repaired is a board being brought
|
||||||
|
// up to today's shape).
|
||||||
|
try BoardWriter.createBoard(
|
||||||
|
at: folder,
|
||||||
|
title: AppModel.folderDisplayName(of: folder),
|
||||||
|
operation: .mintBoardIndex
|
||||||
|
)
|
||||||
|
case .stampSchema:
|
||||||
|
// **An ordinary app write** — which is the ruling's word for it, and what
|
||||||
|
// `updateIndex` means by default: `modified` stamped, `modified-by` cleared,
|
||||||
|
// the round trip preserving every other byte. `kind: .board` is named rather
|
||||||
|
// than derived because a board root's folder name is a Finder document name,
|
||||||
|
// not an identity, so position has no answer to give.
|
||||||
|
try BoardWriter.updateIndex(
|
||||||
|
inItemFolder: folder,
|
||||||
|
kind: .board,
|
||||||
|
operation: .stampSchema
|
||||||
|
) { document in
|
||||||
|
document.set(FrontmatterKeys.schema, to: .int(BoardLoader.supportedSchema))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
failure = error
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the bracket, over whatever landed: every receipt this ledger holds came from a repair,
|
||||||
|
// which is what makes the blanket mark honest (`EchoLedger.markAllAsHeal`). A partial batch is
|
||||||
|
// marked too — the files that did land are still the app's own heal work.
|
||||||
|
ledger.markAllAsHeal()
|
||||||
|
return Outcome(ledger: ledger, failure: failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The surface
|
||||||
|
|
||||||
|
/// **The decision surface** (01-storage-format.md § Malformed input, settled 2026-07-31):
|
||||||
|
///
|
||||||
|
/// > Blocking means one aggregated surface hosted by the pre-snapshot loading window
|
||||||
|
/// > (02-architecture.md ▸ the loading state) — the loading content transforms in place, never a
|
||||||
|
/// > sheet over a spinner.
|
||||||
|
///
|
||||||
|
/// So this is a plain view in the board window's content area, replacing `BoardLoadingView` where it
|
||||||
|
/// stood. No sheet, no alert, no second window: the window the user opened is the window that
|
||||||
|
/// explains itself, and ⌘W means Cancel because there is nothing else it could mean.
|
||||||
|
///
|
||||||
|
/// Everything it renders is `BoardDecisionSurfaceModel`'s; everything it *does* is the host's, as
|
||||||
|
/// three closures. That split is the file's whole shape — see the model.
|
||||||
|
struct BoardDecisionSurface: View {
|
||||||
|
|
||||||
|
let model: BoardDecisionSurfaceModel
|
||||||
|
|
||||||
|
/// Applies the chosen repairs and re-runs the walk with the skip set.
|
||||||
|
let onRepairAndOpen: () -> Void
|
||||||
|
/// Re-runs the walk with the current skip set, changing nothing on disk.
|
||||||
|
let onRecheck: () -> Void
|
||||||
|
/// Aborts the open — the window retires and the board lands row-level on welcome.
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
/// The identifier a UI suite finds the surface by — **on the heading, not on the container**.
|
||||||
|
///
|
||||||
|
/// That placement is load-bearing rather than incidental: SwiftUI's `accessibilityIdentifier`
|
||||||
|
/// propagates *down*, and an outer one overwrites every identifier set inside it. A container
|
||||||
|
/// carrying this string would therefore stamp it over the sections, the rows and the three
|
||||||
|
/// buttons — leaving a surface with one identifier repeated a dozen times and nothing a test could
|
||||||
|
/// press. Every identifier in this file is on a leaf for that reason.
|
||||||
|
static let accessibilityIdentifier = "decision-surface"
|
||||||
|
|
||||||
|
@MainActor private static var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
||||||
|
@MainActor private static var gutter: CGFloat { BoardMetrics.em(1.4, bodyPointSize: pointSize) }
|
||||||
|
@MainActor private static var stack: CGFloat { BoardMetrics.em(0.8, bodyPointSize: pointSize) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
header
|
||||||
|
Divider()
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: Self.gutter) {
|
||||||
|
ForEach(model.sections) { section in
|
||||||
|
sectionView(section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Self.gutter)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
// `.contain` rather than `.combine`, the banner strip's rule for its reason: the sections and
|
||||||
|
// their rows are each their own element, and fusing a whole repair decision into one
|
||||||
|
// utterance would bury the row a VoiceOver user has to act on.
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityLabel(AccessibilityPhrases.decisionSurfaceLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Header and footer
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Self.stack / 2) {
|
||||||
|
Text(AccessibilityPhrases.decisionSurfaceLabel)
|
||||||
|
.font(.headline)
|
||||||
|
.accessibilityIdentifier(Self.accessibilityIdentifier)
|
||||||
|
Text(AccessibilityPhrases.decisionSurfaceSummary(defects: model.rows.count))
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if let failure = model.repairFailure {
|
||||||
|
// The pre-store failure's only surface — see `BoardDecisionSurfaceModel.repairFailure`.
|
||||||
|
// Phrased by `BannerCenter`, like every other write failure in the app.
|
||||||
|
Label(BannerCenter.headline(for: failure), systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.accessibilityIdentifier("decision-repair-failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(Self.gutter)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footer: some View {
|
||||||
|
HStack(spacing: Self.stack) {
|
||||||
|
if model.isWorking {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.accessibilityLabel(AccessibilityPhrases.boardLoading)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Button("Cancel", role: .cancel, action: onCancel)
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.accessibilityIdentifier("decision-cancel")
|
||||||
|
Button("Re-check", action: onRecheck)
|
||||||
|
.accessibilityIdentifier("decision-re-check")
|
||||||
|
Button("Repair and Open", action: onRepairAndOpen)
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.disabled(!model.canRepairAndOpen)
|
||||||
|
.accessibilityIdentifier("decision-repair-and-open")
|
||||||
|
}
|
||||||
|
.disabled(model.isWorking)
|
||||||
|
.padding(Self.gutter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: One class
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func sectionView(_ section: BoardDefectSection) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: Self.stack) {
|
||||||
|
VStack(alignment: .leading, spacing: Self.stack / 3) {
|
||||||
|
Text(section.title)
|
||||||
|
.font(.headline)
|
||||||
|
// On the heading rather than on the section — see `accessibilityIdentifier`.
|
||||||
|
.accessibilityIdentifier("decision-section-\(section.id)")
|
||||||
|
Text(section.explanation)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if section.choices.count > 1 {
|
||||||
|
Picker("What to do", selection: classChoiceBinding(section)) {
|
||||||
|
ForEach(section.choices, id: \.self) { choice in
|
||||||
|
Text(choice.label).tag(Optional(choice))
|
||||||
|
}
|
||||||
|
// **Only while the class actually is mixed.** A per-item override leaves the
|
||||||
|
// class with no single answer, and a picker with no matching tag would silently
|
||||||
|
// show the first choice instead — which would say the override had not happened.
|
||||||
|
// Offering it the rest of the time would be a fourth radio button that does
|
||||||
|
// nothing.
|
||||||
|
if section.classChoice == nil {
|
||||||
|
Text("Mixed").tag(Optional<BoardDefectChoice>.none)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.radioGroup)
|
||||||
|
.accessibilityIdentifier("decision-class-choice-\(section.id)")
|
||||||
|
} else if let only = section.choices.first {
|
||||||
|
Text(only.label)
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Self.stack / 2) {
|
||||||
|
ForEach(section.rows) { row in
|
||||||
|
rowView(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if section.offersPerItemOverride {
|
||||||
|
DisclosureGroup("Choose for each file") {
|
||||||
|
VStack(alignment: .leading, spacing: Self.stack / 2) {
|
||||||
|
ForEach(section.rows) { row in
|
||||||
|
overrideView(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, Self.stack / 2)
|
||||||
|
}
|
||||||
|
.font(.callout)
|
||||||
|
.accessibilityIdentifier("decision-overrides-\(section.id)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityLabel(section.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: One file
|
||||||
|
|
||||||
|
private func rowView(_ row: BoardDefectRow) -> some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: Self.stack) {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(row.defect.path)
|
||||||
|
.font(.callout.monospaced())
|
||||||
|
Text(row.defect.reason.description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
Button("Reveal in Finder") {
|
||||||
|
NSWorkspace.shared.activateFileViewerSelecting([row.revealURL])
|
||||||
|
}
|
||||||
|
.buttonStyle(.link)
|
||||||
|
.font(.callout)
|
||||||
|
|
||||||
|
if let editURL = row.editURL {
|
||||||
|
Button("Open in Editor") {
|
||||||
|
NSWorkspace.shared.open(editURL)
|
||||||
|
}
|
||||||
|
.buttonStyle(.link)
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("decision-row-\(row.id)")
|
||||||
|
// One element per file, path and reason together, so a VoiceOver user hears *which* file and
|
||||||
|
// *what is wrong with it* as one sentence rather than as two neighbouring labels. The two
|
||||||
|
// buttons stay inside it as custom actions and are Tab stops besides — the banner row's rule.
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel(
|
||||||
|
AccessibilityPhrases.decisionRowLabel(path: row.defect.path, reason: row.defect.reason.description)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func overrideView(_ row: BoardDefectRow) -> some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: Self.stack) {
|
||||||
|
Text(row.defect.path)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Picker("What to do", selection: rowChoiceBinding(row)) {
|
||||||
|
ForEach(row.choices, id: \.self) { choice in
|
||||||
|
Text(choice.label).tag(Optional(choice))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.fixedSize()
|
||||||
|
.disabled(row.choices.count < 2)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("decision-override-\(row.id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Bindings
|
||||||
|
|
||||||
|
private func classChoiceBinding(_ section: BoardDefectSection) -> Binding<BoardDefectChoice?> {
|
||||||
|
Binding(
|
||||||
|
get: { section.classChoice },
|
||||||
|
set: { choice in
|
||||||
|
guard let choice else { return }
|
||||||
|
model.choose(choice, inClass: section.defectClass)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rowChoiceBinding(_ row: BoardDefectRow) -> Binding<BoardDefectChoice?> {
|
||||||
|
Binding(
|
||||||
|
get: { row.choice },
|
||||||
|
set: { choice in
|
||||||
|
guard let choice else { return }
|
||||||
|
model.choose(choice, forPath: row.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,769 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftGitX
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// **The decision surface** (01-storage-format.md § Malformed input, settled 2026-07-31) — the
|
||||||
|
/// grouping, the vocabulary, the two minted repairs, the skip channel's ride through the session, and
|
||||||
|
/// the attendance branch that decides whether any of it is reached at all.
|
||||||
|
///
|
||||||
|
/// Everything here is asked of values. The surface's rules live in `BoardDecisionSurfaceModel` and
|
||||||
|
/// its landing rule in a static on the host precisely so a suite with no window can hold the whole
|
||||||
|
/// decision in its hand: what a class offers, what it defaults to, when Repair and Open may be
|
||||||
|
/// pressed, and what a repair actually puts on disk.
|
||||||
|
|
||||||
|
// MARK: - Fixtures
|
||||||
|
|
||||||
|
/// A board whose root has no `schema` and one lane that will not parse — the minimal two-class
|
||||||
|
/// aggregate, and the shape most of these cases need: one minted repair, one hand-edit.
|
||||||
|
@MainActor
|
||||||
|
private func makeTwoClassBoard() throws -> WriterFixture {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
// No `schema` at the root — the this-really-is-a-board gate, defect #1.
|
||||||
|
try fixture.item("", "---\ntitle: Two Classes\n---\nA board with two kinds of problem.\n")
|
||||||
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Intact\norder: 1024\n---\n")
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", "---\nschema: 1\ntitle: Card\norder: 1024\n---\n")
|
||||||
|
// Unparseable YAML — an unterminated flow sequence, defect #2.
|
||||||
|
try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: Broken\norder: 2048\nlabels: [a, b\n---\n")
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A board whose only defects are **below the root** — the skip channel's golden case.
|
||||||
|
@MainActor
|
||||||
|
private func makeSkippableBoard() throws -> WriterFixture {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
try fixture.item("", "---\nschema: 1\ntitle: Skippable\n---\n")
|
||||||
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Intact\norder: 1024\n---\n")
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", "---\nschema: 1\ntitle: Card\norder: 1024\n---\n")
|
||||||
|
// A card from a newer Lanework: unfixable, so Skip is the only offer.
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", "---\nschema: 2\ntitle: Newer\norder: 2048\n---\n")
|
||||||
|
// A lane that will not parse: Open in Editor + Re-check, or Skip.
|
||||||
|
try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: Broken\norder: 2048\nlabels: [a, b\n---\n")
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The failure a board refuses with — the input every model here is built from.
|
||||||
|
@MainActor
|
||||||
|
private func failure(of fixture: WriterFixture, skipping: Set<String> = []) throws -> BoardLoadFailure {
|
||||||
|
do throws(BoardLoadFailure) {
|
||||||
|
_ = try BoardLoader.load(boardRoot: fixture.root, skipping: skipping)
|
||||||
|
Issue.record("the fixture board loaded — it is supposed to refuse")
|
||||||
|
throw BoardLoadFailure(BoardLoadError(path: ".", reason: .notADirectory))
|
||||||
|
} catch {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func makeModel(_ fixture: WriterFixture) throws -> BoardDecisionSurfaceModel {
|
||||||
|
BoardDecisionSurfaceModel(failure: try failure(of: fixture), boardRoot: fixture.root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Grouping and defaults
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Decision surface ▸ grouping and defaults")
|
||||||
|
struct BoardDecisionSurfaceGroupingTests {
|
||||||
|
|
||||||
|
@Test("Defects group by class, and the classes come in walk order")
|
||||||
|
func groupingIsByClassInWalkOrder() throws {
|
||||||
|
let fixture = try makeSkippableBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let model = try makeModel(fixture)
|
||||||
|
|
||||||
|
// The walk is root, then lanes in folder-name order with their cards inside them — so the
|
||||||
|
// newer-schema card (under lane 1) is met before the unparseable lane 2.
|
||||||
|
#expect(model.sections.map(\.defectClass) == [.newerSchema, .unreadableFrontmatter])
|
||||||
|
#expect(model.sections.allSatisfy { $0.rows.count == 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("One class with several files is one section, in walk order")
|
||||||
|
func oneClassIsOneSection() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n")
|
||||||
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\nlabels: [a\n---\n")
|
||||||
|
try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: B\norder: 2048\nlabels: [b\n---\n")
|
||||||
|
|
||||||
|
let model = try makeModel(fixture)
|
||||||
|
|
||||||
|
// "each class section states the defect once, lists the affected files".
|
||||||
|
#expect(model.sections.count == 1)
|
||||||
|
let section = try #require(model.sections.first)
|
||||||
|
#expect(section.defectClass == .unreadableFrontmatter)
|
||||||
|
#expect(section.rows.map(\.id) == ["\(Ident.lane1)/index.md", "\(Ident.lane2)/index.md"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every class preselects its ruled default")
|
||||||
|
func classDefaultsAreTheRuledOnes() throws {
|
||||||
|
// The minted repairs, at the root where they occur.
|
||||||
|
#expect(BoardDefectClass.missingBoardIndex.choices(atPath: "index.md").first == .repair(.mintBoardIndex))
|
||||||
|
#expect(BoardDefectClass.missingRootSchema.choices(atPath: "index.md").first == .repair(.stampSchema))
|
||||||
|
// "Open in Editor + Re-check, **or** Skip" — the posture leads, the tolerance follows.
|
||||||
|
#expect(
|
||||||
|
BoardDefectClass.unreadableFrontmatter.choices(atPath: "lane/index.md")
|
||||||
|
== [.editAndRecheck, .skip]
|
||||||
|
)
|
||||||
|
// "an unfixable row … offering only Skip".
|
||||||
|
#expect(BoardDefectClass.newerSchema.choices(atPath: "lane/index.md") == [.skip])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("malformedSchema is seated with the YAML family, root restriction and all")
|
||||||
|
func malformedSchemaJoinsTheYAMLFamily() {
|
||||||
|
// Redesign Gap bcdd1942: not one of the ruled four, and the same honest posture — the app
|
||||||
|
// must not guess what `schema: banana` was meant to be.
|
||||||
|
#expect(BoardDefectClass(.malformedSchema(raw: "banana")) == .unreadableFrontmatter)
|
||||||
|
#expect(BoardDefectClass(.unparseableYAML(message: "x", line: 2)) == .unreadableFrontmatter)
|
||||||
|
// The root restriction it inherits: Editor + Re-check at the root, plus Skip below it.
|
||||||
|
#expect(BoardDefectClass.unreadableFrontmatter.choices(atPath: "index.md") == [.editAndRecheck])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A root defect is never offered Skip")
|
||||||
|
func theRootIsNeverSkippable() throws {
|
||||||
|
let fixture = try makeTwoClassBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let model = try makeModel(fixture)
|
||||||
|
|
||||||
|
let rootRow = try #require(model.rows.first { $0.id == BoardLoader.indexFileName })
|
||||||
|
#expect(!rootRow.choices.contains(.skip), "there is no board without its root")
|
||||||
|
#expect(rootRow.choice == .repair(.stampSchema))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every row states its path and the walk's own reason")
|
||||||
|
func rowsCarryPathAndSpecifics() throws {
|
||||||
|
let fixture = try makeTwoClassBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let model = try makeModel(fixture)
|
||||||
|
|
||||||
|
let broken = try #require(model.rows.first { $0.id == "\(Ident.lane2)/index.md" })
|
||||||
|
#expect(broken.defect.reason.description.contains("unparseable YAML"))
|
||||||
|
// Reveal points at the file that is actually there; Open in Editor too.
|
||||||
|
#expect(broken.revealURL.lastPathComponent == BoardLoader.indexFileName)
|
||||||
|
#expect(broken.editURL != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A root with no index.md has nothing to open, and reveals its folder")
|
||||||
|
func aMissingIndexRevealsTheFolder() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Orphan\norder: 1024\n---\n")
|
||||||
|
|
||||||
|
let model = try makeModel(fixture)
|
||||||
|
let row = try #require(model.rows.first)
|
||||||
|
|
||||||
|
#expect(row.defectClass == .missingBoardIndex)
|
||||||
|
#expect(row.editURL == nil, "Finder cannot open a file that is not there")
|
||||||
|
#expect(row.revealURL == fixture.root, "the folder is what the user needs to look at")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The choices, the override, and the enabling rule
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Decision surface ▸ choices and the enabling rule")
|
||||||
|
struct BoardDecisionSurfaceChoiceTests {
|
||||||
|
|
||||||
|
@Test("A class-level choice sets every row in the class")
|
||||||
|
func theClassChoiceSetsTheClass() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n")
|
||||||
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\nlabels: [a\n---\n")
|
||||||
|
try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: B\norder: 2048\nlabels: [b\n---\n")
|
||||||
|
|
||||||
|
let model = try makeModel(fixture)
|
||||||
|
#expect(model.sections.first?.classChoice == .editAndRecheck)
|
||||||
|
|
||||||
|
model.choose(.skip, inClass: .unreadableFrontmatter)
|
||||||
|
#expect(model.sections.first?.classChoice == .skip)
|
||||||
|
#expect(model.skipSet == ["\(Ident.lane1)/index.md", "\(Ident.lane2)/index.md"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A per-item override moves one row and leaves the class mixed")
|
||||||
|
func perItemOverrideLeavesTheClassMixed() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n")
|
||||||
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\nlabels: [a\n---\n")
|
||||||
|
try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: B\norder: 2048\nlabels: [b\n---\n")
|
||||||
|
|
||||||
|
let model = try makeModel(fixture)
|
||||||
|
model.choose(.skip, forPath: "\(Ident.lane1)/index.md")
|
||||||
|
|
||||||
|
#expect(model.skipSet == ["\(Ident.lane1)/index.md"], "only the overridden row moved")
|
||||||
|
#expect(model.sections.first?.classChoice == nil, "the class has no single answer any more")
|
||||||
|
#expect(model.sections.first?.offersPerItemOverride == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A class-level choice a row cannot take leaves that row alone")
|
||||||
|
func aRootRowIgnoresAClassLevelSkip() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
// Unparseable YAML at the root *and* below it: one class, two offer sets.
|
||||||
|
try fixture.item("", "---\nschema: 1\ntitle: Board\nlabels: [a\n---\n")
|
||||||
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\nlabels: [b\n---\n")
|
||||||
|
|
||||||
|
let model = try makeModel(fixture)
|
||||||
|
model.choose(.skip, inClass: .unreadableFrontmatter)
|
||||||
|
|
||||||
|
#expect(model.skipSet == ["\(Ident.lane1)/index.md"], "the root never joins a skip set")
|
||||||
|
let rootRow = try #require(model.rows.first { $0.id == BoardLoader.indexFileName })
|
||||||
|
#expect(rootRow.choice == .editAndRecheck)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Repair and Open enables only when every defect has an actionable resolution")
|
||||||
|
func repairAndOpenNeedsEveryDefectResolved() throws {
|
||||||
|
let fixture = try makeSkippableBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let model = try makeModel(fixture)
|
||||||
|
|
||||||
|
// Out of the box the unparseable lane defaults to "fix it myself", which is not a resolution.
|
||||||
|
#expect(!model.canRepairAndOpen)
|
||||||
|
|
||||||
|
model.choose(.skip, inClass: .unreadableFrontmatter)
|
||||||
|
#expect(model.canRepairAndOpen, "a consented Skip is an actionable resolution")
|
||||||
|
#expect(model.skipSet.count == 2, "the newer-schema card defaults to Skip, its only offer")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Repair and Open enables on a board whose only defect has a minted repair")
|
||||||
|
func aMintedRepairAloneEnablesIt() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", "---\ntitle: No Schema\n---\n")
|
||||||
|
|
||||||
|
let model = try makeModel(fixture)
|
||||||
|
#expect(model.canRepairAndOpen)
|
||||||
|
#expect(model.plannedRepairs == [
|
||||||
|
BoardDecisionSurfaceModel.PlannedRepair(path: "index.md", repair: .stampSchema)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A root blocked by a newer schema disables Repair and Open outright")
|
||||||
|
func aRootBlockerDisablesRepairAndOpen() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
// "on the board root it blocks the whole board (Cancel is the only exit)".
|
||||||
|
try fixture.item("", "---\nschema: 99\ntitle: From The Future\n---\n")
|
||||||
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Fine\norder: 1024\n---\n")
|
||||||
|
|
||||||
|
let model = try makeModel(fixture)
|
||||||
|
let row = try #require(model.rows.first)
|
||||||
|
|
||||||
|
#expect(row.isBlocking, "no repair, and no tolerance at the root")
|
||||||
|
#expect(row.choice == nil)
|
||||||
|
#expect(model.isRootBlocked)
|
||||||
|
#expect(!model.canRepairAndOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The surface still shows everything a blocked board has wrong")
|
||||||
|
func aBlockedSurfaceStillShowsEverything() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", "---\nschema: 99\ntitle: From The Future\n---\n")
|
||||||
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\nlabels: [a\n---\n")
|
||||||
|
|
||||||
|
let model = try makeModel(fixture)
|
||||||
|
|
||||||
|
// The user whose root was written by a newer Lanework is owed the whole picture — what they
|
||||||
|
// do next depends on how much else is wrong.
|
||||||
|
#expect(model.sections.map(\.defectClass) == [.newerSchema, .unreadableFrontmatter])
|
||||||
|
#expect(model.rows.count == 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Re-aggregation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Decision surface ▸ re-aggregation")
|
||||||
|
struct BoardDecisionSurfaceReaggregationTests {
|
||||||
|
|
||||||
|
@Test("A fresh walk refreshes the same surface and keeps the answers still standing")
|
||||||
|
func reaggregationKeepsStandingAnswers() throws {
|
||||||
|
let fixture = try makeTwoClassBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let model = try makeModel(fixture)
|
||||||
|
|
||||||
|
// The user overrides the broken lane to Skip, then repairs the root by hand outside the app.
|
||||||
|
model.choose(.skip, inClass: .unreadableFrontmatter)
|
||||||
|
try fixture.item("", "---\nschema: 1\ntitle: Two Classes\n---\n")
|
||||||
|
|
||||||
|
model.reaggregate(try failure(of: fixture))
|
||||||
|
|
||||||
|
#expect(model.sections.map(\.defectClass) == [.unreadableFrontmatter], "the root's defect is gone")
|
||||||
|
#expect(model.skipSet == ["\(Ident.lane2)/index.md"], "the standing consent survived the re-walk")
|
||||||
|
#expect(model.canRepairAndOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A defect the walk newly reveals arrives at its class default")
|
||||||
|
func newlyRevealedDefectsTakeTheirDefault() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n")
|
||||||
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\nlabels: [a\n---\n")
|
||||||
|
// Hidden behind the broken lane: a broken lane takes its subtree with it.
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", "---\nschema: 2\ntitle: Newer\norder: 1024\n---\n")
|
||||||
|
|
||||||
|
let model = try makeModel(fixture)
|
||||||
|
#expect(model.rows.count == 1, "the card under the broken lane is never enumerated")
|
||||||
|
|
||||||
|
// The lane is fixed by hand; Re-check reveals what it was hiding — in the *same* surface.
|
||||||
|
model.choose(.skip, forPath: "\(Ident.lane1)/index.md")
|
||||||
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\n---\n")
|
||||||
|
model.reaggregate(try failure(of: fixture))
|
||||||
|
|
||||||
|
#expect(model.rows.count == 1)
|
||||||
|
let revealed = try #require(model.rows.first)
|
||||||
|
#expect(revealed.id == "\(Ident.lane1)/\(Ident.card1)/index.md")
|
||||||
|
#expect(revealed.choice == .skip, "the newer-schema class's only offer, preselected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The repairs
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Decision surface ▸ the minted repairs")
|
||||||
|
struct BoardDecisionSurfaceRepairTests {
|
||||||
|
|
||||||
|
@Test("Stamping schema writes schema: 1, stamps modified, and clears modified-by")
|
||||||
|
func stampingSchemaIsAnOrdinaryAppWrite() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", """
|
||||||
|
---
|
||||||
|
title: No Schema
|
||||||
|
created: 2026-01-01T09:00:00Z
|
||||||
|
modified: 2026-01-01T09:00:00Z
|
||||||
|
modified-by: claude
|
||||||
|
project: lanework
|
||||||
|
---
|
||||||
|
Body text.
|
||||||
|
|
||||||
|
""")
|
||||||
|
|
||||||
|
let model = try makeModel(fixture)
|
||||||
|
let outcome = BoardRepairRun.apply(model.plannedRepairs, boardRoot: fixture.root)
|
||||||
|
#expect(outcome.failure == nil)
|
||||||
|
|
||||||
|
let text = try fixture.indexText("")
|
||||||
|
#expect(text.contains("schema: 1"))
|
||||||
|
// "each repaired `index.md` is an ordinary app write (stamps `modified`, clears
|
||||||
|
// `modified-by`)".
|
||||||
|
#expect(!text.contains("modified-by"))
|
||||||
|
#expect(!text.contains("modified: 2026-01-01"))
|
||||||
|
// And the round trip is the Writer's own: unknown keys and the body survive.
|
||||||
|
#expect(text.contains("project: lanework"))
|
||||||
|
#expect(text.contains("Body text."))
|
||||||
|
|
||||||
|
// The board loads now, which is the whole test of a repair.
|
||||||
|
#expect(throws: Never.self) { try BoardLoader.load(boardRoot: fixture.root) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Minting a board index creates one titled after the folder, at schema 1")
|
||||||
|
func mintingABoardIndexUsesTheFolderName() throws {
|
||||||
|
let outer = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("DecisionMint-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
let root = outer.appendingPathComponent("Roadmap.kanban", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: outer) }
|
||||||
|
try Data("---\nschema: 1\ntitle: Todo\norder: 1024\n---\n".utf8)
|
||||||
|
.write(to: {
|
||||||
|
let lane = root.appendingPathComponent(Ident.lane1, isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: lane, withIntermediateDirectories: true)
|
||||||
|
return lane.appendingPathComponent(BoardLoader.indexFileName)
|
||||||
|
}())
|
||||||
|
|
||||||
|
let aggregate: BoardLoadFailure
|
||||||
|
do throws(BoardLoadFailure) {
|
||||||
|
_ = try BoardLoader.load(boardRoot: root)
|
||||||
|
Issue.record("a folder with no index.md is not a board")
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
aggregate = error
|
||||||
|
}
|
||||||
|
let model = BoardDecisionSurfaceModel(failure: aggregate, boardRoot: root)
|
||||||
|
#expect(model.plannedRepairs.map(\.repair) == [.mintBoardIndex])
|
||||||
|
|
||||||
|
let outcome = BoardRepairRun.apply(model.plannedRepairs, boardRoot: root)
|
||||||
|
#expect(outcome.failure == nil)
|
||||||
|
|
||||||
|
// "(folder-name title, `schema: 1`)" — and the extension is not part of the name
|
||||||
|
// (01 § Board naming).
|
||||||
|
let loaded = try BoardLoader.load(boardRoot: root)
|
||||||
|
#expect(loaded.model.title.value == "Roadmap")
|
||||||
|
#expect(loaded.model.schema == 1)
|
||||||
|
#expect(loaded.model.lanes.count == 1, "the board that was already there is still there")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The repair bracket's receipts are all heal-marked")
|
||||||
|
func repairReceiptsAreHealMarked() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", "---\ntitle: No Schema\n---\n")
|
||||||
|
|
||||||
|
let model = try makeModel(fixture)
|
||||||
|
let outcome = BoardRepairRun.apply(model.plannedRepairs, boardRoot: fixture.root)
|
||||||
|
|
||||||
|
let index = fixture.root.appendingPathComponent(BoardLoader.indexFileName)
|
||||||
|
#expect(outcome.ledger.receipt(at: index) != nil, "the write dropped a receipt")
|
||||||
|
#expect(
|
||||||
|
outcome.ledger.isHeal(at: index),
|
||||||
|
"01: repairs drop heal-marked receipts and commit separately as one repair commit"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A repair pass with nothing to repair writes nothing and fails nothing")
|
||||||
|
func anEmptyRepairPassIsANoOp() throws {
|
||||||
|
let fixture = try makeSkippableBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let model = try makeModel(fixture)
|
||||||
|
model.choose(.skip, inClass: .unreadableFrontmatter)
|
||||||
|
|
||||||
|
// Every resolution here is a Skip: Repair and Open is a re-walk and nothing else.
|
||||||
|
#expect(model.plannedRepairs.isEmpty)
|
||||||
|
let outcome = BoardRepairRun.apply(model.plannedRepairs, boardRoot: fixture.root)
|
||||||
|
#expect(outcome.failure == nil)
|
||||||
|
#expect(outcome.ledger.outstandingReceipts == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A repaired board walks clean, which is the loop Repair and Open runs")
|
||||||
|
func repairThenWalkIsTheLoop() throws {
|
||||||
|
let fixture = try makeTwoClassBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let model = try makeModel(fixture)
|
||||||
|
model.choose(.skip, inClass: .unreadableFrontmatter)
|
||||||
|
|
||||||
|
BoardRepairRun.apply(model.plannedRepairs, boardRoot: fixture.root)
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root, skipping: model.skipSet)
|
||||||
|
|
||||||
|
#expect(result.model.schema == 1)
|
||||||
|
// The skipped lane left with its whole subtree; the intact one stayed.
|
||||||
|
#expect(result.model.lanes.map { $0.id.rawValue } == [Ident.lane1])
|
||||||
|
#expect(result.warnings.contains(.userSkipped(path: "\(Ident.lane2)/index.md")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An interrupted batch is accepted: what landed stays, and the rest re-aggregates")
|
||||||
|
func aPartialRepairReAggregates() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", "---\ntitle: No Schema\n---\n")
|
||||||
|
|
||||||
|
// A repair aimed at a file that is not there — the shape of a disk that moved underneath.
|
||||||
|
let planned = [
|
||||||
|
BoardDecisionSurfaceModel.PlannedRepair(path: "index.md", repair: .stampSchema),
|
||||||
|
BoardDecisionSurfaceModel.PlannedRepair(
|
||||||
|
path: "\(Ident.lane1)/index.md", repair: .stampSchema)
|
||||||
|
]
|
||||||
|
let outcome = BoardRepairRun.apply(planned, boardRoot: fixture.root)
|
||||||
|
|
||||||
|
#expect(outcome.failure != nil, "the second repair could not run")
|
||||||
|
#expect(try fixture.indexText("").contains("schema: 1"), "the first one still landed")
|
||||||
|
// "every intermediate state valid, and a partial repair simply re-aggregates on the next walk".
|
||||||
|
#expect(throws: Never.self) { try BoardLoader.load(boardRoot: fixture.root) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The skip channel through the session
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Decision surface ▸ the skip set rides the session")
|
||||||
|
struct BoardDecisionSurfaceSkipTests {
|
||||||
|
|
||||||
|
@Test("A skip-carrying open builds a store that retains the set")
|
||||||
|
func theStoreRetainsTheSkipSet() async throws {
|
||||||
|
let fixture = try makeSkippableBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let registry = BoardStoreRegistry()
|
||||||
|
let skips: Set<String> = ["\(Ident.lane2)/index.md", "\(Ident.lane1)/\(Ident.card2)/index.md"]
|
||||||
|
|
||||||
|
let store = try #require(try await registry.acquireOffMain(fixture.root, skipping: skips))
|
||||||
|
defer { registry.release(store) }
|
||||||
|
|
||||||
|
#expect(store.skippedPaths == skips)
|
||||||
|
#expect(store.snapshot.lanes.map { $0.id.rawValue } == [Ident.lane1])
|
||||||
|
#expect(store.snapshot.lanes.first?.cards.count == 1, "the newer-schema card left too")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every reload of that session passes the same set")
|
||||||
|
func reloadsCarryTheSkipSet() async throws {
|
||||||
|
let fixture = try makeSkippableBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let registry = BoardStoreRegistry()
|
||||||
|
let skips: Set<String> = ["\(Ident.lane2)/index.md", "\(Ident.lane1)/\(Ident.card2)/index.md"]
|
||||||
|
|
||||||
|
let store = try #require(try await registry.acquireOffMain(fixture.root, skipping: skips))
|
||||||
|
defer { registry.release(store) }
|
||||||
|
|
||||||
|
// A foreign filesystem event. Without the retained set this reload walks the still-broken
|
||||||
|
// board, fails, and the window carries a breakage banner over the board the user just chose
|
||||||
|
// to open.
|
||||||
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||||
|
await store.awaitQuiescence()
|
||||||
|
|
||||||
|
#expect(store.reloadFailure == nil, "the open's consent stands for the session")
|
||||||
|
#expect(store.snapshot.lanes.map { $0.id.rawValue } == [Ident.lane1])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The walk marks every skip loudly, and the notice is written from those marks")
|
||||||
|
func skipsAreLoudlyMarked() throws {
|
||||||
|
let fixture = try makeSkippableBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
// Every defect on this board has to be answered before it opens at all — a skip omits the item
|
||||||
|
// it names, never its siblings' defects.
|
||||||
|
let skips: Set<String> = ["\(Ident.lane1)/\(Ident.card2)/index.md", "\(Ident.lane2)/index.md"]
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root, skipping: skips)
|
||||||
|
let skipped = result.warnings.compactMap { warning -> String? in
|
||||||
|
if case let .userSkipped(path) = warning { path } else { nil }
|
||||||
|
}
|
||||||
|
// Walk order, like every other list the aggregate produces.
|
||||||
|
#expect(skipped == ["\(Ident.lane1)/\(Ident.card2)/index.md", "\(Ident.lane2)/index.md"])
|
||||||
|
|
||||||
|
// The row the opened board carries, values and all.
|
||||||
|
let items = skipped.map { RevealTarget(path: $0, url: fixture.root.appendingPathComponent($0)) }
|
||||||
|
let center = BannerCenter()
|
||||||
|
center.postSkippedOnOpen(items)
|
||||||
|
|
||||||
|
let loss = try #require(center.losses.first)
|
||||||
|
#expect(loss.message == "Opened without 2 items — you chose to skip them")
|
||||||
|
#expect(loss.reveals.map(\.path) == skipped)
|
||||||
|
// Each reveal points at the file the surface's row named, still on disk and untouched.
|
||||||
|
#expect(loss.reveals.allSatisfy { FileManager.default.fileExists(atPath: $0.url.path) })
|
||||||
|
// "each with Reveal in Finder", as a control the strip can render and Tab can reach.
|
||||||
|
let row = BannerRow.loss(loss)
|
||||||
|
#expect(row.tone == .warning, "a warning-tone loss row — nothing failed")
|
||||||
|
#expect(row.controls.map(\.label) == ["Reveal in Finder", "Dismiss"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A sole skip is named rather than counted")
|
||||||
|
func aSoleSkipIsNamed() throws {
|
||||||
|
let center = BannerCenter()
|
||||||
|
center.postSkippedOnOpen([
|
||||||
|
RevealTarget(path: "lane/index.md", url: URL(fileURLWithPath: "/b/lane/index.md"))
|
||||||
|
])
|
||||||
|
#expect(center.losses.first?.message == "Opened without 'lane/index.md' — you chose to skip it")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Several skips fold to a count, with a reveal for each")
|
||||||
|
func severalSkipsFold() throws {
|
||||||
|
let items = [
|
||||||
|
RevealTarget(path: "a/index.md", url: URL(fileURLWithPath: "/b/a/index.md")),
|
||||||
|
RevealTarget(path: "b/index.md", url: URL(fileURLWithPath: "/b/b/index.md")),
|
||||||
|
RevealTarget(path: "c/index.md", url: URL(fileURLWithPath: "/b/c/index.md"))
|
||||||
|
]
|
||||||
|
let center = BannerCenter()
|
||||||
|
center.postSkippedOnOpen(items)
|
||||||
|
|
||||||
|
let loss = try #require(center.losses.first)
|
||||||
|
#expect(loss.message == "Opened without 3 items — you chose to skip them")
|
||||||
|
// The fold is only safe because "which ones" survives on the row.
|
||||||
|
#expect(loss.reveals.count == 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An open that skipped nothing says nothing")
|
||||||
|
func noSkipsNoNotice() {
|
||||||
|
let center = BannerCenter()
|
||||||
|
center.postSkippedOnOpen([])
|
||||||
|
#expect(center.losses.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An ordinary loss row still carries no reveal control")
|
||||||
|
func ordinaryLossRowsAreUnchanged() throws {
|
||||||
|
let center = BannerCenter()
|
||||||
|
center.postSkippedFolders(count: 2)
|
||||||
|
let loss = try #require(center.losses.first)
|
||||||
|
#expect(loss.reveals.isEmpty)
|
||||||
|
#expect(BannerRow.loss(loss).controls.map(\.label) == ["Dismiss"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The attendance branch
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Decision surface ▸ the attendance branch")
|
||||||
|
struct BoardDecisionSurfaceAttendanceTests {
|
||||||
|
|
||||||
|
private func aggregate(_ reason: BoardLoadError.Reason, path: String = "index.md") -> BoardLoadFailure {
|
||||||
|
BoardLoadFailure(BoardLoadError(path: path, reason: reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A restored open never reaches the surface, whatever is wrong")
|
||||||
|
func restoredAlwaysRetires() {
|
||||||
|
// "restoration failures keep the retire-to-welcome-row landing … launch never chains dialogs".
|
||||||
|
let repairable = aggregate(.missingSchema)
|
||||||
|
let handEdit = aggregate(.unparseableYAML(message: "x", line: 1), path: "lane/index.md")
|
||||||
|
#expect(BoardWindowHost.landing(for: repairable, origin: .restored) == .retire)
|
||||||
|
#expect(BoardWindowHost.landing(for: handEdit, origin: .restored) == .retire)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An attended open reaches the surface for anything a repair could touch")
|
||||||
|
func attendedDecides() {
|
||||||
|
#expect(BoardWindowHost.landing(for: aggregate(.missingSchema), origin: .attended) == .decide)
|
||||||
|
#expect(BoardWindowHost.landing(for: aggregate(.boardRootMissingIndex), origin: .attended) == .decide)
|
||||||
|
#expect(BoardWindowHost.landing(for: aggregate(.schemaNewerThanApp(found: 9)), origin: .attended) == .decide)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An environmental failure retires even when attended")
|
||||||
|
func theEnvironmentalCarveOut() {
|
||||||
|
// Redesign Gap 87cd782a: there is nothing on disk to repair, so a surface would offer a
|
||||||
|
// decision with no choices in it. Welcome's row says the same thing in one line.
|
||||||
|
let gone = BoardLoadFailure(
|
||||||
|
BoardLoadError(path: ".", reason: .unreadableRoot(message: "no such file or directory")))
|
||||||
|
let file = BoardLoadFailure(BoardLoadError(path: ".", reason: .notADirectory))
|
||||||
|
#expect(BoardWindowHost.landing(for: gone, origin: .attended) == .retire)
|
||||||
|
#expect(BoardWindowHost.landing(for: file, origin: .attended) == .retire)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An environmental defect among repairable ones still shows the surface")
|
||||||
|
func aMixedAggregateStillDecides() {
|
||||||
|
// The carve-out is "nothing to repair", not "one of these is environmental": the rest of the
|
||||||
|
// aggregate is still actionable.
|
||||||
|
let mixed = BoardLoadFailure([
|
||||||
|
BoardLoadError(path: "index.md", reason: .missingSchema),
|
||||||
|
BoardLoadError(path: ".", reason: .notADirectory)
|
||||||
|
])
|
||||||
|
#expect(BoardWindowHost.landing(for: mixed, origin: .attended) == .decide)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The origin rides the pending open, attended by default")
|
||||||
|
func theOriginRidesThePendingOpen() throws {
|
||||||
|
let folder = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("DecisionOrigin-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: folder) }
|
||||||
|
|
||||||
|
let model = AppModel(
|
||||||
|
registryStorageURL: folder.appendingPathComponent("board-registry.json"),
|
||||||
|
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
|
||||||
|
)
|
||||||
|
let attended = folder.appendingPathComponent("Attended.kanban", isDirectory: true)
|
||||||
|
let restored = folder.appendingPathComponent("Restored.kanban", isDirectory: true)
|
||||||
|
// The stash `openBoard(at:origin:)` makes — reached directly, because that method needs a
|
||||||
|
// SwiftUI `OpenWindowAction` no test can build (`AppModel.stashPendingOpen`).
|
||||||
|
model.stashPendingOpen(for: BoardWindowRef(url: attended), url: attended, origin: .attended)
|
||||||
|
model.stashPendingOpen(for: BoardWindowRef(url: restored), url: restored, origin: .restored)
|
||||||
|
|
||||||
|
let attendedClaim = model.claimPendingOpen(for: BoardWindowRef(url: attended))
|
||||||
|
#expect(attendedClaim.origin == .attended)
|
||||||
|
#expect(attendedClaim.access != nil, "the scope rides the same stash")
|
||||||
|
#expect(model.claimPendingOpen(for: BoardWindowRef(url: restored)).origin == .restored)
|
||||||
|
// Claiming removes it, and a window that arrived by some other route reads attended with no
|
||||||
|
// scope — the safe direction (the worst it can do is offer a repair nobody asked for).
|
||||||
|
let second = model.claimPendingOpen(for: BoardWindowRef(url: attended))
|
||||||
|
#expect(second.origin == .attended)
|
||||||
|
#expect(second.access == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The Pro repair commit
|
||||||
|
|
||||||
|
/// HEAD's first-parent ancestry, newest first — read through SwiftGitX, never through the committer
|
||||||
|
/// that made the commits (`AutoCommitTests`' rule, kept: nothing here shells out to `git`).
|
||||||
|
private func repairHistory(at boardRoot: URL, limit: Int = 8) throws -> [(subject: String, authorName: String, authorEmail: String, committerName: String)] {
|
||||||
|
let repository = try Repository.open(at: boardRoot)
|
||||||
|
guard !repository.isHEADUnborn, let tip = try repository.HEAD.target as? Commit else { return [] }
|
||||||
|
|
||||||
|
var records: [(String, String, String, String)] = []
|
||||||
|
var current: Commit? = tip
|
||||||
|
while let commit = current, records.count < limit {
|
||||||
|
records.append((commit.summary, commit.author.name, commit.author.email, commit.committer.name))
|
||||||
|
current = (try? commit.parents)?.first
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Decision surface ▸ the Pro repair commit")
|
||||||
|
struct BoardDecisionSurfaceRepairCommitTests {
|
||||||
|
|
||||||
|
/// An `AppModel` whose app-side state lives in temp rather than in the app's real Application
|
||||||
|
/// Support home, and which reads as Pro — `AutoCommitCompositionRootTests`' fixture.
|
||||||
|
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
|
||||||
|
let folder = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("DecisionRepairCommit-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
let model = AppModel(
|
||||||
|
registryStorageURL: folder.appendingPathComponent("board-registry.json"),
|
||||||
|
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
|
||||||
|
)
|
||||||
|
model.currentTier = { .pro }
|
||||||
|
return (model, { try? FileManager.default.removeItem(at: folder) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **A repaired Pro board's first flush is one heal commit, authored by the integrity identity**
|
||||||
|
/// (01-storage-format.md § Malformed input: "On Pro boards the repairs drop heal-marked receipts
|
||||||
|
/// and commit separately as one repair commit, never folded into anyone else's work";
|
||||||
|
/// 06-history-undo.md ▸ Commit messages ▸ Healing mutations commit separately).
|
||||||
|
///
|
||||||
|
/// The whole chain is exercised end to end, because every link in it can fail silently and the
|
||||||
|
/// symptom is identical each time — a commit blaming the outside world for the app's own repair:
|
||||||
|
///
|
||||||
|
/// 1. The surface's default resolution is the minted repair.
|
||||||
|
/// 2. `BoardRepairRun` writes it store-lessly and marks every receipt in its own ledger.
|
||||||
|
/// 3. The store built by the following walk **adopts** that ledger (`EchoLedger.adopt`) —
|
||||||
|
/// before `beginSession`, which is where Pro's committer is composed and started.
|
||||||
|
/// 4. `GitAutoCommitter.start()` harvests, so the debounce it arms can see receipts that were
|
||||||
|
/// dropped before any write bracket of this session existed.
|
||||||
|
/// 5. `CommitAttribution.split` sorts the repaired path into the heal class, and the heal class
|
||||||
|
/// is authored `Lanework Integrity <integrity@lanework.invalid>` with the user as committer.
|
||||||
|
@Test("A repaired board under Pro + git produces a separate heal commit")
|
||||||
|
func aRepairCommitsAsTheIntegrityIdentity() async throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
// A board whose root is missing `schema` — the minted stamp's own case. The lane is here so
|
||||||
|
// the repository has an ordinary tree around the file being repaired.
|
||||||
|
try fixture.item("", "---\ntitle: Needs A Stamp\ncreated: 2026-01-01T09:00:00Z\n---\nBoard.\n")
|
||||||
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\n---\n")
|
||||||
|
|
||||||
|
let (model, tearDown) = try makeModel()
|
||||||
|
defer { tearDown() }
|
||||||
|
|
||||||
|
// The repository, with everything as it stands committed — including the broken root, which is
|
||||||
|
// what makes the repair a real change rather than a fresh file.
|
||||||
|
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, ledger: EchoLedger()))
|
||||||
|
#expect(await git.addGit())
|
||||||
|
let commitsBefore = try repairHistory(at: fixture.root).count
|
||||||
|
git.stopAutoCommit()
|
||||||
|
|
||||||
|
// — The surface, exactly as the window builds it.
|
||||||
|
let surface = BoardDecisionSurfaceModel(failure: try failure(of: fixture), boardRoot: fixture.root)
|
||||||
|
#expect(surface.canRepairAndOpen, "a lone missing root schema is a minted repair, preselected")
|
||||||
|
|
||||||
|
// — Repair and Open's write half.
|
||||||
|
let outcome = BoardRepairRun.apply(surface.plannedRepairs, boardRoot: fixture.root)
|
||||||
|
#expect(outcome.failure == nil)
|
||||||
|
|
||||||
|
// — The walk that follows, and the store it builds. Built directly rather than through the
|
||||||
|
// registry so this case is about the repair's commit and not about the open's *other* heals
|
||||||
|
// (the agent guide, the `.gitignore` seed), which the registry's acquire also fires.
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root, skipping: surface.skipSet)
|
||||||
|
let store = BoardStore(rootURL: fixture.root, loaded: result, skipping: surface.skipSet)
|
||||||
|
// — The adoption, before the session composes the committer.
|
||||||
|
store.echoes.adopt(outcome.ledger)
|
||||||
|
|
||||||
|
let ref = BoardWindowRef(url: fixture.root)
|
||||||
|
let recordID = model.boardRegistry.recordOpen(of: fixture.root)
|
||||||
|
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
|
||||||
|
|
||||||
|
let committer = try #require(model.session(for: ref)?.git?.committer)
|
||||||
|
// The debounce `start()` armed is not what this asserts; the explicit flush is.
|
||||||
|
committer.stop()
|
||||||
|
committer.debounceInterval = .seconds(30)
|
||||||
|
committer.coveringSnapshotDeadline = .milliseconds(50)
|
||||||
|
committer.coveringSnapshotPollInterval = .milliseconds(5)
|
||||||
|
await committer.flushNow()
|
||||||
|
|
||||||
|
let log = try repairHistory(at: fixture.root)
|
||||||
|
#expect(log.count == commitsBefore + 1, "one repair commit, never folded and never split further")
|
||||||
|
|
||||||
|
let head = try #require(log.first)
|
||||||
|
#expect(head.authorName == CommitAttribution.integrityAuthorName)
|
||||||
|
#expect(head.authorEmail == CommitAttribution.integrityAuthorEmail)
|
||||||
|
#expect(
|
||||||
|
head.authorEmail != CommitAttribution.externalAuthorEmail,
|
||||||
|
"the app's own repair must never be blamed on the outside world"
|
||||||
|
)
|
||||||
|
// "the committer stays the user (the recorded-by convention)".
|
||||||
|
#expect(head.committerName == GitCommitOperation.userIdentity(at: fixture.root).name)
|
||||||
|
#expect(GitCommitOperation.changedPaths(at: fixture.root).isEmpty, "the flush leaves nothing dirty")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,29 +3,35 @@ import XCTest
|
|||||||
/// **A board that will not load, from the outside** (01-storage-format.md § Malformed input;
|
/// **A board that will not load, from the outside** (01-storage-format.md § Malformed input;
|
||||||
/// 02-architecture.md § Launch and window lifecycle).
|
/// 02-architecture.md § Launch and window lifecycle).
|
||||||
///
|
///
|
||||||
/// ### The claim
|
/// ### The claims, as they stand after the decision surface
|
||||||
///
|
|
||||||
/// > A restored board that fails surfaces on welcome, row-level: its window doesn't open; welcome
|
|
||||||
/// > appears alongside whatever did restore, the failed board's recents row carrying fail-fast's
|
|
||||||
/// > specifics (load error) … never a silent drop.
|
|
||||||
///
|
///
|
||||||
/// The `malformed` fixture is a well-formed board with exactly one unparseable card `index.md`
|
/// The `malformed` fixture is a well-formed board with exactly one unparseable card `index.md`
|
||||||
/// (`UITestLaunch.malformedIndexText` — a frontmatter flow sequence that is never closed). Building
|
/// (`UITestLaunch.malformedIndexText` — a frontmatter flow sequence that is never closed). Building
|
||||||
/// it succeeds; loading it must not, and *how* it fails is the whole of this file:
|
/// it succeeds; loading it must not, and *how* it fails is the whole of this file:
|
||||||
///
|
///
|
||||||
/// 1. **No board window.** Not an empty one, not one with the good lanes in it — fail-fast is
|
/// 1. **The window stays, and explains itself.** The fixture opens through `AppModel.openBoard`, which
|
||||||
/// all-or-nothing, so a partial board on screen would be the worse failure.
|
/// is an **attended** open — and 01 (settled 2026-07-31) makes an attended refusal the decision
|
||||||
/// 2. **Welcome, loudly.** The recents row for that board wears the loader's own sentence, which
|
/// surface's case: "one aggregated surface hosted by the pre-snapshot loading window — the loading
|
||||||
/// names the offending file. A row that fell back to "Unavailable", or to a count, would be the
|
/// content transforms in place, never a sheet over a spinner". So there *is* a window, it is the
|
||||||
/// app declining to say what it found.
|
/// one that was opening, and what it shows names the offending file.
|
||||||
/// 3. **Nothing repaired.** The bytes on disk are the bytes the fixture wrote. The loader is a pure
|
/// 2. **No board behind it.** Fail-fast is all-or-nothing: the surface is a decision, not a partial
|
||||||
/// function of the tree and writes nothing, ever — the Repair precedent — so a board it refused
|
/// board, so the good lanes are not on screen underneath it.
|
||||||
/// must still be refusable, byte for byte.
|
/// 3. **Cancel is the old landing, on demand.** "**Cancel** aborts the open: the window retires and
|
||||||
|
/// the board lands row-level on welcome, record-before-load unchanged" — which is the sequence
|
||||||
|
/// this file used to assert *unconditionally*, now reached by the user's own choice.
|
||||||
|
/// 4. **Nothing repaired.** The bytes on disk are the bytes the fixture wrote. The loader is a pure
|
||||||
|
/// function of the tree and writes nothing, ever — the Repair precedent — and the surface writes
|
||||||
|
/// only what the user chooses, which here is nothing.
|
||||||
|
///
|
||||||
|
/// **What changed and why**: claims 1 and 2 used to read "no board window, welcome instead". That was
|
||||||
|
/// the whole landing for *every* refusal before this milestone; it is now the **restored** landing
|
||||||
|
/// (`RestoreBootstrapView`) and the environmental one. The fixture launch is neither — it opens a
|
||||||
|
/// board the way a double-click does.
|
||||||
///
|
///
|
||||||
/// ### Where each claim is checked
|
/// ### Where each claim is checked
|
||||||
///
|
///
|
||||||
/// The first two are here, because they are about *windows* and a window is what a unit test does not
|
/// The first three are here, because they are about *windows* and a window is what a unit test does
|
||||||
/// have. The third is checked **both** here and in `KanbanTests` — unconditionally there
|
/// not have. The fourth is checked **both** here and in `KanbanTests` — unconditionally there
|
||||||
/// (`UITestMalformedFixtureBoardTests`, which builds the fixture and re-reads the tree), and
|
/// (`UITestMalformedFixtureBoardTests`, which builds the fixture and re-reads the tree), and
|
||||||
/// opportunistically here, because reaching the app's container from the runner depends on how the
|
/// opportunistically here, because reaching the app's container from the runner depends on how the
|
||||||
/// app under test was signed and installed. Where the container is not reachable this file says so
|
/// app under test was signed and installed. Where the container is not reachable this file says so
|
||||||
@@ -37,47 +43,107 @@ final class FailFastLaunchTests: XCTestCase {
|
|||||||
continueAfterFailure = false
|
continueAfterFailure = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The whole of claims 1 and 2, in one launch: no board window, welcome instead, and the row
|
/// Claims 1 and 2: the surface appears in the board's own window, names the file, and shows no
|
||||||
/// carrying the loader's specifics.
|
/// board.
|
||||||
@MainActor
|
@MainActor
|
||||||
func testMalformedBoardFailsLoudlyAndOpensNoWindow() throws {
|
func testMalformedBoardShowsTheDecisionSurface() throws {
|
||||||
let app = XCUIApplication.launched(with: .malformed)
|
let app = XCUIApplication.launched(with: .malformed)
|
||||||
|
|
||||||
// Welcome is where a failed open lands (`BoardWindowHost.start`: record the failure, refresh
|
// The surface, by the identifier it carries for exactly this
|
||||||
// the recents, open welcome, dismiss the board window).
|
// (`BoardDecisionSurface.accessibilityIdentifier`).
|
||||||
|
let surface = app.descendants(matching: .any)["decision-surface"]
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
app.windows["Welcome to Lanework"].waitForExistence(timeout: XCUIApplication.uiTimeout),
|
surface.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||||
"the welcome window did not appear after a failed open"
|
"an attended open of a refusing board did not transform into the decision surface"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Claim 1. Checked *after* welcome has appeared, so this is "the board window never came",
|
// It is the *opening window* that hosts it — the loading content transformed in place, so the
|
||||||
// not "the board window has not come yet".
|
// window is still called what the loading state called it.
|
||||||
XCTAssertFalse(
|
XCTAssertTrue(
|
||||||
app.windows[FixtureBoard.malformed.windowTitle].exists,
|
app.windows[FixtureBoard.malformed.windowTitle].exists,
|
||||||
"a board window opened for a board the loader rejected"
|
"the surface is not in the board's own window"
|
||||||
|
)
|
||||||
|
XCTAssertFalse(
|
||||||
|
app.windows["Welcome to Lanework"].exists,
|
||||||
|
"an attended refusal must not retire — that is the restored landing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Claim 2. The row is one combined accessibility element — name, location, caption — and the
|
// The class section for unparseable frontmatter, and the specifics on its row. The UUIDs in
|
||||||
// caption is `BoardLoadError.description`: "⟨lane⟩/⟨card⟩/index.md: unparseable YAML at line
|
// the path are minted at launch and unknowable here, so the assertion is on the parts that are
|
||||||
// N: …". The UUIDs in that path are minted at launch and unknowable here, so the assertion is
|
// the *app's* to keep stable: the offending file is named, and the reason is stated.
|
||||||
// on the parts that are the *app's* to keep stable: the offending file is named, and the
|
|
||||||
// reason is stated.
|
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
app.element(labelContaining: "index.md").waitForExistence(timeout: XCUIApplication.uiTimeout),
|
app.descendants(matching: .any)["decision-section-unreadable-frontmatter"].exists,
|
||||||
"no welcome row named the offending index.md — fail-fast's specifics did not reach the surface"
|
"the defect was not grouped into its class section"
|
||||||
|
)
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.element(labelContaining: "index.md").exists,
|
||||||
|
"no row named the offending index.md — fail-fast's specifics did not reach the surface"
|
||||||
)
|
)
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
app.element(labelContaining: "unparseable YAML").exists,
|
app.element(labelContaining: "unparseable YAML").exists,
|
||||||
"the welcome row did not say why the board was refused"
|
"the row did not say what is wrong with the file"
|
||||||
)
|
)
|
||||||
// And it is the malformed board's own row that says it.
|
|
||||||
|
// Claim 2: a decision, not a partial board. The fixture's intact lane is not on screen.
|
||||||
|
XCTAssertFalse(
|
||||||
|
app.element(labelContaining: "A good card").exists,
|
||||||
|
"a partial board rendered behind the surface — fail-fast is all-or-nothing"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claim 3: Cancel is the old landing, reached by the user's own choice — the window retires and
|
||||||
|
/// the board lands row-level on welcome with fail-fast's specifics on it.
|
||||||
|
@MainActor
|
||||||
|
func testCancelRetiresToWelcomeWithTheSpecifics() throws {
|
||||||
|
let app = XCUIApplication.launched(with: .malformed)
|
||||||
|
|
||||||
|
// Queried across every element type rather than as `app.buttons[...]`: the surface's footer
|
||||||
|
// sits inside a `.contain` accessibility group, and which AX type a SwiftUI `Button` lands on
|
||||||
|
// there is not something a test should be asserting about in passing.
|
||||||
|
let cancel = app.descendants(matching: .any)["decision-cancel"]
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
app.element(labelContaining: FixtureBoard.malformed.windowTitle).exists,
|
cancel.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||||
|
"the surface did not appear, so Cancel could not be pressed"
|
||||||
|
)
|
||||||
|
cancel.click()
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.windows["Welcome to Lanework"].waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||||
|
"Cancel did not land on welcome"
|
||||||
|
)
|
||||||
|
XCTAssertFalse(
|
||||||
|
app.windows[FixtureBoard.malformed.windowTitle].exists,
|
||||||
|
"the window did not retire"
|
||||||
|
)
|
||||||
|
// The row is one combined accessibility element — name, location, caption — and the caption is
|
||||||
|
// `BoardLoadFailure.description`. Matched on *value* as well as label: welcome's rows are
|
||||||
|
// list cells whose combined text lands in the AX value, not in a label (see
|
||||||
|
// `Self.element(textContaining:in:)`).
|
||||||
|
XCTAssertTrue(
|
||||||
|
Self.element(textContaining: "unparseable YAML", in: app)
|
||||||
|
.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||||
|
"the welcome row did not carry fail-fast's specifics"
|
||||||
|
)
|
||||||
|
XCTAssertTrue(
|
||||||
|
Self.element(textContaining: FixtureBoard.malformed.windowTitle, in: app).exists,
|
||||||
"the failure did not land on the failed board's row"
|
"the failure did not land on the failed board's row"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Claim 3, twice over: the malformed bytes survive the refusal, and a second launch is refused
|
/// The first element whose **label or value** contains `fragment`.
|
||||||
|
///
|
||||||
|
/// `XCUIApplication.element(labelContaining:)` matches the label alone, which is the right question
|
||||||
|
/// for the decision surface's own rows (each is one combined element with an explicit
|
||||||
|
/// accessibility label) and the wrong one for welcome's recents rows: those are list cells whose
|
||||||
|
/// name-location-caption text arrives as the cell's AX *value*.
|
||||||
|
@MainActor
|
||||||
|
private static func element(textContaining fragment: String, in app: XCUIApplication) -> XCUIElement {
|
||||||
|
app.descendants(matching: .any)
|
||||||
|
.matching(NSPredicate(format: "label CONTAINS %@ OR value CONTAINS %@", fragment, fragment))
|
||||||
|
.firstMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claim 4, twice over: the malformed bytes survive the refusal, and a second launch is refused
|
||||||
/// the same way rather than opening a board the app quietly fixed.
|
/// the same way rather than opening a board the app quietly fixed.
|
||||||
///
|
///
|
||||||
/// The relaunch is not redundant with the byte check — it is what the byte check *means* from the
|
/// The relaunch is not redundant with the byte check — it is what the byte check *means* from the
|
||||||
@@ -86,8 +152,8 @@ final class FailFastLaunchTests: XCTestCase {
|
|||||||
func testMalformedBoardIsNeverRepaired() throws {
|
func testMalformedBoardIsNeverRepaired() throws {
|
||||||
let app = XCUIApplication.launched(with: .malformed)
|
let app = XCUIApplication.launched(with: .malformed)
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
app.windows["Welcome to Lanework"].waitForExistence(timeout: XCUIApplication.uiTimeout),
|
app.descendants(matching: .any)["decision-surface"].waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||||
"the welcome window did not appear after a failed open"
|
"the surface did not appear"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The bytes, where the runner can reach them. `NSTemporaryDirectory()` inside the sandboxed
|
// The bytes, where the runner can reach them. `NSTemporaryDirectory()` inside the sandboxed
|
||||||
@@ -101,7 +167,7 @@ final class FailFastLaunchTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
malformed.contains("order: [1024"),
|
malformed.contains("order: [1024"),
|
||||||
"the malformed frontmatter was repaired — fail-fast must not write"
|
"the malformed frontmatter was repaired — nothing here mints a rewrite of unparseable YAML"
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Not a failure, and not silence either: the run says which half of the claim it made.
|
// Not a failure, and not silence either: the run says which half of the claim it made.
|
||||||
@@ -120,16 +186,12 @@ final class FailFastLaunchTests: XCTestCase {
|
|||||||
app.terminate()
|
app.terminate()
|
||||||
let second = XCUIApplication.launched(with: .malformed)
|
let second = XCUIApplication.launched(with: .malformed)
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
second.windows["Welcome to Lanework"].waitForExistence(timeout: XCUIApplication.uiTimeout),
|
second.descendants(matching: .any)["decision-surface"].waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||||
"the second launch did not reach welcome"
|
|
||||||
)
|
|
||||||
XCTAssertFalse(
|
|
||||||
second.windows[FixtureBoard.malformed.windowTitle].exists,
|
|
||||||
"the second launch opened the board the first one refused"
|
"the second launch opened the board the first one refused"
|
||||||
)
|
)
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
second.element(labelContaining: "index.md").waitForExistence(timeout: XCUIApplication.uiTimeout),
|
second.element(labelContaining: "unparseable YAML").exists,
|
||||||
"the second launch did not name the offending file"
|
"the second launch did not name the same defect"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ enum FixtureBoard {
|
|||||||
/// lot of it.
|
/// lot of it.
|
||||||
case large
|
case large
|
||||||
|
|
||||||
/// A well-formed board with exactly one unparseable card `index.md`, for the fail-fast pass. It
|
/// A well-formed board with exactly one unparseable card `index.md`, for the fail-fast pass. Its
|
||||||
/// is the only variant whose board window is *expected* never to appear.
|
/// window is the only one that never holds a board: the open is attended, so the refusal
|
||||||
|
/// transforms the loading content into the decision surface (`FailFastLaunchTests`).
|
||||||
case malformed
|
case malformed
|
||||||
|
|
||||||
/// `UITestLaunch.fixtureFlag` plus this variant's own flag. Both, always: the first is what "this
|
/// `UITestLaunch.fixtureFlag` plus this variant's own flag. Both, always: the first is what "this
|
||||||
@@ -149,8 +150,10 @@ extension XCUIApplication {
|
|||||||
/// Launches the app on `board` — the audit fixture unless told otherwise — and waits for its
|
/// Launches the app on `board` — the audit fixture unless told otherwise — and waits for its
|
||||||
/// window.
|
/// window.
|
||||||
///
|
///
|
||||||
/// **Not for `.malformed`**, which has no window to wait for: that variant is launched with
|
/// **Not for `.malformed`**, whose window never holds a board: since the decision surface (01
|
||||||
/// `launched(with:)` and the caller waits for welcome instead.
|
/// § Malformed input, settled 2026-07-31) that variant's window does appear — carrying the
|
||||||
|
/// surface rather than lanes — so the wait below would pass while saying nothing. That variant is
|
||||||
|
/// launched with `launched(with:)` and the caller waits for the surface instead.
|
||||||
@MainActor
|
@MainActor
|
||||||
static func launchedWithFixtureBoard(_ board: FixtureBoard = .standard) -> XCUIApplication {
|
static func launchedWithFixtureBoard(_ board: FixtureBoard = .standard) -> XCUIApplication {
|
||||||
let app = launched(with: board)
|
let app = launched(with: board)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Lanework is in early development. This list tracks what has actually shipped and
|
|||||||
- **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and a reserved `.trash/` container read by the very same card parse the lanes use — below the board root `order` and `schema` are optional (order-less items read append-at-end until a write stamps them), and the board-root `.gitignore`, seeded on every board, is the one noise gate the loose-file relocation heal obeys. One walk reports **every** fail-fast defect at once rather than stopping at the first — the root's own, then each lane and card in walk order, a broken lane's subtree left for the repair's re-check — so a refusal is one aggregate instead of a chain of them, and a per-open set of skipped paths (never persisted, never accepted at the root) loads the board without exactly the items the user chose to leave out. Pinned by a golden fixture suite of 17 on-disk boards.
|
- **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and a reserved `.trash/` container read by the very same card parse the lanes use — below the board root `order` and `schema` are optional (order-less items read append-at-end until a write stamps them), and the board-root `.gitignore`, seeded on every board, is the one noise gate the loose-file relocation heal obeys. One walk reports **every** fail-fast defect at once rather than stopping at the first — the root's own, then each lane and card in walk order, a broken lane's subtree left for the repair's re-check — so a refusal is one aggregate instead of a chain of them, and a per-open set of skipped paths (never persisted, never accepted at the root) loads the board without exactly the items the user chose to leave out. Pinned by a golden fixture suite of 17 on-disk boards.
|
||||||
- **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes move a card's folder into the board's reserved `.trash/` at a caller-minted top rank, restore is the ordinary move back out, and purge is physical; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting. Strays are preserved verbatim everywhere with exactly one carve-out: a loose *file* dropped beside a card's `index.md` belongs in that card's `attachments/`, so the app moves it there — Finder-renamed on collision, byte-faithfully, without touching `index.md` — and says so in a dismissable warning row naming the card and the files. Detection stays read-only in the loader; the move is an ordinary bracketed write that waits out the read-only lock and never retries a failure in a loop. Stray *folders*, symlinks, and everything at board or lane level keep the verbatim posture untouched, and a paste normalizes at the import boundary so a pasted card lands already tidy. The one other scope on that promise is the handful of board-root names the app claims: a file or symlink squatting `.trash/`, a folder squatting `CLAUDE.md`, is moved aside by the same Finder-style ladder rather than deleted or worked around, since deletion is broken while it stands.
|
- **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes move a card's folder into the board's reserved `.trash/` at a caller-minted top rank, restore is the ordinary move back out, and purge is physical; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting. Strays are preserved verbatim everywhere with exactly one carve-out: a loose *file* dropped beside a card's `index.md` belongs in that card's `attachments/`, so the app moves it there — Finder-renamed on collision, byte-faithfully, without touching `index.md` — and says so in a dismissable warning row naming the card and the files. Detection stays read-only in the loader; the move is an ordinary bracketed write that waits out the read-only lock and never retries a failure in a loop. Stray *folders*, symlinks, and everything at board or lane level keep the verbatim posture untouched, and a paste normalizes at the import boundary so a pasted card lands already tidy. The one other scope on that promise is the handful of board-root names the app claims: a file or symlink squatting `.trash/`, a folder squatting `CLAUDE.md`, is moved aside by the same Finder-style ladder rather than deleted or worked around, since deletion is broken while it stands.
|
||||||
- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo, and a per-board **write-provenance ledger** — in-memory, dying with the session — records a content hash, an absence marker or an old→new pair for every file the app writes, so a landing reload can tell its own echo from an outside edit file by file (final content decides: byte-identical is the app's, one byte different is somebody else's); a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state.
|
- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo, and a per-board **write-provenance ledger** — in-memory, dying with the session — records a content hash, an absence marker or an old→new pair for every file the app writes, so a landing reload can tell its own echo from an outside edit file by file (final content decides: byte-identical is the app's, one byte different is somebody else's); a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state.
|
||||||
- **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded, then per-card frame memory once you've placed one; follows its card across lanes; dismisses the moment its card leaves the board — into the trash, with its deleted lane, purged, or moved to another board). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted.
|
- **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded, then per-card frame memory once you've placed one; follows its card across lanes; dismisses the moment its card leaves the board — into the trash, with its deleted lane, purged, or moved to another board). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted. Every open passes through a real loading window — it appears immediately at its saved frame under the registry's cached name, its content a centered spinner behind a ~200 ms grace, while the tree walk runs off the main actor (one walk per board however many windows ask at once), and ⌘W during the walk genuinely cancels it. When an **attended** open refuses, that same content area transforms in place into the **decision surface** — never a sheet, never a chained dialog: the walk's defects grouped by class, each class stating the problem once and listing the affected files with Reveal in Finder and Open in Editor, one class-level choice preselected to its default and a per-file override behind a disclosure. Only honest choices are offered — unparseable frontmatter gets Open in Editor and Re-check (or Skip below the root), a file from a newer Lanework gets Skip alone (and blocks the whole board at the root), a board root with no `index.md` gets a minted index, a root with no `schema` gets a `schema: 1` stamp. Repair and Open applies every chosen fix in one write bracket — ordinary app writes, and on Pro boards a separate heal-authored repair commit — then re-runs the whole walk, opening on a clean result and re-aggregating into the *same* surface otherwise; Re-check re-walks without writing; Cancel (and ⌘W) retires to welcome's row. Skips are per-open consent that rides the session and is never persisted, and the opened board carries a warning-tone notice naming what was left out, each with its own Reveal in Finder. Restoration failures keep the retire-to-welcome-row landing — repair is an attended act, and launch never chains dialogs.
|
||||||
- **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the drag surface, a plain click selecting the lane and movement carrying it away.
|
- **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the drag surface, a plain click selecting the lane and movement carrying it away.
|
||||||
- **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced). A card has one presentation: selection changes only its styling, never its geometry, so the masonry never reflows on a click — the paperclip chip is the face's whole attachment story, and viewing the files themselves is the card window's job.
|
- **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced). A card has one presentation: selection changes only its styling, never its geometry, so the masonry never reflows on a click — the paperclip chip is the face's whole attachment story, and viewing the files themselves is the card window's job.
|
||||||
- **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a target that is trashed or deleted discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock.
|
- **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a target that is trashed or deleted discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock.
|
||||||
|
|||||||
Reference in New Issue
Block a user