Files
lanework/Kanban/App/UndoCommands.swift
T
rzen 57542177c1 The undo rows stop hearing their own echo — a text manager answers canRedo by posting the checkpoint that re-invalidated the row, forever
Diagnosed by sampling a live frozen instance: with a text surface focused,
RedoMenuRow's body reads the routed manager's title, NSUndoManager.canRedo
posts NSUndoManagerCheckpoint synchronously, UndoCommandTicker bumps its
observed revision mid-body, and SwiftUI schedules the re-evaluation whose
own read posts the next checkpoint — the main thread never returns to the
event loop (~99% CPU, app frozen). Board-routed reads never echo, because
BoardUndoManager's overrides answer from the provider without posting —
which is why the board-only live probe (21/21) never met the loop.

The rows now derive title and enablement inside
UndoCommandTicker.silencingReadEchoes, a synchronous main-actor window in
which bump() drops what arrives: a read cannot change the state it reads,
so the echo carries no information and dropping it loses nothing. Genuine
checkpoints — registration closing a group, a crossing — still land.

Three regression tests pin the mechanism, including the asymmetry that
made the redo side the fuel: canUndo answers silently, canRedo posts.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-08 22:50:00 -04:00

334 lines
16 KiB
Swift

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 checkpoint is also what a
/// text manager posts back when a row merely *reads* it — an echo the rows silence, because heard it
/// is a livelock (`silencingReadEchoes`).
///
/// ### 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
/// Runs a row's derivation with the ticker deaf to it.
///
/// Reading a plain `NSUndoManager`'s enablement or composed title is not passive: `canRedo` —
/// and the composed redo title through it — posts `NSUndoManagerCheckpoint` synchronously as a
/// side effect (documented `NSUndoManager` behavior; `BoardUndoManager`'s overrides answer from
/// the provider and post nothing, so a board-routed read never echoes — only a *text* manager's
/// can). Un-silenced, that echo closes a feedback loop through this ticker: the row's `body`
/// reads a title, the read posts a checkpoint, `bump()` lands mid-`body`, the observation
/// invalidates the row, and the re-derived `body` reads the title again — the main thread never
/// returns to the event loop. Diagnosed 2026-08-08 by sampling a frozen instance; entered
/// whenever the rows rendered while a text surface held the keyboard, which is why the board-only
/// live probe never saw it. A read cannot change the state it reads, so the echo carries no
/// information and dropping it loses nothing.
///
/// The window is synchronous and main-actor, so nothing else can slip a *genuine* checkpoint
/// into it — and one posted from off the main thread hops through `Task` and lands after it
/// closes.
func silencingReadEchoes<T>(_ read: () -> T) -> T {
let outer = isSilenced
isSilenced = true
defer { isSilenced = outer }
return read()
}
/// See `silencingReadEchoes` — bookkeeping about observation, never state a view reads.
@ObservationIgnored private var isSilenced = false
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() {
guard !isSilenced else { return }
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
)
// Silenced because these reads *post*: a text manager answers its title and enablement by
// firing a checkpoint, and a checkpoint bumping the ticker from inside `body` is the row
// invalidating itself, forever (`UndoCommandTicker.silencingReadEchoes`).
let (title, isLive) = UndoCommandTicker.shared.silencingReadEchoes {
(UndoCommandRouting.undoTitle(of: manager), UndoCommandRouting.canUndo(manager))
}
Button(title) {
cross()
}
.keyboardShortcut("z", modifiers: .command)
.disabled(!isLive)
}
/// **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
)
let (title, isLive) = UndoCommandTicker.shared.silencingReadEchoes {
(UndoCommandRouting.redoTitle(of: manager), UndoCommandRouting.canRedo(manager))
}
Button(title) {
cross()
}
.keyboardShortcut("z", modifiers: [.command, .shift])
.disabled(!isLive)
}
private func cross() {
let manager = UndoCommandRouting.routedManager(
stack: stack,
firstResponder: NSApp.keyWindow?.firstResponder
)
guard let manager, manager.canRedo else {
NSSound.beep()
return
}
manager.redo()
}
}