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:
@@ -117,8 +117,11 @@ final class CardWindowSession: CardSessionFlushing {
|
||||
///
|
||||
/// This file owns identity, lifecycle, the title and subtitle, and where the window opens. The
|
||||
/// two-column composition inside it is `CardWindowView`'s, and what fills those columns — the title
|
||||
/// field, Preview/Edit, the raw-source outlet, the sidebar's five sections — arrives card by card
|
||||
/// underneath a composition that does not move.
|
||||
/// field, Preview/Edit, the sidebar's five sections — arrives card by card underneath a composition
|
||||
/// that does not move. The *window-scoped* state those surfaces need lives here, because a window is
|
||||
/// what it is scoped to: the body column's mode (`CardBodyPresentation`) and the raw-source outlet
|
||||
/// (`CardRawSourceSession`), both published through the focus system so the View menu's rows can
|
||||
/// reach the frontmost card window.
|
||||
struct CardWindowHost: View {
|
||||
|
||||
let ref: CardWindowRef
|
||||
@@ -132,6 +135,10 @@ struct CardWindowHost: View {
|
||||
/// This window's body column — the Preview/Edit mode, and the ⌘F hook a menu item reaches
|
||||
/// through the focus system (`CardBodyPresentation`).
|
||||
@State private var bodyPresentation = CardBodyPresentation()
|
||||
/// This window's raw-source outlet — the whole-content-area swap View ▸ Raw Source (⌥⌘E) drives
|
||||
/// (`CardRawSourceSession`). Beside the body handle rather than inside it: the two are different
|
||||
/// scopes, and the Edit Body row reads both.
|
||||
@State private var rawSource = CardRawSourceSession()
|
||||
/// Whether a close is waiting on the dirty-buffer modal. Set when `windowShouldClose` could not
|
||||
/// flush; cleared by the resolution that lets the close resume.
|
||||
@State private var isClosePending = false
|
||||
@@ -199,6 +206,31 @@ struct CardWindowHost: View {
|
||||
// item reaches the frontmost one's body surface through this, exactly as board-window
|
||||
// items reach their window's store (`FocusedBoardStoreKey`).
|
||||
.focusedSceneValue(\.cardBody, bodyPresentation)
|
||||
// View ▸ Raw Source (⌥⌘E) reaches the frontmost card window the same way, and Edit Body
|
||||
// reads it too — "View ▸ Edit Body (⌘E) disables while source mode is active"
|
||||
// (05-card-window.md ▸ Raw source outlet).
|
||||
.focusedSceneValue(\.cardRawSource, rawSource)
|
||||
// The raw-source outlet's detailed alert, presented over this window — a validation
|
||||
// refusal on Apply, or a file that could not be opened as source. It hangs *here* rather
|
||||
// than inside the editor because the second of those fires while source mode is still
|
||||
// closed, when there is no editor on screen to present it from.
|
||||
.alert(
|
||||
rawSource.alert?.title ?? "",
|
||||
isPresented: Binding(
|
||||
get: { rawSource.alert != nil },
|
||||
set: { presented in
|
||||
guard !presented else { return }
|
||||
rawSource.dismissAlert()
|
||||
}
|
||||
),
|
||||
presenting: rawSource.alert
|
||||
) { _ in
|
||||
// One button, because there is one thing to do: OK returns to the text, which is
|
||||
// exactly where it was. Nothing was written, so there is nothing to retry or discard.
|
||||
Button("OK") { rawSource.dismissAlert() }
|
||||
} message: { alert in
|
||||
Text(alert.message)
|
||||
}
|
||||
// The one modal moment (02-architecture.md § Write-failure surfacing), presented over
|
||||
// the window whose close it is holding up — which is why it hangs here and not on the
|
||||
// board: the text being saved is this window's.
|
||||
@@ -231,6 +263,7 @@ struct CardWindowHost: View {
|
||||
cardFolder: Self.cardFolder(root: store.rootURL, placement: placement),
|
||||
bodyPresentation: bodyPresentation,
|
||||
bodySession: session.body,
|
||||
rawSource: rawSource,
|
||||
// "Under the read-only lock the controls disable in place — an in-content mutation
|
||||
// menu validation can't reach" (05 ▸ Preview). The checkbox is that control, and
|
||||
// the store's own lock is the whole predicate.
|
||||
@@ -346,6 +379,53 @@ struct CardWindowHost: View {
|
||||
bodyPresentation.flushEdits = { [session] in
|
||||
session.body.endEditSession()
|
||||
}
|
||||
Self.configureRawSource(
|
||||
rawSource,
|
||||
body: session.body,
|
||||
presentation: bodyPresentation,
|
||||
store: store,
|
||||
cardID: cardID
|
||||
)
|
||||
}
|
||||
|
||||
/// Points the raw-source outlet at its card — the outlet's three seams (05-card-window.md ▸ Raw
|
||||
/// source outlet), wired in the one place that knows both a buffer and a board.
|
||||
///
|
||||
/// **The flush is the Preview flip**, not a second mechanism: "Entering source mode flushes any
|
||||
/// pending title/body edits first" and "Leaving Edit flushes the debounce (mode flip, raw-source
|
||||
/// entry, window close)" are the same sentence read from two directions, so putting the entry
|
||||
/// through `setMode(.preview)` makes the flush structural — and settles the exit state at the same
|
||||
/// time, because a window that genuinely left Edit on the way in has Preview waiting for it on the
|
||||
/// way out (`CardRawSourceSession`). The unconditional `flush()` behind it costs nothing on a
|
||||
/// clean buffer and covers the case where the mode was already Preview with a save still owed (a
|
||||
/// tick suspended under the read-only lock, say).
|
||||
///
|
||||
/// The store is captured **weakly**, `configureSession`'s rule: an outlet still holding a closure
|
||||
/// after the board window has gone should write nothing rather than resurrect a released store.
|
||||
///
|
||||
/// `static`, and taking every collaborator as a parameter, for the reason the fate and subtitle
|
||||
/// rules are: the ordering above is the whole of "flush, *then* read fresh", it is invisible in a
|
||||
/// running window until it is wrong, and this shape is what lets a test drive the real wiring
|
||||
/// rather than a re-typed copy of it.
|
||||
static func configureRawSource(
|
||||
_ rawSource: CardRawSourceSession,
|
||||
body: CardBodyEditSession,
|
||||
presentation: CardBodyPresentation,
|
||||
store: BoardStore,
|
||||
cardID: ItemID
|
||||
) {
|
||||
rawSource.flushPendingEdits = { [body, presentation] in
|
||||
presentation.setMode(.preview)
|
||||
body.flush()
|
||||
}
|
||||
rawSource.read = { [weak store] in
|
||||
guard let store else { return .vanished }
|
||||
return store.readCardSource(inCard: cardID)
|
||||
}
|
||||
rawSource.apply = { [weak store] text in
|
||||
guard let store else { return .vanished }
|
||||
return store.applyCardSource(inCard: cardID, text: text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Size and placement — **the remembered frame first, the cascade second** (05-card-window.md
|
||||
|
||||
@@ -6,18 +6,22 @@ import SwiftUI
|
||||
///
|
||||
/// 11-command-nexus.md's own contract runs both directions: "a command absent here doesn't exist, and
|
||||
/// adding one means adding a row here first" — so once a row *is* in the Nexus, shipping the window
|
||||
/// behind it is a validation-and-action change, not a menu change. `FutureCommand` (a `Button`) and
|
||||
/// `FutureToggleCommand` (a `Toggle`) below are that reading, applied: the row exists now, stably
|
||||
/// titled and stably chorded — `NSUserKeyEquivalents` already resolves it, so a user can remap it
|
||||
/// today — with validation pinned to `false` and the action a no-op until the milestone named at the
|
||||
/// call site fills both in. That milestone's whole diff then reads as "flip `.disabled`, fill the
|
||||
/// closure" rather than "add a menu item", which is also why every call site below carries the
|
||||
/// codebase's `mN-` marker for a component still owed.
|
||||
/// behind it is a validation-and-action change, not a menu change. `FutureCommand` below is that
|
||||
/// reading, applied: the row exists now, stably titled and stably chorded — `NSUserKeyEquivalents`
|
||||
/// already resolves it, so a user can remap it today — with validation pinned to `false` and the
|
||||
/// action a no-op until the milestone named at the call site fills both in. That milestone's whole
|
||||
/// diff then reads as "flip `.disabled`, fill the closure" rather than "add a menu item", which is
|
||||
/// also why every call site below carries the codebase's `mN-` marker for a component still owed.
|
||||
///
|
||||
/// **The title never moves once a row ships**, disabled or not: a toggle wired live later must not
|
||||
/// **The title never moves once a row ships**, disabled or not: a command wired live later must not
|
||||
/// gain a second spelling on the way (04-interactions.md ▸ Configurable bindings — "toggles keep one
|
||||
/// stable title, checkmark state only" — which applies to a row that has not started ticking yet
|
||||
/// exactly as it does to one that has).
|
||||
///
|
||||
/// There was a `FutureToggleCommand` beside this — a disabled `Toggle` for a checkmark row — and it
|
||||
/// went with the last of its call sites (Raw Source, `RawSourceCommand`). Every row still owed is a
|
||||
/// plain command; a future checkmark row brings its scaffold back with it rather than keeping an
|
||||
/// unused one warm.
|
||||
struct FutureCommand: View {
|
||||
let title: String
|
||||
var key: KeyEquivalent?
|
||||
@@ -32,22 +36,6 @@ struct FutureCommand: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// `FutureCommand`'s checkmark-state twin, for a row the Nexus already marks "(checkmark toggle)".
|
||||
///
|
||||
/// `isOn` is a constant `false` rather than real state: there is no session yet for a binding to
|
||||
/// read, which is exactly the disabled, unchecked state a not-yet-wired toggle should show.
|
||||
struct FutureToggleCommand: View {
|
||||
let title: String
|
||||
var key: KeyEquivalent?
|
||||
var modifiers: EventModifiers = .command
|
||||
|
||||
var body: some View {
|
||||
Toggle(title, isOn: .constant(false))
|
||||
.keyboardShortcut(key.map { KeyboardShortcut($0, modifiers: modifiers) })
|
||||
.disabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File ▸ Save as Template
|
||||
|
||||
/// File ▸ Save as Template — no default chord (11-command-nexus.md; 09-templates.md).
|
||||
@@ -108,21 +96,19 @@ struct FindSteppingCommands: View {
|
||||
/// View ▸ Edit Body (⌘E) / Raw Source (⌥⌘E) / History — the card window's three view-state rows
|
||||
/// (11-command-nexus.md).
|
||||
///
|
||||
/// **Edit Body is live** (`EditBodyCommand`, beside the focused value it reads): the body column's
|
||||
/// Preview/Edit toggle, checkmark state and all. Its diff was the one `FutureCommand` promises —
|
||||
/// the title and the chord did not move, the validation and the action filled in.
|
||||
/// **Edit Body and Raw Source are both live** (`EditBodyCommand`, `RawSourceCommand`, each beside the
|
||||
/// focused value it reads): the body column's Preview/Edit toggle, and the window-level outlet whose
|
||||
/// toggling-off *applies*. Each diff was the one `FutureCommand` promises — the title and the chord
|
||||
/// did not move, the validation and the action filled in — and the pair also carries the clause that
|
||||
/// joins them, "Edit Body disables while Raw Source is active" (05-card-window.md).
|
||||
///
|
||||
// m6-raw-source: Raw Source is the same shape one card later — a checkmark toggle over a
|
||||
// window-level mode, which also adds the "Edit Body disables while Raw Source is active" clause to
|
||||
// the row above (05-card-window.md).
|
||||
//
|
||||
// m6-card-sidebar: History is a plain command that focuses the sidebar's History section, and
|
||||
// disables outright on mode `none` / repo-nested boards once that section exists (05-card-window.md,
|
||||
// 07-sync-collab.md). Both remain unconditionally disabled here — neither surface exists yet.
|
||||
// 07-sync-collab.md). It remains unconditionally disabled here — that surface does not exist yet.
|
||||
struct CardViewCommands: View {
|
||||
var body: some View {
|
||||
EditBodyCommand()
|
||||
FutureToggleCommand(title: "Raw Source", key: "e", modifiers: [.option, .command])
|
||||
RawSourceCommand()
|
||||
FutureCommand(title: "History")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -652,6 +652,13 @@ public final class BannerCenter {
|
||||
// act happening on its own. The keystrokes are still in the buffer — the banner says the
|
||||
// app could not put them on disk, not that they are gone.
|
||||
if let title { "Couldn't save '\(title)'" } else { "Couldn't save the card" }
|
||||
case let .rawSource(title):
|
||||
// **Apply**, because that is the button they pressed (05-card-window.md ▸ Raw source
|
||||
// outlet), and "source changes" because what failed to land is the whole file as they
|
||||
// typed it — not a save of the card's body, which is what "Couldn't save" would claim.
|
||||
// The buffer is still on screen: the banner says the app could not put those bytes on
|
||||
// disk, not that they are gone.
|
||||
if let title { "Couldn't apply source changes to '\(title)'" } else { "Couldn't apply the source changes" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -668,6 +675,12 @@ public final class BannerCenter {
|
||||
message
|
||||
case let .staleTarget(message):
|
||||
message
|
||||
case let .invalidSource(error):
|
||||
// The loader's own reason, without its path: the path is always the card's own
|
||||
// `index.md`, and the banner has already named the card. In practice the raw-source
|
||||
// outlet raises this in its alert and never here — the store validates before it opens a
|
||||
// write bracket — so this line exists for a caller that reached the Writer directly.
|
||||
error.reason.description
|
||||
}
|
||||
return trimmed(text)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -400,21 +400,30 @@ public enum BoardLoader: Sendable {
|
||||
/// explicit that a BOM'd file is rejected at load (it fails the frontmatter delimiter);
|
||||
/// decoding byte-faithfully is what makes that stated rejection actually happen.
|
||||
private static func readDocument(at url: URL, path: String) throws(BoardLoadError) -> FrontmatterDocument {
|
||||
let text: String
|
||||
let data: Data
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
guard let decoded = String(validating: data, as: UTF8.self) else {
|
||||
throw BoardLoadError(path: path, reason: .unparseableYAML(message: "file is not UTF-8", line: nil))
|
||||
}
|
||||
text = decoded
|
||||
} catch let error as BoardLoadError {
|
||||
throw error
|
||||
data = try Data(contentsOf: url)
|
||||
} catch {
|
||||
throw BoardLoadError(
|
||||
path: path,
|
||||
reason: .unparseableYAML(message: "could not read file: \(error.localizedDescription)", line: nil)
|
||||
)
|
||||
}
|
||||
return try parseDocument(data, path: path)
|
||||
}
|
||||
|
||||
/// The decode-and-parse half of `readDocument(at:path:)`, over bytes rather than a URL.
|
||||
///
|
||||
/// Split out for the raw-source outlet, which validates bytes that are **not on disk yet**
|
||||
/// (`validateCardIndex`) — and split rather than copied on purpose: "Apply validates through the
|
||||
/// same fail-fast parse the loader uses" (05-card-window.md ▸ Raw source outlet) is only true if
|
||||
/// it is literally the same function. The strict UTF-8 decode is half of what that buys — a BOM'd
|
||||
/// or non-UTF-8 proposal is rejected here by the same two lines that reject one on disk
|
||||
/// (01-storage-format.md § Fractal layout ▸ Rules).
|
||||
static func parseDocument(_ data: Data, path: String) throws(BoardLoadError) -> FrontmatterDocument {
|
||||
guard let text = String(validating: data, as: UTF8.self) else {
|
||||
throw BoardLoadError(path: path, reason: .unparseableYAML(message: "file is not UTF-8", line: nil))
|
||||
}
|
||||
|
||||
do {
|
||||
return try FrontmatterDocument.parse(text)
|
||||
@@ -424,6 +433,32 @@ public enum BoardLoader: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `data` would load as a **card's** `index.md` — the raw-source Apply's gate
|
||||
/// (05-card-window.md ▸ Raw source outlet: "Apply validates through the same fail-fast parse the
|
||||
/// loader uses (detailed alert on error, stays in source mode) before writing byte-for-byte").
|
||||
///
|
||||
/// **Exactly the three checks `load(boardRoot:)` runs on a card**, in its order and through its
|
||||
/// own functions: decode + parse (`parseDocument`), then `schema` (present, well-formed, not
|
||||
/// newer than this app) and `order` (present, well-formed) — the two fields a card must carry.
|
||||
/// Nothing card-shaped is checked beyond that, because nothing else *is*: `title` is optional,
|
||||
/// unknown keys are the whole point of the outlet, and the body is free text.
|
||||
///
|
||||
/// It deliberately does **not** check `uneditableShape`: that refusal exists for surgical
|
||||
/// span edits (`BoardWriter.updateIndex`), and raw source replaces the whole file — a flow-mapping
|
||||
/// frontmatter is precisely one of the things the escape hatch exists to let a user rewrite.
|
||||
///
|
||||
/// The error is the loader's own, undiluted, so the alert can show the taxonomy's display text
|
||||
/// (line numbers included) rather than a re-worded copy.
|
||||
///
|
||||
/// - Parameter path: what the error names — `indexFileName` from every call site today, which is
|
||||
/// what the card window's alert is about.
|
||||
public static func validateCardIndex(_ data: Data, path: String) throws(BoardLoadError) -> FrontmatterDocument {
|
||||
let document = try parseDocument(data, path: path)
|
||||
_ = try validatedSchema(in: document, path: path)
|
||||
_ = try validatedOrder(in: document, path: path)
|
||||
return document
|
||||
}
|
||||
|
||||
private static func validatedSchema(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Int {
|
||||
switch document.schema {
|
||||
case .missing:
|
||||
|
||||
@@ -1103,6 +1103,123 @@ public enum BoardWriter: Sendable {
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Raw source
|
||||
|
||||
/// Reads a card's `index.md` as **literal text** — what the raw-source outlet opens
|
||||
/// (05-card-window.md ▸ Raw source outlet: "swaps the entire content area … for the literal
|
||||
/// on-disk `index.md` (frontmatter and all)"; "Entering source mode flushes any pending title/body
|
||||
/// edits first, then reads the file fresh from disk").
|
||||
///
|
||||
/// **Fresh from disk, never from a snapshot** — the same rule `updateIndex` opens with, and here
|
||||
/// it is the feature rather than a precaution: the user asked to see the file, and a stale
|
||||
/// in-memory rendering of it is the one thing this surface must never show.
|
||||
///
|
||||
/// **It does not parse.** Every other read in this file parses because it is about to edit a
|
||||
/// document; this one hands the bytes to a text editor. A file whose frontmatter an agent has
|
||||
/// just broken is exactly what the outlet exists to let a human fix, and refusing to *open* it
|
||||
/// would close the only door to the repair. Apply validates on the way back out.
|
||||
///
|
||||
/// The one refusal is **encoding**: bytes that are not UTF-8 cannot be shown as text without
|
||||
/// inventing characters, and applying that invention would rewrite the file into a transcoding
|
||||
/// the user never asked for. Strict `String(validating:as:)`, so a BOM survives into the buffer
|
||||
/// visibly rather than being silently swallowed and silently re-added (01-storage-format.md §
|
||||
/// Fractal layout ▸ Rules).
|
||||
public static func readRawSource(ofCard cardFolder: URL) throws(BoardWriteError) -> String {
|
||||
let operation = WriteOperation.rawSource(title: nil)
|
||||
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
|
||||
// A card's `index.md` and only a card's: the outlet is the card window's, and a lane or a
|
||||
// board root reached through it would put a surface with no window behind it on disk.
|
||||
try checkIsCardFolder(cardFolder, operation: operation)
|
||||
|
||||
let indexURL = cardFolder.appendingPathComponent(BoardLoader.indexFileName)
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: indexURL)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: indexURL.path,
|
||||
reason: .unreadable(message: "could not read file: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
guard let text = String(validating: data, as: UTF8.self) else {
|
||||
throw BoardWriteError(operation: operation, path: indexURL.path, reason: .unreadable(message: "file is not UTF-8"))
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/// Writes a card's `index.md` **byte-for-byte from the user's text** — the raw-source Apply
|
||||
/// (05-card-window.md ▸ Raw source outlet), and the one write in this app that is not a document
|
||||
/// edit at all.
|
||||
///
|
||||
/// ### Validated, then verbatim
|
||||
///
|
||||
/// 1. **Validate through the loader's own fail-fast parse** (`BoardLoader.validateCardIndex`):
|
||||
/// decode, parse, `schema`, `order`. The card window has already run this to raise its alert;
|
||||
/// it runs again here for `writeBody`'s reason — the layer that owns the bytes is the layer
|
||||
/// that can promise the app never writes a file its own loader would refuse to load, against
|
||||
/// every caller including a future one.
|
||||
/// 2. **Write the user's text exactly.** `atomicReplace` emits `Data(text.utf8)`, so what lands is
|
||||
/// the buffer's own bytes: unknown keys, comments, key order, blank lines, line endings and a
|
||||
/// missing final newline all survive because nothing re-serialized them — not because anything
|
||||
/// here remembered to preserve them.
|
||||
///
|
||||
/// ### No stamps. Deliberately, and stated twice in the design
|
||||
///
|
||||
/// This path does **not** set `modified` and does **not** clear `modified-by`, and it is the only
|
||||
/// write in the app of which both are true. 01-storage-format.md § Frontmatter settles each
|
||||
/// explicitly: `modified` "updates on every app write that rewrites the item's `index.md`, and
|
||||
/// only those … Two designed app writes therefore don't bump it, deliberately: **raw-source
|
||||
/// Apply** writes the validated buffer byte-for-byte (the verbatim contract outranks stamping)";
|
||||
/// and `modified-by` gets "One carve-out: **raw-source Apply** … writes byte-for-byte and does
|
||||
/// *not* clear a stamp the user typed or kept". 05 says the same from the other side ("including
|
||||
/// a `modified-by` stamp the user typed or kept — Apply is the one app write that doesn't clear
|
||||
/// it").
|
||||
///
|
||||
/// The reasoning is worth keeping next to the code: every other write here is *composed* by the
|
||||
/// app — the user asked for a rename, a move, a body edit, and the app decided which bytes express
|
||||
/// it, so stamping is the app reporting its own authorship. Here the user typed the bytes. A stamp
|
||||
/// would be the app editing a file it was told to write literally, and "byte-for-byte" would be
|
||||
/// false in the one place the whole feature rests on it.
|
||||
///
|
||||
/// ### The equality gate
|
||||
///
|
||||
/// Text identical to what is already on disk writes nothing and returns `false` — `writeBody`'s
|
||||
/// untouched gate, applied to the whole file instead of the body span. Apply on a buffer the user
|
||||
/// only read must not churn `mtime`, wake every watcher, and (on git boards) mint an empty commit.
|
||||
///
|
||||
/// - Returns: `true` when bytes were written, `false` when the file already read exactly like
|
||||
/// `text`.
|
||||
@discardableResult
|
||||
public static func writeRawSource(inCard cardFolder: URL, text: String) throws(BoardWriteError) -> Bool {
|
||||
var operation = WriteOperation.rawSource(title: nil)
|
||||
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
|
||||
try checkIsCardFolder(cardFolder, operation: operation)
|
||||
|
||||
let indexURL = cardFolder.appendingPathComponent(BoardLoader.indexFileName)
|
||||
// Best-effort, and both of its uses tolerate failure: the equality gate treats "cannot read"
|
||||
// as "not equal" (write it), and the title enrichment falls back to the untitled phrasing.
|
||||
let current = try? Data(contentsOf: indexURL)
|
||||
if let current, let document = try? BoardLoader.parseDocument(current, path: BoardLoader.indexFileName) {
|
||||
// The title as the file *currently* reads, not as the buffer proposes it — `.rename`'s
|
||||
// rule: a failed write names the card the user is still looking at in the title bar,
|
||||
// rather than a name that never landed.
|
||||
operation = operation.withTitle(document.title.value)
|
||||
}
|
||||
|
||||
let proposed = Data(text.utf8)
|
||||
do throws(BoardLoadError) {
|
||||
_ = try BoardLoader.validateCardIndex(proposed, path: BoardLoader.indexFileName)
|
||||
} catch {
|
||||
throw BoardWriteError(operation: operation, path: indexURL.path, reason: .invalidSource(error))
|
||||
}
|
||||
|
||||
guard current != proposed else { return false }
|
||||
|
||||
try atomicReplace(text: text, at: indexURL, operation: operation)
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Attachments
|
||||
|
||||
/// The one folder this app ever creates under a card — every other subfolder under
|
||||
@@ -1638,6 +1755,21 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
/// as the read that preceded the write found it — the name on the window they are typing in.
|
||||
case editBody(title: String?)
|
||||
|
||||
/// The card window's raw-source Apply — the whole `index.md` replaced with the text the user
|
||||
/// typed (05-card-window.md ▸ Raw source outlet).
|
||||
///
|
||||
/// Its own case beside `.editBody`, on the vocabulary's standing reasoning and then some: this is
|
||||
/// not a body write and not a frontmatter edit but the *file* being written, and it is the one
|
||||
/// operation whose bytes the app did not compose. A banner saying the app "couldn't save the
|
||||
/// card" would describe the Edit buffer the user was not in. The word the user pressed is
|
||||
/// **Apply**.
|
||||
///
|
||||
/// `title` is the card's title as the file *currently* reads it — the name on the window — never
|
||||
/// the one the buffer proposes, which may be a title that never landed (`.rename`'s rule). It is
|
||||
/// also the operation `readRawSource` carries on the way *in*; that read's failures reach an
|
||||
/// alert rather than the banner, so the Apply phrasing is never shown for one.
|
||||
case rawSource(title: String?)
|
||||
|
||||
/// Fills in the title once the Writer has read it off the document the operation is acting
|
||||
/// on — identity for the six cases with no title slot at all: `createBoard`/`createLane`/
|
||||
/// `createCard` are minting a file, not reading one; `importAttachment`'s "title" is the
|
||||
@@ -1664,6 +1796,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .duplicateBoard: .duplicateBoard(title: title)
|
||||
case .toggleTask: .toggleTask(title: title)
|
||||
case .editBody: .editBody(title: title)
|
||||
case .rawSource: .rawSource(title: title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1694,6 +1827,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
|
||||
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
|
||||
case let .editBody(title): Self.phrase("save the body of", title)
|
||||
case let .rawSource(title): Self.phrase("apply source changes to", title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1748,6 +1882,16 @@ public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertib
|
||||
/// key that moved is still the same key.
|
||||
case staleTarget(message: String)
|
||||
|
||||
/// **The text the raw-source outlet was asked to write would not load** (05-card-window.md ▸
|
||||
/// Raw source outlet: "Apply validates through the same fail-fast parse the loader uses …
|
||||
/// before writing byte-for-byte"). Carries the loader's own error — path, reason and line
|
||||
/// number — because the alert that shows it is the design's "detailed alert", and a
|
||||
/// re-worded copy would be a second, worse taxonomy.
|
||||
///
|
||||
/// Distinct from `.unreadable`, which is about the file **on disk**: here disk is fine and
|
||||
/// the *proposal* is not, so nothing was attempted and nothing changed.
|
||||
case invalidSource(BoardLoadError)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case let .unreadable(message):
|
||||
@@ -1758,6 +1902,8 @@ public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertib
|
||||
message
|
||||
case let .staleTarget(message):
|
||||
message
|
||||
case let .invalidSource(error):
|
||||
"the source text wouldn't load: \(error.description)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ import SwiftUI
|
||||
///
|
||||
/// **Two cases, not three.** The raw-source outlet swaps the *entire content area* — title, body
|
||||
/// and sidebar — so it is a state of the window, not of the body column, and it does not belong in
|
||||
/// this enum. Edit Body disabling while raw source is active (11-command-nexus.md) is that
|
||||
/// window-level state's rule to enforce over this one.
|
||||
/// this enum. It lives in `CardRawSourceSession`, which is also where the grammar's two open
|
||||
/// questions are settled (which mode a raw exit lands in, and what an empty body after Apply does).
|
||||
/// Edit Body disabling while raw source is active (11-command-nexus.md) is that window-level state's
|
||||
/// rule over this one, and it is enforced on the row: `EditBodyCommand.isEnabled(body:rawSource:)`.
|
||||
public enum CardBodyMode: Equatable, Sendable {
|
||||
/// The rendered, selectable preview — **the resting state**.
|
||||
case preview
|
||||
@@ -123,18 +125,29 @@ public final class CardBodyPresentation {
|
||||
/// what changed with this milestone is the validation and the action, exactly as `FutureCommands`
|
||||
/// predicts.
|
||||
///
|
||||
/// Validation is scope: with no card window in front there is no `cardBody` focused value, and the
|
||||
/// row disables. The read-only lock is deliberately **not** part of it — entering Edit is not a
|
||||
/// mutation, and 02-architecture.md § the lock's scope keeps editor buffers alive under the lock
|
||||
/// (only their saves suspend), so a locked board can still be read in the editor and its text
|
||||
/// copied out.
|
||||
/// Validation is scope **plus the raw-source clause**: with no card window in front there is no
|
||||
/// `cardBody` focused value, and the row disables; with source mode active it disables too — "View ▸
|
||||
/// Edit Body (⌘E) disables while source mode is active, matching its toolbar item" (05 ▸ Raw source
|
||||
/// outlet; 11-command-nexus.md files the same clause on the row). The reason is that the two would
|
||||
/// be editing the same bytes from two surfaces: while the whole `index.md` is open as text, a mode
|
||||
/// flip in the body column beneath it has nothing to flip *to* — the column is not on screen — and
|
||||
/// its buffer's next debounced save would write a body the raw buffer is also about to overwrite.
|
||||
/// Cancel and Apply own the exits (03-board-ui.md ▸ Toolbar).
|
||||
///
|
||||
// m6-raw-source: "View ▸ Edit Body (⌘E) disables while source mode is active, matching its toolbar
|
||||
// item" (05 ▸ Raw source outlet). That is one more clause on `isDisabled` once a window-level raw
|
||||
// mode exists to read; the row, its title and its chord do not move.
|
||||
/// The read-only lock is deliberately **not** part of it — entering Edit is not a mutation, and
|
||||
/// 02-architecture.md § the lock's scope keeps editor buffers alive under the lock (only their saves
|
||||
/// suspend), so a locked board can still be read in the editor and its text copied out.
|
||||
struct EditBodyCommand: View {
|
||||
|
||||
@FocusedValue(\.cardBody) private var cardBody
|
||||
@FocusedValue(\.cardRawSource) private var rawSource
|
||||
|
||||
/// The row's validation, as a value a test can hold: a menu item's `.disabled` is otherwise only
|
||||
/// observable by driving the menu bar, and "⌘E disables while raw source is active" is precisely
|
||||
/// the kind of clause that regresses silently.
|
||||
static func isEnabled(body: CardBodyPresentation?, rawSource: CardRawSourceSession?) -> Bool {
|
||||
body != nil && rawSource?.isActive != true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Toggle("Edit Body", isOn: Binding(
|
||||
@@ -142,7 +155,7 @@ struct EditBodyCommand: View {
|
||||
set: { isOn in cardBody?.setMode(isOn ? .edit : .preview) }
|
||||
))
|
||||
.keyboardShortcut("e", modifiers: .command)
|
||||
.disabled(cardBody == nil)
|
||||
.disabled(!Self.isEnabled(body: cardBody, rawSource: rawSource))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - CardRawSourceView
|
||||
|
||||
/// The raw-source outlet's content: a monospaced editor over the literal `index.md`, with Cancel and
|
||||
/// Apply beneath it (05-card-window.md ▸ Raw source outlet).
|
||||
///
|
||||
/// It replaces **the whole content area** — title, body and sidebar — rather than sitting beside
|
||||
/// them, which is the design's own word for it and the reason the interactive controls are gone
|
||||
/// while it is up: "the same frontmatter is being edited as raw text, so interactive controls over it
|
||||
/// would fight the raw edit". The window keeps its title bar, its subtitle and its toolbar; nothing
|
||||
/// inside the window survives.
|
||||
///
|
||||
/// ### No syntax highlighting, deliberately
|
||||
///
|
||||
/// 05 gives the Edit editor "lightweight Markdown syntax highlighting" and gives this one exactly
|
||||
/// "a monospaced editor". The asymmetry is honest rather than an omission: this file is YAML *and*
|
||||
/// Markdown with a delimiter between them, and a Markdown pass run over the frontmatter would tint
|
||||
/// `---` as a thematic break and a `# comment` as a heading — dressing the file up as something it
|
||||
/// is not, in the one surface whose promise is that it shows the file as it is. What the editor does
|
||||
/// borrow is the *font*: `MarkdownHighlighter.baseAttributes` is the app's one monospaced run, so the
|
||||
/// two editors match without either owning a font.
|
||||
///
|
||||
/// ### What it shares with the body surface, and why
|
||||
///
|
||||
/// ⌘F, the find bar, selection, copying, session-scoped undo, and the suppression of every automatic
|
||||
/// substitution are all here too — the same reasons `CardBodySurface` gives, and one more that is
|
||||
/// specific to this surface: a smart quote substituted into YAML would be the app corrupting the
|
||||
/// user's frontmatter as they typed it.
|
||||
struct CardRawSourceView: View {
|
||||
|
||||
let session: CardRawSourceSession
|
||||
/// The window's body handle — the raw editor takes over its `findInText` while it is on screen,
|
||||
/// because the body surface it normally points at has been unmounted by the swap.
|
||||
let presentation: CardBodyPresentation
|
||||
|
||||
private var bodyPointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
CardRawSourceEditor(session: session, presentation: presentation)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(spacing: bodyPointSize * 0.75) {
|
||||
Spacer()
|
||||
// Escape. `.cancelAction` and the text view's own `cancelOperation` both aim here —
|
||||
// belt and braces, because which of the two sees the key depends on where focus is —
|
||||
// and `cancel()` is idempotent, so a double hit is one discard.
|
||||
Button("Cancel") { session.cancel() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
// **⌘↩, never plain Return** — "Return just types — it's an editor" (05). Which is
|
||||
// also why this is not `.defaultAction`: that would bind Return, and the first
|
||||
// newline the user typed in their frontmatter would apply the file instead.
|
||||
Button("Apply") { session.applyAndLeave() }
|
||||
.keyboardShortcut(.return, modifiers: .command)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
|
||||
.padding(.vertical, bodyPointSize * 0.6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The editor
|
||||
|
||||
/// The hosted text view: TextKit's own editor, configured for a file rather than for prose.
|
||||
private struct CardRawSourceEditor: NSViewRepresentable {
|
||||
|
||||
let session: CardRawSourceSession
|
||||
let presentation: CardBodyPresentation
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(session: session)
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSScrollView {
|
||||
// The stack is built by hand for `CardBodySurface`'s reason — `NSTextView(frame:)` hands back
|
||||
// a TextKit 2 view, and this app's text machinery (its find bar, its storage access) is
|
||||
// written against TextKit 1 throughout. One substrate, not two.
|
||||
let storage = NSTextStorage()
|
||||
let layoutManager = NSLayoutManager()
|
||||
storage.addLayoutManager(layoutManager)
|
||||
let container = NSTextContainer(size: CGSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
|
||||
container.widthTracksTextView = true
|
||||
layoutManager.addTextContainer(container)
|
||||
|
||||
let textView = CardRawSourceTextView(frame: .zero, textContainer: container)
|
||||
textView.delegate = context.coordinator
|
||||
textView.isEditable = true
|
||||
textView.isSelectable = true
|
||||
// Plain text in every sense: no attributes the user can introduce, and none the app adds
|
||||
// beyond the monospaced base run.
|
||||
textView.isRichText = false
|
||||
textView.drawsBackground = false
|
||||
textView.isVerticallyResizable = true
|
||||
textView.isHorizontallyResizable = false
|
||||
textView.autoresizingMask = .width
|
||||
textView.minSize = CGSize(width: 0, height: 0)
|
||||
textView.maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
||||
// The list `CardBodySurface` explains, with more at stake: a smart quote or an en dash
|
||||
// substituted into YAML is not a cosmetic liberty, it is the app rewriting the user's
|
||||
// frontmatter behind the cursor.
|
||||
textView.isAutomaticLinkDetectionEnabled = false
|
||||
textView.isAutomaticQuoteSubstitutionEnabled = false
|
||||
textView.isAutomaticDashSubstitutionEnabled = false
|
||||
textView.isAutomaticTextReplacementEnabled = false
|
||||
textView.isAutomaticSpellingCorrectionEnabled = false
|
||||
textView.isAutomaticDataDetectionEnabled = false
|
||||
textView.smartInsertDeleteEnabled = false
|
||||
textView.isContinuousSpellCheckingEnabled = false
|
||||
textView.allowsUndo = true
|
||||
textView.usesFindBar = true
|
||||
textView.isIncrementalSearchingEnabled = true
|
||||
// One run, three properties: a plain-text `NSTextView` drives display off `font` and
|
||||
// `textColor` rather than off `typingAttributes`, so all three are filled from the app's
|
||||
// single monospaced run — `MarkdownHighlighter.baseAttributes`, borrowed for its font rather
|
||||
// than for its highlighting, which this editor deliberately does not do.
|
||||
let base = MarkdownHighlighter.baseAttributes(pointSize: CardWindowMetrics.bodyPointSize)
|
||||
textView.typingAttributes = base
|
||||
textView.font = base[.font] as? NSFont
|
||||
textView.textColor = base[.foregroundColor] as? NSColor
|
||||
|
||||
let gutter = CardWindowMetrics.gutter(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
||||
textView.textContainerInset = CGSize(width: gutter, height: gutter)
|
||||
|
||||
textView.onCancel = { [session] in session.cancel() }
|
||||
|
||||
let scrollView = NSScrollView()
|
||||
scrollView.documentView = textView
|
||||
scrollView.hasVerticalScroller = true
|
||||
scrollView.hasHorizontalScroller = false
|
||||
scrollView.autohidesScrollers = true
|
||||
scrollView.drawsBackground = false
|
||||
scrollView.findBarPosition = .aboveContent
|
||||
|
||||
context.coordinator.textView = textView
|
||||
|
||||
// Deferred one turn, `CardBodySurface`'s reason: this runs inside a SwiftUI update and both
|
||||
// writes below re-enter graphs that update is already walking.
|
||||
Task { @MainActor [weak textView] in
|
||||
guard let textView else { return }
|
||||
// ⌘F is find-in-text "over the focused body surface (Preview's selectable text, the Edit
|
||||
// editor, **raw source**)" — 05 ▸ Preview names this surface explicitly. The body
|
||||
// surface's own closure died with the view the swap unmounted; this one replaces it, and
|
||||
// the body surface reclaims it when the swap goes the other way.
|
||||
presentation.findInText = { [weak textView] in
|
||||
guard let textView else { return }
|
||||
textView.window?.makeFirstResponder(textView)
|
||||
let sender = NSMenuItem()
|
||||
sender.tag = NSTextFinder.Action.showFindInterface.rawValue
|
||||
textView.performTextFinderAction(sender)
|
||||
}
|
||||
textView.window?.makeFirstResponder(textView)
|
||||
}
|
||||
|
||||
return scrollView
|
||||
}
|
||||
|
||||
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||
context.coordinator.session = session
|
||||
guard let textView = scrollView.documentView as? CardRawSourceTextView,
|
||||
let storage = textView.textStorage
|
||||
else { return }
|
||||
|
||||
// The equality guard is load-bearing, not an optimization: this runs on every keystroke (the
|
||||
// buffer changed, so SwiftUI re-ran the update), and re-setting the storage to the string it
|
||||
// already holds would collapse the selection and drop the undo stack per character typed.
|
||||
// In practice it therefore only fires once — the initial fill, where the text flows the other
|
||||
// way for the only time in the session.
|
||||
guard storage.string != session.text else { return }
|
||||
let selected = textView.selectedRange()
|
||||
storage.setAttributedString(NSAttributedString(
|
||||
string: session.text,
|
||||
attributes: MarkdownHighlighter.baseAttributes(pointSize: CardWindowMetrics.bodyPointSize)
|
||||
))
|
||||
let length = (session.text as NSString).length
|
||||
textView.setSelectedRange(NSRange(
|
||||
location: min(selected.location, length),
|
||||
length: min(selected.length, max(0, length - min(selected.location, length)))
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: Coordinator
|
||||
|
||||
@MainActor
|
||||
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||
|
||||
var session: CardRawSourceSession
|
||||
weak var textView: NSTextView?
|
||||
|
||||
/// **The editor's own undo manager** — `CardBodySurface`'s rule, applied to the surface 06
|
||||
/// names in the same breath: "While a text-editing surface is focused (card title field, body
|
||||
/// Edit mode, **raw source**, board inline rename), ⌘Z/⇧⌘Z are that editor's own text undo —
|
||||
/// standard, transient, session-scoped" (06-history-undo.md ▸ Undo routing). Owning one here
|
||||
/// rather than borrowing the window's is what keeps ⌘Z after a Cancel from reaching back into
|
||||
/// a buffer that was deliberately discarded.
|
||||
private let editorUndoManager = UndoManager()
|
||||
|
||||
init(session: CardRawSourceSession) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func undoManager(for view: NSTextView) -> UndoManager? {
|
||||
editorUndoManager
|
||||
}
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard let textView = notification.object as? NSTextView else { return }
|
||||
session.text = textView.string
|
||||
}
|
||||
|
||||
/// Focus leaving the editor ends the undo session (06's "session-scoped"), and is not a
|
||||
/// commit: only Apply writes, and it is a button and a chord, never a side effect of clicking
|
||||
/// somewhere else.
|
||||
func textDidEndEditing(_ notification: Notification) {
|
||||
editorUndoManager.removeAllActions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CardRawSourceTextView
|
||||
|
||||
/// The raw editor's text view, subclassed for exactly one key.
|
||||
///
|
||||
/// **Escape is Cancel** (05 ▸ Raw source outlet), and it has to be caught here because an editable
|
||||
/// `NSTextView` has its own meaning for it (text completion) and would swallow it before the button's
|
||||
/// key equivalent ever ran. With the find bar up the bar is first responder and this is never
|
||||
/// reached — which is correct: Escape closes the find bar, exactly as it does everywhere else in
|
||||
/// macOS.
|
||||
final class CardRawSourceTextView: NSTextView {
|
||||
|
||||
var onCancel: (() -> Void)?
|
||||
|
||||
override func cancelOperation(_ sender: Any?) {
|
||||
guard let onCancel else {
|
||||
super.cancelOperation(sender)
|
||||
return
|
||||
}
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,11 @@ import SwiftUI
|
||||
///
|
||||
/// The *shell*: the two columns, their scrolling, the sidebar's fixed width, and the read-only
|
||||
/// renderings of what the loader already knows — the card's title and its created/modified line —
|
||||
/// over the body column's two live surfaces (Preview and Edit, `CardBodySurface`). Everything that
|
||||
/// over the body column's two live surfaces (Preview and Edit, `CardBodySurface`), with the
|
||||
/// raw-source outlet swapping the pair of columns out entirely when it is active. Everything that
|
||||
/// reads or writes beyond that is later work and is marked where it lands:
|
||||
///
|
||||
/// - the title as an editable field (commit on Return / focus loss, Escape abandons),
|
||||
/// - the raw-source outlet,
|
||||
/// - the sidebar's five sections, which are section *headers* here and nothing more.
|
||||
///
|
||||
/// The placeholders are structural rather than apologetic: the sidebar's inventory and its order are
|
||||
@@ -47,6 +47,9 @@ struct CardWindowView: View {
|
||||
/// it, Preview renders it, and `adopt(diskBody:)` below is where the snapshot gets a say —
|
||||
/// which is exactly the point at which dirty-buffer-wins is decided.
|
||||
let bodySession: CardBodyEditSession
|
||||
/// This window's raw-source outlet. While it is active the two columns are gone entirely — see
|
||||
/// `body`.
|
||||
let rawSource: CardRawSourceSession
|
||||
/// Whether a checkbox may write — `false` under the board's read-only lock.
|
||||
let isEditable: Bool
|
||||
/// Commits a checkbox flip: the marker's byte offset in the body, and the state the user saw.
|
||||
@@ -57,18 +60,35 @@ struct CardWindowView: View {
|
||||
/// together when the system text size changes.
|
||||
private var bodyPointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
||||
|
||||
/// The two columns — **or the raw-source editor in place of both of them**.
|
||||
///
|
||||
/// A swap rather than an overlay, which is 05 ▸ Raw source outlet's own word for it ("swaps the
|
||||
/// **entire content area — title, body, and sidebar —** for the literal on-disk `index.md`") and
|
||||
/// what the rule underneath it requires: the same frontmatter is being edited as raw text, so a
|
||||
/// sidebar still offering to restyle the card, or a title field still writing to `title`, would
|
||||
/// be two editors racing for one file. Unmounting them is the only version of "they can't fight"
|
||||
/// that cannot be got wrong later.
|
||||
///
|
||||
/// The cost is one thing and it is accepted: the body editor's scroll position and selection do
|
||||
/// not survive a round trip through source mode, because its text view genuinely goes away. What
|
||||
/// does survive is the buffer, which is the Edit session's, not the view's — and it was flushed
|
||||
/// to disk on the way in regardless.
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
bodyColumn
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
if rawSource.isActive {
|
||||
CardRawSourceView(session: rawSource, presentation: bodyPresentation)
|
||||
} else {
|
||||
HStack(spacing: 0) {
|
||||
bodyColumn
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
|
||||
Divider()
|
||||
Divider()
|
||||
|
||||
sidebar
|
||||
// Fixed, and the one place it comes from.
|
||||
.frame(width: CardWindowMetrics.sidebarWidth(bodyPointSize: bodyPointSize))
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.background(.background.secondary)
|
||||
sidebar
|
||||
// Fixed, and the one place it comes from.
|
||||
.frame(width: CardWindowMetrics.sidebarWidth(bodyPointSize: bodyPointSize))
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.background(.background.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user