The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - Restore subjects compose the inverse, never nest: crossing "Undo: S" emits "Redo: S" and vice versa; parity, not stack depth, reads a legacy double prefix (GitHistoryProvider.restoreSubject). - Git-operation failures join the one-shot failure banner tier: BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error tone at failure rank merged with write one-shots by recency; the postLoss compromise is retired at both AppModel wirings. - order/schema optional below the board root: append-at-end reading (ordered siblings first, folder-name tie-break among the order-less), schema reads 1, both coerce-tier logged; the root keeps its requirements. Ranks.resolvedOrders materializes finite ranks so models and placement math stay untouched; first Writer rewrite stamps a real rank on touch, placement against an order-less sibling stamps that sibling inline in the same bracket. Agent guide v10 teaches optional keys and zero-read filing. Hostile-YAML order shapes become coercion tests; Fixtures/Valid/optional-keys.kanban replaces the four retired Malformed boards. - .gitignore is the relocation-heal noise gate: GitignoreRules pure matcher (standard semantics, board-root file only), loader consults it once per walk so matched loose files keep the stray posture; seeded (.DS_Store + .*.lanework-*) at board creation and template instantiation, healed in when missing at open — repo-nested included; empty file honored, existing files never edited; the committer's obedience via libgit2 status is pinned by test. - Comments crash-residue sweep gates on step ownership: HistoryStep derives backing from its own undo expectations, backedContent unions both stacks, the sweep purges per-entry only what no live step owns. - Skip-purge decoupled (16ef377): a stale-skipped coarse step strands whole in NativeHistoryProvider.strandedSteps — still backing, retired only at session end; clean exits purge as before. - Coarse close step named "Changes to '<card>'"; the fine body-edit wording never leaks onto the board menu. - Branch-switch settle clears every open card window's fine stack on Save All and Discard alike; the empty fold registers no coarse step. - Close flush awaits its covering snapshot (quiesce + one generation bump, 1s bound), and an explicit flush now queues behind an in-flight one instead of skipping — the audit-caught interleaving could lose a close flush permanently when the debounce fired inside the close sequence; regression tests force both races. - Commit comment bullets sort chronologically by created, not UUID. - The production-unwired CardBodyEditSession.editSessionDidChange seam is deleted with its seam-only tests. - Composition-root pins: beginSession composes the committer with the store's own EchoLedger and binds the announcer (the miswire class). - Deliberate 06 conformance pass over every 2026-07-31-tagged sentence: fixed Change-custom-key subjects (the retired named generic was the only producer), the unbuilt Replace attachment vocabulary, heal commits now authored Lanework Integrity, the config reader scopes identity to plain [user] sections, add-git re-runs detection at create (a stale mode-none could initialize inside the user's repo), and add-git failures answer at the form or the banner. Structural residue filed on the Redesign board. 2554 tests / 439 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
206 lines
12 KiB
Swift
206 lines
12 KiB
Swift
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`.
|
|
///
|
|
/// ### 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 seam, though, must not be an `NSUndoManager`: a gitless board's stack is one, a Pro git
|
|
/// board's is git (12-editions.md ▸ The provider seam), and a protocol that vended one could only
|
|
/// ever have had a single implementation. So the substrate stays behind `HistoryProviding` and
|
|
/// *this* object is the translation — one per board session, over whichever provider that session
|
|
/// was composed with. A git board inherits the whole command surface (enablement, dynamic titles,
|
|
/// ⌘Z, the toolbar pair) by binding its provider and changing nothing here, which is what "a user
|
|
/// subscribing (or lapsing) relearns nothing" (12) has to mean in code.
|
|
///
|
|
/// **And one per open card window**, over that window's own stack (13 ▸ Rules ▸ two levels, re-ruled
|
|
/// 2026-07-31 — `CardWindowUndo.manager`): the second level needs precisely the same translation, so
|
|
/// it gets the same object rather than a second one shaped like it. The name is now half-right and
|
|
/// kept anyway: renaming it would say the two levels are two mechanisms, and they are not.
|
|
///
|
|
/// ### It deliberately keeps its inherited stack empty
|
|
///
|
|
/// Every question is overridden, so anything that *did* register into this manager by accident (a
|
|
/// stray AppKit client finding it through the window) would be invisible rather than half-live: it
|
|
/// could never enable Undo, retitle it, or be crossed by ⌘Z. Nothing registers here today; the
|
|
/// overrides are what make that safe rather than lucky.
|
|
///
|
|
/// `removeAllActions()` is deliberately **not** forwarded to `HistoryProviding.clear()`: AppKit
|
|
/// clears a window's undo manager on paths this app does not control, and a card window closing must
|
|
/// not empty the board's stack (13-native-undo.md ▸ Rules — the stack belongs to the *session*, and
|
|
/// its one clearing point is that session's teardown).
|
|
///
|
|
/// That non-forwarding is what the **close fold** rests on, now that a card window has a stack of its
|
|
/// own: AppKit empties a closing window's undo manager, and the session's net effect is read off that
|
|
/// window's steps a moment later, from `onDisappear` (`CardWindowSession.endSession`). A forwarded
|
|
/// clear would silently make every close a no-net-change close.
|
|
///
|
|
/// ### The read-only lock disables Undo and Redo here
|
|
///
|
|
/// "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
|
|
/// § "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 a Pro session binding the git
|
|
/// provider must inherit the rule without reimplementing it.
|
|
public final class BoardUndoManager: UndoManager {
|
|
|
|
/// The substrate this manager is a face for. Strong: the session owns both, and the manager is
|
|
/// only ever reachable while the session that made it is alive.
|
|
///
|
|
/// ### `nil` is a board with **no undo provider**, and it is a real state
|
|
///
|
|
/// **No board the app composes is one any more** (re-ruled 2026-07-31, twice): "the pair disabled
|
|
/// only under locks and on empty stacks — the provider follows the board, so boards without
|
|
/// app-managed git — repo-nested included — bind 13-native-undo.md's native stack in **every**
|
|
/// tier" (03-board-ui.md ▸ Toolbar ▸ Catalog). The repo-nested board was the last holder of this
|
|
/// state and no longer is: that rule was about *git*, and this stack never touches git.
|
|
///
|
|
/// The state stays modelled because the seam still admits it — a test binds a substrate-less
|
|
/// session through `AppModel.makeHistoryProvider` — and because an absent substrate is the honest
|
|
/// shape for one: every question below answers the empty way, so the Edit menu's rows, the
|
|
/// toolbar pair, and ⌘Z itself go quiet together through the same validation path a lock uses,
|
|
/// and nothing can accidentally accumulate in a stack that is not there.
|
|
///
|
|
/// ### Settable, for exactly one event
|
|
///
|
|
/// **Add-git** (06 ▸ Rules ▸ Detection) is the design's one sanctioned mid-session mode flip:
|
|
/// "clicking it flips the open board into git mode immediately". 13's header says what that means
|
|
/// here — the flip **swaps the substrate**: the mode-none board's native stack is discarded and
|
|
/// the git trail seeded from the root commit, the branch-switch discard-and-reseed precedent. The
|
|
/// composition root writes the new provider into this property rather than rebuilding this
|
|
/// object, so AppKit keeps the identical manager it has already been handed by
|
|
/// `windowWillReturnUndoManager` and simply revalidates over a different stack.
|
|
///
|
|
/// (This is *not* a tier flip. 12-editions.md's "an open board finishes with the provider it
|
|
/// composed" is about a subscription lapsing, which cannot change a running session's tier at
|
|
/// all — `BoardSession.tier` is a `let` with no setter. Mode can change, by explicit command,
|
|
/// and only in this one direction.)
|
|
var history: (any HistoryProviding)?
|
|
|
|
/// Whether the board is refusing writes — `BoardStore.isReadOnly`, read through a closure rather
|
|
/// than by holding the store. The adapter is deliberately store-free (it is a face for a *seam*,
|
|
/// and Pro binds a different substrate behind the same one), and a closure is what lets the
|
|
/// composition root wire the board's own truth in without this file learning what a `BoardStore`
|
|
/// is. The default answers "writable", which is what a manager built without a board — a test of
|
|
/// the adapter's own grammar — should have.
|
|
private let isReadOnly: @MainActor () -> Bool
|
|
|
|
public init(history: (any HistoryProviding)?, isReadOnly: @escaping @MainActor () -> Bool = { false }) {
|
|
self.history = history
|
|
self.isReadOnly = isReadOnly
|
|
super.init()
|
|
}
|
|
|
|
// MARK: Enablement
|
|
|
|
/// **False under the lock, whatever the stack holds.** The steps are still there — this is an
|
|
/// enablement answer, not a clearing — so the first ⌘Z after the lock clears crosses the step it
|
|
/// would have crossed before it landed. False with no substrate at all, for the reason
|
|
/// `history` records.
|
|
public override var canUndo: Bool { !isReadOnly() && history?.canUndo == true }
|
|
|
|
public override var canRedo: Bool { !isReadOnly() && history?.canRedo == true }
|
|
|
|
// MARK: Crossing
|
|
|
|
/// Not gated on the lock, deliberately: `undo:` reaches a manager only through a menu item or
|
|
/// toolbar button that has already validated against `canUndo`, and a crossing that somehow
|
|
/// started anyway is refused one layer down by `performWrite` — which leaves the step on the
|
|
/// stack (`HistoryStepOutcome.failed`), the same place this enablement rule keeps it. A second
|
|
/// guard here would be a second answer to one question.
|
|
public override func undo() { history?.undo() }
|
|
|
|
public override func redo() { history?.redo() }
|
|
|
|
// MARK: Titles
|
|
|
|
/// `NSUndoManager`'s own vocabulary for "the phrase, without the verb" — `""` when there is
|
|
/// nothing to cross, which is what its menu-title composition expects.
|
|
public override var undoActionName: String { history?.undoActionName ?? "" }
|
|
|
|
public override var redoActionName: String { history?.redoActionName ?? "" }
|
|
|
|
/// "Undo Move 3 Cards" — composed and localized by the platform (`undoMenuTitle(forUndoActionName:)`
|
|
/// reads the `undo.strings` pattern), so the step vocabulary stays the bare phrase and this app
|
|
/// never spells the word "Undo" in a title.
|
|
///
|
|
/// The trim is for the nameless case: the pattern is `Undo %@`, so an empty action name would
|
|
/// otherwise leave a trailing space where `NSUndoManager` itself answers a bare "Undo".
|
|
public override var undoMenuItemTitle: String {
|
|
undoMenuTitle(forUndoActionName: undoActionName).trimmingCharacters(in: .whitespaces)
|
|
}
|
|
|
|
public override var redoMenuItemTitle: String {
|
|
redoMenuTitle(forUndoActionName: redoActionName).trimmingCharacters(in: .whitespaces)
|
|
}
|
|
}
|
|
|
|
// MARK: - BoardUndoRouting
|
|
|
|
/// Which undo a hosted window answers with, given what holds the keyboard — 06-history-undo.md
|
|
/// ▸ Undo routing, which 12 records as **tier-independent**: "focus decides text-undo vs
|
|
/// board-undo; only the substrate behind board-undo differs".
|
|
///
|
|
/// ### Most of the rule is AppKit's, and is not here
|
|
///
|
|
/// "While a text-editing surface is focused ... ⌘Z/⇧⌘Z are that editor's own text undo" is the
|
|
/// platform's own behaviour for a text view that vends a manager: `NSWindow.undo:` crosses the
|
|
/// *first responder's* `undoManager`, and both of this app's real editors vend their own through the
|
|
/// text-view delegate (`CardBodySurface`, `CardRawSourceView` — each with an `UndoManager` created
|
|
/// per editor, emptied when the session ends). Those never reach this file.
|
|
///
|
|
/// ### What is here is the case AppKit gets wrong
|
|
///
|
|
/// A **field editor** — the shared `NSTextView` that appears inside every focused `NSTextField` and
|
|
/// `NSSearchField` — vends no manager of its own to the window, so `undo:` falls through to the
|
|
/// window's, and a reflexive ⌘Z over a typo in the search field or the board-rename field would
|
|
/// cross a *board* step. 06 rules that out by name: "Control-class text fields route the same way
|
|
/// (settled): the search field ..., the popover's rename field, and the settings sheet's text fields
|
|
/// (commit identity, credentials, remote URL) own ⌘Z/⇧⌘Z as field-local text undo while focused — a
|
|
/// reflexive undo over a typo must never become a tree checkout." (That list is 06's own since the
|
|
/// 2026-07-31 popover/sheet split; the rule is per *responder class*, so it covers each of them
|
|
/// wherever it is hosted, and the sheet's arrival adds nothing here.) So the
|
|
/// window-level answer is the board's stack **only when no text-editing surface holds the
|
|
/// keyboard**, and a per-window text manager otherwise — which is also exactly what AppKit would
|
|
/// have created for such a window on its own, so nothing about typing in a field changes.
|
|
///
|
|
/// Pure and free of windows on purpose: the decision is three lines that are invisible until they
|
|
/// are wrong, and the responder it reads is the only part a test cannot conjure.
|
|
public enum BoardUndoRouting {
|
|
|
|
/// Whether `responder` is a text-editing surface — every `NSTextView`, field editors included,
|
|
/// which `NSText` is precisely the ancestor of. An `NSTextField` that has focus without editing
|
|
/// is *not* one: AppKit installs the field editor the moment editing begins, and until then the
|
|
/// board is what the keyboard is acting on.
|
|
public static func isTextEditing(_ responder: NSResponder?) -> Bool {
|
|
responder is NSText
|
|
}
|
|
|
|
/// The manager a window's delegate should answer with.
|
|
///
|
|
/// `board` is `nil` for every window that is not showing a board — welcome, the restore
|
|
/// bootstrap, the template chooser, and a board or card window whose session has already been
|
|
/// torn down. Those get the text manager too, which is the platform default they had before this
|
|
/// seam existed.
|
|
public static func undoManager(
|
|
isTextEditing: Bool,
|
|
board: UndoManager?,
|
|
textFallback: UndoManager
|
|
) -> UndoManager {
|
|
guard !isTextEditing, let board else { return textFallback }
|
|
return board
|
|
}
|
|
}
|