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
+141
View File
@@ -107,6 +107,61 @@ public enum CardBodyWriteOutcome: Sendable, Equatable {
case failed(BoardWriteError)
}
/// What came of opening a card's file in the raw-source outlet (05-card-window.md Raw source
/// outlet; `BoardStore.readCardSource(inCard:)`).
///
/// Three cases because the *entry* can be refused, which is the half of the outlet the design leaves
/// to the implementation: 05 fixes what Apply does with a bad buffer and says nothing about a file
/// that cannot be shown at all. The settled reading is that source mode does not open see
/// `CardRawSourceSession.enter()`.
public enum RawSourceReadOutcome: Sendable, Equatable {
/// The file, byte-honest, as the editor will show it.
case read(String)
/// The card is not live in this board any more hard-deleted, moved away, or tombstoned. The
/// window is dismissing itself in the same breath; there is nothing to open.
case vanished
/// The file could not be read, or is not UTF-8. The alert names it and the toggle stays
/// unchecked; nothing on disk was touched.
case failed(BoardWriteError)
}
/// What came of a raw-source Apply (05-card-window.md Raw source outlet;
/// `BoardStore.applyCardSource(inCard:text:)`).
///
/// `CardBodyWriteOutcome`'s shape and for its reason the caller holds a buffer and has to know
/// whether it may stop holding it plus the one case the body write cannot have: a proposal that
/// would not load. **Only `.applied`, `.unchanged` and `.vanished` leave source mode**; the other
/// three keep the buffer on screen with its text intact.
public enum RawSourceApplyOutcome: Sendable, Equatable {
/// The bytes landed exactly as typed. The echoing reload refreshes every window.
case applied
/// The file already read exactly like the buffer, so nothing was written an Apply on a buffer
/// that was only read. As good as `.applied`: disk says what the user means it to say, and no
/// `mtime` churn, watcher round-trip or empty commit was spent making that true.
case unchanged
/// **The text would not load** the fail-fast parse refused it (`BoardLoader.validateCardIndex`).
/// Nothing was attempted and nothing changed: source mode stays open with the detailed alert, and
/// the toggle stays checked (05: "a failed validation keeps source mode open").
case invalid(BoardLoadError)
/// The board is locked read-only. Suspended rather than failed, `CardBodyWriteOutcome.suspended`'s
/// rule: the buffer is kept, the standing lock row is the message, and nothing is posted.
case suspended(ReadOnlyLockReason)
/// The card left the board (or was tombstoned) under the open buffer. 05 Deletion & lifecycle
/// is explicit that this buffer discards rather than writes "a foreign delete is never reverted
/// by a stale buffer" so source mode closes with nothing written.
case vanished
/// The write was attempted and failed; `performWrite` has already posted the banner. The buffer
/// stays on screen, because the text is only in it.
case failed(BoardWriteError)
}
/// What a cross-board drop is doing to the items it carries the **effective** operation the
/// locality model resolved (04-interactions.md Drag and drop, settled).
///
@@ -1262,6 +1317,92 @@ public final class BoardStore {
return nil
}
// MARK: - Raw source
/// Reads a card's `index.md` as literal text, for the raw-source outlet's entry
/// (05-card-window.md Raw source outlet: "Entering source mode flushes any pending title/body
/// edits first, then reads the file fresh from disk").
///
/// **Never from the snapshot**, which is what the design's "fresh" means and what the store is
/// least able to offer: a `BoardModel` holds a parsed `FrontmatterDocument`, and re-emitting it
/// would be a rendering of the file rather than the file. It also lags the reload, so a pull or
/// an agent write that landed a moment ago would be invisible to the one surface that promises to
/// show what is actually there.
///
/// **Ancestor-walked liveness** (`liveItem`), unlike `writeCardBody`'s deliberately liveness-blind
/// walk: there is nothing to *rescue* here a tombstoned card's window is dismissing itself, and
/// opening its whole `index.md` in an editor whose Apply would undelete it is exactly what 05
/// Deletion & lifecycle forbids ("An open raw-source buffer discards instead: its Apply writes
/// the *whole* pre-tombstone `index.md` and would silently undelete the card").
///
/// No `performWrite` bracket and no banner: this is a read, and its one failure a file that is
/// not UTF-8, or is gone between the snapshot and the read is the card window's alert to raise,
/// where it can say "so source mode did not open" rather than joining a strip of write failures.
public func readCardSource(inCard cardID: ItemID) -> RawSourceReadOutcome {
guard let target = Self.liveItem(cardID, in: snapshot), let card = target.cardID else { return .vanished }
let folder = rootURL
.appendingPathComponent(target.laneID.rawValue, isDirectory: true)
.appendingPathComponent(card.rawValue, isDirectory: true)
do {
return .read(try BoardWriter.readRawSource(ofCard: folder))
} catch {
return .failed(error)
}
}
/// Applies a raw-source buffer: **validate, then write the bytes verbatim** (05-card-window.md
/// Raw source outlet).
///
/// ### Validation runs before the bracket, on purpose
///
/// A proposal that would not load is not a failed write it is a write that never started. Doing
/// it here means an invalid Apply opens no watcher bracket, posts no banner, and touches nothing;
/// the typed `BoardLoadError` travels back so the window's alert can show the loader's own detail
/// ("detailed alert on error, stays in source mode"). `BoardWriter.writeRawSource` validates the
/// same bytes through the same function again as its own guarantee the two are one call to
/// `BoardLoader.validateCardIndex`, not two rules that could drift.
///
/// ### Everything else is an ordinary store write
///
/// One `performWrite` bracket, so the echo comes back as a single app-mediated reload that
/// refreshes every window on the board; the banner posts itself on a real failure; the snapshot is
/// never touched here. The read-only lock refuses it like any other write Apply is a mutation,
/// however literal and the buffer's owner reads `.suspended` as "hold the text", the standing
/// lock row being the message.
///
/// A pull landing mid-session is not consulted at all: "Apply stays last-writer-wins" (05, citing
/// 07-sync-collab.md), the same posture the Edit buffer takes.
public func applyCardSource(inCard cardID: ItemID, text: String) -> RawSourceApplyOutcome {
guard let target = Self.liveItem(cardID, in: snapshot), let card = target.cardID else { return .vanished }
let folder = rootURL
.appendingPathComponent(target.laneID.rawValue, isDirectory: true)
.appendingPathComponent(card.rawValue, isDirectory: true)
do throws(BoardLoadError) {
_ = try BoardLoader.validateCardIndex(Data(text.utf8), path: BoardLoader.indexFileName)
} catch {
return .invalid(error)
}
do {
// The closure's signature is spelled out because it returns a value the inference wart
// `performWrite`'s doc comment records.
let wrote = try performWrite { () throws(BoardWriteError) -> Bool in
try BoardWriter.writeRawSource(inCard: folder, text: text)
}
return wrote ? .applied : .unchanged
} catch let refusal as BoardStoreWriteRefusal {
guard case let .readOnlyLocked(reason) = refusal else { return .unchanged }
return .suspended(reason)
} catch let error as BoardWriteError {
return .failed(error)
} catch {
Self.logger.error("unexpected error applying raw source: \(String(describing: error), privacy: .public)")
return .unchanged
}
}
// MARK: - Board rename
/// Writes the board's own `title` the board popover's rename field (03-board-ui.md § Board