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:
@@ -602,6 +602,27 @@ public final class BannerCenter {
|
||||
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
|
||||
/// 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
|
||||
@@ -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
|
||||
// about.
|
||||
"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:
|
||||
// **"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
|
||||
@@ -1284,6 +1314,24 @@ public final class BannerCenter {
|
||||
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
|
||||
/// em dash, plurals folded into their counts.
|
||||
///
|
||||
|
||||
@@ -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
|
||||
/// 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
|
||||
/// 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 colorHex: the ground colour of that render (`FacetsRecipe.primaryColorHex`) —
|
||||
@@ -2088,6 +2107,10 @@ public final class BoardStore: HealHost {
|
||||
) { document in
|
||||
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 }
|
||||
generatedBackgroundEcho = (
|
||||
@@ -2133,15 +2156,21 @@ public final class BoardStore: HealHost {
|
||||
/// undo-restores-both-fields shape, the same `WriteOperation.setBoardBackground`, the same
|
||||
/// 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
|
||||
/// generator wrote. Undo restores the `image` *field*, and a field cannot point an undo back at
|
||||
/// bytes this gesture just erased — so the file has to survive for the same reason
|
||||
/// `applyGeneratedBackground`'s own overwrite-in-place does. A board that regenerates after
|
||||
/// choosing solid still finds `facets.png` free to overwrite in place; the file becomes an orphan
|
||||
/// only when nothing in the frontmatter ever points at it again, which is the same quiet leftover
|
||||
/// a hand-deleted `image:` line already leaves.
|
||||
/// **Superseded by the ruling below** (01-storage-format.md § Validation and healing, ruled
|
||||
/// 2026-08-09): earlier, choosing a solid colour over a generated background never deleted the
|
||||
/// picture the generator wrote, on exactly the reasoning `applyGeneratedBackground`'s own overwrite-
|
||||
/// in-place note still gives for a same-name regeneration — undo restores the `image` *field*, and
|
||||
/// a field cannot point an undo back at bytes this gesture erased. The ruling reads "unsets" as one
|
||||
/// more shape of "repoints away from a `.backgrounds/` file", so this gesture now trims that file
|
||||
/// 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
|
||||
/// swatches, which read `FacetsRecipe.primaryColorHex` at the filters' tone/saturation level.
|
||||
@@ -2152,6 +2181,9 @@ public final class BoardStore: HealHost {
|
||||
let root = rootURL
|
||||
let priorImage = snapshot.backgroundImage
|
||||
let priorColor = snapshot.background
|
||||
let backgroundsFolder = root.appendingPathComponent(
|
||||
BoardBackdrop.backgroundsFolderName, isDirectory: true
|
||||
)
|
||||
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
try BoardWriter.updateIndex(
|
||||
@@ -2159,6 +2191,11 @@ public final class BoardStore: HealHost {
|
||||
) { document in
|
||||
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 }
|
||||
|
||||
@@ -2257,6 +2294,140 @@ public final class BoardStore: HealHost {
|
||||
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
|
||||
|
||||
/// **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.
|
||||
/// - **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
|
||||
/// twice in two formats therefore leaves the first file behind — the same quiet leftover
|
||||
/// choosing a solid colour over a generated background already leaves, and for the same reason:
|
||||
/// undo restores the *field*, and a field cannot point an undo back at bytes this gesture
|
||||
/// deleted.
|
||||
/// twice in two formats no longer leaves the first file behind once `snapshot` has caught up
|
||||
/// with the first paste — see the trim note below, which is `applyGeneratedBackground`'s own,
|
||||
/// restated.
|
||||
///
|
||||
/// **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
|
||||
/// 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
|
||||
/// coming".
|
||||
@discardableResult
|
||||
@@ -2305,6 +2483,11 @@ public final class BoardStore: HealHost {
|
||||
) { document in
|
||||
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 }
|
||||
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
|
||||
/// `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.
|
||||
/// **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() {
|
||||
displaceClaimedNames()
|
||||
seedGitignore()
|
||||
@@ -4959,6 +5145,7 @@ public final class BoardStore: HealHost {
|
||||
migrateLegacyTombstones()
|
||||
remintDuplicateIdentities()
|
||||
refreshAgentGuide()
|
||||
tidyBackgroundImages()
|
||||
}
|
||||
|
||||
// MARK: - Selection (delegated)
|
||||
|
||||
@@ -171,6 +171,11 @@ public final class HealScheduler {
|
||||
/// This row is what replaced the retired user-gated Repair banner: the same information, none
|
||||
/// of the waiting.
|
||||
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) {
|
||||
@@ -185,6 +190,8 @@ public final class HealScheduler {
|
||||
banners.postDisplacedClaimedNames(displacements)
|
||||
case let .remintedDuplicateIDs(titles):
|
||||
banners.postRemintedDuplicateIDs(titles)
|
||||
case let .tidiedBackgroundImages(names):
|
||||
banners.postTidiedBackgroundImages(names)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -778,6 +778,12 @@ public enum IntegrityRules: Sendable {
|
||||
/// 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
|
||||
/// 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 {
|
||||
case looseCardFiles
|
||||
case legacyTombstone
|
||||
@@ -786,6 +792,7 @@ public enum IntegrityRules: Sendable {
|
||||
case staleAgentGuide
|
||||
case missingGitignore
|
||||
case commentTrashResidue
|
||||
case orphanedBackgroundImages
|
||||
}
|
||||
|
||||
/// The scheduled-heal class this defect belongs to, or **`nil` where there is no heal** — the
|
||||
|
||||
@@ -96,6 +96,26 @@ enum BoardBackdrop {
|
||||
/// leftover an overwritten `facets.png` already could.
|
||||
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**
|
||||
/// (`BoardWindowHost`, `HostedWindowController.setExtendsContentUnderTitlebar`): a board with one
|
||||
/// runs its content under a transparent title bar, and a board without one keeps the standard
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -477,12 +477,12 @@ struct SolidBackgroundWriteTests {
|
||||
#expect(after.backgroundImage == .missing)
|
||||
}
|
||||
|
||||
/// **The deliberate half of the contract**: choosing a solid colour over a generated background
|
||||
/// does not delete the picture on disk — only the field that pointed at it. Undo has to have
|
||||
/// something to point back to (`SolidBackgroundUndoTests.restoresAPriorGeneratedImage`), and even
|
||||
/// without undo the file is the user's now, not litter the app cleans up on its own.
|
||||
@Test(".backgrounds/facets.png stays on disk when the board had one")
|
||||
func leavesTheGeneratedFileOnDisk() throws {
|
||||
/// **The echo-window case**: back to back with no reload in between, `snapshot` has not caught up
|
||||
/// with the generation this solid choice is about to unset, so the trim it would otherwise make
|
||||
/// (`SolidBackgroundOrphanTrimTests.trimsTheGeneratedFileOnceSettled`) sees a `nil` prior and does
|
||||
/// nothing — the same staleness gate `theTwoProducersStayApart` pins for the paste path.
|
||||
@Test(".backgrounds/facets.png survives an unsettled solid choice")
|
||||
func leavesTheGeneratedFileOnDiskBeforeTheReloadLands() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
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
|
||||
/// solid choice pointed away from — which is what makes the file surviving on disk
|
||||
/// (`SolidBackgroundWriteTests.leavesTheGeneratedFileOnDisk`) worth doing: an undo with nothing to
|
||||
/// point back at would make the surviving bytes an orphan from the moment they landed.
|
||||
/// solid choice pointed away from. `facets.png` here is a **legacy** bare reference at board root
|
||||
/// (`aLegacyBareReferenceIsNotOverwrittenInPlace`'s own shape), which the orphan tidy never
|
||||
/// 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")
|
||||
func restoresAPriorGeneratedImage() throws {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,6 +748,26 @@ struct PasteBoardBackgroundTests {
|
||||
#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")
|
||||
func undoRestoresTheSubkey() throws {
|
||||
let fixture = try makeClipboardBoard()
|
||||
|
||||
Reference in New Issue
Block a user