Relocate loose card files into attachments

01's Lanework-owns-the-board carve-out: a regular file beside a card's
index.md belongs in attachments/, and the app moves it there. The
loader detects read-only — a new LoadResult.looseCardFiles channel,
separate from the stray-tolerance warnings because it says the opposite
thing — skipping directories, symlinks, hidden entries, and the
reserved names compared case-insensitively (on APFS, Index.md IS the
index). The relocation rides one performWrite bracket at the tail of
every successful reload, which makes lock deferral free: the reload
that lifts a read-only lock is the reload that relocates. A
lane/card/filename memo keeps a failing relocation from hot-looping —
one one-shot, then silence until disk changes. The notice rides the
loss-row class, phrasing folded by BannerCenter (one file, one card's
files, a multi-card sweep), naming original filenames per the
importAttachment rule. Paste normalizes at the import boundary: staged
snapshots' loose files land in the pasted card's attachments silently,
every arrival path declaring its side via an explicit
normalizingLooseFiles parameter — drag paths decline and fall back to
the destination's own carve-out. checkIsCardFolder closes the hole
where a lane's notes.txt would have been relocated: card depth is
exact, UUID under UUID.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 09:09:31 -04:00
parent 5c0c0e5619
commit af1860debf
10 changed files with 1434 additions and 22 deletions
+22 -4
View File
@@ -320,6 +320,15 @@ public final class ClipboardStore {
/// does rather than earning a sub-rule for the one case where the items were already on this
/// board. It is cleared here rather than at V so the two staleness guards keep their meaning: a
/// paste the pasteboard moved under lands nothing, and so clears nothing.
///
/// **A paste is an import boundary, so normalization applies** (04 Clipboard, settled
/// 2026-07-28 01-storage-format.md's loose-file carve-out): every arrival below passes
/// `normalizingLooseFiles: true`, so a loose file the staged snapshot faithfully carried beside
/// a card's `index.md` lands inside the pasted card's `attachments/`, Finder-renamed on
/// collision. Both branches and both operations, unqualified, because 04's sentence is
/// unqualified. Nothing is dropped and nothing is announced: the snapshot preserved the file,
/// the paste kept it, and it is where the schema says it belongs the carve-out's notice is for
/// files the app moves *without* being asked, which is the loader's path, not this one.
private func perform(_ manifest: ClipboardManifest, plan: Plan, into store: BoardStore) {
refresh()
// The pasteboard moved under this paste (another app copied while the chain settled): the
@@ -338,10 +347,17 @@ public final class ClipboardStore {
operation: .move,
toLane: target.laneID,
at: target.index,
clearingTombstones: false
clearingTombstones: false,
normalizingLooseFiles: true
)
case let .lanes(index):
store.receiveLanes(sources, operation: .move, at: index, clearingTombstones: false)
store.receiveLanes(
sources,
operation: .move,
at: index,
clearingTombstones: false,
normalizingLooseFiles: true
)
}
consumeCut()
return
@@ -383,14 +399,16 @@ public final class ClipboardStore {
operation: .copy,
toLane: target.laneID,
at: target.index,
clearingTombstones: clearingTombstones
clearingTombstones: clearingTombstones,
normalizingLooseFiles: true
)
case let .lanes(index):
store.receiveLanes(
sources,
operation: .copy,
at: index,
clearingTombstones: clearingTombstones
clearingTombstones: clearingTombstones,
normalizingLooseFiles: true
)
}
store.banners.postDegradedPaste(losses)