Wire native undo into menus, toolbar, and command validation
The command surface was already almost entirely platform machinery — this card proves it and pins it. Headless probes established that NSWindow.validateMenuItem answers enablement AND rewrites the row title from the delegate-supplied manager, so 'Undo Move 3 Cards' flows step phrase to Edit menu with no code of ours; under the lock the rows dim and keep their names, the correct reading of the-stack-survives. The toolbar twins validate through validateUserInterfaceItem, which never touches labels — 03's static-label exception proven rather than asserted — and their specs' enablement abstention is pinned so nobody later adds a second, disagreeing answer. The one link a headless run cannot close is the nil-target key-window resolution itself: standard responder-chain behavior with none of our code in it, left as the manual check. Base-edition 'disabled without undo' scaffolding is reworded away — every base board has undo now. New suites cover the trash's two doors (delete-then-undo byte-identical to Put Back's effect), position-preserving restore of a middle card, and the capstone: five gestures forward, five presses back to the origin board, five forward again, the menu phrase asserted after every press. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -194,15 +194,15 @@ public final class AppModel {
|
||||
/// **The composition root for `HistoryProviding`** (12-editions.md ▸ The provider seam): what a
|
||||
/// board session's undo stack is built by, called once per board as its session begins.
|
||||
///
|
||||
/// Base binds the native stack — an `NSUndoManager` over inverse `WriteOperation`s
|
||||
/// (13-native-undo.md) — and that is the default here because it is the *shared* code's
|
||||
/// implementation: both targets compile it, and Pro runs it too until pro-m1 replaces this
|
||||
/// closure with the git provider (06-history-undo.md). Nothing in this file is
|
||||
/// edition-conditional; the edition difference is which closure the root installs.
|
||||
/// Base binds the native stack — a pair of step stacks over the inverses registered at the
|
||||
/// Writer boundary (13-native-undo.md, `NativeHistoryProvider`) — and that is the default here
|
||||
/// because it is the *shared* code's implementation: both targets compile it, and Pro runs it
|
||||
/// too until pro-m1 replaces this closure with the git provider (06-history-undo.md). Nothing in
|
||||
/// this file is edition-conditional; the edition difference is which closure the root installs.
|
||||
///
|
||||
/// It takes the store because that is what a provider is a history *of*: the git provider needs
|
||||
/// the board root it is a repository at, and the native one is about to need the same store's
|
||||
/// snapshots to compute inverses from. A property rather than an initializer argument so a test
|
||||
/// the board root it is a repository at, and the native one's steps are computed from the same
|
||||
/// store's snapshots. A property rather than an initializer argument so a test
|
||||
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
|
||||
/// nothing renders from it.
|
||||
@ObservationIgnored
|
||||
|
||||
@@ -152,8 +152,9 @@ struct ToolbarItemSpec {
|
||||
/// - **Undo and Redo have to reach the responder chain.** Their menu rows are the system's own —
|
||||
/// nil-target `undo:`/`redo:` — and "matching their menu items" (03) is literal here: a toolbar
|
||||
/// item with the same nil-target action validates and fires through exactly the same lookup, so
|
||||
/// the pair is disabled today for the same reason the menu rows are, and lights up in m8 with no
|
||||
/// change here. A SwiftUI `Button` cannot express that.
|
||||
/// the pair enables and disables with the menu rows by construction rather than by agreement,
|
||||
/// reading the board window's `BoardUndoManager` through `NSWindow`'s own validation
|
||||
/// (13-native-undo.md). A SwiftUI `Button` cannot express that.
|
||||
/// - **The search item hosts a real `NSSearchField`** with explicit first-responder control, settled
|
||||
/// in m5 for reasons `BoardSearchFieldController` records (⌘F must focus it from a menu item;
|
||||
/// Escape in an empty field must hand the keyboard back to the strip). A toolbar that already
|
||||
|
||||
@@ -143,9 +143,10 @@ public struct HistoryStep {
|
||||
/// "One stack per board, owned by the board session. Not per-window: every window over a board
|
||||
/// (board window, its card windows) shares the store and shares the stack" (13-native-undo.md
|
||||
/// ▸ Rules). `AppModel.BoardSession` is where that ownership lives, and the composition root binds
|
||||
/// which implementation it gets: base binds `NativeHistoryProvider` (an `NSUndoManager` stack over
|
||||
/// inverse `WriteOperation`s), Pro binds the git provider in pro-m1 (undo as forward restore commits
|
||||
/// over HEAD's first-parent ancestry — 06-history-undo.md), Teams inherits Pro's.
|
||||
/// which implementation it gets: base binds `NativeHistoryProvider` (two step stacks over the
|
||||
/// inverses registered at the Writer boundary), Pro binds the git provider in pro-m1 (undo as
|
||||
/// forward restore commits over HEAD's first-parent ancestry — 06-history-undo.md), Teams inherits
|
||||
/// Pro's.
|
||||
///
|
||||
/// ### What this protocol deliberately does not say
|
||||
///
|
||||
|
||||
@@ -34,10 +34,13 @@ extension NSToolbarItem.Identifier {
|
||||
///
|
||||
/// The app ships no Undo/Redo rows of its own: those are the standard Edit-menu items, nil-target
|
||||
/// `undo:`/`redo:` resolved up the responder chain (`KanbanApp.menuCommands`). The toolbar items
|
||||
/// carry the same actions with the same nil target, so "the pair disabled on boards without undo …
|
||||
/// matching their menu items" (03) is not a predicate written here — it is the same validation, and
|
||||
/// on a base-edition board (no undo stack until m8 wires native undo) both are disabled for the same
|
||||
/// reason the menu rows are.
|
||||
/// carry the same actions with the same nil target, so "matching their menu items" (03) is not a
|
||||
/// predicate written here — it is literally the same validation. Both reach the board window, whose
|
||||
/// `windowWillReturnUndoManager` hands back the session's `BoardUndoManager`, and both therefore
|
||||
/// enable exactly when that board has a step to cross and no read-only lock stands
|
||||
/// (13-native-undo.md ▸ Rules). **Every base board has undo**, so there is no edition-shaped
|
||||
/// disablement to write: 03's parenthetical about boards without undo is 06's *git* substrate, which
|
||||
/// only Pro binds.
|
||||
///
|
||||
/// Their labels are the design's one exception to the menu-title rule: `NSUndoManager` rewrites the
|
||||
/// *menu* titles as the stack changes ("Undo Move Card"), which a toolbar label does not track, so
|
||||
|
||||
@@ -23,9 +23,10 @@ import Foundation
|
||||
/// board experience, agent attribution and accessibility are shared code and stay in `Kanban/`.
|
||||
///
|
||||
/// The type itself is a namespace, not state — the composition root that picks a
|
||||
/// `HistoryProviding` per edition arrives with the providers it chooses between (base's native
|
||||
/// stack in m8-native-undo, Pro's git stack in pro-m1), and inventing a seam here before either
|
||||
/// exists would be guessing at its shape.
|
||||
/// `HistoryProviding` per edition is `AppModel.makeHistoryProvider`, and it lives in the shared
|
||||
/// tree because base's `NativeHistoryProvider` is the shared default. Pro's override is one closure
|
||||
/// installed from here once `KanbanPro/Git/` exists (pro-m1); until then Pro runs the native stack,
|
||||
/// which is the seam working rather than a gap.
|
||||
enum ProEdition {
|
||||
/// The edition's own bundle identifier (12 ▸ Targets, ruled 2026-07-27: base keeps
|
||||
/// `dev.rzen.indie.Kanban`, Pro mints its own). Stated as a constant so the value has one
|
||||
|
||||
@@ -594,3 +594,267 @@ struct BoardSessionHistoryTests {
|
||||
#expect(bound.undoCount == 1, "the window's manager reaches whatever the root bound")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The command surface
|
||||
|
||||
/// **What the Edit menu's Undo/Redo rows and the toolbar's pair actually do** — driven through the
|
||||
/// platform machinery they ride on rather than described (11-command-nexus.md ▸ Menu commands, the
|
||||
/// M− row; 03-board-ui.md ▸ Toolbar; 13-native-undo.md ▸ Rules).
|
||||
///
|
||||
/// ### The app writes none of this, which is exactly why it is tested
|
||||
///
|
||||
/// There is no custom Undo/Redo menu code anywhere: the rows are the system's own nil-target
|
||||
/// `undo:`/`redo:`, and the toolbar's two items carry the same selectors with the same nil target
|
||||
/// (`BoardToolbar`). Every claim the design makes about them — they enable on a stack with steps,
|
||||
/// they dim under the read-only lock, the *menu* rows retitle themselves to "Undo Move 3 Cards"
|
||||
/// while the *toolbar* labels stay static — is therefore a claim about `NSWindow`'s own validation
|
||||
/// reading the manager this app's window delegate hands back. Nothing here would fail loudly if the
|
||||
/// wiring came undone; it would just quietly stop working, which is what these tests are for.
|
||||
///
|
||||
/// ### What a headless run can and cannot reach
|
||||
///
|
||||
/// `NSWindow.validateMenuItem(_:)` and `NSWindow.validateUserInterfaceItem(_:)` are the two methods
|
||||
/// AppKit calls once a nil-target lookup has resolved to the window, and both answer fully in a test
|
||||
/// process — which is the half this app owns and the half that can break. The lookup *itself*
|
||||
/// (`NSApp.target(forAction:to:from:)`) needs a **key window**, and a unit-test host has none, so
|
||||
/// "the board window is what the chain resolves to when it is key" is the one link these tests
|
||||
/// cannot close; it is standard responder-chain behaviour with no code of this app's in it.
|
||||
@MainActor
|
||||
@Suite("History ▸ the command surface")
|
||||
struct UndoCommandSurfaceTests {
|
||||
|
||||
/// A scratch window fronted by the app's own delegate proxy, answering with `manager` — the
|
||||
/// board window's wiring exactly (`BoardWindowHost` sets the same closure).
|
||||
private func hostedWindow(_ manager: UndoManager?) -> (NSWindow, HostedWindowController) {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 400, height: 300),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: true
|
||||
)
|
||||
let controller = HostedWindowController()
|
||||
controller.boardUndoManager = { manager }
|
||||
controller.attach(to: window)
|
||||
return (window, controller)
|
||||
}
|
||||
|
||||
private func menuItem(_ selector: String) -> NSMenuItem {
|
||||
NSMenuItem(title: selector == "undo:" ? "Undo" : "Redo", action: NSSelectorFromString(selector), keyEquivalent: "")
|
||||
}
|
||||
|
||||
// MARK: The menu rows
|
||||
|
||||
@Test("The Edit menu's rows read the board's stack, and retitle themselves from its step names")
|
||||
func theMenuRowsReadTheBoardsStack() {
|
||||
let provider = FakeHistoryProvider()
|
||||
let manager = BoardUndoManager(history: provider)
|
||||
let (window, controller) = hostedWindow(manager)
|
||||
defer { controller.detach() }
|
||||
let undoRow = menuItem("undo:")
|
||||
let redoRow = menuItem("redo:")
|
||||
|
||||
#expect(window.undoManager === manager, "the window's answer is the session's adapter")
|
||||
|
||||
// Nothing to cross: both rows disabled, and titled with the bare verbs.
|
||||
#expect(window.validateMenuItem(undoRow) == false)
|
||||
#expect(window.validateMenuItem(redoRow) == false)
|
||||
#expect(undoRow.title == "Undo")
|
||||
#expect(redoRow.title == "Redo")
|
||||
|
||||
provider.canUndo = true
|
||||
provider.undoActionName = "Move 3 Cards"
|
||||
|
||||
// 13's "the 06 vocabulary supplies menu titles ('Undo Move 3 Cards'), via NSUndoManager's
|
||||
// dynamic retitling": the app never writes that string — the platform composes it from the
|
||||
// bare phrase the seam vends, and validation is when it lands on the row.
|
||||
#expect(window.validateMenuItem(undoRow))
|
||||
#expect(undoRow.title == "Undo Move 3 Cards")
|
||||
#expect(window.validateMenuItem(redoRow) == false)
|
||||
#expect(redoRow.title == "Redo")
|
||||
|
||||
provider.canRedo = true
|
||||
provider.redoActionName = "Rename Lane"
|
||||
#expect(window.validateMenuItem(redoRow))
|
||||
#expect(redoRow.title == "Redo Rename Lane")
|
||||
|
||||
// And it tracks the stack, rather than being set once: crossing a step renames the row.
|
||||
provider.undoActionName = "Delete Card"
|
||||
#expect(window.validateMenuItem(undoRow))
|
||||
#expect(undoRow.title == "Undo Delete Card")
|
||||
}
|
||||
|
||||
@Test("The read-only lock dims both rows and leaves their names standing")
|
||||
func theLockDimsTheRowsWithoutRenamingThem() {
|
||||
final class Lock { var isOn = false }
|
||||
let lock = Lock()
|
||||
let provider = FakeHistoryProvider()
|
||||
provider.canUndo = true
|
||||
provider.undoActionName = "Move Card"
|
||||
provider.canRedo = true
|
||||
provider.redoActionName = "Rename Card"
|
||||
let manager = BoardUndoManager(history: provider, isReadOnly: { lock.isOn })
|
||||
let (window, controller) = hostedWindow(manager)
|
||||
defer { controller.detach() }
|
||||
let undoRow = menuItem("undo:")
|
||||
let redoRow = menuItem("redo:")
|
||||
|
||||
#expect(window.validateMenuItem(undoRow))
|
||||
#expect(window.validateMenuItem(redoRow))
|
||||
|
||||
lock.isOn = true
|
||||
|
||||
#expect(window.validateMenuItem(undoRow) == false, "disabled with every other mutating command")
|
||||
#expect(window.validateMenuItem(redoRow) == false)
|
||||
#expect(undoRow.title == "Undo Move Card", "a disabled row keeps its name — the stack survives the lock")
|
||||
#expect(redoRow.title == "Redo Rename Card")
|
||||
|
||||
lock.isOn = false
|
||||
#expect(window.validateMenuItem(undoRow), "and resumes when it clears")
|
||||
}
|
||||
|
||||
@Test("A window with no board has nothing to undo, and says so with the bare verb")
|
||||
func aBoardlessWindowHasNothingToCross() {
|
||||
let (window, controller) = hostedWindow(nil)
|
||||
defer { controller.detach() }
|
||||
let undoRow = menuItem("undo:")
|
||||
|
||||
#expect(window.validateMenuItem(undoRow) == false)
|
||||
#expect(undoRow.title == "Undo")
|
||||
}
|
||||
|
||||
@Test("Every window over one board answers with that board's one stack")
|
||||
func cardWindowsShareTheBoardsStack() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (model, tearDown) = try makeModel()
|
||||
defer { tearDown() }
|
||||
|
||||
let ref = try openBoard(model, at: fixture.root)
|
||||
let session = try #require(model.session(for: ref))
|
||||
// The board window and one of its card windows, wired the way their hosts wire them.
|
||||
let (boardWindow, boardController) = hostedWindow(session.undoManager)
|
||||
defer { boardController.detach() }
|
||||
let (cardWindow, cardController) = hostedWindow(session.undoManager)
|
||||
defer { cardController.detach() }
|
||||
let boardRow = menuItem("undo:")
|
||||
let cardRow = menuItem("undo:")
|
||||
|
||||
session.history.register(StepLog().step("Move 3 Cards"))
|
||||
|
||||
#expect(boardWindow.validateMenuItem(boardRow))
|
||||
#expect(cardWindow.validateMenuItem(cardRow), "one stack per board, not per window")
|
||||
#expect(boardRow.title == "Undo Move 3 Cards")
|
||||
#expect(cardRow.title == boardRow.title)
|
||||
}
|
||||
|
||||
// MARK: The toolbar twins
|
||||
|
||||
@Test("The toolbar pair validates identically to the menu rows — and keeps its static labels")
|
||||
func theToolbarPairMatchesTheMenuRows() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation())
|
||||
let provider = FakeHistoryProvider()
|
||||
let manager = BoardUndoManager(history: provider)
|
||||
let (window, hosted) = hostedWindow(manager)
|
||||
defer { hosted.detach() }
|
||||
|
||||
/// The real items, built by the real delegate — nil target, `undo:`/`redo:` actions.
|
||||
func item(_ identifier: NSToolbarItem.Identifier) throws -> NSToolbarItem {
|
||||
try #require(controller.toolbar(
|
||||
controller.toolbar,
|
||||
itemForItemIdentifier: identifier,
|
||||
willBeInsertedIntoToolbar: true
|
||||
))
|
||||
}
|
||||
let undoItem = try item(.boardUndo)
|
||||
let redoItem = try item(.boardRedo)
|
||||
#expect(undoItem.target == nil, "nil target: the chain resolves it, exactly as the menu row's is")
|
||||
#expect(redoItem.target == nil)
|
||||
|
||||
// `validateUserInterfaceItem` is what `NSToolbarItem.validate()` asks its resolved target,
|
||||
// and `validateMenuItem` is what a menu row's asks — one predicate, two doors.
|
||||
#expect(window.validateUserInterfaceItem(undoItem) == false)
|
||||
#expect(window.validateUserInterfaceItem(redoItem) == false)
|
||||
|
||||
provider.canUndo = true
|
||||
provider.undoActionName = "Move 3 Cards"
|
||||
provider.canRedo = true
|
||||
provider.redoActionName = "Rename Lane"
|
||||
|
||||
let undoRow = menuItem("undo:")
|
||||
let redoRow = menuItem("redo:")
|
||||
#expect(window.validateUserInterfaceItem(undoItem) == window.validateMenuItem(undoRow))
|
||||
#expect(window.validateUserInterfaceItem(redoItem) == window.validateMenuItem(redoRow))
|
||||
#expect(window.validateUserInterfaceItem(undoItem))
|
||||
#expect(window.validateUserInterfaceItem(redoItem))
|
||||
|
||||
// 03's one exception to the label rule, proven rather than asserted: validation rewrote the
|
||||
// *menu* row's title and left the toolbar item's label exactly where it was.
|
||||
#expect(undoRow.title == "Undo Move 3 Cards")
|
||||
#expect(undoItem.label == "Undo")
|
||||
#expect(redoItem.label == "Redo")
|
||||
#expect(undoItem.paletteLabel == "Undo", "the customize palette shows the static label too")
|
||||
}
|
||||
|
||||
@Test("A toolbar item's own validation lands on the board's answer, lock included")
|
||||
func theToolbarItemValidatesThroughTheWindow() throws {
|
||||
final class Lock { var isOn = false }
|
||||
let lock = Lock()
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let toolbar = BoardToolbar.controller(store: store, search: BoardSearchPresentation())
|
||||
let provider = FakeHistoryProvider()
|
||||
let manager = BoardUndoManager(history: provider, isReadOnly: { lock.isOn })
|
||||
let (window, hosted) = hostedWindow(manager)
|
||||
defer { hosted.detach() }
|
||||
|
||||
let item = try #require(toolbar.toolbar(
|
||||
toolbar.toolbar,
|
||||
itemForItemIdentifier: .boardUndo,
|
||||
willBeInsertedIntoToolbar: true
|
||||
))
|
||||
// The one link a headless run cannot make: `NSToolbarItem.validate()` resolves its target
|
||||
// through the key window, and a test host has none. Standing the window in as the target is
|
||||
// that lookup's *answer* — which is what nil-target means when a board window is key — so
|
||||
// what this asserts is the item's own validation path, end to end from `validate()`.
|
||||
item.target = window
|
||||
#expect(item.autovalidates, "AppKit revalidates it on user events; the observation covers the rest")
|
||||
|
||||
item.validate()
|
||||
#expect(item.isEnabled == false, "an empty stack dims it")
|
||||
|
||||
provider.canUndo = true
|
||||
item.validate()
|
||||
#expect(item.isEnabled)
|
||||
|
||||
lock.isOn = true
|
||||
item.validate()
|
||||
#expect(item.isEnabled == false, "the lock disables the toolbar pair with the menu rows")
|
||||
|
||||
lock.isOn = false
|
||||
item.validate()
|
||||
#expect(item.isEnabled)
|
||||
#expect(item.label == "Undo", "no crossing of validation ever moves the label")
|
||||
}
|
||||
|
||||
@Test("The pair's enablement is deliberately not a predicate of the toolbar's own")
|
||||
func theSpecsAbstainFromEnablement() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
|
||||
|
||||
// Every other item mirrors its menu row's predicate; these two mirror the *mechanism*. A
|
||||
// spec-level `isEnabled` here would be a second answer able to disagree with the responder
|
||||
// chain's — and it would have to read a stack the toolbar has no route to, since the board's
|
||||
// window is what owns that answer.
|
||||
for identifier in [NSToolbarItem.Identifier.boardUndo, .boardRedo] {
|
||||
let spec = try #require(specs.first { $0.identifier == identifier })
|
||||
#expect(spec.isEnabled, "abstention, not enablement: AppKit's own validation decides")
|
||||
#expect(spec.isOn == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,41 @@ private func untouchedLines(_ text: String) -> [Substring] {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `index.md` on the board, keyed by its folder's path relative to the root — the whole of
|
||||
/// what a round trip has to land back on, files-are-truth read literally.
|
||||
///
|
||||
/// Read as raw bytes off disk rather than through the loader, like every other assertion in this
|
||||
/// file: the board being *equivalent* is not the claim, the board being the same bytes is.
|
||||
private func boardTexts(_ fixture: WriterFixture) throws -> [String: String] {
|
||||
var texts: [String: String] = [:]
|
||||
let rootDepth = fixture.root.standardizedFileURL.pathComponents.count
|
||||
guard let walker = FileManager.default.enumerator(at: fixture.root, includingPropertiesForKeys: nil) else {
|
||||
return texts
|
||||
}
|
||||
for case let url as URL in walker where url.lastPathComponent == "index.md" {
|
||||
let folder = url.deletingLastPathComponent().standardizedFileURL
|
||||
let relative = folder.pathComponents.dropFirst(rootDepth).joined(separator: "/")
|
||||
texts[relative] = try String(decoding: Data(contentsOf: url), as: UTF8.self)
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
/// Asserts two whole-board readings are the same board — the same items, and every file identical
|
||||
/// but for the stamps every app write owns.
|
||||
///
|
||||
/// **"Byte-identical" is byte-identical-except-`modified`**, and deliberately so: an inverse is an
|
||||
/// ordinary app-mediated write (13-native-undo.md), so it stamps on the way past, and 13 promises
|
||||
/// state equivalence rather than mtime equality. That is exactly the precision
|
||||
/// `WriteFidelityTombstoneTests.deleteThenRestoreDiffersFromTheOriginalOnlyInTheModifiedTimestamp`
|
||||
/// established for the delete→restore round trip, applied here to a whole board.
|
||||
private func expectSameBoard(_ actual: [String: String], _ expected: [String: String], _ label: String) {
|
||||
#expect(actual.keys.sorted() == expected.keys.sorted(), "\(label): the board holds different items")
|
||||
for (path, text) in expected {
|
||||
guard let landed = actual[path] else { continue }
|
||||
#expect(untouchedLines(landed) == untouchedLines(text), "\(label): '\(path)' came back different")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Resize
|
||||
|
||||
@MainActor
|
||||
@@ -1276,3 +1311,208 @@ struct HistoryPhraseTests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The trash's two doors
|
||||
|
||||
/// 13-native-undo.md ▸ Interaction with the trash: "⌫'s undo is the move back ... a restore-by-move
|
||||
/// undoes the same way in reverse. **The stack and the trash never conflict** — they are the same
|
||||
/// folder moves addressed by recency instead of by selection."
|
||||
///
|
||||
/// The per-operation round trips live in `TrashUndoTests` above. What is here is the *interplay*:
|
||||
/// that the two doors reach one state rather than two similar ones, and that alternating between
|
||||
/// them leaves a stack that crosses cleanly in both directions.
|
||||
@MainActor
|
||||
@Suite("Undo ▸ the trash's two doors")
|
||||
struct TrashInterplayTests {
|
||||
|
||||
@Test("Undoing a delete lands exactly where Put Back would have — the same bytes, not a near miss")
|
||||
func theTwoDoorsReachOneState() async throws {
|
||||
// Two identical boards, one per door: the claim is about a *state*, so the honest comparison
|
||||
// is the whole board read off disk, not the one field each path happens to write.
|
||||
let byUndo = try makeBoard()
|
||||
defer { byUndo.tearDown() }
|
||||
let byPutBack = try makeBoard()
|
||||
defer { byPutBack.tearDown() }
|
||||
let origin = try boardTexts(byUndo)
|
||||
|
||||
let (undoStore, history) = try makeStore(byUndo)
|
||||
undoStore.delete([card1])
|
||||
history.undo()
|
||||
|
||||
let (putBackStore, _) = try makeStore(byPutBack)
|
||||
putBackStore.delete([card1])
|
||||
// Put Back reads the trashed side of the snapshot, so it has to see the tombstone first —
|
||||
// which is the one-way flow, not a test artefact.
|
||||
await reload(putBackStore)
|
||||
putBackStore.putBack([card1])
|
||||
|
||||
expectSameBoard(try boardTexts(byUndo), try boardTexts(byPutBack), "the two doors")
|
||||
expectSameBoard(try boardTexts(byUndo), origin, "undo against the board it started from")
|
||||
#expect(try document(byUndo, card1Path).deleted.isMissing)
|
||||
#expect(try document(byPutBack, card1Path).deleted.isMissing)
|
||||
}
|
||||
|
||||
@Test("Undoing a delete is position-preserving — the card comes back where it was, not at an end")
|
||||
func undoRestoresInPlace() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
/// The lane's live cards in display order, read through the loader — the order the board
|
||||
/// actually renders, rather than the ranks it is derived from.
|
||||
func liveCards() throws -> [String] {
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
let lane = try #require(result.model.lanes.first { $0.id == lane1 })
|
||||
return lane.cards.filter { !$0.isDeleted }.map(\.id.rawValue)
|
||||
}
|
||||
let before = try liveCards()
|
||||
#expect(before == [Ident.card1, Ident.card2, Ident.card3], "the middle card is genuinely in the middle")
|
||||
|
||||
// The middle of three: an implementation that restored by appending would pass on a first or
|
||||
// last card and fail here.
|
||||
store.delete([card2])
|
||||
await reload(store)
|
||||
#expect(try liveCards() == [Ident.card1, Ident.card3])
|
||||
|
||||
history.undo()
|
||||
|
||||
#expect(try liveCards() == before, "01's deletion bullet: restore is position-perfect")
|
||||
#expect(try document(fixture, card2Path).order.value == 2048, "the rank it held all along")
|
||||
}
|
||||
|
||||
@Test("Alternating the two doors leaves a stack that crosses cleanly, both ways")
|
||||
func interleavedDoorsCrossBothWays() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
let live = try boardTexts(fixture)
|
||||
|
||||
// Three gestures over one card, alternating the doors: ⌫, Put Back, ⌫.
|
||||
store.delete([card1])
|
||||
await reload(store)
|
||||
let filedUnder = try #require(try document(fixture, card1Path).deleted.value)
|
||||
|
||||
store.putBack([card1])
|
||||
await reload(store)
|
||||
#expect(try document(fixture, card1Path).deleted.isMissing)
|
||||
|
||||
store.delete([card1])
|
||||
await reload(store)
|
||||
#expect(history.undoActionName == "Delete Card")
|
||||
let trashed = try boardTexts(fixture)
|
||||
|
||||
// Back up the stack: delete → restore → tombstone → restore. The middle step is Put Back's
|
||||
// inverse, which has to re-file the row under the stamp it was filed under rather than under
|
||||
// now — the trash sorts by it.
|
||||
history.undo()
|
||||
#expect(try document(fixture, card1Path).deleted.isMissing)
|
||||
#expect(history.undoActionName == "Restore Card")
|
||||
|
||||
history.undo()
|
||||
#expect(try document(fixture, card1Path).deleted.value == filedUnder,
|
||||
"re-tombstoned where it was filed, not where a fresh stamp would put it")
|
||||
#expect(history.undoActionName == "Delete Card")
|
||||
|
||||
history.undo()
|
||||
#expect(history.canUndo == false)
|
||||
expectSameBoard(try boardTexts(fixture), live, "three doors back")
|
||||
|
||||
// And forward again, the same three steps in the other direction.
|
||||
history.redo()
|
||||
#expect(try document(fixture, card1Path).deleted.value != nil)
|
||||
history.redo()
|
||||
#expect(try document(fixture, card1Path).deleted.isMissing)
|
||||
history.redo()
|
||||
#expect(history.canRedo == false)
|
||||
expectSameBoard(try boardTexts(fixture), trashed, "three doors forward")
|
||||
#expect(store.banners.signposts.isEmpty, "nothing was stale: the doors never collided")
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The capstone
|
||||
|
||||
/// **A whole session, unwound and wound back up.** 13-native-undo.md's promise, taken literally: a
|
||||
/// user creates, moves, styles, renames and deletes without touching the mouse, then holds ⌘Z until
|
||||
/// the board is the one they opened — and ⇧⌘Z back to the one they built.
|
||||
///
|
||||
/// The assertion is the *board*, read off disk in full: every `index.md`, byte for byte but for the
|
||||
/// `modified` stamps every app write owns (`expectSameBoard`, whose note records why that is the
|
||||
/// right precision — an inverse is an ordinary write, and 13 promises state equivalence rather than
|
||||
/// mtime equality).
|
||||
///
|
||||
/// Five gestures rather than one of each kind on purpose: they all name the **same card**, so each
|
||||
/// step's staleness predicate is validated against a board four other steps have since written to.
|
||||
/// A chain that only worked on untouched targets would pass a suite of five isolated round trips and
|
||||
/// fail here.
|
||||
@MainActor
|
||||
@Suite("Undo ▸ the full session")
|
||||
struct FullSessionUndoTests {
|
||||
|
||||
@Test("Create, move, style, rename, delete — then ⌘Z back to the origin and ⇧⌘Z forward again")
|
||||
func theWholeSessionRoundTrips() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
let origin = try boardTexts(fixture)
|
||||
|
||||
// 1 — create. The one gesture whose undo removes a folder rather than rewriting one.
|
||||
store.transient.beginPlaceholder(inLane: lane1, after: card1)
|
||||
store.transient.updateDraft("Fresh")
|
||||
let created = try #require(store.commitPlaceholder())
|
||||
await reload(store)
|
||||
#expect(history.undoActionName == "Add Card")
|
||||
|
||||
// 2 — move it to the other lane.
|
||||
store.moveCards([created], toLane: lane2, at: 0)
|
||||
await reload(store)
|
||||
#expect(history.undoActionName == "Move Card")
|
||||
|
||||
// 3 — style it.
|
||||
store.applyStyle(to: .items([created]), background: .set("red"), icon: .set("flag"))
|
||||
await reload(store)
|
||||
#expect(history.undoActionName == "Restyle Card")
|
||||
|
||||
// 4 — rename it.
|
||||
store.transient.beginRename(of: created, currentTitle: "Fresh")
|
||||
store.transient.updateRenameDraft("Renamed")
|
||||
store.commitRename()
|
||||
await reload(store)
|
||||
#expect(history.undoActionName == "Rename Card")
|
||||
|
||||
// 5 — delete it.
|
||||
store.delete([created])
|
||||
await reload(store)
|
||||
#expect(history.undoActionName == "Delete Card")
|
||||
|
||||
let built = try boardTexts(fixture)
|
||||
#expect(built.count == origin.count + 1, "one new card, and nothing else arrived")
|
||||
|
||||
// ⌘Z ×5. The Edit menu's row renames itself on every press — the platform composes
|
||||
// "Undo <phrase>" from exactly these names (`BoardUndoManager`).
|
||||
for next in ["Rename Card", "Restyle Card", "Move Card", "Add Card"] {
|
||||
history.undo()
|
||||
#expect(history.undoActionName == next)
|
||||
}
|
||||
history.undo()
|
||||
|
||||
#expect(history.canUndo == false, "five gestures, five steps, no more and no fewer")
|
||||
#expect(history.redoActionName == "Add Card")
|
||||
expectSameBoard(try boardTexts(fixture), origin, "unwound")
|
||||
#expect(store.banners.signposts.isEmpty, "nothing went stale under its own session")
|
||||
#expect(store.banners.oneShots.isEmpty, "and nothing failed to write")
|
||||
|
||||
// ⇧⌘Z ×5, back to the board the session built.
|
||||
for next in ["Move Card", "Restyle Card", "Rename Card", "Delete Card"] {
|
||||
history.redo()
|
||||
#expect(history.redoActionName == next)
|
||||
}
|
||||
history.redo()
|
||||
|
||||
#expect(history.canRedo == false)
|
||||
#expect(history.undoActionName == "Delete Card")
|
||||
expectSameBoard(try boardTexts(fixture), built, "rewound")
|
||||
#expect(store.banners.signposts.isEmpty)
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ Lanework is in early development. This list tracks what has actually shipped and
|
||||
|
||||
- **Customizable toolbars** — both windows carry a real macOS toolbar: right-click ▸ Customize Toolbar…, drag to rearrange, the system overflow, and the Icon and Text / Icon Only / Text Only display options, with your arrangement remembered across launches. They are pure enhancement — every item is a menu command with a shortcut, so removing all of them costs you nothing but a click. The board ships with the search field alone, trailing, and offers New Card, New Lane, Undo, Redo and Show Trash in the palette (Undo and Redo validate exactly as the Edit menu's rows do, and keep static labels because the menu's titles rewrite themselves); the board popover deliberately has no item, since the window-title chevron is its home. Take the search field out and ⌘F still summons search — the field appears in a strip just under the title bar and stays until the search clears, keeping the keyboard while you type. The card window ships Edit Body · Raw Source · Add Attachment, the first two as toggles showing their on-state, with Edit Body disabling while raw source is up and Add Attachment live in every mode.
|
||||
|
||||
- **Undo** — ⌘Z and ⇧⌘Z are native macOS undo, per board: one stack owned by the board's session and shared by every window over it, so a card window's ⌘Z crosses the same step the board window's does, and another board's never does. Every app-mediated mutation registers an inverse at the write boundary — create, move, reorder, rename, restyle, resize, delete, Put Back, and an Edit session's whole run of saves — one gesture to one step, named in the app's own vocabulary so the Edit menu reads "Undo Move 3 Cards" and the toolbar's twins light up and dim with it. Undoing is a real write, never an in-memory revert: it goes through the same atomic writer, echoes back through the watcher, and refreshes every window. Because the app is not the only writer, each step re-checks its target the moment you press ⌘Z — field by field, against what its own write left — and a step the disk has moved past is **skipped rather than applied**, with a quiet row saying which item changed outside Lanework, while ⌘Z falls through to the next step; a step that merely failed to write (a full disk, an unplugged volume) stays put to be retried. Permanent deletion and the duplicate-id repair are deliberately outside it — the confirmation is the safety — attachment add and remove register nothing in v1, and foreign edits never join the stack. The read-only lock disables Undo and Redo with every other mutating command and gives them back, stack intact, when it clears. The stack lives with the session and dies at close, standard macOS behaviour. Both editions ship it: base runs the native stack, and Lanework Pro binds git behind the same seam without changing a keystroke.
|
||||
|
||||
## Development
|
||||
|
||||
The Xcode project is generated — `project.yml` is the source of truth, not the `.xcodeproj`:
|
||||
|
||||
Reference in New Issue
Block a user