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:
@@ -0,0 +1,270 @@
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - CardRawSourceSession
|
||||
|
||||
/// One card window's raw-source outlet: whether it is showing, the text in it, and the three seams
|
||||
/// through which it reaches the file (05-card-window.md ▸ Raw source outlet).
|
||||
///
|
||||
/// ### Why this is a window state and not a third body mode
|
||||
///
|
||||
/// `CardBodyMode` says it in its own doc comment, and this type is the other half of that sentence:
|
||||
/// the outlet "swaps the **entire content area — title, body, and sidebar —** for the literal on-disk
|
||||
/// `index.md`", so it is a state of the *window*, not of the body column. Folding it into the mode
|
||||
/// enum would have made every `switch` over Preview/Edit answer a question about the sidebar.
|
||||
///
|
||||
/// ### The mode grammar, settled where 05 leaves it open
|
||||
///
|
||||
/// 05 fixes the keys (⌥⌘E in and out, Escape is Cancel, ⌘↩ is Apply, toggling off applies) and says
|
||||
/// nothing about which body mode the window returns to. Two decisions record themselves here:
|
||||
///
|
||||
/// - **Leaving raw source lands in Preview.** Not a fresh choice — a consequence: 05 ▸ Mode grammar
|
||||
/// lists "raw-source entry" among the three events that *leave Edit* ("Leaving Edit flushes the
|
||||
/// debounce (mode flip, raw-source entry, window close)"). Entering therefore genuinely leaves
|
||||
/// Edit, and Preview is the resting state it leaves to; exiting simply reveals what was already
|
||||
/// there. It reads right, too: after an Apply that may have rewritten the body wholesale, the
|
||||
/// rendered result is the useful thing to show, not an editor over text the user just retyped.
|
||||
/// - **An empty body after Apply does not open Edit.** The empty-body rule is about *opening a card*
|
||||
/// and has already run for this window (`CardBodyPresentation.openIfNeeded`); a raw exit is not an
|
||||
/// open. A user who emptied the body in raw source gets the blank Preview they wrote, and ⌘E.
|
||||
///
|
||||
/// ### The seams, and why they are closures
|
||||
///
|
||||
/// `flushPendingEdits`, `read` and `apply` are filled in by the window once it has a store and a card
|
||||
/// (`CardWindowHost.configureSession`) — `CardBodyEditSession.save`'s precedent, and for its reason:
|
||||
/// this type is a buffer and a state machine, and it stays testable by having no idea what a board
|
||||
/// is. `nil` seams mean a window that has not joined its board yet, and every one of them fails
|
||||
/// closed — the outlet does not open, and an Apply writes nothing.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class CardRawSourceSession {
|
||||
|
||||
// MARK: State
|
||||
|
||||
/// Whether the outlet is showing — the whole of "View ▸ Raw Source's checkmark", "the content
|
||||
/// area is swapped", and "Edit Body disables while source mode is active".
|
||||
public private(set) var isActive = false
|
||||
|
||||
/// The editor's buffer: the file as it was read, plus whatever the user has typed since.
|
||||
///
|
||||
/// **Never reconciled with disk while it is open.** A watcher reload arriving mid-session updates
|
||||
/// everything else and leaves this alone — 05's dirty-buffer rule, and here it is not even a
|
||||
/// question of dirtiness: the buffer is the *whole file*, so "following disk" would mean throwing
|
||||
/// away the user's edit the moment anything on the board changed. "A pull landing mid-session
|
||||
/// neither blocks on the open buffer nor invalidates it … Apply stays last-writer-wins" (05).
|
||||
public var text = ""
|
||||
|
||||
/// The alert waiting to be shown, if any — a validation refusal on Apply, or a file that could
|
||||
/// not be opened as source. Cleared by the OK that dismisses it, which returns the user to the
|
||||
/// text they were editing (05: "a failed validation keeps source mode open (toggle stays
|
||||
/// checked) with the alert").
|
||||
public private(set) var alert: CardRawSourceAlert?
|
||||
|
||||
// MARK: Seams
|
||||
|
||||
/// Flushes pending title and body edits — "Entering source mode flushes any pending title/body
|
||||
/// edits first, **then** reads the file fresh from disk" (05). The ordering is the contract: the
|
||||
/// read must see the flushed body, or the outlet would open on a file the app was about to
|
||||
/// overwrite from a buffer the user could no longer see.
|
||||
///
|
||||
/// The window wires this to the body column's own flush *and* to the Preview flip, which is the
|
||||
/// same call: `CardBodyPresentation.setMode(.preview)` flushes on its way out of Edit by
|
||||
/// construction.
|
||||
@ObservationIgnored
|
||||
public var flushPendingEdits: (() -> Void)?
|
||||
|
||||
/// Reads the file fresh — `BoardStore.readCardSource(inCard:)`.
|
||||
@ObservationIgnored
|
||||
public var read: (() -> RawSourceReadOutcome)?
|
||||
|
||||
/// Validates and writes the buffer — `BoardStore.applyCardSource(inCard:text:)`.
|
||||
@ObservationIgnored
|
||||
public var apply: ((String) -> RawSourceApplyOutcome)?
|
||||
|
||||
/// How many Applies have actually reached the seam. The state machine's own testimony: "Cancel
|
||||
/// wrote nothing" is otherwise an inference from bytes that a passing test could reach by
|
||||
/// accident.
|
||||
@ObservationIgnored
|
||||
public private(set) var applyAttempts = 0
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - Entering
|
||||
|
||||
/// ⌥⌘E on a window that is not in source mode: flush, then read fresh, then show.
|
||||
///
|
||||
/// **A file that cannot be read does not open the outlet** (settled here — 05 covers Apply's
|
||||
/// failure and is silent on entry's). The alternative, opening an editor over an error message or
|
||||
/// over lossily-decoded bytes, would make the escape hatch's one promise false: whatever is in
|
||||
/// that editor is what is in the file, and Apply writes it back. A refusal keeps the toggle
|
||||
/// unchecked and says why.
|
||||
///
|
||||
/// - Returns: whether source mode opened.
|
||||
@discardableResult
|
||||
public func enter() -> Bool {
|
||||
guard !isActive else { return true }
|
||||
flushPendingEdits?()
|
||||
|
||||
guard let read else { return false }
|
||||
switch read() {
|
||||
case let .read(source):
|
||||
text = source
|
||||
alert = nil
|
||||
isActive = true
|
||||
return true
|
||||
case .vanished:
|
||||
// The window is dismissing itself; an alert about a card that is already gone from the
|
||||
// board would outlive the thing it is about.
|
||||
return false
|
||||
case let .failed(error):
|
||||
alert = .unreadable(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Leaving
|
||||
|
||||
/// ⌘↩, the Apply button, and **⌥⌘E toggling off** — "leaving-by-toggle commits, mirroring
|
||||
/// leaving-Edit-flushes" (05).
|
||||
///
|
||||
/// Three outcomes close the outlet and three keep it open; see `RawSourceApplyOutcome`. The two
|
||||
/// that keep it open without an alert of their own (a failed write, a locked board) are already
|
||||
/// spoken for by the banner strip and the standing lock row, and what matters here is the same in
|
||||
/// both cases: the text stays on screen, because it is the only place it exists.
|
||||
///
|
||||
/// - Returns: whether source mode closed.
|
||||
@discardableResult
|
||||
public func applyAndLeave() -> Bool {
|
||||
guard isActive else { return false }
|
||||
guard let apply else { return false }
|
||||
|
||||
applyAttempts += 1
|
||||
switch apply(text) {
|
||||
case .applied, .unchanged:
|
||||
close()
|
||||
return true
|
||||
case .vanished:
|
||||
// "A card hard-deleted externally (folder gone) discards both — nowhere left to write"
|
||||
// (05 ▸ Deletion & lifecycle).
|
||||
close()
|
||||
return true
|
||||
case let .invalid(error):
|
||||
alert = .invalid(error)
|
||||
return false
|
||||
case .suspended, .failed:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape, the Cancel button, and the window closing — **"discards without ceremony"** (05).
|
||||
///
|
||||
/// No flush, no confirmation, no `DirtyBufferGuard`: the raw buffer was never a debounced session
|
||||
/// with saves owed to it, and its Apply is an explicit act the user did not perform. The close
|
||||
/// path needs no call at all — the window goes and the buffer with it — which is exactly why this
|
||||
/// is safe to be a one-liner.
|
||||
public func cancel() {
|
||||
close()
|
||||
}
|
||||
|
||||
/// OK on the alert — back to the text, which was never touched.
|
||||
public func dismissAlert() {
|
||||
alert = nil
|
||||
}
|
||||
|
||||
private func close() {
|
||||
isActive = false
|
||||
alert = nil
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The alert
|
||||
|
||||
/// The raw-source outlet's one modal surface: **the detailed alert** 05 requires on a failed
|
||||
/// validation, plus the entry refusal that shares its shape.
|
||||
///
|
||||
/// It carries the errors rather than strings so the phrasing stays in one place and the taxonomy
|
||||
/// stays undiluted — the loader's own message, line number included, is what makes an alert
|
||||
/// "detailed" rather than "sorry, something is wrong".
|
||||
public enum CardRawSourceAlert: Sendable, Equatable {
|
||||
/// Apply refused: the text would not load (`BoardLoader.validateCardIndex`).
|
||||
case invalid(BoardLoadError)
|
||||
|
||||
/// The file could not be opened as source — unreadable, or not UTF-8.
|
||||
case unreadable(BoardWriteError)
|
||||
|
||||
/// The alert's title — what happened, in the user's vocabulary (Apply; opening the source).
|
||||
public var title: String {
|
||||
switch self {
|
||||
case .invalid: "These source changes can't be applied"
|
||||
case .unreadable: "This card's source can't be opened"
|
||||
}
|
||||
}
|
||||
|
||||
/// The detail, plus the reassurance that nothing was written. Both cases end in the same place —
|
||||
/// the file on disk is exactly as it was — which is the fact that makes the alert dismissible
|
||||
/// with a single OK.
|
||||
public var message: String {
|
||||
switch self {
|
||||
case let .invalid(error):
|
||||
"\(error.reason.description)\n\nThe file on disk is unchanged."
|
||||
case let .unreadable(error):
|
||||
"\(error.reason.description)\n\nThe file on disk is unchanged."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View ▸ Raw Source
|
||||
|
||||
/// View ▸ Raw Source (⌥⌘E) — the card window's raw-source toggle, with checkmark state
|
||||
/// (11-command-nexus.md: "Raw Source (checkmark toggle; toggling off = Apply)"; 05-card-window.md ▸
|
||||
/// Raw source outlet).
|
||||
///
|
||||
/// **A `Toggle` whose two directions are not symmetric**, which is the row's whole subtlety: on
|
||||
/// enters, off *applies*. 04-interactions.md ▸ Configurable bindings requires one stable title with
|
||||
/// checkmark state only, so the row cannot say "Apply" when it is checked — the asymmetry lives in
|
||||
/// the action, and the checkmark simply fails to clear when a validation refuses (05: "a failed
|
||||
/// validation keeps source mode open (toggle stays checked) with the alert"). That falls out for
|
||||
/// free: the checkmark reads `isActive`, and `applyAndLeave()` leaves it standing.
|
||||
///
|
||||
/// Validation is scope alone — a card window in front. The read-only lock is deliberately not part of
|
||||
/// it, `EditBodyCommand`'s reasoning: entering source mode is a *read*, and 02-architecture.md § the
|
||||
/// lock's scope keeps editor buffers alive under the lock so a locked board's file can still be
|
||||
/// opened and copied out. The Apply is the mutation, and `performWrite` refuses it there.
|
||||
struct RawSourceCommand: View {
|
||||
|
||||
@FocusedValue(\.cardRawSource) private var rawSource
|
||||
|
||||
var body: some View {
|
||||
Toggle("Raw Source", isOn: Binding(
|
||||
get: { rawSource?.isActive == true },
|
||||
set: { isOn in
|
||||
guard let rawSource else { return }
|
||||
if isOn {
|
||||
rawSource.enter()
|
||||
} else {
|
||||
rawSource.applyAndLeave()
|
||||
}
|
||||
}
|
||||
))
|
||||
.keyboardShortcut("e", modifiers: [.option, .command])
|
||||
.disabled(rawSource == nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// The focused card window's raw-source outlet, beside `FocusedValues.cardBody` — see
|
||||
/// `FocusedBoardStoreKey` for why window-scoped menu items reach their window this way.
|
||||
///
|
||||
/// Its own focused value rather than a field on `CardBodyPresentation`: the two are read by different
|
||||
/// rows (⌘E and ⌥⌘E), and one of them — Edit Body — needs *both*, which is exactly the shape a
|
||||
/// separate key expresses and a merged object would hide.
|
||||
struct FocusedCardRawSourceKey: FocusedValueKey {
|
||||
typealias Value = CardRawSourceSession
|
||||
}
|
||||
|
||||
extension FocusedValues {
|
||||
var cardRawSource: CardRawSourceSession? {
|
||||
get { self[FocusedCardRawSourceKey.self] }
|
||||
set { self[FocusedCardRawSourceKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user