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.
+185 -8
View File
@@ -147,6 +147,16 @@ public final class BoardStore {
/// describe the tree currently on screen.
public private(set) var loadWarnings: [LoadWarning]
/// The cards the load that produced `snapshot` found holding loose files, exactly as the loader
/// reported them the loose-file carve-out's detection channel (01-storage-format.md § Fractal
/// layout Rules, settled 2026-07-28). Replaced with the snapshot, like `loadWarnings`, so it
/// always describes the tree currently on screen.
///
/// **Nothing renders it.** A loose file is not content it reaches no view, and the card it
/// sits in draws exactly as it would without it. Its one consumer is
/// `relocateLooseCardFiles()`, immediately below the reload that produced it.
public private(set) var looseCardFiles: [LooseCardFiles]
/// The standing read-side condition: the error from the last reload that failed, `nil` when the
/// board is healthy. `BoardLoadError` already carries fail-fast's specifics the offending path
/// and what is wrong with it which is the whole of what the banner needs to render
@@ -302,6 +312,12 @@ public final class BoardStore {
@ObservationIgnored
private var quiescenceWaiters: [CheckedContinuation<Void, Never>] = []
/// The loose-file set the last relocation attempt was made against the loop guard
/// `relocateLooseCardFiles()` documents. Empty means "nothing has been attempted against the
/// current picture", which is both the opening state and what a clean board resets it to.
@ObservationIgnored
private var attemptedRelocation: Set<String> = []
/// Awaited off the main actor **after** a tree walk finishes and **before** its result is
/// applied the one seam this type keeps, `nil` in production.
///
@@ -327,11 +343,19 @@ public final class BoardStore {
///
/// The walk is synchronous because the caller has nothing to render until it lands; the
/// asynchronous, off-main pipeline starts with the first reload.
///
/// **It writes nothing, the opened board's loose files included.** `looseCardFiles` is recorded
/// here and acted on by whoever wired this store up `BoardStoreRegistry.acquire` calls
/// `relocateLooseCardFiles()` once the watcher and the brackets exist, so the relocation is a
/// bracketed write with a reload behind it rather than a write into a board nothing is watching
/// yet. A store built directly (a test, a storeless consumer) relocates when it is asked to, and
/// on every reload thereafter.
public init(rootURL: URL) throws(BoardLoadError) {
let result = try BoardLoader.load(boardRoot: rootURL)
self.rootURL = rootURL
self.snapshot = result.model
self.loadWarnings = result.warnings
self.looseCardFiles = result.looseCardFiles
self.reloadFailure = nil
self.readOnlyLock = nil
self.transient = TransientBoardState()
@@ -478,6 +502,7 @@ public final class BoardStore {
// Breakage always heals on a success it *is* the claim "the last reload failed", and
// this one did not.
reloadFailure = nil
looseCardFiles = result.looseCardFiles
clearLockIfDisproved(by: origin)
// The registry write-through, for the same "not board structure" reason the lock
// clearing sits out here: whether this board's row needs a new title, icon, or
@@ -485,6 +510,11 @@ public final class BoardStore {
// guard), not a decision this store makes by comparing against its own prior
// snapshot.
displayStateDelegate?()
// Last, and after `clearLockIfDisproved` deliberately: this is the seam the deferred
// relocation is armed on. A board that was locked read-only tolerated its loose files
// for exactly as long as the lock stood, and the reload that clears the lock is the
// reload that lets them move see `relocateLooseCardFiles()`.
relocateLooseCardFiles()
case let .failure(error):
// `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload
@@ -1455,7 +1485,14 @@ public final class BoardStore {
/// finest grain (01-storage-format.md's per-folder degradation, which is `moveItem`'s own
/// behaviour rather than something this method arranges).
public func receiveCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) {
receive(sources.map(ItemSource.folder), operation: operation, toLane: laneID, at: index, clearingTombstones: false)
receive(
sources.map(ItemSource.folder),
operation: operation,
toLane: laneID,
at: index,
clearingTombstones: false,
normalizingLooseFiles: false
)
}
/// **The clipboard's card arrival** `receiveCards`/`receiveRestoredCards` with the two axes a
@@ -1467,14 +1504,35 @@ public final class BoardStore {
/// is stripped **at materialization**". A cut is live-only (X is disabled in the trash), so the
/// two flags never both fire; the parameter is not narrowed for that, because which of them is
/// reachable is the *clipboard's* rule and this method's job is only to obey both.
///
/// `normalizingLooseFiles` is the third axis: **"a paste is an import boundary, so normalization
/// applies"** (04-interactions.md Clipboard, settled 2026-07-28 01-storage-format.md's
/// loose-file rule). Loose files the staged snapshot carries beside a card's `index.md` land in
/// the pasted card's `attachments/`, Finder-renamed on collision, so "nothing the snapshot
/// preserved is dropped on arrival" *and* nothing arrives out of place.
///
/// It has **no default**, here and on `receiveLanes`, so every arrival path states which side of
/// the import boundary it is on rather than inheriting an answer. The clipboard passes `true`
/// (both operations: 04 says "a paste is an import boundary" unqualified, and an armed cut's
/// move is a paste); the drag passes `false` and leaves its arrivals to the destination board's
/// own carve-out, which relocates on the next reload with the notice a user-initiated paste has
/// no need of.
public func receiveCards(
_ sources: [ItemSource],
operation: TransferOperation,
toLane laneID: ItemID,
at index: Int,
clearingTombstones: Bool
clearingTombstones: Bool,
normalizingLooseFiles: Bool
) {
receive(sources, operation: operation, toLane: laneID, at: index, clearingTombstones: clearingTombstones)
receive(
sources,
operation: operation,
toLane: laneID,
at: index,
clearingTombstones: clearingTombstones,
normalizingLooseFiles: normalizingLooseFiles
)
}
/// The cross-board half of drag-to-restore (04-interactions.md The trash): tombstoned rows
@@ -1495,7 +1553,14 @@ public final class BoardStore {
/// because `restoreItem` is already the one expression in the app for "remove the `deleted:`
/// key" the bytes are never rewritten any other way.
public func receiveRestoredCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) {
receive(sources.map(ItemSource.folder), operation: operation, toLane: laneID, at: index, clearingTombstones: true)
receive(
sources.map(ItemSource.folder),
operation: operation,
toLane: laneID,
at: index,
clearingTombstones: true,
normalizingLooseFiles: false
)
}
private func receive(
@@ -1503,7 +1568,8 @@ public final class BoardStore {
operation: TransferOperation,
toLane laneID: ItemID,
at index: Int,
clearingTombstones: Bool
clearingTombstones: Bool,
normalizingLooseFiles: Bool
) {
guard !sources.isEmpty,
let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted })
@@ -1535,8 +1601,14 @@ public final class BoardStore {
sourceBoardRoot: Self.boardRoot(ofCardFolder:),
order: rank
) else { continue }
let cardFolder = laneFolder.appendingPathComponent(arrived.rawValue, isDirectory: true)
// Inside the same bracket, so the card lands normalized in one round trip rather
// than appearing loose for a reload and being tidied afterwards.
if normalizingLooseFiles {
try BoardWriter.normalizeLooseFiles(inCard: cardFolder)
}
guard clearingTombstones else { continue }
try BoardWriter.restoreItem(at: laneFolder.appendingPathComponent(arrived.rawValue, isDirectory: true))
try BoardWriter.restoreItem(at: cardFolder)
}
}
}
@@ -1600,7 +1672,13 @@ public final class BoardStore {
/// not exist by drag at all ( is ignored on lane drags; the clipboard is that operation's one
/// home), so this method is cross-board by construction.
public func receiveLanes(_ sources: [URL], operation: TransferOperation, at stripIndex: Int) {
receiveLanes(sources.map(ItemSource.folder), operation: operation, at: stripIndex, clearingTombstones: false)
receiveLanes(
sources.map(ItemSource.folder),
operation: operation,
at: stripIndex,
clearingTombstones: false,
normalizingLooseFiles: false
)
}
/// **The clipboard's lane arrival** `receiveLanes` with the staging-less fallback and the
@@ -1614,11 +1692,16 @@ public final class BoardStore {
/// destination the lane-level twin of `receiveRestoredCards`, and the reason the strip runs
/// first is that the two writes touch different files and the strip's target list is the one that
/// must be read before anything is rewritten.
///
/// `normalizingLooseFiles` is the import boundary's, exactly as on `receiveCards` and with the
/// same no-default rule; at lane level it reaches each arriving lane's **cards**, which is the
/// only level the carve-out has (a lane's own loose files keep the verbatim posture).
public func receiveLanes(
_ sources: [ItemSource],
operation: TransferOperation,
at stripIndex: Int,
clearingTombstones: Bool
clearingTombstones: Bool,
normalizingLooseFiles: Bool
) {
guard !sources.isEmpty else { return }
@@ -1654,6 +1737,11 @@ public final class BoardStore {
if operation == .copy {
try BoardWriter.stripTombstonedChildren(of: laneFolder)
}
// After the strip, so a tombstoned card the copy is about to remove is not tidied
// on its way to being deleted.
if normalizingLooseFiles {
try BoardWriter.normalizeLooseFiles(inLane: laneFolder)
}
if clearingTombstones {
try BoardWriter.restoreItem(at: laneFolder)
}
@@ -1703,6 +1791,95 @@ public final class BoardStore {
}
}
// MARK: - The loose-file carve-out
/// Moves every loose file the last applied snapshot found beside a card's `index.md` into that
/// card's `attachments/`, and posts one notice naming what moved the **act** half of
/// 01-storage-format.md's loose-file carve-out (§ Fractal layout Rules, settled 2026-07-28,
/// "Lanework-owns-the-board"; the loader's `looseCardFiles` is the notice half).
///
/// **It is an ordinary app write and nothing more.** One `performWrite` bracket over the whole
/// board's worth of relocation, so the churn rounds back as a single app-mediated reload and (on
/// git boards) a single commit the style batch's rule, applied to a batch the app started
/// itself. The snapshot is not touched here any more than it is anywhere else: the files move,
/// the watcher notices, the reload lands.
///
/// ### The read-only lock defers it, it does not cancel it
///
/// "The relocation waits out any read-only lock strays stay tolerated until it clears." A
/// locked board returns here having written nothing **and having remembered nothing**, so the
/// next attempt is a fresh one. The arming seam is `land(_:generation:origin:)`: every lock
/// clears on a successful reload and nowhere else, and this runs at the end of every successful
/// reload, after `clearLockIfDisproved` so the reload that lifts the lock is the reload that
/// performs the relocation, with no timer, no queue, and no second state to keep in step.
///
/// ### It cannot hot-loop
///
/// The relocation's own reload re-walks the tree, which is the loop the guard exists for. After
/// a success the walk finds nothing loose, `looseCardFiles` empties, and the memo below is
/// cleared the ordinary resting state. After a *failure* the walk finds the same files again,
/// and an unguarded call would fail again, forever, at the speed of a directory walk. So an
/// attempt is made only when the loose-file set **differs from the last one attempted**: one
/// failure, one banner row, then silence until the picture on disk actually changes (a file
/// added, removed, or partially moved by the failed attempt itself each of which is a
/// different set and so a fresh attempt).
///
/// The failure is the banner's already: `performWrite` posts every `BoardWriteError` before it
/// rethrows, and the rethrow is swallowed here like every other gesture with nothing else to do
/// about it. Files moved before the failure stay moved, and the notice names exactly those.
public func relocateLooseCardFiles() {
let work = looseCardFiles
guard !work.isEmpty else {
// The resting state, and the memo's reset: a board with nothing loose has nothing to
// remember having tried.
attemptedRelocation = []
return
}
// Deferred, not abandoned and deliberately *before* the memo is written, so the attempt
// this lock refused is not the attempt the guard below remembers.
guard readOnlyLock == nil else {
Self.logger.debug("loose-file relocation deferred — the board is read-only")
return
}
let signature = Self.relocationSignature(of: work)
guard signature != attemptedRelocation else { return }
attemptedRelocation = signature
let root = rootURL
var relocated: [BannerCenter.Relocation] = []
try? performWrite { () throws(BoardWriteError) -> Void in
for card in work {
let folder = root
.appendingPathComponent(card.laneID.rawValue, isDirectory: true)
.appendingPathComponent(card.cardID.rawValue, isDirectory: true)
let moved = try BoardWriter.relocateLooseFiles(card.fileNames, inCard: folder)
// A card whose files all vanished under the write contributes no line: the Writer
// skipped them because they are gone, and nothing was moved to report.
guard !moved.isEmpty else { continue }
relocated.append(BannerCenter.Relocation(
title: card.title,
fileNames: moved.map { $0.sourceURL.lastPathComponent }
))
}
}
banners.postRelocatedLooseFiles(relocated)
}
/// The loose-file picture as a comparable value: one entry per file, keyed by where it sits.
///
/// A `Set` rather than the array itself because the *identity* of the work is what matters, not
/// the order the walk happened to meet it in and because two loads of an unchanged tree must
/// compare equal even if a lane's folder-name ordering shifted underneath them.
nonisolated static func relocationSignature(of work: [LooseCardFiles]) -> Set<String> {
var signature: Set<String> = []
for card in work {
for name in card.fileNames {
signature.insert("\(card.laneID.rawValue)/\(card.cardID.rawValue)/\(name)")
}
}
return signature
}
/// Creates one card per file at `index` in `laneID`, each titled with its filename minus the
/// extension and carrying that file as its attachment the drop-into-a-lane half.
///
+13
View File
@@ -204,6 +204,19 @@ public final class BoardStoreRegistry {
bookmark: bookmark,
lastKnownRoot: rootURL
)
// The loose-file carve-out's first firing (01-storage-format.md § Fractal layout Rules,
// settled 2026-07-28): files an agent or a hand-editor left beside a card's `index.md`
// while this board was closed are relocated into `attachments/` now, with the notice.
//
// **Here rather than in `BoardStore.init`**, and last rather than first: the store's own
// init is one tree walk and no writes, and a relocation written before the brackets and the
// watcher exist would be a write nothing is watching landing on disk with the snapshot
// above it left one reload stale. By this line the pair is wired, so it is an ordinary
// bracketed app write whose echo reload refreshes the board like any other. Every reload
// thereafter re-fires it from `BoardStore.land`; this call is only the one the opening walk
// would otherwise have no reload behind.
store.relocateLooseCardFiles()
return store
}