The tier axis comes out of the git stack — compose unconditional, postures mode-driven
HistoryStore.compose(boardRoot📒) returns non-optional and runs for every session — the nil the gate produced was the only nil it ever had. makeHistoryProvider is a one-axis decision: git-mode boards bind the git provider, everything else native, in every tier; Session.tier stays recorded, dormant. BoardGitSection shrinks to the four mode postures (.absent and .proPointer die, BoardGitNote and the .git probe with them); every board carries all three popover tabs (BoardInfoTab.available retired); the titlebar branch shows on any git-mode board; the settings sheet and card History section stop reading tier. InertGitTests is repurposed as UntouchedGitTests — the file layer still never opens .git, now load-bearing for mode-none boards. The accessibility audit reaches the settings sheet at last: the fixture board hosts it in every tier, so the free-fixture disabled-row test becomes an open-and-audit test. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
+159
-141
@@ -335,14 +335,20 @@ public final class AppModel {
|
||||
|
||||
/// **The tier a board session composes under**, as an injectable seam.
|
||||
///
|
||||
/// Defaulted to the real entitlement's local read and separated from it for `makeHistoryProvider`'s
|
||||
/// reason exactly: a test binds a tier without needing a StoreKit transaction, an App Store
|
||||
/// account, or a second `AppModel` initializer. `@MainActor` on the closure type because the
|
||||
/// entitlement it reads is main-actor state, and `@ObservationIgnored` because nothing renders
|
||||
/// from it — the tier reaches the UI, where it reaches it at all, through `entitlement`.
|
||||
/// Defaulted to the real entitlement's local read and separated from it so a test binds a tier
|
||||
/// without needing a StoreKit transaction, an App Store account, or a second `AppModel`
|
||||
/// initializer. `@MainActor` on the closure type because the entitlement it reads is main-actor
|
||||
/// state, and `@ObservationIgnored` because nothing renders from it — the tier reaches the UI,
|
||||
/// where it reaches it at all, through `entitlement`.
|
||||
///
|
||||
/// **Read once per session, at composition, and never again** (12 ▸ The entitlement: "a lapse
|
||||
/// never interrupts an open session"). `beginSession` is the only caller.
|
||||
///
|
||||
/// **Dormant since PIVOT 2026-08-07** (12-editions.md — git left the paywall): the answer is
|
||||
/// still read and still recorded on the session (`BoardSession.tier`), and **nothing git-shaped
|
||||
/// consults it any more** — `HistoryStore.compose` and `makeHistoryProvider` both lost the axis.
|
||||
/// The seam is kept unchanged, mechanics and all, because the entitlement is correct for
|
||||
/// whatever the next base/Pro split turns out to gate; what it does not do is decide undo.
|
||||
@ObservationIgnored
|
||||
public var currentTier: @MainActor () -> Tier = { .free }
|
||||
|
||||
@@ -351,12 +357,14 @@ public final class AppModel {
|
||||
/// **The composition root for `HistoryProviding`** (12-editions.md ▸ The provider seam): what a
|
||||
/// board session's undo stack is built by, called once per board as its session begins.
|
||||
///
|
||||
/// **The provider follows the board, not the tier alone** (re-ruled 2026-07-31 — 12 ▸ The
|
||||
/// provider seam; 13-native-undo.md's header; 06 ▸ Rules): a board's substrate is decided by what
|
||||
/// the board *is*, and the tier only decides whether git is on the table at all. The rule it
|
||||
/// replaced bound the native stack free-tier-wide and nothing at all on Pro's gitless boards,
|
||||
/// which made subscribing *remove* undo from a mode-none board — an upgrade that takes a feature
|
||||
/// away.
|
||||
/// **The provider follows the board — and, since PIVOT 2026-08-07, the board alone**
|
||||
/// (re-ruled 2026-07-31 — 12 ▸ The provider seam; 13-native-undo.md's header; 06 ▸ Rules; then
|
||||
/// 12 ▸ PIVOT 2026-08-07, git leaves the paywall). A board's substrate is decided by what the
|
||||
/// board *is*. The 2026-07-31 re-ruling took the tier out of every row but one — it still decided
|
||||
/// whether git was on the table at all — and the pivot took it out of that one too: "every tier
|
||||
/// composes the git stack on git-mode boards exactly as Pro did". The rule the re-ruling replaced
|
||||
/// bound the native stack free-tier-wide and nothing at all on Pro's gitless boards, which made
|
||||
/// subscribing *remove* undo from a mode-none board — an upgrade that takes a feature away.
|
||||
///
|
||||
/// It takes the store because that is what a provider is a history *of*: the git provider needs
|
||||
/// the board root it is a repository at, and the native one's steps are computed from the same
|
||||
@@ -364,21 +372,24 @@ public final class AppModel {
|
||||
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
|
||||
/// nothing renders from it.
|
||||
///
|
||||
/// ### The two answers, and the `nil` that is no longer one of them
|
||||
/// ### The two answers, and the two `nil`s that are no longer among them
|
||||
///
|
||||
/// - **No `HistoryStore` at all** — the free tier, where `HistoryStore.compose` returns `nil`
|
||||
/// without so much as a `stat`: the **native stack, on every board**. "The free tier binds it
|
||||
/// everywhere (any `.git` inert)" (13), and 12 ▸ The free tier and `.git` names the boards that
|
||||
/// covers by hand — "a formerly-subscribed user's board, a 1.x board, a repo-nested board …
|
||||
/// native undo runs". The absent git state *is* the tier test; nothing here reads a flag.
|
||||
/// - **Mode `git`** (Pro only — no other tier composes a git state) — the git provider: undo as
|
||||
/// forward restore commits over HEAD's first-parent ancestry (06).
|
||||
/// - **Mode `none`, `repoNested`, and `unverifiable` alike** — the **native stack**, exactly as
|
||||
/// in the free tier. "Boards without app-managed git — repo-nested included — bind
|
||||
/// 13-native-undo.md's native stack in **every** tier" (03-board-ui.md ▸ Toolbar ▸ Catalog,
|
||||
/// re-ruled 2026-07-31 twice; 12 ▸ The provider seam; 13's header). `unverifiable` joins the
|
||||
/// same branch structurally — a denial can never be told apart from a repository actually
|
||||
/// being there, so it takes `repoNested`'s posture, undo included (06 ▸ Rules ▸ Detection).
|
||||
/// - **Mode `git`** — the git provider: undo as forward restore commits over HEAD's first-parent
|
||||
/// ancestry (06). **In every tier** since the pivot; this row used to read "Pro only — no other
|
||||
/// tier composes a git state", because `HistoryStore.compose` answered `nil` off Pro without so
|
||||
/// much as a `stat`.
|
||||
/// - **Mode `none`, `repoNested`, and `unverifiable` alike** — the **native stack**. "Boards
|
||||
/// without app-managed git — repo-nested included — bind 13-native-undo.md's native stack in
|
||||
/// **every** tier" (03-board-ui.md ▸ Toolbar ▸ Catalog, re-ruled 2026-07-31 twice; 12 ▸ The
|
||||
/// provider seam; 13's header). `unverifiable` joins the same branch structurally — a denial
|
||||
/// can never be told apart from a repository actually being there, so it takes `repoNested`'s
|
||||
/// posture, undo included (06 ▸ Rules ▸ Detection). This is where a board nobody opted git in
|
||||
/// for lands, permanently: opt-in per board is exactly what the pivot left standing (13's
|
||||
/// header, read through 12 ▸ PIVOT 2026-08-07).
|
||||
/// - **No `HistoryStore` at all** — the argument stays optional for the seam's own reason (a test
|
||||
/// binds a git-stateless board through it), and no session composes one: `compose` returns a
|
||||
/// store for every board there is. It answers the native stack, which is what it always
|
||||
/// answered for the free tier that used to arrive this way.
|
||||
///
|
||||
/// **The repo-nested no-undo case is gone** (re-ruled 2026-07-31): 06's leave-strictly-alone
|
||||
/// stance "concerns *git*, and this stack never touches git — memory-only, journal-free,
|
||||
@@ -402,7 +413,7 @@ public final class AppModel {
|
||||
/// one event that changes a board's answer under an open session — add-git's commanded flip,
|
||||
/// which swaps `none`'s native stack for `git`'s trail.
|
||||
@ObservationIgnored
|
||||
public var makeHistoryProvider: (BoardStore, Tier, HistoryStore?) -> (any HistoryProviding)? = { store, _, git in
|
||||
public var makeHistoryProvider: (BoardStore, HistoryStore?) -> (any HistoryProviding)? = { store, git in
|
||||
guard let git else { return NativeHistoryProvider() }
|
||||
switch git.mode {
|
||||
case .git: return GitHistoryProvider(boardRoot: store.rootURL)
|
||||
@@ -429,8 +440,9 @@ public final class AppModel {
|
||||
/// window). It lives here for the store's reason exactly: the session is what every window
|
||||
/// over this board shares, and "undo is board-local".
|
||||
///
|
||||
/// Which implementation it is, is the tier's answer and nobody else's
|
||||
/// (12-editions.md ▸ The provider seam) — see `AppModel.makeHistoryProvider`.
|
||||
/// Which implementation it is, is the board's answer and nobody else's
|
||||
/// (12-editions.md ▸ The provider seam, read through ▸ PIVOT 2026-08-07) — see
|
||||
/// `AppModel.makeHistoryProvider`.
|
||||
///
|
||||
/// **`nil` is a board with no undo at all, and no board the app composes is one any more**
|
||||
/// (re-ruled 2026-07-31 — see `AppModel.makeHistoryProvider`): boards without app-managed git,
|
||||
@@ -443,33 +455,35 @@ public final class AppModel {
|
||||
/// A `var`, unlike `tier` beside it, and for one event only: **add-git**, the design's single
|
||||
/// sanctioned mid-session mode flip, *swaps* the substrate here on the board it flips —
|
||||
/// native out, git in, the in-session steps discarded with it
|
||||
/// (`bindHistoryProvider(for:)`). A tier lapse still cannot touch it — `tier` has no setter.
|
||||
/// (`bindHistoryProvider(for:)`). A tier lapse still cannot touch it — `tier` has no setter,
|
||||
/// and since PIVOT 2026-08-07 it has no say in this either.
|
||||
public var history: (any HistoryProviding)?
|
||||
|
||||
/// **The tier this board composed under** (12-editions.md ▸ The entitlement).
|
||||
/// **The tier this board composed under** (12-editions.md ▸ The entitlement) — recorded,
|
||||
/// and **dormant since PIVOT 2026-08-07**.
|
||||
///
|
||||
/// A `let`, on a value type, set once by `beginSession` — which is the entire mechanism
|
||||
/// behind "a lapse never interrupts an open session: an open board finishes with the provider
|
||||
/// it composed; the next open composes the native stack over inert `.git`". There is no
|
||||
/// setter, no observation, and nothing anywhere that re-evaluates a live session's tier: a
|
||||
/// subscription ending mid-session is a fact about the *next* open and about nothing that is
|
||||
/// already on screen.
|
||||
/// A `let`, on a value type, set once by `beginSession`. That is the entire mechanism behind
|
||||
/// "a lapse never interrupts an open session": there is no setter, no observation, and
|
||||
/// nothing anywhere that re-evaluates a live session's tier, so a subscription ending
|
||||
/// mid-session is a fact about the *next* open and about nothing already on screen.
|
||||
///
|
||||
/// It is recorded rather than merely used-and-discarded because the provider it selects is
|
||||
/// not the only thing that will ever ask. pro-m1's surfaces — the card window's History
|
||||
/// section, View ▸ History (12 ▸ Tier matrix) — are per-board questions asked long after
|
||||
/// composition, and they must get the answer this board actually opened with rather than
|
||||
/// whatever the entitlement happens to say when the sidebar renders.
|
||||
/// **Nothing git-shaped reads it.** Git left the paywall (12 ▸ PIVOT 2026-08-07), so the
|
||||
/// substrate, the committer, the branch controls and the History surfaces are all decided by
|
||||
/// the board's mode alone. It stays recorded because the entitlement's machinery stays built
|
||||
/// and correct for whatever the re-decided base/Pro split gates, and because the fact a board
|
||||
/// opened under is a composition-time answer the way the provider binding is: whatever asks
|
||||
/// next must get what this board opened with, never what the entitlement says at render time.
|
||||
public let tier: Tier
|
||||
|
||||
/// **This board's git state** (06-history-undo.md ▸ Rules; 02-architecture.md ▸ Components
|
||||
/// ▸ HistoryStore) — the detected mode, the repository behind it in git mode, and the
|
||||
/// add-git action the popover offers on a board that has none.
|
||||
///
|
||||
/// `nil` under the free tier, and that is the inert posture made structural rather than
|
||||
/// remembered: with no object there is nothing to consult, nothing to detect with, and no
|
||||
/// path by which a free-tier session could touch `.git` (12-editions.md ▸ The free tier and
|
||||
/// `.git`). `HistoryStore.compose` is the one place the tier decides it.
|
||||
/// **Never `nil` on a session the app composes**, since PIVOT 2026-08-07 (12-editions.md):
|
||||
/// `HistoryStore.compose` runs detection for every board in every tier and returns a store.
|
||||
/// It used to answer `nil` under the free tier — the inert-`.git` posture made structural
|
||||
/// rather than remembered — and that posture is retired. The optionality survives for the
|
||||
/// seam's sake alone: a test binds a git-stateless session through `makeHistoryProvider`.
|
||||
///
|
||||
/// A `let` beside `tier`, for `tier`'s reason: which board this is a git story *of* is
|
||||
/// settled at composition and cannot change under an open session. What can change is the
|
||||
@@ -477,15 +491,15 @@ public final class AppModel {
|
||||
public let git: HistoryStore?
|
||||
|
||||
/// The mode this board is being edited in, `none` when there is no git state at all — which
|
||||
/// is every free-tier session ("The free tier ships exactly one mode: `none`",
|
||||
/// 12-editions.md ▸ Tier matrix).
|
||||
/// no session the app composes is any more (see `git` above), and which a board nobody has
|
||||
/// added git to detects anyway.
|
||||
///
|
||||
/// **Its first consumer is the provider seam** — `makeHistoryProvider` reads exactly this to
|
||||
/// know whether the board has a repository to be an undo stack for, and it is the *mode*
|
||||
/// rather than the tier that decides (re-ruled 2026-07-31): `git` binds the git provider,
|
||||
/// `none` and `repoNested` alike the native stack. The popover's git section is the other
|
||||
/// reader — and the one place the two gitless modes still differ, since add-git is offered on
|
||||
/// one and explained away on the other.
|
||||
/// know whether the board has a repository to be an undo stack for, and it is the *mode* and
|
||||
/// nothing else that decides (re-ruled 2026-07-31; the tier's last say went with PIVOT
|
||||
/// 2026-08-07): `git` binds the git provider, `none` and `repoNested` alike the native stack.
|
||||
/// The popover's git section is the other reader — and the one place the two gitless modes
|
||||
/// still differ, since add-git is offered on one and explained away on the other.
|
||||
///
|
||||
/// `@MainActor` because the state it reads is: a nested type does not inherit its enclosing
|
||||
/// type's isolation, and everything that asks a session what mode it is in is main-actor
|
||||
@@ -886,17 +900,19 @@ public final class AppModel {
|
||||
func beginSession(ref: BoardWindowRef, store: BoardStore, recordID: UUID, access: ScopedAccess?) {
|
||||
// **The entitlement read** (12-editions.md ▸ The entitlement): "Pro state is read from
|
||||
// StoreKit's signed on-device transaction store at board-session composition — the open path
|
||||
// gains no network dependency." Synchronous, over facts already in memory, on the same line
|
||||
// as the provider it selects — which is the shape that makes "the open path never waits on
|
||||
// the App Store" checkable by reading four lines rather than by auditing a call graph. It is
|
||||
// also the *only* time this board asks: the answer becomes `BoardSession.tier` and nothing
|
||||
// re-derives it.
|
||||
// gains no network dependency." Synchronous, over facts already in memory. It is the *only*
|
||||
// time this board asks: the answer becomes `BoardSession.tier` and nothing re-derives it.
|
||||
//
|
||||
// **Recorded, and consulted by nothing below** (12 ▸ PIVOT 2026-08-07 — git left the
|
||||
// paywall). This line used to sit on the same line as the git state it gated; the gate is
|
||||
// gone, and what is left is a dormant fact kept for the base/Pro split still to be ruled.
|
||||
let tier = currentTier()
|
||||
// **Mode detection** (06-history-undo.md ▸ Rules ▸ Detection: "checked at every board
|
||||
// open"), on the same line as the tier that gates it. Under `.free` this returns `nil`
|
||||
// without looking at the disk at all — the inert posture is unconditional there — and under
|
||||
// `.pro` it is one `stat` per open, freshly, so a board that gained or lost a `.git` since
|
||||
// its last open opens in the mode it now has.
|
||||
// open") — unconditionally, in every tier. One `stat` per open, freshly, so a board that
|
||||
// gained or lost a `.git` since its last open opens in the mode it now has. Until the
|
||||
// 2026-08-07 pivot this call took the tier and answered `nil` under `.free`, which bought
|
||||
// the free tier a *zero*-stat open (the inert-`.git` posture); that promise is retired with
|
||||
// the posture, and every open now pays what Pro's opens always paid.
|
||||
//
|
||||
// Deliberately *not* re-run anywhere: no reload path, no watcher event, nothing. "The
|
||||
// running session keeps its mode, and the watcher does not scan for `.git` appearing."
|
||||
@@ -911,93 +927,91 @@ public final class AppModel {
|
||||
// callers (the add-git surface, unit tests), and a session that took that default would hand
|
||||
// the committer a ledger nothing ever writes to: every commit this app made would classify
|
||||
// foreign and be authored `Lanework External`. The default is a fallback, never this path's.
|
||||
let git = HistoryStore.compose(boardRoot: store.rootURL, tier: tier, ledger: store.echoes)
|
||||
let git = HistoryStore.compose(boardRoot: store.rootURL, ledger: store.echoes)
|
||||
// The board's stack is born here, with the session that owns it, and dies in `tearDown`
|
||||
// below — the whole of 13-native-undo.md's session-only persistence: "the stack lives with
|
||||
// the board session and dies at close/quit ... standard macOS behavior". On Pro's git boards
|
||||
// the board session and dies at close/quit ... standard macOS behavior". On a git-mode board
|
||||
// it is instead the repository's own trail, which survives everything (06 ▸ Rules ▸ Undo
|
||||
// survives relaunch) — the seam's whole point.
|
||||
let history = makeHistoryProvider(store, tier, git)
|
||||
let history = makeHistoryProvider(store, git)
|
||||
// **The loader's earlier-occurrence-wins history rung** (01-storage-format.md ▸ Fractal
|
||||
// layout ▸ Rules; `BoardLoader.IdentityHistoryRanker`): git-mode boards get a ranker,
|
||||
// everything else keeps injecting nothing. A *provider* rather than a ranker because each
|
||||
// load wants its own — see `BoardStore.makeIdentityHistoryRanker` — and because add-git
|
||||
// flips the mode mid-session, which this closure picks up for free by asking the git state
|
||||
// at the moment of each load rather than at composition.
|
||||
if let git {
|
||||
store.makeIdentityHistoryRanker = { [weak git] in git?.identityHistoryRanker }
|
||||
// **The auto-commit engine, wired into the session it commits for** (06-history-undo.md
|
||||
// ▸ Rules ▸ Auto-commit). Called on every Pro session and not only on git-mode ones,
|
||||
// because add-git can flip a board mid-session and the committer it builds then must land
|
||||
// in exactly this shape — `activateAutoCommit` remembers the wiring for that.
|
||||
git.activateAutoCommit { [weak store] committer in
|
||||
guard let store else { return }
|
||||
committer.currentSnapshot = { [weak store] in store?.snapshot }
|
||||
// **The flush awaits the snapshot that covers it** (06 ▸ Rules ▸ Auto-commit, ruled
|
||||
// 2026-07-31): "the composer diffs `store.snapshot` against HEAD, so the close flush
|
||||
// awaits a snapshot generation covering its changed paths before the committer runs —
|
||||
// the commit's subject can never be outrun by its own reload". Both halves of that
|
||||
// await are reads of the store the composer is already diffing, which is why they are
|
||||
// wired here rather than reached for: the engine holds the *policy* (when to wait, how
|
||||
// long), the session supplies the two facts (`GitAutoCommitter.awaitCoveringSnapshot`).
|
||||
//
|
||||
// The generation the gate counts is `landedReloads` — completed *walks* rather than
|
||||
// applied snapshots — because a value-equal reload skips the assignment and its
|
||||
// counter since 2026-07-31, and a walk covers a flush's paths whether or not it found
|
||||
// anything to change (`BoardStore.landedReloads`).
|
||||
committer.awaitReloadQuiescence = { [weak store] in await store?.awaitQuiescence() }
|
||||
committer.landedReloads = { [weak store] in store?.landedReloads }
|
||||
// 02-architecture.md ▸ Write-failure surfacing, through the strip the board window
|
||||
// already renders: a genuine commit failure means "your edits are saved, history has
|
||||
// stopped advancing", which is exactly what the standing suspension row says. Lock
|
||||
// contention and a held repository never reach here — neither is a failure.
|
||||
committer.reportFailure = { [weak store] failure in
|
||||
store?.banners.suspendHistory(reason: failure.message)
|
||||
}
|
||||
committer.reportRecovery = { [weak store] in
|
||||
store?.banners.clearHistorySuspension()
|
||||
}
|
||||
// **The corrupt-`.git` loud failure's standing row** (06-history-undo.md ▸ Rules,
|
||||
// ruled 2026-07-31): a repository the app cannot open pauses the whole git surface
|
||||
// and says so on the strip, "announced per 10-accessibility.md" — and the same seam
|
||||
// heals it, since the paused engine's own 15 s re-read is what notices a repository
|
||||
// repaired in a terminal. Distinct from the suspension above: that row is history
|
||||
// failing to advance and retrying, this one is there being nothing to advance into.
|
||||
committer.reportRepositoryUnreadable = { [weak store] unreadable in
|
||||
store?.noteRepositoryUnreadable(unreadable)
|
||||
}
|
||||
store.commitSeam = .binding(to: committer)
|
||||
// **The undo stack's ear on the committer** — every commit this engine lands, and
|
||||
// which of it was heal work (06 ▸ Rules ▸ The stack is HEAD's first-parent ancestry,
|
||||
// live; ▸ Heal commits are transparent to undo). Bound here rather than in
|
||||
// `wireGitUndo` because add-git builds a *new* committer, and this wiring is what
|
||||
// `activateAutoCommit` remembers on its behalf.
|
||||
committer.reportLanded = { [weak self, ref] window in
|
||||
guard let provider = self?.sessions[ref]?.history as? GitHistoryProvider else { return }
|
||||
provider.noteLanded(window)
|
||||
}
|
||||
store.makeIdentityHistoryRanker = { [weak git] in git?.identityHistoryRanker }
|
||||
// **The auto-commit engine, wired into the session it commits for** (06-history-undo.md
|
||||
// ▸ Rules ▸ Auto-commit). Called on every session and not only on git-mode ones, because
|
||||
// add-git can flip a board mid-session and the committer it builds then must land in
|
||||
// exactly this shape — `activateAutoCommit` remembers the wiring for that.
|
||||
git.activateAutoCommit { [weak store] committer in
|
||||
guard let store else { return }
|
||||
committer.currentSnapshot = { [weak store] in store?.snapshot }
|
||||
// **The flush awaits the snapshot that covers it** (06 ▸ Rules ▸ Auto-commit, ruled
|
||||
// 2026-07-31): "the composer diffs `store.snapshot` against HEAD, so the close flush
|
||||
// awaits a snapshot generation covering its changed paths before the committer runs —
|
||||
// the commit's subject can never be outrun by its own reload". Both halves of that
|
||||
// await are reads of the store the composer is already diffing, which is why they are
|
||||
// wired here rather than reached for: the engine holds the *policy* (when to wait, how
|
||||
// long), the session supplies the two facts (`GitAutoCommitter.awaitCoveringSnapshot`).
|
||||
//
|
||||
// The generation the gate counts is `landedReloads` — completed *walks* rather than
|
||||
// applied snapshots — because a value-equal reload skips the assignment and its
|
||||
// counter since 2026-07-31, and a walk covers a flush's paths whether or not it found
|
||||
// anything to change (`BoardStore.landedReloads`).
|
||||
committer.awaitReloadQuiescence = { [weak store] in await store?.awaitQuiescence() }
|
||||
committer.landedReloads = { [weak store] in store?.landedReloads }
|
||||
// 02-architecture.md ▸ Write-failure surfacing, through the strip the board window
|
||||
// already renders: a genuine commit failure means "your edits are saved, history has
|
||||
// stopped advancing", which is exactly what the standing suspension row says. Lock
|
||||
// contention and a held repository never reach here — neither is a failure.
|
||||
committer.reportFailure = { [weak store] failure in
|
||||
store?.banners.suspendHistory(reason: failure.message)
|
||||
}
|
||||
// **Add-git swaps the undo substrate too** (06 ▸ Rules ▸ Detection — the one commanded
|
||||
// mid-session mode flip; 13-native-undo.md's header — "discards the in-session native
|
||||
// stack and seeds the git trail from the root commit"). See `bindHistoryProvider(for:)`.
|
||||
git.didAddGit = { [weak self] in
|
||||
self?.bindHistoryProvider(for: ref)
|
||||
committer.reportRecovery = { [weak store] in
|
||||
store?.banners.clearHistorySuspension()
|
||||
}
|
||||
// **The form-anchored posture's fallback half** (06 ▸ Interaction with external writers,
|
||||
// ruled 2026-07-31): add-git answers inline in the form that asked, and lands here instead
|
||||
// when that form has been dismissed before the answer arrived — "inline is the primary
|
||||
// surface, never a silence trap". The banner enumeration is the same one branch switch and
|
||||
// undo restore post into, one row per failure.
|
||||
git.reportFailure = { [weak store] failure in
|
||||
store?.banners.postGitFailure(.addGit, reason: failure.message)
|
||||
// **The corrupt-`.git` loud failure's standing row** (06-history-undo.md ▸ Rules,
|
||||
// ruled 2026-07-31): a repository the app cannot open pauses the whole git surface
|
||||
// and says so on the strip, "announced per 10-accessibility.md" — and the same seam
|
||||
// heals it, since the paused engine's own 15 s re-read is what notices a repository
|
||||
// repaired in a terminal. Distinct from the suspension above: that row is history
|
||||
// failing to advance and retrying, this one is there being nothing to advance into.
|
||||
committer.reportRepositoryUnreadable = { [weak store] unreadable in
|
||||
store?.noteRepositoryUnreadable(unreadable)
|
||||
}
|
||||
store.commitSeam = .binding(to: committer)
|
||||
// **The undo stack's ear on the committer** — every commit this engine lands, and
|
||||
// which of it was heal work (06 ▸ Rules ▸ The stack is HEAD's first-parent ancestry,
|
||||
// live; ▸ Heal commits are transparent to undo). Bound here rather than in
|
||||
// `wireGitUndo` because add-git builds a *new* committer, and this wiring is what
|
||||
// `activateAutoCommit` remembers on its behalf.
|
||||
committer.reportLanded = { [weak self, ref] window in
|
||||
guard let provider = self?.sessions[ref]?.history as? GitHistoryProvider else { return }
|
||||
provider.noteLanded(window)
|
||||
}
|
||||
// **The detection-time answer, published once** (06 ▸ Rules: "a standing breakage-class
|
||||
// banner **at detection**"). The probe ran inside `compose` above — before this session
|
||||
// existed, and therefore before the seam that carries its transitions was wired — so a
|
||||
// board that opened into an unreadable repository raises its row here rather than
|
||||
// waiting for the first debounce to rediscover what composition already knows.
|
||||
store.noteRepositoryUnreadable(git.isRepositoryUnreadable)
|
||||
}
|
||||
// **Add-git swaps the undo substrate too** (06 ▸ Rules ▸ Detection — the one commanded
|
||||
// mid-session mode flip; 13-native-undo.md's header — "discards the in-session native
|
||||
// stack and seeds the git trail from the root commit"). See `bindHistoryProvider(for:)`.
|
||||
git.didAddGit = { [weak self] in
|
||||
self?.bindHistoryProvider(for: ref)
|
||||
}
|
||||
// **The form-anchored posture's fallback half** (06 ▸ Interaction with external writers,
|
||||
// ruled 2026-07-31): add-git answers inline in the form that asked, and lands here instead
|
||||
// when that form has been dismissed before the answer arrived — "inline is the primary
|
||||
// surface, never a silence trap". The banner enumeration is the same one branch switch and
|
||||
// undo restore post into, one row per failure.
|
||||
git.reportFailure = { [weak store] failure in
|
||||
store?.banners.postGitFailure(.addGit, reason: failure.message)
|
||||
}
|
||||
// **The detection-time answer, published once** (06 ▸ Rules: "a standing breakage-class
|
||||
// banner **at detection**"). The probe ran inside `compose` above — before this session
|
||||
// existed, and therefore before the seam that carries its transitions was wired — so a
|
||||
// board that opened into an unreadable repository raises its row here rather than
|
||||
// waiting for the first debounce to rediscover what composition already knows.
|
||||
store.noteRepositoryUnreadable(git.isRepositoryUnreadable)
|
||||
// **The binding 13-native-undo.md ▸ Rules' "registration at the Writer boundary" needs**: the
|
||||
// store is that boundary — every app-mediated mutation goes out through one of its write
|
||||
// methods — so it is the store that computes each inverse and registers it. What it cannot
|
||||
@@ -1291,15 +1305,17 @@ public final class AppModel {
|
||||
/// A judgment call, recorded when the free-tier matrix still left this board with no provider at
|
||||
/// all: the mode flip already carries the *committer* through (`HistoryStore.activateAutoCommit`
|
||||
/// remembers its wiring for precisely this board); 12-editions.md's "an open board finishes with
|
||||
/// the provider it composed" is a rule about a **tier** lapsing, which cannot change a running
|
||||
/// session at all; and a board that visibly starts accumulating commits while ⌘Z answers from a
|
||||
/// the provider it composed" was a rule about a **tier** lapsing, which never could change a
|
||||
/// running session — and since PIVOT 2026-08-07 no longer changes the *next* open either, since
|
||||
/// a git board composes the git provider in every tier; and a board that visibly starts
|
||||
/// accumulating commits while ⌘Z answers from a
|
||||
/// stack the repository knows nothing about would read as a defect rather than as a policy.
|
||||
///
|
||||
/// Called exactly once per board, structurally: `HistoryStore.addGit` refuses any mode but
|
||||
/// `none`, and flips to `.git` before it fires `didAddGit`.
|
||||
func bindHistoryProvider(for ref: BoardWindowRef) {
|
||||
guard var session = sessions[ref], let git = session.git, git.mode == .git else { return }
|
||||
guard let history = makeHistoryProvider(session.store, session.tier, git) else { return }
|
||||
guard let history = makeHistoryProvider(session.store, git) else { return }
|
||||
guard history !== session.history else { return }
|
||||
// Before the reassignment, while `session.history` is still the substrate being replaced: the
|
||||
// in-flight native steps go with it, and any closure that outlives this line finds an empty
|
||||
@@ -1351,8 +1367,9 @@ public final class AppModel {
|
||||
///
|
||||
/// This is the honest seam between the two halves of the rule: the host knows a window exists, the
|
||||
/// committer knows what staging is, and only the app model knows which board a card window belongs
|
||||
/// to and how to reach its committer. A board with no committer — the free tier, a Pro board with
|
||||
/// no repository — records nothing, which is the same `nil` every other git seam takes.
|
||||
/// to and how to reach its committer. A board with no committer — any board nobody has added git
|
||||
/// to, which since PIVOT 2026-08-07 is the only such board there is (12-editions.md) — records
|
||||
/// nothing, which is the same `nil` every other git seam takes.
|
||||
///
|
||||
/// The card's folder is handed over as a **closure**, not a URL: a card can change lane, or be
|
||||
/// moved into the trash, in the middle of a session, and what must be staged around is wherever
|
||||
@@ -1693,8 +1710,9 @@ public final class AppModel {
|
||||
// pending auto-commit — before teardown; nothing settled is ever left unsaved or
|
||||
// uncommitted by closing"). By the time it runs, step 1 has ended every card window's
|
||||
// session *and released its stage-around*, so each session — body, comments, purge and
|
||||
// all — lands in exactly one commit. `nil` on every board with no committer, which is the
|
||||
// whole free tier.
|
||||
// all — lands in exactly one commit. `nil` on every board with no committer, which is
|
||||
// every board nobody has added git to (12 ▸ PIVOT 2026-08-07 — the tier no longer
|
||||
// decides; opt-in per board still does).
|
||||
committerFlush: { [weak self] in
|
||||
await self?.sessions[ref]?.git?.committer?.flushNow()
|
||||
},
|
||||
|
||||
@@ -113,8 +113,9 @@ struct BoardWindowHost: View {
|
||||
/// 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.
|
||||
/// reach that store's ledger before `beginSession` composes a git board's committer, or the app's
|
||||
/// own repair commits as `Lanework External`. (Pro's committer, until 12-editions.md ▸ PIVOT
|
||||
/// 2026-08-07 made it every git board's.) Cleared once adopted.
|
||||
@State private var repairLedger: EchoLedger?
|
||||
|
||||
@State private var phase: Phase = .opening
|
||||
@@ -396,7 +397,8 @@ struct BoardWindowHost: View {
|
||||
|
||||
// **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
|
||||
// repair commit" — every git board's since 12-editions.md ▸ PIVOT 2026-08-07, the sentence
|
||||
// otherwise unchanged). `beginSession` is where that 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`.
|
||||
@@ -730,31 +732,34 @@ struct BoardWindowHost: View {
|
||||
// load rather than at attach because it carries the store; the controller installs it once,
|
||||
// whichever of the two arrives second.
|
||||
//
|
||||
// The tier and the git state come from the **session**, which `start()` began a moment ago,
|
||||
// rather than from the entitlement or the disk: a board's popover must describe the board as
|
||||
// it opened (12-editions.md ▸ The entitlement, "an open board finishes with the provider it
|
||||
// composed"; 06-history-undo.md ▸ Rules, mode is an open-time fact). A `nil` session cannot
|
||||
// happen on this path — `beginSession` precedes `configureWindow` — and reads as the free
|
||||
// tier's posture, which is the harmless direction.
|
||||
// The git state comes from the **session**, which `start()` began a moment ago, rather than
|
||||
// from the entitlement or the disk: a board's popover must describe the board as it opened
|
||||
// (12-editions.md ▸ The entitlement, "an open board finishes with the provider it composed";
|
||||
// 06-history-undo.md ▸ Rules, mode is an open-time fact). A `nil` session cannot happen on
|
||||
// this path — `beginSession` precedes `configureWindow` — and reads as a board with no
|
||||
// repository, which is the harmless direction.
|
||||
//
|
||||
// **The tier is no longer passed down** (12 ▸ PIVOT 2026-08-07): git is tier-independent, so
|
||||
// every one of these surfaces reads the board's mode and nothing else. `BoardSession.tier`
|
||||
// still exists and is still recorded — it just has no git-facing consumer here.
|
||||
let session = appModel.session(for: ref)
|
||||
// The settings sheet's two doors validate on the same pair, so they are adopted here rather
|
||||
// The settings sheet's two doors validate on the same fact, so it is adopted here rather
|
||||
// than read again somewhere else: the popover's Board Settings… row and Board ▸ Board
|
||||
// Settings… must never disagree about whether this board has setup to show
|
||||
// (`BoardSettingsAvailability`). The mode *inside* the git state stays live — add-git flipping
|
||||
// it re-resolves the sheet's sections and both doors, which is the one mid-session transition
|
||||
// 06 sanctions.
|
||||
boardSettings.adopt(tier: session?.tier ?? .free, git: session?.git)
|
||||
boardSettings.adopt(git: session?.git)
|
||||
windowController.installTitlebarAccessory(
|
||||
boardInfoTitlebarAccessory(
|
||||
store: store,
|
||||
recents: appModel.styleRecents,
|
||||
tier: session?.tier ?? .free,
|
||||
git: session?.git,
|
||||
presentation: boardInfo,
|
||||
settings: boardSettings
|
||||
)
|
||||
)
|
||||
// The widget above now says the board's name (and, on a git-mode Pro board, its branch)
|
||||
// The widget above now says the board's name (and, on a git-mode board, its branch)
|
||||
// itself, so the system title display would only repeat it — the card-window seam
|
||||
// (`CardWindowHost.configureWindow`, `HostedWindowController.hideTitle`), applied here for
|
||||
// the same reason. `.navigationTitle(windowTitle)` a few lines up in `body` is untouched —
|
||||
|
||||
@@ -213,7 +213,9 @@ final class CardWindowSession: CardSessionFlushing {
|
||||
// net effect, and the `comments/.trash/` purge, which must not run while a board step's undo
|
||||
// still restores comments out of it. Registering answers whether the step took the purge on —
|
||||
// and a board whose substrate keeps no steps has already run it by the time that answer comes
|
||||
// back, which is how Pro keeps purging at the close flush without a word about tiers here.
|
||||
// back, which is how a git board keeps purging at the close flush without a word about tiers
|
||||
// here (and there is none to say since 12-editions.md ▸ PIVOT 2026-08-07 — the substrate is
|
||||
// the board's mode, not a subscription).
|
||||
//
|
||||
// **This window stops owning its card's comment trash first** (ruled 2026-08-06 — the
|
||||
// open-window carve-out). While the window is open the store defers every purge of this
|
||||
@@ -418,9 +420,11 @@ struct CardWindowHost: View {
|
||||
/// **This card's commit trail, or nothing at all** (05-card-window.md ▸ History).
|
||||
///
|
||||
/// `nil` is the section's absence rule, read from the board's own git state rather than from a
|
||||
/// flag: no `HistoryStore` means the free tier (12-editions.md — where the section never exists),
|
||||
/// and a mode other than `git` means a board the app manages no history for. The object is held
|
||||
/// by this host so it survives every snapshot, `thumbnails`' reason exactly.
|
||||
/// flag: a mode other than `git` means a board the app manages no history for, on any tier. The
|
||||
/// question stopped having a tier half at 12-editions.md ▸ PIVOT 2026-08-07 — every session
|
||||
/// composes a git state now, so what decides the section is whether *this board's* history is
|
||||
/// git-backed. The object is held by this host so it survives every snapshot, `thumbnails`'
|
||||
/// reason exactly.
|
||||
private var cardHistory: CardHistory? {
|
||||
guard appModel.session(for: ref.board)?.gitMode == .git else { return nil }
|
||||
return history
|
||||
|
||||
@@ -39,8 +39,10 @@ import Foundation
|
||||
public enum BoardGitMode: String, Sendable, Equatable, CaseIterable {
|
||||
|
||||
/// No `.git` at the board root and none above it — **every** ancestor check answered not-found,
|
||||
/// "clean none" in 06's own words. The only mode the free tier ships (12-editions.md ▸ Tier
|
||||
/// matrix), the one add-git moves a board out of, and — now that this axis exists — the one mode
|
||||
/// "clean none" in 06's own words. The mode of every board nobody has opted into git for — which
|
||||
/// is what a board without app-managed git *is* now that the tier axis is gone (12-editions.md
|
||||
/// ▸ PIVOT 2026-08-07; it used to be the only mode the free tier shipped, over the retired
|
||||
/// inert-`.git` posture). The one add-git moves a board out of, and — now that this axis exists — the one mode
|
||||
/// add-git's own re-detection requires before it will act: a raced or stale read that turns out
|
||||
/// to be `.unverifiable` or `.repoNested` refuses the init exactly as those modes always did.
|
||||
case none
|
||||
|
||||
@@ -44,12 +44,15 @@ public struct GitLandedWindow: Sendable, Equatable {
|
||||
/// **Every settled change becomes a commit** (06-history-undo.md ▸ Rules ▸ Auto-commit), debounced
|
||||
/// past drag and typing churn, on git-mode boards and nowhere else.
|
||||
///
|
||||
/// ### Structurally unreachable off Pro
|
||||
/// ### Structurally unreachable on a board with no repository
|
||||
///
|
||||
/// One of these exists per `HistoryStore` in mode `git`, and a `HistoryStore` exists only under Pro
|
||||
/// (`HistoryStore.compose` is the tier gate). The free tier therefore has no committer to disable,
|
||||
/// no debounce to cancel and no `.git` to touch — 12-editions.md's inert posture as a shape rather
|
||||
/// than as a flag, which `InertGitTests` pins against real bytes.
|
||||
/// One of these exists per `HistoryStore` in mode `git` and nowhere else. Until the 2026-08-07 pivot
|
||||
/// there was a second gate above it — a `HistoryStore` existed only under Pro — and that one is gone
|
||||
/// (12-editions.md ▸ PIVOT 2026-08-07: git left the paywall, and every tier composes the git stack
|
||||
/// on git-mode boards). What remains is the stronger of the two anyway, because it never depended on
|
||||
/// a subscription: git is **opt-in per board** (06 ▸ Rules), so a board the user never added git to
|
||||
/// detects `none`, composes no committer, and has no debounce to cancel and no `.git` to touch. The
|
||||
/// file layer's own indifference to `.git` is pinned against real bytes by `UntouchedGitTests`.
|
||||
///
|
||||
/// ### What arms it
|
||||
///
|
||||
|
||||
@@ -254,13 +254,15 @@ public final class GitHistoryProvider: HistoryProviding {
|
||||
// MARK: - HistoryProviding
|
||||
|
||||
/// **Deliberately nothing — except the one thing a dropped step is owed.** On a git board an undo
|
||||
/// step is a commit, and the Writer boundary's inverse operations are the *free* tier's substrate
|
||||
/// (13-native-undo.md). `BoardStore` registers against whatever provider the session bound, and
|
||||
/// step is a commit, and the Writer boundary's inverse operations are the *gitless* board's
|
||||
/// substrate (13-native-undo.md — the tier axis it once read as went with 12-editions.md
|
||||
/// ▸ PIVOT 2026-08-07; the substrate is the board's mode alone). `BoardStore` registers against
|
||||
/// whatever provider the session bound, and
|
||||
/// this one has a repository to read instead — so the registrations arrive and are dropped, which
|
||||
/// is exactly what "the commit trail itself is the substrate" (14 ▸ C1) means in code.
|
||||
///
|
||||
/// Dropping a step means **retiring** it (`HistoryStep.Retirement`), and that is what keeps the
|
||||
/// tier split in 13's purge rule structural rather than conditional: "on Pro the substrate is
|
||||
/// substrate split in 13's purge rule structural rather than conditional: "on Pro the substrate is
|
||||
/// history: the close commit nets delete-plus-purge to a removal, revert restores it, so purge
|
||||
/// rides the close flush there as before" (13 ▸ Interaction with the trash). A card window's close
|
||||
/// step registered here is retired on arrival, so its deferred `comments/.trash/` purge runs
|
||||
|
||||
@@ -377,12 +377,12 @@ enum GitHousekeeping {
|
||||
/// **When a housekeeping pass runs** (06-history-undo.md ▸ Repository hygiene) — one per git-mode
|
||||
/// board session, scheduled at board open and never again.
|
||||
///
|
||||
/// ### Structurally unreachable off Pro
|
||||
/// ### Structurally unreachable on a board with no repository
|
||||
///
|
||||
/// One of these exists per `HistoryStore` in mode `git`, and a `HistoryStore` exists only under Pro
|
||||
/// (`HistoryStore.compose` is the tier gate) — `GitAutoCommitter`'s rule, for its reason. The free
|
||||
/// tier has no housekeeper to disable and no `.git` to pack (12-editions.md ▸ The free tier and
|
||||
/// `.git`, which `InertGitTests` pins against real bytes).
|
||||
/// One of these exists per `HistoryStore` in mode `git` and nowhere else — `GitAutoCommitter`'s
|
||||
/// rule, for its reason, including the tier gate that used to sit above it and no longer does
|
||||
/// (12-editions.md ▸ PIVOT 2026-08-07). A board the user never added git to detects `none`, so there
|
||||
/// is no housekeeper to disable and no `.git` to pack.
|
||||
///
|
||||
/// ### Off the open path, on purpose
|
||||
///
|
||||
|
||||
@@ -7,9 +7,11 @@ import Foundation
|
||||
///
|
||||
/// ### Why a struct of closures rather than a reference to the committer
|
||||
///
|
||||
/// `BoardStore` lives in the live store and must not learn what a repository is: the free tier
|
||||
/// composes no `HistoryStore`, so the engine has to be *structurally* unreachable there rather than
|
||||
/// switched off, and a store holding an optional committer would be a store that knows about git.
|
||||
/// `BoardStore` lives in the live store and must not learn what a repository is: most boards have no
|
||||
/// committer at all — git is opt-in per board (06 ▸ Rules), and since the 2026-08-07 pivot that is
|
||||
/// the *only* reason a board lacks one (12-editions.md) — so the engine has to be *structurally*
|
||||
/// unreachable there rather than switched off, and a store holding an optional committer would be a
|
||||
/// store that knows about git.
|
||||
/// One optional value, `nil` on every board that has no committer, is the same shape `watcherBrackets`
|
||||
/// and `history` already take, and it keeps the three orderings — before the write, after the
|
||||
/// bracket, after the landing — stated in one type instead of three properties that could drift.
|
||||
|
||||
@@ -7,19 +7,25 @@ import os
|
||||
/// opened in, the repository behind it when there is one, and the two operations that can change
|
||||
/// either — the app's own add-git, and nothing else.
|
||||
///
|
||||
/// ### One per board session, composed under the tier
|
||||
/// ### One per board session, composed at every open — in every tier
|
||||
///
|
||||
/// `compose(boardRoot:tier:)` is the whole gate: **the free tier gets no `HistoryStore` at all**, so
|
||||
/// a free-tier session runs no detection, opens no repository, and does not so much as `stat` a
|
||||
/// `.git` — "any `.git` is inert … the app never reads history, never commits, never touches `.git`
|
||||
/// in any way" (12-editions.md ▸ The free tier and `.git`), which `InertGitTests` pins against real
|
||||
/// bytes. Nothing in this type is conditional on a tier, because the tier decided whether the type
|
||||
/// exists.
|
||||
/// `compose(boardRoot:ledger:)` runs detection and hands back a store for **every** session
|
||||
/// (12-editions.md ▸ PIVOT 2026-08-07 — git left the paywall: "every tier composes the git stack on
|
||||
/// git-mode boards exactly as Pro did"). It used to be the gate: the free tier got no object at all,
|
||||
/// so a free session ran no detection and did not so much as `stat` a `.git` — the inert-`.git`
|
||||
/// posture made structural rather than remembered. That posture is **retired**. A `.git` at a board
|
||||
/// root is live in every tier, detection runs at every board open off the same path Pro's always
|
||||
/// used, and nothing in this type ever asks what anybody paid.
|
||||
///
|
||||
/// What the pivot does **not** change is why mode `none` still exists at all: git stays **opt-in per
|
||||
/// board** (06 ▸ Rules — "No silent auto-init, ever"). A board whose user never asked for a
|
||||
/// repository composes here, detects `none`, and builds no committer, no switcher and no
|
||||
/// housekeeper — nothing that could touch a `.git` it does not have.
|
||||
///
|
||||
/// ### What it does not do yet
|
||||
///
|
||||
/// This is the foundation card of pro-m1: mode, a repository, add-git, and the loader's path-history
|
||||
/// ranker. **The provider binding reads `mode` and nothing else about a tier** — the composition
|
||||
/// ranker. **The provider binding reads `mode` and nothing else** — the composition
|
||||
/// root binds the git provider on mode `git` and the native stack on modes `none` and `repoNested`
|
||||
/// alike (`AppModel.makeHistoryProvider`, re-ruled 2026-07-31: the provider follows the board, and
|
||||
/// what a repo-nested board denies is app-managed history, never ⌘Z). Auto-commit, commit messages,
|
||||
@@ -82,9 +88,11 @@ public final class HistoryStore {
|
||||
/// **The auto-commit engine** (06-history-undo.md ▸ Rules ▸ Auto-commit), or `nil` on a board
|
||||
/// there is no repository to commit into.
|
||||
///
|
||||
/// Its existence is exactly `mode == .git`, and that invariant is the tier gate one level down:
|
||||
/// no `HistoryStore` off Pro means no committer anywhere off Pro, with nothing to disable and no
|
||||
/// flag to forget.
|
||||
/// Its existence is exactly `mode == .git`, and since the 2026-08-07 pivot (12-editions.md) that
|
||||
/// invariant carries the whole story on its own: what keeps a committer off a board is the
|
||||
/// board's own mode — git is opt-in per board, so a user who never asked for a repository has
|
||||
/// nothing here to disable and no flag anyone could forget. It used to rest on a tier gate one
|
||||
/// level up (no `HistoryStore` off Pro meant no committer off Pro); the gate is gone.
|
||||
///
|
||||
/// **Composed inert and started separately.** Composition happens on the board-open path, where
|
||||
/// nothing may block and where a session does not exist yet; `activateAutoCommit(_:)` is what
|
||||
@@ -256,14 +264,19 @@ public final class HistoryStore {
|
||||
housekeeper.schedule()
|
||||
}
|
||||
|
||||
/// **The tier gate and the open-time detection, in one line** (12-editions.md ▸ The provider
|
||||
/// seam; 06-history-undo.md ▸ Rules ▸ Detection) — called by `AppModel.beginSession` beside the
|
||||
/// entitlement read that supplies `tier`.
|
||||
/// **The open-time detection** (06-history-undo.md ▸ Rules ▸ Detection: "checked at every board
|
||||
/// open") — called by `AppModel.beginSession`, unconditionally, once per board.
|
||||
///
|
||||
/// `nil` under `.free` means exactly what it says: no git state exists for that session, so no
|
||||
/// caller can accidentally consult one. Under `.pro` the mode is whatever the filesystem says
|
||||
/// right now, and a board that has changed mode since its last open simply opens in the new one
|
||||
/// — "the app just reflects what it finds".
|
||||
/// ### This was the tier gate, and is not one any more
|
||||
///
|
||||
/// It took a `tier` and answered `nil` under `.free`: no git state existed for such a session, so
|
||||
/// no caller could consult one and no free open ever stat'ed a `.git`. **PIVOT 2026-08-07**
|
||||
/// (12-editions.md) retired that whole axis — "detection runs at every board open" in every tier
|
||||
/// — so the parameter is *removed* rather than ignored, and the zero-stat promise dies with it:
|
||||
/// every open now pays the same handful of `stat`s Pro's opens always paid, off this same path.
|
||||
///
|
||||
/// The mode is whatever the filesystem says right now, and a board that has changed mode since
|
||||
/// its last open simply opens in the new one — "the app just reflects what it finds".
|
||||
///
|
||||
/// **Adoption needs no step of its own**: a board whose root already carries `.git` lands in
|
||||
/// `.git` here, silently, with no dialog and nothing to confirm — "the repo's presence *is* the
|
||||
@@ -274,8 +287,7 @@ public final class HistoryStore {
|
||||
/// store-less `HistoryStore` still composes: an empty ledger vouches for nothing, which is the
|
||||
/// honest answer for a git state with no session behind it (everything reads foreign, the
|
||||
/// launch-catch-up doctrine).
|
||||
public static func compose(boardRoot: URL, tier: Tier, ledger: EchoLedger = EchoLedger()) -> HistoryStore? {
|
||||
guard tier == .pro else { return nil }
|
||||
public static func compose(boardRoot: URL, ledger: EchoLedger = EchoLedger()) -> HistoryStore {
|
||||
let mode = BoardGitMode.detect(boardRoot: boardRoot)
|
||||
logger.debug("board opened in git mode \(mode.rawValue, privacy: .public)")
|
||||
return HistoryStore(boardRoot: boardRoot, mode: mode, ledger: ledger)
|
||||
@@ -286,10 +298,11 @@ public final class HistoryStore {
|
||||
/// **Opt-in init** (06-history-undo.md ▸ Rules): initializes a repository at the board root and
|
||||
/// immediately commits the whole tree as "Initial board state".
|
||||
///
|
||||
/// Reachable from one place — the board settings sheet's Git section under Pro — and from nowhere
|
||||
/// else:
|
||||
/// Reachable from one place — the board settings sheet's Git section, in every tier since the
|
||||
/// 2026-08-07 pivot (12-editions.md) — and from nowhere else:
|
||||
/// "No silent auto-init, ever", a deliberate pivot from the pathfinder, which initialized a repo
|
||||
/// under every board it opened.
|
||||
/// under every board it opened. Opt-in is what the pivot deliberately left standing: git leaving
|
||||
/// the paywall widened *who* may ask, never *whether* asking is required.
|
||||
///
|
||||
/// **It flips the open board's mode immediately**, which is the design's one sanctioned
|
||||
/// mid-session transition: "clicking it flips the open board into git mode immediately — the
|
||||
@@ -419,8 +432,8 @@ public final class HistoryStore {
|
||||
// MARK: - The loader's history seam
|
||||
|
||||
/// **The git-backed `IdentityHistoryRanker`** (01-storage-format.md ▸ Fractal layout ▸ Rules;
|
||||
/// `BoardLoader.IdentityHistoryRanker`), or `nil` on any board the app manages no git for — the
|
||||
/// free tier and modes `none`/`repoNested` alike, all of which fall through to the ladder's
|
||||
/// `BoardLoader.IdentityHistoryRanker`), or `nil` on any board the app manages no git for —
|
||||
/// modes `none`, `repoNested` and `unverifiable` alike, all of which fall through to the ladder's
|
||||
/// remaining rungs (birth date, then traversal order).
|
||||
///
|
||||
/// **A fresh ranker per ask, deliberately.** Each one computes its map at most once, lazily, and
|
||||
|
||||
@@ -13,11 +13,12 @@ import Foundation
|
||||
///
|
||||
/// ### Two jobs, and the second is why this is a type rather than a stored provider
|
||||
///
|
||||
/// **The fine stack**: an ordinary `NativeHistoryProvider`, in *both* tiers. The steps a card window
|
||||
/// **The fine stack**: an ordinary `NativeHistoryProvider`, on *every* board. The steps a card window
|
||||
/// registers are values-based inverses at the Writer boundary — the same shape whatever substrate the
|
||||
/// board's own history has — so a Pro git board's card window still walks its own gestures with the
|
||||
/// native grammar, and only the *coarse* close unit is tier-split ("one native board step, or one
|
||||
/// commit" — 06-history-undo.md ▸ Undo routing).
|
||||
/// board's own history has — so a git board's card window still walks its own gestures with the
|
||||
/// native grammar, and only the *coarse* close unit splits by substrate ("one native board step, or
|
||||
/// one commit" — 06-history-undo.md ▸ Undo routing). The split was written as a tier split and is a
|
||||
/// board split since 12-editions.md ▸ PIVOT 2026-08-07; nothing in this type ever read either.
|
||||
///
|
||||
/// **The fold**: window close registers "one coarse step ... whose undo restores the card subtree to
|
||||
/// its session-start state ... and whose redo reapplies the net effect". That net effect is exactly
|
||||
|
||||
@@ -262,21 +262,25 @@ public struct HistoryStep {
|
||||
/// seam from a window is the one coarse step its close registers.
|
||||
///
|
||||
/// `AppModel.BoardSession` is where the board half's ownership lives, and the composition root binds
|
||||
/// which implementation it gets — **following the board, not the tier alone** (re-ruled 2026-07-31):
|
||||
/// a board without app-managed git — repo-nested included (re-ruled 2026-07-31) — binds
|
||||
/// `NativeHistoryProvider` (two step stacks over the inverses registered at the Writer boundary) in
|
||||
/// every tier, a Pro git board binds the git provider (undo as forward restore commits over HEAD's
|
||||
/// first-parent ancestry — 06-history-undo.md), and Teams inherits Pro's.
|
||||
/// which implementation it gets — **following the board, and since the 2026-08-07 pivot the board
|
||||
/// alone** (re-ruled 2026-07-31; 12-editions.md ▸ PIVOT 2026-08-07): a board without app-managed git
|
||||
/// — repo-nested included (re-ruled 2026-07-31) — binds `NativeHistoryProvider` (two step stacks over
|
||||
/// the inverses registered at the Writer boundary), and a git-mode board binds the git provider (undo
|
||||
/// as forward restore commits over HEAD's first-parent ancestry — 06-history-undo.md). Both answers
|
||||
/// are the same in every tier; the tier used to decide whether git was on the table at all, and no
|
||||
/// longer does.
|
||||
///
|
||||
/// ### What this protocol deliberately does not say
|
||||
///
|
||||
/// - **No `NSUndoManager`, anywhere in the signature.** It is the native provider's implementation
|
||||
/// detail, and a seam that vended one would be a seam only one provider could ever satisfy — the
|
||||
/// opposite of the reason the seam exists at all ("the free tier's native undo is the first proof
|
||||
/// the seam is real", 12). AppKit still needs an `UndoManager` to hand the responder chain; that
|
||||
/// adapter is `BoardUndoManager`, which sits *over* this protocol rather than inside it.
|
||||
/// - **No persistence promise.** The native stack dies with the session (13); Pro's survives
|
||||
/// relaunch because git does (06). Both are honest implementations of these seven members.
|
||||
/// the seam is real", 12 — written when native undo was the free tier's; the proof it names is
|
||||
/// two working substrates, which the pivot leaves standing). AppKit still needs an `UndoManager`
|
||||
/// to hand the responder chain; that adapter is `BoardUndoManager`, which sits *over* this
|
||||
/// protocol rather than inside it.
|
||||
/// - **No persistence promise.** The native stack dies with the session (13); the git provider's
|
||||
/// survives relaunch because git does (06). Both are honest implementations of these seven members.
|
||||
/// - **No routing.** Which surface ⌘Z reaches is focus's answer, not the substrate's
|
||||
/// (06 ▸ Undo routing, tier-independent) — `BoardUndoRouting`.
|
||||
///
|
||||
|
||||
@@ -2,8 +2,10 @@ import Foundation
|
||||
|
||||
// MARK: - NativeHistoryProvider
|
||||
|
||||
/// The free tier's undo substrate: one stack per board session (13-native-undo.md) — **and the
|
||||
/// substrate of every open card window's stack, in either tier** (re-ruled 2026-07-31, the
|
||||
/// The undo substrate of every board without app-managed git: one stack per board session
|
||||
/// (13-native-undo.md, whose header lost its tier axis with the 2026-08-07 pivot — the substrate is
|
||||
/// the board's mode alone, and git is opt-in per board, so a board nobody opted in keeps this stack
|
||||
/// for good) — **and the substrate of every open card window's stack, on any board** (re-ruled 2026-07-31, the
|
||||
/// session-coarsening model): a window's fine-grained gestures are values-based inverses whatever the
|
||||
/// board's own substrate is, so `CardWindowUndo` holds one of these too. Nothing below knows which of
|
||||
/// the two it is; both need the same four-line grammar.
|
||||
|
||||
@@ -448,10 +448,14 @@ struct BoardInfoCommand: View {
|
||||
/// ### Validation: scope, then reachability — and never the lock
|
||||
///
|
||||
/// The row stays **visible and disabled** where the sheet cannot exist (`BoardSettingsAvailability`,
|
||||
/// which carries the reasoning): the free tier, and a Pro board nested inside someone else's
|
||||
/// repository. That is standard menu validation, and it is the deliberate asymmetry with the popover
|
||||
/// row, which is *absent* there instead — a menu is an inventory of the app, a popover section is a
|
||||
/// description of this board.
|
||||
/// which carries the reasoning): a board nested inside someone else's repository, one whose ancestor
|
||||
/// check was denied, and one whose own repository will not open. That is standard menu validation,
|
||||
/// and it is the deliberate asymmetry with the popover row, which is *absent* there instead — a menu
|
||||
/// is an inventory of the app, a popover section is a description of this board.
|
||||
///
|
||||
/// **The free tier used to be the fourth of those**, and is not since 12-editions.md ▸ PIVOT
|
||||
/// 2026-08-07: git is tier-independent, so what this row validates on is the board in front of the
|
||||
/// user and nothing about their subscription.
|
||||
///
|
||||
/// The read-only lock does not close it, for Board Info's reason: a settings sheet is *configuration*
|
||||
/// (04-interactions.md ▸ The map's carve-out), a locked board is exactly when a user may want to read
|
||||
|
||||
@@ -15,26 +15,25 @@ import SwiftUI
|
||||
/// `sectionHeader("Git")` and a `Divider()`. The tabs already sit under the popover header's own
|
||||
/// divider; a second rule and a second "Git" would be the surface naming itself twice.
|
||||
///
|
||||
/// ### Membership is the posture, so this view never renders an empty state
|
||||
/// ### Every board has this tab (pivot 2026-08-07)
|
||||
///
|
||||
/// `BoardInfoTab.available(tier:mode:hasGitDirectory:)` drops the Git tab from the strip exactly when
|
||||
/// `BoardGitSection` resolves to `.absent`, so this view is never built for the free tier's ordinary
|
||||
/// board. That is 12-editions.md's "absent, no placeholder" rule carried up to the tab strip — a
|
||||
/// standing Git tab on every free board would be the standing ad for Pro that 12 forbids.
|
||||
/// 12-editions.md ▸ PIVOT 2026-08-07 took git off the paywall, and 03's Git-tab note records what
|
||||
/// that does to this surface: with no free-only postures left, every board resolves one of the four
|
||||
/// mode postures below and **the tab is always in the strip**. The membership rule the Git session
|
||||
/// ruled (the strip asks the posture) stands structurally in `BoardInfoTab` — it simply never hears
|
||||
/// "absent" any more, because there is no longer a posture that says nothing.
|
||||
|
||||
// MARK: - The posture
|
||||
|
||||
/// **What the Git tab shows, for one board** (03-board-ui.md ▸ Board popover ▸ Git tab;
|
||||
/// 06-history-undo.md ▸ Rules; 12-editions.md ▸ The free tier and `.git`) — a pure function of two
|
||||
/// facts, so the posture matrix is provable without a popover on screen.
|
||||
/// 06-history-undo.md ▸ Rules) — a pure function of the board's **mode**, so the posture matrix is
|
||||
/// provable without a popover on screen.
|
||||
///
|
||||
/// The free tier's two cases are settled 2026-07-27: absent on an ordinary board, a one-line Pro
|
||||
/// pointer on a board carrying an inert `.git`. The Pro cases are the mode, one to one — and the
|
||||
/// mode-`none` and repo-nested pair is where the design is most insistent: a repo-nested board gets
|
||||
/// **prose, not a disabled button**. "The option is absent because it *can't* apply, and the UI
|
||||
/// should teach that rather than look broken" (06 ▸ Rules). `unverifiable` (the git-detection axis)
|
||||
/// joins as a fourth Pro case, structurally identical to `repoNested` but worded as its own honest
|
||||
/// prose — a denial is not a nesting.
|
||||
/// The four cases are the mode, one to one — and the mode-`none` and repo-nested pair is where the
|
||||
/// design is most insistent: a repo-nested board gets **prose, not a disabled button**. "The option
|
||||
/// is absent because it *can't* apply, and the UI should teach that rather than look broken" (06 ▸
|
||||
/// Rules). `unverifiable` (the git-detection axis) is structurally identical to `repoNested` but
|
||||
/// worded as its own honest prose — a denial is not a nesting.
|
||||
///
|
||||
/// **The 2026-07-31 popover/sheet split thinned two of these cases without removing either.** Setup
|
||||
/// left the popover for the board settings sheet, so mode `none` no longer renders an action here at
|
||||
@@ -43,59 +42,49 @@ import SwiftUI
|
||||
/// and the identity fields. What each case still *is* is a posture, which is why the matrix and its
|
||||
/// test survived the move unchanged.
|
||||
///
|
||||
/// **The 2026-08-07 tab restructure rehomed the surface, not the matrix** — the same six cases, now
|
||||
/// **The 2026-08-07 tab restructure rehomed the surface, not the matrix** — the same cases, now
|
||||
/// rendered as one tab each by `BoardGitTabView` rather than as a closing section of the popover's
|
||||
/// single pane. `.absent` grew one further job in the move: it is what `BoardInfoTab.available` reads
|
||||
/// to decide whether the Git tab is in the strip at all, which is why membership can never disagree
|
||||
/// with what the tab would have rendered.
|
||||
/// single pane.
|
||||
///
|
||||
/// **The 2026-08-07 pivot took the tier axis out of it** (12-editions.md ▸ PIVOT 2026-08-07, the
|
||||
/// same day). The two free-tier cases — `.absent` on an ordinary board, `.proPointer` on a board
|
||||
/// carrying an inert `.git`, both settled 2026-07-27 — described a gate that no longer exists: git
|
||||
/// is tier-independent, a `.git` at a board root is live in every tier, and detection runs at every
|
||||
/// board open. So the free-tier branch, the `hasGitDirectory` input it asked for, and both cases are
|
||||
/// **retired**, and what is left is the mode — which is what the postures were always about.
|
||||
enum BoardGitSection: Equatable, CaseIterable {
|
||||
|
||||
/// Nothing at all — the free tier's ordinary board, where "the popover is rename + style,
|
||||
/// complete in itself". Since the restructure this is a **missing tab**, not an empty one
|
||||
/// (`BoardInfoTab.available`).
|
||||
case absent
|
||||
|
||||
/// The free tier's one-line explanation of an inert `.git`, and the app's one in-context pointer
|
||||
/// to Pro (12 ▸ Tier naming).
|
||||
case proPointer
|
||||
|
||||
/// Pro, mode `none`: a board that could have a history and has none. There is no daily surface
|
||||
/// for that — the tab is one caption stating the fact above the Board Settings… door, where
|
||||
/// add-git now lives (03 ▸ Board settings sheet). The header-plus-door posture blessed
|
||||
/// 2026-08-06, restated for a surface whose header is now the tab label.
|
||||
/// Mode `none`: a board that could have a history and has none. There is no daily surface for
|
||||
/// that — the tab is one caption stating the fact above the Board Settings… door, where add-git
|
||||
/// now lives (03 ▸ Board settings sheet). The header-plus-door posture blessed 2026-08-06,
|
||||
/// restated for a surface whose header is now the tab label.
|
||||
///
|
||||
/// **Every tier's posture since the pivot** (03 ▸ Git tab, pivot note 2026-08-07), and git stays
|
||||
/// **opt-in per board**: the door is an offer, never an auto-init.
|
||||
case noRepository
|
||||
|
||||
/// Pro, repo-nested: the honest explanation, no action — and no Board Settings… row either,
|
||||
/// since nothing setup-shaped can apply (`BoardSettingsAvailability`).
|
||||
/// Repo-nested: the honest explanation, no action — and no Board Settings… row either, since
|
||||
/// nothing setup-shaped can apply (`BoardSettingsAvailability`).
|
||||
case repoNested
|
||||
|
||||
/// Pro, unverifiable: **not** `.repoNested` — a denied ancestor check, not a found repository
|
||||
/// Unverifiable: **not** `.repoNested` — a denied ancestor check, not a found repository
|
||||
/// (06 ▸ Rules ▸ Detection, "Denial is not absence"). Structurally identical to `.repoNested`
|
||||
/// (no action, no Board Settings… row, `BoardSettingsAvailability` false), but its own case so
|
||||
/// the view renders its own honest prose rather than the nested sentence — "unverifiable" is not
|
||||
/// "nested".
|
||||
case unverifiable
|
||||
|
||||
/// Pro, git mode: the branch/source line with the **switch** picker, the abnormal-state
|
||||
/// explanation when the surface is held, and the Board Settings… row. The remote half —
|
||||
/// tracking, Pull/Push, the status badges — is 07-sync-collab.md's own card and joins this same
|
||||
/// posture.
|
||||
/// Git mode: the branch/source line with the **switch** picker, the abnormal-state explanation
|
||||
/// when the surface is held, and the Board Settings… row. The remote half — tracking, Pull/Push,
|
||||
/// the status badges — is 07-sync-collab.md's own card and joins this same posture.
|
||||
case branch
|
||||
|
||||
static func resolve(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> BoardGitSection {
|
||||
switch tier {
|
||||
case .free:
|
||||
// Detection never runs under the free tier, so the mode is not consulted here — the one
|
||||
// question asked is whether the folder carries a `.git`, which is what the pointer is
|
||||
// about (12: "any `.git` is inert … a stray like any other, preserved verbatim").
|
||||
return hasGitDirectory ? .proPointer : .absent
|
||||
case .pro:
|
||||
switch mode {
|
||||
case .none: return .noRepository
|
||||
case .git: return .branch
|
||||
case .repoNested: return .repoNested
|
||||
case .unverifiable: return .unverifiable
|
||||
}
|
||||
static func resolve(mode: BoardGitMode) -> BoardGitSection {
|
||||
switch mode {
|
||||
case .none: return .noRepository
|
||||
case .git: return .branch
|
||||
case .repoNested: return .repoNested
|
||||
case .unverifiable: return .unverifiable
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,71 +133,29 @@ private struct BoardGitUnverifiableNote: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The contextual git note — **a quiet signpost, not a feature** (12-editions.md ▸ The free tier and
|
||||
/// `.git`, settled 2026-07-27, carried through the one-app collapse). The free tier has no git
|
||||
/// integration (that is the Pro subscription's), so this is not a grow-in-place slot the way the old
|
||||
/// `BoardGitSlot` placeholder was: there is nothing here to grow. The free tier's whole git story is
|
||||
/// one line, shown only when it is true — and it is **the one in-context pointer to Pro**, the second
|
||||
/// of the three places the app names it (12 ▸ Tier naming; the other two are `AboutBox` and the
|
||||
/// Settings Pro section).
|
||||
///
|
||||
/// On an ordinary board nothing instantiates this type at all — the board has no Git tab to put it in
|
||||
/// (`BoardInfoTab.available`), matching the card window's absent History section (12: "absent, no
|
||||
/// placeholder"). Only a board that carries an inert `.git` (12 ▸ the inert posture: "any `.git` is
|
||||
/// inert" — the free tier never reads or writes it, whether the board's own or one a lapsed
|
||||
/// subscription left behind) earns this note, worded exactly as 12 rules: an honest explanation of
|
||||
/// what the folder is, named exactly where the question arises, never a standing ad for Pro.
|
||||
///
|
||||
/// Not `private`: `hasGitDirectory(at:)` is the pure seam `BoardInfoPopoverTests.swift` pins directly
|
||||
/// (a fixture board with `.git` → true, without → false), and `BoardInfoView.init` is its one caller
|
||||
/// in the app — the popover asks the question once at open and hands the answer down, both to this
|
||||
/// tab and to `BoardInfoTab.available`.
|
||||
struct BoardGitNote: View {
|
||||
|
||||
var body: some View {
|
||||
Text("This board has a git history. Lanework Pro works with it.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
/// Whether `boardRoot` carries a `.git` entry — the free tier's entire detection story, and a deliberately
|
||||
/// small one: nothing in `BoardStore`, `BoardModel`, or `BoardLoader` tracks this as a live fact
|
||||
/// today, because nothing needs it to be live. `FolderWatcher` filters `.git` out of the folder
|
||||
/// watch by design (§ .git filtering — it exists to ignore git churn), so there is no reload
|
||||
/// event this could hang off even if it wanted to; a plain, read-only `FileManager` check taken
|
||||
/// once, at the moment the popover is built, is the honest amount of machinery for a single quiet
|
||||
/// line. A `.git` added or removed while the popover happens to be open is stale until the next
|
||||
/// open — a gap this note's own posture makes harmless, since it is a signpost, not a control.
|
||||
static func hasGitDirectory(at boardRoot: URL) -> Bool {
|
||||
FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The tab
|
||||
|
||||
/// The Git tab's surface: whichever of the six postures this board is in, and the Board Settings…
|
||||
/// The Git tab's surface: whichever of the four postures this board is in, and the Board Settings…
|
||||
/// door where it applies.
|
||||
struct BoardGitTabView: View {
|
||||
|
||||
let store: BoardStore
|
||||
|
||||
/// The tier and the git state this board's **session** composed with, handed down from
|
||||
/// `BoardInfoView` rather than re-derived — 12-editions.md ▸ The entitlement ("a lapse never
|
||||
/// interrupts an open session"). `git` is `@Observable`, so a branch switch or a pause landing
|
||||
/// while the tab is open repaints it.
|
||||
let tier: Tier
|
||||
/// The git state this board's **session** composed with, handed down from `BoardInfoView` rather
|
||||
/// than re-derived — 12-editions.md ▸ The entitlement ("a lapse never interrupts an open
|
||||
/// session"). `git` is `@Observable`, so a branch switch or a pause landing while the tab is open
|
||||
/// repaints it.
|
||||
///
|
||||
/// Optional because a caller with no session in hand (previews, the accessory-installation tests)
|
||||
/// has none to hand over; a `nil` reads as mode `none`, which is the honest description of a
|
||||
/// board nothing has detected anything about yet. Since the 2026-08-07 pivot it is no longer a
|
||||
/// *tier* signal — every session composes one (12 ▸ PIVOT 2026-08-07).
|
||||
let git: HistoryStore?
|
||||
|
||||
/// The window's settings sheet, so this tab can carry the **Board Settings…** row that opens it.
|
||||
/// `nil` where there is no window to present a sheet on, which reads as a tab with no door.
|
||||
let settings: BoardSettingsPresentation?
|
||||
|
||||
/// The free tier's one question about the board's folder, asked once in `BoardInfoView.init` and
|
||||
/// passed down — the same answer the tab strip's membership was computed from, so the tab and its
|
||||
/// content can never disagree about which posture this board is in.
|
||||
let hasGitDirectory: Bool
|
||||
|
||||
/// The popover's own padding figure (`BoardInfoView.inset`), matching `BoardInfoTabView`'s and
|
||||
/// `BoardThemeTabView`'s own parameter — the tab pads by this amount instead of restating the
|
||||
/// derivation.
|
||||
@@ -231,18 +178,7 @@ struct BoardGitTabView: View {
|
||||
/// header's rule.
|
||||
@ViewBuilder
|
||||
private var posture: some View {
|
||||
switch BoardGitSection.resolve(tier: tier, mode: git?.mode ?? .none, hasGitDirectory: hasGitDirectory) {
|
||||
case .absent:
|
||||
// Unreachable, and kept honest rather than fatal: `BoardInfoTab.available` drops the Git
|
||||
// tab from the strip on exactly this posture, so nothing ever selects a tab that would
|
||||
// land here. Written as `EmptyView` because that is what the case *means* — if the
|
||||
// membership rule ever loosened, the free tier's ordinary board would render nothing,
|
||||
// which is still the design's answer for it (12: "absent, no placeholder").
|
||||
EmptyView()
|
||||
|
||||
case .proPointer:
|
||||
BoardGitNote()
|
||||
|
||||
switch BoardGitSection.resolve(mode: git?.mode ?? .none) {
|
||||
case .noRepository:
|
||||
// Nothing daily to show on a board with no repository — so the tab is the fact and the
|
||||
// door. Add-git itself moved to the sheet with the 2026-07-31 split; what stays here is
|
||||
@@ -285,7 +221,6 @@ struct BoardGitTabView: View {
|
||||
@ViewBuilder
|
||||
private var boardSettingsRow: some View {
|
||||
if let settings, BoardSettingsAvailability.resolve(
|
||||
tier: tier,
|
||||
mode: git?.mode ?? .none,
|
||||
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
|
||||
) {
|
||||
|
||||
@@ -13,8 +13,10 @@ import SwiftUI
|
||||
/// which the pre-tab body's mode-aware git section rehomed into whole — postures, notes, and the
|
||||
/// Board Settings… row, none of them re-ruled by the move.
|
||||
///
|
||||
/// **Tab membership is the git posture's** (`BoardInfoTab.available(tier:mode:hasGitDirectory:)`),
|
||||
/// and **selection resets to Info on every open** — both the Git session's rulings.
|
||||
/// **Tab membership is the git posture's**, and **selection resets to Info on every open** — both
|
||||
/// the Git session's rulings. Since 12-editions.md ▸ PIVOT 2026-08-07 the first of those is a
|
||||
/// structural rule with nothing left to exclude: every board carries all three tabs (03 ▸ Board
|
||||
/// popover, the same-day pivot note) — see `BoardInfoTab`.
|
||||
///
|
||||
/// ### One home, deliberately
|
||||
///
|
||||
@@ -62,8 +64,8 @@ extension FocusedValues {
|
||||
|
||||
// MARK: - The window-title widget
|
||||
|
||||
/// The titlebar widget: the board's name — and, on a git-mode Pro board, its branch — with a
|
||||
/// trailing disclosure chevron, whose one job is this popover.
|
||||
/// The titlebar widget: the board's name — and, on a git-mode board, its branch — with a trailing
|
||||
/// disclosure chevron, whose one job is this popover.
|
||||
///
|
||||
/// **The popover is anchored to the widget itself** — it hangs from the button rather than from the
|
||||
/// window or the board — which is what makes the affordance and the surface read as one thing. A
|
||||
@@ -80,12 +82,14 @@ struct BoardInfoWidget: View {
|
||||
let store: BoardStore
|
||||
let recents: StyleRecents
|
||||
|
||||
/// The tier and the git state this board's **session** composed with — read once, at the moment
|
||||
/// the widget is installed, and never re-derived (12-editions.md ▸ The entitlement: "a lapse
|
||||
/// never interrupts an open session"). `git` is a reference type and `@Observable`, so add-git
|
||||
/// flipping the mode, or a branch switch, redraws the widget without anything here being
|
||||
/// re-created.
|
||||
let tier: Tier
|
||||
/// The git state this board's **session** composed with — read once, at the moment the widget is
|
||||
/// installed, and never re-derived (12-editions.md ▸ The entitlement: "a lapse never interrupts
|
||||
/// an open session"). `git` is a reference type and `@Observable`, so add-git flipping the mode,
|
||||
/// or a branch switch, redraws the widget without anything here being re-created.
|
||||
///
|
||||
/// It stopped being a tier signal at 12 ▸ PIVOT 2026-08-07 — every session composes a git state
|
||||
/// now, whatever the tier — so a `nil` here means only "this caller had no session to hand over"
|
||||
/// (previews, the accessory-installation tests), which reads as mode `none`.
|
||||
let git: HistoryStore?
|
||||
|
||||
@Bindable var presentation: BoardInfoPresentation
|
||||
@@ -105,7 +109,6 @@ struct BoardInfoWidget: View {
|
||||
BoardInfoTitlebarSummary(
|
||||
snapshotTitle: store.snapshot.title.value,
|
||||
rootURL: store.rootURL,
|
||||
tier: tier,
|
||||
mode: git?.mode ?? .none,
|
||||
branch: git?.branch
|
||||
)
|
||||
@@ -173,7 +176,7 @@ struct BoardInfoWidget: View {
|
||||
// costs nothing on the other four postures.
|
||||
.task { await git?.refreshBranch() }
|
||||
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
|
||||
BoardInfoView(store: store, recents: recents, tier: tier, git: git, settings: settings)
|
||||
BoardInfoView(store: store, recents: recents, git: git, settings: settings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,23 +215,26 @@ struct BoardInfoWidget: View {
|
||||
/// with plain values and no fixture board on disk — the one duplication this card leaves behind
|
||||
/// rather than reshaping `AppModel.displayName(of:)`'s signature to fit both call sites.
|
||||
///
|
||||
/// **Branch.** Shown only when the board is actually git-mode under Pro — `tier == .pro && mode ==
|
||||
/// .git` with a non-`nil` branch — the same condition family `BoardGitSection.resolve`'s `.branch`
|
||||
/// case covers. The free tier and an inert `.git` (mode `.none` or `.repoNested`) show no branch;
|
||||
/// neither does a git-mode board whose branch has not been read yet (`HistoryStore.branch` starts
|
||||
/// `nil` until `refreshBranch()` answers, which the widget's own `.task` kicks off at open).
|
||||
/// **Branch.** Shown only when the board is actually git-mode — `mode == .git` with a non-`nil`
|
||||
/// branch — the same condition `BoardGitSection.resolve`'s `.branch` case covers. **The tier clause
|
||||
/// is gone** (12-editions.md ▸ PIVOT 2026-08-07: git is tier-independent, so a git-mode board is a
|
||||
/// git-mode board and the widget says so on every tier); the rule it read `tier == .pro && mode ==
|
||||
/// .git` until that day is recorded here rather than restated in code. A board with no repository or
|
||||
/// an inert one (mode `.none`, `.repoNested`, `.unverifiable`) shows no branch; neither does a
|
||||
/// git-mode board whose branch has not been read yet (`HistoryStore.branch` starts `nil` until
|
||||
/// `refreshBranch()` answers, which the widget's own `.task` kicks off at open).
|
||||
struct BoardInfoTitlebarSummary: Equatable {
|
||||
|
||||
let title: String
|
||||
let branch: String?
|
||||
|
||||
init(snapshotTitle: String?, rootURL: URL, tier: Tier, mode: BoardGitMode, branch: String?) {
|
||||
init(snapshotTitle: String?, rootURL: URL, mode: BoardGitMode, branch: String?) {
|
||||
if let snapshotTitle, !snapshotTitle.isEmpty {
|
||||
self.title = snapshotTitle
|
||||
} else {
|
||||
self.title = rootURL.deletingPathExtension().lastPathComponent
|
||||
}
|
||||
self.branch = (tier == .pro && mode == .git) ? branch : nil
|
||||
self.branch = mode == .git ? branch : nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,14 +246,13 @@ struct BoardInfoTitlebarSummary: Equatable {
|
||||
/// window, removed on detach — for the same reason it owns the delegate proxying: the window is
|
||||
/// SwiftUI's, and anything hung on it has to be taken back off.
|
||||
@MainActor
|
||||
/// `tier`/`git` default to the free tier's posture — a popover with no git section at all — and
|
||||
/// `settings` to no sheet, so that a caller with no session in hand (the accessory-installation
|
||||
/// tests, which are about AppKit plumbing rather than about git) describes a board honestly rather
|
||||
/// than by accident. The app's own call site passes the session's values explicitly.
|
||||
/// `git` defaults to no session and `settings` to no sheet, so that a caller with none in hand (the
|
||||
/// accessory-installation tests, which are about AppKit plumbing rather than about git) describes a
|
||||
/// board honestly rather than by accident: a Git tab in its no-repository posture, and no
|
||||
/// Board Settings… row behind it. The app's own call site passes the session's values explicitly.
|
||||
func boardInfoTitlebarAccessory(
|
||||
store: BoardStore,
|
||||
recents: StyleRecents,
|
||||
tier: Tier = .free,
|
||||
git: HistoryStore? = nil,
|
||||
presentation: BoardInfoPresentation,
|
||||
settings: BoardSettingsPresentation? = nil
|
||||
@@ -256,7 +261,6 @@ func boardInfoTitlebarAccessory(
|
||||
rootView: BoardInfoWidget(
|
||||
store: store,
|
||||
recents: recents,
|
||||
tier: tier,
|
||||
git: git,
|
||||
presentation: presentation,
|
||||
settings: settings
|
||||
@@ -291,26 +295,16 @@ enum BoardInfoTab: String, CaseIterable, Identifiable {
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
/// **Which tabs this board actually offers** — membership *is* the git posture (03-board-ui.md
|
||||
/// § Board popover, the Git session's ruling, 2026-08-07): the Git tab joins the strip only when
|
||||
/// the git section has something true to say, and drops out exactly where `BoardGitSection`
|
||||
/// resolves to `.absent` — the free tier's ordinary board, which shows Info | Theme alone.
|
||||
///
|
||||
/// That carries 12-editions.md's "absent, no placeholder" rule up to the tab strip: a standing
|
||||
/// Git tab on every free board would be the standing ad for Pro that 12 forbids, and an empty tab
|
||||
/// behind it would be the placeholder 12 forbids twice over.
|
||||
///
|
||||
/// **Delegated, never restated.** The posture matrix is settled, test-pinned design
|
||||
/// (`BoardGitSectionTests`), and a second reading of "is there git to show here" is a reading
|
||||
/// that can disagree with the tab it labels — so this asks `BoardGitSection.resolve` the same
|
||||
/// question `BoardGitTabView` asks, from the same three facts.
|
||||
///
|
||||
/// Order is `allCases`' own — Info, then Theme, then Git — so the Git tab, where present, is
|
||||
/// always last and the two standing tabs never move under a user who switched boards.
|
||||
static func available(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> [BoardInfoTab] {
|
||||
let posture = BoardGitSection.resolve(tier: tier, mode: mode, hasGitDirectory: hasGitDirectory)
|
||||
return allCases.filter { $0 != .git || posture != .absent }
|
||||
}
|
||||
// **Membership is the git posture's, and the posture never says "absent" any more.** The Git
|
||||
// session ruled (2026-08-07) that the Git tab joins the strip only where `BoardGitSection` has
|
||||
// something true to say, which then meant dropping it on a free board with no `.git` — the
|
||||
// "absent, no placeholder" rule carried up to the strip. **12-editions.md ▸ PIVOT 2026-08-07**,
|
||||
// the same day, retired the free-only postures wholesale: git left the paywall, `.absent` and
|
||||
// `.proPointer` died with it, and 03-board-ui.md ▸ Board popover records the consequence — "the
|
||||
// absent posture is unreachable and every board carries all three tabs. The membership rule
|
||||
// stands structurally — the strip still asks the posture — it just never hears 'absent'
|
||||
// anymore." So there is no `available(…)` filter here to ask it with: membership is `allCases`,
|
||||
// in `allCases`' own order (Info, Theme, Git), which is what the filter answered anyway.
|
||||
}
|
||||
|
||||
// MARK: - The popover's content
|
||||
@@ -326,7 +320,6 @@ struct BoardInfoView: View {
|
||||
|
||||
let store: BoardStore
|
||||
let recents: StyleRecents
|
||||
let tier: Tier
|
||||
let git: HistoryStore?
|
||||
let settings: BoardSettingsPresentation?
|
||||
|
||||
@@ -337,17 +330,6 @@ struct BoardInfoView: View {
|
||||
/// `BoardInfoWidget` hands `.popover` fresh on every open, is exactly that rule and nothing more.
|
||||
@State private var tab: BoardInfoTab = .info
|
||||
|
||||
/// Whether this board carries a `.git` — checked once, off disk, when the view is built (which
|
||||
/// is every time the popover opens, since `BoardInfoWidget` hands `.popover` a fresh instance).
|
||||
/// See `BoardGitNote.hasGitDirectory(at:)` for why a live-updating fact isn't needed here.
|
||||
///
|
||||
/// **The free tier's input only.** Under Pro the Git tab reads the session's detected mode
|
||||
/// instead — a fact settled at open, which is where 06-history-undo.md puts detection — and this
|
||||
/// stays what it always was: the one quiet question the free tier asks of a board's folder. Since
|
||||
/// the tab restructure it answers for the tab *strip* as well as for the tab's content
|
||||
/// (`tabs` below), which is why it is read once here and handed down rather than asked twice.
|
||||
private let hasGitDirectory: Bool
|
||||
|
||||
/// The style editor brings its own padding, so the sections around it carry the same number by
|
||||
/// hand instead of an outer padding that would double up on it — **the editor's own figure**
|
||||
/// (`StyleEditorLayout.sectionSpacing`), which is font-derived, so the popover's chrome scales
|
||||
@@ -356,36 +338,16 @@ struct BoardInfoView: View {
|
||||
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
||||
}
|
||||
|
||||
/// **The tab strip's membership** (`BoardInfoTab.available`), read inside `body` so the mode it
|
||||
/// delegates to is live — `HistoryStore` is `@Observable`, and add-git flipping a board's mode
|
||||
/// must not leave a stale strip behind it.
|
||||
///
|
||||
/// **Stable across an open, in practice and by construction.** `tier` is the session's, fixed at
|
||||
/// the widget's birth (12-editions.md ▸ The entitlement: "a lapse never interrupts an open
|
||||
/// session"), and `hasGitDirectory` is one `init`-time read; the mode is the only live input, and
|
||||
/// `BoardGitSection.resolve` answers `.absent` only under the free tier, where it never consults
|
||||
/// the mode at all. So no live change can move the Git tab in or out from under the selection.
|
||||
/// Add-git — the one gesture that flips a mode — runs from the settings sheet anyway, which the
|
||||
/// Board Settings… row dismisses this popover to reach.
|
||||
private var tabs: [BoardInfoTab] {
|
||||
BoardInfoTab.available(tier: tier, mode: git?.mode ?? .none, hasGitDirectory: hasGitDirectory)
|
||||
}
|
||||
|
||||
init(
|
||||
store: BoardStore,
|
||||
recents: StyleRecents,
|
||||
tier: Tier = .free,
|
||||
git: HistoryStore? = nil,
|
||||
settings: BoardSettingsPresentation? = nil
|
||||
) {
|
||||
self.store = store
|
||||
self.recents = recents
|
||||
self.tier = tier
|
||||
self.git = git
|
||||
self.settings = settings
|
||||
// Asked only where it is the answer: under Pro the mode already knows, and a free-tier
|
||||
// board is the only one this question is for (12-editions.md ▸ The free tier and `.git`).
|
||||
self.hasGitDirectory = tier == .free && BoardGitNote.hasGitDirectory(at: store.rootURL)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -432,10 +394,12 @@ struct BoardInfoView: View {
|
||||
// The tab bar: a segmented control rather than a `TabView`, because the popover is a
|
||||
// compact settings surface and the segmented idiom is the macOS shape for switching
|
||||
// between a handful of peer panes inside one. The label is hidden visually but stays
|
||||
// the control's accessibility name. It iterates `tabs`, not `allCases` — membership is
|
||||
// this board's git posture (`BoardInfoTab.available`).
|
||||
// the control's accessibility name. It iterates `allCases` — the strip's membership is
|
||||
// still the board's git posture in principle (the Git session's ruling), and since
|
||||
// 12-editions.md ▸ PIVOT 2026-08-07 that posture is never "absent", so every board
|
||||
// carries all three tabs (03-board-ui.md ▸ Board popover, the same-day pivot note).
|
||||
Picker("Board configuration", selection: $tab) {
|
||||
ForEach(tabs) { tab in
|
||||
ForEach(BoardInfoTab.allCases) { tab in
|
||||
Text(tab.rawValue)
|
||||
}
|
||||
}
|
||||
@@ -452,14 +416,7 @@ struct BoardInfoView: View {
|
||||
case .theme:
|
||||
BoardThemeTabView(store: store, inset: inset)
|
||||
case .git:
|
||||
BoardGitTabView(
|
||||
store: store,
|
||||
tier: tier,
|
||||
git: git,
|
||||
settings: settings,
|
||||
hasGitDirectory: hasGitDirectory,
|
||||
inset: inset
|
||||
)
|
||||
BoardGitTabView(store: store, git: git, settings: settings, inset: inset)
|
||||
}
|
||||
}
|
||||
// The style editor's popover width, taken from the editor rather than restated — the number
|
||||
|
||||
@@ -57,8 +57,8 @@ struct BoardInfoMetrics: Equatable {
|
||||
/// gathered in one pass so the four numbers describe the same instant.
|
||||
///
|
||||
/// **Taken once per appearance, never live.** The tab measures when it appears and shows that —
|
||||
/// the `hasGitDirectory` posture one section over: a read-only fact refreshed by reopening, not a
|
||||
/// live subscription. `FolderWatcher` deliberately filters `.git` churn out of the reload stream,
|
||||
/// a read-only fact refreshed by reopening, not a live subscription. `FolderWatcher` deliberately
|
||||
/// filters `.git` churn out of the reload stream,
|
||||
/// so there is no event these numbers could honestly hang off; and a size that ticked while the
|
||||
/// user watched would be motion without meaning on a settings surface.
|
||||
struct BoardDiskFootprint: Equatable, Sendable {
|
||||
|
||||
@@ -39,51 +39,55 @@ import SwiftUI
|
||||
/// than per board or per app): one per window, `@State` in `BoardWindowHost`, published through the
|
||||
/// focus system so Board ▸ Board Settings… means "the board in front".
|
||||
///
|
||||
/// **It carries the session's tier and git state** where the popover flag carries nothing, and for a
|
||||
/// reason the popover does not have: both of this sheet's doors have to *validate*, and one of them
|
||||
/// is a menu row with no view around it to ask. The facts are adopted once, from the session, at the
|
||||
/// same moment the titlebar widget adopts them (`BoardWindowHost.configureWindow`) and never
|
||||
/// re-derived — 12-editions.md ▸ The entitlement, "a lapse never interrupts an open session". The
|
||||
/// *mode* inside the git state is `@Observable` and does move, by add-git alone, which is exactly the
|
||||
/// transition this sheet is where the user performs: the sections re-resolve under it live.
|
||||
/// **It carries the session's git state** where the popover flag carries nothing, and for a reason
|
||||
/// the popover does not have: both of this sheet's doors have to *validate*, and one of them is a
|
||||
/// menu row with no view around it to ask. The fact is adopted once, from the session, at the same
|
||||
/// moment the titlebar widget adopts it (`BoardWindowHost.configureWindow`) and never re-derived —
|
||||
/// 12-editions.md ▸ The entitlement, "a lapse never interrupts an open session". The *mode* inside
|
||||
/// the git state is `@Observable` and does move, by add-git alone, which is exactly the transition
|
||||
/// this sheet is where the user performs: the sections re-resolve under it live.
|
||||
///
|
||||
/// **The tier came out at 12 ▸ PIVOT 2026-08-07.** It was adopted here alongside the git state until
|
||||
/// that day, because the sheet was Pro's; git is tier-independent now, so what the doors validate on
|
||||
/// is the board's mode alone.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class BoardSettingsPresentation {
|
||||
|
||||
var isPresented = false
|
||||
|
||||
/// The tier this window's board composed under — the free tier's default until a session says
|
||||
/// otherwise, which is the harmless direction (an unreachable sheet).
|
||||
private(set) var tier: Tier = .free
|
||||
|
||||
/// This window's board git state, `nil` under the free tier and on a window whose session has not
|
||||
/// been adopted yet.
|
||||
/// This window's board git state, `nil` on a window whose session has not been adopted yet —
|
||||
/// which reads as mode `none`, the harmless direction (an add-git sheet, offered to a board that
|
||||
/// may well already have a repository, is nothing anyone can act on before adoption lands).
|
||||
private(set) var git: HistoryStore?
|
||||
|
||||
/// Called once per window, from the same place the titlebar widget is handed the same two facts.
|
||||
func adopt(tier: Tier, git: HistoryStore?) {
|
||||
self.tier = tier
|
||||
/// Called once per window, from the same place the titlebar widget is handed the same fact.
|
||||
func adopt(git: HistoryStore?) {
|
||||
self.git = git
|
||||
}
|
||||
|
||||
/// What the sheet would show right now — and therefore, when empty, that there is no sheet to
|
||||
/// show (`BoardSettingsAvailability`).
|
||||
///
|
||||
/// **An unadopted window has none**, and that is a `nil` check rather than a mode reading: every
|
||||
/// control this sheet hosts writes *through* the git state (`section(_:)` renders nothing without
|
||||
/// one), so a window that has not been handed its session yet would otherwise offer a sheet of
|
||||
/// bare headers. It was the free tier's default that kept this shut before 12 ▸ PIVOT 2026-08-07;
|
||||
/// what keeps it shut now is the honest absence of a session, which is the only thing a `nil`
|
||||
/// ever meant here.
|
||||
var sections: [BoardSettingsSection] {
|
||||
BoardSettingsSection.resolve(
|
||||
tier: tier,
|
||||
mode: git?.mode ?? .none,
|
||||
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
|
||||
guard let git else { return [] }
|
||||
return BoardSettingsSection.resolve(
|
||||
mode: git.mode,
|
||||
isRepositoryUnreadable: git.isRepositoryUnreadable
|
||||
)
|
||||
}
|
||||
|
||||
/// Both doors' validation: the menu row's `disabled` state and whether the popover shows its row
|
||||
/// at all.
|
||||
/// at all. Derived from `sections` rather than from `BoardSettingsAvailability` directly, so the
|
||||
/// unadopted case above cannot answer one way here and another there.
|
||||
var isReachable: Bool {
|
||||
BoardSettingsAvailability.resolve(
|
||||
tier: tier,
|
||||
mode: git?.mode ?? .none,
|
||||
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
|
||||
)
|
||||
!sections.isEmpty
|
||||
}
|
||||
|
||||
/// **Opening, not toggling** — unlike ⌘I. A sheet is modal to its window and carries its own
|
||||
@@ -118,22 +122,30 @@ extension FocusedValues {
|
||||
|
||||
// MARK: - What the sheet holds
|
||||
|
||||
/// **The sheet's inventory for one board**, as a pure function of the tier and the mode — the shape
|
||||
/// **The sheet's inventory for one board**, as a pure function of the mode — the shape
|
||||
/// `BoardGitSection.resolve` has one surface over, and for the same reason: the *contents* are the
|
||||
/// part worth pinning and the SwiftUI that renders them is not.
|
||||
///
|
||||
/// **The tier axis came out at 12-editions.md ▸ PIVOT 2026-08-07**: this resolved `guard tier ==
|
||||
/// .pro else { return [] }` first and the mode second until git left the paywall. Every board can
|
||||
/// reach the setup it has now, and what it has is still the mode's answer — the sheet's whole
|
||||
/// vocabulary is repository-shaped, so a board with nothing repository-shaped to say still hosts
|
||||
/// nothing.
|
||||
///
|
||||
/// Ordered as the sheet lays them out, top to bottom. pro-m2's cards each add a case here and a
|
||||
/// branch in `BoardSettingsSheet.section(_:)` — nothing else.
|
||||
enum BoardSettingsSection: String, Equatable, CaseIterable, Identifiable {
|
||||
|
||||
/// Pro, mode `none`: **add-git** (06-history-undo.md ▸ Rules ▸ Opt-in init).
|
||||
/// Mode `none`: **add-git** (06-history-undo.md ▸ Rules ▸ Opt-in init) — the offer, on every
|
||||
/// tier since the pivot, and still never an auto-init (git is opt-in per board, 12 ▸ PIVOT
|
||||
/// 2026-08-07).
|
||||
case git
|
||||
|
||||
/// Pro, mode `git`: **branch creation**. Switching stays in the popover (03 ▸ Board popover);
|
||||
/// Mode `git`: **branch creation**. Switching stays in the popover (03 ▸ Board popover);
|
||||
/// create-and-switch runs 06's identical settle sequence from here.
|
||||
case branch
|
||||
|
||||
/// Pro, mode `git`: the **commit identity** name/email that repo-local `.git/config` carries
|
||||
/// Mode `git`: the **commit identity** name/email that repo-local `.git/config` carries
|
||||
/// (06 ▸ Interaction with external writers).
|
||||
case commitIdentity
|
||||
|
||||
@@ -154,14 +166,9 @@ enum BoardSettingsSection: String, Equatable, CaseIterable, Identifiable {
|
||||
/// `git` — every other mode has no repository for the probe to have failed on, and a caller
|
||||
/// that has no git state to ask is describing one of those boards.
|
||||
static func resolve(
|
||||
tier: Tier,
|
||||
mode: BoardGitMode,
|
||||
isRepositoryUnreadable: Bool = false
|
||||
) -> [BoardSettingsSection] {
|
||||
// **The free tier has no setup to host** (12-editions.md ▸ The free tier and `.git`): git is
|
||||
// the Pro subscription's, "any `.git` is inert", and 03 gives the free tier's whole git story
|
||||
// as the popover's one-line pointer. There is nothing for a sheet to be about.
|
||||
guard tier == .pro else { return [] }
|
||||
switch mode {
|
||||
case .none:
|
||||
return [.git]
|
||||
@@ -204,15 +211,14 @@ enum BoardSettingsSection: String, Equatable, CaseIterable, Identifiable {
|
||||
/// Reachable **iff the sheet has something to show**, which is the rule rather than a shortcut: a
|
||||
/// surface whose whole job is hosting setup controls has no honest empty state, and deriving the
|
||||
/// answer from the inventory is what keeps the two from drifting when pro-m2's sections land. In
|
||||
/// today's terms that reads: Pro, on a board whose mode is `none` or `git`.
|
||||
/// today's terms that reads: a board whose mode is `none` or `git`, on **any tier** — the Pro
|
||||
/// requirement that stood beside it retired with 12-editions.md ▸ PIVOT 2026-08-07.
|
||||
///
|
||||
/// The two unreachable postures are unreachable for *different* reasons, and both are the design's:
|
||||
///
|
||||
/// - **The free tier**: no setup exists there at all (03-board-ui.md ▸ Board popover;
|
||||
/// 12-editions.md). The popover's own postures are untouched by this card — an ordinary board
|
||||
/// shows nothing, a board carrying an inert `.git` shows the one-line Pro pointer.
|
||||
/// - **Pro, repo-nested**: `BoardSettingsSection.resolve`'s own comment carries this one — nothing
|
||||
/// setup-shaped can apply, so the popover's prose stands and no door opens.
|
||||
/// The unreachable postures are unreachable for reasons that are all the board's, and all the
|
||||
/// design's — `BoardSettingsSection.resolve`'s own comments carry them one by one: **repo-nested**
|
||||
/// and **unverifiable**, where nothing setup-shaped can apply, and **git mode over a repository that
|
||||
/// will not open**, where both sections would be writes into something the app cannot open. In each,
|
||||
/// the popover's prose stands and no door opens.
|
||||
///
|
||||
/// The menu **row stays visible and disabled** either way (standard menu validation — a command that
|
||||
/// does not apply here is still a command this app has), while the **popover row appears only where
|
||||
@@ -221,12 +227,10 @@ enum BoardSettingsSection: String, Equatable, CaseIterable, Identifiable {
|
||||
enum BoardSettingsAvailability {
|
||||
|
||||
static func resolve(
|
||||
tier: Tier,
|
||||
mode: BoardGitMode,
|
||||
isRepositoryUnreadable: Bool = false
|
||||
) -> Bool {
|
||||
!BoardSettingsSection.resolve(
|
||||
tier: tier,
|
||||
mode: mode,
|
||||
isRepositoryUnreadable: isRepositoryUnreadable
|
||||
).isEmpty
|
||||
|
||||
@@ -44,7 +44,8 @@ extension NSToolbarItem.Identifier {
|
||||
/// enable exactly when that board has a step to cross and no read-only lock stands
|
||||
/// (13-native-undo.md ▸ Rules). **Every board has undo in every tier**, so there is no tier-shaped
|
||||
/// disablement to write: 03's parenthetical about boards without undo is 06's *git* substrate, which
|
||||
/// only a Pro subscription binds.
|
||||
/// a board binds by being in git mode — on any tier since 12-editions.md ▸ PIVOT 2026-08-07, and
|
||||
/// under a Pro subscription before it.
|
||||
///
|
||||
/// Their labels are the design's one exception to the menu-title rule: `NSUndoManager` rewrites the
|
||||
/// *menu* titles as the stack changes ("Undo Move Card"), which a toolbar label does not track, so
|
||||
|
||||
@@ -83,9 +83,15 @@ enum CardHistoryRows {
|
||||
/// ### Its existence is the section's visibility rule
|
||||
///
|
||||
/// "The section is **absent** on boards without app-managed git (mode none, repo-nested) — same
|
||||
/// honesty rule as the popover's git section" (05 ▸ History), and the free tier has no git state at
|
||||
/// all (12-editions.md). So the host builds one of these only in mode `git`, and `nil` is the whole
|
||||
/// of the absence — no placeholder, no empty header, nothing to explain.
|
||||
/// honesty rule as the popover's git section" (05 ▸ History). So the host builds one of these only
|
||||
/// where the board's history is genuinely git-backed — its `HistoryStore` in mode `git` — and `nil`
|
||||
/// is the whole of the absence: no placeholder, no empty header, nothing to explain.
|
||||
///
|
||||
/// **Per board, never per tier** (12-editions.md ▸ PIVOT 2026-08-07). 12's old tier matrix listed
|
||||
/// the card History sidebar as a Pro row, so the section's absence used to have two causes at once —
|
||||
/// a gitless board, or a free-tier session with no git state to ask. Git is tier-independent now:
|
||||
/// the one question left is whether *this board* has a repository the app manages, which is the
|
||||
/// question 05 was always asking.
|
||||
///
|
||||
/// ### It re-reads rather than subscribes
|
||||
///
|
||||
|
||||
@@ -97,8 +97,8 @@ struct CardWindowView: View {
|
||||
/// the close can fold it, which is why it arrives here rather than being made here.
|
||||
let undo: CardWindowUndo
|
||||
/// **This card's commit trail** (05 ▸ History), or `nil` on every board with no app-managed git —
|
||||
/// the free tier, mode none, and repo-nested boards. The `nil` *is* the section's absence rule;
|
||||
/// see `historySlot`.
|
||||
/// mode none, repo-nested, unverifiable. The `nil` *is* the section's absence rule; see
|
||||
/// `historySlot`. A per-board question on every tier since 12-editions.md ▸ PIVOT 2026-08-07.
|
||||
let history: CardHistory?
|
||||
/// The whole-window file drop (05 ▸ Attachments: "the drop surface remains the **whole
|
||||
/// window**"). `nil` only where a caller has no store to import through.
|
||||
@@ -338,10 +338,14 @@ struct CardWindowView: View {
|
||||
/// commit trail, read-only … newest first — semantic subject, relative date, author").
|
||||
///
|
||||
/// **Absence is the `nil`, and it is the whole rule.** "The section is absent on boards without
|
||||
/// app-managed git (mode none, repo-nested) — same honesty rule as the popover's git section",
|
||||
/// and the free tier has no git state at all (12-editions.md ▸ The free tier and `.git`). The host
|
||||
/// builds a `CardHistory` only in mode `git`, so there is no placeholder here to decide about:
|
||||
/// what the slot reserves is the **position**, and on every other board that position is empty.
|
||||
/// app-managed git (mode none, repo-nested) — same honesty rule as the popover's git section".
|
||||
/// The host builds a `CardHistory` only where the board's history is git-backed (mode `git`), so
|
||||
/// there is no placeholder here to decide about: what the slot reserves is the **position**, and
|
||||
/// on every other board that position is empty.
|
||||
///
|
||||
/// **The tier is not one of the inputs** (12-editions.md ▸ PIVOT 2026-08-07 — git left the
|
||||
/// paywall, retiring 12's tier matrix row that made this a Pro surface): a git board shows its
|
||||
/// trail on any tier, a gitless one shows nothing on any tier.
|
||||
///
|
||||
// A later card: View ▸ History, which focuses this section (11-command-nexus.md).
|
||||
@ViewBuilder
|
||||
|
||||
Reference in New Issue
Block a user