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
+1 -1
View File
@@ -15,7 +15,7 @@ Lane/card folder names are fixed literal lowercase-UUIDv4-shaped strings (never
| `rich-board.kanban` | A full-breadth well-formed board: 2 lanes, 3 cards, bodies, styling (background/icon/iconColor/width), unknown + reserved frontmatter keys, `attachments/` and `comments/` with real content. Its `attachments/` also carries all four listing shapes — two ordinary files, a hidden one, and a subfolder with a file — so `Card.attachments`' flat rule (01-storage-format.md § Attachments) is asserted against a real tree. Also the board every `index.md` in the tree is round-tripped against. |
| `interrupted-create.kanban` | The motivating skip-not-error case: a UUID-shaped lane folder and a UUID-shaped card folder, each with no `index.md` yet (folder created, write not yet landed). |
| `non-uuid-strays.kanban` | Non-UUID-shaped folders at both lane and card depth, with and without `index.md` — name shape gates candidacy before the file is ever read. |
| `stray-files.kanban` | Stray (non-directory) files at board, lane, and card level — never level candidates, never warned about. |
| `stray-files.kanban` | Stray (non-directory) files at board, lane, and card level — never level candidates, never warned about. The **card-level** one (`scratch.md`) is also the loose-file carve-out's golden case: tolerated everywhere else, it is reported in `LoadResult.looseCardFiles` for the app to relocate into `attachments/` (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28). Detection is read-only, so the file stays put on disk. |
| `tombstones.kanban` | A tombstoned lane and a tombstoned card, both still on disk and still in the snapshot, flagged (`isDeleted`) rather than removed. Also proves a tombstoned lane doesn't recursively flag its own un-deleted children. |
| `duplicate-order-tie-break.kanban` | Three cards sharing one `order` in one lane, and two lanes sharing one `order` — both broken by folder name, ascending. |
| `unknown-key-order.kanban` | Unknown/reserved frontmatter keys interleaved with schema-owned ones at board, lane, and card level — `document.unknownFields` must preserve exactly the order they were written in. |
+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)
+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
}
+142 -7
View File
@@ -24,12 +24,21 @@ import os
/// shape rule `attachments` and `comments` are non-UUID-shaped and would read as strays, not
/// levels, so they never need special-casing against the stray warning.
///
/// **The one read inside a card folder** is `attachmentNames(in:)`: a single flat listing of
/// `attachments/`, feeding `Card.attachments`. It is a *names* read and nothing more it never
/// opens a file, never descends, never warns, and degrades to `[]` on any failure. The board
/// window's face needs it before a card window exists (the quiet paperclip indicator
/// 03-board-ui.md § Card face), and the snapshot is where it reads from. Everything else about a
/// card folder's contents remains outside this loader's business.
/// **Two reads inside a card folder**, both of them flat name listings and nothing more neither
/// opens a file, descends, warns, or fails a load; each degrades to `[]`:
///
/// - `attachmentNames(in:)` `attachments/`, feeding `Card.attachments`. The board window's face
/// needs it before a card window exists (the quiet paperclip indicator 03-board-ui.md § Card
/// face), and the snapshot is where it reads from.
/// - `looseFileNames(in:)` the card folder *itself*, feeding `LoadResult.looseCardFiles`. This is
/// the loose-file carve-out's **detection** half (01-storage-format.md § Fractal layout Rules,
/// settled 2026-07-28): a regular file sitting beside a card's `index.md` belongs in
/// `attachments/`, and the app relocates it. Detection stays read-only *here* this loader is a
/// pure function of the tree and writes nothing, ever (the Repair precedent); the relocation is a
/// Writer-mediated app write the store schedules off the snapshot
/// (`BoardStore.relocateLooseCardFiles`).
///
/// Everything else about a card folder's contents remains outside this loader's business.
///
/// Symlinks: a lane/card candidate that is itself a symlink is treated as a stray and never
/// followed, whether it points to a file or a directory this loader does not resolve
@@ -52,6 +61,22 @@ public enum BoardLoader: Sendable {
/// the writer must never disagree about which file a folder's content lives in.
static let indexFileName = "index.md"
/// The card-level names the app claims, and therefore the three the loose-file carve-out
/// never touches (01-storage-format.md § Fractal layout Rules: "Reserved card-level names
/// untouched"): the card's own `index.md` plus the two reserved children. `comments` is
/// listed because the schema reserves the name, not because anything writes it yet
/// `attachments/` is still the one folder this app ever creates under a card.
///
/// **Compared lowercased**, because the filesystem this runs on usually is: a file spelled
/// `Index.md` *is* the card's index to `fileExists`, and a case-sensitive reservation check
/// would hand the loose-file relocation a card's own content to move into `attachments/`.
///
/// Internal rather than `private`: `BoardWriter.relocateLooseFiles` refuses the same three
/// names on its own, so a caller passing a hand-made list cannot reach past this rule.
static let reservedCardChildNames: Set<String> = [
indexFileName, BoardWriter.attachmentsFolderName, "comments",
]
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader")
// MARK: - Entry point
@@ -72,6 +97,10 @@ public enum BoardLoader: Sendable {
logger.warning("\(warning.description, privacy: .public)")
}
// The carve-out's detection channel deliberately *not* `warnings`, which is the
// stray-*tolerance* vocabulary (see `LoadResult.looseCardFiles`).
var looseCardFiles: [LooseCardFiles] = []
// Legal per the frontmatter table, meaningless at board level ignore and log, never
// tombstone (01-storage-format.md § Deletion).
if !boardDocument.deleted.isMissing {
@@ -113,6 +142,18 @@ public enum BoardLoader: Sendable {
let cardSchema = try validatedSchema(in: cardDocument, path: cardPath)
let cardOrder = try validatedOrder(in: cardDocument, path: cardPath)
// Noticed, never acted on: the relocation is the store's, through the Writer.
let loose = looseFileNames(in: cardURL)
if !loose.isEmpty {
looseCardFiles.append(LooseCardFiles(
laneID: ItemID(rawValue: laneName),
cardID: ItemID(rawValue: cardName),
title: cardDocument.title.value,
fileNames: loose
))
logger.info("\(cardRelPath, privacy: .public): \(loose.count, privacy: .public) loose file(s) beside index.md — to be relocated into attachments/")
}
cards.append(Card(
id: ItemID(rawValue: cardName),
schema: cardSchema,
@@ -164,7 +205,7 @@ public enum BoardLoader: Sendable {
document: boardDocument
)
return LoadResult(model: model, warnings: warnings)
return LoadResult(model: model, warnings: warnings, looseCardFiles: looseCardFiles)
}
// MARK: - Filesystem helpers
@@ -229,6 +270,57 @@ public enum BoardLoader: Sendable {
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}
/// A card folder's **loose top-level files** the one carve-out to uniform stray tolerance
/// (01-storage-format.md § Fractal layout Rules, settled 2026-07-28, "Lanework-owns-the-board"):
/// "a regular file sitting beside a card's `index.md` (not `attachments/`, not a reserved name)
/// belongs in `attachments/`, and the app moves it there".
///
/// **This function only notices.** It opens nothing, moves nothing, and creates nothing; the
/// relocation is `BoardWriter.relocateLooseFiles`, run through the store's write bracket. A load
/// is a pure function of the tree and stays one.
///
/// Four exclusions, three of them `attachmentNames(in:)`' own and for its reasons:
///
/// - **Directories.** The carve-out is exactly *files*. A stray folder in a card a nested
/// clone, a hand-made subfolder keeps the verbatim posture, because "relocating a directory
/// into the flat attachment model would be wrong".
/// - **Symlinks**, never touched and never traversed (§ Rules) the same stance the level walk
/// takes. `isSymbolicLink` is checked *beside* `isRegularFile` rather than trusted to imply
/// it, exactly as `directoryCandidates` does, so a link pointing at a file is excluded on its
/// own account.
/// - **Hidden entries.** `.DS_Store` and friends are not the user's files, and relocating one
/// would surface it in a card's attachment list the loudest possible way to be wrong about
/// a file nobody wrote on purpose. It is also what keeps a crashed write's dot-prefixed
/// residue out of the relocation.
/// - **The reserved card-level names** (`reservedCardChildNames`), case-insensitively.
///
/// Finder order (`localizedStandardCompare`), like every other name listing here, so the notice
/// the store posts names files the way the board would sort them.
///
/// Failure is silent (`[]`): a permissions race here must never be the reason a board refuses
/// to open, and "nothing to relocate" is the safe reading of "cannot tell".
static func looseFileNames(in cardFolder: URL) -> [String] {
guard let entries = try? FileManager.default.contentsOfDirectory(
at: cardFolder,
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
options: [.skipsHiddenFiles]
) else {
return []
}
return entries
.filter { url in
guard !reservedCardChildNames.contains(url.lastPathComponent.lowercased()),
let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
else {
return false
}
return values.isRegularFile == true && values.isSymbolicLink != true
}
.map(\.lastPathComponent)
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}
/// The hex characters `isUUIDShaped` accepts in each `-`-delimited group **both cases**,
/// per the shape-only identity predicate below.
private static let uuidGroupCharacters = Set("0123456789abcdefABCDEF")
@@ -366,6 +458,49 @@ public enum BoardLoader: Sendable {
public struct LoadResult: Sendable {
public var model: BoardModel
public var warnings: [LoadWarning]
/// The cards this walk found carrying loose files, in the order the walk met them the
/// loose-file carve-out's detection channel (01-storage-format.md § Fractal layout Rules,
/// settled 2026-07-28).
///
/// **Its own field rather than a `LoadWarning` case**, because the two say opposite things.
/// `warnings` is the *stray-tolerance* vocabulary: "this was ignored, it is staying exactly
/// where it is, there is nothing to do". A loose card file is the one thing on a board that is
/// **not** tolerated it is pending work, and the store acts on it. Folding it into the
/// warning channel would also mean throwing away everything the act needs (which lane, which
/// card, which title, which names) and re-deriving it from a display string.
///
/// Nothing renders this: a loose file is not content, and it reaches no view. Its one consumer
/// is `BoardStore.relocateLooseCardFiles()`, which relocates and posts the notice.
///
/// Tombstoned cards are included, and cards under tombstoned lanes with them. Where a file
/// belongs on disk is a question about the *tree*, not about what the board is currently
/// rendering the same reason the loader flags a tombstoned card at all rather than dropping
/// it.
public var looseCardFiles: [LooseCardFiles] = []
}
/// One card found holding files that belong in its `attachments/` everything the relocation and
/// its notice need, and nothing more.
///
/// `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), so the phrasing layer decides what to call it. The path is
/// carried as its two identity components rather than as a URL, `BoardStore.liveItem`'s convention,
/// so the write derives its path from the store's *current* root.
public struct LooseCardFiles: Sendable, Equatable {
public let laneID: ItemID
public let cardID: ItemID
public let title: String?
/// The loose files' names, in Finder order (`BoardLoader.looseFileNames`). Never empty a card
/// with nothing loose contributes no entry at all.
public let fileNames: [String]
public init(laneID: ItemID, cardID: ItemID, title: String?, fileNames: [String]) {
self.laneID = laneID
self.cardID = cardID
self.title = title
self.fileNames = fileNames
}
}
/// A tolerated anomaly the loader kept going past. Never blocks a load see `BoardLoadError`
+175 -1
View File
@@ -1080,6 +1080,168 @@ public enum BoardWriter: Sendable {
return landed
}
/// Moves loose files out of a card folder and into its `attachments/` the **write half** of
/// 01-storage-format.md's loose-file carve-out (§ Fractal layout Rules, settled 2026-07-28,
/// "Lanework-owns-the-board"): "a regular file sitting beside a card's `index.md` belongs in
/// `attachments/`, and the app moves it there Finder-style rename on collision".
///
/// **A move, not a copy** the file is not being imported from somewhere else, it is being put
/// where it already belonged, and leaving a second copy beside `index.md` would leave the very
/// thing this call exists to clear. `FileManager.moveItem` within one folder is a `rename(2)`:
/// atomic, and byte-preserving by not touching bytes at all.
///
/// **`index.md` is never opened.** Relocating a stray says nothing about the card's content, so
/// no `modified` stamp is written and no frontmatter is read which is also why a card whose
/// frontmatter is uneditable (a flow mapping) still gets its files tidied.
///
/// `names` is the caller's list the loader's `looseFileNames` at the store, or this file's own
/// `normalizeLooseFiles(inCard:)` at the import boundary and **every name is re-checked
/// against disk before it is touched** (`isRelocatable`). A name that has stopped being a plain
/// non-hidden regular file since it was listed, or that names a reserved child, or that is not a
/// bare filename at all, is **skipped silently**: the reload is the authority on what is there,
/// and a file the user deleted between the walk and the write is not a failure to report. That
/// re-check is also what makes the rule "folders and symlinks are never relocated" a property of
/// this call rather than of its callers.
///
/// The batch is `importAttachments`' shape exactly: in order, one finished move at a time, the
/// first failure stopping it and throwing while everything already moved stays moved. Returns
/// what actually landed, in input order `sourceURL` naming the file where it sat, `fileName`
/// the (possibly Finder-renamed) name it took inside `attachments/`.
///
/// `attachments/` is created only when something is actually going to move into it, so a card
/// whose loose files all vanished under the write is left exactly as it was no empty folder
/// minted for nothing.
///
/// **`cardFolder` must really be a card** (`checkIsCardFolder`, which is stricter than the
/// UUID-shape guard the rest of this file uses): a lane's own loose files keep the verbatim
/// posture, and no other write in the app has to tell the two levels apart.
@discardableResult
public static func relocateLooseFiles(
_ names: [String],
inCard cardFolder: URL
) throws(BoardWriteError) -> [ImportedAttachment] {
guard !names.isEmpty else { return [] }
// Before the per-file loop starts no single file is implicated yet the first name stands
// in for the batch, exactly as `importAttachments` lets its first source name it.
let batchOperation = WriteOperation.relocateLooseFile(filename: names[0])
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: batchOperation)
try checkIsCardFolder(cardFolder, operation: batchOperation)
let relocatable = names.filter { isRelocatable($0, in: cardFolder) }
guard !relocatable.isEmpty else { return [] }
let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
do {
try FileManager.default.createDirectory(at: attachmentsFolder, withIntermediateDirectories: true)
} catch {
throw BoardWriteError(
operation: batchOperation,
path: cardFolder.path,
reason: .io(message: "could not create attachments folder: \(error.localizedDescription)")
)
}
var moved: [ImportedAttachment] = []
for name in relocatable {
// Each file names itself the ORIGINAL name, not the Finder-style renamed one decided
// on the next line, for `importAttachments`' reason: the operation describes the file
// the user (or their agent) actually wrote.
let operation = WriteOperation.relocateLooseFile(filename: name)
let sourceURL = cardFolder.appendingPathComponent(name)
let landed = freshAttachmentName(for: name, in: attachmentsFolder)
do {
try FileManager.default.moveItem(at: sourceURL, to: attachmentsFolder.appendingPathComponent(landed))
} catch {
throw BoardWriteError(
operation: operation,
path: sourceURL.path,
reason: .io(message: "could not move file into attachments: \(error.localizedDescription)")
)
}
moved.append(ImportedAttachment(sourceURL: sourceURL, fileName: landed))
}
return moved
}
/// Discovers *and* relocates the loose-file rule applied at an **import boundary**, where
/// there is no loader round trip to discover through (04-interactions.md Clipboard, settled
/// 2026-07-28: "A paste is an import boundary, so normalization applies loose files the staged
/// snapshot carries beside a card's `index.md` land in the pasted card's `attachments/`,
/// Finder-renamed on collision nothing the snapshot preserved is dropped on arrival").
///
/// The pasted card therefore lands **already normalized**, rather than arriving loose and being
/// tidied a reload later: the write is happening anyway, and one that leaves work behind for the
/// carve-out to find would also post the carve-out's notice a warning row about a mess the
/// user's own paste made and the app immediately cleaned up.
///
/// A card with nothing loose is one directory listing and no write at all.
@discardableResult
public static func normalizeLooseFiles(inCard cardFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] {
try relocateLooseFiles(BoardLoader.looseFileNames(in: cardFolder), inCard: cardFolder)
}
/// The lane-level face of the same import-boundary normalization: every card of an arriving
/// lane, in folder order.
///
/// Children are `childCandidates` the loader's own level detection so a stray *folder*
/// inside the arriving lane is neither descended into nor tidied, and nothing below a card is
/// reached: the carve-out is card-level and one level deep, exactly as 01 states it.
@discardableResult
public static func normalizeLooseFiles(inLane laneFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] {
var moved: [ImportedAttachment] = []
for card in childCandidates(of: laneFolder) {
moved.append(contentsOf: try normalizeLooseFiles(inCard: card))
}
return moved
}
/// Refuses any folder that is not a **card**: UUID-shaped, *under* a UUID-shaped parent.
///
/// `checkIsUUIDShaped` is the guard every other item write leans on, and it is the wrong one
/// here because it cannot tell a lane from a card both are UUID-shaped, which is exactly the
/// distinction the carve-out turns on ("everything at board or lane level keeps the verbatim
/// posture"; "board/lane-level strays are legitimate residents"). Pointing the relocation at a
/// lane would sweep a hand-editor's `notes.txt` into an `attachments/` folder no lane should
/// ever have.
///
/// The parent test is exact rather than heuristic because 01-storage-format.md § Fractal layout
/// fixes the depth: a card is `<root>/<lane>/<card>` and a lane is `<root>/<lane>`, so a
/// UUID-shaped folder whose parent is *also* UUID-shaped is a card and nothing else. It is the
/// same reading `BoardStore.boardRoot(ofCardFolder:)` already derives a root from.
private static func checkIsCardFolder(_ folder: URL, operation: WriteOperation) throws(BoardWriteError) {
try checkIsUUIDShaped(folder, operation: operation)
guard BoardLoader.isUUIDShaped(folder.deletingLastPathComponent().lastPathComponent) else {
throw BoardWriteError(
operation: operation,
path: folder.path,
reason: .unreadable(message: "folder is not a card: only a card's own files are relocated")
)
}
}
/// Whether `name` inside `cardFolder` is a file this app may relocate: a bare filename (never a
/// path), not hidden, not one of the reserved card-level names, and read from disk, at write
/// time a regular file that is not a symlink.
///
/// The four name rules restate the loader's listing exclusions rather than trusting them,
/// because `relocateLooseFiles` takes a caller's list: this is where "the carve-out is exactly
/// that narrow" stops being a convention and becomes something the filesystem-touching code
/// enforces on its own.
private static func isRelocatable(_ name: String, in cardFolder: URL) -> Bool {
guard !name.isEmpty,
!name.hasPrefix("."),
!name.contains("/"),
!BoardLoader.reservedCardChildNames.contains(name.lowercased()),
let values = try? cardFolder
.appendingPathComponent(name)
.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
else {
return false
}
return values.isRegularFile == true && values.isSymbolicLink != true
}
/// The Finder-style collision-free name for `originalName` landing in `folder`: the name
/// itself when nothing on disk claims it yet, else the base name suffixed `" 2"`, `" 3"`,
/// counting up from 2 against what is on disk *at decision time*, one collision at a time.
@@ -1324,6 +1486,16 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case listAttachments
case renumberChildren // order-maintenance sweep (compaction)
/// A loose file being moved out of a card folder into its `attachments/` the loose-file
/// carve-out's write (01-storage-format.md § Fractal layout Rules, settled 2026-07-28).
///
/// Its own case rather than a fold into `.importAttachment`, on `.rename`'s and
/// `.duplicateBoard`'s reasoning: nothing was *imported* no file crossed into the board, the
/// user dropped nothing, and a banner saying the app "couldn't import 'notes.txt'" would
/// describe a gesture that never happened. `filename` is the name as it sat beside `index.md`,
/// never the Finder-renamed one it would have landed under.
case relocateLooseFile(filename: String)
/// Fills in the title once the Writer has read it off the document the operation is acting
/// on identity for the six cases with no title slot at all: `createBoard`/`createLane`/
/// `createCard` are minting a file, not reading one; `importAttachment`'s "title" is the
@@ -1335,7 +1507,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
/// case is immutable once a caller has it in hand there is nothing to "forget" later.
public func withTitle(_ title: String?) -> WriteOperation {
switch self {
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments, .renumberChildren:
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
.renumberChildren, .relocateLooseFile:
self
case .move: .move(title: title)
case .reorder: .reorder(title: title)
@@ -1374,6 +1547,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .importAttachment(filename): "import attachment '\(filename)'"
case .listAttachments: "list attachments"
case .renumberChildren: "renumber children"
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
}
}
+33
View File
@@ -223,6 +223,39 @@ struct FixtureStrayFilesTests {
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card])
}
/// The board's card-level `scratch.md` is the **one** stray this fixture holds that is not
/// tolerated: the loose-file carve-out (01-storage-format.md § Fractal layout Rules, settled
/// 2026-07-28) says a regular file beside a card's `index.md` belongs in `attachments/`. It is
/// reported on its own channel never as a `warning`, which is the *tolerance* vocabulary
/// and the board-level and lane-level strays around it stay exactly as tolerated as they were.
///
/// **Detection does not mutate**: this is the loader, over a fixture that lives in git, and the
/// assertion that the file is still there afterwards is the read-only claim stated on the one
/// tree where a stray write would be visible in `git status`.
@Test func aCardLevelLooseFileIsReportedForRelocationWithoutBeingTouched() throws {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "20000000-0000-4000-8000-000000000002"
let result = try loadFixture("Valid/stray-files.kanban")
#expect(result.warnings.isEmpty)
#expect(result.looseCardFiles == [
LooseCardFiles(
laneID: ItemID(rawValue: lane),
cardID: ItemID(rawValue: card),
title: result.model.lanes[0].cards[0].title.value,
fileNames: ["scratch.md"]
),
])
let scratch = fixtureBoard("Valid/stray-files.kanban")
.appendingPathComponent("\(lane)/\(card)/scratch.md")
#expect(FileManager.default.fileExists(atPath: scratch.path))
#expect(!FileManager.default.fileExists(
atPath: fixtureBoard("Valid/stray-files.kanban")
.appendingPathComponent("\(lane)/\(card)/attachments").path
))
}
}
// MARK: - Valid/tombstones.kanban
+778
View File
@@ -0,0 +1,778 @@
import Foundation
import Testing
@testable import Kanban
/// The loose-file carve-out, end to end (01-storage-format.md § Fractal layout Rules, settled
/// 2026-07-28, "Lanework-owns-the-board"; 04-interactions.md Clipboard for the paste boundary).
///
/// The rule is one sentence with four halves, and this file is organized as those four:
///
/// 1. **The loader notices, and only notices** a regular file beside a card's `index.md` is
/// reported on its own channel, and the walk that reported it changed nothing on disk.
/// 2. **The Writer moves it** into `attachments/`, Finder-renamed on collision, byte-faithfully,
/// without opening `index.md`.
/// 3. **The store schedules that write** one bracket, one loss row, deferred under the read-only
/// lock, and never hot-looping on a failure.
/// 4. **A paste normalizes at the boundary** the pasted card lands already tidy.
///
/// Like every other write suite here these read back through the loader or through raw bytes, never
/// through a snapshot the store handed out: the claims are about the files. `WriterFixture`, `Ident`
/// and `Item` come from `WriterTestSupport.swift`; `FakePasteboard` and `ClipboardHarness` from
/// `ClipboardTests.swift`.
// MARK: - Shared fixtures
/// A one-lane, one-card board, ready for whatever the test wants to leave beside `index.md`.
private func makeCardBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
return fixture
}
private let cardPath = "\(Ident.lane1)/\(Ident.card1)"
private func cardFolder(in fixture: WriterFixture) -> URL {
fixture.url(cardPath)
}
/// The loader's own reading of the tree the detection channel, read fresh from disk.
private func looseFiles(in fixture: WriterFixture) throws -> [LooseCardFiles] {
try BoardLoader.load(boardRoot: fixture.root).looseCardFiles
}
/// A file's bytes and mtime "this file was not rewritten", stated the way `WriteFidelityTests`
/// states it.
private func stat(_ url: URL) throws -> (bytes: Data, modified: Date) {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
guard let modified = attributes[.modificationDate] as? Date else {
throw NSError(domain: "LooseFileRelocationTests", code: 1)
}
return (try Data(contentsOf: url), modified)
}
private func symlink(_ name: String, to destination: String, in folder: URL) throws {
try FileManager.default.createSymbolicLink(
atPath: folder.appendingPathComponent(name).path,
withDestinationPath: destination
)
}
// MARK: - 1. Detection (read-only, in the loader)
@Suite("Loose files ▸ detection")
struct LooseFileDetectionTests {
@Test("A regular file beside a card's index.md is reported — and nothing else is")
func looseFileIsReported() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8))
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.looseCardFiles == [
LooseCardFiles(
laneID: ItemID(rawValue: Ident.lane1),
cardID: ItemID(rawValue: Ident.card1),
title: "Fix login",
fileNames: ["notes.txt"]
),
])
// It is not a *stray* the tolerance vocabulary says nothing about it either way.
#expect(result.warnings.isEmpty)
}
/// Detection is read-only: "the Repair precedent". The walk that noticed the file must leave it
/// exactly where it was and must not mint the `attachments/` it is destined for.
@Test("Loading does not move the file, and does not create attachments/")
func detectionMutatesNothing() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
let loose = try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8))
let before = try stat(loose)
_ = try BoardLoader.load(boardRoot: fixture.root)
_ = try BoardLoader.load(boardRoot: fixture.root)
let after = try stat(loose)
#expect(after.bytes == before.bytes)
#expect(after.modified == before.modified)
#expect(!fixture.exists("\(cardPath)/attachments"))
}
/// "The carve-out is exactly that narrow." A stray *folder* in a card keeps the verbatim
/// posture relocating a directory into the flat attachment model would be wrong whether it
/// is UUID-shaped (a nested clone) or not (a hand-made subfolder).
@Test("Stray folders in a card are not loose files")
func strayFoldersAreNotFlagged() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
try fixture.file("\(cardPath)/scratch/inside.txt", Data("nested".utf8))
try fixture.item("\(cardPath)/\(Ident.card2)", Item.rich(order: "1024", title: "Nested clone"))
#expect(try looseFiles(in: fixture).isEmpty)
}
/// Symlinks are never touched and never traversed (§ Rules) including one whose name would
/// otherwise read as an ordinary loose file, and one pointing at a directory.
@Test("Symlinks beside index.md are never loose files")
func symlinksAreNotFlagged() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
try fixture.file("\(cardPath)/attachments/real.png", Data("png".utf8))
try symlink("notes.txt", to: "attachments/real.png", in: cardFolder(in: fixture))
try symlink("elsewhere", to: "attachments", in: cardFolder(in: fixture))
#expect(try looseFiles(in: fixture).isEmpty)
}
/// "Everything at board or lane level keeps the verbatim posture" `CLAUDE.user.md` and a
/// hand-made `notes/` folder are legitimate residents.
@Test("Board-level and lane-level files are untouched strays, not loose files")
func boardAndLaneLevelFilesAreNotFlagged() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
try fixture.file("CLAUDE.user.md", Data("mine".utf8))
try fixture.file("\(Ident.lane1)/notes.txt", Data("lane".utf8))
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.looseCardFiles.isEmpty)
#expect(result.warnings.isEmpty)
}
/// "Reserved card-level names (`attachments/`, `comments/`, `index.md`) untouched" and the
/// reservation is case-insensitive, because on this filesystem `Index.md` *is* the card's index
/// and relocating it would move a card's content into its own attachments.
@Test("index.md, attachments/, comments/ and their case-spellings are never loose")
func reservedNamesAreNotFlagged() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
try fixture.file("\(cardPath)/attachments/real.png", Data("png".utf8))
try fixture.file("\(cardPath)/comments/note.md", Data("comment".utf8))
#expect(try looseFiles(in: fixture).isEmpty)
// A *file* by a reserved name is malformed rather than loose: still not relocated.
let bare = try WriterFixture()
defer { bare.tearDown() }
try bare.item("", Item.board)
try bare.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try bare.item(cardPath, Item.rich(order: "1024", title: "Fix login"))
try bare.file("\(cardPath)/attachments", Data("not a folder".utf8))
try bare.file("\(cardPath)/comments", Data("not a folder either".utf8))
#expect(try looseFiles(in: bare).isEmpty)
}
/// `.DS_Store` and friends are not the user's files; relocating one would surface it as an
/// attachment, which is the loudest possible way to be wrong about a file nobody wrote.
@Test("Hidden files are not loose files")
func hiddenFilesAreNotFlagged() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
try fixture.file("\(cardPath)/.DS_Store", Data("finder".utf8))
try fixture.file("\(cardPath)/.hidden-draft.md", Data("hidden".utf8))
#expect(try looseFiles(in: fixture).isEmpty)
}
@Test("Several files on several cards come back in Finder order, one entry per card")
func severalFilesAndCards() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.file("\(cardPath)/shot 10.png", Data("ten".utf8))
try fixture.file("\(cardPath)/shot 2.png", Data("two".utf8))
try fixture.file("\(Ident.lane1)/\(Ident.card2)/notes.txt", Data("notes".utf8))
let found = try looseFiles(in: fixture)
#expect(found.count == 2)
#expect(found.first?.fileNames == ["shot 2.png", "shot 10.png"])
#expect(found.last?.fileNames == ["notes.txt"])
#expect(found.last?.title == "Second")
}
/// A tombstoned card's files are the tree's business too: where a file belongs on disk is not a
/// question about what the board is currently rendering.
@Test("A tombstoned card's loose file is still reported")
func tombstonedCardIsStillReported() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
try fixture.item("\(Ident.lane1)/\(Ident.card2)", """
---
schema: 1
title: Trashed
order: 2048
deleted: 2026-03-03T09:00:00Z
---
""")
try fixture.file("\(Ident.lane1)/\(Ident.card2)/notes.txt", Data("notes".utf8))
#expect(try looseFiles(in: fixture).map(\.title) == ["Trashed"])
}
}
// MARK: - 2. The write (BoardWriter)
@Suite("Loose files ▸ the relocation write")
struct LooseFileWriteTests {
@Test("The file moves into attachments/, byte for byte, and index.md is not touched")
func movesByteFaithfullyWithoutTouchingIndex() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
let bytes = Data([0x00, 0xFF, 0x10, 0x89, 0x50, 0x4E, 0x47])
try fixture.file("\(cardPath)/photo.png", bytes)
let indexBefore = try stat(cardFolder(in: fixture).appendingPathComponent("index.md"))
let moved = try BoardWriter.relocateLooseFiles(["photo.png"], inCard: cardFolder(in: fixture))
#expect(moved.map(\.fileName) == ["photo.png"])
#expect(try fixture.data("\(cardPath)/attachments/photo.png") == bytes)
#expect(!fixture.exists("\(cardPath)/photo.png"))
// A relocation says nothing about the card's content: no `modified` stamp, no rewrite.
let indexAfter = try stat(cardFolder(in: fixture).appendingPathComponent("index.md"))
#expect(indexAfter.bytes == indexBefore.bytes)
#expect(indexAfter.modified == indexBefore.modified)
}
@Test("A name already taken in attachments/ is Finder-renamed, never overwritten")
func collisionIsFinderRenamed() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
try fixture.file("\(cardPath)/attachments/notes.txt", Data("the attachment".utf8))
try fixture.file("\(cardPath)/attachments/notes 2.txt", Data("the second".utf8))
try fixture.file("\(cardPath)/notes.txt", Data("the loose one".utf8))
let moved = try BoardWriter.relocateLooseFiles(["notes.txt"], inCard: cardFolder(in: fixture))
#expect(moved.map(\.fileName) == ["notes 3.txt"])
// The reported source keeps the ORIGINAL name the one the user wrote.
#expect(moved.map { $0.sourceURL.lastPathComponent } == ["notes.txt"])
#expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("the attachment".utf8))
#expect(try fixture.data("\(cardPath)/attachments/notes 2.txt") == Data("the second".utf8))
#expect(try fixture.data("\(cardPath)/attachments/notes 3.txt") == Data("the loose one".utf8))
}
/// The Writer re-checks every name against disk, so the narrowness of the carve-out is enforced
/// where the filesystem is touched rather than trusted to the caller's list.
@Test("Folders, symlinks, hidden and reserved names are skipped even when named explicitly")
func nonRelocatableNamesAreSkipped() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
try fixture.file("\(cardPath)/scratch/inside.txt", Data("nested".utf8))
try fixture.file("\(cardPath)/attachments/real.png", Data("png".utf8))
try fixture.file("\(cardPath)/.DS_Store", Data("finder".utf8))
try symlink("link.txt", to: "attachments/real.png", in: cardFolder(in: fixture))
let moved = try BoardWriter.relocateLooseFiles(
["scratch", "link.txt", ".DS_Store", "index.md", "attachments", "comments", "gone.txt", "../escape.txt"],
inCard: cardFolder(in: fixture)
)
#expect(moved.isEmpty)
#expect(fixture.exists("\(cardPath)/scratch"))
#expect(fixture.exists("\(cardPath)/.DS_Store"))
#expect(fixture.exists("\(cardPath)/index.md"))
#expect(try fixture.entryNames("\(cardPath)/attachments") == ["real.png"])
let link = try FileManager.default
.attributesOfItem(atPath: cardFolder(in: fixture).appendingPathComponent("link.txt").path)
#expect(link[.type] as? FileAttributeType == .typeSymbolicLink)
}
/// Nothing to move means nothing is made: a card whose loose files all vanished under the write
/// is left exactly as it was, with no empty `attachments/` minted for it.
@Test("attachments/ is not created when nothing is relocatable")
func noEmptyAttachmentsFolder() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
#expect(try BoardWriter.relocateLooseFiles(["gone.txt"], inCard: cardFolder(in: fixture)).isEmpty)
#expect(try BoardWriter.relocateLooseFiles([], inCard: cardFolder(in: fixture)).isEmpty)
#expect(!fixture.exists("\(cardPath)/attachments"))
}
/// The carve-out is card-level, so the guard has to be card-level too: a lane is UUID-shaped
/// exactly like a card, and only its *parent* tells them apart. A lane's `notes.txt` and a
/// board's `CLAUDE.user.md` are legitimate residents.
@Test("A lane folder or a board root is refused")
func onlyCardsAreValidTargets() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
try fixture.file("\(Ident.lane1)/notes.txt", Data("lane".utf8))
try fixture.file("notes.txt", Data("board".utf8))
let laneFailure = writeFailure {
_ = try BoardWriter.relocateLooseFiles(["notes.txt"], inCard: fixture.url(Ident.lane1))
}
#expect(laneFailure?.operation == .relocateLooseFile(filename: "notes.txt"))
#expect(fixture.exists("\(Ident.lane1)/notes.txt"))
// The board root's folder name is never UUID-shaped, so the same guard covers it.
#expect(writeFailure {
_ = try BoardWriter.relocateLooseFiles(["notes.txt"], inCard: fixture.root)
} != nil)
#expect(fixture.exists("notes.txt"))
}
@Test("normalizeLooseFiles discovers the card's own loose files")
func normalizeDiscoversForItself() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
try fixture.file("\(cardPath)/a.txt", Data("a".utf8))
try fixture.file("\(cardPath)/b.txt", Data("b".utf8))
try fixture.file("\(cardPath)/scratch/inside.txt", Data("nested".utf8))
let moved = try BoardWriter.normalizeLooseFiles(inCard: cardFolder(in: fixture))
#expect(moved.map(\.fileName) == ["a.txt", "b.txt"])
#expect(try fixture.entryNames("\(cardPath)").sorted() == ["attachments", "index.md", "scratch"])
}
@Test("normalizeLooseFiles(inLane:) reaches every card and nothing else")
func normalizeALaneReachesItsCards() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.file("\(cardPath)/a.txt", Data("a".utf8))
try fixture.file("\(Ident.lane1)/\(Ident.card2)/b.txt", Data("b".utf8))
try fixture.file("\(Ident.lane1)/lane-level.txt", Data("lane".utf8))
let moved = try BoardWriter.normalizeLooseFiles(inLane: fixture.url(Ident.lane1))
#expect(moved.count == 2)
#expect(try fixture.data("\(cardPath)/attachments/a.txt") == Data("a".utf8))
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/b.txt") == Data("b".utf8))
// The lane's own stray keeps the verbatim posture.
#expect(fixture.exists("\(Ident.lane1)/lane-level.txt"))
}
}
// MARK: - 3. The store's scheduling
/// Counts the bracket calls a store makes, standing in for the watcher the registry wires up
/// `BoardStoreTests`' own helper, which is `private` there.
@MainActor
private final class RelocationBracketLog {
private(set) var begins = 0
func attach(to store: BoardStore) {
store.watcherBrackets = (begin: { self.begins += 1 }, end: {})
}
}
@MainActor
@Suite("Loose files ▸ the store's relocation")
struct LooseFileStoreTests {
@Test("The relocation moves the file and posts the ruling's notice")
func relocatesAndPostsTheNotice() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8))
let store = try BoardStore(rootURL: fixture.root)
let brackets = RelocationBracketLog()
brackets.attach(to: store)
store.relocateLooseCardFiles()
#expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("notes".utf8))
#expect(!fixture.exists("\(cardPath)/notes.txt"))
#expect(store.banners.losses.map(\.message) == ["Moved 'notes.txt' into attachments — 'Fix login'"])
#expect(store.banners.oneShots.isEmpty)
// One gesture, one bracket one app-mediated reload and, on git boards, one commit.
#expect(brackets.begins == 1)
}
@Test("A collision is Finder-renamed, and the notice still names the file the user wrote")
func collisionIsRenamedThroughTheStore() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
try fixture.file("\(cardPath)/attachments/notes.txt", Data("the attachment".utf8))
try fixture.file("\(cardPath)/notes.txt", Data("the loose one".utf8))
let store = try BoardStore(rootURL: fixture.root)
store.relocateLooseCardFiles()
#expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("the attachment".utf8))
#expect(try fixture.data("\(cardPath)/attachments/notes 2.txt") == Data("the loose one".utf8))
#expect(store.banners.losses.map(\.message) == ["Moved 'notes.txt' into attachments — 'Fix login'"])
}
@Test("A reload is what fires it — detection rides the snapshot")
func aReloadFiresIt() async throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(store.looseCardFiles.isEmpty)
try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8))
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
#expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("notes".utf8))
#expect(store.banners.losses.count == 1)
// The echo reload finds nothing loose, so nothing fires again and no second row appears.
store.handleWatcherEvent(.treeChanged(.appMediated))
await store.awaitQuiescence()
#expect(store.looseCardFiles.isEmpty)
#expect(store.banners.losses.count == 1)
}
/// "The relocation waits out any read-only lock strays stay tolerated until it clears."
/// `.unwritableLocation` is the lock a reload does not disprove by itself, so an ordinary reload
/// under it proves the deferral rather than racing it.
@Test("A locked board writes nothing, and relocates when the lock clears")
func lockedBoardDefers() async throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8))
let store = try BoardStore(rootURL: fixture.root)
let brackets = RelocationBracketLog()
brackets.attach(to: store)
store.enterUnwritableLock()
store.relocateLooseCardFiles()
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
// Tolerated, exactly as before the carve-out existed: still beside index.md, nothing said.
#expect(fixture.exists("\(cardPath)/notes.txt"))
#expect(!fixture.exists("\(cardPath)/attachments"))
#expect(store.banners.losses.isEmpty)
#expect(brackets.begins == 0)
#expect(store.isReadOnly)
// A reconciling reload re-probes writability, the lock clears and the same reload
// performs the relocation it had been holding back.
store.handleWatcherEvent(.treeChanged(.reconciling))
await store.awaitQuiescence()
#expect(!store.isReadOnly)
#expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("notes".utf8))
#expect(store.banners.losses.map(\.message) == ["Moved 'notes.txt' into attachments — 'Fix login'"])
#expect(brackets.begins == 1)
}
/// The loop the guard exists for: a relocation that fails leaves the same files on disk, so the
/// next walk hands back the same work. One failure, one row, then silence.
@Test("A failing relocation is attempted once, not forever")
func repeatedFailureDoesNotHotLoop() async throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8))
let store = try BoardStore(rootURL: fixture.root)
let brackets = RelocationBracketLog()
brackets.attach(to: store)
// Readable (so the walk still sees the file) but not writable (so the move cannot land).
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: cardFolder(in: fixture).path)
store.relocateLooseCardFiles()
#expect(store.banners.oneShots.count == 1)
#expect(store.banners.oneShots.first?.error.operation == .relocateLooseFile(filename: "notes.txt"))
#expect(store.banners.losses.isEmpty)
#expect(brackets.begins == 1)
for _ in 0 ..< 3 {
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
}
// Same picture on disk, so no second attempt and no second row.
#expect(store.looseCardFiles.count == 1)
#expect(store.banners.oneShots.count == 1)
#expect(brackets.begins == 1)
// A picture that actually changed is a fresh attempt.
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cardFolder(in: fixture).path)
try fixture.file("\(cardPath)/second.txt", Data("second".utf8))
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
#expect(brackets.begins == 2)
#expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("notes".utf8))
#expect(try fixture.data("\(cardPath)/attachments/second.txt") == Data("second".utf8))
}
@Test("Several cards fold into one bracket and one row")
func severalCardsFold() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.file("\(cardPath)/a.txt", Data("a".utf8))
try fixture.file("\(cardPath)/b.txt", Data("b".utf8))
try fixture.file("\(Ident.lane1)/\(Ident.card2)/c.txt", Data("c".utf8))
let store = try BoardStore(rootURL: fixture.root)
let brackets = RelocationBracketLog()
brackets.attach(to: store)
store.relocateLooseCardFiles()
#expect(brackets.begins == 1)
#expect(store.banners.losses.map(\.message) == ["Moved 3 files into attachments — 2 cards"])
#expect(try fixture.entryNames("\(cardPath)/attachments") == ["a.txt", "b.txt"])
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card2)/attachments") == ["c.txt"])
}
@Test("A board with nothing loose writes nothing and says nothing")
func cleanBoardIsSilent() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let brackets = RelocationBracketLog()
brackets.attach(to: store)
store.relocateLooseCardFiles()
#expect(brackets.begins == 0)
#expect(store.banners.losses.isEmpty)
#expect(store.banners.oneShots.isEmpty)
}
}
// MARK: - The phrasing (BannerCenter owns every word)
@Suite("Loose files ▸ phrasing")
struct LooseFileMessageTests {
private func relocation(_ title: String?, _ names: String...) -> BannerCenter.Relocation {
BannerCenter.Relocation(title: title, fileNames: names)
}
@Test("One card, one file names both — the design's own sentence")
func oneFileOneCard() {
#expect(
BannerCenter.relocatedLooseFilesMessage(for: [relocation("Fix login", "notes.txt")])
== "Moved 'notes.txt' into attachments — 'Fix login'"
)
}
@Test("Several files on one card fold to a count, keeping the card named")
func severalFilesOneCard() {
#expect(
BannerCenter.relocatedLooseFilesMessage(
for: [relocation("Fix login", "a.txt", "b.txt", "c.txt")]
) == "Moved 3 files into attachments — 'Fix login'"
)
}
@Test("Several cards fold to two counts")
func severalCards() {
#expect(
BannerCenter.relocatedLooseFilesMessage(for: [
relocation("Fix login", "a.txt", "b.txt"),
relocation("Ship it", "c.txt"),
relocation(nil, "d.txt", "e.txt"),
]) == "Moved 5 files into attachments — 3 cards"
)
}
@Test("An untitled card is named as one — 'Untitled' is a rendering, never a value")
func untitledCard() {
#expect(
BannerCenter.relocatedLooseFilesMessage(for: [relocation(nil, "notes.txt")])
== "Moved 'notes.txt' into attachments — an untitled card"
)
}
@Test("Nothing moved says nothing")
func nothingMoved() {
#expect(BannerCenter.relocatedLooseFilesMessage(for: []) == nil)
#expect(BannerCenter.relocatedLooseFilesMessage(for: [relocation("Fix login")]) == nil)
}
/// The row is a **loss row** warning tone, dismissable, and ranked above the ambient notices
/// rather than at the bottom of the strip with them.
@Test("It rides the loss-row class")
@MainActor
func ridesTheLossRowClass() {
let center = BannerCenter()
center.postRelocatedLooseFiles([relocation("Fix login", "notes.txt")])
let rows = BannerCenter.rows(
lock: nil,
breakage: nil,
oneShots: [],
losses: center.losses,
suspension: nil,
operations: [],
signposts: [InfoSignpost(message: "elsewhere")]
)
#expect(rows.first?.tone == .warning)
#expect(rows.first?.dismissID == center.losses.first?.id)
#expect(rows.count == 2)
}
/// A failed relocation is a one-shot write failure, and the banner owns its words too.
@Test("A failed relocation says so in the relocation's own verb")
func failureHeadline() {
let error = BoardWriteError(
operation: .relocateLooseFile(filename: "notes.txt"),
path: "/tmp/card/notes.txt",
reason: .io(message: "disk full")
)
#expect(BannerCenter.headline(for: error) == "Couldn't move 'notes.txt' into attachments — disk full")
}
}
// MARK: - 4. The paste boundary (04-interactions.md Clipboard)
/// A source board whose card carries an attachment, two loose files one of them colliding with
/// that attachment and a symlink stray.
@MainActor
private func makeLooseFileClipboardBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(cardPath, Item.rich(order: "1024", title: "Fix login"))
try fixture.file("\(cardPath)/attachments/notes.txt", Data("the attachment".utf8))
try fixture.file("\(cardPath)/notes.txt", Data("the loose one".utf8))
try fixture.file("\(cardPath)/draft.md", Data("draft".utf8))
try symlink("link.txt", to: "attachments/notes.txt", in: fixture.url(cardPath))
return fixture
}
/// The destination: one lane holding one resident card, so an arrival has neighbours.
@MainActor
private func makePasteDestination() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane4, Item.rich(order: "1024", title: "Inbox"))
try fixture.item("\(Ident.lane4)/\(Ident.indexless)", Item.rich(order: "1024", title: "Resident"))
return fixture
}
/// The folder name of the card that just arrived in the destination's lane.
private func arrivedCard(in fixture: WriterFixture) throws -> String {
let lane = try #require(
try BoardLoader.load(boardRoot: fixture.root).model.lanes.first { $0.id.rawValue == Ident.lane4 }
)
return try #require(lane.cards.filter { !$0.isDeleted }.last?.id.rawValue)
}
@MainActor
@Suite("Loose files ▸ the paste boundary")
struct LooseFilePasteTests {
@Test("A pasted card lands already normalized, its collision Finder-renamed")
func pasteNormalizes() async throws {
let harness = try ClipboardHarness(fixture: try makeLooseFileClipboardBoard())
defer { harness.tearDown() }
let destination = try makePasteDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([ItemID(rawValue: Ident.card1)], liveness: .live)
harness.clipboard.copy(from: harness.store)
target.select([ItemID(rawValue: Ident.lane4)], liveness: .live)
await harness.clipboard.paste(into: target)?.value
let arrived = try arrivedCard(in: destination)
let path = "\(Ident.lane4)/\(arrived)"
// Nothing the snapshot preserved is dropped on arrival and nothing arrives out of place.
#expect(try destination.data("\(path)/attachments/notes.txt") == Data("the attachment".utf8))
#expect(try destination.data("\(path)/attachments/notes 2.txt") == Data("the loose one".utf8))
#expect(try destination.data("\(path)/attachments/draft.md") == Data("draft".utf8))
#expect(!destination.exists("\(path)/notes.txt"))
#expect(!destination.exists("\(path)/draft.md"))
// A normalized arrival is not news: the user asked for this paste.
#expect(target.banners.losses.isEmpty)
#expect(target.banners.oneShots.isEmpty)
}
/// "Symlinks (never touched/traversed)" a paste copies one verbatim, as the stray it is, and
/// the destination board's own loader agrees it is nothing to relocate.
@Test("A symlink stray pastes verbatim, beside index.md")
func symlinkStrayPastesVerbatim() async throws {
let harness = try ClipboardHarness(fixture: try makeLooseFileClipboardBoard())
defer { harness.tearDown() }
let destination = try makePasteDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([ItemID(rawValue: Ident.card1)], liveness: .live)
harness.clipboard.copy(from: harness.store)
target.select([ItemID(rawValue: Ident.lane4)], liveness: .live)
await harness.clipboard.paste(into: target)?.value
let arrived = try arrivedCard(in: destination)
let link = destination.url("\(Ident.lane4)/\(arrived)").appendingPathComponent("link.txt")
let attributes = try FileManager.default.attributesOfItem(atPath: link.path)
#expect(attributes[.type] as? FileAttributeType == .typeSymbolicLink)
#expect(
try FileManager.default.destinationOfSymbolicLink(atPath: link.path) == "attachments/notes.txt"
)
#expect(try BoardLoader.load(boardRoot: destination.root).looseCardFiles.isEmpty)
}
/// The source is untouched by a copy-paste the loose files it still holds are its own board's
/// carve-out to handle, on its own reload.
@Test("The source board's loose files are left where they are")
func sourceIsUntouched() async throws {
let harness = try ClipboardHarness(fixture: try makeLooseFileClipboardBoard())
defer { harness.tearDown() }
let destination = try makePasteDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([ItemID(rawValue: Ident.card1)], liveness: .live)
harness.clipboard.copy(from: harness.store)
target.select([ItemID(rawValue: Ident.lane4)], liveness: .live)
await harness.clipboard.paste(into: target)?.value
#expect(harness.fixture.exists("\(cardPath)/notes.txt"))
#expect(harness.fixture.exists("\(cardPath)/draft.md"))
}
@Test("A pasted lane normalizes every card it carries")
func lanePasteNormalizesItsCards() async throws {
let harness = try ClipboardHarness(fixture: try makeLooseFileClipboardBoard())
defer { harness.tearDown() }
let destination = try makePasteDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([ItemID(rawValue: Ident.lane1)], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: target)?.value
let model = try BoardLoader.load(boardRoot: destination.root).model
let arrivedLane = try #require(model.lanes.first { $0.id.rawValue != Ident.lane4 })
let arrivedCard = try #require(arrivedLane.cards.first)
let path = "\(arrivedLane.id.rawValue)/\(arrivedCard.id.rawValue)"
#expect(try destination.data("\(path)/attachments/notes 2.txt") == Data("the loose one".utf8))
#expect(try destination.data("\(path)/attachments/draft.md") == Data("draft".utf8))
#expect(!destination.exists("\(path)/notes.txt"))
#expect(try BoardLoader.load(boardRoot: destination.root).looseCardFiles.isEmpty)
}
/// The staging-less fallback carries only `index.md`, so there is nothing to normalize and the
/// normalization must not invent an `attachments/` for a card that has none.
@Test("A degraded paste normalizes nothing and mints no attachments folder")
func degradedPasteIsUnaffected() async throws {
let harness = try ClipboardHarness(fixture: try makeLooseFileClipboardBoard())
defer { harness.tearDown() }
let destination = try makePasteDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([ItemID(rawValue: Ident.card1)], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
// The snapshot goes missing between the copy and the paste 04's degraded paste.
for staged in try harness.stagedCopyIDs() {
try FileManager.default.removeItem(at: harness.staging.appendingPathComponent(staged))
}
target.select([ItemID(rawValue: Ident.lane4)], liveness: .live)
await harness.clipboard.paste(into: target)?.value
let arrived = try arrivedCard(in: destination)
#expect(!destination.exists("\(Ident.lane4)/\(arrived)/attachments"))
#expect(try destination.entryNames("\(Ident.lane4)/\(arrived)") == ["index.md"])
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ The defining consequence: anything that can read and write files is a first-clas
Lanework is in early development. This list tracks what has actually shipped and grows milestone by milestone; the full design lives in [DESIGN/](DESIGN/).
- **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and tombstone-aware snapshots — pinned by a golden fixture suite of 18 on-disk boards.
- **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes are in-place tombstones with position-perfect restore and physical purge; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting.
- **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes are in-place tombstones with position-perfect restore and physical purge; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting. Strays are preserved verbatim everywhere with exactly one carve-out: a loose *file* dropped beside a card's `index.md` belongs in that card's `attachments/`, so the app moves it there — Finder-renamed on collision, byte-faithfully, without touching `index.md` — and says so in a dismissable warning row naming the card and the files. Detection stays read-only in the loader; the move is an ordinary bracketed write that waits out the read-only lock and never retries a failure in a loop. Stray *folders*, symlinks, and everything at board or lane level keep the verbatim posture untouched, and a paste normalizes at the import boundary so a pasted card lands already tidy.
- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo; a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state.
- **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded; follows its card across lanes; dismisses on delete, tombstone, or cross-board move). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted.
- **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the drag surface, a plain click selecting the lane and movement carrying it away.