Orphaned .backgrounds/ files get tidied — a repoint trims the one it leaves behind, and every open sweeps what got away

Follow-up to d0c5461's .backgrounds/ folder: generating, pasting, or
choosing solid now trims the app's own prior file in .backgrounds/ as
part of the same write when it repoints or unsets background.image
away from it — silent, best-effort, never blocking the gesture that
triggered it. A scheduled heal at every board open sweeps whatever
that trim declined or missed: any .backgrounds/ file the board's
current background.image no longer names, announced with a loss-row
notice in the loose-file relocation's own voice. Legacy root-level
references are untouched by both paths — the tidy scopes to
.backgrounds/ only, since that is the one folder the app can prove it
wrote into.

Removal is via FileManager.trashItem, matching the attachment-removal
precedent (recoverable, never a hard delete).

Supersedes applySolidBackground's earlier "facets.png survives on
disk" contract for the settled case: the ruling reads "unsets" as one
more shape of "repoints away from a .backgrounds/ file", so a settled
solid choice now trims the generated picture it displaces, same as
switching producers does. The superseded test and doc comment are
updated to the new behavior; the echo-window (no-reload-yet) case is
unaffected and still leaves the file in place.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 09:44:30 -04:00
parent d905e73960
commit ca0328be2e
9 changed files with 858 additions and 26 deletions
+73 -4
View File
@@ -2767,6 +2767,58 @@ public enum BoardWriter: Sendable {
return trashedURL as URL?
}
/// Moves a file out of `.backgrounds/` to the **system** Trash `trashAttachment`'s body one
/// folder over, and the shared primitive both orphan-tidy mechanisms use (01-storage-format.md §
/// Validation and healing, ruled 2026-08-09): the replace-in-place trim a background apply makes
/// on the file it just repointed or unset away from (`BoardStore.applyGeneratedBackground`,
/// `applyPastedBackground`, `applySolidBackground`), and the open-time sweep of whatever
/// `.backgrounds/` holds that the board's current `background.image` no longer names
/// (`BoardStore.tidyBackgroundImages`).
///
/// **`FileManager.trashItem`, never `removeItem`** `trashAttachment`'s own reasoning, applied to
/// a file the app rather than the user put there: an app-written background is recoverable exactly
/// the way a trashed attachment is, by the user, in Finder, with no help from this app, which is
/// the ruling's own "removal via `FileManager.trashItem` (recoverable, matches
/// attachment-removal precedent)".
///
/// **A name no longer present in `backgroundsFolder` is not a failure** `trashAttachment`'s rule
/// again: the reload (or, for the in-flow caller, the disk itself a moment later) is the authority
/// on what is there, and a file that vanished between the scan and the write a hand delete, a
/// losing race with a foreign write contributes nothing to report.
///
/// - Parameter healMarked: whether the receipt this drops is marked as a heal's
/// (06-history-undo.md Commit messages; `relocateLooseFiles`' own parameter, restated here).
/// `true` for the scheduled sweep, which is app-initiated work discovered on its own; `false` for
/// the in-flow trim, which rides inside the gesture that triggered it rather than a heal's own
/// bracket.
/// - Returns: where the file now sits inside the Trash, or `nil` when there was nothing to move.
@discardableResult
static func trashBoardImage(
named name: String,
inFolder backgroundsFolder: URL,
healMarked: Bool,
operation: WriteOperation
) throws(BoardWriteError) -> URL? {
let fileURL = backgroundsFolder.appendingPathComponent(name)
guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil }
var trashedURL: NSURL?
do {
try FileManager.default.trashItem(at: fileURL, resultingItemURL: &trashedURL)
EchoLedger.current?.recordDeletion(at: fileURL)
if healMarked {
EchoLedger.current?.markHeal(at: fileURL)
}
} catch {
throw BoardWriteError(
operation: operation,
path: fileURL.path,
reason: .io(message: "could not move file to the Trash: \(error.localizedDescription)")
)
}
return trashedURL as URL?
}
// MARK: - Move/copy pre-flight
/// The rank a moved or copied root lands on: the caller's explicit value a drop between
@@ -3084,6 +3136,22 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
/// never the Finder-renamed one it would have landed under.
case relocateLooseFile(filename: String)
/// A `.backgrounds/` file being moved to the **system** Trash because nothing names it any more
/// the orphan tidy's write, both mechanisms (01-storage-format.md § Validation and healing, ruled
/// 2026-08-09): the replace-in-place trim a background apply makes on the file it just repointed
/// or unset away from, and the open-time sweep of whatever `.backgrounds/` holds that the board's
/// current `background.image` no longer names.
///
/// Its own case on `.relocateLooseFile`'s reasoning, doubled: this is work the *app* started on
/// its own, on a file the user never named, and a banner saying the app "couldn't move 'facets
/// 2.png' to the Trash" without this case would borrow `.removeAttachment`'s sentence for a
/// gesture a click on a Remove row the user never made. In practice only the sweep's own
/// failure ever reaches a banner: the in-flow trim swallows whatever this throws (`try?`, the
/// ruling's own "temp-file posture"), so this case's phrasing exists for the sweep and for the
/// developer-facing `description` either mechanism's failure would log. `filename` is the bare
/// name inside `.backgrounds/`, the one the sweep's own successful notice would have used.
case tidyBackgroundImage(filename: String)
/// The board-root `CLAUDE.md` being written or upgraded and the rescue move that precedes it
/// when a markerless one has to be displaced to `CLAUDE.user.md` (08-agent-integration.md The
/// agent guide; `AgentGuide`).
@@ -3275,8 +3343,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
// the name the user is looking at and the only one its banner should say and there is no
// document to enrich from anyway, since the failure it describes is the read of one.
case .createBoard, .createLane, .createCard, .importAttachment, .importBoard, .listAttachments,
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .seedGitignore,
.mintBoardIndex, .stampSchema, .setBoardBackground,
.removeAttachment, .renumberChildren, .relocateLooseFile, .tidyBackgroundImage, .agentGuide,
.seedGitignore, .mintBoardIndex, .stampSchema, .setBoardBackground,
.displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment,
.editComment, .deleteComment, .purgeCommentTrash:
self
@@ -3345,8 +3413,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
.style, .resize, .collapse, .expand, .rename, .duplicateBoard, .saveAsTemplate, .shareBoard, .paste,
.exportBoard, .importBoard,
.importAttachment,
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore,
.mintBoardIndex, .stampSchema, .setBoardBackground,
.listAttachments, .removeAttachment, .relocateLooseFile, .tidyBackgroundImage, .agentGuide,
.seedGitignore, .mintBoardIndex, .stampSchema, .setBoardBackground,
.displaceClaimedName,
.repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment,
.editComment, .deleteComment, .purgeCommentTrash:
@@ -3388,6 +3456,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .removeAttachment(filename): "move attachment '\(filename)' to the Trash"
case .renumberChildren: "renumber children"
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
case let .tidyBackgroundImage(filename): "tidy background image '\(filename)'"
case .agentGuide: "update the agent guide"
case .seedGitignore: "seed the board's .gitignore"
case .mintBoardIndex: "create this board's index.md"