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