Build Preview mode rendering
The card body's resting state: swift-markdown (pinned 0.8.0, smart typography off — Preview renders the bytes on disk) parsed into a pure BodyMarkup model with UTF-8 source offsets, rendered on one hosted TextKit 1 NSTextView — chosen because find-in-text is NSTextFinder, checkbox clicks reuse AppKit character hit-testing, links are .link attributes, and NSTextTable's automatic layout is exactly the columns-sized-to-contents rule. The GFM subset renders per 05; HTML stays verbatim code-styled text; relative images resolve against the card folder while remote URLs are never fetched, drawing a quiet chip instead. Task checkboxes are live: a click flips exactly one byte through a fresh-read, refuse-uneditable, stamp, atomic-replace write — the app's only offset-addressed write, so a moved target refuses as staleTarget and what the user saw decides the direction, netting one toggle on a double-click. Empty bodies open in Edit per CardBodyMode's opening rule, applied once; the Edit surface itself stays an honest read-only stub until its card. FindCommand prefers the card body's find over board search when a card window is focused. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -979,6 +979,68 @@ public enum BoardWriter: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Task checkboxes
|
||||
|
||||
/// Flips one task-list checkbox in an item's body — **a single-byte edit, and the only write
|
||||
/// in the app that touches the body at all** (05-card-window.md ▸ Preview: "clicking a
|
||||
/// `- [ ]` / `- [x]` checkbox flips exactly that marker in the source — a single-character
|
||||
/// textual edit; every other byte of the body is untouched").
|
||||
///
|
||||
/// It is `updateIndex`'s four steps with one addition, and the addition is why it is written
|
||||
/// out here rather than expressed as an `edits` closure: the flip can **refuse**, and
|
||||
/// `updateIndex`'s closure cannot. Everything else is identical and deliberately so — read
|
||||
/// fresh from disk, refuse an uneditable frontmatter shape, edit, stamp `modified` and clear
|
||||
/// `modified-by`, replace atomically. A toggle is "an ordinary user edit — the standard atomic
|
||||
/// write, auto-committed and undoable on git boards" (05), not a special case of anything.
|
||||
///
|
||||
/// ### The offset, and why it is re-checked
|
||||
///
|
||||
/// `bodyOffset` is a UTF-8 byte offset into the **body** (`FrontmatterDocument.body`, the text
|
||||
/// after the closing delimiter) naming the character *between* the brackets — the offset
|
||||
/// `BodyTask.markerOffset` carried out of the parse that drew the box the user clicked.
|
||||
///
|
||||
/// That parse ran against a snapshot; this call reads disk. In between, an agent, a hand edit
|
||||
/// or a pull may have rewritten the file — the same staleness `updateIndex`'s read-fresh rule
|
||||
/// exists for, except that here the *target* is a byte offset rather than a key, and a stale
|
||||
/// key merely rewrites the wrong value while a stale offset would drop an `x` into the middle
|
||||
/// of a sentence. So `BodyMarkup.flippingTaskMarker` re-verifies the brackets, the marker, and
|
||||
/// the state the user saw before anything is written, and `.staleTarget` refuses when any of
|
||||
/// the three has moved. The refusal is loud (the banner) rather than silent: the user clicked
|
||||
/// a box and it did not tick.
|
||||
///
|
||||
/// **`checked` is what the user saw, not what they want** — the flip's direction is derived
|
||||
/// from it, which is what makes a double-click land on one net toggle instead of racing.
|
||||
public static func toggleTaskMarker(
|
||||
inItemFolder folder: URL,
|
||||
bodyOffset: Int,
|
||||
checked: Bool
|
||||
) throws(BoardWriteError) {
|
||||
var operation = WriteOperation.toggleTask(title: nil)
|
||||
try checkIsDirectory(folder, describedAs: "item folder", operation: operation)
|
||||
// Lanes and cards both have bodies; a board root's is its description, and no surface
|
||||
// previews it. The same shape guard every other item write leans on keeps this call off it.
|
||||
try checkIsUUIDShaped(folder, operation: operation)
|
||||
|
||||
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
||||
var document = try readDocument(at: indexURL, operation: operation)
|
||||
operation = operation.withTitle(document.title.value)
|
||||
try checkEditable(document, at: indexURL, operation: operation)
|
||||
|
||||
guard let flipped = BodyMarkup.flippingTaskMarker(in: document.body, at: bodyOffset, expecting: checked) else {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: indexURL.path,
|
||||
reason: .staleTarget(message: "this checkbox is no longer where it was — the file changed")
|
||||
)
|
||||
}
|
||||
|
||||
document.body = flipped
|
||||
document.set(FrontmatterKeys.modified, to: .date(Date()))
|
||||
document.remove(FrontmatterKeys.modifiedBy)
|
||||
|
||||
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
|
||||
}
|
||||
|
||||
// MARK: - Attachments
|
||||
|
||||
/// The one folder this app ever creates under a card — every other subfolder under
|
||||
@@ -1496,6 +1558,14 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
/// never the Finder-renamed one it would have landed under.
|
||||
case relocateLooseFile(filename: String)
|
||||
|
||||
/// A Preview task-list checkbox being ticked or unticked (05-card-window.md ▸ Preview).
|
||||
///
|
||||
/// Its own case rather than a fold into `.rename`'s or `.style`'s neighbourhood, on the
|
||||
/// vocabulary's standing reasoning: the user clicked a checkbox, and a banner telling them the
|
||||
/// app could not "restyle" or "rename" the card would name a gesture that never happened. It is
|
||||
/// also the app's only *body* write, which is worth being able to see in a log at a glance.
|
||||
case toggleTask(title: String?)
|
||||
|
||||
/// Fills in the title once the Writer has read it off the document the operation is acting
|
||||
/// on — identity for the six cases with no title slot at all: `createBoard`/`createLane`/
|
||||
/// `createCard` are minting a file, not reading one; `importAttachment`'s "title" is the
|
||||
@@ -1520,6 +1590,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .resize: .resize(title: title)
|
||||
case .rename: .rename(title: title)
|
||||
case .duplicateBoard: .duplicateBoard(title: title)
|
||||
case .toggleTask: .toggleTask(title: title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1548,6 +1619,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .listAttachments: "list attachments"
|
||||
case .renumberChildren: "renumber children"
|
||||
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
|
||||
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1592,6 +1664,16 @@ public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertib
|
||||
/// permissions, volume error. The destination still holds its previous bytes.
|
||||
case io(message: String)
|
||||
|
||||
/// **The bytes the edit aimed at are not what the caller was shown.** The file read
|
||||
/// cleanly and its frontmatter parsed — this is not `.unreadable` — but the surgical
|
||||
/// target moved: the checkbox at that offset is gone, or is already in the state the
|
||||
/// click would have produced (05-card-window.md ▸ Preview, `toggleTaskMarker`).
|
||||
///
|
||||
/// Its own reason because the app's one *offset-addressed* write is the one place where
|
||||
/// "read fresh from disk" is not enough on its own: every other write names a key, and a
|
||||
/// key that moved is still the same key.
|
||||
case staleTarget(message: String)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case let .unreadable(message):
|
||||
@@ -1600,6 +1682,8 @@ public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertib
|
||||
"frontmatter cannot be edited in place: \(shape.description)"
|
||||
case let .io(message):
|
||||
message
|
||||
case let .staleTarget(message):
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user