Materialize the trash — storage layer

Phase 1 of the trash pivot: the file format learns .trash/. The loader
parses the reserved root container — cards only, one shared parseCard
for both containers so fail-fast, attachments, and verbatim documents
are literally the same code; absent means empty; symlinks and
lane-shaped nestings fall out as strays by construction. BoardModel
grows snapshot.trash as a plain rank-ordered card list — the container
has no identity to carry. Legacy deleted: keys keep flowing through
the retiring flag path so every tombstone consumer stays green, and
are additionally reported through LoadResult.legacyTombstones in the
loose-file idiom for phase 2's migration scheduling — nothing vanishes
from view before its folder has actually moved, which is also 01's
lock-deferral posture. Writer primitives land value-passing: move to
trash with caller-minted rank and the deliberate modified stamp,
tombstone migrations that surgically remove the key, physical lane
removal, per-card and whole-container purge that leaves strays
verbatim, and byte-faithful whole-subtree capture/recreate for lane
undo. Board-wide identity now spans the trash, so an import colliding
with a trashed UUID remints instead of colliding. The watcher already
delivered .trash events — isGitInternal tests a component, not a dot —
now stated and pinned rather than relied on.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 15:55:40 -04:00
parent 96c4014fef
commit 4cf5f09d93
8 changed files with 1746 additions and 30 deletions
+7
View File
@@ -635,6 +635,13 @@ public final class BannerCenter {
if let title { "Couldn't put '\(title)' back" } else { "Couldn't put the item back" }
case let .purge(title):
if let title { "Couldn't permanently delete '\(title)'" } else { "Couldn't permanently delete the item" }
case let .migrateTombstone(title):
// One sentence covering both shapes of the migration, because the user's mental model
// of either is the same non-event: a board written by an older version being brought
// up to date. It deliberately names neither "delete" nor "trash" a card's migration
// moves it into the trash and a lane's brings it *back*, so any verb specific enough
// to describe one would be actively wrong about the other.
if let title { "Couldn't update '\(title)' to the current format" } else { "Couldn't update an item to the current format" }
case let .style(title):
if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" }
case let .resize(title):
+7
View File
@@ -531,6 +531,13 @@ public final class FolderWatcher {
/// so it schedules. The one thing lost is the ability to notice a pure-history change (a
/// commit that touched no working-tree file), which changes nothing on screen anyway.
///
/// **`.trash/` is deliberately not filtered, dot-name notwithstanding** (01-storage-format.md
/// § Deletion, resettled 2026-07-28). It is the one hidden folder in a board whose contents
/// *are* rendered a materialized container of ordinary cards so a foreign delete, restore
/// or purge (another device, an agent, a hand move in Finder) must reload the board exactly
/// like any other move. The filter tests for a `.git` component specifically rather than for a
/// dot prefix, which is what makes that true by construction rather than by exception.
///
/// A path that does not sit under the watched root at all is *not* filtered better a
/// redundant reload than a missed one.
private func isGitInternal(_ path: String) -> Bool {