The chooser completes its three tiers: bundled by template order, then keyed user templates, then keyless boards by display name — and a malformed user template still lists, by folder name with the loader's own sentence on the row, never failing its neighbours. The store is re-scanned on every presentation and on app activation, the Reveal round trip made honest without watching a folder 09 deliberately leaves unwatched; Reveal lives in the chooser's header and mints the store on first press. Save as Template repeats Duplicate's sequence — progress row with Cancel, flush, detached cancellable copy — through the engine: mint the store, read the next user order before the copy can count itself, Finder-ladder the name, copy excluding .git and .trash/, then stamp the whole template: mapping on the landed copy through updateIndex, with no bracket because the copy lives outside every watched board. Folder attributes deliberately don't carry — the one lock the command stays live under is the read-only-DMG one, and carrying its mode bits would mint a read-only template in the user's own store; the command gates instead on the real hazard, unsaved card content. A signpost names the template only when the ladder renamed it. One name ladder now serves Duplicate and the store. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
292 lines
14 KiB
Swift
292 lines
14 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 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 }
|
|
}
|
|
}
|