diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index b3cc849..5ea10cc 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -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 diff --git a/Kanban/Git/BoardGitMode.swift b/Kanban/Git/BoardGitMode.swift index 6c83381..e1fe67a 100644 --- a/Kanban/Git/BoardGitMode.swift +++ b/Kanban/Git/BoardGitMode.swift @@ -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 /// diff --git a/Kanban/Git/GitAutoCommitter.swift b/Kanban/Git/GitAutoCommitter.swift index 050ee75..8ab7ade 100644 --- a/Kanban/Git/GitAutoCommitter.swift +++ b/Kanban/Git/GitAutoCommitter.swift @@ -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)? diff --git a/Kanban/Git/HistoryStore.swift b/Kanban/Git/HistoryStore.swift index 46f4ccf..5bb08ca 100644 --- a/Kanban/Git/HistoryStore.swift +++ b/Kanban/Git/HistoryStore.swift @@ -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 { diff --git a/Kanban/History/BoardStoreHistory.swift b/Kanban/History/BoardStoreHistory.swift index eb50a9a..989ea4e 100644 --- a/Kanban/History/BoardStoreHistory.swift +++ b/Kanban/History/BoardStoreHistory.swift @@ -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 diff --git a/Kanban/History/BoardUndoManager.swift b/Kanban/History/BoardUndoManager.swift index b88c6d6..cbdccb8 100644 --- a/Kanban/History/BoardUndoManager.swift +++ b/Kanban/History/BoardUndoManager.swift @@ -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 /// diff --git a/Kanban/History/CardWindowUndo.swift b/Kanban/History/CardWindowUndo.swift index 2750ef4..0f7f61d 100644 --- a/Kanban/History/CardWindowUndo.swift +++ b/Kanban/History/CardWindowUndo.swift @@ -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] } } diff --git a/Kanban/History/HistoryProviding.swift b/Kanban/History/HistoryProviding.swift index d2e933b..c9feba2 100644 --- a/Kanban/History/HistoryProviding.swift +++ b/Kanban/History/HistoryProviding.swift @@ -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 /// diff --git a/Kanban/History/HistoryStaleness.swift b/Kanban/History/HistoryStaleness.swift index 52f8831..9027428 100644 --- a/Kanban/History/HistoryStaleness.swift +++ b/Kanban/History/HistoryStaleness.swift @@ -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) + + /// `/comments//` — one posted comment. + case comment(ItemID, inCard: ItemID) + + /// `/comments/.trash//` — one deleted comment, undo's backing store. + case trashedComment(ItemID, inCard: ItemID) + + /// `/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/` versus `comments/`, 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) } } diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 3e079c4..6b3b29a 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -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, @@ -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 + ) } } diff --git a/Kanban/LiveStore/BoardStoreComments.swift b/Kanban/LiveStore/BoardStoreComments.swift index 03afc92..fae22a8 100644 --- a/Kanban/LiveStore/BoardStoreComments.swift +++ b/Kanban/LiveStore/BoardStoreComments.swift @@ -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 } diff --git a/Kanban/Tier/Tier.swift b/Kanban/Tier/Tier.swift index 94691d0..e25ef58 100644 --- a/Kanban/Tier/Tier.swift +++ b/Kanban/Tier/Tier.swift @@ -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 } diff --git a/KanbanTests/CardSessionUndoTests.swift b/KanbanTests/CardSessionUndoTests.swift index 260deb7..3151050 100644 --- a/KanbanTests/CardSessionUndoTests.swift +++ b/KanbanTests/CardSessionUndoTests.swift @@ -320,12 +320,13 @@ struct CardSessionCloseTests { #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty) } - @Test("A window whose card left the board registers nothing") - func aVanishedCardRegistersNoSessionStep() async throws { + @Test("A window whose card was trashed still registers — the trash is a relocation, not a vanishing") + func aTrashedCardStillRegistersItsSession() async throws { let fixture = try WriterFixture() defer { fixture.tearDown() } _ = try makeCommentBoard(fixture) let window = try makeWindow(fixture) + let original = try body(fixture, cardPath) editBody(window, to: "Edited.\n") // The card window's own Actions ▸ Delete — a board gesture, on the board's stack, which @@ -336,7 +337,34 @@ struct CardSessionCloseTests { #expect(window.board.undoActionName == "Delete Card") await window.session.endSession() - #expect(window.board.undoActionName == "Delete Card", "no session step for a card that has gone") + // "A tracked relocation — a lane move mid-session or after close, **a trash move** — never + // stales the step" (13 ▸ Rules, ruled 2026-07-31): the session resolves through the walk that + // spans both containers, so the step registers over the delete rather than being dropped. + #expect(window.board.undoActionName == "Edit Card") + + window.board.undo() + #expect(window.store.banners.signposts.isEmpty, "nothing about the card's content changed") + #expect(try body(fixture, ".trash/\(Ident.card1)") == original, + "walked back where the card is now, its subtree intact") + #expect(window.board.undoActionName == "Delete Card", "and the delete is the next step down") + } + + @Test("A card that resolves nowhere registers nothing") + func aCardThatResolvesNowhereRegistersNoSessionStep() async throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + _ = try makeCommentBoard(fixture) + let window = try makeWindow(fixture) + editBody(window, to: "Edited.\n") + + // Purged, or moved out of the board — neither container holds the card, so there is no folder + // for the step's components to be about and "the honest skip" is not to register one at all. + try FileManager.default.removeItem(at: fixture.url(cardPath)) + window.store.handleWatcherEvent(.treeChanged(.foreign)) + await window.store.awaitQuiescence() + + await window.session.endSession() + #expect(!window.board.canUndo, "no session step for a card that is nowhere") } @Test("A style change made in the window's sidebar joins the session, not the board") @@ -421,6 +449,127 @@ struct CardSessionStalenessTests { } } +// MARK: - Identity anchoring + +/// **Session steps anchor by card identity, never by path** (13-native-undo.md ▸ Rules, ruled +/// 2026-07-31) — the defect these tests exist for, stated as the gesture that produced it: a card +/// window's steps carried lane-bearing folder paths, so one board-side lane move staled *every* +/// component of the session step at once and the whole session skipped, though nothing about the +/// card's content had changed. +/// +/// The claim is not "moves are ignored". It is that a **relocation** and a **content change** are +/// different questions and only the second is what validation exists to catch — so the collision half +/// is pinned here beside the round trip, at the card's *new* home. +@MainActor +@Suite("Card session undo ▸ identity anchoring") +struct CardSessionAnchorTests { + + /// The one-lane comment board with somewhere to move the card to. + @MainActor + private func twoLaneBoard(_ fixture: WriterFixture) throws -> String { + let card = try makeCommentBoard(fixture) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) + return card + } + + /// The card's folder after a move into the second lane. + private let movedPath = "\(Ident.lane2)/\(Ident.card1)" + + @Test("A board-side lane move never stales the session — all three gestures still apply") + func aLaneMoveDoesNotStaleTheSessionStep() async throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try twoLaneBoard(fixture) + let deleted = commentPath(CommentIdent.one, inCard: card) + try fixture.item(deleted, commentText(body: "kept somewhere\n")) + let window = try makeWindow(fixture) + window.comments.reload() + let original = try body(fixture, cardPath) + + editBody(window, to: "Edited in the window.\n") + let posted = try #require(postComment(window, body: "A remark.\n")) + window.comments.delete(ItemID(rawValue: CommentIdent.one)) + + // The board moves the card to another lane while its window is open — a drag on the board, + // which is a board gesture on the board's own stack. Every folder the session wrote to has + // just changed path. + window.store.moveCards([cardID], toLane: ItemID(rawValue: Ident.lane2), at: 0) + window.store.handleWatcherEvent(.treeChanged(.appMediated)) + await window.store.awaitQuiescence() + #expect(fixture.exists(movedPath)) + #expect(window.board.undoActionName == "Move Card") + + await window.session.endSession() + #expect(window.board.undoActionName == "Edit Card", "the session registered over the move") + + window.board.undo() + #expect(window.store.banners.signposts.isEmpty, "a relocation is not a collision") + #expect(try body(fixture, movedPath) == original, "the body is back — at the card's new home") + #expect(!fixture.exists("\(movedPath)/comments/\(posted.rawValue)"), "the post is unposted") + #expect(fixture.exists("\(movedPath)/comments/.draft")) + #expect(fixture.exists("\(movedPath)/comments/\(CommentIdent.one)"), + "and the deleted comment is back, out of the trash that travelled with the card") + #expect(window.board.undoActionName == "Move Card", "the move is the next step down") + } + + @Test("A genuine field collision at the card's new home still skips the whole step") + func aFieldCollisionAfterAMoveStillSkipsWhole() async throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try twoLaneBoard(fixture) + let deleted = commentPath(CommentIdent.one, inCard: card) + try fixture.item(deleted, commentText(body: "kept somewhere\n")) + let window = try makeWindow(fixture) + window.comments.reload() + + editBody(window, to: "Edited in the window.\n") + window.comments.delete(ItemID(rawValue: CommentIdent.one)) + window.store.moveCards([cardID], toLane: ItemID(rawValue: Ident.lane2), at: 0) + window.store.handleWatcherEvent(.treeChanged(.appMediated)) + await window.store.awaitQuiescence() + await window.session.endSession() + + // Somebody else rewrites the body — at the folder the card now sits in, which is exactly where + // the anchor resolves. One component, and the whole step goes (13: "any stale component skips + // the whole step — never a partial session revert"). + _ = try BoardWriter.writeBody(inItemFolder: fixture.url(movedPath), body: "Somebody else.\n") + + window.board.undo() + #expect(window.store.banners.signposts.map(\.message) + == ["Undo skipped — 'Fix login' changed outside Lanework"]) + // The skip pops the step and **⌘Z falls through to the next one down** (13 ▸ Rules), which + // here is the board's own move — so the card, the foreign body and the still-deleted comment + // all travel back to the lane the move took them from. + #expect(fixture.exists(cardPath)) + #expect(try body(fixture, cardPath) == "Somebody else.\n", "never applied over a newer write") + #expect(!fixture.exists("\(cardPath)/comments/\(CommentIdent.one)"), + "and the comment half did not half-happen either") + #expect(try fixture.entryNames("\(cardPath)/comments/.trash").isEmpty, + "the skipped step retired, so the backing it was holding was purged with it") + #expect(!window.board.canUndo, "both steps are gone — one skipped, one applied") + } + + @Test("The window's own ⌘Z survives a lane move too — the fine steps carry the same anchors") + func theWindowStackSurvivesALaneMove() async throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + _ = try twoLaneBoard(fixture) + let window = try makeWindow(fixture) + let original = try body(fixture, cardPath) + + editBody(window, to: "Edited in the window.\n") + window.store.moveCards([cardID], toLane: ItemID(rawValue: Ident.lane2), at: 0) + window.store.handleWatcherEvent(.treeChanged(.appMediated)) + await window.store.awaitQuiescence() + + // "The coarse step — **and the window's fine steps it folds**" (13 ▸ Rules): the fold is only + // as sound as its components, so the window's own stack is anchored the same way. + window.window.stack.undo() + #expect(try body(fixture, movedPath) == original) + #expect(window.store.banners.signposts.isEmpty) + } +} + // MARK: - The deferred purge @MainActor @@ -543,8 +692,10 @@ struct CardSessionPurgeTests { @Test("A board with no substrate at all owes the purge immediately") func noProviderRunsTheRetirementAtOnce() throws { - // Repo-nested boards bind no provider (06-history-undo.md ▸ Rules), so nothing could ever - // report the step's death — the work is owed at registration or never. + // A store with no session behind it — no board the app composes binds no provider any more + // (`AppModel.makeHistoryProvider`, re-ruled 2026-07-31: repo-nested boards bind the native + // stack too). Nothing could ever report the step's death, so the work is owed at registration + // or never. let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) diff --git a/KanbanTests/CommentWriteTests.swift b/KanbanTests/CommentWriteTests.swift index 66a519d..3f74779 100644 --- a/KanbanTests/CommentWriteTests.swift +++ b/KanbanTests/CommentWriteTests.swift @@ -554,10 +554,12 @@ struct CommentUndoTests { let path = commentPath(CommentIdent.one, inCard: card) try fixture.item(path, commentText()) - #expect(HistoryStaleness.isCurrent([.present(fixture.url(path))])) - #expect(HistoryStaleness.isCurrent([.absent(fixture.url("\(card)/comments/.draft"))])) + // Path anchors resolve to themselves — the resolver a caller with no board hands in + // (`HistoryAnchor.literalPath`); the card-anchored halves are `CardSessionUndoTests`'. + #expect(HistoryStaleness.isCurrent([.present(fixture.url(path))], resolvedBy: \.literalPath)) + #expect(HistoryStaleness.isCurrent([.absent(fixture.url("\(card)/comments/.draft"))], resolvedBy: \.literalPath)) try FileManager.default.removeItem(at: fixture.url(path)) - #expect(!HistoryStaleness.isCurrent([.present(fixture.url(path))])) + #expect(!HistoryStaleness.isCurrent([.present(fixture.url(path))], resolvedBy: \.literalPath)) } } diff --git a/KanbanTests/GitUndoTests.swift b/KanbanTests/GitUndoTests.swift index fe2ac96..669b042 100644 --- a/KanbanTests/GitUndoTests.swift +++ b/KanbanTests/GitUndoTests.swift @@ -898,17 +898,21 @@ struct GitUndoSessionTests { /// **The provider follows the board, not the tier alone** (re-ruled 2026-07-31 — 12-editions.md /// ▸ The provider seam; 13-native-undo.md's header; 06-history-undo.md ▸ Rules), stated as the -/// matrix it is: gitless boards bind the native stack in *every* tier ("an upgrade never removes -/// undo"), a Pro git board binds the git provider, and a repo-nested board binds nothing. +/// matrix it is: **boards without app-managed git — repo-nested included — bind the native stack in +/// *every* tier** ("an upgrade never removes undo"), and a Pro git board binds the git provider. +/// There is no third answer any more. /// -/// ### The free tier's row is one cell wide, structurally +/// ### The repo-nested row stopped being an exception /// /// `HistoryStore.compose` returns `nil` off Pro, so a free-tier session never detects a mode at all /// and cannot tell a repo-nested board from a plain one — which is not an omission but 12 ▸ The free /// tier and `.git` verbatim: "opening a board that has one (a formerly-subscribed user's board, a /// 1.x board, **a repo-nested board**) works normally — files read and write as on any board, -/// **native undo runs**". 06's no-undo rule for repo-nested boards is a rule of a doc whose own first -/// line reads "Tier scope: Lanework Pro", and the tests below pin both halves. +/// **native undo runs**". Pro used to answer differently on the same board, which made subscribing +/// *remove* ⌘Z from it; the re-ruling of 2026-07-31 retired that case outright — "leave strictly +/// alone concerns *git*, and this stack never touches git — memory-only, journal-free, +/// session-scoped" (13's header) — so the two tiers now agree on every board there is, and the tests +/// below pin both halves of that agreement. @MainActor @Suite("Git undo ▸ which board gets a provider") struct GitUndoBindingTests { @@ -977,8 +981,8 @@ struct GitUndoBindingTests { #expect(log.crossings == ["undo Move Card"]) } - @Test("Pro on a repo-nested board binds no provider — the one no-undo case") - func proOnARepoNestedBoardBindsNothing() throws { + @Test("Pro on a repo-nested board binds the native stack — the no-undo case is retired") + func proOnARepoNestedBoardBindsTheNativeStack() throws { let outer = try WriterFixture() defer { outer.tearDown() } // A repository at the *parent*, with the board inside it — the nested posture. @@ -995,16 +999,26 @@ struct GitUndoBindingTests { let ref = try openBoard(model, at: boardRoot) let session = try #require(model.session(for: ref)) - // 06 ▸ Rules: a board inside somebody else's repository is "left strictly alone … so they get - // **no undo**" — no app-managed undo journal, which an in-memory stack here would be. + // The mode is detected — Pro walks the ancestors — and it no longer decides ⌘Z: "Pro binds it + // on mode-none **and repo-nested** boards alike … what repo-nested denies is app-managed + // history, never ⌘Z" (13's header, re-ruled 2026-07-31). #expect(session.gitMode == .repoNested) - #expect(session.history == nil, "the app leaves that repository strictly alone") - #expect(session.undoManager.canUndo == false) - #expect(session.undoManager.canRedo == false) - #expect(session.undoManager.undoMenuItemTitle == "Undo", "a bare row, with nothing to name") - // And a crossing that somehow started still writes nothing. + #expect(session.history is NativeHistoryProvider) + #expect(session.undoManager.canUndo == false, "empty, not absent") + + // A working stack, exactly as the free tier's on this same board — which is the whole point + // of the re-ruling: the two tiers answer alike here now. + let log = StepLog() + session.history?.register(log.step("Move Card")) + #expect(session.undoManager.canUndo) + #expect(session.undoManager.undoMenuItemTitle == "Undo Move Card") session.undoManager.undo() - session.undoManager.redo() + #expect(log.crossings == ["undo Move Card"]) + + // And the enclosing repository is still left strictly alone: no repository was created at the + // board, and nothing here reaches for the ancestor's `.git` (13: memory-only, journal-free). + #expect(!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path)) + #expect(session.git?.committer == nil, "no committer, and so nothing that could write there") } @Test("The free tier's repo-nested board still binds the native stack — it never detects one") @@ -1031,6 +1045,8 @@ struct GitUndoBindingTests { #expect(session.git == nil) #expect(session.gitMode == .none, "no detection ran; the session reports the tier's one mode") #expect(session.history is NativeHistoryProvider) + // Pro on this same board now answers identically (above) — the two tiers agree, which is what + // the re-ruling of 2026-07-31 bought: an upgrade never removes undo from *any* board. } @Test("Add-git swaps the substrate — the native stack is discarded, the git trail seeded")