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:
2026-07-28 11:25:25 -04:00
parent e989c1f26e
commit 40c0a75c24
12 changed files with 1733 additions and 65 deletions
+82 -2
View File
@@ -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
+19 -33
View File
@@ -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")
}
}
+13
View File
@@ -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)
}
+141
View File
@@ -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
+43 -8
View File
@@ -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:
+146
View File
@@ -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)"
}
}
}
+24 -11
View File
@@ -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))
}
}
+270
View File
@@ -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 }
}
}
+244
View File
@@ -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()
}
}
+31 -11
View File
@@ -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)
}
}
}