Give card windows their own undo stacks and coarsen the close

Phase B of the two-level undo card: every card-window gesture — comment
post/delete/edit, body Edit sessions, style and details changes —
registers fine-grained on the window's own stack (window.undoManager
answers with it; board ⌘Z never sees mid-session card steps; an empty
window stack beeps, never falls through). Window close folds the stack
into one coarse values-based board step ("Edit card 'X'") — per-target
per-field later-wins merge, so foreign mid-session writes stay out by
construction, a no-net-change session registers nothing, and any stale
component skips the whole step. The comments/.trash purge defers with
the coarse step via a step-retirement seam on the providers: it runs
when the step leaves the board stack or the board session ends; the git
provider retires dropped steps on register, which keeps Pro's
purge-at-close-flush structural with no tier check. Interim on git
boards: gestures still auto-commit per debounce until phase C's
close-flush commit.

2432 tests in 418 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-31 19:39:50 -04:00
parent c0c741fe62
commit 71664dab02
23 changed files with 668 additions and 124 deletions
+34 -14
View File
@@ -425,10 +425,13 @@ struct CardCommentsAttachmentTests {
@Suite("Card comments ▸ the close flush")
struct CardCommentsCloseTests {
@Test("Saves first, purge last")
func savesPrecedeThePurge() {
// The purge removes the folders a delete moved aside; running it before a session's save
// could remove a folder that save was about to write into.
@Test("The saves land, and the purge is not the pane's to run")
func savesLandAndThePurgeDefers() {
// Realigned 2026-07-31 with the session-coarsening model (13-native-undo.md Interaction
// with the trash): the ordering this pinned every save before anything empties
// `comments/.trash/` still holds and is now the window session's to keep, because only
// that level knows whether a coarse close step took the purge on (`CardWindowSession`).
// What the pane owes the close is its two saves, in order.
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([comment(CommentIdent.one, body: "first\n")])
@@ -441,9 +444,12 @@ struct CardCommentsCloseTests {
let edit = try? #require(spy.events.firstIndex(of: .edit(CommentIdent.one, "mid-sentence\n")))
let draft = try? #require(spy.events.firstIndex(of: .saveDraft("a draft\n")))
let purge = try? #require(spy.events.firstIndex(of: .purge))
#expect((edit ?? 0) < (purge ?? 0))
#expect((draft ?? 0) < (purge ?? 0))
#expect((edit ?? 0) < (draft ?? 1))
#expect(!spy.events.contains(.purge), "the trash outlives the pane's own close")
// And it is still one call away, for whoever ends up owing it.
comments.purgeTrashNow()
#expect(spy.events.last == .purge)
}
@Test("The close flushes the session — it never reverts it")
@@ -472,13 +478,15 @@ struct CardCommentsCloseTests {
#expect(spy.events.filter { $0 == .saveDraft("a draft\n") }.count == 1)
}
@Test("A clean pane still purges — the trash is the app's leftovers, not the user's work")
func aCleanPaneStillPurges() {
@Test("A clean pane's close writes nothing and purges nothing")
func aCleanPaneClosesQuietly() {
// The trash is still the app's leftovers rather than the user's work but who empties it,
// and when, is the window session's question now (`CardSessionUndoTests`).
let spy = CommentsSpy()
let comments = makeComments(spy)
comments.endSession()
#expect(spy.events == [.purge])
#expect(spy.events.isEmpty)
}
}
@@ -580,7 +588,7 @@ struct CardCommentsWiringTests {
let comments = CardComments()
comments.isEditable = true
comments.cardFolder = fixture.url(card)
CardWindowHost.configureComments(comments, store: store, cardID: cardID)
CardWindowHost.configureComments(comments, store: store, cardID: cardID, on: CardWindowUndo())
comments.open()
comments.composer.edited("A remark.\n")
@@ -599,8 +607,12 @@ struct CardCommentsWiringTests {
#expect(!fixture.exists("\(card)/comments/.draft"))
}
@Test("Delete moves into comments/.trash/, and the close purge empties it")
@Test("Delete moves into comments/.trash/, and the deferred purge empties it")
func deleteThenPurge() throws {
// Realigned 2026-07-31 (13-native-undo.md Interaction with the trash): the pane's own
// close no longer purges `comments/.trash/` is what the window's coarse close step
// restores from, so emptying it is the purge the window session owns and hands out
// (`CardWindowSession.endSession`; `CardSessionUndoTests` drives the deferral itself).
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
@@ -610,7 +622,9 @@ struct CardCommentsWiringTests {
let comments = CardComments()
comments.isEditable = true
comments.cardFolder = fixture.url(card)
CardWindowHost.configureComments(comments, store: store, cardID: ItemID(rawValue: Ident.card1))
CardWindowHost.configureComments(
comments, store: store, cardID: ItemID(rawValue: Ident.card1), on: CardWindowUndo()
)
comments.open()
#expect(comments.thread.comments.count == 1)
@@ -619,6 +633,10 @@ struct CardCommentsWiringTests {
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
comments.endSession()
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
"the saves land; the trash is not the pane's to empty any more")
comments.purgeTrashNow()
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
}
@@ -633,7 +651,9 @@ struct CardCommentsWiringTests {
let comments = CardComments()
comments.isEditable = true
comments.cardFolder = fixture.url(card)
CardWindowHost.configureComments(comments, store: store, cardID: ItemID(rawValue: Ident.card1))
CardWindowHost.configureComments(
comments, store: store, cardID: ItemID(rawValue: Ident.card1), on: CardWindowUndo()
)
comments.open()
comments.beginEdit(ItemID(rawValue: CommentIdent.one))