Materialize the trash — faces, menus, and grammar
Phase 3 finishes the pivot at the surface. One card face serves two containers: CardFaceView extracted with a role — board or trash — so stripe, tint, chip, selection stroke, cut dim, marquee registration, and drag are shared by construction, the trash side differing only in its absences: no Open, no rename, no Style, no file-hover highlight, and a Delete that goes through the confirmation host. The column rewrote around the lanes' own single-column masonry so drag reflow reads as positional slides; chrome stays the hatched header, symbol, and count — 11 gives Empty Trash to the File menu alone. Two real grammar bugs die here: plain Backspace on a trash selection purged without the confirmation the menu raises, and the context menu's Delete resolved against the standing selection, so right-clicking a trash card under a board selection silently did nothing — it now stages the clicked set explicitly. Open, Rename, Style, and Empty Trash validation became testable store seams; the column is one named accessibility container of ordinary card elements. The tombstone era is swept: deleteItem, restoreItem, stripTombstonedChildren — dead since lane copies stopped nesting trash — the restore verb, the unreachable put-back banner row, and every quasi-lane doc comment. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -786,60 +786,6 @@ public enum BoardWriter: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Physically removes every tombstoned card from a **just-copied** lane, and reports which —
|
||||
/// the tail of a lane copy (04-interactions.md ▸ Drag and drop: "A lane copy **strips
|
||||
/// tombstoned cards**: the copy transfers content, and trash isn't content"; the same rule
|
||||
/// governs a pasted lane copy).
|
||||
///
|
||||
/// **Removed, not tombstoned.** These folders were minted seconds ago by `copyItem` and were
|
||||
/// never content in this board, so there is nothing here for a Put Back to recover and no
|
||||
/// tombstone to leave standing — the tombstoned *originals* stay recoverable in the source
|
||||
/// board, which is where the recovery story lives. A lane **move** carries them whole and never
|
||||
/// calls this: the folder travels as-is and its tombstones land in the destination's trash by
|
||||
/// rendering.
|
||||
///
|
||||
/// **Only ever pointed at a fresh copy.** `copyItem` has no filter hook — it copies the tree
|
||||
/// verbatim by design, which is what makes attachments and strays arrive byte-identical — so
|
||||
/// the strip is a second step rather than a parameter, and a caller that aimed it at a lane the
|
||||
/// user actually owns would be destroying their trash. Every call site in the app is the line
|
||||
/// after a `copyItem` that materialized the folder.
|
||||
///
|
||||
/// A child whose `index.md` is missing or unreadable is **left alone**: the liveness question
|
||||
/// cannot be answered for it, and the conservative direction is to keep the folder — the same
|
||||
/// leniency `copyItem` extends below its root. Liveness is read exactly as the loader reads it
|
||||
/// (a present `deleted` key, malformed or not).
|
||||
///
|
||||
/// The operation vocabulary is `.copy`, not `.purge`: the user pressed nothing called "delete",
|
||||
/// and a failure here must say the app could not copy the lane (02-architecture.md §
|
||||
/// Write-failure surfacing).
|
||||
@discardableResult
|
||||
public static func stripTombstonedChildren(of laneFolder: URL) throws(BoardWriteError) -> [ItemID] {
|
||||
let operation = WriteOperation.copy(title: nil)
|
||||
try checkIsDirectory(laneFolder, describedAs: "lane folder", operation: operation)
|
||||
try checkIsUUIDShaped(laneFolder, operation: operation)
|
||||
|
||||
var removed: [ItemID] = []
|
||||
for child in childCandidates(of: laneFolder) {
|
||||
let indexURL = child.appendingPathComponent(BoardLoader.indexFileName)
|
||||
guard FileManager.default.fileExists(atPath: indexURL.path),
|
||||
let document = try? readDocument(at: indexURL, operation: operation),
|
||||
!document.deleted.isMissing
|
||||
else { continue }
|
||||
|
||||
do {
|
||||
try FileManager.default.removeItem(at: child)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: child.path,
|
||||
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
removed.append(ItemID(rawValue: child.lastPathComponent))
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/// Materializes an item from **supplied `index.md` text** rather than from a folder on disk —
|
||||
/// the clipboard's staging-less fallback (04-interactions.md ▸ Clipboard: "if the staged
|
||||
/// snapshot is missing or unreadable at paste time, paste falls back to the embedded
|
||||
@@ -1391,82 +1337,20 @@ public enum BoardWriter: Sendable {
|
||||
try? FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: url.path)
|
||||
}
|
||||
|
||||
// MARK: - Tombstone (retired, awaiting removal)
|
||||
//
|
||||
// The tombstone model is retired (01-storage-format.md § Deletion, resettled 2026-07-28): the
|
||||
// app's delete is the physical move above, and no `deleted:` key is ever written again.
|
||||
//
|
||||
// **These have no app callers left.** Every consumer moved across with the store swap — the
|
||||
// delete is `deleteCardToTrash`, the lane delete is `removeLane`, the restore is an ordinary
|
||||
// `moveItem`, and the legacy keys are handled by the two `migrate…` calls above. They are kept
|
||||
// here for exactly one more beat because `purgeItem` below is still live (Delete Immediately's
|
||||
// board-side purge) and the three read as one family; the pair and
|
||||
// `stripTombstonedChildren` go together in the trash's cleanup pass, with the suites that
|
||||
// still pin their byte-level behaviour.
|
||||
|
||||
/// Tombstones a lane or card in place: writes `deleted: <now>` into its own `index.md` —
|
||||
/// the whole of a delete (01-storage-format.md § Deletion). The folder never moves, never
|
||||
/// renames, and nothing beneath it is touched: hiding the subtree is the renderer's
|
||||
/// ancestor walk, not a stored flag, so deleting a lane rewrites *only* the lane's own
|
||||
/// file — its cards' files are exactly as they were.
|
||||
///
|
||||
/// **Board-root deletion is structurally unreachable at this layer**: `checkIsUUIDShaped`
|
||||
/// — the same guard `moveItem`/`copyItem` lean on — refuses any folder whose name isn't
|
||||
/// UUID-shaped, and a board root never is (§ Board naming). A board-level `deleted:` key
|
||||
/// is legal-but-meaningless per the frontmatter table (the loader ignores and warns on
|
||||
/// it), but this call is simply never able to *produce* one: it has no board-root code
|
||||
/// path to fall through, only a refusal.
|
||||
///
|
||||
/// Deleting an **already-tombstoned** item is not refused — it just refreshes the
|
||||
/// timestamp, a harmless rewrite (the gesture happened again; this layer does not police
|
||||
/// liveness, the store's UI does). Goes through `updateIndex`, so the usual contract
|
||||
/// applies: fresh read, refuse an uneditable shape, `modified` stamped and `modified-by`
|
||||
/// cleared, atomic replace.
|
||||
public static func deleteItem(at itemFolder: URL) throws(BoardWriteError) {
|
||||
let operation = WriteOperation.delete(title: nil)
|
||||
try checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation)
|
||||
try checkIsUUIDShaped(itemFolder, operation: operation)
|
||||
|
||||
try updateIndex(inItemFolder: itemFolder, operation: operation) { document in
|
||||
document.set(FrontmatterKeys.deleted, to: .date(Date()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Put Back: removes the `deleted` key, undoing exactly what `deleteItem` wrote.
|
||||
/// **Position-perfect by construction** — the folder never moved, so the item simply
|
||||
/// re-enters the visible set at its recorded `order` among its current siblings
|
||||
/// (01-storage-format.md § Deletion). `FrontmatterDocument.remove` takes *every*
|
||||
/// occurrence of the key, so a hand-duplicated `deleted` line cannot resurrect the
|
||||
/// tombstone the instant the winning occurrence is gone.
|
||||
///
|
||||
/// Restoring an item that **isn't** tombstoned is not refused — it is a harmless stamped
|
||||
/// rewrite, the same shrug `deleteItem` gives an already-deleted item: this layer does not
|
||||
/// police liveness (a second, independent liveness check here could only drift from the
|
||||
/// store UI's own, which is what actually decides whether Put Back is offered at all).
|
||||
public static func restoreItem(at itemFolder: URL) throws(BoardWriteError) {
|
||||
let operation = WriteOperation.restore(title: nil)
|
||||
try checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation)
|
||||
try checkIsUUIDShaped(itemFolder, operation: operation)
|
||||
|
||||
try updateIndex(inItemFolder: itemFolder, operation: operation) { document in
|
||||
document.remove(FrontmatterKeys.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
/// Physical removal — Delete Immediately / Empty Trash (03-board-ui.md): deletes the
|
||||
/// folder tree from disk. Irreversible, and distinct from tombstoning — this call does
|
||||
/// **not** require the item to be tombstoned first, since Delete Immediately skips the
|
||||
/// tombstone stage by design.
|
||||
/// folder tree from disk. Irreversible, and distinct from the ordinary delete, which is a
|
||||
/// *move* into `.trash/` — this call does **not** require the item to be in the trash first,
|
||||
/// since Delete Immediately "skips the trash from anywhere" by design.
|
||||
///
|
||||
/// **A folder that is already gone is success, not an error** — checked first, before the
|
||||
/// shape guard below. A Finder deletion converges on exactly the end state a purge would
|
||||
/// produce (01-storage-format.md § Deletion, "a folder that disappears without a
|
||||
/// tombstone... is also a delete"), so there is nothing left here to distinguish: a stray
|
||||
/// path that never existed and a once-real item someone already threw away in Finder both
|
||||
/// purge cleanly, silently, without inspecting what used to be there.
|
||||
/// produce (01-storage-format.md § Deletion, "a folder that disappears ... is also a
|
||||
/// delete"), so there is nothing left here to distinguish: a stray path that never existed
|
||||
/// and a once-real item someone already threw away in Finder both purge cleanly, silently,
|
||||
/// without inspecting what used to be there.
|
||||
///
|
||||
/// When the folder *does* exist, `checkIsUUIDShaped` guards the same unreachability
|
||||
/// `deleteItem`/`restoreItem` rely on: a board root or a stray never purges through this
|
||||
/// `moveItem`/`copyItem` rely on: a board root or a stray never purges through this
|
||||
/// call, only a lane or a card.
|
||||
public static func purgeItem(at itemFolder: URL) throws(BoardWriteError) {
|
||||
let operation = WriteOperation.purge(title: nil)
|
||||
@@ -1837,7 +1721,7 @@ public enum BoardWriter: Sendable {
|
||||
/// The order of checks is the contract:
|
||||
///
|
||||
/// 1. **`cardFolder` must be an existing, UUID-shaped directory** — attachments belong to
|
||||
/// cards, the same shape guard `deleteItem`/`restoreItem`/`purgeItem` lean on
|
||||
/// cards, the same shape guard `moveItem`/`copyItem`/`purgeItem` lean on
|
||||
/// (`checkIsUUIDShaped`): a lane or a board root is refused before anything else happens.
|
||||
/// 2. **`attachments/` is created if missing** (`.io` naming `cardFolder` on failure) — the
|
||||
/// one exception to "subfolders are never created by the app" (§ Attachments); every
|
||||
@@ -2400,10 +2284,12 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case reorder(title: String?)
|
||||
case copy(title: String?)
|
||||
/// ⌫ / ⌘⌫ — a card moving into `.trash/` (`deleteCardToTrash`) or a lane being removed
|
||||
/// outright (`removeLane`). Still the word the user pressed; the retiring tombstone write
|
||||
/// shares it while it lasts.
|
||||
/// outright (`removeLane`). The word the user pressed, whichever staging it took.
|
||||
///
|
||||
/// **There is no `restore` case**: restoring is an ordinary move out (`moveItem`), so a failed
|
||||
/// restore says the app couldn't *move* the card — which is exactly what it couldn't do
|
||||
/// (03-board-ui.md § Trash, resettled 2026-07-28 — Put Back is retired with the tombstone model).
|
||||
case delete(title: String?)
|
||||
case restore(title: String?)
|
||||
case purge(title: String?)
|
||||
|
||||
/// A legacy `deleted:` key being migrated away — a card relocating into `.trash/` with the key
|
||||
@@ -2511,7 +2397,6 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .reorder: .reorder(title: title)
|
||||
case .copy: .copy(title: title)
|
||||
case .delete: .delete(title: title)
|
||||
case .restore: .restore(title: title)
|
||||
case .purge: .purge(title: title)
|
||||
case .migrateTombstone: .migrateTombstone(title: title)
|
||||
case .style: .style(title: title)
|
||||
@@ -2539,7 +2424,6 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case let .reorder(title): Self.phrase("reorder", title)
|
||||
case let .copy(title): Self.phrase("copy", title)
|
||||
case let .delete(title): Self.phrase("delete", title)
|
||||
case let .restore(title): Self.phrase("restore", title)
|
||||
case let .purge(title): Self.phrase("purge", title)
|
||||
case let .migrateTombstone(title): Self.phrase("migrate the legacy 'deleted' key on", title)
|
||||
case let .style(title): Self.phrase("style", title)
|
||||
|
||||
Reference in New Issue
Block a user