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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,718 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The card window's raw-source outlet (05-card-window.md ▸ Raw source outlet) — the escape hatch
|
||||
/// that "keeps *everything* — unknown keys, exotic formatting — reachable in-app".
|
||||
///
|
||||
/// Its promise is a byte one, so this suite reads bytes: what Apply writes must be the buffer's own
|
||||
/// bytes and nothing else, which means **no `modified` stamp and no cleared `modified-by`** — the one
|
||||
/// write in the app of which both are true (01-storage-format.md § Frontmatter settles each
|
||||
/// explicitly). The negative half matters as much: a proposal the loader would refuse must leave the
|
||||
/// file untouched, and "the loader" has to mean the same code the loader runs, not a second parser
|
||||
/// that agrees with it today.
|
||||
///
|
||||
/// `WriterFixture`, `Ident`, `Item` and `writeFailure` come from `WriterTestSupport.swift`.
|
||||
|
||||
// MARK: - Fixture
|
||||
|
||||
/// The card the outlet opens, carrying everything a verbatim write must be able to preserve *and*
|
||||
/// everything a stamping write would have destroyed: an unknown key with an inline comment, a second
|
||||
/// unknown key in a shape the app never writes, an old `modified`, and a foreign `modified-by`.
|
||||
private let editableCard = """
|
||||
---
|
||||
schema: 1
|
||||
title: Notes
|
||||
order: 1024
|
||||
project: lanework # agent overlay
|
||||
labels: [a, b, c]
|
||||
created: 2026-01-01T09:00:00Z
|
||||
modified: 2026-02-02T09:00:00Z
|
||||
modified-by: claude
|
||||
---
|
||||
# Notes
|
||||
|
||||
Some *prose*.
|
||||
|
||||
"""
|
||||
|
||||
/// A proposal in the shape only this outlet can produce: a hand-added key, a comment above it,
|
||||
/// blank lines inside the frontmatter, an old `modified` left exactly as the user found it, a
|
||||
/// `modified-by` they typed themselves — and **no final newline**, the byte a re-serializing writer
|
||||
/// would have added back.
|
||||
private let handEditedSource = """
|
||||
---
|
||||
schema: 1
|
||||
title: Notes
|
||||
|
||||
# the user's own comment
|
||||
sphere: work
|
||||
project: lanework # agent overlay
|
||||
labels: [a, b, c]
|
||||
order: 1024
|
||||
created: 2026-01-01T09:00:00Z
|
||||
modified: 2026-02-02T09:00:00Z
|
||||
modified-by: rzen
|
||||
---
|
||||
# Notes
|
||||
|
||||
Rewritten by hand.
|
||||
"""
|
||||
|
||||
private let cardPath = "\(Ident.lane1)/\(Ident.card1)"
|
||||
|
||||
@MainActor
|
||||
private func makeBoard() throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item(cardPath, editableCard)
|
||||
return fixture
|
||||
}
|
||||
|
||||
private func modificationDate(_ fixture: WriterFixture, _ relativePath: String) throws -> Date? {
|
||||
let url = fixture.url(relativePath).appendingPathComponent("index.md")
|
||||
return try FileManager.default.attributesOfItem(atPath: url.path)[.modificationDate] as? Date
|
||||
}
|
||||
|
||||
/// Filesystem timestamps are coarse; a write inside the same tick would be invisible to an `mtime`
|
||||
/// assertion either way it goes.
|
||||
private func settleClock() {
|
||||
Thread.sleep(forTimeInterval: 0.05)
|
||||
}
|
||||
|
||||
// MARK: - Validation, through the loader's own parse
|
||||
|
||||
@MainActor
|
||||
@Suite("Raw source ▸ validation")
|
||||
struct RawSourceValidationTests {
|
||||
|
||||
@Test("A card file with unknown keys and exotic formatting is valid — that is the whole point")
|
||||
func theOutletValidatesWhatItExistsToWrite() throws {
|
||||
let document = try BoardLoader.validateCardIndex(Data(handEditedSource.utf8), path: "index.md")
|
||||
|
||||
#expect(document.title.value == "Notes")
|
||||
// Unknown keys are not merely tolerated, they are the outlet's reason for being: "the write
|
||||
// path for frontmatter the app doesn't own" (05 ▸ Details).
|
||||
#expect(document.value(for: "sphere") == .string("work"))
|
||||
}
|
||||
|
||||
@Test("The two required fields are required, with the loader's own words")
|
||||
func schemaAndOrderAreRequired() {
|
||||
let noSchema = validationFailure("---\ntitle: x\norder: 1\n---\nbody\n")
|
||||
#expect(noSchema?.reason == .missingSchema)
|
||||
|
||||
let noOrder = validationFailure("---\nschema: 1\ntitle: x\n---\nbody\n")
|
||||
#expect(noOrder?.reason == .missingOrder)
|
||||
|
||||
// The same fail-fast rule the loader applies at load: a card from a newer app is not
|
||||
// something this one may rewrite.
|
||||
let future = validationFailure("---\nschema: 99\norder: 1\n---\nbody\n")
|
||||
#expect(future?.reason == .schemaNewerThanApp(found: 99))
|
||||
}
|
||||
|
||||
@Test("Malformed YAML refuses with the loader's error, line number and all")
|
||||
func malformedYAMLRefuses() {
|
||||
let error = validationFailure("---\nschema: 1\norder: 1\n bad: [unclosed\n---\nbody\n")
|
||||
|
||||
guard case let .unparseableYAML(_, line) = error?.reason else {
|
||||
Issue.record("expected unparseable YAML, got \(String(describing: error?.reason))")
|
||||
return
|
||||
}
|
||||
// The taxonomy's display text is what the alert shows — "detailed alert" means this detail.
|
||||
#expect(error?.reason.description.contains("unparseable YAML") == true)
|
||||
#expect(line != nil, "the alert names the line the user has to go and look at")
|
||||
}
|
||||
|
||||
@Test("A file with no frontmatter at all refuses")
|
||||
func missingDelimitersRefuse() {
|
||||
let error = validationFailure("just a body, no frontmatter\n")
|
||||
#expect(error?.reason.description.contains("---") == true)
|
||||
}
|
||||
|
||||
@Test("A BOM is refused — the same rejection the loader makes, not a second rule")
|
||||
func aBOMIsRefused() {
|
||||
var bommed = Data([0xEF, 0xBB, 0xBF])
|
||||
bommed.append(Data("---\nschema: 1\norder: 1\n---\nbody\n".utf8))
|
||||
|
||||
let error = validationFailureData(bommed)
|
||||
// "A BOM'd file fails the frontmatter delimiter and is rejected the same way, deliberately"
|
||||
// (01-storage-format.md § Fractal layout ▸ Rules) — so the refusal is the delimiter's, which
|
||||
// is exactly what proves the outlet is not re-implementing the encoding rules.
|
||||
#expect(error?.reason.description.contains("---") == true)
|
||||
}
|
||||
|
||||
@Test("Bytes that are not UTF-8 are refused before anything tries to parse them")
|
||||
func invalidUTF8IsRefused() {
|
||||
var invalid = Data("---\nschema: 1\norder: 1\ntitle: ".utf8)
|
||||
invalid.append(contentsOf: [0xFF, 0xFE, 0x80])
|
||||
invalid.append(Data("\n---\nbody\n".utf8))
|
||||
|
||||
let error = validationFailureData(invalid)
|
||||
#expect(error?.reason == .unparseableYAML(message: "file is not UTF-8", line: nil))
|
||||
}
|
||||
|
||||
@Test("A flow-mapping frontmatter is valid here, though no other write in the app may touch it")
|
||||
func theUneditableShapeIsWritableAsSource() throws {
|
||||
// `BoardWriter.writeBody` refuses this shape — a surgical span edit of it cannot be
|
||||
// expressed. Raw source replaces the whole file, so it is precisely the surface that can get
|
||||
// a user *out* of such a file. Nothing here checks `uneditableShape`, deliberately.
|
||||
let document = try BoardLoader.validateCardIndex(
|
||||
Data("---\n{schema: 1, order: 1024, title: Odd}\n---\nodd body\n".utf8),
|
||||
path: "index.md"
|
||||
)
|
||||
#expect(document.uneditableShape != nil, "still uneditable in place — and still writable as source")
|
||||
}
|
||||
|
||||
private func validationFailure(_ text: String) -> BoardLoadError? {
|
||||
validationFailureData(Data(text.utf8))
|
||||
}
|
||||
|
||||
private func validationFailureData(_ data: Data) -> BoardLoadError? {
|
||||
do {
|
||||
_ = try BoardLoader.validateCardIndex(data, path: "index.md")
|
||||
Issue.record("expected the validation to refuse, but it passed")
|
||||
return nil
|
||||
} catch {
|
||||
return error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The Writer
|
||||
|
||||
@MainActor
|
||||
@Suite("BoardWriter ▸ raw source")
|
||||
struct RawSourceWriterTests {
|
||||
|
||||
@Test("Reading gives the file's literal text — comments, CRLF, missing final newline and all")
|
||||
func theReadIsByteHonest() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
// No final newline, mixed line endings, a comment: everything a round-trip through a parser
|
||||
// might tidy away.
|
||||
let odd = "---\r\nschema: 1\r\norder: 1024\n# comment\ntitle: Odd\r\n---\r\nbody with no final newline"
|
||||
try fixture.item(cardPath, odd)
|
||||
|
||||
#expect(try BoardWriter.readRawSource(ofCard: fixture.url(cardPath)) == odd)
|
||||
}
|
||||
|
||||
@Test("A file that is not UTF-8 refuses to open as source rather than opening a lie")
|
||||
func nonUTF8RefusesToOpen() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
var bytes = Data("---\nschema: 1\norder: 1024\ntitle: ".utf8)
|
||||
bytes.append(contentsOf: [0xFF, 0xFE])
|
||||
bytes.append(Data("\n---\nbody\n".utf8))
|
||||
try fixture.item(cardPath, bytes: bytes)
|
||||
|
||||
var thrown: BoardWriteError?
|
||||
do {
|
||||
_ = try BoardWriter.readRawSource(ofCard: fixture.url(cardPath))
|
||||
Issue.record("expected the read to refuse")
|
||||
} catch {
|
||||
thrown = error
|
||||
}
|
||||
#expect(thrown?.reason == .unreadable(message: "file is not UTF-8"))
|
||||
}
|
||||
|
||||
@Test("Only a card's file is reachable — not a lane's, not the board's")
|
||||
func onlyCardsAreReachable() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
for folder in [fixture.root, fixture.url(Ident.lane1)] {
|
||||
let error = writeFailure { try BoardWriter.writeRawSource(inCard: folder, text: editableCard) }
|
||||
if case .unreadable = error?.reason {} else {
|
||||
Issue.record("expected an unreadable refusal, got \(String(describing: error?.reason))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Apply writes the buffer's bytes exactly — no stamp, no cleared modified-by, no added newline")
|
||||
func applyIsByteForByte() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let wrote = try BoardWriter.writeRawSource(inCard: fixture.url(cardPath), text: handEditedSource)
|
||||
|
||||
#expect(wrote)
|
||||
// The whole contract in one assertion: what is on disk *is* the proposal.
|
||||
#expect(try fixture.indexData(cardPath) == Data(handEditedSource.utf8))
|
||||
// And the three things a composed app write would have done to it, spelled out because each
|
||||
// is settled prose rather than an implementation detail:
|
||||
let after = try fixture.indexText(cardPath)
|
||||
// 1. `modified` untouched — "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)" (01-storage-format.md § Frontmatter).
|
||||
#expect(after.contains("modified: 2026-02-02T09:00:00Z"))
|
||||
// 2. `modified-by` kept — "One carve-out: **raw-source Apply** … writes byte-for-byte and does
|
||||
// *not* clear a stamp the user typed or kept" (01), and 05 from the other side.
|
||||
#expect(after.contains("modified-by: rzen"))
|
||||
// 3. No final newline invented.
|
||||
#expect(!after.hasSuffix("\n"))
|
||||
}
|
||||
|
||||
@Test("Byte-for-byte still means the file was rewritten — mtime moves, content is the proposal")
|
||||
func aRealApplyTouchesTheFile() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try modificationDate(fixture, cardPath)
|
||||
settleClock()
|
||||
|
||||
try BoardWriter.writeRawSource(inCard: fixture.url(cardPath), text: handEditedSource)
|
||||
|
||||
// The stamping choice is about *content*: `modified` describes the item, `mtime` describes
|
||||
// the file, and the file genuinely changed. Pinning both directions is what keeps a future
|
||||
// "helpful" stamp from passing the byte assertion by luck.
|
||||
#expect(try modificationDate(fixture, cardPath) != before)
|
||||
#expect(try fixture.indexData(cardPath) == Data(handEditedSource.utf8))
|
||||
}
|
||||
|
||||
@Test("Applying the text the file already has writes nothing at all — bytes and mtime")
|
||||
func anUnchangedBufferIsNeverReSerialized() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.indexData(cardPath)
|
||||
let mtime = try modificationDate(fixture, cardPath)
|
||||
settleClock()
|
||||
|
||||
let wrote = try BoardWriter.writeRawSource(inCard: fixture.url(cardPath), text: editableCard)
|
||||
|
||||
#expect(!wrote, "Apply on a buffer that was only read must not churn the file")
|
||||
#expect(try fixture.indexData(cardPath) == before)
|
||||
#expect(try modificationDate(fixture, cardPath) == mtime)
|
||||
}
|
||||
|
||||
@Test("A proposal that would not load is refused, and the file keeps every byte it had")
|
||||
func aRefusedProposalWritesNothing() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.indexData(cardPath)
|
||||
let mtime = try modificationDate(fixture, cardPath)
|
||||
settleClock()
|
||||
|
||||
let error = writeFailure {
|
||||
try BoardWriter.writeRawSource(
|
||||
inCard: fixture.url(cardPath),
|
||||
text: "---\nschema: 1\n order: [unclosed\n---\nbody\n"
|
||||
)
|
||||
}
|
||||
|
||||
guard case let .invalidSource(loadError) = error?.reason else {
|
||||
Issue.record("expected an invalid-source refusal, got \(String(describing: error?.reason))")
|
||||
return
|
||||
}
|
||||
if case .unparseableYAML = loadError.reason {} else {
|
||||
Issue.record("expected the loader's own parse error, got \(loadError.reason)")
|
||||
}
|
||||
#expect(try fixture.indexData(cardPath) == before)
|
||||
#expect(try modificationDate(fixture, cardPath) == mtime)
|
||||
}
|
||||
|
||||
@Test("A BOM'd proposal is refused by the same rule, and writes nothing")
|
||||
func aBOMdProposalWritesNothing() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let before = try fixture.indexData(cardPath)
|
||||
|
||||
let error = writeFailure {
|
||||
try BoardWriter.writeRawSource(inCard: fixture.url(cardPath), text: "\u{FEFF}" + editableCard)
|
||||
}
|
||||
|
||||
if case .invalidSource = error?.reason {} else {
|
||||
Issue.record("expected an invalid-source refusal, got \(String(describing: error?.reason))")
|
||||
}
|
||||
#expect(try fixture.indexData(cardPath) == before)
|
||||
}
|
||||
|
||||
@Test("A failure names the card by the title the file still carries")
|
||||
func failuresNameTheCard() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let folder = fixture.url(cardPath)
|
||||
// Unwritable folder: the read, the title enrichment and the validation all succeed, and then
|
||||
// the atomic replace cannot land its temp file.
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: folder.path)
|
||||
|
||||
let error = writeFailure {
|
||||
try BoardWriter.writeRawSource(inCard: folder, text: handEditedSource)
|
||||
}
|
||||
|
||||
// The title on the window — read off disk, never off the buffer, which may propose a name
|
||||
// that never landed (`WriteOperation.rawSource`).
|
||||
#expect(error?.operation == .rawSource(title: "Notes"))
|
||||
#expect(
|
||||
BannerCenter.headline(for: try #require(error))
|
||||
.hasPrefix("Couldn't apply source changes to 'Notes'")
|
||||
)
|
||||
}
|
||||
|
||||
@Test("A flow-mapping card — unwritable by every other path — is rewritable as source")
|
||||
func theEscapeHatchEscapes() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let path = "\(Ident.lane1)/\(Ident.card3)"
|
||||
try fixture.item(path, "---\n{schema: 1, order: 3072, title: Odd}\n---\nodd body\n")
|
||||
// `writeBody` refuses this file outright; the outlet is how a user gets out of it.
|
||||
#expect(writeFailure { try BoardWriter.writeBody(inItemFolder: fixture.url(path), body: "new") } != nil)
|
||||
|
||||
let repaired = "---\nschema: 1\norder: 3072\ntitle: Odd\n---\nodd body\n"
|
||||
try BoardWriter.writeRawSource(inCard: fixture.url(path), text: repaired)
|
||||
|
||||
#expect(try fixture.indexData(path) == Data(repaired.utf8))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Through the store
|
||||
|
||||
@MainActor
|
||||
@Suite("BoardStore ▸ raw source")
|
||||
struct RawSourceStoreTests {
|
||||
|
||||
@Test("Reading gives the file, and Apply puts the buffer on disk verbatim")
|
||||
func theStoreReadsAndApplies() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let card = ItemID(rawValue: Ident.card1)
|
||||
|
||||
#expect(store.readCardSource(inCard: card) == .read(editableCard))
|
||||
#expect(store.applyCardSource(inCard: card, text: handEditedSource) == .applied)
|
||||
// Read back off disk, never through the snapshot: the one-way flow means the snapshot only
|
||||
// catches up when the watcher's reload lands (02-architecture.md).
|
||||
#expect(try fixture.indexData(cardPath) == Data(handEditedSource.utf8))
|
||||
}
|
||||
|
||||
@Test("Applying the file's own text reports unchanged and writes nothing")
|
||||
func anUnchangedApplyWritesNothing() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let before = try fixture.indexData(cardPath)
|
||||
let mtime = try modificationDate(fixture, cardPath)
|
||||
settleClock()
|
||||
|
||||
#expect(store.applyCardSource(inCard: ItemID(rawValue: Ident.card1), text: editableCard) == .unchanged)
|
||||
#expect(try fixture.indexData(cardPath) == before)
|
||||
#expect(try modificationDate(fixture, cardPath) == mtime)
|
||||
}
|
||||
|
||||
@Test("An invalid buffer is refused before any bracket opens — no write, no banner")
|
||||
func anInvalidBufferPostsNothing() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let before = try fixture.indexData(cardPath)
|
||||
|
||||
let outcome = store.applyCardSource(
|
||||
inCard: ItemID(rawValue: Ident.card1),
|
||||
text: "---\ntitle: no schema here\norder: 1\n---\nbody\n"
|
||||
)
|
||||
|
||||
#expect(outcome == .invalid(BoardLoadError(path: "index.md", reason: .missingSchema)))
|
||||
#expect(try fixture.indexData(cardPath) == before)
|
||||
// The alert is the surfacing for this one — a banner as well would say the same thing twice,
|
||||
// and a write that never started is not a failed write.
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A tombstoned card takes no Apply — a stale buffer never undeletes a card")
|
||||
func aTombstonedCardIsRefused() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try BoardWriter.deleteItem(at: fixture.url(cardPath))
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let before = try fixture.indexData(cardPath)
|
||||
let card = ItemID(rawValue: Ident.card1)
|
||||
|
||||
// 05 ▸ Deletion & lifecycle: "An open raw-source buffer discards instead: its Apply writes
|
||||
// the *whole* pre-tombstone `index.md` and would silently undelete the card — a foreign
|
||||
// delete is never reverted by a stale buffer." The Edit buffer's flush is the opposite rule,
|
||||
// deliberately, which is why the two walks differ (`liveItem` vs `cardBodyTarget`).
|
||||
#expect(store.readCardSource(inCard: card) == .vanished)
|
||||
#expect(store.applyCardSource(inCard: card, text: handEditedSource) == .vanished)
|
||||
#expect(try fixture.indexData(cardPath) == before)
|
||||
}
|
||||
|
||||
@Test("A card that is not in the board at all is vanished too")
|
||||
func anAbsentCardIsVanished() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
#expect(store.readCardSource(inCard: ItemID(rawValue: Ident.card4)) == .vanished)
|
||||
#expect(store.applyCardSource(inCard: ItemID(rawValue: Ident.card4), text: handEditedSource) == .vanished)
|
||||
}
|
||||
|
||||
@Test("A read-only board suspends the Apply rather than failing it")
|
||||
func theLockSuspends() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let before = try fixture.indexData(cardPath)
|
||||
let card = ItemID(rawValue: Ident.card1)
|
||||
store.enterVanishedRootLock()
|
||||
|
||||
// Apply is a mutation, however literal, so the lock refuses it like every other write — and
|
||||
// the buffer's owner reads this as "hold the text", the standing lock row being the message.
|
||||
#expect(store.applyCardSource(inCard: card, text: handEditedSource) == .suspended(.vanishedRoot))
|
||||
#expect(try fixture.indexData(cardPath) == before)
|
||||
#expect(store.banners.oneShots.isEmpty, "the lock's row is the message; a refused Apply posts nothing")
|
||||
// Reading is not a write: a locked board still opens its source, which is how the text gets
|
||||
// copied out (02-architecture.md § the lock's scope).
|
||||
#expect(store.readCardSource(inCard: card) == .read(editableCard))
|
||||
}
|
||||
|
||||
@Test("A failed write reports the error, and the banner has it")
|
||||
func aFailedApplyReports() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o500],
|
||||
ofItemAtPath: fixture.url(cardPath).path
|
||||
)
|
||||
|
||||
let outcome = store.applyCardSource(inCard: ItemID(rawValue: Ident.card1), text: handEditedSource)
|
||||
|
||||
guard case let .failed(error) = outcome else {
|
||||
Issue.record("expected a failure, got \(outcome)")
|
||||
return
|
||||
}
|
||||
#expect(error.operation == .rawSource(title: "Notes"))
|
||||
#expect(store.banners.oneShots.contains {
|
||||
BannerCenter.headline(for: $0.error).hasPrefix("Couldn't apply source changes to 'Notes'")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The session
|
||||
|
||||
/// The outlet's state machine, driven through **the window's own wiring**
|
||||
/// (`CardWindowHost.configureRawSource`) rather than a re-typed copy of it: the ordering it encodes —
|
||||
/// flush, *then* read fresh — is invisible in a running window until it is wrong.
|
||||
@MainActor
|
||||
@Suite("Card raw source ▸ session")
|
||||
struct RawSourceSessionTests {
|
||||
|
||||
/// Everything a card window holds, wired exactly as the host wires it.
|
||||
private struct Rig {
|
||||
let fixture: WriterFixture
|
||||
let store: BoardStore
|
||||
let presentation: CardBodyPresentation
|
||||
let body: CardBodyEditSession
|
||||
let raw: CardRawSourceSession
|
||||
}
|
||||
|
||||
private func makeRig() throws -> Rig {
|
||||
let fixture = try makeBoard()
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let cardID = ItemID(rawValue: Ident.card1)
|
||||
|
||||
let presentation = CardBodyPresentation()
|
||||
let body = CardBodyEditSession()
|
||||
body.save = { [weak store] text in
|
||||
guard let store else { return .vanished }
|
||||
return store.writeCardBody(inCard: cardID, body: text)
|
||||
}
|
||||
body.adopt(diskBody: try FrontmatterDocument.parse(fixture.indexText(cardPath)).body)
|
||||
presentation.flushEdits = { body.endEditSession() }
|
||||
|
||||
let raw = CardRawSourceSession()
|
||||
CardWindowHost.configureRawSource(raw, body: body, presentation: presentation, store: store, cardID: cardID)
|
||||
|
||||
return Rig(fixture: fixture, store: store, presentation: presentation, body: body, raw: raw)
|
||||
}
|
||||
|
||||
@Test("Entering flushes the Edit buffer first, then reads what the flush put on disk")
|
||||
func enteringFlushesThenReadsFresh() throws {
|
||||
let rig = try makeRig()
|
||||
defer { rig.fixture.tearDown() }
|
||||
rig.presentation.setMode(.edit)
|
||||
// Unsaved keystrokes, well inside the debounce: nothing has reached disk yet.
|
||||
rig.body.edited("# Notes\n\nTyped but not yet saved.\n")
|
||||
#expect(rig.body.isDirty)
|
||||
#expect(!(try rig.fixture.indexText(cardPath).contains("not yet saved")))
|
||||
|
||||
#expect(rig.raw.enter())
|
||||
|
||||
// The ordering, both halves. If the read had run first the editor would show a file the app
|
||||
// was a moment from overwriting from a buffer the user could no longer see.
|
||||
#expect(try rig.fixture.indexText(cardPath).contains("Typed but not yet saved."))
|
||||
#expect(rig.raw.text.contains("Typed but not yet saved."))
|
||||
#expect(rig.raw.text == (try rig.fixture.indexText(cardPath)), "the buffer is the file, byte for byte")
|
||||
#expect(!rig.body.isDirty)
|
||||
}
|
||||
|
||||
@Test("Entering from Edit leaves the body column in Preview — and leaving raw source lands there")
|
||||
func rawSourceExitsToPreview() throws {
|
||||
let rig = try makeRig()
|
||||
defer { rig.fixture.tearDown() }
|
||||
rig.presentation.setMode(.edit)
|
||||
|
||||
rig.raw.enter()
|
||||
|
||||
// 05 lists "raw-source entry" among the three events that *leave Edit*, so entering genuinely
|
||||
// leaves it and Preview — the resting state — is what the exit reveals. Recorded as a choice
|
||||
// because 05 does not name the exit mode itself.
|
||||
#expect(rig.presentation.mode == .preview)
|
||||
rig.raw.cancel()
|
||||
#expect(rig.presentation.mode == .preview)
|
||||
#expect(!rig.raw.isActive)
|
||||
}
|
||||
|
||||
@Test("An empty body after Apply does not drag the window into Edit")
|
||||
func theOpeningRuleDoesNotRunAgain() throws {
|
||||
let rig = try makeRig()
|
||||
defer { rig.fixture.tearDown() }
|
||||
// The window opened on a card with a body, so it opened in Preview — once, per the rule.
|
||||
#expect(rig.presentation.openIfNeeded(body: "# Notes\n") == .preview)
|
||||
rig.raw.enter()
|
||||
|
||||
// The user deletes the body in source mode and applies.
|
||||
rig.raw.text = "---\nschema: 1\ntitle: Notes\norder: 1024\n---\n"
|
||||
#expect(rig.raw.applyAndLeave())
|
||||
|
||||
// "The rule is about *opening* a card" (`CardBodyPresentation.openIfNeeded`), and a raw exit
|
||||
// is not an open: the user gets the blank Preview they just wrote, and ⌘E.
|
||||
#expect(rig.presentation.mode == .preview)
|
||||
#expect(try FrontmatterDocument.parse(rig.fixture.indexText(cardPath)).body.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Apply closes the outlet and lands the bytes")
|
||||
func applyCommitsAndLeaves() throws {
|
||||
let rig = try makeRig()
|
||||
defer { rig.fixture.tearDown() }
|
||||
rig.raw.enter()
|
||||
rig.raw.text = handEditedSource
|
||||
|
||||
#expect(rig.raw.applyAndLeave())
|
||||
|
||||
#expect(!rig.raw.isActive)
|
||||
#expect(rig.raw.alert == nil)
|
||||
#expect(try rig.fixture.indexData(cardPath) == Data(handEditedSource.utf8))
|
||||
}
|
||||
|
||||
@Test("A failed validation keeps source mode open, with the alert and the text")
|
||||
func aFailedValidationStays() throws {
|
||||
let rig = try makeRig()
|
||||
defer { rig.fixture.tearDown() }
|
||||
let before = try rig.fixture.indexData(cardPath)
|
||||
rig.raw.enter()
|
||||
let broken = "---\nschema: 1\n order: [unclosed\n---\nbody\n"
|
||||
rig.raw.text = broken
|
||||
|
||||
#expect(!rig.raw.applyAndLeave())
|
||||
|
||||
// "A failed validation keeps source mode open (toggle stays checked) with the alert" (05).
|
||||
#expect(rig.raw.isActive, "the toggle stays checked")
|
||||
#expect(rig.raw.text == broken, "and the user's text is still in front of them")
|
||||
guard case .invalid = rig.raw.alert else {
|
||||
Issue.record("expected a validation alert, got \(String(describing: rig.raw.alert))")
|
||||
return
|
||||
}
|
||||
#expect(try rig.fixture.indexData(cardPath) == before)
|
||||
|
||||
// OK returns to editing the raw text — nothing else moves.
|
||||
rig.raw.dismissAlert()
|
||||
#expect(rig.raw.alert == nil)
|
||||
#expect(rig.raw.isActive)
|
||||
#expect(rig.raw.text == broken)
|
||||
}
|
||||
|
||||
@Test("Cancel discards without ceremony — nothing written, nothing asked")
|
||||
func cancelDiscards() throws {
|
||||
let rig = try makeRig()
|
||||
defer { rig.fixture.tearDown() }
|
||||
let before = try rig.fixture.indexData(cardPath)
|
||||
rig.raw.enter()
|
||||
rig.raw.text = handEditedSource
|
||||
|
||||
rig.raw.cancel()
|
||||
|
||||
#expect(!rig.raw.isActive)
|
||||
#expect(rig.raw.applyAttempts == 0, "Cancel is not a write that happened to fail — it never asked")
|
||||
#expect(try rig.fixture.indexData(cardPath) == before)
|
||||
#expect(rig.raw.text.isEmpty, "and the buffer is gone with it, so a re-entry reads disk afresh")
|
||||
}
|
||||
|
||||
@Test("A file that cannot be read does not open source mode")
|
||||
func anUnreadableFileDoesNotOpen() throws {
|
||||
let rig = try makeRig()
|
||||
defer { rig.fixture.tearDown() }
|
||||
var bytes = Data("---\nschema: 1\norder: 1024\ntitle: ".utf8)
|
||||
bytes.append(contentsOf: [0xFF, 0xFE])
|
||||
bytes.append(Data("\n---\nbody\n".utf8))
|
||||
try rig.fixture.item(cardPath, bytes: bytes)
|
||||
|
||||
#expect(!rig.raw.enter())
|
||||
|
||||
// Settled here, 05 being silent: an editor over lossily-decoded bytes would make the outlet's
|
||||
// one promise false, since Apply writes back whatever is in it.
|
||||
#expect(!rig.raw.isActive, "the toggle stays unchecked")
|
||||
guard case .unreadable = rig.raw.alert else {
|
||||
Issue.record("expected an unreadable alert, got \(String(describing: rig.raw.alert))")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test("A window that has not joined its board opens nothing and writes nothing")
|
||||
func unwiredSeamsFailClosed() {
|
||||
let raw = CardRawSourceSession()
|
||||
|
||||
#expect(!raw.enter())
|
||||
#expect(!raw.isActive)
|
||||
#expect(!raw.applyAndLeave(), "and an Apply with no outlet open is not an Apply")
|
||||
}
|
||||
|
||||
@Test("Re-entering after an Apply reads disk again rather than reusing the old buffer")
|
||||
func reEntryReadsFresh() throws {
|
||||
let rig = try makeRig()
|
||||
defer { rig.fixture.tearDown() }
|
||||
rig.raw.enter()
|
||||
rig.raw.text = handEditedSource
|
||||
rig.raw.applyAndLeave()
|
||||
|
||||
// Somebody else — an agent, a pull — rewrites the card while no window holds it.
|
||||
let foreign = handEditedSource.replacingOccurrences(of: "Rewritten by hand.", with: "Rewritten by them.")
|
||||
try rig.fixture.item(cardPath, foreign)
|
||||
|
||||
rig.raw.enter()
|
||||
#expect(rig.raw.text == foreign, "the outlet never shows an in-memory snapshot of the file")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The menu rows
|
||||
|
||||
@MainActor
|
||||
@Suite("Card view commands ▸ validation")
|
||||
struct CardViewCommandValidationTests {
|
||||
|
||||
@Test("Edit Body needs a card window, and disables while source mode is active")
|
||||
func editBodyDisablesUnderRawSource() {
|
||||
let presentation = CardBodyPresentation()
|
||||
let raw = CardRawSourceSession()
|
||||
raw.read = { .read("---\nschema: 1\norder: 1\n---\nbody\n") }
|
||||
raw.apply = { _ in .applied }
|
||||
|
||||
// No card window in front: scope alone disables the row.
|
||||
#expect(!EditBodyCommand.isEnabled(body: nil, rawSource: nil))
|
||||
#expect(!EditBodyCommand.isEnabled(body: nil, rawSource: raw))
|
||||
// A card window, not in source mode: live.
|
||||
#expect(EditBodyCommand.isEnabled(body: presentation, rawSource: raw))
|
||||
|
||||
raw.enter()
|
||||
|
||||
// "View ▸ Edit Body (⌘E) disables while source mode is active, matching its toolbar item"
|
||||
// (05 ▸ Raw source outlet) — the two would otherwise be editing the same bytes from two
|
||||
// surfaces, one of which is not on screen.
|
||||
#expect(raw.isActive)
|
||||
#expect(!EditBodyCommand.isEnabled(body: presentation, rawSource: raw))
|
||||
|
||||
raw.cancel()
|
||||
#expect(EditBodyCommand.isEnabled(body: presentation, rawSource: raw))
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ Lanework is in early development. This list tracks what has actually shipped and
|
||||
|
||||
- **The card body — Preview and Edit** — the body is read as a fully rendered Markdown preview and written as raw Markdown, never a WYSIWYG halfway house. Preview lays out headings, emphasis, code, quotes, lists, GFM tables, thematic breaks and images resolved against the card's own folder; HTML shows verbatim as code, remote images never load (Preview does no networking), links open in the browser or the file's default app, and task-list checkboxes are live — clicking one flips exactly that character in the file and touches no other byte. ⌘E toggles View ▸ Edit Body, Return in Preview enters it, Escape leaves it, and a card whose body is empty opens straight into the editor with the cursor ready. Edit is a monospaced editor with lightweight syntax highlighting — headings emphasized, bold and italic styled, code tinted, link targets and structural markers dimmed — that is presentation only: the text stays the raw Markdown character for character, smart quotes and dashes off. It saves ~700 ms after you stop typing, and flushes the moment you leave Edit or close the window, so neither the preview nor the disk ever lags what you typed. ⌘Z is the editor's own undo, scoped to the session; ⌘F is find-in-text over whichever surface is showing. Three write rules keep the file honest: a body nobody touched is never re-serialized (byte-identical on disk, modification date included), an edit typed and then undone is not written, and the app's own save echoing back through the watcher is not written again. If the file changes underneath you while the buffer has unsaved keystrokes, the buffer wins — the board, the preview and every other window take the new version while your text stays exactly where it is, and your save then lands over theirs. A close that cannot save stops and asks: try again, save a copy elsewhere, or discard.
|
||||
|
||||
- **Raw source** — View ▸ Raw Source (⌥⌘E) swaps the card window's whole content area — title, body and sidebar — for the literal `index.md` in a monospaced editor with Cancel and Apply. It's the escape hatch that keeps everything reachable in-app: unknown keys an agent added, hand-written comments, exotic YAML the app has no control for. Entering flushes whatever you were typing and then reads the file fresh off disk, never an in-memory copy. Apply validates through the very same fail-fast parse the loader uses — a broken proposal stops with a detailed alert naming the line, source mode stays open with your text, and the file on disk is untouched — and a valid one is written byte for byte, the only write in the app that neither stamps `modified` nor clears a `modified-by` you typed or kept, because you wrote those bytes and nothing may quietly edit them. The reload then refreshes every window. Escape is Cancel, ⌘↩ is Apply, ⌥⌘E toggled off applies too, Return just types; Cancel and closing the window discard without ceremony, and a card deleted out from under an open buffer discards it rather than letting a stale Apply undelete the card. ⌘E stands down while source mode is up, ⌘F still finds, and ⌘Z is the editor's own undo. A file that isn't valid UTF-8 declines to open as source rather than showing you a lossy guess of it.
|
||||
|
||||
- **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it sits the same style editor every other anchor uses, aimed permanently at the board, and below that a Git section that says plainly that this board has no repository yet. The read-only lock disables the surface without closing it.
|
||||
|
||||
## Development
|
||||
|
||||
Reference in New Issue
Block a user