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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user