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:
@@ -0,0 +1,253 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
// MARK: - Vocabulary
|
||||
|
||||
/// What the user chose at the save-or-discard step (06-history-undo.md ▸ Branch switching: "**Save
|
||||
/// All** ends every session with its normal commit …, **Discard** reverts buffers and uncommitted
|
||||
/// saves to HEAD, **Cancel** keeps the current branch and the sessions").
|
||||
public enum SessionSettleChoice: Sendable, Equatable {
|
||||
case saveAll
|
||||
case discard
|
||||
case cancel
|
||||
}
|
||||
|
||||
/// What the gate concluded — the only thing the operation behind it branches on.
|
||||
public enum SessionSettleOutcome: Sendable, Equatable {
|
||||
|
||||
/// Nothing needed settling, or everything did and did. The tree is settled; run.
|
||||
case proceed
|
||||
|
||||
/// The user chose Cancel. "Cancel keeps everything" — nothing was written, nothing reverted.
|
||||
case cancelled
|
||||
|
||||
/// **Save All met a raw-source buffer that would not validate.** "Since Apply validates, a buffer
|
||||
/// that fails validation cancels the whole switch with focus on the offending window, nothing
|
||||
/// half-switched" (06 ▸ Branch switching). The payload is that window's session id, already
|
||||
/// focused by the gate.
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
// MARK: - What one card window offers the step
|
||||
|
||||
/// **A card window's three answers to the save-or-discard step**, handed over as closures.
|
||||
///
|
||||
/// A type of its own rather than three members on `CardSessionFlushing` because it is optional as a
|
||||
/// unit: a window with nothing settleable in it has no settlement, and the gate should not have to
|
||||
/// ask three questions to find that out. `nil` is also every window in a build with no card session
|
||||
/// at all, which is what the protocol's default supplies.
|
||||
@MainActor
|
||||
public struct CardSessionSettlement {
|
||||
|
||||
/// Whether this window is holding state a wholesale tree operation would disturb.
|
||||
public let needsSettling: @MainActor () -> Bool
|
||||
|
||||
/// Ends the Edit session with its normal commit and *applies* the raw buffer. `false` means the
|
||||
/// raw buffer failed validation.
|
||||
public let saveAll: @MainActor () -> Bool
|
||||
|
||||
/// Reverts the Edit buffer and leaves raw source without writing.
|
||||
public let discard: @MainActor () -> Void
|
||||
|
||||
public init(
|
||||
needsSettling: @escaping @MainActor () -> Bool,
|
||||
saveAll: @escaping @MainActor () -> Bool,
|
||||
discard: @escaping @MainActor () -> Void
|
||||
) {
|
||||
self.needsSettling = needsSettling
|
||||
self.saveAll = saveAll
|
||||
self.discard = discard
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - One settleable session
|
||||
|
||||
/// **A card window, as the save-or-discard step sees it** — three closures and the card it is over.
|
||||
///
|
||||
/// A value of closures rather than a protocol over `CardWindowSession`, for `CloseFlushCoordinator`'s
|
||||
/// reason exactly: what this gate is *about* is a decision procedure, and a procedure written against
|
||||
/// a live window is verifiable only by running the app. The production values come from the card
|
||||
/// windows; a test builds them from a counter.
|
||||
@MainActor
|
||||
public struct SettleableSession {
|
||||
|
||||
/// The window's identity — `CardWindowRef.cardID` is what production passes. Opaque to the gate,
|
||||
/// and only ever handed back to `focus`.
|
||||
public let id: String
|
||||
|
||||
/// The **card's folder name** — its id, which is its folder on disk (01-storage-format.md).
|
||||
///
|
||||
/// Matched component-wise against the paths a wholesale operation would write, which is what
|
||||
/// makes the match survive a lane move: a card's own folder component never changes, only the
|
||||
/// lane above it (`GitHistoryWalk.path(_:isInsideFolderNamed:)`, the same trick, same reason).
|
||||
public let cardFolderName: String
|
||||
|
||||
/// Whether this session is holding state a wholesale tree operation would disturb: unsaved
|
||||
/// keystrokes, an **open** Edit session whose ~700 ms saves are deliberately uncommitted, or a
|
||||
/// raw-source outlet that is open at all.
|
||||
public let needsSettling: @MainActor () -> Bool
|
||||
|
||||
/// **Save All** for this one session: end the Edit session with its normal commit, and *apply*
|
||||
/// the raw buffer. `false` means the raw buffer failed validation — the whole operation is off.
|
||||
public let saveAll: @MainActor () -> Bool
|
||||
|
||||
/// **Discard** for this one session: revert the buffer and leave raw source without writing. The
|
||||
/// on-disk uncommitted saves are reverted by the operation itself, which is comparing this card's
|
||||
/// folder against the working tree rather than against HEAD for exactly that reason
|
||||
/// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)`).
|
||||
public let discard: @MainActor () -> Void
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
cardFolderName: String,
|
||||
needsSettling: @escaping @MainActor () -> Bool,
|
||||
saveAll: @escaping @MainActor () -> Bool,
|
||||
discard: @escaping @MainActor () -> Void
|
||||
) {
|
||||
self.id = id
|
||||
self.cardFolderName = cardFolderName
|
||||
self.needsSettling = needsSettling
|
||||
self.saveAll = saveAll
|
||||
self.discard = discard
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SessionSettleGate
|
||||
|
||||
/// **The save-or-discard step**, as one reusable decision procedure (06-history-undo.md ▸ Rules
|
||||
/// ▸ Undo restore vs open Edit sessions; ▸ Branch switching).
|
||||
///
|
||||
/// ### One machinery, two callers, by design
|
||||
///
|
||||
/// 06 does not describe two gates. It describes the branch-switch step and then hands undo the same
|
||||
/// one by name: "When the diff *does* touch a session card, the restore **gates on the branch-switch
|
||||
/// save-or-discard step** (Branch switching below — Save All / Discard / Cancel, same machinery, same
|
||||
/// rationale)." So this object is written for both from the start; the undo provider is its first
|
||||
/// caller and the branch controls will be its second, passing the paths a checkout would write
|
||||
/// instead of the paths a restore would.
|
||||
///
|
||||
/// ### The diff decides whether it appears at all
|
||||
///
|
||||
/// "A restore materializes only the diff between the current tree and the target state, so a card
|
||||
/// whose open Edit session the diff doesn't touch is simply unaffected — its uncommitted ~700 ms
|
||||
/// saves and the stage-around rule continue undisturbed, and most undos never meet an editor at all."
|
||||
/// That is `settle(touching:)`'s first line, and it is why the gate takes paths rather than a
|
||||
/// yes/no: a modal that appeared on every ⌘Z because *some* window somewhere was in Edit would be a
|
||||
/// different, much worse feature.
|
||||
///
|
||||
/// ### Why the ask is a closure
|
||||
///
|
||||
/// Presenting three buttons is AppKit's job and cannot be asserted without a display. The rule this
|
||||
/// file exists to hold — which sessions are asked about, what each answer does to them, and that a
|
||||
/// failing raw buffer stops everything with focus on the offender — is decidable from values, so the
|
||||
/// presentation is a seam and the decision is testable.
|
||||
@MainActor
|
||||
public struct SessionSettleGate {
|
||||
|
||||
/// Every open card session on this board, read live: a window can open or close between the
|
||||
/// moment an operation starts and the moment it asks.
|
||||
public var sessions: () -> [SettleableSession]
|
||||
|
||||
/// Presents the three-button step and answers what the user chose.
|
||||
public var ask: () async -> SessionSettleChoice
|
||||
|
||||
/// Brings one session's window forward — the "focus on the offending window" half of the
|
||||
/// validation-failure rule.
|
||||
public var focus: (String) -> Void
|
||||
|
||||
public init(
|
||||
sessions: @escaping () -> [SettleableSession],
|
||||
ask: @escaping () async -> SessionSettleChoice,
|
||||
focus: @escaping (String) -> Void = { _ in }
|
||||
) {
|
||||
self.sessions = sessions
|
||||
self.ask = ask
|
||||
self.focus = focus
|
||||
}
|
||||
|
||||
// MARK: The decision
|
||||
|
||||
/// Settles whatever the operation's paths reach, and answers whether it may run.
|
||||
///
|
||||
/// - Parameter paths: board-root-relative paths the operation would write.
|
||||
public func settle(touching paths: Set<String>) async -> SessionSettleOutcome {
|
||||
let candidates = Self.reached(by: paths, among: sessions()).filter { $0.needsSettling() }
|
||||
guard !candidates.isEmpty else { return .proceed }
|
||||
|
||||
switch await ask() {
|
||||
case .cancel:
|
||||
return .cancelled
|
||||
|
||||
case .discard:
|
||||
for session in candidates { session.discard() }
|
||||
return .proceed
|
||||
|
||||
case .saveAll:
|
||||
for session in candidates {
|
||||
guard session.saveAll() else {
|
||||
// "Nothing half-switched": the sessions saved before this one are saved, which is
|
||||
// an ordinary Save and loses nothing, but the operation itself does not run.
|
||||
focus(session.id)
|
||||
return .failed(session.id)
|
||||
}
|
||||
}
|
||||
return .proceed
|
||||
}
|
||||
}
|
||||
|
||||
/// **Which sessions a set of paths reaches** — pure, and the whole of "the diff touches a session
|
||||
/// card".
|
||||
///
|
||||
/// Component-exact folder matching, so a card whose id happens to be a prefix of another's cannot
|
||||
/// drag that other card's window into the step.
|
||||
public static func reached(
|
||||
by paths: Set<String>,
|
||||
among sessions: [SettleableSession]
|
||||
) -> [SettleableSession] {
|
||||
guard !paths.isEmpty else { return [] }
|
||||
return sessions.filter { session in
|
||||
paths.contains { GitHistoryWalk.path($0, isInsideFolderNamed: session.cardFolderName) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The presented step
|
||||
|
||||
/// **The three buttons**, as an `NSAlert` — the production `SessionSettleGate.ask`.
|
||||
///
|
||||
/// One of 02-architecture.md's sanctioned modal moments, and it is modal for `DirtyBufferGuard`'s
|
||||
/// reason exactly: the operation behind it cannot proceed until the user has decided what happens to
|
||||
/// text no commit protects, and there is no non-modal shape for a question whose three answers are
|
||||
/// mutually exclusive and immediate.
|
||||
///
|
||||
/// The wording is 06's own vocabulary. The default is **Cancel**, deliberately: a Return pressed
|
||||
/// reflexively at a dialog nobody read must be the answer that changes nothing, and both other
|
||||
/// answers write.
|
||||
public enum SessionSettleStep {
|
||||
|
||||
public static let title = "Unsaved card edits"
|
||||
|
||||
public static let message = """
|
||||
Restoring an earlier state would change cards you are editing. \
|
||||
Save them, discard the changes, or cancel.
|
||||
"""
|
||||
|
||||
@MainActor
|
||||
public static func ask() async -> SessionSettleChoice {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = title
|
||||
alert.informativeText = message
|
||||
// Order matters for the key equivalents AppKit assigns: the first button takes Return, so
|
||||
// Cancel leads and the two writing answers follow. Escape reaches Cancel either way.
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
alert.addButton(withTitle: "Save All")
|
||||
alert.addButton(withTitle: "Discard")
|
||||
|
||||
switch alert.runModal() {
|
||||
case .alertSecondButtonReturn: return .saveAll
|
||||
case .alertThirdButtonReturn: return .discard
|
||||
default: return .cancel
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user