Make the card window the commit unit on Pro boards
Phase C of the two-level undo card: the committer stages around the
whole open card folder — comments included — so gestures in an open
window never land in interim commits; window close flushes the session
as one semantically-named commit ("Edit card 'X'" with the thread as
body bullets, "Mixed update — N changes to card 'X'" when events mix),
with the two-commit foreign/user split preserved and the
comments/.trash purge riding the same bracket. Comment gestures lose
their per-gesture commits structurally (they write inside the held
folder). Branch-switch settle releases every window's staging before
checkout and re-arms on resume.
Fixes two latent pro-m1 defects: the committer was composed without
the store's EchoLedger, so every production commit classified foreign
and was authored Lanework External; and interim flushes dropped
harvest receipts they had not spent, unvouching the session's own
writes at close. Also lands 06's mixed-subject re-ruling (the retired
"Update board" fallback) and phase B's two files missed by the
previous commit's pathspec.
2444 tests in 422 suites green.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
+113
-31
@@ -772,7 +772,14 @@ public final class AppModel {
|
||||
// **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 ledger is the store's own** (06 ▸ Interaction with external writers: "the Writer/echo
|
||||
// machinery — the EchoLedger — lets the auto-committer classify every observed change, per
|
||||
// file, as app-mediated or foreign"). `compose` defaults to a fresh one for the store-less
|
||||
// callers (the add-git surface, unit tests), and a session that took that default would hand
|
||||
// the committer a ledger nothing ever writes to: every commit this app made would classify
|
||||
// foreign and be authored `Lanework External`. The default is a fallback, never this path's.
|
||||
let git = HistoryStore.compose(boardRoot: store.rootURL, tier: tier, ledger: store.echoes)
|
||||
// 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
|
||||
@@ -875,7 +882,12 @@ public final class AppModel {
|
||||
}
|
||||
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() }
|
||||
// The stage-around the settle released comes back with the committer: a card window still open
|
||||
// after the restore is still a session (`resumeCardSessionStaging(for:)`).
|
||||
provider.resumeCommitting = { [weak self, weak git] in
|
||||
git?.committer?.start()
|
||||
self?.resumeCardSessionStaging(for: ref)
|
||||
}
|
||||
// **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").
|
||||
//
|
||||
@@ -903,7 +915,11 @@ public final class AppModel {
|
||||
// `GitRestoreOperation.plan`.
|
||||
provider?.noteDiscarded(cardFolderName: folder)
|
||||
}
|
||||
return await gate.settle(touching: paths)
|
||||
let outcome = await gate.settle(touching: paths)
|
||||
// **Only on `.proceed`** — a cancelled or failed settle leaves the board exactly as it
|
||||
// was, sessions and their staging included.
|
||||
if outcome == .proceed { self.releaseCardSessionStaging(for: ref) }
|
||||
return outcome
|
||||
}
|
||||
provider.seed()
|
||||
|
||||
@@ -929,7 +945,12 @@ public final class AppModel {
|
||||
switcher.flushPendingCommit = { [weak git] in await git?.committer?.flushNow() }
|
||||
switcher.isHeld = { [weak git] in git?.committer?.pause != nil }
|
||||
switcher.suspendCommitting = { [weak git] in git?.committer?.stop() }
|
||||
switcher.resumeCommitting = { [weak git] in git?.committer?.start() }
|
||||
// As on the restore path: what the settle released is a session that has not ended, and the
|
||||
// window is still open on the other side of the checkout.
|
||||
switcher.resumeCommitting = { [weak self, weak git] in
|
||||
git?.committer?.start()
|
||||
self?.resumeCardSessionStaging(for: ref)
|
||||
}
|
||||
// **The undo/redo reseed** — the provider's own API, which is the relaunch reseed by
|
||||
// construction: "discarded and reseeded from the new HEAD's first-parent ancestry … redo
|
||||
// starts empty".
|
||||
@@ -974,7 +995,11 @@ public final class AppModel {
|
||||
switcher?.noteDiscarded(cardFolderName: folder)
|
||||
}
|
||||
// Every open session, not the ones a diff reaches — see `SessionSettleGate.settleAll`.
|
||||
return await gate.settleAll()
|
||||
let outcome = await gate.settleAll()
|
||||
// The switch's flush runs next and must find a tree it can settle whole — see
|
||||
// `releaseCardSessionStaging(for:)` for why the modal's own predicate is not enough.
|
||||
if outcome == .proceed { self.releaseCardSessionStaging(for: ref) }
|
||||
return outcome
|
||||
}
|
||||
|
||||
// **The own-leftovers check, at open** (06 ▸ Rules ▸ Abnormal repo states). Beside the
|
||||
@@ -1097,47 +1122,94 @@ public final class AppModel {
|
||||
}
|
||||
sessions[ref.board]?.cardRefs.insert(ref)
|
||||
cardSessions[ref] = session
|
||||
// **The window *is* the commit unit** (06 ▸ Rules ▸ Auto-commit, widened 2026-07-31), so the
|
||||
// stage-around opens here — with the window — rather than at the body's first Edit→Preview
|
||||
// flip. From this line to `unregisterCardWindow` nothing this card's folder receives can land
|
||||
// in an interim commit.
|
||||
setCardSession(true, for: ref)
|
||||
}
|
||||
|
||||
func unregisterCardWindow(_ ref: CardWindowRef) {
|
||||
sessions[ref.board]?.cardRefs.remove(ref)
|
||||
cardSessions[ref] = nil
|
||||
// A window that left without its session ending — a crash-shaped teardown, or a dismissal
|
||||
// that raced the flush — must not leave its card folder excluded from staging forever.
|
||||
setEditSession(false, for: ref)
|
||||
// **The close flush's release** — and, for a window that left without its session ending (a
|
||||
// crash-shaped teardown, a dismissal that raced the flush), the backstop that must not leave a
|
||||
// card folder excluded from staging forever. Both are the same line because both mean the same
|
||||
// thing: this window is no longer holding its folder back.
|
||||
setCardSession(false, for: ref)
|
||||
}
|
||||
|
||||
/// Tokens the committer knows each card window's Edit session by. Beside `cardSessions` for its
|
||||
/// Tokens the committer knows each card window's session by. Beside `cardSessions` for its
|
||||
/// reason: this is the seam table's third column, written only here.
|
||||
@ObservationIgnored
|
||||
private var editSessionTokens: [CardWindowRef: UUID] = [:]
|
||||
private var cardSessionTokens: [CardWindowRef: UUID] = [:]
|
||||
|
||||
/// **A card window's Edit session opened or closed** (06-history-undo.md ▸ Rules ▸ Auto-commit:
|
||||
/// the committer "stages around open Edit sessions").
|
||||
/// **A card window's session opened or closed** (06-history-undo.md ▸ Rules ▸ Auto-commit: the
|
||||
/// committer "stages around the whole open card folder").
|
||||
///
|
||||
/// This is the honest seam between the two halves of the rule: `CardBodyEditSession` knows a
|
||||
/// session is open, the committer knows what staging is, and only the app model knows which board
|
||||
/// a card window belongs to and how to reach its committer. A board with no committer — the free
|
||||
/// tier, a Pro board with no repository — records nothing, which is the same `nil` every other
|
||||
/// git seam takes.
|
||||
/// This is the honest seam between the two halves of the rule: the host knows a window exists, the
|
||||
/// committer knows what staging is, and only the app model knows which board a card window belongs
|
||||
/// to and how to reach its committer. A board with no committer — the free tier, a Pro board with
|
||||
/// no repository — records nothing, which is the same `nil` every other git seam takes.
|
||||
///
|
||||
/// The card's folder is handed over as a **closure**, not a URL: a card can change lane, or be
|
||||
/// moved into the trash, in the middle of a session, and what must be staged around is wherever
|
||||
/// it is at the moment of the commit. `BoardStore.cardBodyTarget` is the resolution that spans
|
||||
/// both containers, which is exactly why the body save uses it too.
|
||||
func setEditSession(_ isOpen: Bool, for ref: CardWindowRef) {
|
||||
///
|
||||
/// Idempotent both ways: re-opening reuses the token (a settle that released it, then a resume),
|
||||
/// and closing an already-closed session reaches a committer that has nothing to remove.
|
||||
func setCardSession(_ isOpen: Bool, for ref: CardWindowRef) {
|
||||
guard isOpen else {
|
||||
// **The token goes whether or not there is anyone left to tell.** A card window's own
|
||||
// teardown can land after its board's, and a token kept past the board it names would
|
||||
// outlive everything that could ever release it.
|
||||
guard let token = cardSessionTokens.removeValue(forKey: ref) else { return }
|
||||
sessions[ref.board]?.git?.committer?.endCardSession(token)
|
||||
return
|
||||
}
|
||||
guard let session = sessions[ref.board], let committer = session.git?.committer else { return }
|
||||
if isOpen {
|
||||
let token = editSessionTokens[ref] ?? UUID()
|
||||
editSessionTokens[ref] = token
|
||||
let cardID = ref.cardIdentity
|
||||
committer.beginEditSession(token) { [weak store = session.store] in
|
||||
guard let store,
|
||||
let path = BoardStore.cardBodyTarget(cardID, in: store.snapshot) else { return nil }
|
||||
return path.folder(under: store.rootURL)
|
||||
}
|
||||
} else if let token = editSessionTokens.removeValue(forKey: ref) {
|
||||
committer.endEditSession(token)
|
||||
let token = cardSessionTokens[ref] ?? UUID()
|
||||
cardSessionTokens[ref] = token
|
||||
let cardID = ref.cardIdentity
|
||||
committer.beginCardSession(token) { [weak store = session.store] in
|
||||
guard let store,
|
||||
let path = BoardStore.cardBodyTarget(cardID, in: store.snapshot) else { return nil }
|
||||
return path.folder(under: store.rootURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// **The stage-around releases at the settle step** (06 ▸ Branch switching; ▸ Rules ▸ Undo restore
|
||||
/// vs open Edit sessions) — every open card window on this board, unconditionally.
|
||||
///
|
||||
/// ### Why unconditionally, rather than through the modal
|
||||
///
|
||||
/// The save-or-discard step asks about *buffers* — "unsaved keystrokes, or on-disk ~700 ms saves
|
||||
/// the session hasn't committed" — and a window that is merely open, with a comment posted an hour
|
||||
/// ago and a clean editor, answers `needsSettling` with `false`. Under the widened stage-around
|
||||
/// that window is still holding its whole folder out of every commit, so leaving it held would
|
||||
/// walk a checkout onto a dirty tree and break the one guarantee the settle exists to buy: "with
|
||||
/// sessions settled the restore runs on a settled tree … it cannot fail dirty".
|
||||
///
|
||||
/// Releasing is therefore structural and silent, and the modal keeps its own narrower predicate:
|
||||
/// Save All's flush then carries the session's commit, and Discard's reverted bytes are
|
||||
/// reconciled by the operation itself (`GitRestoreOperation.plan`, `GitBranchSwitcher`), which is
|
||||
/// why this runs *after* the gate has answered rather than before it.
|
||||
func releaseCardSessionStaging(for ref: BoardWindowRef) {
|
||||
for cardRef in sessions[ref]?.cardRefs ?? [] {
|
||||
setCardSession(false, for: cardRef)
|
||||
}
|
||||
}
|
||||
|
||||
/// **The next session begins** — the other half of `releaseCardSessionStaging(for:)`, run when the
|
||||
/// operation behind the settle has finished with the tree.
|
||||
///
|
||||
/// A window that is still open after a restore or a branch switch is still a session, and its
|
||||
/// folder must go back to being staged around. Idempotent, so the paths that resume without ever
|
||||
/// having released (a cancelled switch, the open-time leftover check) cost a dictionary lookup.
|
||||
func resumeCardSessionStaging(for ref: BoardWindowRef) {
|
||||
for cardRef in sessions[ref]?.cardRefs ?? [] {
|
||||
setCardSession(true, for: cardRef)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1353,6 +1425,15 @@ public final class AppModel {
|
||||
},
|
||||
endCardSession: { [weak self] cardRef in
|
||||
await self?.cardSessions[cardRef]?.endSession()
|
||||
// **The release, here rather than only at the host's unregister** (06 ▸ Rules ▸
|
||||
// Auto-commit). The unregister does release it — that is what closes a window on its
|
||||
// own — but it arrives from the *window's* teardown, which this sequence waits for
|
||||
// only up to `cardDrainDeadline` and then proceeds anyway. A quit whose last window
|
||||
// was slow to disappear would then flush with the folder still staged around and leave
|
||||
// a settled session uncommitted, which is precisely what "nothing settled is ever left
|
||||
// ... uncommitted by closing" forbids. Ending the session is this step's own act, so
|
||||
// releasing what the session held is too. Idempotent with the unregister.
|
||||
self?.setCardSession(false, for: cardRef)
|
||||
},
|
||||
dismissCardWindow: { [weak self] cardRef in
|
||||
self?.windowDismisser?(value: cardRef)
|
||||
@@ -1372,8 +1453,9 @@ public final class AppModel {
|
||||
// window close and app quit flush the pipeline — any pending editor save, then the
|
||||
// pending auto-commit — before teardown; nothing settled is ever left unsaved or
|
||||
// uncommitted by closing"). By the time it runs, step 1 has ended every card window's
|
||||
// Edit session, so nothing is staged around and each session's body lands in exactly one
|
||||
// commit. `nil` on every board with no committer, which is the whole free tier.
|
||||
// session *and released its stage-around*, so each session — body, comments, purge and
|
||||
// all — lands in exactly one commit. `nil` on every board with no committer, which is the
|
||||
// whole free tier.
|
||||
committerFlush: { [weak self] in
|
||||
await self?.sessions[ref]?.git?.committer?.flushNow()
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user