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:
@@ -705,6 +705,22 @@ struct AgentGuideContentTests {
|
||||
#expect(!content.contains("Arrivals go on top"))
|
||||
}
|
||||
|
||||
/// **v9: values stay on one line** (2026-07-31 incident): a wrapped double-quoted `title` lost
|
||||
/// its continuation line in a hand copy between boards, leaving the quote unclosed — and an
|
||||
/// unclosed quote runs to the end of the block, so the whole file stopped parsing and the board
|
||||
/// refused to load. The guide has to teach the shape, say why copying it is where it breaks, and
|
||||
/// name the unterminated quote in Hard rules beside the colon it now shares billing with.
|
||||
@Test("v9 teaches one-line frontmatter values and names the unterminated quote")
|
||||
func v9OneLineValueVocabularyIsPresent() {
|
||||
let content = AgentGuide.content
|
||||
#expect(content.contains("**Keep every value on one line.**"))
|
||||
#expect(content.contains("swallow every key below"))
|
||||
#expect(content.contains("copy its frontmatter block whole"))
|
||||
// Hard rules names both classic violations, not just the colon.
|
||||
#expect(content.contains("The classic violations are an"))
|
||||
#expect(content.contains("quoted value left unclosed across a"))
|
||||
}
|
||||
|
||||
/// The pathfinder's guide taught `media/` and tombstone deletes; both are retired
|
||||
/// (01-storage-format.md ▸ Changes from the pathfinder schema; ▸ Deletion). The one legitimate
|
||||
/// mention of `deleted:` is the warning never to write it.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -276,7 +276,9 @@ struct CommentPaneAnnouncementTests {
|
||||
comments.isEditable = true
|
||||
comments.cardFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)")
|
||||
comments.cardTitle = "Fix login"
|
||||
CardWindowHost.configureComments(comments, store: store, cardID: ItemID(rawValue: Ident.card1))
|
||||
CardWindowHost.configureComments(
|
||||
comments, store: store, cardID: ItemID(rawValue: Ident.card1), on: CardWindowUndo()
|
||||
)
|
||||
let spoken = Spoken(store: store)
|
||||
comments.announce = { spoken.record($0) }
|
||||
return (comments, spoken)
|
||||
|
||||
@@ -703,7 +703,7 @@ struct UndoCommandSurfaceTests {
|
||||
defer: true
|
||||
)
|
||||
let controller = HostedWindowController()
|
||||
controller.boardUndoManager = { manager }
|
||||
controller.windowUndoManager = { manager }
|
||||
controller.attach(to: window)
|
||||
return (window, controller)
|
||||
}
|
||||
@@ -792,8 +792,11 @@ struct UndoCommandSurfaceTests {
|
||||
#expect(undoRow.title == "Undo")
|
||||
}
|
||||
|
||||
@Test("Every window over one board answers with that board's one stack")
|
||||
func cardWindowsShareTheBoardsStack() throws {
|
||||
@Test("A board window answers with the board's stack; a card window answers with its own")
|
||||
func eachWindowAnswersWithItsOwnStack() throws {
|
||||
// Realigned 2026-07-31 with the two-level model (13-native-undo.md ▸ Rules): this pinned the
|
||||
// shared-stack wiring, which is exactly what the session-coarsening re-ruling replaced —
|
||||
// "a card window owns its own stack ... and `window.undoManager` answers with it".
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (model, tearDown) = try makeModel()
|
||||
@@ -801,10 +804,12 @@ struct UndoCommandSurfaceTests {
|
||||
|
||||
let ref = try openBoard(model, at: fixture.root)
|
||||
let session = try #require(model.session(for: ref))
|
||||
// The board window and one of its card windows, wired the way their hosts wire them.
|
||||
// The board window and one of its card windows, wired the way their hosts wire them
|
||||
// (`BoardWindowHost.configureWindow`, `CardWindowHost.configureWindow`).
|
||||
let card = CardWindowUndo()
|
||||
let (boardWindow, boardController) = hostedWindow(session.undoManager)
|
||||
defer { boardController.detach() }
|
||||
let (cardWindow, cardController) = hostedWindow(session.undoManager)
|
||||
let (cardWindow, cardController) = hostedWindow(card.manager)
|
||||
defer { cardController.detach() }
|
||||
let boardRow = menuItem("undo:")
|
||||
let cardRow = menuItem("undo:")
|
||||
@@ -812,9 +817,17 @@ struct UndoCommandSurfaceTests {
|
||||
session.history?.register(StepLog().step("Move 3 Cards"))
|
||||
|
||||
#expect(boardWindow.validateMenuItem(boardRow))
|
||||
#expect(cardWindow.validateMenuItem(cardRow), "one stack per board, not per window")
|
||||
#expect(boardRow.title == "Undo Move 3 Cards")
|
||||
#expect(cardRow.title == boardRow.title)
|
||||
|
||||
// **No fall-through** (06-history-undo.md ▸ Undo routing): the card window's own stack is
|
||||
// empty, so its row is disabled and ⌘Z beeps — it never reaches the board's step.
|
||||
#expect(cardWindow.validateMenuItem(cardRow) == false)
|
||||
#expect(cardRow.title == "Undo")
|
||||
|
||||
card.stack.register(StepLog().step("Comment"))
|
||||
#expect(cardWindow.validateMenuItem(cardRow))
|
||||
#expect(cardRow.title == "Undo Comment")
|
||||
#expect(boardRow.title == "Undo Move 3 Cards", "and the board's row is untouched by it")
|
||||
}
|
||||
|
||||
// MARK: The toolbar twins
|
||||
|
||||
Reference in New Issue
Block a user