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