Build the Raw Source outlet
The escape hatch: View > Raw Source (opt-cmd-E) unmounts the whole content area for the literal on-disk index.md in a plain monospaced editor with Cancel/Apply. Raw source is window-level state, not a third body mode — entry rides setMode(.preview), which flushes the Edit session by construction, then reads the file fresh; exit reveals Preview, and an empty body after Apply does not reopen Edit (openIfNeeded already ran). Apply validates the proposed bytes through the loader's own card checks — parseDocument's strict UTF-8/BOM rejection, schema, order — deliberately skipping the uneditable-shape refusal, since a flow-mapping card is exactly what the hatch repairs; invalid bytes alert in place with the loader's own error and no bracket opens. The write is byte-for-byte with no modified stamp and no modified-by clear, per 01's explicit carve-out — the verbatim contract outranks stamping — and identical bytes write nothing. Escape cancels, cmd-Return applies, toggle-off applies too, and cmd-E disables while raw is active via a testable predicate. Tombstoned targets refuse as vanished: a foreign delete is never reverted by a stale buffer. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -1103,6 +1103,123 @@ public enum BoardWriter: Sendable {
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Raw source
|
||||
|
||||
/// Reads a card's `index.md` as **literal text** — what the raw-source outlet opens
|
||||
/// (05-card-window.md ▸ Raw source outlet: "swaps the entire content area … for the literal
|
||||
/// on-disk `index.md` (frontmatter and all)"; "Entering source mode flushes any pending title/body
|
||||
/// edits first, then reads the file fresh from disk").
|
||||
///
|
||||
/// **Fresh from disk, never from a snapshot** — the same rule `updateIndex` opens with, and here
|
||||
/// it is the feature rather than a precaution: the user asked to see the file, and a stale
|
||||
/// in-memory rendering of it is the one thing this surface must never show.
|
||||
///
|
||||
/// **It does not parse.** Every other read in this file parses because it is about to edit a
|
||||
/// document; this one hands the bytes to a text editor. A file whose frontmatter an agent has
|
||||
/// just broken is exactly what the outlet exists to let a human fix, and refusing to *open* it
|
||||
/// would close the only door to the repair. Apply validates on the way back out.
|
||||
///
|
||||
/// The one refusal is **encoding**: bytes that are not UTF-8 cannot be shown as text without
|
||||
/// inventing characters, and applying that invention would rewrite the file into a transcoding
|
||||
/// the user never asked for. Strict `String(validating:as:)`, so a BOM survives into the buffer
|
||||
/// visibly rather than being silently swallowed and silently re-added (01-storage-format.md §
|
||||
/// Fractal layout ▸ Rules).
|
||||
public static func readRawSource(ofCard cardFolder: URL) throws(BoardWriteError) -> String {
|
||||
let operation = WriteOperation.rawSource(title: nil)
|
||||
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
|
||||
// A card's `index.md` and only a card's: the outlet is the card window's, and a lane or a
|
||||
// board root reached through it would put a surface with no window behind it on disk.
|
||||
try checkIsCardFolder(cardFolder, operation: operation)
|
||||
|
||||
let indexURL = cardFolder.appendingPathComponent(BoardLoader.indexFileName)
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: indexURL)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: indexURL.path,
|
||||
reason: .unreadable(message: "could not read file: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
guard let text = String(validating: data, as: UTF8.self) else {
|
||||
throw BoardWriteError(operation: operation, path: indexURL.path, reason: .unreadable(message: "file is not UTF-8"))
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/// Writes a card's `index.md` **byte-for-byte from the user's text** — the raw-source Apply
|
||||
/// (05-card-window.md ▸ Raw source outlet), and the one write in this app that is not a document
|
||||
/// edit at all.
|
||||
///
|
||||
/// ### Validated, then verbatim
|
||||
///
|
||||
/// 1. **Validate through the loader's own fail-fast parse** (`BoardLoader.validateCardIndex`):
|
||||
/// decode, parse, `schema`, `order`. The card window has already run this to raise its alert;
|
||||
/// it runs again here for `writeBody`'s reason — the layer that owns the bytes is the layer
|
||||
/// that can promise the app never writes a file its own loader would refuse to load, against
|
||||
/// every caller including a future one.
|
||||
/// 2. **Write the user's text exactly.** `atomicReplace` emits `Data(text.utf8)`, so what lands is
|
||||
/// the buffer's own bytes: unknown keys, comments, key order, blank lines, line endings and a
|
||||
/// missing final newline all survive because nothing re-serialized them — not because anything
|
||||
/// here remembered to preserve them.
|
||||
///
|
||||
/// ### No stamps. Deliberately, and stated twice in the design
|
||||
///
|
||||
/// This path does **not** set `modified` and does **not** clear `modified-by`, and it is the only
|
||||
/// write in the app of which both are true. 01-storage-format.md § Frontmatter settles each
|
||||
/// explicitly: `modified` "updates on every app write that rewrites the item's `index.md`, and
|
||||
/// only those … Two designed app writes therefore don't bump it, deliberately: **raw-source
|
||||
/// Apply** writes the validated buffer byte-for-byte (the verbatim contract outranks stamping)";
|
||||
/// and `modified-by` gets "One carve-out: **raw-source Apply** … writes byte-for-byte and does
|
||||
/// *not* clear a stamp the user typed or kept". 05 says the same from the other side ("including
|
||||
/// a `modified-by` stamp the user typed or kept — Apply is the one app write that doesn't clear
|
||||
/// it").
|
||||
///
|
||||
/// The reasoning is worth keeping next to the code: every other write here is *composed* by the
|
||||
/// app — the user asked for a rename, a move, a body edit, and the app decided which bytes express
|
||||
/// it, so stamping is the app reporting its own authorship. Here the user typed the bytes. A stamp
|
||||
/// would be the app editing a file it was told to write literally, and "byte-for-byte" would be
|
||||
/// false in the one place the whole feature rests on it.
|
||||
///
|
||||
/// ### The equality gate
|
||||
///
|
||||
/// Text identical to what is already on disk writes nothing and returns `false` — `writeBody`'s
|
||||
/// untouched gate, applied to the whole file instead of the body span. Apply on a buffer the user
|
||||
/// only read must not churn `mtime`, wake every watcher, and (on git boards) mint an empty commit.
|
||||
///
|
||||
/// - Returns: `true` when bytes were written, `false` when the file already read exactly like
|
||||
/// `text`.
|
||||
@discardableResult
|
||||
public static func writeRawSource(inCard cardFolder: URL, text: String) throws(BoardWriteError) -> Bool {
|
||||
var operation = WriteOperation.rawSource(title: nil)
|
||||
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
|
||||
try checkIsCardFolder(cardFolder, operation: operation)
|
||||
|
||||
let indexURL = cardFolder.appendingPathComponent(BoardLoader.indexFileName)
|
||||
// Best-effort, and both of its uses tolerate failure: the equality gate treats "cannot read"
|
||||
// as "not equal" (write it), and the title enrichment falls back to the untitled phrasing.
|
||||
let current = try? Data(contentsOf: indexURL)
|
||||
if let current, let document = try? BoardLoader.parseDocument(current, path: BoardLoader.indexFileName) {
|
||||
// The title as the file *currently* reads, not as the buffer proposes it — `.rename`'s
|
||||
// rule: a failed write names the card the user is still looking at in the title bar,
|
||||
// rather than a name that never landed.
|
||||
operation = operation.withTitle(document.title.value)
|
||||
}
|
||||
|
||||
let proposed = Data(text.utf8)
|
||||
do throws(BoardLoadError) {
|
||||
_ = try BoardLoader.validateCardIndex(proposed, path: BoardLoader.indexFileName)
|
||||
} catch {
|
||||
throw BoardWriteError(operation: operation, path: indexURL.path, reason: .invalidSource(error))
|
||||
}
|
||||
|
||||
guard current != proposed else { return false }
|
||||
|
||||
try atomicReplace(text: text, at: indexURL, operation: operation)
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Attachments
|
||||
|
||||
/// The one folder this app ever creates under a card — every other subfolder under
|
||||
@@ -1638,6 +1755,21 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
/// as the read that preceded the write found it — the name on the window they are typing in.
|
||||
case editBody(title: String?)
|
||||
|
||||
/// The card window's raw-source Apply — the whole `index.md` replaced with the text the user
|
||||
/// typed (05-card-window.md ▸ Raw source outlet).
|
||||
///
|
||||
/// Its own case beside `.editBody`, on the vocabulary's standing reasoning and then some: this is
|
||||
/// not a body write and not a frontmatter edit but the *file* being written, and it is the one
|
||||
/// operation whose bytes the app did not compose. A banner saying the app "couldn't save the
|
||||
/// card" would describe the Edit buffer the user was not in. The word the user pressed is
|
||||
/// **Apply**.
|
||||
///
|
||||
/// `title` is the card's title as the file *currently* reads it — the name on the window — never
|
||||
/// the one the buffer proposes, which may be a title that never landed (`.rename`'s rule). It is
|
||||
/// also the operation `readRawSource` carries on the way *in*; that read's failures reach an
|
||||
/// alert rather than the banner, so the Apply phrasing is never shown for one.
|
||||
case rawSource(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
|
||||
@@ -1664,6 +1796,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .duplicateBoard: .duplicateBoard(title: title)
|
||||
case .toggleTask: .toggleTask(title: title)
|
||||
case .editBody: .editBody(title: title)
|
||||
case .rawSource: .rawSource(title: title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1694,6 +1827,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
|
||||
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
|
||||
case let .editBody(title): Self.phrase("save the body of", title)
|
||||
case let .rawSource(title): Self.phrase("apply source changes to", title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1748,6 +1882,16 @@ public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertib
|
||||
/// key that moved is still the same key.
|
||||
case staleTarget(message: String)
|
||||
|
||||
/// **The text the raw-source outlet was asked to write would not load** (05-card-window.md ▸
|
||||
/// Raw source outlet: "Apply validates through the same fail-fast parse the loader uses …
|
||||
/// before writing byte-for-byte"). Carries the loader's own error — path, reason and line
|
||||
/// number — because the alert that shows it is the design's "detailed alert", and a
|
||||
/// re-worded copy would be a second, worse taxonomy.
|
||||
///
|
||||
/// Distinct from `.unreadable`, which is about the file **on disk**: here disk is fine and
|
||||
/// the *proposal* is not, so nothing was attempted and nothing changed.
|
||||
case invalidSource(BoardLoadError)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case let .unreadable(message):
|
||||
@@ -1758,6 +1902,8 @@ public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertib
|
||||
message
|
||||
case let .staleTarget(message):
|
||||
message
|
||||
case let .invalidSource(error):
|
||||
"the source text wouldn't load: \(error.description)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user