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
+48
View File
@@ -602,6 +602,27 @@ public final class BannerCenter {
postLoss(message) postLoss(message)
} }
/// **The orphaned background-image sweep** (01-storage-format.md § Validation and healing, ruled
/// 2026-08-09: "the open-time heal posts the calm notice-family banner the loose-file relocation
/// heal uses state what was tidied"): a file in `.backgrounds/` that nothing on the board named
/// any more was moved to the system Trash, and this is the row that says so.
///
/// **A loss row, on `postRelocatedLooseFiles`' exact reasoning**: the app removed a file on its own
/// initiative, on a board it opened rather than on a gesture the user made that must be said out
/// loud, must not evaporate unread, and must not rank as an error, because nothing failed.
///
/// **Only the sweep posts this.** The in-flow replace-in-place trim a background apply makes on
/// its own prior file is deliberately silent the ruling's "temp-file posture": the app
/// superseding its own file inside a gesture the user already sees succeed (a fresh picture lands)
/// is not news the way a heal discovered on open is.
///
/// `names` are the bare filenames the sweep actually removed, in the order it removed them. A
/// sweep that removed nothing posts nothing.
public func postTidiedBackgroundImages(_ names: [String]) {
guard let message = Self.tidiedBackgroundImagesMessage(for: names) else { return }
postLoss(message)
}
/// **The skip notice** (01-storage-format.md § Malformed input, ruled 2026-07-31): the decision /// **The skip notice** (01-storage-format.md § Malformed input, ruled 2026-07-31): the decision
/// surface offered Skip on a defect the app has no honest repair for, the user consented, the /// surface offered Skip on a defect the app has no honest repair for, the user consented, the
/// board opened without that item "the file stays on disk untouched, tolerated-invisible like /// board opened without that item "the file stays on disk untouched, tolerated-invisible like
@@ -973,6 +994,15 @@ public final class BannerCenter {
// failure the user did not provoke is exactly the one they have no other way to learn // failure the user did not provoke is exactly the one they have no other way to learn
// about. // about.
"Couldn't move '\(filename)' into attachments" "Couldn't move '\(filename)' into attachments"
case let .tidyBackgroundImage(filename):
// **The successful sweep's own verb, negated** `postTidiedBackgroundImages`' "Removed
// '\(filename)' " said the other way round, `.relocateLooseFile`'s own precedent: the
// failure reads as the same sentence's negation rather than a different event. It stays
// in the ordinary one-shot precedence class for `.relocateLooseFile`'s reason too this
// 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. In practice only the sweep's own failure
// ever reaches here; the in-flow trim swallows its own (the ruling's "temp-file posture").
"Couldn't remove '\(filename)'"
case .agentGuide: case .agentGuide:
// **"the agent guide", not "CLAUDE.md"**: the file is the app's, written for agents, and // **"the agent guide", not "CLAUDE.md"**: the file is the app's, written for agents, and
// most users will never have opened it a filename here would name something they have // most users will never have opened it a filename here would name something they have
@@ -1284,6 +1314,24 @@ public final class BannerCenter {
return "Repaired duplicate id — \(sole(only))" return "Repaired duplicate id — \(sole(only))"
} }
/// The orphaned background-image sweep's line, `migratedTombstonesMessage`'s voice: the act
/// first, the subject after an em dash, plurals folded, a tail naming the cause without it the
/// sentence would read as something that happened *to* the board rather than the app's own
/// housekeeping.
///
/// - **One**: "Removed 'facets 2.png' it was no longer the board's background".
/// - **Several**: "Removed 3 background images they were no longer the board's background".
///
/// `nil` when nothing was removed a sweep that swept nothing is not news.
public nonisolated static func tidiedBackgroundImagesMessage(for names: [String]) -> String? {
guard !names.isEmpty else { return nil }
let subject = names.count == 1 ? "'\(names[0])'" : "\(names.count) background images"
let tail = names.count == 1
? "it was no longer the board's background"
: "they were no longer the board's background"
return "Removed \(subject)\(tail)"
}
/// The lossy export's line, in the relocation family's voice the act first, the cause after an /// The lossy export's line, in the relocation family's voice the act first, the cause after an
/// em dash, plurals folded into their counts. /// em dash, plurals folded into their counts.
/// ///
+200 -13
View File
@@ -2056,8 +2056,27 @@ public final class BoardStore: HealHost {
/// alternative a temp copy, a versioned name buys byte-perfect undo of a picture nobody asked /// alternative a temp copy, a versioned name buys byte-perfect undo of a picture nobody asked
/// to keep at the price of litter in a folder the user owns. /// to keep at the price of litter in a folder the user owns.
/// ///
/// ### The prior `.backgrounds/` file is trimmed, not just orphaned
///
/// **The replace-in-place half of the orphan tidy** (01-storage-format.md § Validation and
/// healing, ruled 2026-08-09): once the new picture and the field both land, `snapshot`'s own prior
/// image read before this write, the same value the undo step above closes over is trimmed
/// from `.backgrounds/` when it named a file of ours different from the one this write just landed
/// on (`tidyReplacedBackgroundImage`). Switching producers (a settled paste, then a generate) no
/// longer leaves the earlier picture behind forever; a regeneration over the *same* name is
/// unaffected, because there is no "old" file distinct from the new one to trim.
///
/// **This sharpens the undo note above, not contradicts it**: an undo that restores a prior
/// `.backgrounds/` reference now restores a field that may point at bytes the Trash holds rather
/// than the board folder the missing-file degrade `BoardBackdropImage` already has for a
/// hand-deleted file, and recoverable exactly as one, on the ruling's own "temp-file posture". A
/// board's own `snapshot` is what decides whether the trim fires at all: back-to-back gestures
/// inside one reload's echo window (`theTwoProducersStayApart`) see a `nil` prior and trim nothing,
/// the same staleness that already gates the echo naming above.
///
/// Failure is `performWrite`'s: the banner is posted before the rethrow, which is swallowed here /// Failure is `performWrite`'s: the banner is posted before the rethrow, which is swallowed here
/// like every other gesture with no second thing to do about it. /// like every other gesture with no second thing to do about it. The trim's own failure is
/// swallowed independently and never reaches this banner at all see `tidyReplacedBackgroundImage`.
/// ///
/// - Parameter png: the encoded image, already rendered (`FacetsGenerator.pngData`). /// - Parameter png: the encoded image, already rendered (`FacetsGenerator.pngData`).
/// - Parameter colorHex: the ground colour of that render (`FacetsRecipe.primaryColorHex`) /// - Parameter colorHex: the ground colour of that render (`FacetsRecipe.primaryColorHex`)
@@ -2088,6 +2107,10 @@ public final class BoardStore: HealHost {
) { document in ) { document in
Self.pointBackground(at: target.reference, color: colorHex, in: &document) Self.pointBackground(at: target.reference, color: colorHex, in: &document)
} }
// The replace-in-place half of the orphan tidy (ruled 2026-08-09) see its own note.
Self.tidyReplacedBackgroundImage(
priorImage: priorImage.value, newReference: target.reference, inFolder: backgroundsFolder
)
} }
guard landed != nil else { return false } guard landed != nil else { return false }
generatedBackgroundEcho = ( generatedBackgroundEcho = (
@@ -2133,15 +2156,21 @@ public final class BoardStore: HealHost {
/// undo-restores-both-fields shape, the same `WriteOperation.setBoardBackground`, the same /// undo-restores-both-fields shape, the same `WriteOperation.setBoardBackground`, the same
/// swallowed failure. /// swallowed failure.
/// ///
/// ### `facets.png` survives on disk /// ### `facets.png` is trimmed when the switch has settled
/// ///
/// Choosing a solid colour over a generated background does **not** delete the picture the /// **Superseded by the ruling below** (01-storage-format.md § Validation and healing, ruled
/// generator wrote. Undo restores the `image` *field*, and a field cannot point an undo back at /// 2026-08-09): earlier, choosing a solid colour over a generated background never deleted the
/// bytes this gesture just erased so the file has to survive for the same reason /// picture the generator wrote, on exactly the reasoning `applyGeneratedBackground`'s own overwrite-
/// `applyGeneratedBackground`'s own overwrite-in-place does. A board that regenerates after /// in-place note still gives for a same-name regeneration undo restores the `image` *field*, and
/// choosing solid still finds `facets.png` free to overwrite in place; the file becomes an orphan /// a field cannot point an undo back at bytes this gesture erased. The ruling reads "unsets" as one
/// only when nothing in the frontmatter ever points at it again, which is the same quiet leftover /// more shape of "repoints away from a `.backgrounds/` file", so this gesture now trims that file
/// a hand-deleted `image:` line already leaves. /// too, the same `tidyReplacedBackgroundImage` call `applyGeneratedBackground` and
/// `applyPastedBackground` make see that method's own note, including the accepted cost: an undo
/// back to the picture this gesture unset now restores a field that may point at bytes the Trash
/// holds rather than the board folder, gracefully, the same missing-file degrade a hand-deleted file
/// already has. The trim only fires once `snapshot` has caught up with the write it is trimming
/// two solid-colour gestures back to back inside one reload's echo window trim nothing on the
/// second, `applyGeneratedBackground`'s own staleness gate, restated here.
/// ///
/// - Parameter colorHex: the solid colour to write, `#RRGGBB` one of the Theme tab's Solid color /// - Parameter colorHex: the solid colour to write, `#RRGGBB` one of the Theme tab's Solid color
/// swatches, which read `FacetsRecipe.primaryColorHex` at the filters' tone/saturation level. /// swatches, which read `FacetsRecipe.primaryColorHex` at the filters' tone/saturation level.
@@ -2152,6 +2181,9 @@ public final class BoardStore: HealHost {
let root = rootURL let root = rootURL
let priorImage = snapshot.backgroundImage let priorImage = snapshot.backgroundImage
let priorColor = snapshot.background let priorColor = snapshot.background
let backgroundsFolder = root.appendingPathComponent(
BoardBackdrop.backgroundsFolderName, isDirectory: true
)
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
try BoardWriter.updateIndex( try BoardWriter.updateIndex(
@@ -2159,6 +2191,11 @@ public final class BoardStore: HealHost {
) { document in ) { document in
Self.pointSolidBackground(color: colorHex, in: &document) Self.pointSolidBackground(color: colorHex, in: &document)
} }
// The replace-in-place half of the orphan tidy (ruled 2026-08-09) see this method's own
// note. `newReference: nil`, the unset case: solid never writes an `image` subkey.
Self.tidyReplacedBackgroundImage(
priorImage: priorImage.value, newReference: nil, inFolder: backgroundsFolder
)
} }
guard landed != nil else { return false } guard landed != nil else { return false }
@@ -2257,6 +2294,140 @@ public final class BoardStore: HealHost {
return (fresh, "\(BoardBackdrop.backgroundsFolderName)/\(fresh)") return (fresh, "\(BoardBackdrop.backgroundsFolderName)/\(fresh)")
} }
// MARK: - The orphan tidy replace-in-place
/// **Trims the `.backgrounds/` file a background apply just repointed or unset away from** the
/// replace-in-place half of the orphan tidy (01-storage-format.md § Validation and healing, ruled
/// 2026-08-09: "when the app's own write repoints or unsets the board background away from a
/// `.backgrounds/` file, remove the now-unreferenced old file as part of the operation"), shared by
/// every gesture that can move `background.image` off one of the app's own files
/// `applyGeneratedBackground`, `applyPastedBackground`, `applySolidBackground` each of which calls
/// this as the last line inside its own write bracket, after both of its own writes have landed.
///
/// ### Silent and best-effort, the ruling's own "temp-file posture"
///
/// This rides inside a bracket whose actual job is landing the *new* state, so a failure to trim
/// the *old* file must never fail that write or surface a banner about it the user pressed
/// Generate, Paste, or a colour well, and what failed, as far as they are concerned, is whichever of
/// those the surrounding bracket's own banner already names. `try?` swallows whatever
/// `BoardWriter.trashBoardImage` throws; a file that could not be trimmed is simply left for the
/// scheduled sweep (`tidyBackgroundImages`) to find on the next open the same self-healing shape
/// every heal in this file has when a single attempt does not land.
///
/// `healMarked: false`: this is not a heal discovering work on its own, it is part of the very
/// gesture the user is watching succeed `relocateLooseFiles`' own distinction between its
/// scheduled caller and its inline one, applied here.
///
/// ### A no-op in three shapes, each deliberate
///
/// - `priorImage` is `nil` no field to have named an old file, or (`theTwoProducersStayApart`'s
/// echo window) `snapshot` has not yet caught up with the very write this trim would be reacting
/// to.
/// - `priorImage` names the file the write just landed on (`newReference` unchanged)
/// `applyGeneratedBackground`'s own overwrite-in-place case; there is no "old" file distinct from
/// the new one.
/// - `BoardBackdrop.qualifiedBareName(of:)` answers `nil` `priorImage` is not one of the app's own
/// `.backgrounds/` files at all: unqualified (a legacy reference, ruled "stays where it is no
/// migration"), a hand-placed image elsewhere in the board, or a path this tidy is not scoped to
/// touch (01's own "Tidy scopes to `.backgrounds/` ONLY").
private static func tidyReplacedBackgroundImage(
priorImage: String?, newReference: String?, inFolder backgroundsFolder: URL
) {
guard let priorImage, priorImage != newReference,
let bareName = BoardBackdrop.qualifiedBareName(of: priorImage)
else { return }
try? BoardWriter.trashBoardImage(
named: bareName, inFolder: backgroundsFolder, healMarked: false,
operation: .tidyBackgroundImage(filename: bareName)
)
}
// MARK: - The orphan tidy the open-time sweep
/// **Sweeps `.backgrounds/` for a file the board's current background no longer names** the
/// open-time half of the orphan tidy (01-storage-format.md § Validation and healing, ruled
/// 2026-08-09: "any file in `.backgrounds/` not referenced by the board's current
/// `background.image` is swept"), the safety net behind the replace-in-place trim
/// (`tidyReplacedBackgroundImage`): every case that trim declines the echo window, a foreign
/// write that repointed the field outside the app, a trim that failed and was swallowed leaves
/// exactly the picture this sweep is built to notice and clean up on the next open.
///
/// **Scheduling is the engine's** (`HealScheduler`), on every other scheduled heal's own terms: the
/// resting-clear, the lock-and-writability gate, the signature compare, the armed-before-attempt
/// memo, the one bracket, the banner posture and the clear-on-success are its six steps, and this
/// method is only what is genuinely this heal's which files are candidates, and what the notice
/// names.
///
/// **No `Defect` case** (`IntegrityRules.Defect.Class.orphanedBackgroundImages`'s own note): what is
/// orphaned is two board-root readings taken together at the moment of healing, not something a
/// tree walk over lanes and cards could carry forward `seedGitignore`'s and `refreshAgentGuide`'s
/// shape, not `relocateLooseCardFiles`'s.
///
/// **Announced, unlike the trim** (`HealScheduler.HealNotice.tidiedBackgroundImages`): a heal
/// discovered on open is news the ruling asks to be said out loud, the loose-file relocation's own
/// posture "state what was tidied" where the in-flow trim rides silently inside a gesture the
/// user already watches succeed.
///
/// **The write half re-verifies against disk**: `BoardWriter.trashBoardImage` re-checks each name's
/// existence at write time and no-ops when it is gone, so a file that vanished under the write
/// (a hand delete, a losing race with a foreign write) contributes no line to the notice.
public func tidyBackgroundImages() {
let root = rootURL
let backgroundsFolder = root.appendingPathComponent(
BoardBackdrop.backgroundsFolderName, isDirectory: true
)
let referenced = BoardBackdrop.qualifiedBareName(of: snapshot.backgroundImage.value)
let candidates = Self.orphanedBackgroundFileNames(inFolder: backgroundsFolder, keeping: referenced)
var tidied: [String] = []
heals.run(
.orphanedBackgroundImages,
signature: Set(candidates.map { "background:\($0)" }),
on: self
) { () throws(BoardWriteError) -> Void in
for name in candidates {
let landed = try BoardWriter.trashBoardImage(
named: name, inFolder: backgroundsFolder, healMarked: true,
operation: .tidyBackgroundImage(filename: name)
)
// Vanished under the write the reload is the authority on what is there, and this
// contributes no line, `relocateLooseCardFiles`'s own rule.
guard landed != nil else { continue }
tidied.append(name)
}
} posting: {
.tidiedBackgroundImages(tidied)
}
}
/// The `.backgrounds/` files nothing currently names `tidyBackgroundImages`'s candidate list, read
/// fresh at the moment of healing rather than carried from a tree walk (see that method's own
/// note).
///
/// A **missing** `.backgrounds/` lists as `[]`, not an error: nothing has ever been written there,
/// an ordinary state on a board that has never generated or pasted a background. `.skipsHiddenFiles`
/// is what keeps this from ever proposing the app's own crashed-write residue
/// (`.facets.png.lanework-<uuid>`, `BoardWriter.atomicWrite`'s dot-temp) or a stray `.DS_Store`
/// the same hidden-entry exclusion `BoardLoader.directoryCandidates` uses at every other level.
/// Sub-folders are excluded the same way `relocateLooseFiles` excludes them from a card: this sweep
/// only ever considers `.backgrounds/`'s own direct, regular-file contents, never anything nested.
///
/// - Parameter keeping: the bare name `background.image` currently resolves to inside this folder
/// (`BoardBackdrop.qualifiedBareName(of:)`), `nil` when the board's current reference is not one
/// of ours every file in the folder is then a candidate.
nonisolated static func orphanedBackgroundFileNames(inFolder backgroundsFolder: URL, keeping referenced: String?) -> [String] {
guard let entries = try? FileManager.default.contentsOfDirectory(
at: backgroundsFolder, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]
) else { return [] }
return entries
.filter { url in
url.lastPathComponent != referenced
&& (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true
}
.map(\.lastPathComponent)
.sorted()
}
// MARK: - Pasted background // MARK: - Pasted background
/// **Applies a pasted picture as this board's backdrop** Edit Paste as Board Background /// **Applies a pasted picture as this board's backdrop** Edit Paste as Board Background
@@ -2275,15 +2446,22 @@ public final class BoardStore: HealHost {
/// other subkey survives untouched, which is `setBackgroundImage`'s whole per-subkey contract. /// other subkey survives untouched, which is `setBackgroundImage`'s whole per-subkey contract.
/// - **The name carries the payload's own extension** (`Pasted Background.png`, `.jpeg`, ), /// - **The name carries the payload's own extension** (`Pasted Background.png`, `.jpeg`, ),
/// because the format rule keeps a file-shaped flavor verbatim (`PastedImage`). A board pasted /// because the format rule keeps a file-shaped flavor verbatim (`PastedImage`). A board pasted
/// twice in two formats therefore leaves the first file behind the same quiet leftover /// twice in two formats no longer leaves the first file behind once `snapshot` has caught up
/// choosing a solid colour over a generated background already leaves, and for the same reason: /// with the first paste see the trim note below, which is `applyGeneratedBackground`'s own,
/// undo restores the *field*, and a field cannot point an undo back at bytes this gesture /// restated.
/// deleted.
/// ///
/// **The undo restores the field, not the bytes** `applyGeneratedBackground`'s own note, /// **The undo restores the field, not the bytes** `applyGeneratedBackground`'s own note,
/// unchanged and for its reason: a re-paste over this board's own `.backgrounds/Pasted /// unchanged and for its reason: a re-paste over this board's own `.backgrounds/Pasted
/// Background.png` overwrites pixels nothing kept a copy of. /// Background.png` overwrites pixels nothing kept a copy of.
/// ///
/// **The prior `.backgrounds/` file is trimmed once landed, on the same terms** the
/// replace-in-place half of the orphan tidy (ruled 2026-08-09): once both writes land,
/// `tidyReplacedBackgroundImage` trims `snapshot`'s prior image when it names a file of ours other
/// than the one this write just landed on. `theTwoProducersStayApart`'s echo window is unaffected
/// by construction a `nil` prior (the snapshot has not caught up with the write it would be
/// trimming) trims nothing, so a paste landing right after a generate leaves that generate's file
/// exactly where `applyGeneratedBackground`'s own memo says it should stay findable.
///
/// - Returns: whether bytes reached disk, which is the same question as "is an echo reload /// - Returns: whether bytes reached disk, which is the same question as "is an echo reload
/// coming". /// coming".
@discardableResult @discardableResult
@@ -2305,6 +2483,11 @@ public final class BoardStore: HealHost {
) { document in ) { document in
document.setBackgroundImage(target.reference) document.setBackgroundImage(target.reference)
} }
// The replace-in-place half of the orphan tidy (ruled 2026-08-09) see this method's own
// note.
Self.tidyReplacedBackgroundImage(
priorImage: priorImage.value, newReference: target.reference, inFolder: backgroundsFolder
)
} }
guard landed != nil else { return false } guard landed != nil else { return false }
generatedBackgroundEcho = ( generatedBackgroundEcho = (
@@ -4952,6 +5135,9 @@ public final class BoardStore: HealHost {
/// The rest of the order is immaterial: they touch disjoint files (a card's loose files, a /// The rest of the order is immaterial: they touch disjoint files (a card's loose files, a
/// `deleted:` key inside an `index.md`, `CLAUDE.md`), each opens its own bracket, and each is /// `deleted:` key inside an `index.md`, `CLAUDE.md`), each opens its own bracket, and each is
/// re-armed by the reload the others' writes produce, so none can see another's work half-done. /// re-armed by the reload the others' writes produce, so none can see another's work half-done.
/// **The background-image sweep joins this immaterial tail** (ruled 2026-08-09): it reads
/// `background.image` and writes only inside `.backgrounds/`, a file and a folder none of the
/// other five ever touch, so it has nothing to race and nothing to be raced by.
public func runScheduledHeals() { public func runScheduledHeals() {
displaceClaimedNames() displaceClaimedNames()
seedGitignore() seedGitignore()
@@ -4959,6 +5145,7 @@ public final class BoardStore: HealHost {
migrateLegacyTombstones() migrateLegacyTombstones()
remintDuplicateIdentities() remintDuplicateIdentities()
refreshAgentGuide() refreshAgentGuide()
tidyBackgroundImages()
} }
// MARK: - Selection (delegated) // MARK: - Selection (delegated)
+7
View File
@@ -171,6 +171,11 @@ public final class HealScheduler {
/// This row is what replaced the retired user-gated Repair banner: the same information, none /// This row is what replaced the retired user-gated Repair banner: the same information, none
/// of the waiting. /// of the waiting.
case remintedDuplicateIDs(titles: [String?]) case remintedDuplicateIDs(titles: [String?])
/// Loss row, warning tone: `.backgrounds/` files nothing on the board named any more were
/// moved to the system Trash, named by filename (ruled 2026-08-09 the open-time heal half
/// of the orphan tidy; the in-flow trim rides silently inside the gesture that triggers it and
/// posts nothing here).
case tidiedBackgroundImages([String])
} }
private static func post(_ notice: HealNotice, to banners: BannerCenter) { private static func post(_ notice: HealNotice, to banners: BannerCenter) {
@@ -185,6 +190,8 @@ public final class HealScheduler {
banners.postDisplacedClaimedNames(displacements) banners.postDisplacedClaimedNames(displacements)
case let .remintedDuplicateIDs(titles): case let .remintedDuplicateIDs(titles):
banners.postRemintedDuplicateIDs(titles) banners.postRemintedDuplicateIDs(titles)
case let .tidiedBackgroundImages(names):
banners.postTidiedBackgroundImages(names)
} }
} }
+73 -4
View File
@@ -2767,6 +2767,58 @@ public enum BoardWriter: Sendable {
return trashedURL as URL? 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 // MARK: - Move/copy pre-flight
/// The rank a moved or copied root lands on: the caller's explicit value a drop between /// 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. /// never the Finder-renamed one it would have landed under.
case relocateLooseFile(filename: String) 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 /// 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 /// when a markerless one has to be displaced to `CLAUDE.user.md` (08-agent-integration.md The
/// agent guide; `AgentGuide`). /// 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 // 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. // document to enrich from anyway, since the failure it describes is the read of one.
case .createBoard, .createLane, .createCard, .importAttachment, .importBoard, .listAttachments, case .createBoard, .createLane, .createCard, .importAttachment, .importBoard, .listAttachments,
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .seedGitignore, .removeAttachment, .renumberChildren, .relocateLooseFile, .tidyBackgroundImage, .agentGuide,
.mintBoardIndex, .stampSchema, .setBoardBackground, .seedGitignore, .mintBoardIndex, .stampSchema, .setBoardBackground,
.displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment, .displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment,
.editComment, .deleteComment, .purgeCommentTrash: .editComment, .deleteComment, .purgeCommentTrash:
self self
@@ -3345,8 +3413,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
.style, .resize, .collapse, .expand, .rename, .duplicateBoard, .saveAsTemplate, .shareBoard, .paste, .style, .resize, .collapse, .expand, .rename, .duplicateBoard, .saveAsTemplate, .shareBoard, .paste,
.exportBoard, .importBoard, .exportBoard, .importBoard,
.importAttachment, .importAttachment,
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore, .listAttachments, .removeAttachment, .relocateLooseFile, .tidyBackgroundImage, .agentGuide,
.mintBoardIndex, .stampSchema, .setBoardBackground, .seedGitignore, .mintBoardIndex, .stampSchema, .setBoardBackground,
.displaceClaimedName, .displaceClaimedName,
.repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment, .repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment,
.editComment, .deleteComment, .purgeCommentTrash: .editComment, .deleteComment, .purgeCommentTrash:
@@ -3388,6 +3456,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .removeAttachment(filename): "move attachment '\(filename)' to the Trash" case let .removeAttachment(filename): "move attachment '\(filename)' to the Trash"
case .renumberChildren: "renumber children" case .renumberChildren: "renumber children"
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'" case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
case let .tidyBackgroundImage(filename): "tidy background image '\(filename)'"
case .agentGuide: "update the agent guide" case .agentGuide: "update the agent guide"
case .seedGitignore: "seed the board's .gitignore" case .seedGitignore: "seed the board's .gitignore"
case .mintBoardIndex: "create this board's index.md" case .mintBoardIndex: "create this board's index.md"
+7
View File
@@ -778,6 +778,12 @@ public enum IntegrityRules: Sendable {
/// card window closes; crash residue sweeps at the next card-window open, armed-then-cleared /// card window closes; crash residue sweeps at the next card-window open, armed-then-cleared
/// like every heal memo"). The board walk never opens a thread, so no tree walk could report /// like every heal memo"). The board walk never opens a thread, so no tree walk could report
/// it. /// it.
///
/// `orphanedBackgroundImages` joins this no-`Defect`-case group for the identical reason: what
/// is orphaned is a property of **two** board-root readings taken together at the moment of
/// healing the `.backgrounds/` folder's current listing and `background.image`'s current
/// value (`BoardStore.tidyBackgroundImages`, ruled 2026-08-09) not something a tree walk over
/// lanes and cards could carry forward to the heal that acts on it.
public enum Class: Sendable, Equatable, Hashable, CaseIterable { public enum Class: Sendable, Equatable, Hashable, CaseIterable {
case looseCardFiles case looseCardFiles
case legacyTombstone case legacyTombstone
@@ -786,6 +792,7 @@ public enum IntegrityRules: Sendable {
case staleAgentGuide case staleAgentGuide
case missingGitignore case missingGitignore
case commentTrashResidue case commentTrashResidue
case orphanedBackgroundImages
} }
/// The scheduled-heal class this defect belongs to, or **`nil` where there is no heal** the /// The scheduled-heal class this defect belongs to, or **`nil` where there is no heal** the
+20
View File
@@ -96,6 +96,26 @@ enum BoardBackdrop {
/// leftover an overwritten `facets.png` already could. /// leftover an overwritten `facets.png` already could.
static let backgroundsFolderName = ".backgrounds" static let backgroundsFolderName = ".backgrounds"
/// **The bare filename `reference` names, when it is a direct child of `.backgrounds/`** the
/// read the orphan tidy needs (01-storage-format.md § Validation and healing, ruled 2026-08-09:
/// "Tidy scopes to `.backgrounds/` ONLY app-written by construction") to tell "one of ours,
/// still in service" from everything else a `background.image` could name.
///
/// `nil` for every reading that is not that one shape, folded together on purpose a missing
/// reference, a legacy bare name at board root, a hand-placed image in a subfolder of its own, a
/// reference naming a grandchild of `.backgrounds/` (`.backgrounds/art/x.png`) because every one
/// of those means the same thing to a caller asking "which file in `.backgrounds/` is spoken for
/// right now": none of them are, so every file the folder holds is a candidate. The shared
/// prefix-and-single-component test is exactly `boardImageName`'s own "only the qualified form
/// reads as ours" rule, restated as a pure function so both tidy mechanisms the in-flow trim and
/// the open-time sweep read it the same way without either owning the other's copy.
static func qualifiedBareName(of reference: String?) -> String? {
guard let reference, reference.hasPrefix("\(backgroundsFolderName)/") else { return nil }
let rest = reference.dropFirst(backgroundsFolderName.count + 1)
guard !rest.isEmpty, !rest.contains("/") else { return nil }
return String(rest)
}
/// Whether this board paints a background of its own **the window-chrome predicate** /// Whether this board paints a background of its own **the window-chrome predicate**
/// (`BoardWindowHost`, `HostedWindowController.setExtendsContentUnderTitlebar`): a board with one /// (`BoardWindowHost`, `HostedWindowController.setExtendsContentUnderTitlebar`): a board with one
/// runs its content under a transparent title bar, and a board without one keeps the standard /// runs its content under a transparent title bar, and a board without one keeps the standard
+362
View File
@@ -0,0 +1,362 @@
import Foundation
import Testing
@testable import Kanban
/// The open-time half of the orphan tidy `BoardStore.tidyBackgroundImages` (01-storage-format.md §
/// Validation and healing, ruled 2026-08-09: "any file in `.backgrounds/` not referenced by the
/// board's current `background.image` is swept"). Its sibling, the replace-in-place trim a background
/// apply makes on its own prior file, is pinned in `GeneratedBackgroundTests.swift`
/// (`BackgroundReplaceInPlaceTrimTests`); this file is the safety net behind it the scheduled heal
/// that catches whatever the trim declines (the echo window, a foreign repoint, a trim that failed and
/// was swallowed) on the next open.
///
/// Like the loose-file relocation suite this one is modeled on (`LooseFileRelocationTests.swift`), the
/// board here carries a **current agent guide and a seeded `.gitignore`** what any board the app has
/// opened once looks like so the store's *other* scheduled heals do not open brackets or write files
/// of their own and confuse the bracket counts and banner rows these tests read.
// MARK: - Fixtures
private func makeBoard(background: String? = nil) throws -> WriterFixture {
let fixture = try WriterFixture()
let line = background.map { "background: \($0)\n" } ?? ""
try fixture.item("", "---\nschema: 1\ntitle: Work\n\(line)---\nBoard description.\n")
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
try fixture.file(AgentGuide.agentsFilename, Data(AgentGuide.content.utf8))
try fixture.file(IntegrityRules.gitignoreFileName, Data(BoardWriter.gitignoreSeed.utf8))
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
return fixture
}
@MainActor
private func makeStore(_ fixture: WriterFixture) throws -> BoardStore {
try BoardStore(rootURL: fixture.root)
}
/// Counts the bracket calls a store makes `LooseFileRelocationTests.RelocationBracketLog`'s twin,
/// redeclared here since that one is private to its own file.
@MainActor
private final class TidyBracketLog {
private(set) var begins = 0
func attach(to store: BoardStore) {
store.watcherBrackets = (begin: { self.begins += 1 }, end: {})
}
}
// MARK: - The sweep
@MainActor
@Suite("Background images ▸ the open-time sweep")
struct BackgroundImageSweepTests {
@Test("An unreferenced .backgrounds/ file is removed and the referenced one is spared")
func removesUnreferencedAndSparesReferenced() throws {
let fixture = try makeBoard(background: "{image: .backgrounds/facets.png}")
defer { fixture.tearDown() }
try fixture.file(".backgrounds/facets.png", Data("current".utf8))
try fixture.file(".backgrounds/Pasted Background.png", Data("orphan".utf8))
let store = try makeStore(fixture)
store.tidyBackgroundImages()
#expect(fixture.exists(".backgrounds/facets.png"), "still named by background.image")
#expect(try fixture.data(".backgrounds/facets.png") == Data("current".utf8))
#expect(!fixture.exists(".backgrounds/Pasted Background.png"), "nothing named it any more")
#expect(store.banners.losses.map(\.message)
== ["Removed 'Pasted Background.png' — it was no longer the board's background"])
#expect(store.banners.oneShots.isEmpty)
}
@Test("A board with no background field sweeps everything .backgrounds/ holds")
func noReferenceSweepsEverything() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(".backgrounds/facets.png", Data("a".utf8))
try fixture.file(".backgrounds/Pasted Background.png", Data("b".utf8))
let store = try makeStore(fixture)
store.tidyBackgroundImages()
#expect(try fixture.entryNames(".backgrounds").isEmpty)
#expect(store.banners.losses.map(\.message) == ["Removed 2 background images — they were no longer the board's background"])
}
/// **Legacy scope, pinned at the sweep too**: a bare root-level reference is never even a
/// candidate the sweep only ever looks inside `.backgrounds/` so it survives untouched
/// whether or not it happens to share a name with something the sweep does remove.
@Test("A legacy root-level file is never swept, referenced or not")
func legacyRootLevelFileIsNeverSwept() throws {
let fixture = try makeBoard(background: "{image: facets.png}")
defer { fixture.tearDown() }
try fixture.file("facets.png", Data("legacy".utf8))
try fixture.file(".backgrounds/facets.png", Data("orphan".utf8))
let store = try makeStore(fixture)
store.tidyBackgroundImages()
#expect(try fixture.data("facets.png") == Data("legacy".utf8), "outside .backgrounds/ — never a candidate")
#expect(!fixture.exists(".backgrounds/facets.png"), "unreferenced — the legacy spelling does not protect it")
}
@Test("A missing .backgrounds/ folder is an ordinary resting state")
func missingFolderIsResting() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try makeStore(fixture)
let brackets = TidyBracketLog()
brackets.attach(to: store)
store.tidyBackgroundImages()
#expect(brackets.begins == 0)
#expect(store.banners.losses.isEmpty)
#expect(store.banners.oneShots.isEmpty)
}
@Test("A board with nothing orphaned writes nothing and says nothing")
func nothingOrphanedIsSilent() throws {
let fixture = try makeBoard(background: "{image: .backgrounds/facets.png}")
defer { fixture.tearDown() }
try fixture.file(".backgrounds/facets.png", Data("current".utf8))
let store = try makeStore(fixture)
let brackets = TidyBracketLog()
brackets.attach(to: store)
store.tidyBackgroundImages()
#expect(brackets.begins == 0)
#expect(store.banners.losses.isEmpty)
#expect(fixture.exists(".backgrounds/facets.png"))
}
/// `.skipsHiddenFiles` doing its job: the app's own crashed-write residue and Finder's litter are
/// never proposed as candidates, exactly as `BoardLoader.directoryCandidates` already treats them
/// everywhere else.
@Test("Hidden entries inside .backgrounds/ are never candidates")
func hiddenEntriesAreNeverCandidates() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(".backgrounds/.DS_Store", Data("finder".utf8))
try fixture.file(".backgrounds/.facets.png.lanework-1234", Data("residue".utf8))
let store = try makeStore(fixture)
let brackets = TidyBracketLog()
brackets.attach(to: store)
store.tidyBackgroundImages()
#expect(brackets.begins == 0)
#expect(fixture.exists(".backgrounds/.DS_Store"))
#expect(fixture.exists(".backgrounds/.facets.png.lanework-1234"))
}
/// A sub-folder of `.backgrounds/` is content the sweep leaves alone the same "only this level's
/// own contents" scope `relocateLooseFiles` gives a card's `attachments/`.
@Test("A sub-folder inside .backgrounds/ is not a candidate")
func subfoldersAreNotCandidates() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(".backgrounds/art/sunset.png", Data("nested".utf8))
let store = try makeStore(fixture)
store.tidyBackgroundImages()
#expect(fixture.exists(".backgrounds/art/sunset.png"))
#expect(store.banners.losses.isEmpty)
}
/// A locked board defers the loose-file relocation heal's own posture, restated: strays (here,
/// orphans) stay tolerated until the lock clears.
@Test("A read-only board sweeps nothing, and sweeps once the lock clears")
func readOnlyBoardDefers() async throws {
let fixture = try makeBoard(background: "{image: .backgrounds/facets.png}")
defer { fixture.tearDown() }
try fixture.file(".backgrounds/facets.png", Data("current".utf8))
try fixture.file(".backgrounds/Pasted Background.png", Data("orphan".utf8))
let store = try makeStore(fixture)
let brackets = TidyBracketLog()
brackets.attach(to: store)
store.enterUnwritableLock(.permissionDenied)
store.tidyBackgroundImages()
#expect(fixture.exists(".backgrounds/Pasted Background.png"), "tolerated under the lock")
#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 sweeps
// the orphan it had been holding back.
store.handleWatcherEvent(.treeChanged(.reconciling))
await store.awaitQuiescence()
#expect(!store.isReadOnly)
#expect(!fixture.exists(".backgrounds/Pasted Background.png"))
#expect(store.banners.losses.map(\.message)
== ["Removed 'Pasted Background.png' — it was no longer the board's background"])
}
/// The loop the guard exists for: a sweep that fails leaves the same file on disk, so the next
/// walk hands back the same work one failure, one row, then silence.
@Test("A failing sweep is attempted once, not forever")
func repeatedFailureDoesNotHotLoop() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(".backgrounds/facets.png", Data("orphan".utf8))
let orphan = fixture.root.appendingPathComponent(".backgrounds/facets.png")
try FileManager.default.setAttributes([.immutable: true], ofItemAtPath: orphan.path)
defer { try? FileManager.default.setAttributes([.immutable: false], ofItemAtPath: orphan.path) }
let store = try makeStore(fixture)
let brackets = TidyBracketLog()
brackets.attach(to: store)
store.tidyBackgroundImages()
#expect(store.banners.oneShots.count == 1)
#expect(store.banners.oneShots.first?.error.operation == .tidyBackgroundImage(filename: "facets.png"))
#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.banners.oneShots.count == 1)
#expect(brackets.begins == 1)
// A picture that actually changed is a fresh attempt.
try FileManager.default.setAttributes([.immutable: false], ofItemAtPath: orphan.path)
try fixture.file(".backgrounds/second.png", Data("also orphan".utf8))
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
#expect(brackets.begins == 2)
#expect(!fixture.exists(".backgrounds/facets.png"))
#expect(!fixture.exists(".backgrounds/second.png"))
}
@Test("runScheduledHeals reaches the sweep")
func runScheduledHealsReachesTheSweep() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(".backgrounds/facets.png", Data("orphan".utf8))
let store = try makeStore(fixture)
store.runScheduledHeals()
#expect(!fixture.exists(".backgrounds/facets.png"))
#expect(store.banners.losses.map(\.message)
== ["Removed 'facets.png' — it was no longer the board's background"])
}
}
// MARK: - The candidate listing (pure)
@Suite("Background images ▸ orphaned-file listing")
struct OrphanedBackgroundFileNamesTests {
private func fixture() throws -> WriterFixture { try WriterFixture() }
@Test("A missing folder lists as empty")
func missingFolderListsEmpty() throws {
let fixture = try fixture()
defer { fixture.tearDown() }
#expect(BoardStore.orphanedBackgroundFileNames(
inFolder: fixture.url(".backgrounds"), keeping: nil
) == [])
}
@Test("Every file lists when nothing is kept, sorted")
func everyFileListsWhenNothingIsKept() throws {
let fixture = try fixture()
defer { fixture.tearDown() }
try fixture.file(".backgrounds/b.png", Data())
try fixture.file(".backgrounds/a.png", Data())
#expect(BoardStore.orphanedBackgroundFileNames(
inFolder: fixture.url(".backgrounds"), keeping: nil
) == ["a.png", "b.png"])
}
@Test("The kept name is excluded, everything else lists")
func theKeptNameIsExcluded() throws {
let fixture = try fixture()
defer { fixture.tearDown() }
try fixture.file(".backgrounds/facets.png", Data())
try fixture.file(".backgrounds/Pasted Background.png", Data())
#expect(BoardStore.orphanedBackgroundFileNames(
inFolder: fixture.url(".backgrounds"), keeping: "facets.png"
) == ["Pasted Background.png"])
}
@Test("Hidden entries and sub-folders are excluded")
func hiddenAndNestedAreExcluded() throws {
let fixture = try fixture()
defer { fixture.tearDown() }
try fixture.file(".backgrounds/facets.png", Data())
try fixture.file(".backgrounds/.DS_Store", Data())
try fixture.file(".backgrounds/art/sunset.png", Data())
#expect(BoardStore.orphanedBackgroundFileNames(
inFolder: fixture.url(".backgrounds"), keeping: nil
) == ["facets.png"])
}
}
// MARK: - qualifiedBareName (pure)
@Suite("Background images ▸ qualifiedBareName")
struct QualifiedBareNameTests {
@Test("A qualified reference answers its bare name")
func qualifiedAnswersBareName() {
#expect(BoardBackdrop.qualifiedBareName(of: ".backgrounds/facets.png") == "facets.png")
#expect(BoardBackdrop.qualifiedBareName(of: ".backgrounds/Pasted Background.png") == "Pasted Background.png")
}
@Test("Everything that is not a direct .backgrounds/ child answers nil", arguments: [
nil, "facets.png", "art/sunset.jpg", ".backgrounds/art/sunset.png", ".backgrounds/", ".backgrounds",
"other/.backgrounds/facets.png",
] as [String?])
func everythingElseAnswersNil(_ reference: String?) {
#expect(BoardBackdrop.qualifiedBareName(of: reference) == nil)
}
}
// MARK: - Phrasing (BannerCenter owns every word)
@Suite("Background images ▸ phrasing")
struct BackgroundTidyMessageTests {
@Test("One file names it — the relocation family's own sole-item shape")
func oneFile() {
#expect(BannerCenter.tidiedBackgroundImagesMessage(for: ["facets 2.png"])
== "Removed 'facets 2.png' — it was no longer the board's background")
}
@Test("Several files fold to a count")
func severalFiles() {
#expect(BannerCenter.tidiedBackgroundImagesMessage(for: ["a.png", "b.png", "c.png"])
== "Removed 3 background images — they were no longer the board's background")
}
@Test("Nothing removed says nothing")
func nothingRemoved() {
#expect(BannerCenter.tidiedBackgroundImagesMessage(for: []) == nil)
}
/// A failed sweep is a one-shot write failure, and the banner owns its words too the successful
/// notice's verb, negated, `.relocateLooseFile`'s own precedent.
@Test("A failed removal says so in the sweep's own verb")
func failureHeadline() {
let error = BoardWriteError(
operation: .tidyBackgroundImage(filename: "facets.png"),
path: "/tmp/board/.backgrounds/facets.png",
reason: .io(message: "disk full")
)
#expect(BannerCenter.headline(for: error) == "Couldn't remove 'facets.png' — disk full")
}
}
+121 -9
View File
@@ -477,12 +477,12 @@ struct SolidBackgroundWriteTests {
#expect(after.backgroundImage == .missing) #expect(after.backgroundImage == .missing)
} }
/// **The deliberate half of the contract**: choosing a solid colour over a generated background /// **The echo-window case**: back to back with no reload in between, `snapshot` has not caught up
/// does not delete the picture on disk only the field that pointed at it. Undo has to have /// with the generation this solid choice is about to unset, so the trim it would otherwise make
/// something to point back to (`SolidBackgroundUndoTests.restoresAPriorGeneratedImage`), and even /// (`SolidBackgroundOrphanTrimTests.trimsTheGeneratedFileOnceSettled`) sees a `nil` prior and does
/// without undo the file is the user's now, not litter the app cleans up on its own. /// nothing the same staleness gate `theTwoProducersStayApart` pins for the paste path.
@Test(".backgrounds/facets.png stays on disk when the board had one") @Test(".backgrounds/facets.png survives an unsettled solid choice")
func leavesTheGeneratedFileOnDisk() throws { func leavesTheGeneratedFileOnDiskBeforeTheReloadLands() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let (store, _) = try makeStore(fixture) let (store, _) = try makeStore(fixture)
@@ -550,9 +550,11 @@ struct SolidBackgroundUndoTests {
} }
/// **Both prior fields come back** the colour a board had before, and the generated image the /// **Both prior fields come back** the colour a board had before, and the generated image the
/// solid choice pointed away from which is what makes the file surviving on disk /// solid choice pointed away from. `facets.png` here is a **legacy** bare reference at board root
/// (`SolidBackgroundWriteTests.leavesTheGeneratedFileOnDisk`) worth doing: an undo with nothing to /// (`aLegacyBareReferenceIsNotOverwrittenInPlace`'s own shape), which the orphan tidy never
/// point back at would make the surviving bytes an orphan from the moment they landed. /// touches "Tidy scopes to `.backgrounds/` ONLY" so the file surviving on disk is guaranteed
/// rather than merely likely, and an undo with nothing to point back at would make the surviving
/// bytes an orphan from the moment they landed.
@Test("A prior colour and generated image are both restored") @Test("A prior colour and generated image are both restored")
func restoresAPriorGeneratedImage() throws { func restoresAPriorGeneratedImage() throws {
let fixture = try makeBoard(background: "{color: fern, image: facets.png}") let fixture = try makeBoard(background: "{color: fern, image: facets.png}")
@@ -589,3 +591,113 @@ struct SolidBackgroundUndoTests {
#expect(store.banners.signposts.isEmpty == false, "the skip says so on the strip") #expect(store.banners.signposts.isEmpty == false, "the skip says so on the strip")
} }
} }
// MARK: - The orphan tidy replace-in-place (ruled 2026-08-09)
/// The replace-in-place half of the orphan tidy: once a background apply's own writes have landed, a
/// **settled** prior `.backgrounds/` file it repointed or unset away from is trimmed as part of the
/// same bracket (`BoardStore.tidyReplacedBackgroundImage`). "Settled" is the operative word throughout
/// every unsettled (no-reload-yet) counterpart already lives beside its sibling test:
/// `leavesTheGeneratedFileOnDiskBeforeTheReloadLands` (solid) and
/// `PasteBoardBackgroundTests.theTwoProducersStayApart` (paste after generate).
@MainActor
@Suite("BoardStore ▸ the orphan tidy's replace-in-place trim")
struct BackgroundReplaceInPlaceTrimTests {
/// The ruling's own headline case: choosing a solid colour over a *settled* generated background
/// now trims the picture the generator wrote, superseding the earlier "facets.png survives"
/// contract (`applySolidBackground`'s own doc comment carries the history).
@Test(".backgrounds/facets.png is trimmed once a solid choice has settled")
func trimsTheGeneratedFileOnceSettled() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, _) = try makeStore(fixture)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
await reload(store)
store.applySolidBackground(colorHex: "#513D1A")
#expect(!fixture.exists(".backgrounds/facets.png"), "trimmed as part of the solid write")
#expect(try document(fixture).backgroundImage == .missing)
#expect(store.banners.losses.isEmpty, "the in-flow trim is silent — the ruling's temp-file posture")
#expect(store.banners.oneShots.isEmpty)
}
/// Switching producers the other way a settled generate landing after a settled paste trims
/// the pasted file, `PasteBoardBackgroundTests.theTwoProducersStayApart`'s settled counterpart.
@Test("A settled generate trims a differently-named prior .backgrounds/ file")
func trimsAPriorFileOfADifferentName() async throws {
let fixture = try makeBoard(background: "{image: .backgrounds/Pasted Background.png}")
defer { fixture.tearDown() }
try fixture.file(".backgrounds/Pasted Background.png", Data("pasted".utf8))
let (store, _) = try makeStore(fixture)
await reload(store)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
#expect(!fixture.exists(".backgrounds/Pasted Background.png"), "trimmed by the generate that superseded it")
#expect(try fixture.data(".backgrounds/facets.png") == png)
#expect(try document(fixture).backgroundImage == .valid(".backgrounds/facets.png"))
#expect(store.banners.losses.isEmpty)
}
/// **A regeneration over the app's own fixed name never trims anything** there is no "old" file
/// distinct from the new one; `writeBoardImage`'s overwrite-in-place already lands the new bytes on
/// the very name the trim would otherwise have removed.
@Test("Regenerating the same name trims nothing")
func regeneratingTheSameNameTrimsNothing() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, _) = try makeStore(fixture)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
await reload(store)
store.applyGeneratedBackground(png: otherPNG, colorHex: "#513D1A")
#expect(try fixture.entryNames(".backgrounds") == ["facets.png"], "one file, the same name, overwritten")
#expect(try fixture.data(".backgrounds/facets.png") == otherPNG)
}
/// **Legacy stays where it is, settled or not** "Tidy scopes to `.backgrounds/` ONLY", so a bare
/// root-level reference this tidy cannot prove is app-written survives every gesture that repoints
/// or unsets it, whether or not a reload landed first.
@Test("A legacy root-level prior survives a settled repoint")
func aLegacyPriorSurvivesASettledRepoint() async throws {
let fixture = try makeBoard(background: "{image: facets.png}")
defer { fixture.tearDown() }
try fixture.file("facets.png", Data("legacy".utf8))
let (store, _) = try makeStore(fixture)
await reload(store)
store.applySolidBackground(colorHex: "#E0E5EB")
#expect(try fixture.data("facets.png") == Data("legacy".utf8), "never touched — not app-written by construction")
}
/// The ruling's "temp-file posture", proven rather than merely swallowed: an old file the app
/// cannot remove (here, one the filesystem itself refuses to touch) refuses the trim, and the
/// gesture the user actually asked for landing the new background still succeeds with no
/// banner naming a file the user never saw.
@Test("A trim that cannot land does not fail or announce the gesture that triggered it")
func aFailedTrimDoesNotSurface() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, _) = try makeStore(fixture)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
await reload(store)
let oldFile = fixture.root.appendingPathComponent(".backgrounds/facets.png")
// `uchg` the immutable flag makes even the owner unable to rename or remove this one
// file, while the folder around it stays perfectly writable for the new picture landing in it.
try FileManager.default.setAttributes([.immutable: true], ofItemAtPath: oldFile.path)
defer { try? FileManager.default.setAttributes([.immutable: false], ofItemAtPath: oldFile.path) }
let landed = store.applyPastedBackground(data: otherPNG, fileExtension: "png")
#expect(landed, "the pasted picture and the field both still land")
#expect(try fixture.data(".backgrounds/Pasted Background.png") == otherPNG)
#expect(try document(fixture).backgroundImage == .valid(".backgrounds/Pasted Background.png"))
#expect(fixture.exists(".backgrounds/facets.png"), "the immutable file could not be trimmed")
#expect(store.banners.oneShots.isEmpty, "a swallowed trim failure names nothing")
#expect(store.banners.losses.isEmpty)
}
}
+20
View File
@@ -748,6 +748,26 @@ struct PasteBoardBackgroundTests {
#expect(try background(harness.fixture).backgroundImage.value == ".backgrounds/Pasted Background.png") #expect(try background(harness.fixture).backgroundImage.value == ".backgrounds/Pasted Background.png")
} }
/// `theTwoProducersStayApart`'s settled counterpart the orphan tidy's replace-in-place trim
/// (ruled 2026-08-09): once the generate has *settled* (a reload landed and `snapshot` caught up
/// with it), a paste that repoints away from it trims the generated file, on the same terms
/// `GeneratedBackgroundTests.BackgroundReplaceInPlaceTrimTests` pins for the reverse direction.
@Test("A paste after a settled generated background trims it")
func aSettledPasteTrimsTheGeneratedFile() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let generated = encodedImage(.png, side: 4)
#expect(harness.store.applyGeneratedBackground(png: generated, colorHex: "#445566"))
await settle(harness.store)
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png, side: 8))])
#expect(harness.clipboard.pasteBoardBackground(into: harness.store))
#expect(!harness.fixture.exists(".backgrounds/\(FacetsGenerator.fileName)"), "trimmed by the settled paste")
#expect(try background(harness.fixture).backgroundImage.value == ".backgrounds/Pasted Background.png")
#expect(harness.store.banners.losses.isEmpty, "the in-flow trim is silent")
}
@Test("⌘Z puts the image subkey back and leaves the colour alone") @Test("⌘Z puts the image subkey back and leaves the colour alone")
func undoRestoresTheSubkey() throws { func undoRestoresTheSubkey() throws {
let fixture = try makeClipboardBoard() let fixture = try makeClipboardBoard()