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
334 lines
16 KiB
Swift
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()
|
|
}
|
|
}
|