Build the trash quasi-lane
Deletion becomes a two-stage, Finder-style story. File > Delete and plain Backspace tombstone the live selection; View > Show Trash (no chord — Shift-Cmd-T stays with the system's tab bar) reveals the quasi-lane: trailing, one fixed width unit consumed only while shown, hatched dimmed header, count badge, no new-card button, exempt from resize and reorder alike. Its contents are a pure view over the snapshot — the deterministic sort (deleted newest first, folder-name ties, unparseable stamps oldest) interleaves card rows with a tombstoned lane's single entry, whose count names what Put Back returns; the ancestor walk is absolute, so an own-flag card beneath a tombstoned lane has no row and recovery is deliberately two steps. Put Back twins Delete on Cmd-Backspace with validation enabling exactly one; restore fidelity is byte-perfect because nothing ever moved. Delete Immediately confirms exactly where loss is real (every board is mode-none today; the predicate names the git carve-out for m7), Empty Trash always confirms with the true whole-board count, and dragging a tombstoned card onto a live lane restores it there — positional drops and cross-board locality arrive with m5's machinery. The banner's delete phrasing drops "move to the trash" per the naming constraint: board deletion says Delete, "Move to Trash" stays reserved for the system Trash. 47 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -1070,6 +1070,188 @@ public final class BoardStore {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The trash
|
||||
|
||||
/// Whether physically removing an item on this board destroys the only copy of it — and
|
||||
/// therefore whether Delete Immediately stands an alert between one keystroke and unrecoverable
|
||||
/// deletion (03-board-ui.md § Trash, "Delete Immediately confirms exactly where the loss is
|
||||
/// real").
|
||||
///
|
||||
/// **Every board is `true` today**, because every board is history mode *none*: nothing in the
|
||||
/// app keeps a second copy, so a purge is final everywhere.
|
||||
///
|
||||
// m7-git: git boards answer `false` here — "on git boards it acts immediately, since the content
|
||||
// remains reachable in history" (06-history-undo.md's delete-never-forgets). Repo-nested boards
|
||||
// stay `true` alongside mode none: the app manages no history for them either. The named
|
||||
// predicate exists now so the committer card changes one expression rather than hunting the
|
||||
// confirmation logic out of two menu items and an alert.
|
||||
public var purgeIsUnrecoverable: Bool { true }
|
||||
|
||||
/// Tombstones the current selection — File ▸ Delete ⌘⌫ and its plain-⌫ grammar twin
|
||||
/// (04-interactions.md ▸ The map, 11-command-nexus.md).
|
||||
///
|
||||
/// A convenience over `delete(_:)` so the two call sites cannot disagree about *what* the
|
||||
/// command acts on.
|
||||
public func deleteSelection() {
|
||||
delete(selection.ids)
|
||||
}
|
||||
|
||||
/// Tombstones every live item in `ids` — cards or lanes, in one bracket.
|
||||
///
|
||||
/// **One `performWrite` whatever the set's size**, matching the style batch's rule and for its
|
||||
/// reason: one gesture, one app-mediated reload, and (on git boards) one commit rather than N.
|
||||
/// A lane's tombstone rewrites only the lane's own `index.md` — hiding the subtree is the
|
||||
/// renderer's ancestor walk, not a stored flag (`BoardWriter.deleteItem`).
|
||||
///
|
||||
/// **Tombstoned ids are silently skipped**, not refused: the paths are resolved on the live side
|
||||
/// only, so a selection the next reload will drop writes nothing rather than re-stamping a
|
||||
/// `deleted:` that is already there. An empty resolution never opens the bracket at all.
|
||||
///
|
||||
/// The selection is **cleared**, not moved to a successor. 04-interactions.md ▸ The map asks for
|
||||
/// the Finder-style successor sibling ("repeated ⌫ walks down a lane"), which needs the
|
||||
/// navigation order the keyboard grammar defines — that is m5's card. Clearing is the honest
|
||||
/// interim: what was selected renders nowhere now, and the reload's resolve rule would empty the
|
||||
/// set a moment later anyway.
|
||||
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 }
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
for folder in folders {
|
||||
try BoardWriter.deleteItem(at: folder)
|
||||
}
|
||||
}
|
||||
// m5-keyboard: the successor-selection grammar replaces this line.
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
/// Put Back: removes `deleted:` from every tombstoned item in `ids`, in one bracket
|
||||
/// (03-board-ui.md § Trash).
|
||||
///
|
||||
/// **Restore fidelity is perfect because nothing ever moved** — the item re-enters the visible
|
||||
/// set at its recorded `order` among its current siblings, and the folder is exactly where it
|
||||
/// has been all along (`BoardWriter.restoreItem`).
|
||||
///
|
||||
/// **Putting back a lane splits its contents by flag for free.** The write is the lane's own
|
||||
/// `index.md` and nothing else, so cards hidden *with* the lane return with it while cards
|
||||
/// carrying their own `deleted:` stay tombstoned — and their rows reappear in the trash, which
|
||||
/// is exactly the two-step recovery the design settled on.
|
||||
///
|
||||
/// A card whose lane is itself tombstoned cannot be reached this way through the UI (it has no
|
||||
/// row — `TrashModel.entries`), but the path resolution admits it, and the outcome is the pure
|
||||
/// view's honest one: the key is removed, and the card still renders nowhere because its lane
|
||||
/// is still tombstoned. Putting the lane back then shows it.
|
||||
///
|
||||
/// The selection is deliberately left alone: the restored items flip liveness, and the reload's
|
||||
/// 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 }
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
for folder in folders {
|
||||
try BoardWriter.restoreItem(at: folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete Immediately ⌥⌘⌫: physically removes every tombstoned item in `ids` (03-board-ui.md §
|
||||
/// Trash), in one bracket.
|
||||
///
|
||||
/// **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
|
||||
/// trash row's context menu) that must not each grow their own copy of the rule.
|
||||
///
|
||||
/// Purging a **lane** takes its whole folder — every card inside it, tombstoned or not. That is
|
||||
/// what the lane entry subsuming its subtree means on disk.
|
||||
public func deleteImmediately(_ ids: Set<ItemID>) {
|
||||
let folders = TrashModel.paths(of: ids, on: .trashed, in: snapshot).map { $0.folder(under: rootURL) }
|
||||
guard !folders.isEmpty else { return }
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
for folder in folders {
|
||||
try BoardWriter.purgeItem(at: folder)
|
||||
}
|
||||
}
|
||||
// Nothing the set named exists any more, on either side of the boundary — unlike Put Back,
|
||||
// where the items merely changed sides, there is no vanish for the reload to notice on the
|
||||
// trashed side that would not equally be a vanish here.
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
/// Empty Trash… ⇧⌘⌫: purges **every** tombstone on the board, in one bracket.
|
||||
///
|
||||
/// **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
|
||||
/// is the strongest form of that guarantee.
|
||||
///
|
||||
/// Cards carrying their own `deleted:` under a tombstoned lane go too, without being listed:
|
||||
/// they live inside the lane folder this removes (`TrashModel.emptyTrashTargets`).
|
||||
public func emptyTrash() {
|
||||
let folders = TrashModel.emptyTrashTargets(in: snapshot).map { $0.folder(under: rootURL) }
|
||||
guard !folders.isEmpty else { return }
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
for folder in folders {
|
||||
try BoardWriter.purgeItem(at: folder)
|
||||
}
|
||||
}
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
/// Drag-to-restore: a tombstoned card row dropped over a live lane comes back **into that lane**
|
||||
/// (03-board-ui.md § Trash, 04-interactions.md ▸ The trash).
|
||||
///
|
||||
/// Two writes in **one bracket**, and the order is load-bearing: `restoreItem` first — the folder
|
||||
/// is still where the trash row said it was — then, only when the destination differs, the move.
|
||||
/// Doing it the other way round would have the second call chasing a folder the first one had
|
||||
/// already relocated.
|
||||
///
|
||||
/// **Same lane is a plain Put Back**: the key is removed and nothing else is touched, so the card
|
||||
/// returns at its recorded `order` rather than at the bottom. "Folder moved only if the
|
||||
/// destination lane differs" is the design's own wording, and the position-perfect restore is the
|
||||
/// point of the trash being a pure view.
|
||||
///
|
||||
// m5-drag: two things arrive with the drag card's `DropSlot` port. (1) The **positional** drop —
|
||||
// the design restores "at the drop position", and the append below is the interim; the rank comes
|
||||
// from `Ranks.insertionRank` over the destination's visible cards, exactly as `commitPlaceholder`
|
||||
// computes it. (2) **Cross-board locality** — a drop on another board is a live *copy* by
|
||||
// default with the tombstoned original staying put, and ⌘-drag forces the true restore-move.
|
||||
// Both need the drag controller's target vocabulary; this method is deliberately within-board.
|
||||
///
|
||||
/// Silent no-ops, all of them the reload being the authority rather than this gesture: a
|
||||
/// destination lane that is gone or tombstoned, a card that is not a trash row (its own flag
|
||||
/// unset, or its lane tombstoned so it has no row to drag), and an id that names nothing.
|
||||
public func restoreByDrag(cardID: ItemID, intoLane laneID: ItemID) {
|
||||
guard snapshot.lanes.contains(where: { $0.id == laneID && !$0.isDeleted }),
|
||||
let source = snapshot.lanes.first(where: { lane in
|
||||
!lane.isDeleted && lane.cards.contains { $0.id == cardID && $0.isDeleted }
|
||||
})
|
||||
else { return }
|
||||
|
||||
let root = rootURL
|
||||
let cardFolder = TrashModel.ItemPath(laneID: source.id, cardID: cardID).folder(under: root)
|
||||
let destination = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
let crossesLanes = source.id != laneID
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
try BoardWriter.restoreItem(at: cardFolder)
|
||||
guard crossesLanes else { return }
|
||||
// `order: nil` is the Writer's own append — computed over the destination's *visible*
|
||||
// siblings, which the arriving card is not yet among.
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: cardFolder,
|
||||
toParent: destination,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection (delegated)
|
||||
|
||||
// The thin pass-throughs to `transient`, and the only ones.
|
||||
|
||||
Reference in New Issue
Block a user