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:
@@ -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.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user