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:
@@ -108,7 +108,8 @@ public final class CardComments {
|
||||
@ObservationIgnored
|
||||
public var sweepTrashResidue: (() -> Void)?
|
||||
|
||||
/// The close purge — `BoardStore.purgeCommentTrash(inCard:)`.
|
||||
/// The deferred purge — `BoardStore.purgeCommentTrash(inCard:)`, run through `purgeTrashNow()`
|
||||
/// by whoever owes it (see that method).
|
||||
@ObservationIgnored
|
||||
public var purgeTrash: (() -> Void)?
|
||||
|
||||
@@ -127,6 +128,12 @@ public final class CardComments {
|
||||
@ObservationIgnored
|
||||
public var editComment: ((ItemID, String) -> Bool)?
|
||||
|
||||
/// One inline edit **session**'s undo step — `BoardStore.registerCommentEdit(...)`, handed to each
|
||||
/// session beside its save and called once, at the session's commit point (13-native-undo.md
|
||||
/// ▸ Rules ▸ coalescing; `CommentEditSession.registerUndo`).
|
||||
@ObservationIgnored
|
||||
public var registerCommentEdit: ((ItemID, String, String) -> Void)?
|
||||
|
||||
/// Imports files into an authoring surface's `attachments/` —
|
||||
/// `BoardStore.importCommentAttachments(_:inCard:target:)`.
|
||||
@ObservationIgnored
|
||||
@@ -319,6 +326,9 @@ public final class CardComments {
|
||||
session.save = { [weak self] text in
|
||||
self?.editComment?(commentID, text) ?? false
|
||||
}
|
||||
session.registerUndo = { [weak self] prior, new in
|
||||
self?.registerCommentEdit?(commentID, prior, new)
|
||||
}
|
||||
editing = session
|
||||
}
|
||||
|
||||
@@ -409,20 +419,31 @@ public final class CardComments {
|
||||
|
||||
// MARK: - The close flush
|
||||
|
||||
/// **The window's close, in the order the brief fixes: saves first, purge last.**
|
||||
/// **The pane's half of the window's close: the saves.**
|
||||
///
|
||||
/// The inline session flushes as the body's does (a flush, never a revert — a close is not an
|
||||
/// abandon), then the composer's draft lands, and only then is `comments/.trash/` emptied. The
|
||||
/// purge going last is what makes it safe at all: it removes the folders a delete moved aside, and
|
||||
/// running it before a session's save could remove a folder that save was about to write into.
|
||||
/// abandon), and then the composer's draft lands. Ending twice does nothing the second time — the
|
||||
/// sessions latch — which is what makes the two paths that call this (a window closed on its own,
|
||||
/// and the board's close flush driving it) safe to both exist.
|
||||
///
|
||||
/// Ending twice does nothing the second time — the sessions latch, and a purge over an empty
|
||||
/// trash is a no-op — which is what makes the two paths that call this (a window closed on its
|
||||
/// own, and the board's close flush driving it) safe to both exist.
|
||||
/// **The purge is no longer here** (re-ruled 2026-07-31 — 13-native-undo.md ▸ Interaction with the
|
||||
/// trash): `comments/.trash/` is what the coarse close step's undo restores deleted comments from,
|
||||
/// so emptying it belongs *after* that step exists and only when that step does not. The order
|
||||
/// this method fixed is unchanged and still load-bearing — saves land before anything empties the
|
||||
/// trash — it is now spelled one level up, where the step is registered (`CardWindowSession`),
|
||||
/// because that is the only level that knows whether the purge is owed now or owed later.
|
||||
public func endSession() {
|
||||
editing?.endOnClose()
|
||||
editing = nil
|
||||
composer.flush()
|
||||
}
|
||||
|
||||
/// **Empties `comments/.trash/`** — the deferred purge, run by whoever currently owes it: the
|
||||
/// window's close when no coarse step took it, or that step's retirement when one did.
|
||||
///
|
||||
/// A purge over an empty trash is a no-op, which is what keeps the two owners from having to agree
|
||||
/// about anything but who calls first.
|
||||
public func purgeTrashNow() {
|
||||
purgeTrash?()
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@ struct CardStyleSection: View {
|
||||
let store: BoardStore
|
||||
let recents: StyleRecents
|
||||
let cardID: ItemID
|
||||
/// **This window's undo stack** (13-native-undo.md ▸ Rules ▸ two levels): a colour or symbol
|
||||
/// chosen here is a gesture *issued in this window*, so its step joins the window's session and
|
||||
/// reaches board history only inside the coarse close step. The shared editor takes it as an
|
||||
/// anchor's parameter, exactly as it takes the layout.
|
||||
let undo: CardWindowUndo
|
||||
|
||||
/// The live body metric, read here rather than passed in — `CardAttachmentsSection`'s pattern,
|
||||
/// so every section in this sidebar derives its geometry the same way.
|
||||
@@ -58,7 +63,8 @@ struct CardStyleSection: View {
|
||||
layout: .sidebar(
|
||||
contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize),
|
||||
bodyPointSize: pointSize
|
||||
)
|
||||
),
|
||||
undo: undo
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -91,6 +91,11 @@ struct CardWindowView: View {
|
||||
let comments: CardComments
|
||||
/// This window's thumbnail memory, held by the host so it outlives a snapshot.
|
||||
let thumbnails: AttachmentThumbnailCache
|
||||
/// **This window's undo stack** (13-native-undo.md ▸ Rules ▸ two levels). It arrives for exactly
|
||||
/// one consumer — the Style section, the one sidebar anchor that *writes* — because a gesture
|
||||
/// issued in this window registers on this window's stack. It lives on the window's session so
|
||||
/// the close can fold it, which is why it arrives here rather than being made here.
|
||||
let undo: CardWindowUndo
|
||||
/// **This card's commit trail** (05 ▸ History), or `nil` on every board with no app-managed git —
|
||||
/// the free tier, mode none, and repo-nested boards. The `nil` *is* the section's absence rule;
|
||||
/// see `historySlot`.
|
||||
@@ -314,7 +319,7 @@ struct CardWindowView: View {
|
||||
VStack(alignment: .leading, spacing: bodyPointSize * 1.25) {
|
||||
CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails)
|
||||
|
||||
CardStyleSection(store: store, recents: recents, cardID: card.id)
|
||||
CardStyleSection(store: store, recents: recents, cardID: card.id, undo: undo)
|
||||
|
||||
// The snapshot's own document, not a re-read: the loader parsed this file, unknown
|
||||
// keys and their order included, and `Card` has carried it since (`BoardModel`).
|
||||
|
||||
@@ -77,6 +77,16 @@ public final class CommentEditSession {
|
||||
@ObservationIgnored
|
||||
public var save: ((String) -> Bool)?
|
||||
|
||||
/// Where this session's **one undo step** goes, called at its commit point with the bytes the
|
||||
/// session opened on and the bytes it leaves — `CardBodyEditSession.registerUndo`'s seam one level
|
||||
/// down, and for its reason exactly: a session is one step, never one per debounced tick
|
||||
/// (13-native-undo.md ▸ Rules ▸ coalescing).
|
||||
///
|
||||
/// `CardComments.beginEdit` points it at `BoardStore.registerCommentEdit`, on the *window's*
|
||||
/// stack. `nil` — a session with no window behind it — registers nothing.
|
||||
@ObservationIgnored
|
||||
public var registerUndo: ((_ priorBody: String, _ newBody: String) -> Void)?
|
||||
|
||||
/// How many saves have actually been attempted through `save`.
|
||||
@ObservationIgnored
|
||||
public private(set) var saveAttempts = 0
|
||||
@@ -142,7 +152,9 @@ public final class CommentEditSession {
|
||||
public func commit() -> Bool {
|
||||
guard !hasEnded else { return false }
|
||||
hasEnded = true
|
||||
return flush()
|
||||
let wrote = flush()
|
||||
registerStep()
|
||||
return wrote
|
||||
}
|
||||
|
||||
/// **Cancel, or Escape** — reverts to session-start bytes and ends (05; 11-command-nexus.md's
|
||||
@@ -179,11 +191,25 @@ public final class CommentEditSession {
|
||||
public func endOnClose() -> Bool {
|
||||
guard !hasEnded else { return false }
|
||||
hasEnded = true
|
||||
return flush()
|
||||
let wrote = flush()
|
||||
registerStep()
|
||||
return wrote
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// The session's one step, at whichever of its two ends got here first — and **only when the
|
||||
/// session actually changed the file**: `disk` is what this session knows the file says, so
|
||||
/// `disk == sessionStart` covers both the session that only read and the one that typed its way
|
||||
/// back to where it started (`CardBodyEditSession.endEditSession`'s guard, restated).
|
||||
///
|
||||
/// Cancel deliberately never reaches here: it writes the start bytes back, so its net effect is
|
||||
/// nothing and a step would only offer to undo an undo.
|
||||
private func registerStep() {
|
||||
guard disk != sessionStart else { return }
|
||||
registerUndo?(sessionStart, disk)
|
||||
}
|
||||
|
||||
private func scheduleSave() {
|
||||
cancelPending()
|
||||
let interval = debounceInterval
|
||||
|
||||
Reference in New Issue
Block a user