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:
+174
-17
@@ -290,19 +290,30 @@ public final class AppModel {
|
||||
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
|
||||
/// nothing renders from it.
|
||||
///
|
||||
/// ### The `Tier` argument, and the one consumer it is still short
|
||||
/// ### The three answers, and the `nil` among them
|
||||
///
|
||||
/// **`pro-m1` is the consumer that will switch on it.** The default closure ignores the tier
|
||||
/// today and binds the native stack for both — not because the seam is decorative, but because
|
||||
/// the git provider it would bind does not exist yet (12 ▸ Tier matrix: git init/adoption,
|
||||
/// git-backed undo and the history surfaces are all Pro-tier work, designed in 06-history-undo.md
|
||||
/// and unbuilt). The argument is here now so that arriving milestone is one closure body rather
|
||||
/// than a change to the composition root, and so that the tier a board actually composed under is
|
||||
/// a recorded fact from today (`BoardSession.tier`) instead of something pro-m1 has to introduce
|
||||
/// alongside its provider.
|
||||
/// The closure is now the whole tier matrix, in three lines (12 ▸ Tier matrix; 06 ▸ Rules):
|
||||
///
|
||||
/// - **Free** — the native stack, on every board. There is no `HistoryStore` at all off Pro, so
|
||||
/// the absent git state *is* the tier test; nothing here reads a flag.
|
||||
/// - **Pro, mode `git`** — the git provider: undo as forward restore commits over HEAD's
|
||||
/// first-parent ancestry.
|
||||
/// - **Pro, mode `none` or `repoNested`** — **no provider**. "A board without git has no
|
||||
/// undo/redo", and a repo-nested board is one the app "leaves strictly alone" — so Edit
|
||||
/// ▸ Undo/Redo and the toolbar pair disable there ("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). Not the native stack: on Pro, a mode-none board's edits
|
||||
/// are deliberately unhistoried, and half-undoing them from an in-memory stack would be a second
|
||||
/// substrate the design does not have.
|
||||
///
|
||||
/// The `HistoryStore` argument is what makes that decidable here, and it is why `beginSession`
|
||||
/// composes the git state *before* the provider: which substrate a board gets is a question about
|
||||
/// its repository, and a root that had to ask the disk itself would be a second detection.
|
||||
@ObservationIgnored
|
||||
public var makeHistoryProvider: (BoardStore, Tier) -> any HistoryProviding = { _, _ in
|
||||
NativeHistoryProvider()
|
||||
public var makeHistoryProvider: (BoardStore, Tier, HistoryStore?) -> (any HistoryProviding)? = { store, _, git in
|
||||
guard let git else { return NativeHistoryProvider() }
|
||||
guard git.mode == .git else { return nil }
|
||||
return GitHistoryProvider(boardRoot: store.rootURL)
|
||||
}
|
||||
|
||||
// MARK: Sessions
|
||||
@@ -323,7 +334,16 @@ public final class AppModel {
|
||||
///
|
||||
/// Which implementation it is, is the tier's answer and nobody else's
|
||||
/// (12-editions.md ▸ The provider seam) — see `AppModel.makeHistoryProvider`.
|
||||
public let history: any HistoryProviding
|
||||
///
|
||||
/// **`nil` is a board with no undo at all** — under Pro, mode `none` and repo-nested boards
|
||||
/// (06-history-undo.md ▸ Rules: "A board without git has **no undo/redo**"). The command
|
||||
/// surface disables through `undoManager`, which answers the empty way over an absent
|
||||
/// substrate.
|
||||
///
|
||||
/// A `var`, unlike `tier` beside it, and for one event only: **add-git**, the design's single
|
||||
/// sanctioned mid-session mode flip, binds a provider here on the board it flips
|
||||
/// (`bindHistoryProvider(for:)`). A tier lapse still cannot touch it — `tier` has no setter.
|
||||
public var history: (any HistoryProviding)?
|
||||
|
||||
/// **The tier this board composed under** (12-editions.md ▸ The entitlement).
|
||||
///
|
||||
@@ -724,10 +744,6 @@ public final class AppModel {
|
||||
// also the *only* time this board asks: the answer becomes `BoardSession.tier` and nothing
|
||||
// re-derives it.
|
||||
let tier = currentTier()
|
||||
// The board's stack is born here, with the session that owns it, and dies in `tearDown`
|
||||
// below — the whole of 13-native-undo.md's session-only persistence: "the stack lives with
|
||||
// the board session and dies at close/quit ... standard macOS behavior".
|
||||
let history = makeHistoryProvider(store, tier)
|
||||
// **Mode detection** (06-history-undo.md ▸ Rules ▸ Detection: "checked at every board
|
||||
// open"), on the same line as the tier that gates it. Under `.free` this returns `nil`
|
||||
// without looking at the disk at all — the inert posture is unconditional there — and under
|
||||
@@ -736,7 +752,17 @@ public final class AppModel {
|
||||
//
|
||||
// Deliberately *not* re-run anywhere: no reload path, no watcher event, nothing. "The
|
||||
// running session keeps its mode, and the watcher does not scan for `.git` appearing."
|
||||
//
|
||||
// **Before the provider**, which is new in pro-m1: which substrate a board's undo is depends
|
||||
// on the mode this line detects (`makeHistoryProvider`), and a root that had to look at the
|
||||
// disk itself would be a second detection able to disagree with this one.
|
||||
let git = HistoryStore.compose(boardRoot: store.rootURL, tier: tier)
|
||||
// The board's stack is born here, with the session that owns it, and dies in `tearDown`
|
||||
// below — the whole of 13-native-undo.md's session-only persistence: "the stack lives with
|
||||
// the board session and dies at close/quit ... standard macOS behavior". On Pro's git boards
|
||||
// it is instead the repository's own trail, which survives everything (06 ▸ Rules ▸ Undo
|
||||
// survives relaunch) — the seam's whole point.
|
||||
let history = makeHistoryProvider(store, tier, git)
|
||||
// **The loader's earlier-occurrence-wins history rung** (01-storage-format.md ▸ Fractal
|
||||
// layout ▸ Rules; `BoardLoader.IdentityHistoryRanker`): git-mode boards get a ranker,
|
||||
// everything else keeps injecting nothing. A *provider* rather than a ranker because each
|
||||
@@ -763,6 +789,20 @@ public final class AppModel {
|
||||
store?.banners.clearHistorySuspension()
|
||||
}
|
||||
store.commitSeam = .binding(to: committer)
|
||||
// **The undo stack's ear on the committer** — every commit this engine lands, and
|
||||
// which of it was heal work (06 ▸ Rules ▸ The stack is HEAD's first-parent ancestry,
|
||||
// live; ▸ Heal commits are transparent to undo). Bound here rather than in
|
||||
// `wireGitUndo` because add-git builds a *new* committer, and this wiring is what
|
||||
// `activateAutoCommit` remembers on its behalf.
|
||||
committer.reportLanded = { [weak self, ref] window in
|
||||
guard let provider = self?.sessions[ref]?.history as? GitHistoryProvider else { return }
|
||||
provider.noteLanded(window)
|
||||
}
|
||||
}
|
||||
// **Add-git binds undo too** (06 ▸ Rules ▸ Detection — the one commanded mid-session mode
|
||||
// flip). See `bindHistoryProvider(for:)` for the judgment call this records.
|
||||
git.didAddGit = { [weak self] in
|
||||
self?.bindHistoryProvider(for: ref)
|
||||
}
|
||||
}
|
||||
// **The binding 13-native-undo.md ▸ Rules' "registration at the Writer boundary" needs**: the
|
||||
@@ -789,10 +829,127 @@ public final class AppModel {
|
||||
cardRefs: [],
|
||||
access: access
|
||||
)
|
||||
wireGitUndo(history, store: store, git: git, ref: ref)
|
||||
clearLaunchFailures(naming: [ref.path, store.rootURL.path])
|
||||
refreshRecents()
|
||||
}
|
||||
|
||||
// MARK: - The git provider's wiring
|
||||
|
||||
/// Fills a `GitHistoryProvider`'s seams with the session it is the history of — and does nothing
|
||||
/// at all for any other substrate.
|
||||
///
|
||||
/// Everything the git provider needs is a fact about *this* board that neither a repository nor a
|
||||
/// protocol could supply: which committer's debounce to settle first, whether the git surface is
|
||||
/// held, which card windows a restore's diff would disturb, and the bracket a wholesale tree
|
||||
/// change runs inside. Each arrives as a closure for `HistoryCommitSeam`'s reason — the provider
|
||||
/// stays a thing that knows about commits, and the model stays the only object that knows what a
|
||||
/// window is.
|
||||
private func wireGitUndo(
|
||||
_ history: (any HistoryProviding)?,
|
||||
store: BoardStore,
|
||||
git: HistoryStore?,
|
||||
ref: BoardWindowRef
|
||||
) {
|
||||
guard let provider = history as? GitHistoryProvider, let git else { return }
|
||||
|
||||
provider.flushPendingCommit = { [weak git] in
|
||||
await git?.committer?.flushNow()
|
||||
}
|
||||
provider.isHeld = { [weak git] in git?.committer?.pause != nil }
|
||||
provider.suspendCommitting = { [weak git] in git?.committer?.stop() }
|
||||
provider.resumeCommitting = { [weak git] in git?.committer?.start() }
|
||||
// **A restore that failed cleanly** (06 ▸ Interaction with external writers: "surfaces as a
|
||||
// one-shot banner failure naming the operation and the error, the tree left as it was").
|
||||
//
|
||||
// Posted as a **loss row**, and the compromise is recorded rather than hidden: the true
|
||||
// failure class (`OneShotBanner`) carries a `BoardWriteError`, whose `operation` is the closed
|
||||
// `WriteOperation` vocabulary — and a git operation is deliberately not one of those
|
||||
// (`BoardStore.performWholesale`'s own note says so). The loss row is the nearest honest
|
||||
// class: warning tone, one-shot lifecycle, never auto-expires, and a free-form message that
|
||||
// can name both halves 06 asks for. A message-carrying failure class is the right fix and is
|
||||
// a banner-surface change, not this card's.
|
||||
provider.reportFailure = { [weak store] failure in
|
||||
store?.banners.postLoss(failure.description)
|
||||
}
|
||||
provider.runBracketed = { [weak store] subject, work in
|
||||
guard let store else { return await work() }
|
||||
// The completion phrase 10-accessibility.md gives a bracketed operation is the restore's
|
||||
// own subject — the sentence the trail now carries, spoken once when the reload lands.
|
||||
try? await store.performWholesale(announcing: subject) { await work() }
|
||||
}
|
||||
provider.settleSessions = { [weak self, weak provider] paths in
|
||||
guard let self, let provider else { return .proceed }
|
||||
return await self.settleGate(for: ref, provider: provider).settle(touching: paths)
|
||||
}
|
||||
provider.seed()
|
||||
}
|
||||
|
||||
/// **The save-or-discard step for one board**, built from its open card windows
|
||||
/// (06-history-undo.md ▸ Rules ▸ Undo restore vs open Edit sessions; ▸ Branch switching).
|
||||
///
|
||||
/// Built per ask rather than stored, because its whole content is "which card windows are open
|
||||
/// right now" — a set that changes under any operation slow enough to need the step at all.
|
||||
func settleGate(for ref: BoardWindowRef, provider: GitHistoryProvider?) -> SessionSettleGate {
|
||||
SessionSettleGate(
|
||||
sessions: { [weak self] in
|
||||
guard let self, let session = self.sessions[ref] else { return [] }
|
||||
return session.cardRefs.compactMap { cardRef in
|
||||
guard let flushing = self.cardSessions[cardRef],
|
||||
let settlement = flushing.settlement else { return nil }
|
||||
return SettleableSession(
|
||||
id: cardRef.cardID,
|
||||
cardFolderName: cardRef.cardID,
|
||||
needsSettling: settlement.needsSettling,
|
||||
saveAll: settlement.saveAll,
|
||||
discard: { [weak provider] in
|
||||
settlement.discard()
|
||||
// The card's uncommitted on-disk saves are reverted by the restore
|
||||
// itself, which compares this folder against the working tree rather
|
||||
// than against HEAD — see `GitRestoreOperation.plan`.
|
||||
provider?.noteDiscarded(cardFolderPath: cardRef.cardID)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
ask: { await SessionSettleStep.ask() },
|
||||
focus: { [weak self] id in
|
||||
guard let self, let session = self.sessions[ref] else { return }
|
||||
guard let cardRef = session.cardRefs.first(where: { $0.cardID == id }) else { return }
|
||||
// Opening a window that is already open is how SwiftUI's value-addressed groups say
|
||||
// "bring that one forward" — the same call `BoardWindowHost` makes to open a card, and
|
||||
// the reason reopening a live card focuses its window rather than making a second one.
|
||||
self.windowOpener?(id: WindowID.card, value: cardRef)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// **Binds the git provider onto an already-open session** — add-git's one caller.
|
||||
///
|
||||
/// 06 ▸ Rules ▸ Detection sanctions exactly one mid-session mode flip, the app's own add-git:
|
||||
/// "clicking it flips the open board into git mode immediately — the popover flows straight into
|
||||
/// the git controls, the first auto-commit follows". It does not mention undo, so this is a
|
||||
/// judgment call and it is recorded here: **the flip binds undo too**, live, rather than waiting
|
||||
/// for the next open. Three reasons point the same way — the mode flip already carries the
|
||||
/// *committer* through (`HistoryStore.activateAutoCommit` remembers its wiring for precisely this
|
||||
/// board); 12-editions.md's "an open board finishes with the provider it composed" is a rule about
|
||||
/// a **tier** lapsing, which cannot change a running session at all; and a board that visibly
|
||||
/// starts accumulating commits while ⌘Z stays greyed out until it is closed and reopened would
|
||||
/// read as a defect rather than as a policy.
|
||||
///
|
||||
/// It only ever adds. A board that already has a provider keeps it, and nothing here can take one
|
||||
/// away — there is no un-add-git.
|
||||
func bindHistoryProvider(for ref: BoardWindowRef) {
|
||||
guard var session = sessions[ref], session.history == nil,
|
||||
let git = session.git, git.mode == .git else { return }
|
||||
guard let history = makeHistoryProvider(session.store, session.tier, git) else { return }
|
||||
session.history = history
|
||||
sessions[ref] = session
|
||||
session.store.history = history
|
||||
session.undoManager.history = history
|
||||
wireGitUndo(history, store: session.store, git: git, ref: ref)
|
||||
}
|
||||
|
||||
/// Registers a card window with its board's session, so the close flush can find it.
|
||||
///
|
||||
/// A card window whose board has no session is a card window with no board — the ownership rule
|
||||
@@ -1112,7 +1269,7 @@ public final class AppModel {
|
||||
// Cleared rather than merely dropped because the steps hold closures over the store
|
||||
// this line is about to release, and a stack that outlived its board would be a
|
||||
// retain cycle wearing an undo stack's clothes.
|
||||
session.history.clear()
|
||||
session.history?.clear()
|
||||
storeRegistry.release(session.store)
|
||||
session.access?.stop()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -42,11 +42,23 @@ public protocol CardSessionFlushing: AnyObject {
|
||||
/// template that silently missed those keystrokes would break 09's never-misses-keystrokes
|
||||
/// guarantee, which outranks the item's availability.
|
||||
var holdsUnsavedContent: Bool { get }
|
||||
|
||||
/// **This window's answers to the save-or-discard step** (06-history-undo.md ▸ Rules ▸ Undo
|
||||
/// restore vs open Edit sessions; ▸ Branch switching), or `nil` for a window with nothing a
|
||||
/// wholesale tree operation could disturb.
|
||||
///
|
||||
/// Beside `holdsUnsavedContent` and deliberately not folded into it: that property answers "would
|
||||
/// a *template copy* miss keystrokes", which is a read; this one carries the two writes — Save
|
||||
/// All and Discard — that a restore or a branch switch needs the window to perform before it can
|
||||
/// run on a settled tree. Same window, two different questions, and collapsing them would give
|
||||
/// the settle step the draft-composer exemption `holdsUnsavedContent` deliberately makes.
|
||||
var settlement: CardSessionSettlement? { get }
|
||||
}
|
||||
|
||||
public extension CardSessionFlushing {
|
||||
func endSession() async {}
|
||||
var holdsUnsavedContent: Bool { false }
|
||||
var settlement: CardSessionSettlement? { nil }
|
||||
}
|
||||
|
||||
// MARK: - CloseFlushCoordinator
|
||||
|
||||
@@ -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