Realign undo with the evening rulings — repo-nested and identity anchors
Repo-nested boards bind native undo in every tier (25d2513): the no-undo case is gone, makeHistoryProvider answers git or native, and the native path provably never touches the enclosing repository's .git. Session undo steps anchor by card identity, never by path (9119aa1): HistoryAnchor carries the card UUID (plus comment/draft vocabulary) and apply-time validation resolves the current folder via the same both-container walk writeCardBody uses — a board-side lane or trash move no longer stales the coarse close step, while a genuine field collision still skips it whole. 2448 tests in 423 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
+31
-20
@@ -291,7 +291,7 @@ public final class AppModel {
|
||||
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
|
||||
/// nothing renders from it.
|
||||
///
|
||||
/// ### The three answers, and the one `nil` among them
|
||||
/// ### The two answers, and the `nil` that is no longer one of 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
|
||||
@@ -300,17 +300,26 @@ public final class AppModel {
|
||||
/// 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`** — the **native stack**, exactly as in the free tier. "Gitless boards bind
|
||||
/// the native undo stack in every tier — an upgrade never removes undo" (12).
|
||||
/// - **Mode `repoNested`** — **no provider**, the one no-undo case: a board inside somebody
|
||||
/// else's repository is one the app "leaves strictly alone … so they get **no undo**" (06
|
||||
/// ▸ Rules), and an in-memory stack there would be the app-managed undo journal that rule
|
||||
/// forbids. Edit ▸ Undo/Redo and the toolbar pair disable there and nowhere else but under a
|
||||
/// lock and on an empty stack (03-board-ui.md ▸ Toolbar ▸ Catalog).
|
||||
/// - **Mode `none` and mode `repoNested` 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).
|
||||
///
|
||||
/// The `HistoryStore` argument is what makes that decidable here, and it is why `beginSession`
|
||||
/// composes the git state *before* the provider: which substrate a board gets is a question about
|
||||
/// its repository, and a root that had to ask the disk itself would be a second detection.
|
||||
/// **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,
|
||||
/// session-scoped — so what repo-nested denies is app-managed history, never ⌘Z" (13's header).
|
||||
/// It also made the Pro upgrade story exceptional, which was the other half of the same defect:
|
||||
/// the free tier could not tell such a board from a plain one and bound the native stack anyway,
|
||||
/// so subscribing *removed* undo from exactly the boards it left alone. Edit ▸ Undo/Redo and the
|
||||
/// toolbar pair now disable only under a lock and on an empty stack.
|
||||
///
|
||||
/// This closure therefore never answers `nil`, and the seam stays optional for the seam's own
|
||||
/// reason: a test binds a substrate-less board through it (`BoardUndoManager.history`).
|
||||
///
|
||||
/// The `HistoryStore` argument is what makes the git/gitless split decidable here, and it is why
|
||||
/// `beginSession` composes the git state *before* the provider: which substrate a board gets is a
|
||||
/// question about its repository, and a root that had to ask the disk itself would be a second
|
||||
/// detection.
|
||||
///
|
||||
/// ### Consumers
|
||||
///
|
||||
@@ -322,8 +331,7 @@ public final class AppModel {
|
||||
guard let git else { return NativeHistoryProvider() }
|
||||
switch git.mode {
|
||||
case .git: return GitHistoryProvider(boardRoot: store.rootURL)
|
||||
case .none: return NativeHistoryProvider()
|
||||
case .repoNested: return nil
|
||||
case .none, .repoNested: return NativeHistoryProvider()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,11 +357,13 @@ public final class AppModel {
|
||||
/// Which implementation it is, is the tier's answer and nobody else's
|
||||
/// (12-editions.md ▸ The provider seam) — see `AppModel.makeHistoryProvider`.
|
||||
///
|
||||
/// **`nil` is a board with no undo at all** — a repo-nested board under Pro, and nothing else
|
||||
/// (06-history-undo.md ▸ Rules: boards inside an existing repository "get **no undo**").
|
||||
/// Gitless boards bind the native stack in every tier (re-ruled 2026-07-31 — see
|
||||
/// `AppModel.makeHistoryProvider`). The command surface disables through `undoManager`, which
|
||||
/// answers the empty way over an absent substrate.
|
||||
/// **`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,
|
||||
/// repo-nested included, bind the native stack in every tier, and git boards bind the git
|
||||
/// provider. What keeps the optionality is the seam rather than a board: a test binds a
|
||||
/// substrate-less session through `makeHistoryProvider`, and a store with no session at all
|
||||
/// registers nothing (`BoardStore.registerStep`). The command surface disables through
|
||||
/// `undoManager`, which answers the empty way over an absent substrate.
|
||||
///
|
||||
/// 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 —
|
||||
@@ -398,8 +408,9 @@ public final class AppModel {
|
||||
/// **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` the native stack, `repoNested` nothing. The popover's git section is the other
|
||||
/// reader.
|
||||
/// `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
|
||||
|
||||
@@ -14,8 +14,10 @@ import Foundation
|
||||
///
|
||||
/// `repoNested` is not "git mode with the repository somewhere else". A board inside a user's
|
||||
/// existing repository gets **no app-managed git at all** — "no nested repo, no commits into the
|
||||
/// user's repo, no undo" (06 ▸ Rules) — which makes it as distinct from `git` as `none` is, and the
|
||||
/// reason it is a case rather than a flag on `git`.
|
||||
/// user's repo" (06 ▸ Rules) — which makes it as distinct from `git` as `none` is, and the reason it
|
||||
/// is a case rather than a flag on `git`. What it no longer costs is ⌘Z: the native stack binds here
|
||||
/// too (re-ruled 2026-07-31 — 13-native-undo.md's header; `AppModel.makeHistoryProvider`), because
|
||||
/// that stack is memory-only and touches no repository, anybody's.
|
||||
///
|
||||
/// ### The remote half is deliberately absent
|
||||
///
|
||||
|
||||
@@ -161,8 +161,8 @@ public final class GitAutoCommitter {
|
||||
///
|
||||
/// `nil` wherever no `GitHistoryProvider` is listening — which in practice is nowhere a committer
|
||||
/// exists at all: a committer's existence is exactly mode `git`, and mode `git` is exactly where
|
||||
/// the composition root binds the git provider (`AppModel.makeHistoryProvider`). Mode-none boards
|
||||
/// have a native stack and no committer; repo-nested boards have neither.
|
||||
/// the composition root binds the git provider (`AppModel.makeHistoryProvider`). Mode-none and
|
||||
/// repo-nested boards alike have a native stack and no committer.
|
||||
@ObservationIgnored
|
||||
public var reportLanded: (@MainActor (GitLandedWindow) -> Void)?
|
||||
|
||||
|
||||
@@ -20,11 +20,12 @@ import os
|
||||
///
|
||||
/// 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
|
||||
/// root binds the git provider on mode `git`, the native stack on mode `none`, and nothing on a
|
||||
/// repo-nested board (`AppModel.makeHistoryProvider`, re-ruled 2026-07-31: the provider follows the
|
||||
/// board). Auto-commit, commit messages, branch controls, the identity fields, the
|
||||
/// `.gitignore` seed and its periodic housekeeping each arrived as their own card and are composed
|
||||
/// here now; remotes are pro-m2's and deliberately still absent.
|
||||
/// 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,
|
||||
/// branch controls, the identity fields, the `.gitignore` seed and its periodic housekeeping each
|
||||
/// arrived as their own card and are composed here now; remotes are pro-m2's and deliberately still
|
||||
/// absent.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class HistoryStore {
|
||||
|
||||
@@ -32,10 +32,19 @@ import os
|
||||
/// Beside the two closures, each registration below hands the funnel two `[HistoryExpectation]`
|
||||
/// lists: what the board must look like for the undo to be safe (the state the *forward* write left),
|
||||
/// and what it must look like for the redo to be safe (the state the *undo* leaves). One entry per
|
||||
/// item the gesture touched, naming that item's folder, whether it should be there and live, and the
|
||||
/// fields the write actually set — "extend the registration to carry whatever the predicate needs, no
|
||||
/// more". A field this gesture never wrote is never listed, which is what makes a foreign edit
|
||||
/// elsewhere — another card, another field of the same card — leave the step alone.
|
||||
/// item the gesture touched, naming that item, whether it should be there and live, and the fields
|
||||
/// the write actually set — "extend the registration to carry whatever the predicate needs, no more".
|
||||
/// A field this gesture never wrote is never listed, which is what makes a foreign edit elsewhere —
|
||||
/// another card, another field of the same card — leave the step alone.
|
||||
///
|
||||
/// ### Board gestures name a folder; session steps name a card
|
||||
///
|
||||
/// The one split in that sentence (`HistoryAnchor`, ruled 2026-07-31): a board gesture's expectation
|
||||
/// carries the path it wrote to, because where the item sits is what the gesture is *about*; a card
|
||||
/// window session's carries the card's **identity**, and the folder is resolved at apply time by the
|
||||
/// same snapshot walk the window resolves its own card with. So a lane move stales a move step (it
|
||||
/// should) and no longer stales the body edit that happens to have been typed into the same card.
|
||||
/// `folder(for:)` below is the one resolution both halves of a crossing go through.
|
||||
@MainActor
|
||||
extension BoardStore {
|
||||
|
||||
@@ -88,7 +97,9 @@ extension BoardStore {
|
||||
// call site has, and a store-wide "current window" would be a second answer able to be wrong
|
||||
// for exactly one gesture (the board styling a card whose window happens to be open).
|
||||
guard let sink: any HistoryProviding = window?.stack ?? history else {
|
||||
// No substrate at all — a repo-nested board (06 ▸ Rules), or a store with no session.
|
||||
// No substrate at all — a store with no session, or a test's substrate-less board. No
|
||||
// board the app composes lands here any more (`AppModel.makeHistoryProvider`, re-ruled
|
||||
// 2026-07-31: repo-nested boards bind the native stack like every other gitless board).
|
||||
// Nothing records the step, so nothing can ever retire it: the work is owed now.
|
||||
retirement?.run()
|
||||
return
|
||||
@@ -126,13 +137,18 @@ extension BoardStore {
|
||||
/// Everything about *what* the step does is `CardWindowUndo.netEffect()`'s; everything about
|
||||
/// whether there is a board to register it on is this method's:
|
||||
///
|
||||
/// - **A vanished card registers nothing.** 05-card-window.md ▸ Deletion & lifecycle dismisses the
|
||||
/// window when its card leaves the board — into the trash, with its lane, to another board — and
|
||||
/// the card's own departure is already a board step of its own (`deleteCard`). A session step
|
||||
/// naming folders that have moved could only be a step that skips, so the honest answer is not
|
||||
/// to register one: the window's fine stack dies with the window, as 13's session-only rule has
|
||||
/// it. (A trashed card keeps its `comments/.trash/` too — "a trashed card carries its
|
||||
/// `comments/`", 01-storage-format.md — and the residue sweeps at the next open of that card.)
|
||||
/// - **A card that resolves nowhere registers nothing** — purged, or moved out of the board.
|
||||
/// There is no folder for the step's components to be about, so a step registered here could
|
||||
/// only be a step that skips, and the honest answer is not to register one: the window's fine
|
||||
/// stack dies with the window, as 13's session-only rule has it.
|
||||
/// - **A card in the trash still registers**, and that is the ruling of 2026-07-31 read at the
|
||||
/// coarse step: the resolution below is `cardBodyTarget`'s, spanning both containers exactly as
|
||||
/// `writeCardBody`'s does, so "a trash move" is one of the tracked relocations that "never
|
||||
/// stales the step". 05-card-window.md ▸ Deletion & lifecycle dismisses the window when its card
|
||||
/// is deleted, and the session it was in the middle of is still the user's to walk back — into
|
||||
/// the trash folder the card now sits in, whose subtree the delete moved intact. (A trashed card
|
||||
/// carries its `comments/` — 01-storage-format.md — which is what makes that true of the deleted
|
||||
/// comments too.)
|
||||
/// - **A session with no net change registers nothing**, which is `netEffect()`'s `nil`.
|
||||
///
|
||||
/// - Parameter retiring: the deferred `comments/.trash/` purge (13 ▸ Interaction with the trash).
|
||||
@@ -146,11 +162,11 @@ extension BoardStore {
|
||||
inCard cardID: ItemID,
|
||||
retiring: @escaping @MainActor () -> Void
|
||||
) -> Bool {
|
||||
guard let item = Self.boardItem(cardID, in: snapshot), item.cardID != nil else { return false }
|
||||
guard let target = Self.cardBodyTarget(cardID, in: snapshot) else { return false }
|
||||
guard let net = window.netEffect() else { return false }
|
||||
registerStep(
|
||||
HistoryPhrase.cardSession,
|
||||
subject: item.title,
|
||||
subject: Self.cardTitle(at: target, in: snapshot),
|
||||
retiring: retiring,
|
||||
undoExpects: net.undoExpects,
|
||||
redoExpects: net.redoExpects,
|
||||
@@ -178,7 +194,7 @@ extension BoardStore {
|
||||
// gone is not one to pop: the stack is about to be cleared with the session anyway.
|
||||
guard let store else { return .failed }
|
||||
|
||||
guard HistoryStaleness.isCurrent(expectations) else {
|
||||
guard HistoryStaleness.isCurrent(expectations, resolvedBy: store.folder(for:)) else {
|
||||
store.banners.postSkippedStep(direction, subject: subject)
|
||||
return .skipped
|
||||
}
|
||||
@@ -206,6 +222,39 @@ extension BoardStore {
|
||||
|
||||
private static let historyLogger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "history")
|
||||
|
||||
// MARK: Resolving an anchor at apply time
|
||||
|
||||
/// **Where a step's anchor points on this board right now** — the one resolver, read by the
|
||||
/// staleness predicate and by every card-anchored inverse below (13 ▸ Rules, ruled 2026-07-31:
|
||||
/// "apply-time validation resolves the card's *current* folder exactly the way the window itself
|
||||
/// always resolves its card").
|
||||
///
|
||||
/// One method rather than two so validation and the write it guards can never disagree about
|
||||
/// where a step is aimed: `cross` checks with this and the inverse writes with it, in that order,
|
||||
/// against the same snapshot and the same `rootURL` — which a mid-session folder rename may have
|
||||
/// moved (`HistoryAnchor.folder(under:in:)`).
|
||||
func folder(for anchor: HistoryAnchor) -> URL? {
|
||||
anchor.folder(under: rootURL, in: snapshot)
|
||||
}
|
||||
|
||||
/// The same, as a write's precondition rather than a question.
|
||||
///
|
||||
/// Unreachable in the ordinary crossing — `cross` has already validated every anchor, and an
|
||||
/// unresolvable one skipped the whole step before any of this ran — so the throw exists to keep
|
||||
/// the impossible case honest rather than to be caught: an inverse that could not find its card
|
||||
/// must not silently write nothing and report success. It reads as an ordinary write failure,
|
||||
/// because that is what it would be.
|
||||
func requiredFolder(for anchor: HistoryAnchor, _ operation: WriteOperation) throws(BoardWriteError) -> URL {
|
||||
guard let folder = folder(for: anchor) else {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: rootURL.path,
|
||||
reason: .staleTarget(message: "the card this step was registered against is no longer on the board")
|
||||
)
|
||||
}
|
||||
return folder
|
||||
}
|
||||
|
||||
// MARK: Creates
|
||||
|
||||
/// One item a gesture brought into being: where it landed, the bytes it landed with, and any
|
||||
|
||||
@@ -61,14 +61,17 @@ public final class BoardUndoManager: UndoManager {
|
||||
///
|
||||
/// ### `nil` is a board with **no undo provider**, and it is a real state
|
||||
///
|
||||
/// A **repo-nested** board under Pro gets no provider at all, and since the re-ruling of
|
||||
/// 2026-07-31 it is the only board that does: "the pair disabled only on repo-nested boards,
|
||||
/// under locks, and on empty stacks — the provider follows the board, so gitless boards bind
|
||||
/// 13-native-undo.md's native stack in **every** tier" (03-board-ui.md ▸ Toolbar ▸ Catalog;
|
||||
/// 06-history-undo.md ▸ Rules). Every question below answers the empty way, so the Edit menu's
|
||||
/// rows, the toolbar pair, and ⌘Z itself go quiet together, through the same validation path a
|
||||
/// lock uses. Modelling it as an absent substrate rather than as a substrate that always says no
|
||||
/// is the honest shape: there is nothing there, and nothing can accidentally accumulate in it.
|
||||
/// **No board the app composes is one any more** (re-ruled 2026-07-31, twice): "the pair disabled
|
||||
/// only under locks and on empty stacks — the provider follows the board, so 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). The repo-nested board was the last holder of this
|
||||
/// state and no longer is: that rule was about *git*, and this stack never touches git.
|
||||
///
|
||||
/// The state stays modelled because the seam still admits it — a test binds a substrate-less
|
||||
/// session through `AppModel.makeHistoryProvider` — and because an absent substrate is the honest
|
||||
/// shape for one: every question below answers the empty way, so the Edit menu's rows, the
|
||||
/// toolbar pair, and ⌘Z itself go quiet together through the same validation path a lock uses,
|
||||
/// and nothing can accidentally accumulate in a stack that is not there.
|
||||
///
|
||||
/// ### Settable, for exactly one event
|
||||
///
|
||||
|
||||
@@ -36,6 +36,14 @@ import Foundation
|
||||
/// prior style fields) — and the coarse step is their composition. That also makes the coarse step's
|
||||
/// *after*-values the values the app itself wrote, so a foreign edit landing between a gesture and
|
||||
/// the close makes the step stale (13's field-level predicate) instead of being quietly reverted.
|
||||
///
|
||||
/// ### The values are anchored to the card, not to its folder
|
||||
///
|
||||
/// "Session steps anchor by card identity, never by path" (13 ▸ Rules, ruled 2026-07-31): every write
|
||||
/// folded here names the card's UUID and the thread position inside it (`HistoryAnchor`), never the
|
||||
/// lane the card happened to be in when the gesture ran. The fold is therefore over identities the
|
||||
/// whole way down, and one board-side lane move — which used to stale every component of the coarse
|
||||
/// step at once — changes nothing about it.
|
||||
@MainActor
|
||||
public final class CardWindowUndo {
|
||||
|
||||
@@ -154,6 +162,11 @@ public final class CardWindowUndo {
|
||||
/// card fold to one target carrying both, while two that set the same dimension fold to one value.
|
||||
/// Presence is whole-target and takes the later answer, which is what makes a post-then-delete of
|
||||
/// one comment fold to "in the trash" rather than to two contradictory claims.
|
||||
///
|
||||
/// **A target is an anchor, not a path** (13 ▸ Rules, ruled 2026-07-31): every write folded here
|
||||
/// is a card window's, so every anchor is a card identity, and the fold is over identities the
|
||||
/// whole way down — which is what carries "session steps anchor by card identity" into the coarse
|
||||
/// step without the fold knowing anything about it.
|
||||
static func fold(_ lists: [[HistoryExpectation]]) -> Fold {
|
||||
var fold = Fold()
|
||||
for list in lists {
|
||||
@@ -170,7 +183,7 @@ public final class CardWindowUndo {
|
||||
var fields: [ExpectedField.Kind: ExpectedField]
|
||||
}
|
||||
|
||||
private var targets: [URL: Target] = [:]
|
||||
private var targets: [HistoryAnchor: Target] = [:]
|
||||
/// First-seen order, so the folded list a step carries is stable rather than hash-ordered —
|
||||
/// a skip banner and a test both read better when the card comes before its comments.
|
||||
///
|
||||
@@ -178,17 +191,17 @@ public final class CardWindowUndo {
|
||||
/// built by walking the same writes in opposite directions, so their orders differ by
|
||||
/// construction while the question being asked — did anything actually change — is about the
|
||||
/// values alone.
|
||||
private var order: [URL] = []
|
||||
private var order: [HistoryAnchor] = []
|
||||
|
||||
static func == (lhs: Fold, rhs: Fold) -> Bool { lhs.targets == rhs.targets }
|
||||
|
||||
/// **A later `.absent` clears what earlier writes said about the path**, which is how a move
|
||||
/// **A later `.absent` clears what earlier writes said about the target**, which is how a move
|
||||
/// inside one session folds correctly: a comment edited and then deleted leaves nothing at its
|
||||
/// live path, and carrying the edit's body expectation there would make the session's own step
|
||||
/// stale the moment it was registered. Every move-shaped step declares both of its paths
|
||||
/// precisely so this is expressible (`BoardStore.deleteComment`, `postComment`).
|
||||
/// live location, and carrying the edit's body expectation there would make the session's own
|
||||
/// step stale the moment it was registered. Every move-shaped step declares both of its
|
||||
/// anchors precisely so this is expressible (`BoardStore.deleteComment`, `postComment`).
|
||||
mutating func merge(_ expectation: HistoryExpectation) {
|
||||
let key = expectation.folder.standardizedFileURL
|
||||
let key = Self.key(expectation.anchor)
|
||||
if targets[key] == nil {
|
||||
order.append(key)
|
||||
targets[key] = Target(presence: expectation.presence, fields: [:])
|
||||
@@ -209,10 +222,18 @@ public final class CardWindowUndo {
|
||||
order.compactMap { key in
|
||||
guard let target = targets[key] else { return nil }
|
||||
let fields = Self.fieldOrder.compactMap { target.fields[$0] }
|
||||
return HistoryExpectation(folder: key, presence: target.presence, fields: fields)
|
||||
return HistoryExpectation(anchor: key, presence: target.presence, fields: fields)
|
||||
}
|
||||
}
|
||||
|
||||
/// Two anchors are one target when they name the same thing. Identity anchors already do that
|
||||
/// by construction — a UUID is its own normal form — so the only normalization left is the
|
||||
/// path anchor's, which a board gesture may spell with a trailing slash or a `.` component.
|
||||
private static func key(_ anchor: HistoryAnchor) -> HistoryAnchor {
|
||||
guard case let .path(url) = anchor else { return anchor }
|
||||
return .path(url.standardizedFileURL)
|
||||
}
|
||||
|
||||
private static let fieldOrder: [ExpectedField.Kind] = [.title, .order, .width, .background, .icon, .body]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,10 +203,10 @@ public struct HistoryStep {
|
||||
///
|
||||
/// `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 gitless board 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), a repo-nested board
|
||||
/// binds none at all, and Teams inherits Pro's.
|
||||
/// 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.
|
||||
///
|
||||
/// ### What this protocol deliberately does not say
|
||||
///
|
||||
|
||||
@@ -63,6 +63,99 @@ public enum ExpectedField: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HistoryAnchor
|
||||
|
||||
/// **What an expectation is an expectation *about*** — a folder fixed when the step was registered,
|
||||
/// or a card identity resolved afresh every time the step is crossed.
|
||||
///
|
||||
/// ### Board gestures anchor by path
|
||||
///
|
||||
/// A move, a reorder, a delete, a create, a board-issued restyle: the gesture *is* about where an
|
||||
/// item sits, its inverse is the move back, and the path it names is the path it wrote to. The
|
||||
/// container check rides in that path (`HistoryExpectation`), and for these steps that is exactly the
|
||||
/// reading wanted — a foreign restore out of the trash *should* stale a delete step's undo.
|
||||
///
|
||||
/// ### Session steps anchor by card identity
|
||||
///
|
||||
/// "**Session steps anchor by card identity, never by path**" (13-native-undo.md ▸ Rules, ruled
|
||||
/// 2026-07-31): "the coarse step — and the window's fine steps it folds — stores the card's UUID plus
|
||||
/// expected values, and apply-time validation resolves the card's *current* folder exactly the way
|
||||
/// the window itself always resolves its card (the per-snapshot UUID walk; `writeCardBody` already
|
||||
/// resolves trash locations on purpose). A tracked relocation — a lane move mid-session or after
|
||||
/// close, a trash move — therefore never stales the step; only genuine content changes do, which is
|
||||
/// what the validation exists to catch."
|
||||
///
|
||||
/// The defect that ruled it: every component of a card window's session carried a **lane-bearing**
|
||||
/// folder path, so one board-side lane move — a drag on the board while the window sat open, or any
|
||||
/// move after it closed — staled all of them at once and the whole session step skipped, though
|
||||
/// nothing about the card's *content* had changed.
|
||||
///
|
||||
/// ### The card-relative cases are a closed vocabulary
|
||||
///
|
||||
/// Four, and they are exactly the folders a card window's gestures write to: the card itself, one
|
||||
/// posted comment, one deleted comment, the composer's draft. Cases rather than a card id plus a
|
||||
/// relative path list, so the path grammar stays in one place — resolution calls `CommentThread`'s own
|
||||
/// folder helpers, and a step can never disagree with the thread reader about where a comment lives.
|
||||
public enum HistoryAnchor: Sendable, Hashable {
|
||||
|
||||
/// A folder, as the gesture resolved it at registration time.
|
||||
case path(URL)
|
||||
|
||||
/// A card's own folder, wherever the card is now.
|
||||
case card(ItemID)
|
||||
|
||||
/// `<card>/comments/<id>/` — one posted comment.
|
||||
case comment(ItemID, inCard: ItemID)
|
||||
|
||||
/// `<card>/comments/.trash/<id>/` — one deleted comment, undo's backing store.
|
||||
case trashedComment(ItemID, inCard: ItemID)
|
||||
|
||||
/// `<card>/comments/.draft/` — the composer's backing file.
|
||||
case commentDraft(inCard: ItemID)
|
||||
}
|
||||
|
||||
extension HistoryAnchor {
|
||||
|
||||
/// Where this anchor points **now**, or `nil` when it points nowhere.
|
||||
///
|
||||
/// **The card walk is `writeCardBody`'s, deliberately** (`BoardStore.cardBodyTarget`): the one
|
||||
/// resolution in the app that spans both containers, because 05-card-window.md ▸ Deletion &
|
||||
/// lifecycle already needs a card window's own flush to reach a card that was moved into the trash
|
||||
/// out from under it. 13 names that walk by hand as the one a session step resolves through, so a
|
||||
/// trash move is a tracked relocation here rather than a vanishing.
|
||||
///
|
||||
/// `nil` is "the card resolves nowhere — purged, or moved out of the board", which 13 calls "the
|
||||
/// honest skip". The snapshot is the store's own, one reload behind the app's own writes exactly as
|
||||
/// the card window's is: the window resolves its card this way on every gesture, so a step that
|
||||
/// resolved any *fresher* would be answering a question the window itself never asks.
|
||||
public func folder(under root: URL, in snapshot: BoardModel) -> URL? {
|
||||
switch self {
|
||||
case let .path(url):
|
||||
url
|
||||
case let .card(id):
|
||||
Self.cardFolder(id, under: root, in: snapshot)
|
||||
case let .comment(id, card):
|
||||
Self.cardFolder(card, under: root, in: snapshot).map { CommentThread.commentFolder(id, inCard: $0) }
|
||||
case let .trashedComment(id, card):
|
||||
Self.cardFolder(card, under: root, in: snapshot).map { CommentThread.trashedCommentFolder(id, inCard: $0) }
|
||||
case let .commentDraft(card):
|
||||
Self.cardFolder(card, under: root, in: snapshot).map { CommentThread.draftFolder(inCard: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
private static func cardFolder(_ id: ItemID, under root: URL, in snapshot: BoardModel) -> URL? {
|
||||
BoardStore.cardBodyTarget(id, in: snapshot)?.folder(under: root)
|
||||
}
|
||||
|
||||
/// The folder a `.path` anchor names, and `nil` for every identity anchor — the resolver for a
|
||||
/// caller with no board to resolve against, which in the app is nobody and in a test is the
|
||||
/// shortest way to check a path-anchored expectation.
|
||||
public var literalPath: URL? {
|
||||
guard case let .path(url) = self else { return nil }
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HistoryExpectation
|
||||
|
||||
/// What one folder must currently hold for a step to be safe to cross — the state that step's write
|
||||
@@ -84,11 +177,19 @@ public enum ExpectedField: Sendable, Equatable {
|
||||
/// leaves nothing. That is why `Presence` is a two-case answer rather than the tombstone era's
|
||||
/// three-way live/tombstoned/absent reading of a `deleted:` key — there is no key to read, and no
|
||||
/// ancestor to walk to find one.
|
||||
///
|
||||
/// **A card-anchored expectation makes the same check about a path it resolves rather than
|
||||
/// remembers**, which is the whole of the difference: a comment's anchor still names
|
||||
/// `comments/.trash/<id>` versus `comments/<id>`, so the container reading above is untouched, while
|
||||
/// the card's own lane — the part of the path no session gesture is about — stops being asserted.
|
||||
/// Which of the two anchorings a step uses is `HistoryAnchor`'s subject and the one thing this type
|
||||
/// stayed neutral about: everything below reads the folder the anchor resolves to, identically either
|
||||
/// way.
|
||||
public struct HistoryExpectation: Sendable, Equatable {
|
||||
|
||||
/// Where the item this step wrote to should be — the destination for a move, the item's own
|
||||
/// folder for everything else, and the board root for the board's own rename and styling.
|
||||
public let folder: URL
|
||||
/// What the item this step wrote to is addressed by — a path for a board gesture, a card identity
|
||||
/// for a session step (`HistoryAnchor`).
|
||||
public let anchor: HistoryAnchor
|
||||
|
||||
/// Whether the item should be there.
|
||||
public let presence: Presence
|
||||
@@ -107,26 +208,40 @@ public struct HistoryExpectation: Sendable, Equatable {
|
||||
case absent
|
||||
}
|
||||
|
||||
public init(folder: URL, presence: Presence, fields: [ExpectedField]) {
|
||||
self.folder = folder
|
||||
public init(anchor: HistoryAnchor, presence: Presence, fields: [ExpectedField]) {
|
||||
self.anchor = anchor
|
||||
self.presence = presence
|
||||
self.fields = fields
|
||||
}
|
||||
|
||||
/// The item is at this path and its fields say what the step set them to.
|
||||
public static func present(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation {
|
||||
HistoryExpectation(folder: folder, presence: .present, fields: fields)
|
||||
/// The item is where this anchor points and its fields say what the step set them to.
|
||||
public static func present(_ anchor: HistoryAnchor, _ fields: ExpectedField...) -> HistoryExpectation {
|
||||
HistoryExpectation(anchor: anchor, presence: .present, fields: fields)
|
||||
}
|
||||
|
||||
/// The same, for a caller whose field list is computed — the styling gesture's, which varies per
|
||||
/// dimension. A label rather than a second variadic, so `.present(folder)` stays unambiguous.
|
||||
/// dimension. A label rather than a second variadic, so `.present(anchor)` stays unambiguous.
|
||||
public static func present(_ anchor: HistoryAnchor, fields: [ExpectedField]) -> HistoryExpectation {
|
||||
HistoryExpectation(anchor: anchor, presence: .present, fields: fields)
|
||||
}
|
||||
|
||||
/// Nothing is where this anchor points.
|
||||
public static func absent(_ anchor: HistoryAnchor) -> HistoryExpectation {
|
||||
HistoryExpectation(anchor: anchor, presence: .absent, fields: [])
|
||||
}
|
||||
|
||||
/// The path-anchored trio, spelled with the folder a board gesture already holds — the shape every
|
||||
/// call site outside a card window uses.
|
||||
public static func present(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation {
|
||||
HistoryExpectation(anchor: .path(folder), presence: .present, fields: fields)
|
||||
}
|
||||
|
||||
public static func present(_ folder: URL, fields: [ExpectedField]) -> HistoryExpectation {
|
||||
HistoryExpectation(folder: folder, presence: .present, fields: fields)
|
||||
HistoryExpectation(anchor: .path(folder), presence: .present, fields: fields)
|
||||
}
|
||||
|
||||
/// Nothing is at this path.
|
||||
public static func absent(_ folder: URL) -> HistoryExpectation {
|
||||
HistoryExpectation(folder: folder, presence: .absent, fields: [])
|
||||
HistoryExpectation(anchor: .path(folder), presence: .absent, fields: [])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,23 +272,42 @@ public struct HistoryExpectation: Sendable, Equatable {
|
||||
/// The tombstone era's liveness half walked a folder's ancestors looking for a `deleted:` key, and
|
||||
/// needed the root to know where to stop. Materializing the trash removed the walk: an item's
|
||||
/// container is its path, and a path is checked by asking the filesystem whether anything is there.
|
||||
///
|
||||
/// ### It needs a *resolver*, though — one, injected
|
||||
///
|
||||
/// A session step's expectations name a card rather than a folder (`HistoryAnchor`, ruled
|
||||
/// 2026-07-31), so somebody has to turn the anchor into the path this reads. That somebody is the
|
||||
/// board — `BoardStore.folder(for:)`, the store's own snapshot walk — handed in as a closure rather
|
||||
/// than reached for, which keeps this type what it has always been: a predicate over disk with no
|
||||
/// board, no root and no state of its own.
|
||||
public enum HistoryStaleness {
|
||||
|
||||
/// Whether every target a step named still holds what that step left there.
|
||||
public static func isCurrent(_ expectations: [HistoryExpectation]) -> Bool {
|
||||
expectations.allSatisfy(isCurrent)
|
||||
///
|
||||
/// - Parameter resolve: where each anchor points now. **An anchor that resolves nowhere fails**,
|
||||
/// whatever its presence half says: "a card that resolves nowhere (purged, or moved out of the
|
||||
/// board) is the honest skip" (13 ▸ Rules), and reading an unresolvable card's `.absent`
|
||||
/// expectations as satisfied would let half a step through on a card that has left.
|
||||
public static func isCurrent(
|
||||
_ expectations: [HistoryExpectation],
|
||||
resolvedBy resolve: (HistoryAnchor) -> URL?
|
||||
) -> Bool {
|
||||
expectations.allSatisfy { expectation in
|
||||
guard let folder = resolve(expectation.anchor) else { return false }
|
||||
return isCurrent(expectation, at: folder)
|
||||
}
|
||||
}
|
||||
|
||||
/// One target's answer.
|
||||
/// One target's answer, at the folder its anchor resolved to.
|
||||
///
|
||||
/// A file that cannot be read or parsed fails a `.present` expectation: an `index.md` somebody
|
||||
/// has just broken is not one holding this step's after-value, and the honest reading of "the
|
||||
/// field no longer holds it" covers a field that can no longer be read at all.
|
||||
public static func isCurrent(_ expectation: HistoryExpectation) -> Bool {
|
||||
public static func isCurrent(_ expectation: HistoryExpectation, at folder: URL) -> Bool {
|
||||
guard expectation.presence != .absent else {
|
||||
return !FileManager.default.fileExists(atPath: expectation.folder.path)
|
||||
return !FileManager.default.fileExists(atPath: folder.path)
|
||||
}
|
||||
guard let document = index(at: expectation.folder) else { return false }
|
||||
guard let document = index(at: folder) else { return false }
|
||||
return expectation.fields.allSatisfy { matches($0, in: document) }
|
||||
}
|
||||
|
||||
|
||||
@@ -1502,15 +1502,28 @@ public final class BoardStore: HealHost {
|
||||
/// close step. The board popover, the Style… popover and the quick-style rows pass nothing, which
|
||||
/// is the board's stack — where a board-issued gesture belongs even when it names a card whose
|
||||
/// window is open.
|
||||
///
|
||||
/// **And so is the step's *anchoring***, by exactly the same split (13 ▸ Rules, ruled
|
||||
/// 2026-07-31). A window's restyle is a session gesture: it anchors to the card's identity, so the
|
||||
/// lane move that would once have staled it — and with it the whole coarse step it folds into —
|
||||
/// now resolves through. A board-issued restyle keeps its path anchors, because a board gesture's
|
||||
/// subjects are a *selection*, spanning lanes and the board root, and "board-stack steps keep
|
||||
/// their existing path-anchored expectations" is the scope the ruling drew.
|
||||
public func applyStyle(
|
||||
to target: StyleTarget,
|
||||
background: StyleChange = .keep,
|
||||
icon: StyleChange = .keep,
|
||||
on window: CardWindowUndo? = nil
|
||||
) {
|
||||
// A session gesture anchors to the card, everything else to the folder it resolved (above).
|
||||
// The level is asked once, off the target, because a window's editor names exactly one card
|
||||
// and a board gesture's selection may name lanes and the root — neither of which a card walk
|
||||
// could ever resolve.
|
||||
let anchorsByIdentity = window != nil && styleLevel(of: target) == .card
|
||||
let edits: [(
|
||||
id: ItemID?,
|
||||
folder: URL,
|
||||
anchor: HistoryAnchor,
|
||||
background: StyleChange,
|
||||
icon: StyleChange,
|
||||
priorBackground: FieldValue<String>,
|
||||
@@ -1520,9 +1533,15 @@ public final class BoardStore: HealHost {
|
||||
let background = Self.effective(background, against: subject.background)
|
||||
let icon = Self.effective(icon, against: subject.icon)
|
||||
guard background != .keep || icon != .keep else { return nil }
|
||||
let anchor: HistoryAnchor = if anchorsByIdentity, let id = subject.id {
|
||||
.card(id)
|
||||
} else {
|
||||
.path(subject.folder)
|
||||
}
|
||||
return (
|
||||
id: subject.id,
|
||||
folder: subject.folder,
|
||||
anchor: anchor,
|
||||
background: background,
|
||||
icon: icon,
|
||||
priorBackground: subject.background,
|
||||
@@ -1569,20 +1588,20 @@ public final class BoardStore: HealHost {
|
||||
subject: subject,
|
||||
on: window,
|
||||
undoExpects: edits.map {
|
||||
.present($0.folder, fields: Self.styledFields(background: $0.background, icon: $0.icon))
|
||||
.present($0.anchor, fields: Self.styledFields(background: $0.background, icon: $0.icon))
|
||||
},
|
||||
redoExpects: edits.map {
|
||||
.present($0.folder, fields: Self.restoredStyleFields(
|
||||
.present($0.anchor, fields: Self.restoredStyleFields(
|
||||
background: $0.background,
|
||||
priorBackground: $0.priorBackground,
|
||||
icon: $0.icon,
|
||||
priorIcon: $0.priorIcon
|
||||
))
|
||||
}
|
||||
) { _ in
|
||||
) { store in
|
||||
for edit in edits {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: edit.folder,
|
||||
inItemFolder: try store.requiredFolder(for: edit.anchor, .style(title: nil)),
|
||||
kind: edit.id == nil ? .board : nil,
|
||||
operation: .style(title: nil)
|
||||
) { document in
|
||||
@@ -1590,10 +1609,10 @@ public final class BoardStore: HealHost {
|
||||
Self.restore(edit.priorIcon, to: FrontmatterKeys.icon, in: &document)
|
||||
}
|
||||
}
|
||||
} redo: { _ in
|
||||
} redo: { store in
|
||||
for edit in edits {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: edit.folder,
|
||||
inItemFolder: try store.requiredFolder(for: edit.anchor, .style(title: nil)),
|
||||
kind: edit.id == nil ? .board : nil,
|
||||
operation: .style(title: nil)
|
||||
) { document in
|
||||
@@ -1971,14 +1990,17 @@ public final class BoardStore: HealHost {
|
||||
/// the delimiter were never this write's to change. That is the one inverse in the app whose
|
||||
/// fidelity is byte-level rather than field-level.
|
||||
///
|
||||
/// ### Its staleness predicate is the bytes, at the path the session wrote to
|
||||
/// ### Its staleness predicate is the bytes, at the card the session wrote to
|
||||
///
|
||||
/// "Body steps compare bytes" (13 ▸ Rules), so the expectation is the whole body span as this
|
||||
/// session left it — a foreign editor that changed one character of it skips the step rather than
|
||||
/// throwing that character away. The **container rides in the path** (`HistoryStaleness`): a
|
||||
/// session that ended because its card was moved to the trash registers against the trash folder
|
||||
/// it actually flushed into, and a later restore moves the card out from under the step, which
|
||||
/// the ordinary existence check then reads as the collision it is.
|
||||
/// throwing that character away.
|
||||
///
|
||||
/// **The step is anchored to the card, never to its folder** (13 ▸ Rules, ruled 2026-07-31): it
|
||||
/// carries the card's UUID and the bytes, and the folder is resolved at apply time by the walk
|
||||
/// `writeCardBody` itself uses. A lane move, mid-session or long after, therefore leaves the step
|
||||
/// alone, and so does the trash move a dismissing window flushes into — the two relocations this
|
||||
/// step used to be staled by, though neither is a change to the bytes it is about.
|
||||
///
|
||||
/// ### Which stack it lands on is the caller's to say
|
||||
///
|
||||
@@ -1994,19 +2016,26 @@ public final class BoardStore: HealHost {
|
||||
on window: CardWindowUndo? = nil
|
||||
) {
|
||||
guard priorBody != newBody, let target = Self.cardBodyTarget(cardID, in: snapshot) else { return }
|
||||
let folder = target.folder(under: rootURL)
|
||||
let title = Self.cardTitle(at: target, in: snapshot)
|
||||
let card = HistoryAnchor.card(cardID)
|
||||
let operation = WriteOperation.editBody(title: title)
|
||||
|
||||
registerStep(
|
||||
HistoryPhrase.name(.edit, kind: .card),
|
||||
subject: title,
|
||||
on: window,
|
||||
undoExpects: [.present(folder, .body(newBody))],
|
||||
redoExpects: [.present(folder, .body(priorBody))]
|
||||
) { _ in
|
||||
_ = try BoardWriter.writeBody(inItemFolder: folder, body: priorBody)
|
||||
} redo: { _ in
|
||||
_ = try BoardWriter.writeBody(inItemFolder: folder, body: newBody)
|
||||
undoExpects: [.present(card, .body(newBody))],
|
||||
redoExpects: [.present(card, .body(priorBody))]
|
||||
) { store in
|
||||
_ = try BoardWriter.writeBody(
|
||||
inItemFolder: try store.requiredFolder(for: card, operation),
|
||||
body: priorBody
|
||||
)
|
||||
} redo: { store in
|
||||
_ = try BoardWriter.writeBody(
|
||||
inItemFolder: try store.requiredFolder(for: card, operation),
|
||||
body: newBody
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -141,22 +141,29 @@ extension BoardStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
let folder = card.folder
|
||||
let title = card.title
|
||||
let draftFolder = CommentThread.draftFolder(inCard: folder)
|
||||
let postedFolder = CommentThread.commentFolder(posted.id, inCard: folder)
|
||||
// **Anchored to the card, not to its folder** (13 ▸ Rules, ruled 2026-07-31): both halves of
|
||||
// the move name the card's identity plus the thread position, and the card's folder is
|
||||
// resolved at apply time — so a lane move under an open window leaves this step, and the
|
||||
// coarse step folding it, exactly as sound as they were.
|
||||
let cardAnchor = HistoryAnchor.card(id)
|
||||
let operation = WriteOperation.postComment(title: title)
|
||||
registerStep(
|
||||
HistoryPhrase.comment,
|
||||
subject: title,
|
||||
on: window,
|
||||
undoExpects: [.present(postedFolder), .absent(draftFolder)],
|
||||
redoExpects: [.present(draftFolder), .absent(postedFolder)]
|
||||
) { _ in
|
||||
try BoardWriter.unpostComment(posted.id, inCard: folder, cardTitle: title)
|
||||
} redo: { _ in
|
||||
undoExpects: [.present(.comment(posted.id, inCard: id)), .absent(.commentDraft(inCard: id))],
|
||||
redoExpects: [.present(.commentDraft(inCard: id)), .absent(.comment(posted.id, inCard: id))]
|
||||
) { store in
|
||||
try BoardWriter.unpostComment(
|
||||
posted.id,
|
||||
inCard: try store.requiredFolder(for: cardAnchor, operation),
|
||||
cardTitle: title
|
||||
)
|
||||
} redo: { store in
|
||||
try BoardWriter.repostComment(
|
||||
as: posted.id,
|
||||
inCard: folder,
|
||||
inCard: try store.requiredFolder(for: cardAnchor, operation),
|
||||
stamping: posted.posted,
|
||||
cardTitle: title
|
||||
)
|
||||
@@ -205,19 +212,28 @@ extension BoardStore {
|
||||
on window: CardWindowUndo? = nil
|
||||
) {
|
||||
guard priorBody != newBody, let card = commentSubject(id) else { return }
|
||||
let folder = CommentThread.commentFolder(commentID, inCard: card.folder)
|
||||
let title = card.title
|
||||
let comment = HistoryAnchor.comment(commentID, inCard: id)
|
||||
let operation = WriteOperation.editComment(title: title)
|
||||
|
||||
registerStep(
|
||||
HistoryPhrase.name(.edit, kind: .comment),
|
||||
subject: title,
|
||||
on: window,
|
||||
undoExpects: [.present(folder, .body(newBody))],
|
||||
redoExpects: [.present(folder, .body(priorBody))]
|
||||
) { _ in
|
||||
_ = try BoardWriter.editComment(at: folder, body: priorBody, cardTitle: title)
|
||||
} redo: { _ in
|
||||
_ = try BoardWriter.editComment(at: folder, body: newBody, cardTitle: title)
|
||||
undoExpects: [.present(comment, .body(newBody))],
|
||||
redoExpects: [.present(comment, .body(priorBody))]
|
||||
) { store in
|
||||
_ = try BoardWriter.editComment(
|
||||
at: try store.requiredFolder(for: comment, operation),
|
||||
body: priorBody,
|
||||
cardTitle: title
|
||||
)
|
||||
} redo: { store in
|
||||
_ = try BoardWriter.editComment(
|
||||
at: try store.requiredFolder(for: comment, operation),
|
||||
body: newBody,
|
||||
cardTitle: title
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,9 +244,10 @@ extension BoardStore {
|
||||
///
|
||||
/// The step is the move read backwards, `moveToTrash`'s registration one level down and without
|
||||
/// its rank half: a comment's trash has no order, so there is no `.order` after-value to compare
|
||||
/// and existence is the whole predicate. The container rides in the path exactly as it does at
|
||||
/// board level — the undo expects the comment in `comments/.trash/`, the redo expects it back in
|
||||
/// the thread — so a foreign restore or a foreign re-delete skips the right half by itself.
|
||||
/// and existence is the whole predicate. The container rides in the *anchor* exactly as it rides
|
||||
/// in the path at board level — the undo expects the comment in `comments/.trash/`, the redo
|
||||
/// expects it back in the thread — so a foreign restore or a foreign re-delete skips the right
|
||||
/// half by itself, while the card's own lane is nowhere in either claim.
|
||||
///
|
||||
/// **Both sides of the move are declared**, `postComment`'s shape (added 2026-07-31 with the
|
||||
/// window stack): the undo needs the trashed folder there *and the live path free*, because the
|
||||
@@ -256,17 +273,30 @@ extension BoardStore {
|
||||
}
|
||||
guard landed != nil else { return false }
|
||||
|
||||
let trashed = CommentThread.trashedCommentFolder(commentID, inCard: folder)
|
||||
// `postComment`'s anchoring, for its reason: the pair of anchors is the move, and the card's
|
||||
// folder is resolved at apply time rather than baked into the step (13 ▸ Rules, ruled
|
||||
// 2026-07-31).
|
||||
let cardAnchor = HistoryAnchor.card(id)
|
||||
let comment = HistoryAnchor.comment(commentID, inCard: id)
|
||||
let trashed = HistoryAnchor.trashedComment(commentID, inCard: id)
|
||||
let operation = WriteOperation.deleteComment(title: title)
|
||||
registerStep(
|
||||
HistoryPhrase.name(.delete, kind: .comment),
|
||||
subject: title,
|
||||
on: window,
|
||||
undoExpects: [.present(trashed), .absent(live)],
|
||||
redoExpects: [.present(live), .absent(trashed)]
|
||||
) { _ in
|
||||
try BoardWriter.restoreComment(commentID, inCard: folder, cardTitle: title)
|
||||
} redo: { _ in
|
||||
_ = try BoardWriter.deleteComment(at: live, cardTitle: title)
|
||||
undoExpects: [.present(trashed), .absent(comment)],
|
||||
redoExpects: [.present(comment), .absent(trashed)]
|
||||
) { store in
|
||||
try BoardWriter.restoreComment(
|
||||
commentID,
|
||||
inCard: try store.requiredFolder(for: cardAnchor, operation),
|
||||
cardTitle: title
|
||||
)
|
||||
} redo: { store in
|
||||
_ = try BoardWriter.deleteComment(
|
||||
at: try store.requiredFolder(for: comment, operation),
|
||||
cardTitle: title
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ public enum Tier: String, Sendable, Equatable, Codable, CaseIterable {
|
||||
/// Lanework Pro — an active auto-renewable subscription. It is what puts git on the table; which
|
||||
/// substrate a given board then binds is the *board's* answer, not this case's (re-ruled
|
||||
/// 2026-07-31 — `AppModel.makeHistoryProvider`): the git provider on a git board
|
||||
/// (06-history-undo.md, 07-sync-collab.md), the same native stack the free tier uses on a gitless
|
||||
/// one, nothing on a repo-nested one.
|
||||
/// (06-history-undo.md, 07-sync-collab.md), and the same native stack the free tier uses on every
|
||||
/// board without app-managed git, repo-nested ones included.
|
||||
case pro
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user