The Background tab fills in — facets rendered to order, eight hues in a carousel
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -250,11 +250,26 @@ public enum BoardWriter: Sendable {
|
||||
/// removed best-effort and `.io` is thrown: the destination is either the old bytes or the
|
||||
/// new ones, never a mix, and never a directory littered with half-written files.
|
||||
static func atomicReplace(text: String, at fileURL: URL, operation: WriteOperation) throws(BoardWriteError) {
|
||||
try atomicWrite(Data(text.utf8), at: fileURL, operation: operation)
|
||||
// **The receipt, dropped after the bytes land and before the call returns** (the
|
||||
// EchoLedger's contract, 02-architecture.md ▸ Components). This one line covers every
|
||||
// `index.md` in the app: `updateIndex` funnels here, and so do create, materialize,
|
||||
// recreate, the task-marker flip, the body save and the raw-source Apply.
|
||||
EchoLedger.current?.recordWrite(at: fileURL, text: text)
|
||||
}
|
||||
|
||||
/// The temp-and-rename itself, with no opinion about what the bytes are — shared by the text
|
||||
/// path above and by `writeBoardImage`, so there is one atomic write in the app rather than two
|
||||
/// that could drift on the temp name, the cleanup or the `rename(2)`.
|
||||
///
|
||||
/// It drops **no receipt**: what a write means to the echo ledger differs between an `index.md`
|
||||
/// and a generated image, so each caller records its own.
|
||||
private static func atomicWrite(_ data: Data, at fileURL: URL, operation: WriteOperation) throws(BoardWriteError) {
|
||||
let directory = fileURL.deletingLastPathComponent()
|
||||
let tempURL = directory.appendingPathComponent(".\(fileURL.lastPathComponent).lanework-\(UUID().uuidString)")
|
||||
|
||||
do {
|
||||
try Data(text.utf8).write(to: tempURL)
|
||||
try data.write(to: tempURL)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
throw BoardWriteError(
|
||||
@@ -281,11 +296,51 @@ public enum BoardWriter: Sendable {
|
||||
reason: .io(message: "could not replace file: \(String(cString: strerror(status)))")
|
||||
)
|
||||
}
|
||||
// **The receipt, dropped after the bytes land and before the call returns** (the
|
||||
// EchoLedger's contract, 02-architecture.md ▸ Components). This one line covers every
|
||||
// `index.md` in the app: `updateIndex` funnels here, and so do create, materialize,
|
||||
// recreate, the task-marker flip, the body save and the raw-source Apply.
|
||||
EchoLedger.current?.recordWrite(at: fileURL, text: text)
|
||||
}
|
||||
|
||||
// MARK: - Generated board artwork
|
||||
|
||||
/// **Writes a generated background image into the board folder** — the one path in the app that
|
||||
/// puts *bytes the app composed* on disk rather than text it edited (03-board-ui.md § Styling ▸
|
||||
/// Capabilities, the `background.image` half; `FacetsGenerator`).
|
||||
///
|
||||
/// It is `atomicReplace` with a different payload and the same four properties, which is the
|
||||
/// point of it existing here rather than at the store: hidden dot-temp in the **same folder**, a
|
||||
/// POSIX rename over the destination, best-effort cleanup on failure, and a receipt so the churn
|
||||
/// classifies as the app's rather than as a foreign write. A board whose backdrop is being
|
||||
/// regenerated is a board whose renderer may be mid-decode on the old file, and a rename is the
|
||||
/// only way to hand it either the old bytes or the new ones and never a truncated file.
|
||||
///
|
||||
/// **Overwriting is the caller's decision, expressed as a name.** This writes whatever name it is
|
||||
/// given, so the policy — reuse ours, or step aside from somebody else's file — lives in one
|
||||
/// place at the store (`BoardStore.applyGeneratedBackground`) rather than being half here and
|
||||
/// half there. `name` must be a bare filename; a path is refused rather than resolved, because a
|
||||
/// background that could be written outside the board folder is the mirror of the containment
|
||||
/// rule `BoardBackdrop.imageURL(named:inBoardRoot:)` already enforces on the read side.
|
||||
///
|
||||
/// - Returns: the name written, so a caller can chain straight into the frontmatter write
|
||||
/// without restating it.
|
||||
@discardableResult
|
||||
public static func writeBoardImage(
|
||||
data: Data,
|
||||
named name: String,
|
||||
inRoot root: URL,
|
||||
operation: WriteOperation
|
||||
) throws(BoardWriteError) -> String {
|
||||
guard !name.isEmpty, !name.contains("/"), name != ".", name != ".." else {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: root.appendingPathComponent(name).path,
|
||||
reason: .io(message: "'\(name)' is not a file name a board image can be written under")
|
||||
)
|
||||
}
|
||||
let fileURL = root.appendingPathComponent(name)
|
||||
try atomicWrite(data, at: fileURL, operation: operation)
|
||||
// The bytes are already in hand, so this is the hash-what-you-wrote form rather than
|
||||
// `recordImport`'s read-it-back-and-hope — see `EchoLedger.recordImport(at:)` for the
|
||||
// difference and why the app prefers this side of it wherever it can.
|
||||
EchoLedger.current?.recordWrite(at: fileURL, data: data)
|
||||
return name
|
||||
}
|
||||
|
||||
// MARK: - Create
|
||||
@@ -2883,6 +2938,21 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
/// delete 'Fix login'" would name a gesture they never made.
|
||||
case migrateTombstone(title: String?)
|
||||
case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md)
|
||||
|
||||
/// **A generated board background landing** — the PNG written into the board folder and the
|
||||
/// `background` mapping's two subkeys pointed at it, one bracket
|
||||
/// (`BoardStore.applyGeneratedBackground`; `FacetsGenerator`).
|
||||
///
|
||||
/// Its own case rather than a fold into `.style`, on the vocabulary's standing reasoning: a
|
||||
/// restyle picks a value out of a grid of wells, while this **writes a file into the user's board
|
||||
/// folder** — a different act with a different failure ("the disk is full" means something else
|
||||
/// when a megabyte of picture is involved), and the one styling gesture whose undo cannot put
|
||||
/// everything back (see the store's own note on the overwritten bytes).
|
||||
///
|
||||
/// **No payload**, for `.mintBoardIndex`'s reason: there is one background per board, the user is
|
||||
/// looking at the board while they press the control, and the board's title would name a thing
|
||||
/// nobody could confuse for another.
|
||||
case setBoardBackground
|
||||
case resize(title: String?) // a lane's `width` — the edge drag and the stepper alike (03-board-ui.md § Lane)
|
||||
/// An inline title editor's commit — the third inline editor's write (04-interactions.md ▸
|
||||
/// Grammar). Its own case rather than a fold into `.style`: "the vocabulary grows with the
|
||||
@@ -3117,9 +3187,11 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
// `.seedGitignore`'s reasons at once: neither carries a title slot, and the board they
|
||||
// repair has no readable title to enrich from — a root with no `index.md` has no document
|
||||
// at all, and one with no `schema` is the file the walk just refused.
|
||||
// `.setBoardBackground` joins them on `.mintBoardIndex`'s reasoning: it carries no title
|
||||
// slot, and the board it writes to is the one the user is looking at.
|
||||
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
|
||||
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .seedGitignore,
|
||||
.mintBoardIndex, .stampSchema,
|
||||
.mintBoardIndex, .stampSchema, .setBoardBackground,
|
||||
.displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment,
|
||||
.editComment, .deleteComment, .purgeCommentTrash:
|
||||
self
|
||||
@@ -3183,7 +3255,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
|
||||
.style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment,
|
||||
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore,
|
||||
.mintBoardIndex, .stampSchema,
|
||||
.mintBoardIndex, .stampSchema, .setBoardBackground,
|
||||
.displaceClaimedName,
|
||||
.repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment,
|
||||
.editComment, .deleteComment, .purgeCommentTrash:
|
||||
@@ -3210,6 +3282,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case let .purge(title): Self.phrase("purge", title)
|
||||
case let .migrateTombstone(title): Self.phrase("migrate the legacy 'deleted' key on", title)
|
||||
case let .style(title): Self.phrase("style", title)
|
||||
case .setBoardBackground: "set this board's background"
|
||||
case let .resize(title): Self.phrase("resize", title)
|
||||
case let .rename(title): Self.phrase("rename", title)
|
||||
case let .duplicateBoard(title): Self.phrase("duplicate board", title)
|
||||
|
||||
Reference in New Issue
Block a user