Register inverse operations at the Writer boundary
The store is the Writer boundary, so it computes and registers inverses: a weak history sink bound at session composition, one HistoryStep per gesture at exactly the brackets that were already one performWrite each — multi-card moves, style batches, width pairs, and multi-row restores each undo as one plurally-titled step, and the Edit session registers once at the flip from the bytes disk held before its first landed write, debounce ticks registering nothing. Crossings run through performWrite, so an undo brackets the watcher, echoes through the reload, and reaches every window; every closure captures values, never snapshots. The inventory follows 13 exactly: moves return to origin lane and order, renames restore or remove the title key, restyles and resizes restore field values or absence, tombstones and restores swap with captured timestamps, and an undone create is a real removal — no trace — with redo re-materializing the same UUID from bytes captured at gesture time. Purge, attachments, repair, bookkeeping, checkbox flips, raw Apply, and the whole arrival family register nothing, each exclusion documented where it lives. Step names speak 06's verb vocabulary through the new HistoryPhrase. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -355,6 +355,27 @@ public final class BoardStore {
|
||||
@ObservationIgnored
|
||||
public var displayStateDelegate: (@MainActor () -> Void)?
|
||||
|
||||
/// **Where this board's inverses go** — the undo/redo substrate every write below registers into
|
||||
/// (13-native-undo.md ▸ Rules: "Registration at the Writer boundary … each Writer call site
|
||||
/// registers the inverse operation, computed from the pre-write snapshot the store already
|
||||
/// holds").
|
||||
///
|
||||
/// The store is the Writer boundary: every app-mediated mutation in the app goes through one of
|
||||
/// the methods below and out through `performWrite`, which is precisely the set of call sites 13
|
||||
/// names. So the sink belongs here, injected like `watcherBrackets` and for the same reason — the
|
||||
/// stack is **the session's**, "one stack per board, owned by the board session", and a store that
|
||||
/// made its own would be a second answer to which stack a board has.
|
||||
/// `AppModel.beginSession` wires it the moment the session's provider exists.
|
||||
///
|
||||
/// **Weak, deliberately.** The session owns both the store and the provider, and the provider's
|
||||
/// steps hold closures over *this* store: a strong reference here would close that loop, leaving a
|
||||
/// board that could only be freed by remembering to empty its undo stack first. `nil` — no session
|
||||
/// yet, a storeless test, a board whose stack has been cleared away — keeps every method below
|
||||
/// behaving exactly as it did before this milestone, registering nothing, which is `watcherBrackets`'
|
||||
/// `nil` rule restated for a second seam.
|
||||
@ObservationIgnored
|
||||
public weak var history: (any HistoryProviding)?
|
||||
|
||||
// MARK: Reload machinery
|
||||
|
||||
/// Monotonic id of the most recently *started* reload — and therefore also the number of tree
|
||||
@@ -858,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)] = changes.compactMap { change in
|
||||
let writes: [(folder: URL, units: Int, prior: FieldValue<Int>)] = 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)
|
||||
return (rootURL.appendingPathComponent(change.id.rawValue), change.units, lane.width)
|
||||
}
|
||||
guard !writes.isEmpty else { return }
|
||||
|
||||
@@ -870,16 +891,37 @@ public final class BoardStore {
|
||||
// call site Swift stops inferring the typed `throws(BoardWriteError)` and widens it to `any
|
||||
// Error`, which `performWrite` will not take. Same wart as the value-returning call sites
|
||||
// `performWrite`'s doc comment records, arriving from the other direction.
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
for write in writes {
|
||||
try Self.setWidth(write.units, at: write.folder)
|
||||
}
|
||||
}
|
||||
guard landed != nil else { return }
|
||||
|
||||
// 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
|
||||
for write in writes {
|
||||
try BoardWriter.updateIndex(inItemFolder: write.folder, operation: .resize(title: nil)) { document in
|
||||
if write.units == 1 {
|
||||
document.remove(FrontmatterKeys.width)
|
||||
} else {
|
||||
document.set(FrontmatterKeys.width, to: .int(write.units))
|
||||
}
|
||||
Self.restoreWidth(write.prior, in: &document)
|
||||
}
|
||||
}
|
||||
} redo: { _ in
|
||||
for write in writes {
|
||||
try Self.setWidth(write.units, at: write.folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The width write itself, spelled once so the gesture and its redo cannot drift apart on the
|
||||
/// remove-at-default rule.
|
||||
private static func setWidth(_ units: Int, at folder: URL) throws(BoardWriteError) {
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .resize(title: nil)) { document in
|
||||
if units == 1 {
|
||||
document.remove(FrontmatterKeys.width)
|
||||
} else {
|
||||
document.set(FrontmatterKeys.width, to: .int(units))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -986,16 +1028,28 @@ public final class BoardStore {
|
||||
/// targets written before it written — the Writer is "atomic per filesystem operation, not per
|
||||
/// 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: [(folder: URL, background: StyleChange, icon: StyleChange)] = styleSubjects(of: target)
|
||||
let edits: [(
|
||||
folder: URL,
|
||||
background: StyleChange,
|
||||
icon: StyleChange,
|
||||
priorBackground: FieldValue<String>,
|
||||
priorIcon: FieldValue<String>
|
||||
)] = styleSubjects(of: target)
|
||||
.compactMap { subject in
|
||||
let background = Self.effective(background, against: subject.background)
|
||||
let icon = Self.effective(icon, against: subject.icon)
|
||||
guard background != .keep || icon != .keep else { return nil }
|
||||
return (folder: subject.folder, background: background, icon: icon)
|
||||
return (
|
||||
folder: subject.folder,
|
||||
background: background,
|
||||
icon: icon,
|
||||
priorBackground: subject.background,
|
||||
priorIcon: subject.icon
|
||||
)
|
||||
}
|
||||
guard !edits.isEmpty else { return }
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
for edit in edits {
|
||||
// `.style(title: nil)`: `updateIndex` enriches it off the document it reads, so a
|
||||
// failure names the item by the title it still has (see `WriteOperation.style`).
|
||||
@@ -1005,6 +1059,31 @@ public final class BoardStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
guard landed != nil else { return }
|
||||
|
||||
// restyle → prior style (13-native-undo.md ▸ Rules). **One step for the batch**, which is the
|
||||
// same sentence as this method's one bracket: "choosing a well applies to the whole selection
|
||||
// — one gesture, one commit", substrate swapped.
|
||||
let kind: HistoryPhrase.Kind = switch styleLevel(of: target) {
|
||||
case .board: .board
|
||||
case .lane: .lane
|
||||
case .card: .card
|
||||
}
|
||||
registerStep(HistoryPhrase.name(.restyle, kind: kind, count: edits.count)) { _ 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)
|
||||
Self.restore(edit.priorIcon, to: FrontmatterKeys.icon, in: &document)
|
||||
}
|
||||
}
|
||||
} redo: { _ in
|
||||
for edit in edits {
|
||||
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
|
||||
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
|
||||
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `change` narrowed against what is already on disk: `.keep` when it would write what is
|
||||
@@ -1045,9 +1124,16 @@ public final class BoardStore {
|
||||
/// and a menu item has no second thing to do about a failure.
|
||||
public func createLane() {
|
||||
let root = rootURL
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
_ = try BoardWriter.createLane(inBoard: root, title: nil)
|
||||
let created = try? performWrite { () throws(BoardWriteError) -> ItemID in
|
||||
try BoardWriter.createLane(inBoard: root, title: nil)
|
||||
}
|
||||
guard let created else { return }
|
||||
|
||||
// create → remove the created folder (13-native-undo.md ▸ Rules). The bytes are read back
|
||||
// here, while the folder still exists, because the inverse destroys it — see `CreatedItem`.
|
||||
let folder = root.appendingPathComponent(created.rawValue, isDirectory: true)
|
||||
guard let item = createdItem(at: folder, kind: .lane) else { return }
|
||||
registerCreation([item], kind: .lane)
|
||||
}
|
||||
|
||||
// MARK: - The new-card placeholder's commit
|
||||
@@ -1125,6 +1211,13 @@ public final class BoardStore {
|
||||
return nil
|
||||
}
|
||||
transient.commitPlaceholder(expecting: created)
|
||||
|
||||
// create → remove the created folder (13-native-undo.md ▸ Rules). The rank the pair above
|
||||
// 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)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
@@ -1180,16 +1273,34 @@ public final class BoardStore {
|
||||
folder.append(component: cardID.rawValue)
|
||||
}
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
// `.rename(title: nil)`: `updateIndex` enriches it off the document it reads, so the
|
||||
// banner names the item by the title it still has rather than the one that failed to
|
||||
// land (see `WriteOperation.rename`).
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
|
||||
if let newTitle {
|
||||
document.set(FrontmatterKeys.title, to: .string(newTitle))
|
||||
} else {
|
||||
document.remove(FrontmatterKeys.title)
|
||||
}
|
||||
try Self.setTitle(newTitle, at: folder)
|
||||
}
|
||||
guard landed != nil else { return }
|
||||
|
||||
// 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: ""`.
|
||||
let priorTitle = target.title
|
||||
registerStep(HistoryPhrase.name(.rename, kind: target.cardID == nil ? .lane : .card)) { _ in
|
||||
try Self.setTitle(priorTitle, at: folder)
|
||||
} redo: { _ in
|
||||
try Self.setTitle(newTitle, at: folder)
|
||||
}
|
||||
}
|
||||
|
||||
/// The title write every rename shares — the item-level one and the board's — spelled once so
|
||||
/// the empty-title rule (a missing key, never `title: ""`) cannot differ between a gesture and
|
||||
/// its own undo.
|
||||
private static func setTitle(_ title: String?, at folder: URL) throws(BoardWriteError) {
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
|
||||
if let title {
|
||||
document.set(FrontmatterKeys.title, to: .string(title))
|
||||
} else {
|
||||
document.remove(FrontmatterKeys.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1231,6 +1342,12 @@ public final class BoardStore {
|
||||
/// rather than write blind. Nothing here inspects the body: the store never re-parses to
|
||||
/// second-guess the click, because its own snapshot is exactly as stale as the render was.
|
||||
///
|
||||
/// **It registers no undo step.** 13-native-undo.md ▸ Rules' inventory names the body write it
|
||||
/// makes undoable precisely — "Edit-session body save → restore prior body bytes" — and a Preview
|
||||
/// checkbox is not one: it belongs to no session, has no flip to coalesce at, and 05 files it
|
||||
/// under what is "undoable on git boards", which is the *other* substrate's answer. Registering it
|
||||
/// here would be extending 13's inventory rather than implementing it.
|
||||
///
|
||||
/// **A checkbox in a card that has gone writes nothing** — the vanished-target guard every
|
||||
/// gesture in this file makes, ancestor-walked through `liveItem`: the card window would be
|
||||
/// dismissing itself in the same breath, and the reload that removed the card is the authority.
|
||||
@@ -1297,6 +1414,42 @@ public final class BoardStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers **one Edit session** as one undo step — 13-native-undo.md ▸ Rules' coalescing
|
||||
/// sentence, stated where the session ends rather than where the bytes land.
|
||||
///
|
||||
/// ### Why this is not registered in `writeCardBody`
|
||||
///
|
||||
/// Because a session is not a save. "An Edit session is one step, registered at the Edit→Preview
|
||||
/// flip (the effective Save — 05-card-window.md)", and a session contains any number of debounced
|
||||
/// saves: registering per write would put a step on the stack every ~700 ms of typing, and ⌘Z
|
||||
/// would walk backwards through the user's keystrokes in seven-hundred-millisecond slices rather
|
||||
/// than undoing the edit they made. So `CardBodyEditSession` remembers the bytes disk held when
|
||||
/// the session's first save landed, and calls this once at the flip with that pair — the same
|
||||
/// boundary pro-m1's auto-committer coalesces on, for the same reason.
|
||||
///
|
||||
/// ### The bytes are the whole state
|
||||
///
|
||||
/// `BoardWriter.writeBody` replaces the body span and nothing else, so a step built from two body
|
||||
/// strings restores the prior body **byte for byte** — unknown keys, comments and key order above
|
||||
/// the delimiter were never this write's to change. That is the one inverse in the app whose
|
||||
/// fidelity is byte-level rather than field-level.
|
||||
///
|
||||
/// 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.
|
||||
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
|
||||
_ = try BoardWriter.writeBody(inItemFolder: folder, body: priorBody)
|
||||
} redo: { _ in
|
||||
_ = try BoardWriter.writeBody(inItemFolder: folder, body: newBody)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which folder a card's body write lands in — **the one card walk that ignores liveness**.
|
||||
///
|
||||
/// Every other resolution in this file goes through `liveItem`, whose ancestor-walked liveness is
|
||||
@@ -1373,6 +1526,11 @@ public final class BoardStore {
|
||||
///
|
||||
/// A pull landing mid-session is not consulted at all: "Apply stays last-writer-wins" (05, citing
|
||||
/// 07-sync-collab.md), the same posture the Edit buffer takes.
|
||||
///
|
||||
/// **It registers no undo step**, `toggleTaskMarker`'s reason: 13-native-undo.md ▸ Rules makes the
|
||||
/// *Edit session's* body save undoable, and Apply is not one — it is a whole-file replacement of
|
||||
/// bytes the user typed themselves, with the raw buffer still on screen as its own record of what
|
||||
/// they were.
|
||||
public func applyCardSource(inCard cardID: ItemID, text: String) -> RawSourceApplyOutcome {
|
||||
guard let target = Self.liveItem(cardID, in: snapshot), let card = target.cardID else { return .vanished }
|
||||
let folder = rootURL
|
||||
@@ -1433,16 +1591,19 @@ public final class BoardStore {
|
||||
guard newTitle != snapshot.title.value else { return }
|
||||
|
||||
let folder = rootURL
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
let priorTitle = snapshot.title.value
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
// `.rename(title: nil)`: `updateIndex` enriches it off the document it reads, so a
|
||||
// refusal names the board by the title it still has (see `WriteOperation.rename`).
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
|
||||
if let newTitle {
|
||||
document.set(FrontmatterKeys.title, to: .string(newTitle))
|
||||
} else {
|
||||
document.remove(FrontmatterKeys.title)
|
||||
}
|
||||
}
|
||||
try Self.setTitle(newTitle, at: folder)
|
||||
}
|
||||
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
|
||||
try Self.setTitle(priorTitle, at: folder)
|
||||
} redo: { _ in
|
||||
try Self.setTitle(newTitle, at: folder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1471,18 +1632,28 @@ public final class BoardStore {
|
||||
|
||||
let root = rootURL
|
||||
let folder = root.appendingPathComponent(id.rawValue)
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
// The rank the lane held before the write and the one it lands on — both read out of the
|
||||
// bracket below, because a renumber that fires inside it moves the *prior* value too: the
|
||||
// dragged lane is among the renumbered children, so its pre-gesture `order` would no longer
|
||||
// place it where it was. What an inverse must restore is the rank the file held immediately
|
||||
// before its own rewrite, which is exactly what this captures either way.
|
||||
var priorOrder = lanes[from].order
|
||||
var newOrder: Double?
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var rank = Ranks.insertionRank(amongVisible: remaining.map(\.order), at: target)
|
||||
if rank == nil {
|
||||
// Compact and place again. Unlike the card case the dragged lane *is* among the
|
||||
// renumbered children — it is a real folder on disk — so its fresh rank is dropped
|
||||
// from the ladder before the neighbours are consulted.
|
||||
try BoardWriter.renumberVisibleChildren(of: root)
|
||||
var compacted = Ranks.renumbered(count: lanes.count)
|
||||
let renumbered = Ranks.renumbered(count: lanes.count)
|
||||
priorOrder = renumbered[from]
|
||||
var compacted = renumbered
|
||||
compacted.remove(at: from)
|
||||
rank = Ranks.insertionRank(amongVisible: compacted, at: target)
|
||||
}
|
||||
guard let rank else { return }
|
||||
newOrder = rank
|
||||
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: folder,
|
||||
@@ -1492,6 +1663,25 @@ public final class BoardStore {
|
||||
order: rank
|
||||
)
|
||||
}
|
||||
guard landed != nil, let newOrder else { return }
|
||||
|
||||
// reorder → restore original `order` (13-native-undo.md ▸ Rules). A lane drag never changes
|
||||
// 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
|
||||
try Self.setOrder(restored, at: folder)
|
||||
} redo: { _ in
|
||||
try Self.setOrder(newOrder, at: folder)
|
||||
}
|
||||
}
|
||||
|
||||
/// The bare rank rewrite an inverse reorder performs — `moveItem`'s same-parent degenerate path
|
||||
/// with the URL arithmetic taken out, since an inverse always names the folder directly.
|
||||
private static func setOrder(_ order: Double, at folder: URL) throws(BoardWriteError) {
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .reorder(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.order, to: .double(order))
|
||||
}
|
||||
}
|
||||
|
||||
/// The within-board **lane drag**, multi-drag included: `ids` land contiguously at display
|
||||
@@ -1519,23 +1709,29 @@ public final class BoardStore {
|
||||
else { return }
|
||||
|
||||
let root = rootURL
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
// `moveLane`'s capture, per member — see its note on why the prior rank is read out of the
|
||||
// bracket rather than off the snapshot.
|
||||
var priorOrders = members.map(\.order)
|
||||
var rewrites: [(folder: URL, order: Double)] = []
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
|
||||
if ranks == nil {
|
||||
// Compact and place again. The dragged lanes *are* among the renumbered children —
|
||||
// they are real folders on disk — so their fresh rungs are dropped from the ladder
|
||||
// before the neighbours are consulted, exactly as `moveLane` drops its one.
|
||||
try BoardWriter.renumberVisibleChildren(of: root)
|
||||
let compacted = zip(lanes, Ranks.renumbered(count: lanes.count))
|
||||
.filter { !ids.contains($0.0.id) }
|
||||
.map(\.1)
|
||||
let renumbered = Array(zip(lanes, Ranks.renumbered(count: lanes.count)))
|
||||
priorOrders = renumbered.filter { ids.contains($0.0.id) }.map(\.1)
|
||||
let compacted = renumbered.filter { !ids.contains($0.0.id) }.map(\.1)
|
||||
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
|
||||
for (member, rank) in zip(members, ranks) {
|
||||
let folder = root.appendingPathComponent(member.id.rawValue, isDirectory: true)
|
||||
rewrites.append((folder: folder, order: rank))
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: root.appendingPathComponent(member.id.rawValue, isDirectory: true),
|
||||
at: folder,
|
||||
toParent: root,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
@@ -1543,6 +1739,21 @@ public final class BoardStore {
|
||||
)
|
||||
}
|
||||
}
|
||||
guard landed != nil, !rewrites.isEmpty else { return }
|
||||
|
||||
// reorder → restore original `order`, one step for the whole run: "one `performWrite` bracket
|
||||
// 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
|
||||
for (folder, order) in inverse {
|
||||
try Self.setOrder(order, at: folder)
|
||||
}
|
||||
} redo: { _ in
|
||||
for write in forward {
|
||||
try Self.setOrder(write.order, at: write.folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drag & drop commits
|
||||
@@ -1576,6 +1787,7 @@ public final class BoardStore {
|
||||
private struct DraggedCard {
|
||||
let id: ItemID
|
||||
let laneID: ItemID
|
||||
let order: Double
|
||||
}
|
||||
|
||||
/// `ids` narrowed to live cards under live lanes and sorted into **flatten order** — "lane
|
||||
@@ -1586,15 +1798,15 @@ 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 lanes: [ItemID: ItemID] = [:]
|
||||
var homes: [ItemID: (lane: ItemID, order: Double)] = [:]
|
||||
for lane in snapshot.lanes where !lane.isDeleted {
|
||||
for card in lane.cards where !card.isDeleted {
|
||||
lanes[card.id] = lane.id
|
||||
homes[card.id] = (lane.id, card.order)
|
||||
}
|
||||
}
|
||||
return SelectionGrammar.liveCards(in: snapshot)
|
||||
.filter { ids.contains($0) }
|
||||
.compactMap { id in lanes[id].map { DraggedCard(id: id, laneID: $0) } }
|
||||
.compactMap { id in homes[id].map { DraggedCard(id: id, laneID: $0.lane, order: $0.order) } }
|
||||
}
|
||||
|
||||
/// The within-board card drop: `ids` land contiguously at logical position `index` among
|
||||
@@ -1629,7 +1841,12 @@ public final class BoardStore {
|
||||
let root = rootURL
|
||||
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
// The pre-write home of every member, per 13's "move → move back (original lane, original
|
||||
// `order`)". A renumber inside the bracket rewrites the destination lane's own cards, so a
|
||||
// member that was already there has its captured rank refreshed — `moveLane`'s note.
|
||||
var origins = Dictionary(uniqueKeysWithValues: members.map { ($0.id, (lane: $0.laneID, order: $0.order)) })
|
||||
var arrivals: [(id: ItemID, order: Double)] = []
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
|
||||
if ranks == nil {
|
||||
// Compact and place again. The renumber assigns in display order over the lane's
|
||||
@@ -1637,9 +1854,11 @@ public final class BoardStore {
|
||||
// members already in this lane are dropped from it before the neighbours are
|
||||
// consulted, exactly as `moveLane` drops the dragged lane's own rung.
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
let compacted = zip(rendered, Ranks.renumbered(count: rendered.count))
|
||||
.filter { !ids.contains($0.0.id) }
|
||||
.map(\.1)
|
||||
let renumbered = Array(zip(rendered, Ranks.renumbered(count: rendered.count)))
|
||||
for (card, rank) in renumbered where ids.contains(card.id) {
|
||||
origins[card.id] = (lane: laneID, order: rank)
|
||||
}
|
||||
let compacted = renumbered.filter { !ids.contains($0.0.id) }.map(\.1)
|
||||
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
@@ -1648,6 +1867,7 @@ public final class BoardStore {
|
||||
let folder = root
|
||||
.appendingPathComponent(member.laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(member.id.rawValue, isDirectory: true)
|
||||
arrivals.append((id: member.id, order: rank))
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: folder,
|
||||
toParent: laneFolder,
|
||||
@@ -1657,6 +1877,49 @@ public final class BoardStore {
|
||||
)
|
||||
}
|
||||
}
|
||||
guard landed != nil, !arrivals.isEmpty else { return }
|
||||
|
||||
// move → move back (original lane, original `order`); a drop that never left its lane is
|
||||
// 06's Reorder rather than Move, which is the same distinction the commit vocabulary draws.
|
||||
let inverse: [(from: URL, toParent: URL, order: Double)] = arrivals.compactMap { arrival in
|
||||
guard let origin = origins[arrival.id] else { return nil }
|
||||
return (
|
||||
from: laneFolder.appendingPathComponent(arrival.id.rawValue, isDirectory: true),
|
||||
toParent: root.appendingPathComponent(origin.lane.rawValue, isDirectory: true),
|
||||
order: origin.order
|
||||
)
|
||||
}
|
||||
let forward: [(from: URL, order: Double)] = arrivals.compactMap { arrival in
|
||||
guard let origin = origins[arrival.id] else { return nil }
|
||||
return (
|
||||
from: root
|
||||
.appendingPathComponent(origin.lane.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(arrival.id.rawValue, isDirectory: true),
|
||||
order: arrival.order
|
||||
)
|
||||
}
|
||||
let crossedLanes = members.contains { $0.laneID != laneID }
|
||||
registerStep(HistoryPhrase.name(crossedLanes ? .move : .reorder, kind: .card, count: arrivals.count)) { _ in
|
||||
for step in inverse {
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: step.from,
|
||||
toParent: step.toParent,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: step.order
|
||||
)
|
||||
}
|
||||
} redo: { _ in
|
||||
for step in forward {
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: step.from,
|
||||
toParent: laneFolder,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: step.order
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The within-board ⌥-drag: fresh-GUID duplicates of `ids` land contiguously at `index` among
|
||||
@@ -1716,6 +1979,15 @@ public final class BoardStore {
|
||||
// board's own bracket, which is correct and needs no coordination: the source store's watcher
|
||||
// sees a foreign change and reloads, which is exactly what a foreign change is.
|
||||
//
|
||||
// **None of these register an undo step, and the reason is 13's own two sentences.** Its inverse
|
||||
// inventory names nine operations and an arrival is not among them; and "undo is board-local" —
|
||||
// one stack per board — while a cross-board move's inverse would have to write into the *source*
|
||||
// board, whose stack knows nothing about it and whose window may not even be open. The clipboard's
|
||||
// half is the same shape one remove further: a paste's inverse needs the staged tree to still be
|
||||
// there, which is exactly the staging lifecycle 13 defers with the attachment operations. Within a
|
||||
// board, `copyCards`' ⌥-drag is left out with them: its Writer operation is `.copy`, not a create,
|
||||
// and the three arrival paths are one gesture family that should gain undo together or not at all.
|
||||
//
|
||||
// `sources` are the items' folder URLs in the source board — both boards are open in this app,
|
||||
// so both roots are already security-scoped and the payload can carry plain URLs. The source
|
||||
// board root is read back off the path rather than passed alongside: 01-storage-format.md's
|
||||
@@ -2027,6 +2299,13 @@ public final class BoardStore {
|
||||
|
||||
// MARK: - Finder file drops
|
||||
|
||||
// **The attachment half registers no undo step** (13-native-undo.md ▸ Out of scope, ratified
|
||||
// 2026-07-27): "attachment add/remove registers **no undo step** in v1", because remove →
|
||||
// re-add needs the removed file to survive somewhere and that staging area is a design pass of
|
||||
// its own. Add → remove would be a clean inverse on its own, but half a pair is worse than none:
|
||||
// ⌘Z would undo attaching and refuse to undo detaching, which is not a rule anyone could learn.
|
||||
// The *card-creating* half below is an ordinary create and does register one.
|
||||
//
|
||||
// The writes an external Finder file drag performs (04-interactions.md ▸ Drag and drop, "Files
|
||||
// from Finder"): onto a card the files join its `attachments/`, onto lane empty space they become
|
||||
// one card each. The gesture's half — which card, which slot — is `BoardDropContext`'s; these are
|
||||
@@ -2107,6 +2386,12 @@ public final class BoardStore {
|
||||
/// 01-storage-format.md's loose-file carve-out (§ Fractal layout ▸ Rules, settled 2026-07-28,
|
||||
/// "Lanework-owns-the-board"; the loader's `looseCardFiles` is the notice half).
|
||||
///
|
||||
/// **It registers no undo step**, and unlike its neighbours that is not a deferral: nobody asked
|
||||
/// for it. The relocation is the app tidying its own house on a reload, not a gesture — there is
|
||||
/// no ⌘Z that should follow it, and putting one on the stack would let the next ⌘Z undo something
|
||||
/// the user never did. (It is `renumberVisibleChildren`'s posture: bookkeeping composes no event,
|
||||
/// 06-history-undo.md ▸ Commit messages.)
|
||||
///
|
||||
/// **It is an ordinary app write and nothing more.** One `performWrite` bracket over the whole
|
||||
/// board's worth of relocation, so the churn rounds back as a single app-mediated reload and (on
|
||||
/// git boards) a single commit — the style batch's rule, applied to a batch the app started
|
||||
@@ -2236,6 +2521,7 @@ public final class BoardStore {
|
||||
let root = rootURL
|
||||
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
|
||||
var created: [(folder: URL, source: URL)] = []
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(
|
||||
amongVisible: rendered.map(\.order), at: target, count: urls.count)
|
||||
@@ -2265,8 +2551,17 @@ public final class BoardStore {
|
||||
try? FileManager.default.removeItem(at: folder)
|
||||
throw error
|
||||
}
|
||||
created.append((folder: folder, source: url))
|
||||
}
|
||||
}
|
||||
|
||||
// create → remove the created folder (13-native-undo.md ▸ Rules), one step for the drop
|
||||
// whatever its file count. The redo re-imports from the same source URLs the gesture used —
|
||||
// the one create in the app whose replay needs more than the card's own bytes. Collected
|
||||
// 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)
|
||||
}
|
||||
|
||||
/// The title a dropped file's card takes: **the filename without its extension**
|
||||
@@ -2334,7 +2629,12 @@ public final class BoardStore {
|
||||
let orders = rendered.map(\.order)
|
||||
let positions = Dictionary(uniqueKeysWithValues: rendered.enumerated().map { ($1.id, $0) })
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
// Every rank this gesture rewrites, with the value it replaced — the permutation's own
|
||||
// inverse. It is read out of the bracket because the ladder may be the *renumbered* one:
|
||||
// after a compaction the card at display position `origin` holds `ladder[origin]`, which is
|
||||
// what its own rewrite overwrites and therefore what an undo has to put back.
|
||||
var rewrites: [(folder: URL, from: Double, to: Double)] = []
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ladder = orders
|
||||
if !Self.isStrictlyAscending(orders) {
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
@@ -2345,8 +2645,10 @@ public final class BoardStore {
|
||||
for (destination, id) in plan.ordering.enumerated() {
|
||||
guard let origin = positions[id], origin != destination else { continue }
|
||||
let rank = ladder[destination]
|
||||
let folder = laneFolder.appendingPathComponent(id.rawValue, isDirectory: true)
|
||||
rewrites.append((folder: folder, from: ladder[origin], to: rank))
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: laneFolder.appendingPathComponent(id.rawValue, isDirectory: true),
|
||||
inItemFolder: folder,
|
||||
// `.reorder(title: nil)`: `updateIndex` enriches it off the document it reads, so
|
||||
// a failure names the card by its own title.
|
||||
operation: .reorder(title: nil)
|
||||
@@ -2355,6 +2657,21 @@ public final class BoardStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
guard landed != nil, !rewrites.isEmpty else { return }
|
||||
|
||||
// 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.
|
||||
let steps = rewrites
|
||||
registerStep(HistoryPhrase.name(.reorder, kind: .card, count: selection.ids.count)) { _ in
|
||||
for step in steps {
|
||||
try Self.setOrder(step.from, at: step.folder)
|
||||
}
|
||||
} redo: { _ in
|
||||
for step in steps {
|
||||
try Self.setOrder(step.to, at: step.folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a lane's ranks separate its cards on their own — the condition under which they can
|
||||
@@ -2415,13 +2732,13 @@ public final class BoardStore {
|
||||
/// reload-survival rule), and neither do `putBack`/`deleteImmediately` — the item merely changed
|
||||
/// sides, or nothing survives on either.
|
||||
public func delete(_ ids: Set<ItemID>) {
|
||||
let folders = TrashModel.paths(of: ids, on: .live, in: snapshot).map { $0.folder(under: rootURL) }
|
||||
guard !folders.isEmpty else { return }
|
||||
let paths = TrashModel.paths(of: ids, on: .live, in: snapshot)
|
||||
guard !paths.isEmpty else { return }
|
||||
// The successor is drawn from what the lane is *showing*, so a delete under an active search
|
||||
// walks the filtered lane rather than selecting a card the query has hidden.
|
||||
let successor = SelectionGrammar.successor(afterDeleting: ids, in: snapshot, filter: searchFilter)
|
||||
|
||||
tombstone(folders)
|
||||
tombstone(paths)
|
||||
|
||||
if let successor {
|
||||
select([successor], liveness: .live, anchor: successor, head: successor)
|
||||
@@ -2456,10 +2773,9 @@ public final class BoardStore {
|
||||
/// Cards only, by the gesture's own gate (`TrashDrop.accepts`) — but nothing here depends on
|
||||
/// that: the paths resolve on the live side exactly as `delete(_:)`'s do.
|
||||
public func deleteByDrag(cardIDs: [ItemID]) {
|
||||
let folders = TrashModel.paths(of: Set(cardIDs), on: .live, in: snapshot)
|
||||
.map { $0.folder(under: rootURL) }
|
||||
guard !folders.isEmpty else { return }
|
||||
tombstone(folders)
|
||||
let paths = TrashModel.paths(of: Set(cardIDs), on: .live, in: snapshot)
|
||||
guard !paths.isEmpty else { return }
|
||||
tombstone(paths)
|
||||
}
|
||||
|
||||
/// **The card window's Actions ▸ Delete** (05-card-window.md ▸ Actions: "Delete — tombstones the
|
||||
@@ -2485,9 +2801,9 @@ public final class BoardStore {
|
||||
/// rule; liveness is ancestor-walked, so a card under a tombstoned lane is gone too — and its
|
||||
/// window is already dismissing.
|
||||
public func deleteCard(_ id: ItemID) {
|
||||
let folders = TrashModel.paths(of: [id], on: .live, in: snapshot).map { $0.folder(under: rootURL) }
|
||||
guard !folders.isEmpty else { return }
|
||||
tombstone(folders)
|
||||
let paths = TrashModel.paths(of: [id], on: .live, in: snapshot)
|
||||
guard !paths.isEmpty else { return }
|
||||
tombstone(paths)
|
||||
}
|
||||
|
||||
/// The tombstone write itself — **one `performWrite` bracket, whatever the set's size and
|
||||
@@ -2495,8 +2811,27 @@ public final class BoardStore {
|
||||
///
|
||||
/// Spelled once so ⌫ and drop-on-trash cannot drift apart on disk; everything that differs
|
||||
/// between them is about the *selection*, and lives in the callers.
|
||||
private func tombstone(_ folders: [URL]) {
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
/// It is also where the tombstone's **undo step** is registered, for the identical reason: 13's
|
||||
/// "tombstone (⌫) → restore" has to mean the same thing whichever gesture asked, and a step
|
||||
/// registered at each caller would be three chances to name it differently.
|
||||
private func tombstone(_ paths: [TrashModel.ItemPath]) {
|
||||
let folders = paths.map { $0.folder(under: rootURL) }
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
for folder in folders {
|
||||
try BoardWriter.deleteItem(at: folder)
|
||||
}
|
||||
}
|
||||
guard landed != nil else { return }
|
||||
|
||||
// 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.
|
||||
let kind: HistoryPhrase.Kind = paths.contains(where: \.isLane) ? .lane : .card
|
||||
registerStep(HistoryPhrase.name(.delete, kind: kind, count: folders.count)) { _ in
|
||||
for folder in folders {
|
||||
try BoardWriter.restoreItem(at: folder)
|
||||
}
|
||||
} redo: { _ in
|
||||
for folder in folders {
|
||||
try BoardWriter.deleteItem(at: folder)
|
||||
}
|
||||
@@ -2524,19 +2859,53 @@ public final class BoardStore {
|
||||
/// resolve rule ejects them from a `.trashed` set as a vanish — the same silent shrink an
|
||||
/// external restore would produce.
|
||||
public func putBack(_ ids: Set<ItemID>) {
|
||||
let folders = TrashModel.paths(of: ids, on: .trashed, in: snapshot).map { $0.folder(under: rootURL) }
|
||||
guard !folders.isEmpty else { return }
|
||||
let paths = TrashModel.paths(of: ids, on: .trashed, in: snapshot)
|
||||
guard !paths.isEmpty else { return }
|
||||
// Captured before the write: the timestamp each row is filed in the trash under, which is
|
||||
// what an undo has to put back — see `restoreTombstone`.
|
||||
let restored = paths.map { (folder: $0.folder(under: rootURL), deleted: deletedField(at: $0)) }
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
for folder in folders {
|
||||
try BoardWriter.restoreItem(at: folder)
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
for item in restored {
|
||||
try BoardWriter.restoreItem(at: item.folder)
|
||||
}
|
||||
}
|
||||
guard landed != nil else { return }
|
||||
|
||||
// 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
|
||||
for item in restored {
|
||||
try BoardWriter.updateIndex(inItemFolder: item.folder, operation: .delete(title: nil)) { document in
|
||||
Self.restoreTombstone(item.deleted, in: &document)
|
||||
}
|
||||
}
|
||||
} redo: { _ in
|
||||
for item in restored {
|
||||
try BoardWriter.restoreItem(at: item.folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
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 }
|
||||
return lane.cards.first(where: { $0.id == cardID })?.deleted ?? .missing
|
||||
}
|
||||
|
||||
/// Delete Immediately ⌥⌘⌫: physically removes every tombstoned item in `ids` (03-board-ui.md §
|
||||
/// Trash), in one bracket.
|
||||
///
|
||||
/// **It registers no undo step, and `purgeIsUnrecoverable` stays `true`** — 13-native-undo.md
|
||||
/// ▸ Rules settles this by name: "Permanently delete (Delete Immediately, Empty Trash) —
|
||||
/// `purgeIsUnrecoverable` stays true in base, and the existing confirmation rule already fires on
|
||||
/// all base boards … the confirm *is* the safety". A stack entry here would be a promise the
|
||||
/// filesystem cannot keep.
|
||||
///
|
||||
/// **The confirmation is not here.** Whether the loss is real is `purgeIsUnrecoverable`'s
|
||||
/// question and the alert is the window's; a store method that put up its own dialog could not
|
||||
/// be driven from a test, and the same purge is reached by two surfaces (the menu item and the
|
||||
@@ -2561,6 +2930,9 @@ public final class BoardStore {
|
||||
|
||||
/// Empty Trash… ⇧⌘⌫: purges **every** tombstone on the board, in one bracket.
|
||||
///
|
||||
/// Not undoable, `deleteImmediately`'s ruling and its wording — this is the other half of 13's
|
||||
/// "Permanently delete".
|
||||
///
|
||||
/// **Whole-trash scope, search-independent** (03-board-ui.md § Trash, settled): the targets come
|
||||
/// from the snapshot, never from the filtered view — "a bulk command about the trash itself never
|
||||
/// silently narrows to the visible subset". The filter does not reach this method at all, which
|
||||
@@ -2641,7 +3013,12 @@ public final class BoardStore {
|
||||
let rendered = destination.cards.filter { !$0.isDeleted }
|
||||
let target = min(max(0, index), rendered.count)
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
// What each row was before the gesture — its lane, its recorded rank, and the timestamp it is
|
||||
// filed in the trash under — against where it lands. A tombstoned card is not among the
|
||||
// destination's rendered cards, so a renumber inside the bracket cannot touch its own rank;
|
||||
// only the neighbours' move, and their sequence is preserved.
|
||||
var moves: [(cardID: ItemID, laneID: ItemID, priorOrder: Double, deleted: FieldValue<Date>, order: Double)] = []
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: rows.count)
|
||||
if ranks == nil {
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
@@ -2655,6 +3032,13 @@ public final class BoardStore {
|
||||
|
||||
for (row, rank) in zip(rows, ranks) {
|
||||
let cardFolder = TrashModel.ItemPath(laneID: row.laneID, cardID: row.card.id).folder(under: root)
|
||||
moves.append((
|
||||
cardID: row.card.id,
|
||||
laneID: row.laneID,
|
||||
priorOrder: row.card.order,
|
||||
deleted: row.card.deleted,
|
||||
order: rank
|
||||
))
|
||||
|
||||
guard row.laneID != laneID else {
|
||||
try BoardWriter.updateIndex(
|
||||
@@ -2680,6 +3064,49 @@ public final class BoardStore {
|
||||
)
|
||||
}
|
||||
}
|
||||
guard landed != nil, !moves.isEmpty else { return }
|
||||
|
||||
// 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.
|
||||
let steps = moves
|
||||
registerStep(HistoryPhrase.name(.restore, kind: .card, count: steps.count)) { _ in
|
||||
for step in steps {
|
||||
let priorFolder = TrashModel.ItemPath(laneID: step.laneID, cardID: step.cardID).folder(under: root)
|
||||
if step.laneID != laneID {
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: laneFolder.appendingPathComponent(step.cardID.rawValue, isDirectory: true),
|
||||
toParent: root.appendingPathComponent(step.laneID.rawValue, isDirectory: true),
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: step.priorOrder
|
||||
)
|
||||
}
|
||||
try BoardWriter.updateIndex(inItemFolder: priorFolder, operation: .delete(title: nil)) { document in
|
||||
Self.restoreTombstone(step.deleted, in: &document)
|
||||
document.set(FrontmatterKeys.order, to: .double(step.priorOrder))
|
||||
}
|
||||
}
|
||||
} redo: { _ in
|
||||
for step in steps {
|
||||
let priorFolder = TrashModel.ItemPath(laneID: step.laneID, cardID: step.cardID).folder(under: root)
|
||||
guard step.laneID != laneID else {
|
||||
try BoardWriter.updateIndex(inItemFolder: priorFolder, operation: .restore(title: nil)) { document in
|
||||
document.remove(FrontmatterKeys.deleted)
|
||||
document.set(FrontmatterKeys.order, to: .double(step.order))
|
||||
}
|
||||
continue
|
||||
}
|
||||
try BoardWriter.restoreItem(at: priorFolder)
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: priorFolder,
|
||||
toParent: laneFolder,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: step.order
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection (delegated)
|
||||
|
||||
Reference in New Issue
Block a user