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