Implement staleness validation and skip-with-banner
Every crossing validates its expectations before writing: each step
carries per-item HistoryExpectations — folder, effective ancestor-walked
liveness, and exactly the fields the gesture set — and a mismatch pops
the step, posts the signpost ('Undo skipped — Fix login changed outside
Lanework'), and falls through to the next. Validation reads disk, not
the in-memory snapshot: the snapshot is by construction one reload
behind every app write, so a rapid second undo would false-skip against
the pre-state — disk is what current can honestly mean at press time.
Stale and failed part ways: a stale step is one the board moved past,
so dropping it loses nothing; a failed one is refused by a usually
momentary condition, so it stays put and the crossing stops with only
performWrite's own error row — which forced the provider off
NSUndoManager onto two plain arrays, since a popped group cannot be put
back. The read-only lock disables Undo/Redo through the adapter while
the stack survives to resume on clear. Delete and restore validate
presence alone — a machine timestamp is not a decision — and a
malformed field matches nothing, since it is a shape the app never
writes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -320,6 +320,25 @@ public final class BannerCenter {
|
||||
signposts.insert(InfoSignpost(message: message), at: 0)
|
||||
}
|
||||
|
||||
/// **The skipped undo step** (13-native-undo.md ▸ Rules ▸ staleness validation): an inverse found
|
||||
/// its target holding somebody else's newer value, so it was popped rather than applied and ⌘Z
|
||||
/// fell through to the next step. This is the row that says so.
|
||||
///
|
||||
/// **A signpost, and no new class** — the vocabulary's answer rather than a compromise. 13 asks
|
||||
/// for an "info-tone banner", and 02-architecture.md § The banner surface gives the info tone
|
||||
/// exactly two halves: the pinned in-progress row with its spinner, and the passive signpost.
|
||||
/// Nothing is in flight here, so the passive half is the whole of the choice. It also reads
|
||||
/// right: unlike a `loss` row (warning tone, "content that didn't arrive though nothing failed"),
|
||||
/// **nothing was lost and nothing failed** — the file holds exactly what its most recent writer
|
||||
/// meant it to, the stack moved on to a step that did apply, and the user's ⌘Z did something. A
|
||||
/// row that ranks last and may collapse behind "+N more" is the honest weight for that: calm by
|
||||
/// design, nothing gated on seeing it instantly. And it is emphatically not a `oneShot`, which
|
||||
/// carries a `BoardWriteError` — a *failed* inverse posts one of those instead, and the two rows
|
||||
/// must stay distinguishable (`HistoryStepOutcome`).
|
||||
public func postSkippedStep(_ direction: HistoryDirection, subject: String) {
|
||||
postSignpost(Self.skippedStepMessage(direction, subject: subject))
|
||||
}
|
||||
|
||||
/// One item a degraded paste could not bring its attachments with — what
|
||||
/// `degradedPasteMessage(for:)` names.
|
||||
///
|
||||
@@ -796,6 +815,30 @@ public final class BannerCenter {
|
||||
return "Moved '\(name)' into attachments — \(subject)"
|
||||
}
|
||||
|
||||
/// The skipped-step line — 13-native-undo.md ▸ Rules' own example sentence, "Undo skipped — 'Fix
|
||||
/// login' changed outside Lanework", with ⇧⌘Z's mirror ("Redo skipped — …").
|
||||
///
|
||||
/// **The verb is the command the user pressed**, not the half of the step that declined: a step
|
||||
/// already undone sits on the redo stack reversed, so the closure ⇧⌘Z crosses is the one
|
||||
/// registered as `redo`, and a sentence naming the half would tell the user they pressed the
|
||||
/// other key (`HistoryDirection`).
|
||||
///
|
||||
/// **`subject` is quoted whatever it names** — the item's title for a step with one target
|
||||
/// ("'Fix login'"), the step's own 06 phrase for a batch or an untitled item ("'Move 3 Cards'").
|
||||
/// One sentence shape for both readings, chosen where the step is registered
|
||||
/// (`BoardStore.registerStep`) because that is the only place that knows how many items it named.
|
||||
///
|
||||
/// **"changed outside Lanework"** is the design's own wording and stays literal: it is the whole
|
||||
/// explanation the row owes — the app did not decline out of caution, somebody else wrote to that
|
||||
/// item, and the reason the step is gone is that applying it would have thrown their edit away.
|
||||
public nonisolated static func skippedStepMessage(_ direction: HistoryDirection, subject: String) -> String {
|
||||
let verb = switch direction {
|
||||
case .undo: "Undo"
|
||||
case .redo: "Redo"
|
||||
}
|
||||
return "\(verb) skipped — '\(subject)' changed outside Lanework"
|
||||
}
|
||||
|
||||
/// The suspended-history line. It names the *consequence* the user cares about — undo and the
|
||||
/// flush-before-overwrite guarantee are degraded — rather than the git mechanics, and carries
|
||||
/// the diagnosis as its tail.
|
||||
|
||||
@@ -879,11 +879,11 @@ public final class BoardStore {
|
||||
/// hand-written `width: 1` is legal and preserved until the app itself next edits width — the
|
||||
/// unchanged-units guard below skips it, so only a real change reaches the remove.
|
||||
private func writeLaneWidths(_ changes: [(id: ItemID, units: Int)]) {
|
||||
let writes: [(folder: URL, units: Int, prior: FieldValue<Int>)] = changes.compactMap { change in
|
||||
let writes: [(folder: URL, units: Int, prior: FieldValue<Int>, title: String?)] = changes.compactMap { change in
|
||||
guard let lane = snapshot.lanes.first(where: { $0.id == change.id }),
|
||||
LaneLayoutMath.displayUnits(of: lane) != change.units
|
||||
else { return nil }
|
||||
return (rootURL.appendingPathComponent(change.id.rawValue), change.units, lane.width)
|
||||
return (rootURL.appendingPathComponent(change.id.rawValue), change.units, lane.width, lane.title.value)
|
||||
}
|
||||
guard !writes.isEmpty else { return }
|
||||
|
||||
@@ -900,7 +900,18 @@ public final class BoardStore {
|
||||
|
||||
// resize → prior width (13-native-undo.md ▸ Rules). One step whatever the batch's size — the
|
||||
// menu items step every selected lane in one gesture, and one gesture is one step.
|
||||
registerStep(HistoryPhrase.name(.resize, kind: .lane, count: writes.count)) { _ in
|
||||
//
|
||||
// The validated field is `width`, read as the app reads it: a lane landing on one unit has
|
||||
// **no key at all** (the remove-at-default rule above), which is a real after-value and the
|
||||
// one `nil` here means. The redo's expectation is the prior as the inverse restores it —
|
||||
// `prior.value`, which is `nil` for a missing *and* for a malformed prior, exactly matching
|
||||
// `restoreWidth`'s own reading.
|
||||
registerStep(
|
||||
HistoryPhrase.name(.resize, kind: .lane, count: writes.count),
|
||||
subject: writes.count == 1 ? writes[0].title : nil,
|
||||
undoExpects: writes.map { .live($0.folder, .width($0.units == 1 ? nil : $0.units)) },
|
||||
redoExpects: writes.map { .live($0.folder, .width($0.prior.value)) }
|
||||
) { _ in
|
||||
for write in writes {
|
||||
try BoardWriter.updateIndex(inItemFolder: write.folder, operation: .resize(title: nil)) { document in
|
||||
Self.restoreWidth(write.prior, in: &document)
|
||||
@@ -1029,6 +1040,7 @@ public final class BoardStore {
|
||||
/// gesture" — and the reload shows the true state, which is the honest one.
|
||||
public func applyStyle(to target: StyleTarget, background: StyleChange = .keep, icon: StyleChange = .keep) {
|
||||
let edits: [(
|
||||
id: ItemID?,
|
||||
folder: URL,
|
||||
background: StyleChange,
|
||||
icon: StyleChange,
|
||||
@@ -1040,6 +1052,7 @@ public final class BoardStore {
|
||||
let icon = Self.effective(icon, against: subject.icon)
|
||||
guard background != .keep || icon != .keep else { return nil }
|
||||
return (
|
||||
id: subject.id,
|
||||
folder: subject.folder,
|
||||
background: background,
|
||||
icon: icon,
|
||||
@@ -1069,7 +1082,28 @@ public final class BoardStore {
|
||||
case .lane: .lane
|
||||
case .card: .card
|
||||
}
|
||||
registerStep(HistoryPhrase.name(.restyle, kind: kind, count: edits.count)) { _ in
|
||||
// **Per dimension, not per item**: a gesture that set only `background` validates only
|
||||
// `background`, so a foreign `icon:` edit on the very same card leaves the step alone. That is
|
||||
// the field-level predicate read at its narrowest, and it is free — `effective(_:against:)`
|
||||
// has already narrowed each dimension to what this write actually changed.
|
||||
let subject = edits.count == 1
|
||||
? edits[0].id.flatMap { Self.liveItem($0, in: snapshot)?.title }
|
||||
: nil
|
||||
registerStep(
|
||||
HistoryPhrase.name(.restyle, kind: kind, count: edits.count),
|
||||
subject: subject,
|
||||
undoExpects: edits.map {
|
||||
.live($0.folder, fields: Self.styledFields(background: $0.background, icon: $0.icon))
|
||||
},
|
||||
redoExpects: edits.map {
|
||||
.live($0.folder, fields: Self.restoredStyleFields(
|
||||
background: $0.background,
|
||||
priorBackground: $0.priorBackground,
|
||||
icon: $0.icon,
|
||||
priorIcon: $0.priorIcon
|
||||
))
|
||||
}
|
||||
) { _ in
|
||||
for edit in edits {
|
||||
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
|
||||
Self.restore(edit.priorBackground, to: FrontmatterKeys.background, in: &document)
|
||||
@@ -1216,7 +1250,7 @@ public final class BoardStore {
|
||||
// may have written is inside the captured bytes, so a redo puts the card back where the
|
||||
// gesture put it, not merely at the bottom of the lane.
|
||||
if let item = createdItem(at: laneFolder.appendingPathComponent(created.rawValue, isDirectory: true), kind: .card) {
|
||||
registerCreation([item], kind: .card)
|
||||
registerCreation([item], kind: .card, subject: title)
|
||||
}
|
||||
return created
|
||||
}
|
||||
@@ -1284,8 +1318,17 @@ public final class BoardStore {
|
||||
// rename → restore title (13-native-undo.md ▸ Rules). The prior title is the *typed* value,
|
||||
// `nil` for an untitled item — so undoing a rename that gave an untitled card a name takes
|
||||
// the `title` key away again rather than writing `title: ""`.
|
||||
//
|
||||
// The validated field is `title` and nothing else: an agent that restyles this very card
|
||||
// between the rename and the ⌘Z has not touched what this step wrote, so the undo applies —
|
||||
// "a foreign change to an unrelated item must not skip anything", read one level finer.
|
||||
let priorTitle = target.title
|
||||
registerStep(HistoryPhrase.name(.rename, kind: target.cardID == nil ? .lane : .card)) { _ in
|
||||
registerStep(
|
||||
HistoryPhrase.name(.rename, kind: target.cardID == nil ? .lane : .card),
|
||||
subject: newTitle ?? priorTitle,
|
||||
undoExpects: [.live(folder, .title(newTitle))],
|
||||
redoExpects: [.live(folder, .title(priorTitle))]
|
||||
) { _ in
|
||||
try Self.setTitle(priorTitle, at: folder)
|
||||
} redo: { _ in
|
||||
try Self.setTitle(newTitle, at: folder)
|
||||
@@ -1437,13 +1480,31 @@ public final class BoardStore {
|
||||
/// Liveness is `writeCardBody`'s deliberately blind walk (`cardBodyTarget`), so a session that
|
||||
/// ended because its card was tombstoned still registers — the keystrokes survived into the
|
||||
/// tombstoned folder, and their undo has to be able to reach the same place.
|
||||
///
|
||||
/// ### Its staleness predicate is the bytes, and the side of the trash the card was on
|
||||
///
|
||||
/// "Body steps compare bytes" (13 ▸ Rules), so the expectation is the whole body span as this
|
||||
/// session left it — a foreign editor that changed one character of it skips the step rather than
|
||||
/// throwing that character away. The liveness half is captured rather than assumed, for the same
|
||||
/// reason this method resolves its folder blind: a session that ended *because* the card was
|
||||
/// tombstoned belongs to a tombstoned card, and demanding a live one would make its own undo
|
||||
/// stale the instant it was registered.
|
||||
public func registerBodyEdit(inCard cardID: ItemID, priorBody: String, newBody: String) {
|
||||
guard priorBody != newBody, let target = Self.cardBodyTarget(cardID, in: snapshot) else { return }
|
||||
let folder = rootURL
|
||||
.appendingPathComponent(target.laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(target.cardID.rawValue, isDirectory: true)
|
||||
|
||||
registerStep(HistoryPhrase.name(.edit, kind: .card)) { _ in
|
||||
let lane = snapshot.lanes.first { $0.id == target.laneID }
|
||||
let card = lane?.cards.first { $0.id == target.cardID }
|
||||
let tombstoned = lane?.isDeleted == true || card?.isDeleted == true
|
||||
|
||||
registerStep(
|
||||
HistoryPhrase.name(.edit, kind: .card),
|
||||
subject: card?.title.value,
|
||||
undoExpects: [.item(folder, tombstoned: tombstoned, .body(newBody))],
|
||||
redoExpects: [.item(folder, tombstoned: tombstoned, .body(priorBody))]
|
||||
) { _ in
|
||||
_ = try BoardWriter.writeBody(inItemFolder: folder, body: priorBody)
|
||||
} redo: { _ in
|
||||
_ = try BoardWriter.writeBody(inItemFolder: folder, body: newBody)
|
||||
@@ -1599,8 +1660,15 @@ public final class BoardStore {
|
||||
}
|
||||
guard landed != nil else { return }
|
||||
|
||||
// rename → restore title, at the one level with no item to aim at.
|
||||
registerStep(HistoryPhrase.name(.rename, kind: .board)) { _ in
|
||||
// rename → restore title, at the one level with no item to aim at. The board root is never
|
||||
// tombstoned however its frontmatter reads (a board-level `deleted:` is a tolerated load
|
||||
// warning), so `.live` here means exactly "the root is still readable".
|
||||
registerStep(
|
||||
HistoryPhrase.name(.rename, kind: .board),
|
||||
subject: newTitle ?? priorTitle,
|
||||
undoExpects: [.live(folder, .title(newTitle))],
|
||||
redoExpects: [.live(folder, .title(priorTitle))]
|
||||
) { _ in
|
||||
try Self.setTitle(priorTitle, at: folder)
|
||||
} redo: { _ in
|
||||
try Self.setTitle(newTitle, at: folder)
|
||||
@@ -1669,7 +1737,12 @@ public final class BoardStore {
|
||||
// parent — the board root is the only one there is — so 06's vocabulary word for it is
|
||||
// Reorder, not Move.
|
||||
let restored = priorOrder
|
||||
registerStep(HistoryPhrase.name(.reorder, kind: .lane)) { _ in
|
||||
registerStep(
|
||||
HistoryPhrase.name(.reorder, kind: .lane),
|
||||
subject: lanes[from].title.value,
|
||||
undoExpects: [.live(folder, .order(newOrder))],
|
||||
redoExpects: [.live(folder, .order(restored))]
|
||||
) { _ in
|
||||
try Self.setOrder(restored, at: folder)
|
||||
} redo: { _ in
|
||||
try Self.setOrder(newOrder, at: folder)
|
||||
@@ -1745,7 +1818,12 @@ public final class BoardStore {
|
||||
// per gesture whatever the set's size" is the same sentence as one gesture, one undo step.
|
||||
let inverse = Array(zip(rewrites.map(\.folder), priorOrders))
|
||||
let forward = rewrites
|
||||
registerStep(HistoryPhrase.name(.reorder, kind: .lane, count: forward.count)) { _ in
|
||||
registerStep(
|
||||
HistoryPhrase.name(.reorder, kind: .lane, count: forward.count),
|
||||
subject: members.count == 1 ? members[0].title.value : nil,
|
||||
undoExpects: forward.map { .live($0.folder, .order($0.order)) },
|
||||
redoExpects: inverse.map { .live($0.0, .order($0.1)) }
|
||||
) { _ in
|
||||
for (folder, order) in inverse {
|
||||
try Self.setOrder(order, at: folder)
|
||||
}
|
||||
@@ -1788,6 +1866,9 @@ public final class BoardStore {
|
||||
let id: ItemID
|
||||
let laneID: ItemID
|
||||
let order: Double
|
||||
/// What it is called, for the skip banner a stale step would raise — read here because the
|
||||
/// snapshot this resolves against is the pre-write one, which is where a title still is.
|
||||
let title: String?
|
||||
}
|
||||
|
||||
/// `ids` narrowed to live cards under live lanes and sorted into **flatten order** — "lane
|
||||
@@ -1798,15 +1879,17 @@ public final class BoardStore {
|
||||
/// membership is a UUID set that vanished items leave silently (02-architecture.md), and "partial
|
||||
/// vanishing drops the survivors" is the design's own wording.
|
||||
private func draggedCards(_ ids: Set<ItemID>) -> [DraggedCard] {
|
||||
var homes: [ItemID: (lane: ItemID, order: Double)] = [:]
|
||||
var homes: [ItemID: (lane: ItemID, order: Double, title: String?)] = [:]
|
||||
for lane in snapshot.lanes where !lane.isDeleted {
|
||||
for card in lane.cards where !card.isDeleted {
|
||||
homes[card.id] = (lane.id, card.order)
|
||||
homes[card.id] = (lane.id, card.order, card.title.value)
|
||||
}
|
||||
}
|
||||
return SelectionGrammar.liveCards(in: snapshot)
|
||||
.filter { ids.contains($0) }
|
||||
.compactMap { id in homes[id].map { DraggedCard(id: id, laneID: $0.lane, order: $0.order) } }
|
||||
.compactMap { id in
|
||||
homes[id].map { DraggedCard(id: id, laneID: $0.lane, order: $0.order, title: $0.title) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The within-board card drop: `ids` land contiguously at logical position `index` among
|
||||
@@ -1899,7 +1982,17 @@ public final class BoardStore {
|
||||
)
|
||||
}
|
||||
let crossedLanes = members.contains { $0.laneID != laneID }
|
||||
registerStep(HistoryPhrase.name(crossedLanes ? .move : .reorder, kind: .card, count: arrivals.count)) { _ in
|
||||
// The two lists are index-aligned mirror images — `inverse[i].from` is where the card is now
|
||||
// and `forward[i].from` is where it was — so the expectations read as one swap: **the undo
|
||||
// wants the card at its destination holding the rank the drop gave it; the redo wants it back
|
||||
// at its origin holding the rank it left.** The destination *path* is the lane check: a card
|
||||
// a foreign writer moved elsewhere leaves nothing there to validate.
|
||||
registerStep(
|
||||
HistoryPhrase.name(crossedLanes ? .move : .reorder, kind: .card, count: arrivals.count),
|
||||
subject: members.count == 1 ? members[0].title : nil,
|
||||
undoExpects: zip(inverse, forward).map { .live($0.from, .order($1.order)) },
|
||||
redoExpects: zip(inverse, forward).map { .live($1.from, .order($0.order)) }
|
||||
) { _ in
|
||||
for step in inverse {
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: step.from,
|
||||
@@ -2561,7 +2654,11 @@ public final class BoardStore {
|
||||
// from what actually landed rather than from `urls`, so a batch that failed halfway still
|
||||
// hands ⌘Z exactly the cards it left behind.
|
||||
let items = created.compactMap { createdItem(at: $0.folder, kind: .card, attachments: [$0.source]) }
|
||||
registerCreation(items, kind: .card)
|
||||
registerCreation(
|
||||
items,
|
||||
kind: .card,
|
||||
subject: created.count == 1 ? Self.cardTitle(forFile: created[0].source) : nil
|
||||
)
|
||||
}
|
||||
|
||||
/// The title a dropped file's card takes: **the filename without its extension**
|
||||
@@ -2662,8 +2759,18 @@ public final class BoardStore {
|
||||
// reorder → restore original `order` (13-native-undo.md ▸ Rules). The step is named for the
|
||||
// *gesture's* subject — the cards the user was moving — not for every sibling the permutation
|
||||
// displaced, which is the same rule 06 applies to a commit subject.
|
||||
//
|
||||
// Every rewritten rank is validated, the displaced siblings' included: they are what this
|
||||
// permutation wrote, so they are what it must find unchanged — the step is *named* for the
|
||||
// gesture's subject and *validated* over its whole write.
|
||||
let steps = rewrites
|
||||
registerStep(HistoryPhrase.name(.reorder, kind: .card, count: selection.ids.count)) { _ in
|
||||
let moved = selection.ids
|
||||
registerStep(
|
||||
HistoryPhrase.name(.reorder, kind: .card, count: moved.count),
|
||||
subject: moved.count == 1 ? moved.first.flatMap { Self.liveItem($0, in: snapshot)?.title } : nil,
|
||||
undoExpects: steps.map { .live($0.folder, .order($0.to)) },
|
||||
redoExpects: steps.map { .live($0.folder, .order($0.from)) }
|
||||
) { _ in
|
||||
for step in steps {
|
||||
try Self.setOrder(step.from, at: step.folder)
|
||||
}
|
||||
@@ -2826,8 +2933,19 @@ public final class BoardStore {
|
||||
// tombstone → restore. The undo is Put Back's own write, which is what makes 13's "the stack
|
||||
// and the trash are two doors to the same tombstone state" true on disk rather than by
|
||||
// agreement — undoing a delete is *identical* in effect to Put Back.
|
||||
//
|
||||
// **Existence and liveness, no fields** (13: "existence/liveness for ... delete ... steps"):
|
||||
// what a tombstone writes is the item's side of the trash, so that is the whole after-value.
|
||||
// The `deleted` timestamp is deliberately *not* compared — it is a machine stamp rather than
|
||||
// a decision, and an item somebody put back and re-deleted is still on the side this step
|
||||
// left it on.
|
||||
let kind: HistoryPhrase.Kind = paths.contains(where: \.isLane) ? .lane : .card
|
||||
registerStep(HistoryPhrase.name(.delete, kind: kind, count: folders.count)) { _ in
|
||||
registerStep(
|
||||
HistoryPhrase.name(.delete, kind: kind, count: folders.count),
|
||||
subject: paths.count == 1 ? title(at: paths[0]) : nil,
|
||||
undoExpects: folders.map { .tombstoned($0) },
|
||||
redoExpects: folders.map { .live($0) }
|
||||
) { _ in
|
||||
for folder in folders {
|
||||
try BoardWriter.restoreItem(at: folder)
|
||||
}
|
||||
@@ -2875,7 +2993,12 @@ public final class BoardStore {
|
||||
// restore (Put Back) → tombstone (13-native-undo.md ▸ Rules) — the trash pair read the other
|
||||
// way round from `tombstone(_:)`'s step.
|
||||
let kind: HistoryPhrase.Kind = paths.contains(where: \.isLane) ? .lane : .card
|
||||
registerStep(HistoryPhrase.name(.restore, kind: kind, count: restored.count)) { _ in
|
||||
registerStep(
|
||||
HistoryPhrase.name(.restore, kind: kind, count: restored.count),
|
||||
subject: paths.count == 1 ? title(at: paths[0]) : nil,
|
||||
undoExpects: restored.map { .live($0.folder) },
|
||||
redoExpects: restored.map { .tombstoned($0.folder) }
|
||||
) { _ in
|
||||
for item in restored {
|
||||
try BoardWriter.updateIndex(inItemFolder: item.folder, operation: .delete(title: nil)) { document in
|
||||
Self.restoreTombstone(item.deleted, in: &document)
|
||||
@@ -2891,6 +3014,16 @@ public final class BoardStore {
|
||||
/// The `deleted` value a trash row currently carries — the one field a Put Back's inverse has to
|
||||
/// carry forward, and one the `ItemPath` vocabulary deliberately does not (a path is a location,
|
||||
/// not a reading of the file there).
|
||||
/// What a trash-pair step's one item is called — the skip banner's quoted subject, `nil` for an
|
||||
/// untitled item (which falls back to the step's own phrase) and for an id the snapshot has
|
||||
/// already lost. A path is a location, not a reading of the file there, so this is the same
|
||||
/// deliberate lookup `deletedField(at:)` is.
|
||||
private func title(at path: TrashModel.ItemPath) -> String? {
|
||||
guard let lane = snapshot.lanes.first(where: { $0.id == path.laneID }) else { return nil }
|
||||
guard let cardID = path.cardID else { return lane.title.value }
|
||||
return lane.cards.first(where: { $0.id == cardID })?.title.value
|
||||
}
|
||||
|
||||
private func deletedField(at path: TrashModel.ItemPath) -> FieldValue<Date> {
|
||||
guard let lane = snapshot.lanes.first(where: { $0.id == path.laneID }) else { return .missing }
|
||||
guard let cardID = path.cardID else { return lane.deleted }
|
||||
@@ -3069,8 +3202,26 @@ public final class BoardStore {
|
||||
// restore → tombstone (13-native-undo.md ▸ Rules), with the position half of the gesture
|
||||
// walked back too: the row returns to the lane it was trashed in, at the rank it was trashed
|
||||
// holding, under the timestamp it was trashed at — which is exactly where its trash row was.
|
||||
//
|
||||
// Both halves of the gesture are validated, because both were written: the card must be live
|
||||
// in the destination lane at the rank the drop gave it, and — the other way round — back in
|
||||
// the lane it was trashed in, tombstoned, at the rank it was trashed holding. The same-lane
|
||||
// case collapses to one folder and is still exactly this: the write that set no `order` set
|
||||
// it to the value it already had.
|
||||
let steps = moves
|
||||
registerStep(HistoryPhrase.name(.restore, kind: .card, count: steps.count)) { _ in
|
||||
registerStep(
|
||||
HistoryPhrase.name(.restore, kind: .card, count: steps.count),
|
||||
subject: rows.count == 1 ? rows[0].card.title.value : nil,
|
||||
undoExpects: steps.map {
|
||||
.live(laneFolder.appendingPathComponent($0.cardID.rawValue, isDirectory: true), .order($0.order))
|
||||
},
|
||||
redoExpects: steps.map {
|
||||
.tombstoned(
|
||||
TrashModel.ItemPath(laneID: $0.laneID, cardID: $0.cardID).folder(under: root),
|
||||
.order($0.priorOrder)
|
||||
)
|
||||
}
|
||||
) { _ in
|
||||
for step in steps {
|
||||
let priorFolder = TrashModel.ItemPath(laneID: step.laneID, cardID: step.cardID).folder(under: root)
|
||||
if step.laneID != laneID {
|
||||
|
||||
Reference in New Issue
Block a user