Materialize the trash — store, undo, and the container universe
Phase 2 swaps every consumer: Liveness and its ancestor walk are gone, replaced by ItemContainer — a UUID set plus the container side it lives on, presence the whole test, one selection boundary instead of the old liveness law. Deletion stages by place: board cards move to the trash at a store-minted head rank, trash-side delete is permanent behind its confirmation, Delete Immediately skips the trash from anywhere, lane delete captures the subtree and removes the folder. Restore has no method at all — moveCards resolves members in either container, so drag-out and cut-paste are the ordinary moves 13 calls them, registering ordinary Move steps. The delete inverse moves the card back to its captured lane and rank; redo replays the captured trash rank, a value the gesture actually wrote; lane undo recreates the subtree byte-faithfully in session. Purges register nothing — where 13's trash section contradicts its own Rules on that, Rules wins, filed for ruling. Staleness collapsed to present-or-absent: a container is a path, so a foreign restore fails the delete step's expectation structurally. Legacy tombstones migrate on the loose-file tail hook, cards oldest-first so minting above top reproduces the retired newest-first column, lanes returning live, one folded loss row naming both directions. Put Back, restoreByDrag, receiveRestoredCards, TrashEntry, and the kind machinery are deleted; the trash column renders the container correctly with its full face rework left to phase 3. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -416,6 +416,27 @@ public final class BannerCenter {
|
||||
postLoss(message)
|
||||
}
|
||||
|
||||
/// **The legacy tombstone migration** (01-storage-format.md § Deletion, resettled 2026-07-28:
|
||||
/// "Legacy `deleted:` keys migrate on load-and-write, never destroy … a graceful warning-tone
|
||||
/// notice"): a board written by an older version carried `deleted:` keys, the app moved the
|
||||
/// cards those keys named into `.trash/` and returned the lanes live, and this is the row that
|
||||
/// says so.
|
||||
///
|
||||
/// **A loss row for `postRelocatedLooseFiles`' exact reason**, and it is the same shape of event:
|
||||
/// the app moved the user's folders on its own initiative, on a board it opened rather than on a
|
||||
/// gesture they made. That must be said out loud, must not evaporate unread, and must not rank
|
||||
/// as an error, because no action failed. The one nuance worth naming: the *lane* half is a
|
||||
/// resurrection rather than a removal — cards nobody asked to see again may reappear on the
|
||||
/// board — which is exactly the kind of surprise this class exists to announce.
|
||||
///
|
||||
/// `cards` and `lanes` are the migrated items' titles, in the order they were written, `nil` for
|
||||
/// an untitled one — "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so
|
||||
/// the phrasing layer decides what to call it. A migration that migrated nothing posts nothing.
|
||||
public func postMigratedTombstones(cards: [String?], lanes: [String?]) {
|
||||
guard let message = Self.migratedTombstonesMessage(cards: cards, lanes: lanes) else { return }
|
||||
postLoss(message)
|
||||
}
|
||||
|
||||
/// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its
|
||||
/// folders (04-interactions.md ▸ Selection, drag & drop, "Folders are refused at hover"): "a
|
||||
/// mixed drag proposes for its files only, and the drop imports the files while a one-shot
|
||||
@@ -822,6 +843,59 @@ public final class BannerCenter {
|
||||
return "Moved '\(name)' into attachments — \(subject)"
|
||||
}
|
||||
|
||||
/// The legacy tombstone migration's line — **one folded sentence for both halves**, written in
|
||||
/// `relocatedLooseFilesMessage`'s voice because it is the same kind of notice: the act first,
|
||||
/// the subject after an em dash, plurals folded, a sole item named.
|
||||
///
|
||||
/// The two clauses are joined rather than posted as two rows, because it is **one migration**:
|
||||
/// a board opened, its old deletion markers were resolved, and that is one thing that happened
|
||||
/// to the user's files. Two rows would also mean two dismissals for one event, and would rank a
|
||||
/// resurrection and a relocation against each other for no reason.
|
||||
///
|
||||
/// The shapes, in the relocation's own idiom:
|
||||
///
|
||||
/// - **One card**, no lanes: "Moved 'Fix login' to the trash — it carried an old deleted marker".
|
||||
/// - **Several cards**: "Moved 3 cards to the trash — they carried old deleted markers".
|
||||
/// - **One lane**, no cards: "Restored 'Doing' — it carried an old deleted marker".
|
||||
/// - **Both**: "Moved 3 cards to the trash and restored 2 lanes — they carried old deleted markers".
|
||||
///
|
||||
/// **The tail names the cause once**, and it is the whole explanation the row owes: the user did
|
||||
/// not delete anything just now, and without the clause the sentence would read as an action
|
||||
/// they had somehow just taken. The singular/plural of the tail follows the *total*, so the
|
||||
/// mixed case never has to spell a singular (two clauses carry at least two items).
|
||||
///
|
||||
/// `nil` when nothing migrated — a migration that migrated nothing is not news.
|
||||
public nonisolated static func migratedTombstonesMessage(cards: [String?], lanes: [String?]) -> String? {
|
||||
let total = cards.count + lanes.count
|
||||
guard total > 0 else { return nil }
|
||||
|
||||
var clauses: [String] = []
|
||||
if !cards.isEmpty {
|
||||
let subject = cards.count == 1
|
||||
? sole(cards[0])
|
||||
: "\(cards.count) cards"
|
||||
clauses.append("Moved \(subject) to the trash")
|
||||
}
|
||||
if !lanes.isEmpty {
|
||||
let subject = lanes.count == 1
|
||||
? sole(lanes[0])
|
||||
: "\(lanes.count) lanes"
|
||||
clauses.append(clauses.isEmpty ? "Restored \(subject)" : "restored \(subject)")
|
||||
}
|
||||
let tail = total == 1
|
||||
? "it carried an old deleted marker"
|
||||
: "they carried old deleted markers"
|
||||
return "\(clauses.joined(separator: " and ")) — \(tail)"
|
||||
}
|
||||
|
||||
/// A sole migrated item's name: its title in quotes, or the untitled rendering the relocation
|
||||
/// line already uses ("an untitled card" / "an untitled lane" are one phrase here, because the
|
||||
/// clause it sits in already says which level it is).
|
||||
private nonisolated static func sole(_ title: String?) -> String {
|
||||
guard let title else { return "an untitled item" }
|
||||
return "'\(title)'"
|
||||
}
|
||||
|
||||
/// 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 — …").
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user