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 file as `enter()` read it — the baseline behind `holdsUnsavedText`, and never shown. /// /// `CardBodyEditSession.disk`'s counterpart, with the same job and none of its reconciliation: /// this one is set once, when the outlet opens, and is not followed by a watcher reload (the /// buffer above is deliberately never reconciled while it is open), so it answers exactly one /// question — has the user typed anything since the file was read. private var loadedText = "" /// **Whether an open outlet is holding bytes the file does not** — the raw-source half of /// 09-templates.md ▸ Save as Template's carve-out, which stays live under the /// unwritable-location lock "unless an open Edit or raw-source session holds unsaved content the /// suspended saves can't flush" (02-architecture.md ▸ Live-reload resilience). /// /// A closed outlet holds nothing (the buffer is emptied on the way out), and an open one that /// has not been typed in holds nothing either — it is showing the file. Apply is the only thing /// that writes it, and under the lock Apply is refused, which is precisely why a template taken /// past this text would silently miss it. public var holdsUnsavedText: Bool { isActive && text != loadedText } /// 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 loadedText = 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 = "" loadedText = "" } } // 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 } } }