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:
@@ -43,6 +43,34 @@ enum AccessibilityPhrases {
|
||||
/// would leave that window silent.
|
||||
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
|
||||
/// `TrashModel.phrase` rather than restated so a lane's spoken count and the trash column's
|
||||
/// cannot drift apart.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// 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)
|
||||
.buttonStyle(.link)
|
||||
.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):
|
||||
Button {
|
||||
onDismiss(id)
|
||||
@@ -230,6 +251,14 @@ private struct BannerRowView: View {
|
||||
.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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user