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:
2026-07-28 11:25:25 -04:00
parent e989c1f26e
commit 40c0a75c24
12 changed files with 1733 additions and 65 deletions
+146
View File
@@ -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)"
}
}
}