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
+84
View File
@@ -359,6 +359,44 @@ public final class BannerCenter {
postLoss(message)
}
/// One card whose loose files were relocated into `attachments/` what
/// `relocatedLooseFilesMessage(for:)` names.
///
/// `fileNames` are the names the files had **beside `index.md`**, not the Finder-renamed ones
/// they may have landed under: those are the names the user or their agent wrote, and the one
/// they would recognize in a sentence (`WriteOperation.importAttachment`'s own rule, read for
/// the relocation). `title` is the card's as written, `nil` for an untitled one "Untitled" is
/// a rendering, never a value (03-board-ui.md § Card face).
public struct Relocation: Sendable, Equatable {
public let title: String?
public let fileNames: [String]
public init(title: String?, fileNames: [String]) {
self.title = title
self.fileNames = fileNames
}
}
/// **The loose-file relocation** (01-storage-format.md § Fractal layout Rules, settled
/// 2026-07-28): a file was sitting beside a card's `index.md`, the app moved it into that card's
/// `attachments/`, and this is the row that says so "surfacing a graceful warning-tone notice
/// naming the card and files".
///
/// **A loss row, though nothing was lost.** The class is the vocabulary's warning-tone,
/// user-dismissed, never-expiring one `LossBanner`'s "their future kin" and this is exactly
/// that shape read once more: the app did something to the user's files that they did not ask
/// for, so it must be said out loud, it must not evaporate unread, and it must not rank as an
/// error, because no action failed. `signpost` would be too quiet (it ranks last and may
/// collapse behind "+N more"); `oneShot` would be a lie (it carries a `BoardWriteError`, and
/// the write succeeded). The name of the class is about its *lifecycle and tone*, not about
/// loss being the only thing it can report.
///
/// A relocation that moved nothing posts nothing.
public func postRelocatedLooseFiles(_ relocations: [Relocation]) {
guard let message = Self.relocatedLooseFilesMessage(for: relocations) 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
@@ -595,6 +633,14 @@ public final class BannerCenter {
"Couldn't read this card's attachments"
case .renumberChildren:
"Couldn't renumber cards"
case let .relocateLooseFile(filename):
// The verb matches the successful notice's ("Moved 'notes.txt' into attachments"), so
// the failure reads as the same sentence negated rather than as a different event.
// It stays in the ordinary one-shot precedence class rather than joining the attachment
// imports at the bottom: the relocation is work the *app* started on its own, and a
// failure the user did not provoke is exactly the one they have no other way to learn
// about.
"Couldn't move '\(filename)' into attachments"
}
}
@@ -679,6 +725,44 @@ public final class BannerCenter {
"Folders can't be attached — \(count) skipped"
}
/// The loose-file relocation's line 01-storage-format.md's own example sentence, "Moved
/// 'notes.txt' into attachments 'Fix login'", generalized over the two axes it varies on.
///
/// **Plurals fold twice**, which is the design's word for it ("plurals fold"; "multiple files
/// one card 'Moved 3 files into attachments Fix login'"):
///
/// - **One card, one file** names the file *and* the card, which is the sentence the design
/// wrote: both facts fit, so both are said.
/// - **One card, several files** drops the filenames for their count. A banner is one line, and
/// a list of names would be the first thing to truncate; the card is still named, which is
/// what makes the notice actionable the user knows exactly which `attachments/` to look in.
/// - **Several cards** folds again, to two counts: "Moved 5 files into attachments 3 cards"
/// (settled here, the judgment 01 leaves to the implementation). It is the degraded paste's
/// own shape "Pasted 2 items without their 5 files" and for its reason: the true total
/// plus the true item count is the honest summary where a truncated list of titles would not
/// be. This case is the whole-board sweep (a board opened after an agent scattered files
/// across it), where naming three cards of eleven would read as a bug.
///
/// The multi-card branch never has to spell a singular: two cards carry at least two files.
///
/// `nil` when nothing moved a relocation that relocated nothing is not news. Entries with no
/// filenames are dropped first, so a caller need not filter its own list.
public nonisolated static func relocatedLooseFilesMessage(for relocations: [Relocation]) -> String? {
let cards = relocations.filter { !$0.fileNames.isEmpty }
guard let only = cards.first else { return nil }
let total = cards.reduce(0) { $0 + $1.fileNames.count }
guard cards.count == 1 else {
return "Moved \(total) files into attachments — \(cards.count) cards"
}
let subject = only.title.map { "'\($0)'" } ?? "an untitled card"
guard total == 1, let name = only.fileNames.first else {
return "Moved \(total) files into attachments — \(subject)"
}
return "Moved '\(name)' into attachments — \(subject)"
}
/// The suspended-history line. It names the *consequence* the user cares about undo and the
/// flush-before-overwrite guarantee are degraded rather than the git mechanics, and carries
/// the diagnosis as its tail.