The undo command surface rebuilds — app-owned rows and explicit toolbar targets over FocusedValues

Edit ▸ Undo/Redo become the app's own replaced rows and the board toolbar
pair takes explicit targets, both reading the focused session's
BoardUndoManager through FocusedValues.undoStack (board windows publish the
session's manager, card windows their own) — the nil-target route died with
the SwiftUI window latch, 13-native-undo.md ▸ Rules ▸ command surface,
re-ruled 2026-08-08. The rows enact the routing predicate themselves: text
focus routes ⌘Z to the first responder's own manager, title and enablement
included, re-derived at fire time with a beep for the stale window.
NativeHistoryProvider turns @Observable so both surfaces re-derive on stack
changes; a checkpoint-notification ticker covers plain text managers.
.responderAction leaves ToolbarItemSpec with its only user;
windowWillReturnUndoManager stays wired for AppKit's own asks.

Live-probed on the fixture board (21/21): the row retitles to "Undo Add
Lane" and crosses via real ⌘Z key events, ⇧⌘Z redoes via a window-server
chord, the toolbar pair validates and fires, search-field and body-editor
⌘Z stay text undo with board stacks untouched, and a card window crosses
its own stack with no fall-through. 2698 unit tests green.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 18:55:34 -04:00
parent 78c32776d4
commit 1fd19dfb12
13 changed files with 926 additions and 201 deletions
+25 -6
View File
@@ -236,6 +236,12 @@ struct BoardWindowHost: View {
// purge-alert host, which the trash's two confirmed commands raise, and its search
// field, which Edit Find focuses and the caret-chord commands yield to.
.focusedSceneValue(\.boardStore, store)
// **What Edit Undo/Redo cross with this window in front** the session's stack, which
// those rows now read for themselves (13-native-undo.md Rules the command-surface
// bullet, re-ruled 2026-08-08; `UndoCommands.swift`). Read afresh from the model rather
// than stored, for `windowUndoManager`'s reason exactly: a torn-down board answers
// nothing rather than a stack with no board behind it.
.focusedSceneValue(\.undoStack, appModel.session(for: ref)?.undoManager)
.focusedSceneValue(\.boardSearch, boardSearch)
// The window's identity beside its store File Duplicate flushes a *session*, which
// is keyed on the window rather than on the board it is showing.
@@ -709,11 +715,20 @@ struct BoardWindowHost: View {
}
}
// This window's answer to "what does Z act on" (13-native-undo.md Rules; 06 Undo
// routing) the *session's* stack, read afresh on every ask so a torn-down board answers
// nothing rather than a stack with no board behind it. The Edit menu's Undo/Redo rows and
// the toolbar's pair are nil-target `undo:`/`redo:`, so this one line is what lights them
// up: `NSWindow` validates and crosses them against exactly this manager.
// This window's answer to "what does Z act on" (13-native-undo.md Rules; Undo routing)
// the *session's* stack, read afresh on every ask so a torn-down board answers nothing
// rather than a stack with no board behind it.
//
// **It no longer lights the command surface**, and that sentence used to be this comment's
// whole point (13 Rules the command-surface bullet, re-ruled 2026-08-08): the Edit
// menu's rows and the toolbar's pair were nil-target `undo:`/`redo:` resolving through
// `NSWindow.undoManager`, which a SwiftUI window latches empty during creation, before this
// controller's delegate can install so `windowWillReturnUndoManager` is never consulted
// and the surface validated against a stack nothing registers into. Both faces read the
// session's manager directly now: the rows through `FocusedValues.undoStack` published a few
// lines up in `body`, the toolbar pair through the explicit target `BoardToolbar` is handed
// below. This closure stays wired because it is still the right answer wherever *AppKit*
// asks a delegate for a manager.
windowController.windowUndoManager = { appModel.session(for: ref)?.undoManager }
// The window-title widget (03-board-ui.md § Board popover) **board windows only**, which
@@ -750,7 +765,11 @@ struct BoardWindowHost: View {
search: boardSearch,
zoom: appModel.zoom,
appearance: appModel.appearance,
session: appModel.dragSession
session: appModel.dragSession,
// The same manager the Edit menu's rows reach through the focus system one object, two
// faces, which is what "the toolbar mirrors the menu" means for this pair now that
// neither of them goes through the responder chain (13 Rules, re-ruled 2026-08-08).
undo: appModel.session(for: ref)?.undoManager
))
}
+22 -8
View File
@@ -362,6 +362,13 @@ struct CardWindowHost: View {
// item reaches the frontmost one's body surface through this, exactly as board-window
// items reach their window's store (`FocusedBoardStoreKey`).
.focusedSceneValue(\.cardBody, bodyPresentation)
// **This window's own stack, published for Edit Undo/Redo** the second of 13's two
// levels reaching the menu (13-native-undo.md Rules two levels; the
// command-surface bullet, re-ruled 2026-08-08). One key serves both levels because a
// card window's manager is the same face type as a board session's, so which of them a
// row crosses is decided by which window is in front and by nothing else no
// fall-through, as a property of the value rather than a rule (`UndoCommands.swift`).
.focusedSceneValue(\.undoStack, session.undo.manager)
// View Raw Source (E) reaches the frontmost card window the same way, and Edit Body
// reads it too "View Edit Body (E) disables while source mode is active"
// (05-card-window.md Raw source outlet).
@@ -878,15 +885,22 @@ struct CardWindowHost: View {
// **This window's own stack** (13-native-undo.md Rules two levels, re-ruled 2026-07-31
// superseding the shared-stack wiring): "a card window owns its own stack for the session it
// represents ... and `window.undoManager` answers with it (standard per-window AppKit
// scoping)". Z with this window in front walks the gestures made *here*, newest first, and
// when they run out it beeps "no fall-through: exhausting the window's stack ... never
// reaches board history" (06-history-undo.md Undo routing). What board history gets is the
// one coarse step this session registers when the window closes.
// represents". Z with this window in front walks the gestures made *here*, newest first,
// and when they run out it beeps "no fall-through: exhausting the window's stack ... never
// reaches board history" (13 Undo routing). What board history gets is the one coarse step
// this session registers when the window closes.
//
// The card's *text* surfaces are untouched by this the body editor and the raw-source
// editor each vend their own manager to the responder chain, which is what keeps typing undo
// above either stack (06 Undo routing, unchanged).
// **The Edit menu does not arrive through here**, which is what the same rule's 2026-08-08
// re-ruling changed: `window.undoManager` is latched empty by SwiftUI before this
// controller's delegate installs, so the rows read this same manager through
// `FocusedValues.undoStack` instead (published in `body` above; `UndoCommands.swift`). The
// closure stays wired for AppKit's own asks, which is the one caller it always had a right
// to.
//
// The card's *text* surfaces are untouched by either wiring the body editor and the
// raw-source editor each vend their own manager to the responder chain, and the rows route
// to the first responder's manager while one of them holds the keyboard, which is what keeps
// typing undo above either stack (13 Undo routing, unchanged).
windowController.windowUndoManager = { [session] in session.undo.manager }
windowController.onAttach = { window in
+294
View File
@@ -0,0 +1,294 @@
import AppKit
import Observation
import SwiftUI
// MARK: - The focused stack
/// **The stack the frontmost window's Z crosses**, published into the focus system by whichever
/// host owns it: `BoardWindowHost` publishes its session's manager, `CardWindowHost` the window's own
/// (`CardWindowUndo.manager`).
///
/// One key serves both levels, and that is the two-level model's own shape rather than a shortcut
/// (13-native-undo.md Rules two levels, re-ruled 2026-07-31): a card window's stack is the *same*
/// face type over the same seam, so a second key would only ask the rows to decide which of two
/// answers is in front which is exactly the question the focus system already answers. Crossing
/// between the two levels is therefore impossible by construction here, which is 13 Undo routing's
/// "no fall-through" holding as a fact about the value rather than as a rule someone applies.
///
/// `FocusedBoardStoreKey` is the precedent and its note carries over verbatim: `focusedSceneValue`
/// rather than `focusedValue`, because the value is the *window's* and not any particular control's,
/// so it stays available whatever inside the window holds the keyboard which is precisely what a
/// row that then routes on the first responder needs to read.
struct FocusedUndoStackKey: FocusedValueKey {
typealias Value = BoardUndoManager
}
extension FocusedValues {
var undoStack: BoardUndoManager? {
get { self[FocusedUndoStackKey.self] }
set { self[FocusedUndoStackKey.self] = newValue }
}
}
// MARK: - Routing
/// **What an Undo/Redo row answers with**, given the focused stack and what holds the keyboard
/// 13-native-undo.md Undo routing's predicate, relocated from the window delegate to the command
/// layer (13 Rules the command-surface bullet, re-ruled 2026-08-08; the rule itself is
/// unchanged, only where it is enacted).
///
/// ### Why the rule had to move
///
/// The predicate used to be the platform's to apply: the system's nil-target `undo:`/`redo:` rows
/// resolve to `NSWindow`, which reads the manager its delegate hands back
/// (`windowWillReturnUndoManager`, still wired in `HostedWindowController` and still the right answer
/// wherever AppKit itself asks). On a SwiftUI window that hook is never consulted `NSWindow` reads
/// and **permanently latches** an undo manager of its own during window creation, before any app code
/// can install a delegate (diagnosed 2026-08-07 by live probe) so the whole command surface became
/// the app's own, and the routing came with it.
///
/// ### Pure, and free of both windows and SwiftUI
///
/// Everything a row needs is two references it is handed. The first responder is the one part a test
/// cannot conjure from the focus system, so it is a parameter rather than a read of `NSApp` the
/// same posture `BoardUndoRouting` takes one layer down, and the reason that enum's decision is
/// reusable here verbatim rather than restated.
@MainActor
enum UndoCommandRouting {
/// The manager a row answers with the focused stack, or, while a text surface holds the
/// keyboard, that responder's own manager.
///
/// The text case is where its typing undo actually registered: an editor that vends a manager
/// through its delegate (`CardBodySurface`, `CardRawSourceView`) hands back that one, and a field
/// editor which vends none resolves up the responder chain to the window's latched manager,
/// which is exactly where AppKit put its typing undo. Either way the answer is the responder's,
/// which is what "a reflexive undo over a typo must never become a board-level restore"
/// (13 Undo routing) means once the app is the one choosing.
///
/// `nil` where there is nothing to cross at all: no stack in front and no text surface focused, or
/// a text surface whose own manager is `nil` (a view in no window, with no delegate to ask). A row
/// over `nil` disables and keeps the bare verb.
static func routedManager(stack: BoardUndoManager?, firstResponder: NSResponder?) -> UndoManager? {
guard BoardUndoRouting.isTextEditing(firstResponder) else { return stack }
guard let text = firstResponder?.undoManager else { return nil }
return BoardUndoRouting.undoManager(isTextEditing: true, board: stack, textFallback: text)
}
// MARK: Titles
/// "Undo Move 3 Cards" composed and localized by the platform over the bare 13-vocabulary
/// phrase, so the app never spells the verb (`BoardUndoManager.undoMenuItemTitle`).
///
/// The trim is for the nameless case on a manager that is *not* this app's adapter: a plain
/// `NSUndoManager` a text view's, a field editor's composes `Undo %@` over an empty name and
/// leaves the trailing space standing. The bare verb is also the answer for no manager at all,
/// which is a row with nothing in front of it rather than a row that lost its name.
static func undoTitle(of manager: UndoManager?) -> String {
title(manager?.undoMenuItemTitle, bare: "Undo")
}
static func redoTitle(of manager: UndoManager?) -> String {
title(manager?.redoMenuItemTitle, bare: "Redo")
}
private static func title(_ composed: String?, bare: String) -> String {
let trimmed = composed?.trimmingCharacters(in: .whitespaces) ?? ""
return trimmed.isEmpty ? bare : trimmed
}
// MARK: Enablement
/// Whether the row is live the routed manager's own answer, which for this app's adapter is
/// "steps, and no read-only lock" (`BoardUndoManager.canUndo`, where 13 Rules' lock clause
/// lives) and for a text manager is the editor's session. `false` with no manager, which is the
/// same disablement an empty stack gets: exhausting what the row reaches never reaches anything
/// else (13 Undo routing, no fall-through).
static func canUndo(_ manager: UndoManager?) -> Bool {
manager?.canUndo == true
}
static func canRedo(_ manager: UndoManager?) -> Bool {
manager?.canRedo == true
}
}
// MARK: - The revalidation ticker
/// **What makes a rendered Undo/Redo row notice the world moved.**
///
/// The rows read three things that are not SwiftUI state: the routed manager's enablement, its
/// composed title, and through the first responder which of the two it is routing to at all.
/// `BoardUndoManager` re-derives from an `@Observable` provider, so a board or card step lands on the
/// row for free; a *text* manager is a plain `NSUndoManager` with no observation in it, and neither
/// is `NSApp.keyWindow?.firstResponder`. This is the subscription those two get.
///
/// A bumped counter rather than published state: nothing here knows what any row's answer is, only
/// that it may have changed, and the rows are cheap to re-derive. Reading `revision` inside a row's
/// `body` is what enrolls it.
///
/// ### The notifications, and why each one
///
/// `NSUndoManagerCheckpoint` with `object: nil` is the load-bearing one: it catches **every**
/// manager's stack changes, which is how typing into a field editor retitles the row that is routing
/// to it. The did-undo/did-redo pair covers a crossing that changes which direction is live, the
/// `NSText` editing pair covers the field editor arriving and leaving, and `NSMenu`'s did-begin-
/// tracking is a re-derive a moment before the Edit menu draws itself.
///
/// ### The known residual, which is cosmetic
///
/// Focus moving in or out of a text surface *without any typing* clicking into an empty search
/// field, tabbing away from an untouched one bumps nothing, because AppKit posts no editing
/// notification until an edit begins. A row rendered across that moment can therefore be titled or
/// enabled off the stack it is no longer routing to. Behaviour stays correct regardless: the action
/// re-derives the routed manager at fire time and beeps rather than crossing the wrong stack, which
/// is 13 Undo routing's own answer for an exhausted focus.
@MainActor
@Observable
final class UndoCommandTicker {
/// One instance, read by both rows: the rows differ in direction, never in when they are stale.
static let shared = UndoCommandTicker()
/// Bumped, never read for its value a row reads it to subscribe, and the number itself means
/// nothing.
private(set) var revision = 0
private var observers: [any NSObjectProtocol] = []
private init() {
let names: [Notification.Name] = [
.NSUndoManagerCheckpoint,
.NSUndoManagerDidUndoChange,
.NSUndoManagerDidRedoChange,
NSText.didBeginEditingNotification,
NSText.didEndEditingNotification,
NSMenu.didBeginTrackingNotification,
]
observers = names.map { name in
NotificationCenter.default.addObserver(forName: name, object: nil, queue: nil) { [weak self] _ in
// All six are posted on the main thread, and the synchronous path is the one that
// matters: a menu that has begun tracking is about to draw, so a hop onto the next
// turn would re-derive the row after the user is already looking at it. The
// asynchronous branch is the promise-keeping half, never the expected one.
if Thread.isMainThread {
MainActor.assumeIsolated { self?.bump() }
} else {
Task { @MainActor in self?.bump() }
}
}
}
}
private func bump() {
revision &+= 1
}
}
// MARK: - The rows
/// **Edit Undo and Edit Redo, as the app's own rows** (13-native-undo.md Rules the
/// command-surface bullet, ruled 2026-08-08).
///
/// `CommandGroup(replacing: .undoRedo)` takes the system's nil-target pair out of the menu, because
/// on a SwiftUI window they are unreachable: `NSWindow` latches an empty undo manager of its own
/// during window creation, before `HostedWindowController` can install the delegate that would have
/// vended the board's, so `windowWillReturnUndoManager` is never consulted and the rows validate
/// against a stack nothing ever registers into. Everything the pair used to get from the platform
/// enablement, the dynamic title, the crossing the rows below now ask for by name, off the same
/// `BoardUndoManager` that surface always meant to be reading.
///
/// The alternatives are recorded in 13 and both re-open decisions this app already made: registering
/// steps into the latched manager needs an `NSUndoManager` substrate, which `HistoryStepOutcome
/// .failed` rules out (`NativeHistoryProvider` two arrays), and intercepting nil-target `undo:`
/// from the responder chain is preempted by `NSWindow` handling the action itself, ahead of its own
/// delegate.
struct UndoRedoCommands: Commands {
var body: some Commands {
CommandGroup(replacing: .undoRedo) {
UndoMenuRow()
RedoMenuRow()
}
}
}
/// Edit Undo (Z).
///
/// **The whole row routes, not just its action** (13 Rules the command-surface bullet, re-ruled
/// 2026-08-08). A row left titled and enabled off the board stack while a text field held the
/// keyboard would fail in one of two ways, and both are worse than a re-derived title: enabled, it
/// advertises a board step it will not cross once the action routes elsewhere; disabled because
/// the board stack happened to be empty it *swallows* Z outright, since a menu item owns its key
/// equivalent whether or not it is live and nothing downstream ever sees the chord. Routing title,
/// enablement and action together is what keeps 13 Undo routing's no-fall-through honest from the
/// user's side of the menu.
private struct UndoMenuRow: View {
@FocusedValue(\.undoStack) private var stack
var body: some View {
// Read for the subscription, never for the value see `UndoCommandTicker`, which is what
// makes a text manager's stack changes and a focus move reach a row that has already
// rendered. It has to be read *in* `body`, which is the only scope SwiftUI tracks.
let _ = UndoCommandTicker.shared.revision
let manager = UndoCommandRouting.routedManager(
stack: stack,
firstResponder: NSApp.keyWindow?.firstResponder
)
Button(UndoCommandRouting.undoTitle(of: manager)) {
cross()
}
.keyboardShortcut("z", modifiers: .command)
.disabled(!UndoCommandRouting.canUndo(manager))
}
/// **Routed again here**, rather than closing over what the row rendered with: focus and stacks
/// both move between a menu's display and its click, and the manager the crossing reaches must be
/// the one that holds the keyboard *now*. The beep is 13 Undo routing's "exhausting a focused
/// editor's stack beeps; it never reaches board history", answering the window where the row's
/// rendered enablement was a moment stale.
private func cross() {
let manager = UndoCommandRouting.routedManager(
stack: stack,
firstResponder: NSApp.keyWindow?.firstResponder
)
guard let manager, manager.canUndo else {
NSSound.beep()
return
}
manager.undo()
}
}
/// Edit Redo (Z) `UndoMenuRow`'s twin in every respect, which is why its reasoning is not
/// repeated here.
private struct RedoMenuRow: View {
@FocusedValue(\.undoStack) private var stack
var body: some View {
let _ = UndoCommandTicker.shared.revision
let manager = UndoCommandRouting.routedManager(
stack: stack,
firstResponder: NSApp.keyWindow?.firstResponder
)
Button(UndoCommandRouting.redoTitle(of: manager)) {
cross()
}
.keyboardShortcut("z", modifiers: [.command, .shift])
.disabled(!UndoCommandRouting.canRedo(manager))
}
private func cross() {
let manager = UndoCommandRouting.routedManager(
stack: stack,
firstResponder: NSApp.keyWindow?.firstResponder
)
guard let manager, manager.canRedo else {
NSSound.beep()
return
}
manager.redo()
}
}
+21 -34
View File
@@ -64,10 +64,6 @@ struct ToolbarItemSpec {
case button(isEnabled: () -> Bool, perform: () -> Void)
/// A toggle showing on-state Show Trash, Edit Body, Raw Source (03 Toolbar).
case toggle(isEnabled: () -> Bool, isOn: () -> Bool, setOn: (Bool) -> Void)
/// An action sent up the responder chain with no target of our own **Undo and Redo**, which
/// is how their menu rows work too, so "matching their menu items" is one mechanism rather
/// than two (03 Toolbar; 06-history-undo.md).
case responderAction(Selector)
/// A search field in AppKit's own `NSSearchToolbarItem` the board's search
/// (03-board-ui.md Toolbar). The item owns the field's layout, so `focusedWidth` is a
/// preference rather than a constraint: it is the width the field takes *when it has the
@@ -88,8 +84,8 @@ struct ToolbarItemSpec {
/// `selected()` names the option index carrying the checkmark, read fresh whenever AppKit
/// opens the menu rather than polled the same freshness every other menu row in the app
/// gets (`validateMenuItem(_:)`) and `select(_:)` is a chosen row's whole action. The one
/// behavior with no `activate()` of its own: firing lives in the dropdown's rows, not in the
/// item itself, the way `responderAction`'s lives in the responder chain rather than here.
/// behavior with no `activate()` of its own: firing lives in the dropdown's rows rather than
/// in the item's primary action.
case picker(
options: [(title: String, symbol: String?)],
selected: () -> Int?,
@@ -126,14 +122,14 @@ struct ToolbarItemSpec {
// MARK: State
/// The item's live enablement. Responder-chain items and the search item answer `true`: the
/// first is validated by the chain itself (which is the point of it), and the second has no
/// enablement of its own.
/// The item's live enablement. The search item and the picker answer `true`: neither has an
/// enablement of its own the field is a control rather than a command, and the picker's
/// choices are always available.
var isEnabled: Bool {
switch behavior {
case let .button(isEnabled, _): isEnabled()
case let .toggle(isEnabled, _, _): isEnabled()
case .responderAction, .searchField, .picker: true
case .searchField, .picker: true
}
}
@@ -141,7 +137,7 @@ struct ToolbarItemSpec {
var isOn: Bool? {
switch behavior {
case let .toggle(_, isOn, _): isOn()
case .button, .responderAction, .searchField, .picker: nil
case .button, .searchField, .picker: nil
}
}
@@ -152,7 +148,7 @@ struct ToolbarItemSpec {
switch behavior {
case let .button(_, perform): perform()
case let .toggle(_, isOn, setOn): setOn(!isOn())
case .responderAction, .searchField, .picker: break
case .searchField, .picker: break
}
}
}
@@ -165,7 +161,9 @@ struct ToolbarItemSpec {
/// ### Why `NSToolbar` rather than SwiftUI's `.toolbar(id:)`
///
/// SwiftUI's customizable toolbar would answer most of 03's clauses, and it was the first choice.
/// Four requirements sent this to AppKit instead, and each is normative rather than aesthetic:
/// Four requirements sent this to AppKit instead, and each was normative rather than aesthetic. Three
/// of them still hold; the fourth is kept below because a retired reason is worth more written down
/// than deleted, and because the three that remain are what the answer now rests on:
///
/// - **F has to know whether the search item is installed.** "With the field removed from the
/// toolbar, invoking it surfaces the field transiently" (03) a decision that needs to read the
@@ -173,12 +171,16 @@ struct ToolbarItemSpec {
/// delegate callbacks); SwiftUI's toolbar API has no such query, and reaching around it into the
/// `NSToolbar` it happens to own means matching identifiers SwiftUI derives rather than ones this
/// app spells.
/// - **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 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.
/// - ~~**Undo and Redo have to reach the responder chain.**~~ **Retired 2026-08-08** with the
/// mechanism it named (13-native-undo.md Rules the command-surface bullet, re-ruled that day):
/// the pair's nil-target `undo:`/`redo:` resolved to `NSWindow`, which reads the manager its
/// delegate vends and a SwiftUI window latches an empty manager of its own during creation,
/// before `HostedWindowController` installs, so that route reached a stack nothing registers into.
/// The pair now carries an **explicit target** over the focused session's `BoardUndoManager`
/// (`BoardToolbar`), which is the same object the app's own Edit Undo/Redo rows read through the
/// focus system "matching their menu items" (03) by sharing the manager rather than by sharing a
/// lookup. Nothing about that needs AppKit; the other three bullets are why this is still an
/// `NSToolbar`.
/// - **The search item is AppKit's own `NSSearchToolbarItem`**, hosting 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
@@ -304,8 +306,6 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
return makeButtonItem(spec)
case .toggle:
return makeToggleItem(spec)
case let .responderAction(selector):
return makeResponderItem(spec, selector: selector)
case let .searchField(focusedWidth, make, install):
return makeSearchItem(
spec,
@@ -333,19 +333,6 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
return item
}
/// **Undo and Redo**: no target, so AppKit resolves and validates the action up the responder
/// chain the same lookup their menu rows use, which is the whole of "matching their menu
/// items" (03 Toolbar). Deliberately *not* a `ValidatingToolbarItem`: the default validation
/// is precisely the behaviour wanted here.
private func makeResponderItem(_ spec: ToolbarItemSpec, selector: Selector) -> NSToolbarItem {
let item = NSToolbarItem(itemIdentifier: spec.identifier)
decorate(item, with: spec)
item.isBordered = true
item.target = nil
item.action = selector
return item
}
/// A toggle button showing on-state. A hosted `NSButton` rather than a plain item because
/// `NSToolbarItem` has no state of its own, and 03 asks for one explicitly ("Show Trash (toggle
/// state matching the View menu checkmark)", "Edit Body is a single toggle button (on-state in
+20 -10
View File
@@ -2,16 +2,25 @@ import AppKit
// MARK: - BoardUndoManager
/// What AppKit is handed for a board's windows: an `UndoManager` that owns no stack and answers
/// every question from the session's `HistoryProviding`.
/// The face a board's undo wears everywhere it is asked about: an `UndoManager` that owns no stack
/// and answers every question from the session's `HistoryProviding`.
///
/// ### Why an adapter exists at all
///
/// The responder chain speaks one currency. `NSWindow` implements `undo:`/`redo:` and validates
/// those menu items itself, reading `canUndo`, `canRedo` and `undoMenuItemTitle` off whatever
/// `UndoManager` the window's delegate hands back (`windowWillReturnUndoManager(_:)`) which is
/// exactly how the system's Edit Undo row and the toolbar's nil-target pair (`BoardToolbar`) light
/// up, disable and retitle with no code of the app's own.
/// The command surface speaks one currency. Enablement, the composed menu title, and the crossing
/// itself are all `UndoManager` questions `canUndo`, `undoMenuItemTitle`, `undo()` and every
/// surface that offers Z asks them of *something*: the app's own Edit Undo/Redo rows read this
/// object through `FocusedValues.undoStack` (`UndoCommands.swift`), the board toolbar's pair carries
/// it as an explicit target (`BoardToolbar`), and AppKit itself still asks the window's delegate for
/// it (`HostedWindowController.windowWillReturnUndoManager`, wired and correct wherever the platform
/// is the one asking). One object answers all of them, which is what keeps them from disagreeing.
///
/// (Those first two used to be the same fact as the third: the rows and the toolbar pair were the
/// system's nil-target `undo:`/`redo:`, validated by `NSWindow` against whatever its delegate vended.
/// That route is unreachable on a SwiftUI window the window latches an empty manager of its own
/// during creation, before any delegate of ours installs so the surface became the app's own,
/// reading this object directly: 13-native-undo.md Rules the command-surface bullet, re-ruled
/// 2026-08-08. What it reads did not change, only how it gets here.)
///
/// The seam, though, must not be an `NSUndoManager`: a protocol that vended one could only ever have
/// had a single implementation, and the substrate stays behind `HistoryProviding` so a future
@@ -50,9 +59,10 @@ import AppKit
/// "Every read-only lock (vanished root, failed reload after wholesale ops, unwritable location)
/// disables Undo/Redo with the other mutating commands; **the stack itself survives the lock and
/// resumes when it clears**" (13 Rules). This is the right place for it and the only one: every
/// surface that offers Z the Edit menu's nil-target row, the toolbar pair, a card window's
/// responder chain validates through this object, so answering `false` here disables all of them
/// at once, exactly as the lock's other victims disable through menu validation (02-architecture.md
/// surface that offers Z the Edit menu's own rows over the focused stack, the board toolbar's
/// pair over the session's, a card window's over its own validates through this object, so
/// answering `false` here disables all of them at once, exactly as the lock's other victims disable
/// through menu validation (02-architecture.md
/// § "The lock's scope"). Putting it in the *provider* would have been the same answer in the wrong
/// place: the stack is not the thing that is locked, the board is, and whatever provider a session
/// binds a future one included must inherit the rule without reimplementing it.
+7 -3
View File
@@ -279,8 +279,10 @@ public struct HistoryStep {
/// the seam is real", 12 written when native undo was the free tier's; the proof it named was
/// two working substrates, which the pivot left standing and the 2026-08-08 excision then narrowed
/// back to one, `strategy/01-git-excision.md` the seam itself is what stays proved either way).
/// AppKit still needs an `UndoManager` to hand the responder chain; that adapter is
/// `BoardUndoManager`, which sits *over* this protocol rather than inside it.
/// The command surface still speaks `UndoManager` the menu rows, the toolbar pair and AppKit's
/// own asks all read one (13-native-undo.md Rules the command-surface bullet, re-ruled
/// 2026-08-08); that adapter is `BoardUndoManager`, which sits *over* this protocol rather than
/// inside it.
/// - **No persistence promise.** The native stack dies with the session (13); a git provider's once
/// survived relaunch because git does, before app-managed git was excised entirely (2026-08-08,
/// `strategy/01-git-excision.md`). Both were honest implementations of these seven members; only
@@ -326,7 +328,9 @@ public protocol HistoryProviding: AnyObject {
func register(_ step: HistoryStep)
/// Whether there is a step to cross. What the Edit menu's Undo row and the toolbar's Undo item
/// enable on, through the same responder-chain answer.
/// enable on both of them through one `BoardUndoManager` over this seam, which is what keeps
/// the two surfaces from being two answers (13-native-undo.md Rules the command-surface
/// bullet, re-ruled 2026-08-08).
var canUndo: Bool { get }
var canRedo: Bool { get }
+25 -7
View File
@@ -1,4 +1,5 @@
import Foundation
import Observation
// MARK: - NativeHistoryProvider
@@ -13,13 +14,16 @@ import Foundation
/// ### Two arrays, and why not `NSUndoManager`
///
/// This provider was an `NSUndoManager` for exactly one milestone, on the argument that the *command
/// surface* is the platform's Edit Undo and Edit Redo are the system's own nil-target
/// `undo:`/`redo:` rows (11-command-nexus.md), the toolbar pair carries the same actions
/// (`BoardToolbar`), and both light up, disable and **retitle** from whatever `UndoManager` the
/// focused window hands back. All of that is still true, and none of it lives here: the retitling is
/// `BoardUndoManager.undoMenuItemTitle` composing `undoMenuTitle(forUndoActionName:)` over the bare
/// phrase this seam vends as a `String?`. The adapter is the `UndoManager`; the substrate never
/// needed to be one.
/// surface* is the platform's Edit Undo and Edit Redo were the system's own nil-target
/// `undo:`/`redo:` rows (11-command-nexus.md), the toolbar pair carried the same actions
/// (`BoardToolbar`), and both lit up, disabled and **retitled** from whatever `UndoManager` the
/// focused window handed back. The surface is the app's own now replaced rows and explicit toolbar
/// targets, reading the focused `BoardUndoManager` through `FocusedValues` (13-native-undo.md Rules
/// the command-surface bullet, re-ruled 2026-08-08, the SwiftUI latch) and the part of that
/// argument this file rests on is untouched either way: none of it lives here, and the *vocabulary*
/// is still the platform's, `BoardUndoManager.undoMenuItemTitle` composing
/// `undoMenuTitle(forUndoActionName:)` over the bare phrase this seam vends as a `String?`. The
/// adapter is the `UndoManager`; the substrate never needed to be one.
///
/// What forced the change is the staleness milestone's third outcome. `HistoryStepOutcome.failed`
/// means **the step stays put** a disk error is retryable, so Z must still be able to reach the
@@ -51,7 +55,21 @@ import Foundation
/// it until the session ends (`strandedSteps`; 13 Interaction with the trash, ruled 2026-07-31
/// the skip-purge decoupling). Nothing can cross a stranded step and nothing can see one in the Edit
/// menu; it is a hold outliving the history it belonged to.
///
/// ### Observable, because the command surface is the app's own now
///
/// The two stacks below back every `canUndo`, `canRedo` and `undoActionName` any surface reads, and
/// since 2026-08-08 those surfaces are SwiftUI's and the toolbar's rather than `NSWindow`'s own
/// validation (13-native-undo.md Rules the command-surface bullet, re-ruled that day: the Edit
/// menu's rows read the focused `BoardUndoManager` through `FocusedValues`, and the toolbar pair
/// carries explicit targets over the same object). Both want
/// to re-derive when a step lands rather than when the user next moves the mouse, and both already
/// know how: the menu rows are views, and `WindowToolbarController.trackValidationState` re-arms
/// `withObservationTracking` over every predicate it was given. Making the *stacks* observed is what
/// lets that machinery reach through the adapter one macro here, and no notification of anyone's
/// own anywhere above.
@MainActor
@Observable
public final class NativeHistoryProvider: HistoryProviding {
/// The two stacks, top last. Both hold steps oriented for crossing see the type's note.
+11 -4
View File
@@ -221,11 +221,18 @@ struct KanbanApp: App {
TrashCommands()
}
// Edit Undo/Redo, replacing the system's own pair **the command surface is the app's**
// (13-native-undo.md Rules, re-ruled 2026-08-08): the platform's nil-target rows resolve
// through `NSWindow.undoManager`, which a SwiftUI window latches empty before any delegate of
// ours can vend the board's, so the rows below read the focused session's stack themselves
// (`UndoCommands.swift`).
UndoRedoCommands()
// The Edit menu: Find (F), then its card-window stepping twins, placed after the standard
// Cut/Copy/Paste/Select All group, which is where macOS puts Find. Undo/Redo and the
// clipboard items are the system's and the board answers them as a responder
// (`ClipboardCommands.swift`) a second item sharing one of those titles is what
// titles-are-API forbids.
// Cut/Copy/Paste/Select All group, which is where macOS puts Find. The clipboard items above
// them stay the system's, answered by the board as a responder (`ClipboardCommands.swift`)
// a second item sharing one of those titles is what titles-are-API forbids. Undo and Redo
// were the system's too until the latch (the group just above).
CommandGroup(after: .pasteboard) {
FindCommand()
FindSteppingCommands()
+58 -20
View File
@@ -34,23 +34,37 @@ extension NSToolbarItem.Identifier {
/// "The board popover deliberately has **no toolbar item** the window-title widget is its
/// committed home" so there is no Board Info entry here, and its absence is pinned by a test.
///
/// ### Undo and Redo are the responder chain's, exactly as the menu's are
/// ### Undo and Redo carry the session's own manager, exactly as the menu's rows do
///
/// 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 "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 board has undo**, so there is no substrate-shaped
/// disablement to write: 03's parenthetical about boards without undo was 06's *git* substrate,
/// which a board once bound by being in git mode on any tier since 12-editions.md PIVOT
/// 2026-08-07, and under a Pro subscription before it, until app-managed git left the app entirely
/// (`strategy/01-git-excision.md`, 2026-08-08) and there stopped being a substrate to bind.
/// **The command surface is the app's** (13-native-undo.md Rules the command-surface bullet,
/// re-ruled 2026-08-08): Edit Undo/Redo are the app's own rows reading the focused session's
/// `BoardUndoManager` through the focus system (`UndoCommands.swift`), and the two items below carry
/// an explicit target over **that same manager** the session's, handed in by the window's host
/// (`BoardWindowHost.configureWindow`). "Matching their menu items" (03) is therefore one *object*
/// rather than one lookup: enablement is `canUndo`/`canRedo` on both surfaces, so a step landing, an
/// empty stack, and the read-only lock reach the pair and the rows together and cannot disagree
/// (`BoardUndoManager` answers all three, and is the only place any of them is decided).
///
/// 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
/// these two are built from static labels (`ToolbarItemSpec.staticLabel`).
/// The pair was nil-target `undo:`/`redo:` until that re-ruling, resolving up the responder chain to
/// the board window's `windowWillReturnUndoManager` a route a SwiftUI window makes unreachable by
/// latching an empty undo manager of its own before any delegate of ours installs (diagnosed
/// 2026-08-07). Nothing about the *design* changed: the same manager, the same predicates, reached
/// by being handed it instead of by looking it up.
///
/// **Every board has undo**, so there is no substrate-shaped disablement to write: 03's
/// parenthetical about boards without undo was the *git* substrate, which a board once bound by being
/// in git mode on any tier since 12-editions.md PIVOT 2026-08-07, and under a Pro subscription
/// before it, until app-managed git left the app entirely (`strategy/01-git-excision.md`,
/// 2026-08-08) and there stopped being a substrate to bind.
///
/// **Toolbar clicks are never text-routed.** The menu rows route Z to the first responder's own
/// manager while a text surface holds the keyboard (13 Undo routing); clicking a toolbar button is
/// not a keystroke aimed at an editor, so these two act on the board stack unconditionally which is
/// also the only stack the item can see.
///
/// Their labels are the design's one exception to the menu-title rule: the *menu* titles are rewritten
/// as the stack changes ("Undo Move Card"), which a toolbar label does not track, so these two are
/// built from static labels (`ToolbarItemSpec.staticLabel`).
@MainActor
enum BoardToolbar {
@@ -78,12 +92,17 @@ enum BoardToolbar {
/// and `@Observable` so the picker's checkmark, read when its menu opens, is never stale.
/// - session: the app's drag session, for the same guard the menu rows carry
/// (`ZoomCommands.isEnabled`).
/// - undo: **the board session's stack**, for the Undo/Redo pair the same `BoardUndoManager`
/// the Edit menu's rows read through the focus system (13-native-undo.md Rules the
/// command-surface bullet, re-ruled 2026-08-08). `nil` for a window whose session has gone,
/// which the pair reads as an empty stack: disabled, and crossing nothing.
static func specs(
store: BoardStore,
search: BoardSearchPresentation,
zoom: BoardZoomStore,
appearance: AppearanceStore,
session: DragSession
session: DragSession,
undo: BoardUndoManager?
) -> [ToolbarItemSpec] {
[
.mirroring(
@@ -132,17 +151,28 @@ enum BoardToolbar {
perform: { [weak zoom] in zoom?.step(.out) }
)
),
// The one pair whose predicate is not a menu row's *expression* but the menu row's own
// object: `canUndo`/`canRedo` on the session's manager, which is where the empty stack
// and the read-only lock are both already decided (`BoardUndoManager`). Weak like every
// other capture here production hands these app-lived objects, and a spec must not be
// what keeps a torn-down session's stack alive.
.staticLabel(
"Undo",
identifier: .boardUndo,
symbol: "arrow.uturn.backward",
behavior: .responderAction(NSSelectorFromString("undo:"))
behavior: .button(
isEnabled: { [weak undo] in undo?.canUndo == true },
perform: { [weak undo] in undo?.undo() }
)
),
.staticLabel(
"Redo",
identifier: .boardRedo,
symbol: "arrow.uturn.forward",
behavior: .responderAction(NSSelectorFromString("redo:"))
behavior: .button(
isEnabled: { [weak undo] in undo?.canRedo == true },
perform: { [weak undo] in undo?.redo() }
)
),
.mirroring(
menuTitle: "Show Trash",
@@ -219,11 +249,19 @@ enum BoardToolbar {
search: BoardSearchPresentation,
zoom: BoardZoomStore,
appearance: AppearanceStore,
session: DragSession
session: DragSession,
undo: BoardUndoManager?
) -> WindowToolbarController {
let controller = WindowToolbarController(
identifier: identifier,
specs: specs(store: store, search: search, zoom: zoom, appearance: appearance, session: session),
specs: specs(
store: store,
search: search,
zoom: zoom,
appearance: appearance,
session: session,
undo: undo
),
defaults: defaultItems
)
// Centered against the window, not a flexible-space sandwich (03 Toolbar's placement