Implement undo and redo as forward commits
GitHistoryProvider is the second HistoryProviding implementation: its stack IS HEAD's first-parent ancestry, reseeded on load (redo empty), re-synced to HEAD before every crossing so agents' self-commits become the top and ⌘Z steps back exactly one commit; any arriving commit clears redo (a heal-only window deliberately does not). Restores are forward commits through the ordinary signature path — GitRestoreOperation materializes only the current-vs-target diff as working-tree writes and resolves no reset/checkout symbol at all; heal commits are transparent in-session (pointer passes over, restores exclude heal-owned paths, identity carried on landed windows via PlannedCommit.kind → GitLandedCommit). Subjects "Undo:/Redo: <crossed subject>"; menu labels never nest in-session; the root commit is not a step (crossing it would restore the empty tree). Provider binding flips: makeHistoryProvider(store, tier, git) — free binds native everywhere, Pro binds the git provider on git boards and NOTHING on mode-none/repo-nested (the pair disables through existing validation); add-git mid-session live-binds via HistoryStore.didAddGit → bindHistoryProvider (the flip only ever adds). SessionSettleGate is the reusable Save All / Discard / Cancel step: restores whose diff touches an open Edit session or raw-source buffer gate on it (Save All applies with validation — a refused buffer cancels the whole restore focused on the offender; Discard reverts via CardBodyEditSession.discardBuffer and reconciles against the working tree, deliberately skipping the second flush); untouched sessions ride through undisturbed. Built for the branch-switch card to reuse. BoardStore gains the async performWholesale sibling. CardHistorySection fills the m6 EmptyView slot: read-only, newest first, follows the card across lane moves by folder-component match (the UUID is the identity — no rename detection), absent off git mode and off Pro. 2332 tests / 403 suites green; InertGitTests untouched. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -48,7 +48,31 @@ 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.
|
||||
private let history: any HistoryProviding
|
||||
///
|
||||
/// ### `nil` is a board with **no undo provider**, and it is a real state
|
||||
///
|
||||
/// Under Pro, a board in mode `none` or `repoNested` gets no provider at all — "the pair disabled
|
||||
/// on boards with no undo provider in the composed tier — under Pro, no-git and repo-nested
|
||||
/// boards, matching their menu items" (03-board-ui.md ▸ Toolbar ▸ Catalog; 06-history-undo.md
|
||||
/// ▸ Rules). 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. Modelling
|
||||
/// it as an absent substrate rather than as a substrate that always says no is the honest shape:
|
||||
/// there is nothing there, and nothing can accidentally accumulate in it.
|
||||
///
|
||||
/// ### 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 — the popover flows straight into
|
||||
/// the git controls, the first auto-commit follows". A board that gains a repository mid-session
|
||||
/// gains a commit trail, and a trail with a dead ⌘Z over it would read as a bug. The composition
|
||||
/// root binds the git provider here on that flip, rather than rebuilding this object, so AppKit
|
||||
/// keeps the identical manager it has already been handed by `windowWillReturnUndoManager`.
|
||||
///
|
||||
/// (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*,
|
||||
@@ -58,7 +82,7 @@ public final class BoardUndoManager: UndoManager {
|
||||
/// the adapter's own grammar — should have.
|
||||
private let isReadOnly: @MainActor () -> Bool
|
||||
|
||||
public init(history: any HistoryProviding, isReadOnly: @escaping @MainActor () -> Bool = { false }) {
|
||||
public init(history: (any HistoryProviding)?, isReadOnly: @escaping @MainActor () -> Bool = { false }) {
|
||||
self.history = history
|
||||
self.isReadOnly = isReadOnly
|
||||
super.init()
|
||||
@@ -68,10 +92,11 @@ public final class BoardUndoManager: UndoManager {
|
||||
|
||||
/// **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.
|
||||
public override var canUndo: Bool { !isReadOnly() && history.canUndo }
|
||||
/// 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 }
|
||||
public override var canRedo: Bool { !isReadOnly() && history?.canRedo == true }
|
||||
|
||||
// MARK: Crossing
|
||||
|
||||
@@ -80,17 +105,17 @@ public final class BoardUndoManager: UndoManager {
|
||||
/// 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 undo() { history?.undo() }
|
||||
|
||||
public override func redo() { history.redo() }
|
||||
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 undoActionName: String { history?.undoActionName ?? "" }
|
||||
|
||||
public override var redoActionName: String { history.redoActionName ?? "" }
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user