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:
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user