Files
lanework/Kanban/UI/Card/CardRawSourceSession.swift
T
rzen 40c0a75c24 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
2026-07-28 11:25:25 -04:00

271 lines
12 KiB
Swift

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 }
}
}