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:
@@ -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 Edit→Preview 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user