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:
2026-07-31 15:54:22 -04:00
parent 563999655f
commit 142c6e75fe
19 changed files with 3126 additions and 82 deletions
+87
View File
@@ -77,6 +77,14 @@ final class CardWindowSession: CardSessionFlushing {
/// window that has not joined its board holds nothing, which is true.
var rawSourceHoldsUnsavedText: (@MainActor () -> Bool)?
/// The window's raw-source outlet, as the save-or-discard step's two writes: **Apply** (which
/// validates, and answers `false` when it refuses) and **Cancel**. Wired by the host beside
/// `rawSourceHoldsUnsavedText`, and for its reason the outlet is window state living beside
/// this object rather than inside it.
var rawSourceApply: (@MainActor () -> Bool)?
var rawSourceCancel: (@MainActor () -> Void)?
var rawSourceIsActive: (@MainActor () -> Bool)?
/// The Edit buffer's dirty text, a typed-in raw-source outlet, or an inline comment edit session
/// holding keystrokes its file has not got see `CardSessionFlushing`.
///
@@ -89,6 +97,44 @@ final class CardWindowSession: CardSessionFlushing {
body.isDirty || rawSourceHoldsUnsavedText?() == true || comments.holdsUnsavedContent
}
/// **What a restore or a branch switch asks this window to settle** (06-history-undo.md Rules
/// Undo restore vs open Edit sessions).
///
/// ### The predicate is *open*, not *dirty*
///
/// An **open** Edit session is what needs settling even with a clean buffer, because its ~700 ms
/// saves are on disk and deliberately uncommitted the stage-around rule's whole point so a
/// restore landing over them would either bury text no commit protects or leave the session's
/// next debounced save to write pre-restore bytes back over the restored card, "a Z that visibly
/// doesn't happen". Same reading `CardBodyEditSession.isEditing` records for staging, applied to
/// the same fact.
///
/// An **open raw-source outlet** counts whether or not it has been typed in, and 06 says why: its
/// Apply "would write the *entire* pre-switch `index.md` byte-for-byte onto the new branch's
/// card". A buffer read from before the restore is the hazard; typing is not required for it.
var settlement: CardSessionSettlement? {
CardSessionSettlement(
needsSettling: { [self] in
body.isEditing || body.isDirty || rawSourceIsActive?() == true
},
saveAll: { [self] in
// The Edit session ends with its normal commit "each card's EditPreview flip".
body.endEditSession()
// Apply validates; a refusal is the whole operation's cancellation, and the alert it
// raised is already on the offending window.
guard rawSourceIsActive?() == true else { return true }
return rawSourceApply?() ?? true
},
discard: { [self] in
// The buffer goes back to what disk says; the *disk* goes back to the target state as
// part of the restore itself, which reconciles this card's folder against the working
// tree rather than against HEAD (`GitRestoreOperation.plan`).
body.discardBuffer()
rawSourceCancel?()
}
)
}
private var hasEnded = false
/// Both `let`s, wired to each other through a local the guard's two closures need the buffer,
@@ -185,6 +231,9 @@ struct CardWindowHost: View {
/// snapshot the store applies a cache that died with the view would regenerate every thumbnail
/// on every reload (`AttachmentThumbnailCache`).
@State private var thumbnails = AttachmentThumbnailCache()
/// This card's commit trail (05-card-window.md History). Held here for `thumbnails`' reason
/// it must survive every snapshot and surfaced to the view only in git mode (`cardHistory`).
@State private var history = CardHistory()
/// Whether a close is waiting on the dirty-buffer modal. Set when `windowShouldClose` could not
/// flush; cleared by the resolution that lets the close resume.
@State private var isClosePending = false
@@ -311,6 +360,29 @@ struct CardWindowHost: View {
.onDisappear { finish() }
}
/// **This card's commit trail, or nothing at all** (05-card-window.md History).
///
/// `nil` is the section's absence rule, read from the board's own git state rather than from a
/// flag: no `HistoryStore` means the free tier (12-editions.md where the section never exists),
/// and a mode other than `git` means a board the app manages no history for. The object is held
/// by this host so it survives every snapshot, `thumbnails`' reason exactly.
private var cardHistory: CardHistory? {
guard appModel.session(for: ref.board)?.gitMode == .git else { return nil }
return history
}
/// What a trail re-read depends on: this card, and the number of commits the board has landed.
///
/// The count is the committer's own (`GitAutoCommitter.commitCount`), which advances for every
/// commit the app makes the debounced ones, the launch catch-up, and a restore's. A foreign
/// commit an agent made *itself* moves HEAD without touching it; the trail then refreshes at the
/// next commit or the next open, which is the same freshness bound the popover's branch line has
/// and a great deal cheaper than polling HEAD from a sidebar.
private func historyReloadKey(store: BoardStore) -> String {
let commits = appModel.session(for: ref.board)?.git?.committer?.commitCount ?? 0
return "\(ref.cardID)#\(commits)"
}
/// **The minimum grows only while the comments pane is beside the body** (05-card-window.md
/// Composition) which is the whole reason the stacked mount exists, so a narrow display keeps
/// the minimum it always had.
@@ -343,11 +415,20 @@ struct CardWindowHost: View {
attachments: attachments,
comments: session.comments,
thumbnails: thumbnails,
history: cardHistory,
fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id),
onToggleTask: { offset, checked in
store.toggleTaskMarker(inCard: placement.card.id, bodyOffset: offset, checked: checked)
}
)
// **The trail, re-read when a commit lands** (05 History). The id is the pair of facts
// the answer depends on: which card this is, and how many commits this board has made
// so the section refreshes after the app's own commits, after an agent's that the watcher
// committed, and after a Z's restore, with nothing here knowing what a committer is.
.task(id: historyReloadKey(store: store)) {
guard let cardHistory else { return }
await cardHistory.load(boardRoot: store.rootURL, cardFolderName: ref.cardID)
}
// **The listing is the snapshot's, republished** `Card.attachments`, which the loader
// fills from `attachments/`'s top-level files in Finder order. Every write in the
// section is bracketed, so the reload that refreshes this arrives by itself and the
@@ -544,6 +625,12 @@ struct CardWindowHost: View {
// The other half of the outlet's wiring: the session answers for this window's unsaved
// content, and the outlet is the half that does not live inside it (`CardWindowSession`).
session.rawSourceHoldsUnsavedText = { [rawSource] in rawSource.holdsUnsavedText }
// The save-or-discard step's half of the same wiring (06-history-undo.md Branch switching):
// Save All *applies* an open outlet validation included, so a refusal cancels the whole
// operation and Discard leaves it without writing.
session.rawSourceIsActive = { [rawSource] in rawSource.isActive }
session.rawSourceApply = { [rawSource] in rawSource.applyAndLeave() }
session.rawSourceCancel = { [rawSource] in rawSource.cancel() }
Self.configureAttachments(attachments, store: store, cardID: cardID)
Self.configureComments(session.comments, store: store, cardID: cardID)
}