Build Edit mode with debounced, byte-honest saves
The editing surface: the same hosted TextKit-1 text view gains an editable branch with a per-keystroke line-scanner highlighter — chosen over a parser re-parse because a mid-typing buffer is usually invalid Markdown and 05 wants the delimiters themselves dimmed; apply only sets attributes, so presentation-never-transforms is structural. Saves ride a ~700ms injectable debounce through BoardWriter.writeBody — toggleTaskMarker's idiom widened to the body span, frontmatter bytes untouched, refusing to write when disk already holds that body, which enforces all three gates (untouched, reverted, echo) at the layer that owns the bytes with one isDirty predicate above it. Mode grammar lands whole: ⌘E toggles with a checkmark, Return in Preview enters, Escape returns, and every flip flushes first; window close flushes through the existing retry/save-copy/discard modal, and the dismissal flush deliberately reaches a tombstoned card. Dirty-buffer-wins: disk always follows the snapshot, the buffer only when clean, both surfaces render the buffer. Undo is the editor's own session-scoped NSUndoManager; endEditSession names the pro-m1 one-commit-per-session boundary. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -680,8 +680,13 @@ public final class AppModel {
|
|||||||
storeFlush: { [weak self] in
|
storeFlush: { [weak self] in
|
||||||
await self?.sessions[ref]?.store.awaitQuiescence()
|
await self?.sessions[ref]?.store.awaitQuiescence()
|
||||||
},
|
},
|
||||||
// editorFlush / committerFlush stay nil until m6 and m7 have something to flush; the
|
// `editorFlush` stays nil, and now deliberately rather than for want of an editor: the
|
||||||
// slots exist so their order is already decided when they do.
|
// card windows' debounced body saves flush in **step 1**, inside each window's
|
||||||
|
// `endSession()` (`CardWindowSession`), which is both earlier than this slot and where
|
||||||
|
// 02-architecture.md puts them ("each open Edit session ends with its normal session
|
||||||
|
// commit", then pending work). The slot stays for a board-level editor with no card
|
||||||
|
// window of its own — the raw-source buffer is the candidate — so that the order
|
||||||
|
// relative to `committerFlush` (m7) is already decided when one arrives.
|
||||||
recordClose: { [weak self] in
|
recordClose: { [weak self] in
|
||||||
guard let self, let session = sessions[ref] else { return }
|
guard let self, let session = sessions[ref] else { return }
|
||||||
let counts = Self.liveCounts(of: session.store.snapshot)
|
let counts = Self.liveCounts(of: session.store.snapshot)
|
||||||
|
|||||||
+134
-12
@@ -29,27 +29,67 @@ public enum CardWindowFate: Equatable {
|
|||||||
|
|
||||||
// MARK: - The session seam
|
// MARK: - The session seam
|
||||||
|
|
||||||
/// A card window's editor session — still a no-op stand-in for the thing 05-card-window.md will
|
/// A card window's editor session: the thing the close flush ends, and now the thing it ends *with
|
||||||
/// build, and deliberately so: **the shell has no Edit session to flush yet.**
|
/// something in it* — the window's Edit buffer (05-card-window.md ▸ Edit).
|
||||||
///
|
///
|
||||||
/// It exists so the close flush has something real to call and something real to be *ordered against*
|
/// The ordering it exists for is unchanged (see `CardSessionFlushing`): the board's close flush
|
||||||
/// (see `CardSessionFlushing`). The only behaviour it has is the one the ordering depends on: ending
|
/// drives `endSession()` for every card window before any of the board's own pending work is
|
||||||
/// twice does nothing the second time, which matters because two paths legitimately end a session —
|
/// flushed, and a card window closed on its own runs it from its disappear. Ending twice does
|
||||||
/// the board's close flush drives it for every card window, and a card window closed on its own runs
|
/// nothing the second time, which is what makes those two paths safe to both exist.
|
||||||
/// it from its disappear.
|
///
|
||||||
|
/// **What ending means now**: the Edit buffer's debounce is cancelled and its text written — the
|
||||||
|
/// "window close" third of 05's flush rule, and on a dismissal caused by a tombstone the surgical
|
||||||
|
/// body write 05 ▸ Deletion & lifecycle promises ("a dirty Edit buffer flushes into the tombstoned
|
||||||
|
/// card's folder before the window dismisses ... so the keystrokes survive Put Back").
|
||||||
|
/// `BoardStore.writeCardBody` resolves tombstoned cards on purpose for exactly this.
|
||||||
|
///
|
||||||
|
/// A *failing* close flush is not this object's problem to solve: it is `DirtyBufferGuard`'s modal
|
||||||
|
/// moment, which the host runs earlier, on `windowShouldClose`, while there is still a window to
|
||||||
|
/// present over. By the time this runs on a window that is genuinely going away, the honest thing
|
||||||
|
/// left to do is try.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class CardWindowSession: CardSessionFlushing {
|
final class CardWindowSession: CardSessionFlushing {
|
||||||
|
|
||||||
|
/// The window's Edit buffer. Created with the window and handed its save target once the window
|
||||||
|
/// has joined its board (`CardWindowHost.start()`); a session with no target keeps its text
|
||||||
|
/// rather than pretending to have written it.
|
||||||
|
let body: CardBodyEditSession
|
||||||
|
|
||||||
|
/// The close-time save-or-lose moment, over this window's buffer (02-architecture.md §
|
||||||
|
/// Write-failure surfacing: "the one modal moment on the write-failure path").
|
||||||
|
let bufferGuard: DirtyBufferGuard
|
||||||
|
|
||||||
private var hasEnded = false
|
private var hasEnded = false
|
||||||
|
|
||||||
|
/// Both `let`s, wired to each other through a local — the guard's two closures need the buffer,
|
||||||
|
/// and a stored property cannot be referenced from another's initializer.
|
||||||
|
///
|
||||||
|
/// (They are also `let` rather than `lazy var` for a SwiftUI reason worth recording: `@State`
|
||||||
|
/// projects a `Binding` through dynamic member lookup for every *settable* property of its
|
||||||
|
/// value, so a `lazy var` here would make `session.bufferGuard` at the call site resolve to a
|
||||||
|
/// binding rather than to the guard.)
|
||||||
|
init() {
|
||||||
|
let body = CardBodyEditSession()
|
||||||
|
self.body = body
|
||||||
|
bufferGuard = DirtyBufferGuard(
|
||||||
|
attemptSave: { () throws(BoardWriteError) -> Void in try body.flushOrThrow() },
|
||||||
|
// "Save a copy elsewhere" writes the *buffer*, not the card: the destination is
|
||||||
|
// somewhere outside the board the user picked in a panel, so what lands there is the
|
||||||
|
// text they were typing, as a file, and nothing about frontmatter or identity travels
|
||||||
|
// with it.
|
||||||
|
writeCopy: { url in try Data(body.text.utf8).write(to: url) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func endSession() async {
|
func endSession() async {
|
||||||
guard !hasEnded else { return }
|
guard !hasEnded else { return }
|
||||||
hasEnded = true
|
hasEnded = true
|
||||||
// m6-card-body: commit the open Edit session here (06-history-undo.md's session
|
// pro-m1: this is the boundary the auto-committer coalesces on — one commit per Edit
|
||||||
// granularity), flushing the debounced body save first — and, on a dismissal caused by a
|
// session, "never per save tick" (06-history-undo.md ▸ Rules ▸ Auto-commit). The debounced
|
||||||
// tombstone, the surgical body write 05 ▸ Deletion & lifecycle promises ("dismissal never
|
// saves inside the session are ordinary bracketed writes; what makes them one commit is that
|
||||||
// eats typed work silently where a save can land"). Nothing exists to flush until the
|
// the committer's own debounce outlives them and this call is where the session is known to
|
||||||
// editor does; the hook's *position* in the sequence is what this milestone pins.
|
// be over.
|
||||||
|
body.endEditSession()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +132,9 @@ struct CardWindowHost: View {
|
|||||||
/// This window's body column — the Preview/Edit mode, and the ⌘F hook a menu item reaches
|
/// This window's body column — the Preview/Edit mode, and the ⌘F hook a menu item reaches
|
||||||
/// through the focus system (`CardBodyPresentation`).
|
/// through the focus system (`CardBodyPresentation`).
|
||||||
@State private var bodyPresentation = CardBodyPresentation()
|
@State private var bodyPresentation = CardBodyPresentation()
|
||||||
|
/// 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
|
||||||
|
|
||||||
private enum Phase {
|
private enum Phase {
|
||||||
case opening
|
case opening
|
||||||
@@ -156,11 +199,23 @@ struct CardWindowHost: View {
|
|||||||
// item reaches the frontmost one's body surface through this, exactly as board-window
|
// item reaches the frontmost one's body surface through this, exactly as board-window
|
||||||
// items reach their window's store (`FocusedBoardStoreKey`).
|
// items reach their window's store (`FocusedBoardStoreKey`).
|
||||||
.focusedSceneValue(\.cardBody, bodyPresentation)
|
.focusedSceneValue(\.cardBody, bodyPresentation)
|
||||||
|
// 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.
|
||||||
|
.dirtyBufferAlert(session.bufferGuard) { Self.copyDestination(named: windowTitle) }
|
||||||
.task { start() }
|
.task { start() }
|
||||||
.onChange(of: shouldDismiss, initial: true) { _, dismisses in
|
.onChange(of: shouldDismiss, initial: true) { _, dismisses in
|
||||||
guard dismisses else { return }
|
guard dismisses else { return }
|
||||||
dismissWindow(id: WindowID.card, value: ref)
|
dismissWindow(id: WindowID.card, value: ref)
|
||||||
}
|
}
|
||||||
|
// The modal resolved — by a retry that landed, a copy saved elsewhere, or a knowing
|
||||||
|
// discard — so the close it was holding may finish. `DirtyBufferGuard` has no fourth
|
||||||
|
// "leave it open" branch by design, so reaching `.idle` always means the close resumes.
|
||||||
|
.onChange(of: session.bufferGuard.phase) { _, phase in
|
||||||
|
guard isClosePending, phase == .idle else { return }
|
||||||
|
isClosePending = false
|
||||||
|
windowController.closeAfterFlush()
|
||||||
|
}
|
||||||
.onDisappear { finish() }
|
.onDisappear { finish() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +230,7 @@ struct CardWindowHost: View {
|
|||||||
card: placement.card,
|
card: placement.card,
|
||||||
cardFolder: Self.cardFolder(root: store.rootURL, placement: placement),
|
cardFolder: Self.cardFolder(root: store.rootURL, placement: placement),
|
||||||
bodyPresentation: bodyPresentation,
|
bodyPresentation: bodyPresentation,
|
||||||
|
bodySession: session.body,
|
||||||
// "Under the read-only lock the controls disable in place — an in-content mutation
|
// "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
|
// menu validation can't reach" (05 ▸ Preview). The checkbox is that control, and
|
||||||
// the store's own lock is the whole predicate.
|
// the store's own lock is the whole predicate.
|
||||||
@@ -267,9 +323,31 @@ struct CardWindowHost: View {
|
|||||||
|
|
||||||
appModel.registerCardWindow(ref, session: session)
|
appModel.registerCardWindow(ref, session: session)
|
||||||
phase = .open(store)
|
phase = .open(store)
|
||||||
|
configureSession(store: store)
|
||||||
configureWindow()
|
configureWindow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Points this window's Edit buffer at its card, and the mode flip at the buffer.
|
||||||
|
///
|
||||||
|
/// Both are seams the two types deliberately leave open (`CardBodyEditSession.save`,
|
||||||
|
/// `CardBodyPresentation.flushEdits`) so that neither the buffer nor the mode has to know what a
|
||||||
|
/// board is — this is the one place that knows both, which is also the only place that could
|
||||||
|
/// wire them wrongly, and it is four lines long.
|
||||||
|
///
|
||||||
|
/// The store is captured **weakly**: it outlives this window by refcount, not by ownership, and a
|
||||||
|
/// debounced save that fired after the board had gone should write nothing rather than resurrect
|
||||||
|
/// a store the registry has released.
|
||||||
|
private func configureSession(store: BoardStore) {
|
||||||
|
let cardID = ref.cardIdentity
|
||||||
|
session.body.save = { [weak store] text in
|
||||||
|
guard let store else { return .vanished }
|
||||||
|
return store.writeCardBody(inCard: cardID, body: text)
|
||||||
|
}
|
||||||
|
bodyPresentation.flushEdits = { [session] in
|
||||||
|
session.body.endEditSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Size and placement — **the remembered frame first, the cascade second** (05-card-window.md
|
/// Size and placement — **the remembered frame first, the cascade second** (05-card-window.md
|
||||||
/// ▸ Window: "New windows open at the last-used card-window size, cascaded; frames restore per
|
/// ▸ Window: "New windows open at the last-used card-window size, cascaded; frames restore per
|
||||||
/// card across relaunch where state restoration allows").
|
/// card across relaunch where state restoration allows").
|
||||||
@@ -314,6 +392,14 @@ struct CardWindowHost: View {
|
|||||||
windowController.onAttach?(window)
|
windowController.onAttach?(window)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **The close flushes first** (05-card-window.md ▸ Edit: "flushed on leaving Edit, entering
|
||||||
|
// source mode, and window close"). Intercepting `windowShouldClose` rather than saving from
|
||||||
|
// `onDisappear` is what makes the failure case possible at all: by the time a window has
|
||||||
|
// disappeared there is nothing left to present a modal over, and the design's one modal
|
||||||
|
// moment is precisely a close that could not save (02-architecture.md § Write-failure
|
||||||
|
// surfacing).
|
||||||
|
windowController.onCloseRequested = { closeAfterFlushing() }
|
||||||
|
|
||||||
windowController.onFrameChanged = { frame in
|
windowController.onFrameChanged = { frame in
|
||||||
if let recordID {
|
if let recordID {
|
||||||
appModel.boardRegistry.updateCardWindowFrame(
|
appModel.boardRegistry.updateCardWindowFrame(
|
||||||
@@ -331,6 +417,42 @@ struct CardWindowHost: View {
|
|||||||
|
|
||||||
// MARK: - Closing
|
// MARK: - Closing
|
||||||
|
|
||||||
|
/// The close, held open exactly as long as the buffer needs.
|
||||||
|
///
|
||||||
|
/// A clean buffer closes immediately — which is every window that was only read, and every
|
||||||
|
/// window whose last keystroke was more than the debounce ago. A dirty one is flushed through
|
||||||
|
/// `DirtyBufferGuard`, and only a genuine write *failure* stops the close: a suspended save
|
||||||
|
/// (read-only lock) and a vanished card do not, because neither has anywhere for the text to
|
||||||
|
/// land and both were already visible to the user as the standing lock row or a card that left
|
||||||
|
/// the board (`CardBodyEditSession.flushOrThrow`).
|
||||||
|
private func closeAfterFlushing() {
|
||||||
|
guard session.body.isDirty else {
|
||||||
|
windowController.closeAfterFlush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if session.bufferGuard.beginClose() {
|
||||||
|
windowController.closeAfterFlush()
|
||||||
|
} else {
|
||||||
|
// The alert is presenting; the close resumes from the phase change, above.
|
||||||
|
isClosePending = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The save panel behind the modal's "Save a Copy…". Pre-filled with the card's name and a `.md`
|
||||||
|
/// extension, because what it writes is the Markdown body the user was typing — not the card,
|
||||||
|
/// which cannot exist outside a board.
|
||||||
|
private static func copyDestination(named title: String) -> URL? {
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.nameFieldStringValue = "\(title.isEmpty ? "Untitled" : title).md"
|
||||||
|
panel.canCreateDirectories = true
|
||||||
|
panel.isExtensionHidden = false
|
||||||
|
panel.allowsOtherFileTypes = true
|
||||||
|
panel.prompt = "Save"
|
||||||
|
panel.message = "Choose where to keep these changes."
|
||||||
|
guard panel.runModal() == .OK else { return nil }
|
||||||
|
return panel.url
|
||||||
|
}
|
||||||
|
|
||||||
/// Leaves the session and lets the store go.
|
/// Leaves the session and lets the store go.
|
||||||
///
|
///
|
||||||
/// The release rides **behind** the session's end rather than beside it: a session that has
|
/// The release rides **behind** the session's end rather than beside it: a session that has
|
||||||
|
|||||||
@@ -108,14 +108,20 @@ struct FindSteppingCommands: View {
|
|||||||
/// View ▸ Edit Body (⌘E) / Raw Source (⌥⌘E) / History — the card window's three view-state rows
|
/// View ▸ Edit Body (⌘E) / Raw Source (⌥⌘E) / History — the card window's three view-state rows
|
||||||
/// (11-command-nexus.md).
|
/// (11-command-nexus.md).
|
||||||
///
|
///
|
||||||
// m6-card-window: Edit Body and Raw Source are checkmark toggles reading the window's edit-mode
|
/// **Edit Body is live** (`EditBodyCommand`, beside the focused value it reads): the body column's
|
||||||
// state ("Edit Body disables while Raw Source is active" — 05-card-window.md); History is a plain
|
/// Preview/Edit toggle, checkmark state and all. Its diff was the one `FutureCommand` promises —
|
||||||
// command that focuses the sidebar's History section and disables outright on mode `none` /
|
/// the title and the chord did not move, the validation and the action filled in.
|
||||||
// repo-nested boards once that section exists (05-card-window.md, 07-sync-collab.md). All three are
|
///
|
||||||
// unconditionally disabled here — there is no card-window mode state anywhere yet.
|
// 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.
|
||||||
struct CardViewCommands: View {
|
struct CardViewCommands: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
FutureToggleCommand(title: "Edit Body", key: "e", modifiers: .command)
|
EditBodyCommand()
|
||||||
FutureToggleCommand(title: "Raw Source", key: "e", modifiers: [.option, .command])
|
FutureToggleCommand(title: "Raw Source", key: "e", modifiers: [.option, .command])
|
||||||
FutureCommand(title: "History")
|
FutureCommand(title: "History")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -646,6 +646,12 @@ public final class BannerCenter {
|
|||||||
// the read that preceded the flip learned its title, so a body write that refused says
|
// the read that preceded the flip learned its title, so a body write that refused says
|
||||||
// *which* card refused it — a card window is not always the frontmost thing on screen.
|
// *which* card refused it — a card window is not always the frontmost thing on screen.
|
||||||
if let title { "Couldn't tick the checkbox in '\(title)'" } else { "Couldn't tick the checkbox" }
|
if let title { "Couldn't tick the checkbox in '\(title)'" } else { "Couldn't tick the checkbox" }
|
||||||
|
case let .editBody(title):
|
||||||
|
// **Save**, because that is the word for what just failed: the Edit→Preview flip is the
|
||||||
|
// effective Save button (05-card-window.md ▸ Edit), and the debounced tick is the same
|
||||||
|
// 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" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,41 @@ public enum BoardStoreWriteRefusal: Error, Sendable, Equatable, CustomStringConv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What became of a card-body save — the card window's Edit buffer meeting disk
|
||||||
|
/// (05-card-window.md ▸ Edit; `BoardStore.writeCardBody(inCard:body:)`).
|
||||||
|
///
|
||||||
|
/// A returned value rather than a thrown error, because **four of the five cases are not failures**
|
||||||
|
/// and the caller's response to each differs: only `.written` and `.unchanged` mean the buffer may
|
||||||
|
/// stop being held dirty. Making them one enum is what keeps that decision in one `switch` rather
|
||||||
|
/// than spread across a `try?` and two guards.
|
||||||
|
public enum CardBodyWriteOutcome: Sendable, Equatable {
|
||||||
|
/// The bytes landed. The buffer matches disk; the echoing reload is now on its way.
|
||||||
|
case written
|
||||||
|
|
||||||
|
/// **Nothing to write** — the body on disk already reads exactly like the buffer. The three-gate
|
||||||
|
/// write rule's outcome (05 ▸ Write rules: untouched, reverted, or the echo of an external
|
||||||
|
/// edit), and as good as `.written` from the buffer's point of view: disk says what the user
|
||||||
|
/// means it to say, and nothing was re-serialized to make that true.
|
||||||
|
case unchanged
|
||||||
|
|
||||||
|
/// The board is locked read-only, so the save is **suspended, not failed** (02-architecture.md §
|
||||||
|
/// the lock's scope: "editor buffers kept but their debounced saves suspended"). The buffer stays
|
||||||
|
/// dirty, the standing lock row already explains why, and nothing is posted — a banner per
|
||||||
|
/// suppressed tick would bury the row that matters under echoes of itself.
|
||||||
|
case suspended(ReadOnlyLockReason)
|
||||||
|
|
||||||
|
/// The card is not in this board's tree at all any more — hard-deleted in Finder, or moved to
|
||||||
|
/// another board. **Not a failure either**: there is nowhere for the text to land, which is 05 ▸
|
||||||
|
/// Deletion & lifecycle's own answer ("A card hard-deleted externally (folder gone) discards
|
||||||
|
/// both — nowhere left to write"). A *tombstoned* card is not this case; it is still on disk and
|
||||||
|
/// is written to.
|
||||||
|
case vanished
|
||||||
|
|
||||||
|
/// The write was attempted and failed. The banner has already been posted by `performWrite`; the
|
||||||
|
/// buffer must stay dirty, and a close standing on it is `DirtyBufferGuard`'s modal moment.
|
||||||
|
case failed(BoardWriteError)
|
||||||
|
}
|
||||||
|
|
||||||
/// What a cross-board drop is doing to the items it carries — the **effective** operation the
|
/// 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).
|
/// locality model resolved (04-interactions.md ▸ Drag and drop, settled).
|
||||||
///
|
///
|
||||||
@@ -1157,6 +1192,76 @@ public final class BoardStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Card body
|
||||||
|
|
||||||
|
/// Saves a card window's Edit buffer — the debounced tick, the flush that leaves Edit, and the
|
||||||
|
/// flush that closes the window (05-card-window.md ▸ Edit).
|
||||||
|
///
|
||||||
|
/// An ordinary store write in every mechanical respect: one `performWrite` bracket, so the churn
|
||||||
|
/// rounds back as a single app-mediated reload (and, on git boards, sits inside the session's
|
||||||
|
/// one commit — see `CardBodyEditSession` for that seam); the banner posts itself on failure;
|
||||||
|
/// the snapshot is never touched here, because the watcher's reload is what brings the text
|
||||||
|
/// back.
|
||||||
|
///
|
||||||
|
/// **It reports rather than swallows**, which is the one way it differs from every other write
|
||||||
|
/// in this file. `toggleTaskMarker` and its neighbours are one-shot gestures whose failure the
|
||||||
|
/// banner fully describes, so they `try?` and move on. This one has a *buffer* behind it: the
|
||||||
|
/// caller has to know whether the text landed, because on success it may stop holding it dirty
|
||||||
|
/// and on failure it must keep holding it — the whole of "nothing is lost while the window stays
|
||||||
|
/// open" (02-architecture.md § Write-failure surfacing). Hence an outcome, not a `Void`.
|
||||||
|
///
|
||||||
|
/// **Tombstones are writable here, deliberately.** The folder is resolved by
|
||||||
|
/// `cardBodyTarget(_:in:)` — a walk that does *not* skip tombstoned cards or lanes — because 05
|
||||||
|
/// ▸ Deletion & lifecycle requires exactly that: "a dirty Edit buffer flushes into the
|
||||||
|
/// tombstoned card's folder before the window dismisses ... so the keystrokes survive Put Back".
|
||||||
|
/// The write is surgical (`BoardWriter.writeBody` replaces the body span and nothing else), so
|
||||||
|
/// the `deleted:` key it lands beside is left standing and the card is not resurrected.
|
||||||
|
public func writeCardBody(inCard cardID: ItemID, body: String) -> CardBodyWriteOutcome {
|
||||||
|
guard let target = Self.cardBodyTarget(cardID, in: snapshot) else { return .vanished }
|
||||||
|
let folder = rootURL
|
||||||
|
.appendingPathComponent(target.laneID.rawValue, isDirectory: true)
|
||||||
|
.appendingPathComponent(target.cardID.rawValue, isDirectory: true)
|
||||||
|
|
||||||
|
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.writeBody(inItemFolder: folder, body: body)
|
||||||
|
}
|
||||||
|
return wrote ? .written : .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 {
|
||||||
|
// `performWrite`'s `throws` is untyped only because its two error types have not been
|
||||||
|
// unified yet (`BoardStoreWriteRefusal`); there is no third thing it can throw.
|
||||||
|
Self.logger.error("unexpected error saving a card body: \(String(describing: error), privacy: .public)")
|
||||||
|
return .unchanged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which folder a card's body write lands in — **the one card walk that ignores liveness**.
|
||||||
|
///
|
||||||
|
/// Every other resolution in this file goes through `liveItem`, whose ancestor-walked liveness is
|
||||||
|
/// what keeps gestures off vanished targets. This one deliberately does not: the card window's
|
||||||
|
/// dismissal flush has to reach a card that was tombstoned *out from under the buffer* (05 ▸
|
||||||
|
/// Deletion & lifecycle), and to `liveItem` that card is already gone. A card whose folder is
|
||||||
|
/// genuinely no longer in the tree — hard-deleted, or moved to another board — still resolves to
|
||||||
|
/// `nil`, which is the case 05 answers with "nowhere left to write".
|
||||||
|
nonisolated static func cardBodyTarget(
|
||||||
|
_ id: ItemID,
|
||||||
|
in snapshot: BoardModel
|
||||||
|
) -> (laneID: ItemID, cardID: ItemID)? {
|
||||||
|
for lane in snapshot.lanes {
|
||||||
|
if let card = lane.cards.first(where: { $0.id == id }) {
|
||||||
|
return (laneID: lane.id, cardID: card.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Board rename
|
// MARK: - Board rename
|
||||||
|
|
||||||
/// Writes the board's own `title` — the board popover's rename field (03-board-ui.md § Board
|
/// Writes the board's own `title` — the board popover's rename field (03-board-ui.md § Board
|
||||||
|
|||||||
@@ -1041,6 +1041,68 @@ public enum BoardWriter: Sendable {
|
|||||||
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
|
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Card body
|
||||||
|
|
||||||
|
/// Replaces an item's **body span** — everything after the frontmatter's closing delimiter — and
|
||||||
|
/// leaves every frontmatter byte exactly as it was. The card window's Edit buffer landing on
|
||||||
|
/// disk (05-card-window.md ▸ Edit: "Saved on a ~700 ms debounce; flushed on leaving Edit,
|
||||||
|
/// entering source mode, and window close").
|
||||||
|
///
|
||||||
|
/// ### Byte-honest by the same construction as everything else here
|
||||||
|
///
|
||||||
|
/// `FrontmatterDocument` keeps the file's raw text and re-emits it as
|
||||||
|
/// `openingDelimiter + spans + closingDelimiter + body`, so assigning `body` is *only* a
|
||||||
|
/// replacement of the body span: unknown keys, their order, comments, blank lines and line
|
||||||
|
/// endings above the delimiter are the same bytes they were, because nothing re-serialized them.
|
||||||
|
/// That is `toggleTaskMarker`'s idiom exactly — this call is that one widened from a single
|
||||||
|
/// character to the whole span, and it shares its four steps: read fresh from disk, refuse an
|
||||||
|
/// uneditable frontmatter shape, edit, stamp `modified` and clear `modified-by`, replace
|
||||||
|
/// atomically.
|
||||||
|
///
|
||||||
|
/// **The stamp is not optional and not a policy choice here**: a body rewrite *is* an `index.md`
|
||||||
|
/// rewrite, and every app-mediated `index.md` rewrite stamps (01-storage-format.md §
|
||||||
|
/// Frontmatter). The raw-source Apply is the one path that keeps a `modified-by`, and it does
|
||||||
|
/// not come through here.
|
||||||
|
///
|
||||||
|
/// ### The gate, and why it lives in the Writer as well as in the session
|
||||||
|
///
|
||||||
|
/// **An untouched body is never re-serialized** (05 ▸ Write rules): if the text on disk already
|
||||||
|
/// equals `body`, this returns `false` having opened the file and touched nothing — no stamp, no
|
||||||
|
/// temp file, no rename, and therefore an untouched `mtime`. The card window's Edit session
|
||||||
|
/// gates on the same comparison before it ever calls (its three gates: untouched, reverted, and
|
||||||
|
/// the echo of an external edit), so in practice this one never fires; it is here because the
|
||||||
|
/// guarantee is about *bytes on disk*, and the layer that owns the bytes is the layer that can
|
||||||
|
/// promise it against every caller, including a future one.
|
||||||
|
///
|
||||||
|
/// **It is not a staleness check.** A body that changed under the buffer is written over
|
||||||
|
/// deliberately — "dirty buffer wins ... deliberate last-writer-wins" (05 ▸ Write rules) — which
|
||||||
|
/// is why nothing here compares against what the caller last saw. Only *equality* refuses, and
|
||||||
|
/// equality refuses because the write would be a no-op that stamped `modified` anyway.
|
||||||
|
///
|
||||||
|
/// - Returns: `true` when bytes were written, `false` when the body on disk already matched.
|
||||||
|
@discardableResult
|
||||||
|
public static func writeBody(inItemFolder folder: URL, body: String) throws(BoardWriteError) -> Bool {
|
||||||
|
var operation = WriteOperation.editBody(title: nil)
|
||||||
|
try checkIsDirectory(folder, describedAs: "item folder", operation: operation)
|
||||||
|
// The same shape guard `toggleTaskMarker` leans on, for its reason: a board root's body is
|
||||||
|
// its description and no editor in this app opens it, so only lanes and cards are reachable.
|
||||||
|
try checkIsUUIDShaped(folder, operation: operation)
|
||||||
|
|
||||||
|
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
||||||
|
var document = try readDocument(at: indexURL, operation: operation)
|
||||||
|
operation = operation.withTitle(document.title.value)
|
||||||
|
try checkEditable(document, at: indexURL, operation: operation)
|
||||||
|
|
||||||
|
guard document.body != body else { return false }
|
||||||
|
|
||||||
|
document.body = body
|
||||||
|
document.set(FrontmatterKeys.modified, to: .date(Date()))
|
||||||
|
document.remove(FrontmatterKeys.modifiedBy)
|
||||||
|
|
||||||
|
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Attachments
|
// MARK: - Attachments
|
||||||
|
|
||||||
/// The one folder this app ever creates under a card — every other subfolder under
|
/// The one folder this app ever creates under a card — every other subfolder under
|
||||||
@@ -1566,6 +1628,16 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
|||||||
/// also the app's only *body* write, which is worth being able to see in a log at a glance.
|
/// also the app's only *body* write, which is worth being able to see in a log at a glance.
|
||||||
case toggleTask(title: String?)
|
case toggleTask(title: String?)
|
||||||
|
|
||||||
|
/// The card window's Edit buffer being saved — the debounced tick, the flush that leaves Edit,
|
||||||
|
/// and the flush that closes the window (05-card-window.md ▸ Edit).
|
||||||
|
///
|
||||||
|
/// Its own case beside `.toggleTask` rather than folded into it, on the vocabulary's standing
|
||||||
|
/// reasoning: both write a body, but one is a checkbox the user ticked and the other is prose
|
||||||
|
/// they typed, and a banner telling someone the app "couldn't tick the checkbox" after they
|
||||||
|
/// wrote three paragraphs would name a gesture that never happened. `title` is the card's title
|
||||||
|
/// as the read that preceded the write found it — the name on the window they are typing in.
|
||||||
|
case editBody(title: String?)
|
||||||
|
|
||||||
/// Fills in the title once the Writer has read it off the document the operation is acting
|
/// 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`/
|
/// 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
|
/// `createCard` are minting a file, not reading one; `importAttachment`'s "title" is the
|
||||||
@@ -1591,6 +1663,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
|||||||
case .rename: .rename(title: title)
|
case .rename: .rename(title: title)
|
||||||
case .duplicateBoard: .duplicateBoard(title: title)
|
case .duplicateBoard: .duplicateBoard(title: title)
|
||||||
case .toggleTask: .toggleTask(title: title)
|
case .toggleTask: .toggleTask(title: title)
|
||||||
|
case .editBody: .editBody(title: title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1620,6 +1693,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
|||||||
case .renumberChildren: "renumber children"
|
case .renumberChildren: "renumber children"
|
||||||
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
|
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
|
||||||
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
|
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
|
||||||
|
case let .editBody(title): Self.phrase("save the body of", title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,19 +70,11 @@ enum BodyMarkupRenderer {
|
|||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The raw Markdown as the **Edit placeholder** shows it: monospaced, unhighlighted, and
|
// The Edit surface's raw, monospaced rendering used to live here as `rawText`, while Edit was a
|
||||||
/// character for character what is on disk.
|
// read-only placeholder sharing this file's substrate. It is now `MarkdownHighlighter`'s —
|
||||||
///
|
// base attributes plus a span pass — and the promise it carried travelled with it: the
|
||||||
/// Here rather than in the placeholder view because the two surfaces share one substrate and
|
// highlighter emits ranges, never a string, so "the text is the raw Markdown, character for
|
||||||
/// therefore one input type — an attributed string — and because "the text is the raw Markdown,
|
// character" (05 ▸ Edit) is structural rather than a convention.
|
||||||
/// character for character — no hidden transforms, no smart substitutions" (05 ▸ Edit) is a
|
|
||||||
/// promise about *this* function: it sets attributes and never touches a character.
|
|
||||||
static func rawText(_ body: String, context: Context) -> NSAttributedString {
|
|
||||||
NSAttributedString(string: body, attributes: [
|
|
||||||
.font: monospacedFont(context.pointSize),
|
|
||||||
.foregroundColor: NSColor.labelColor
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Block layout state
|
// MARK: Block layout state
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
// MARK: - CardBodyEditSession
|
||||||
|
|
||||||
|
/// One card window's Edit buffer: the text the user is typing, what disk last said, and the
|
||||||
|
/// debounced save between them (05-card-window.md ▸ Edit, ▸ Write rules).
|
||||||
|
///
|
||||||
|
/// ### One comparison is the whole write rule
|
||||||
|
///
|
||||||
|
/// 05 states three gates — "untouched → never re-serialized; reverted → not written; echo of an
|
||||||
|
/// external edit → not written back" — and they are three faces of a single predicate: **write if
|
||||||
|
/// and only if the buffer differs from what is on disk**.
|
||||||
|
///
|
||||||
|
/// - *Untouched*: the user opened Edit, read, and left. `text == disk`, so nothing is written and
|
||||||
|
/// the file stays byte-identical, `mtime` included.
|
||||||
|
/// - *Reverted*: they typed and undid it. The debounce is cancelled the moment `text` matches `disk`
|
||||||
|
/// again, so the timer that was going to write does not survive the revert.
|
||||||
|
/// - *Echo*: our own save lands, the watcher reloads, and the snapshot arrives carrying the text we
|
||||||
|
/// just wrote. `adopt(diskBody:)` moves `disk` to it, the buffer is already equal, and nothing is
|
||||||
|
/// written back — which is what stops a save from ringing forever through the one-way flow.
|
||||||
|
///
|
||||||
|
/// `BoardWriter.writeBody` re-checks the same equality against the bytes it reads fresh, so the
|
||||||
|
/// guarantee holds even against a caller that skipped this type. Belt and braces on purpose: this is
|
||||||
|
/// the promise a file-backed app cannot afford to get subtly wrong.
|
||||||
|
///
|
||||||
|
/// ### Dirty-buffer-wins, as one branch
|
||||||
|
///
|
||||||
|
/// "A dirty Edit buffer is never reloaded under the cursor: while the user has unsaved keystrokes,
|
||||||
|
/// watcher reloads update everything else (board, Preview, other windows) but leave the buffer
|
||||||
|
/// alone; the debounced save then writes it — deliberate last-writer-wins. A clean buffer follows
|
||||||
|
/// disk" (05 ▸ Write rules). That is `adopt(diskBody:)`'s single `if`: `disk` always follows the
|
||||||
|
/// snapshot, and `text` follows it only when the two agreed before the snapshot arrived.
|
||||||
|
///
|
||||||
|
/// Keeping `disk` current *even while dirty* is the deliberate half. It means "dirty" reads as
|
||||||
|
/// "differs from the file", not "differs from what the file said when I started" — so a foreign edit
|
||||||
|
/// that happens to arrive at the text the user typed lands the buffer clean and writes nothing,
|
||||||
|
/// rather than re-stamping a file that already says the right thing.
|
||||||
|
///
|
||||||
|
/// ### The undo and commit seams
|
||||||
|
///
|
||||||
|
/// ⌘Z is the *editor's* undo and lives in the text view (`CardBodySurface` gives it an
|
||||||
|
/// `NSUndoManager` of its own, which is what makes it session-scoped). What lives here is the other
|
||||||
|
/// half of 05 ▸ Edit's undo sentence: the **session**, whose end is the effective Save.
|
||||||
|
/// `endEditSession()` is that moment — the Edit→Preview flip, raw-source entry, or the window
|
||||||
|
/// closing — and it is deliberately a named call rather than a side effect of `flush()`, because
|
||||||
|
/// pro-m1's auto-commit coalesces exactly here: every debounced tick inside one session rides its
|
||||||
|
/// own `performWrite` bracket, and the committer's rule is one commit per *session*, "never per save
|
||||||
|
/// tick" (06-history-undo.md ▸ Rules ▸ Auto-commit). On the base edition there is no committer, so
|
||||||
|
/// the two calls do the same work today; the seam is what keeps them from having to be pulled apart
|
||||||
|
/// later.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class CardBodyEditSession {
|
||||||
|
|
||||||
|
// MARK: State
|
||||||
|
|
||||||
|
/// What the editor is showing — and, once the window has opened, the truest text there is: it is
|
||||||
|
/// the buffer when the buffer is dirty and disk when it is not, which is precisely the order 05
|
||||||
|
/// settles. Preview renders it too, so "the preview never lags the text that produced it" needs
|
||||||
|
/// no separate mechanism.
|
||||||
|
public private(set) var text: String = ""
|
||||||
|
|
||||||
|
/// What the last snapshot said is on disk. The write gate's other half; never shown.
|
||||||
|
public private(set) var disk: String = ""
|
||||||
|
|
||||||
|
/// Whether the buffer holds keystrokes the file does not.
|
||||||
|
public var isDirty: Bool { text != disk }
|
||||||
|
|
||||||
|
// MARK: Seams
|
||||||
|
|
||||||
|
/// The debounce interval — **~700 ms** (05 ▸ Edit), and settable so a test does not have to
|
||||||
|
/// spend it. `DragSession.holdTimeout`'s precedent: a production default on the property, and
|
||||||
|
/// the suite dialling it down to milliseconds.
|
||||||
|
@ObservationIgnored
|
||||||
|
public var debounceInterval: Duration = .milliseconds(700)
|
||||||
|
|
||||||
|
/// Where a save goes. Filled in by the window once it has a store and a card to aim at
|
||||||
|
/// (`CardWindowHost`), which is also why it is a closure rather than a store reference: this type
|
||||||
|
/// is a buffer and a clock, and it stays testable by having no idea what a board is.
|
||||||
|
///
|
||||||
|
/// `nil` is a session with nowhere to write — before the window has joined its board, and after
|
||||||
|
/// it has left. A flush then keeps the buffer dirty rather than reporting success.
|
||||||
|
@ObservationIgnored
|
||||||
|
public var save: ((String) -> CardBodyWriteOutcome)?
|
||||||
|
|
||||||
|
@ObservationIgnored
|
||||||
|
private var pending: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// How many saves have actually been attempted through `save` — the debounce's own testimony,
|
||||||
|
/// which a test would otherwise have to infer from `mtime`s.
|
||||||
|
@ObservationIgnored
|
||||||
|
public private(set) var saveAttempts = 0
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
// MARK: - Disk → buffer
|
||||||
|
|
||||||
|
/// A snapshot arrived. **Dirty-buffer-wins**: `disk` always follows it; `text` follows it only
|
||||||
|
/// when the buffer had nothing unsaved.
|
||||||
|
///
|
||||||
|
/// Called on every snapshot the window renders, including the first, which is how the buffer is
|
||||||
|
/// filled at all — a card window opens by adopting its card's body.
|
||||||
|
public func adopt(diskBody: String) {
|
||||||
|
let wasDirty = isDirty
|
||||||
|
disk = diskBody
|
||||||
|
guard !wasDirty else { return }
|
||||||
|
// Assigning an equal string would still notify observers, and an observer here is a text
|
||||||
|
// view that would replace its contents under the cursor.
|
||||||
|
if text != diskBody { text = diskBody }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Buffer → disk
|
||||||
|
|
||||||
|
/// The editor changed. Restarts the debounce — or cancels it outright, when the change brought
|
||||||
|
/// the buffer back to what disk already says (05's *reverted* gate: a revert must not leave a
|
||||||
|
/// timer standing that would then write nothing but a `modified` stamp).
|
||||||
|
public func edited(_ newText: String) {
|
||||||
|
guard text != newText else { return }
|
||||||
|
text = newText
|
||||||
|
guard isDirty else {
|
||||||
|
cancelPending()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves now if there is anything to save, cancelling the pending debounce first — "leaving Edit
|
||||||
|
/// flushes the debounce (mode flip, raw-source entry, window close)" (05 ▸ Mode grammar).
|
||||||
|
///
|
||||||
|
/// Synchronous, because the write is: `BoardWriter` is a temp file and a rename, and a flush that
|
||||||
|
/// returned before the bytes landed would be no flush at all — the close path in particular has
|
||||||
|
/// to know the answer before it lets the window go.
|
||||||
|
@discardableResult
|
||||||
|
public func flush() -> CardBodyWriteOutcome {
|
||||||
|
cancelPending()
|
||||||
|
return saveNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The end of one Edit session — the flip back to Preview, raw-source entry, or the window
|
||||||
|
/// closing. Flushes, and marks the boundary pro-m1's auto-commit coalesces on (see the type's
|
||||||
|
/// doc comment).
|
||||||
|
@discardableResult
|
||||||
|
public func endEditSession() -> CardBodyWriteOutcome {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DirtyBufferGuard`'s `attemptSave`: the same flush, with a real failure raised instead of
|
||||||
|
/// reported.
|
||||||
|
///
|
||||||
|
/// The three non-failures deliberately do *not* throw, because each of them is a state in which
|
||||||
|
/// blocking the close would be dishonest:
|
||||||
|
///
|
||||||
|
/// - `.written` / `.unchanged` — the text is on disk.
|
||||||
|
/// - `.vanished` — the card's folder is gone, so there is nowhere for the save to land; 05 ▸
|
||||||
|
/// Deletion & lifecycle answers exactly this case with "nowhere left to write", and a modal
|
||||||
|
/// offering Try Again against a deleted folder would be a button that can only fail.
|
||||||
|
/// - `.suspended` — the board is locked read-only, which is 05's "where a save can land"
|
||||||
|
/// qualifier failing rather than a write failing: no write was attempted, the lock row has been
|
||||||
|
/// standing the whole time the user was typing, and the lock's own clearing rule (a successful
|
||||||
|
/// reload) is not something a close can wait on.
|
||||||
|
public func flushOrThrow() throws(BoardWriteError) {
|
||||||
|
switch flush() {
|
||||||
|
case .written, .unchanged, .vanished, .suspended:
|
||||||
|
return
|
||||||
|
case let .failed(error):
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private func scheduleSave() {
|
||||||
|
cancelPending()
|
||||||
|
let interval = debounceInterval
|
||||||
|
pending = Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: interval)
|
||||||
|
guard !Task.isCancelled, let self else { return }
|
||||||
|
self.pending = nil
|
||||||
|
_ = self.saveNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelPending() {
|
||||||
|
pending?.cancel()
|
||||||
|
pending = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gate, and the one place `save` is called.
|
||||||
|
///
|
||||||
|
/// A successful landing moves `disk` up to the text that landed, so the echo arriving a reload
|
||||||
|
/// later finds the buffer already clean. A failure, a suspension and a vanished card all leave
|
||||||
|
/// `disk` where it was, which keeps the buffer dirty — and therefore keeps the text, which is the
|
||||||
|
/// whole point.
|
||||||
|
private func saveNow() -> CardBodyWriteOutcome {
|
||||||
|
guard isDirty else { return .unchanged }
|
||||||
|
guard let save else { return .vanished }
|
||||||
|
|
||||||
|
saveAttempts += 1
|
||||||
|
let outcome = save(text)
|
||||||
|
switch outcome {
|
||||||
|
case .written, .unchanged:
|
||||||
|
disk = text
|
||||||
|
case .suspended, .vanished, .failed:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,16 @@ public final class CardBodyPresentation {
|
|||||||
/// exactly when ⌘F has nothing to find in.
|
/// exactly when ⌘F has nothing to find in.
|
||||||
public var findInText: (() -> Void)?
|
public var findInText: (() -> Void)?
|
||||||
|
|
||||||
|
/// Flushes the Edit buffer — **"leaving Edit flushes the debounce"** (05 ▸ Mode grammar), and
|
||||||
|
/// the reason the flip goes through `setMode(_:)` rather than being three separate assignments.
|
||||||
|
///
|
||||||
|
/// Filled in by the window with its edit session's `endEditSession()`. It hangs here rather than
|
||||||
|
/// on the session because *this* is the type every path that leaves Edit already holds: the menu
|
||||||
|
/// item's toggle, Escape in the editor, and Return in Preview all flip the mode through one
|
||||||
|
/// object, so attaching the flush to the flip is what makes "always" true by construction rather
|
||||||
|
/// than by three call sites remembering.
|
||||||
|
public var flushEdits: (() -> Void)?
|
||||||
|
|
||||||
/// Whether the opening rule has already run for this window.
|
/// Whether the opening rule has already run for this window.
|
||||||
///
|
///
|
||||||
/// **Once, not per snapshot.** The rule is about *opening* a card, and the body it judges
|
/// **Once, not per snapshot.** The rule is about *opening* a card, and the body it judges
|
||||||
@@ -80,7 +90,59 @@ public final class CardBodyPresentation {
|
|||||||
|
|
||||||
/// ⌘E, Return in Preview, Escape in Edit — see `CardBodyMode.toggled`.
|
/// ⌘E, Return in Preview, Escape in Edit — see `CardBodyMode.toggled`.
|
||||||
public func toggleMode() {
|
public func toggleMode() {
|
||||||
mode = mode.toggled
|
setMode(mode.toggled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one place the mode changes, and therefore the one place **leaving Edit flushes** (05 ▸
|
||||||
|
/// Mode grammar: "Leaving Edit flushes the debounce (mode flip, raw-source entry, window close)
|
||||||
|
/// — the preview never lags the text that produced it, and neither does disk").
|
||||||
|
///
|
||||||
|
/// The flush runs *before* the flip, not after: Preview reads the same buffer the editor was
|
||||||
|
/// writing, so a flip that rendered first and saved second would be indistinguishable on screen
|
||||||
|
/// — but a failure in that order would leave the user reading text the app had just failed to
|
||||||
|
/// save, with the mode already changed under them. Saving first means the banner (and, on a
|
||||||
|
/// close, the modal) arrives while the editor is still the thing on screen.
|
||||||
|
///
|
||||||
|
/// Setting the mode it already has does nothing at all, which is what keeps a redundant
|
||||||
|
/// menu-item validation pass or a re-published focus value from flushing an untouched buffer.
|
||||||
|
public func setMode(_ newMode: CardBodyMode) {
|
||||||
|
guard newMode != mode else { return }
|
||||||
|
if mode == .edit { flushEdits?() }
|
||||||
|
mode = newMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View ▸ Edit Body
|
||||||
|
|
||||||
|
/// View ▸ Edit Body (⌘E) — the body column's mode toggle, with checkmark state (11-command-nexus.md;
|
||||||
|
/// 05-card-window.md ▸ Mode grammar).
|
||||||
|
///
|
||||||
|
/// **A `Toggle`, because the row is a checkmark row**: 11 files it as "(checkmark toggle)", and
|
||||||
|
/// 04-interactions.md ▸ Configurable bindings requires that such a row keep "one stable title,
|
||||||
|
/// checkmark state only" — so the title is the same string it was while the row was disabled, and
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
// 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.
|
||||||
|
struct EditBodyCommand: View {
|
||||||
|
|
||||||
|
@FocusedValue(\.cardBody) private var cardBody
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Toggle("Edit Body", isOn: Binding(
|
||||||
|
get: { cardBody?.mode == .edit },
|
||||||
|
set: { isOn in cardBody?.setMode(isOn ? .edit : .preview) }
|
||||||
|
))
|
||||||
|
.keyboardShortcut("e", modifiers: .command)
|
||||||
|
.disabled(cardBody == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import SwiftUI
|
|||||||
// MARK: - CardBodySurface
|
// MARK: - CardBodySurface
|
||||||
|
|
||||||
/// The body column's text surface: **one hosted `NSTextView` serving both modes** — the rendered
|
/// The body column's text surface: **one hosted `NSTextView` serving both modes** — the rendered
|
||||||
/// Preview and, until the Edit card lands, the read-only raw-Markdown placeholder.
|
/// Preview and the raw-Markdown Edit editor.
|
||||||
///
|
///
|
||||||
/// ### Why AppKit, and not `Text(…).textSelection(.enabled)`
|
/// ### Why AppKit, and not `Text(…).textSelection(.enabled)`
|
||||||
///
|
///
|
||||||
@@ -28,20 +28,37 @@ import SwiftUI
|
|||||||
///
|
///
|
||||||
/// ### One substrate, two modes
|
/// ### One substrate, two modes
|
||||||
///
|
///
|
||||||
/// Preview and the Edit placeholder differ only in the attributed string they are handed
|
/// Preview and Edit differ in three things and nothing else: whether the view is editable, what it
|
||||||
/// (`BodyMarkupRenderer.attributedString` vs `.rawText`). That is deliberate: it means ⌘F, text
|
/// is handed (a rendered attributed string, or the raw text under a highlighting pass), and which
|
||||||
/// selection and copying behave identically on both surfaces without either one implementing them,
|
/// key means "flip". Everything else — ⌘F, selection, copying, the find bar, the scroll position —
|
||||||
/// and it leaves the Edit card a seam whose shape is already known — make this view editable, give
|
/// belongs to the substrate and is therefore identical in both, without either mode implementing it.
|
||||||
/// it a debounced save, and swap `.rawText` for a highlighting pass.
|
///
|
||||||
|
/// **One view rather than two representables**, deliberately: `CardBodyPresentation.findInText` holds
|
||||||
|
/// a closure over *this* text view, and two views swapping across a mode flip would race to own it —
|
||||||
|
/// ⌘F would work or not depending on the order SwiftUI happened to mount them in. One view has one
|
||||||
|
/// text view for the window's life, and the flip is a reconfiguration.
|
||||||
|
///
|
||||||
|
/// ### What the editor writes, and when
|
||||||
|
///
|
||||||
|
/// Nothing here writes to disk. The text view reports every change to `CardBodyEditSession`, which
|
||||||
|
/// owns the ~700 ms debounce, the three write gates and the flush; this file's whole responsibility
|
||||||
|
/// is that the buffer and the view agree, and that the view never has text replaced under the user's
|
||||||
|
/// cursor (the view half of dirty-buffer-wins — the session's half is `adopt(diskBody:)`).
|
||||||
struct CardBodySurface: NSViewRepresentable {
|
struct CardBodySurface: NSViewRepresentable {
|
||||||
|
|
||||||
/// The card's body, verbatim — the source both renderings are made from.
|
/// The text to show: `CardBodyEditSession.text`, which is the buffer in Edit and — because a
|
||||||
|
/// clean buffer follows disk — the card's body in Preview. One string for both modes is what
|
||||||
|
/// makes "the preview never lags the text that produced it" (05 ▸ Mode grammar) fall out rather
|
||||||
|
/// than need arranging.
|
||||||
let body: String
|
let body: String
|
||||||
let mode: CardBodyMode
|
let mode: CardBodyMode
|
||||||
/// The card's own folder: what relative images and links resolve against.
|
/// The card's own folder: what relative images and links resolve against.
|
||||||
let cardFolder: URL?
|
let cardFolder: URL?
|
||||||
/// The window's body handle — this view fills in its `findInText`.
|
/// The window's body handle — this view fills in its `findInText`.
|
||||||
let presentation: CardBodyPresentation
|
let presentation: CardBodyPresentation
|
||||||
|
/// The buffer this surface edits. Keystrokes go in through `edited(_:)`; nothing else here
|
||||||
|
/// touches it.
|
||||||
|
let session: CardBodyEditSession
|
||||||
/// Whether a checkbox click may write. `false` under the read-only lock, where "the controls
|
/// Whether a checkbox click may write. `false` under the read-only lock, where "the controls
|
||||||
/// disable in place" (05 ▸ Preview; 02-architecture.md § the lock's scope).
|
/// disable in place" (05 ▸ Preview; 02-architecture.md § the lock's scope).
|
||||||
let isTaskToggleEnabled: Bool
|
let isTaskToggleEnabled: Bool
|
||||||
@@ -63,7 +80,7 @@ struct CardBodySurface: NSViewRepresentable {
|
|||||||
container.widthTracksTextView = true
|
container.widthTracksTextView = true
|
||||||
layoutManager.addTextContainer(container)
|
layoutManager.addTextContainer(container)
|
||||||
|
|
||||||
let textView = NSTextView(frame: .zero, textContainer: container)
|
let textView = CardBodyTextView(frame: .zero, textContainer: container)
|
||||||
textView.delegate = context.coordinator
|
textView.delegate = context.coordinator
|
||||||
textView.isEditable = false
|
textView.isEditable = false
|
||||||
textView.isSelectable = true
|
textView.isSelectable = true
|
||||||
@@ -74,12 +91,21 @@ struct CardBodySurface: NSViewRepresentable {
|
|||||||
textView.autoresizingMask = NSView.AutoresizingMask.width
|
textView.autoresizingMask = NSView.AutoresizingMask.width
|
||||||
textView.minSize = CGSize(width: 0, height: 0)
|
textView.minSize = CGSize(width: 0, height: 0)
|
||||||
textView.maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
textView.maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
||||||
// Nothing about a card body is the app's to rewrite as the user reads it.
|
// Nothing about a card body is the app's to rewrite as the user reads it — or as they type
|
||||||
|
// it: "the text is the raw Markdown, character for character — no hidden transforms, no
|
||||||
|
// smart substitutions" (05 ▸ Edit) is exactly this list, and in Edit it is normative rather
|
||||||
|
// than merely tidy. A smart quote substituted into a fenced code block would be the app
|
||||||
|
// silently corrupting the user's file.
|
||||||
textView.isAutomaticLinkDetectionEnabled = false
|
textView.isAutomaticLinkDetectionEnabled = false
|
||||||
textView.isAutomaticQuoteSubstitutionEnabled = false
|
textView.isAutomaticQuoteSubstitutionEnabled = false
|
||||||
textView.isAutomaticDashSubstitutionEnabled = false
|
textView.isAutomaticDashSubstitutionEnabled = false
|
||||||
textView.isAutomaticTextReplacementEnabled = false
|
textView.isAutomaticTextReplacementEnabled = false
|
||||||
textView.isAutomaticSpellingCorrectionEnabled = false
|
textView.isAutomaticSpellingCorrectionEnabled = false
|
||||||
|
textView.isAutomaticDataDetectionEnabled = false
|
||||||
|
textView.smartInsertDeleteEnabled = false
|
||||||
|
// ⌘Z is the editor's own undo (05 ▸ Edit). `allowsUndo` turns it on; the *session* scoping is
|
||||||
|
// the coordinator's `undoManager(for:)`, below.
|
||||||
|
textView.allowsUndo = true
|
||||||
// The renderer already coloured links and checkboxes; the only thing the text view should
|
// The renderer already coloured links and checkboxes; the only thing the text view should
|
||||||
// add is the pointer, so the two do not fight over the run's appearance.
|
// add is the pointer, so the two do not fight over the run's appearance.
|
||||||
let linkAttributes: [NSAttributedString.Key: Any] = [.cursor: NSCursor.pointingHand]
|
let linkAttributes: [NSAttributedString.Key: Any] = [.cursor: NSCursor.pointingHand]
|
||||||
@@ -91,6 +117,15 @@ struct CardBodySurface: NSViewRepresentable {
|
|||||||
let gutter = CardWindowMetrics.gutter(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
let gutter = CardWindowMetrics.gutter(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
||||||
textView.textContainerInset = CGSize(width: gutter, height: gutter)
|
textView.textContainerInset = CGSize(width: gutter, height: gutter)
|
||||||
|
|
||||||
|
// The two fixed keys of the mode grammar, on the surface that owns the keyboard while they
|
||||||
|
// are pressed: "Return in Preview also enters Edit … Escape in Edit returns to Preview"
|
||||||
|
// (05 ▸ Mode grammar). They are the text view's rather than a SwiftUI `.onKeyPress` because
|
||||||
|
// the text view *is* the first responder in both modes — a key handler above it would only
|
||||||
|
// see what the editor declined to eat.
|
||||||
|
let presentation = presentation
|
||||||
|
textView.onReturnInPreview = { presentation.setMode(.edit) }
|
||||||
|
textView.onEscapeInEdit = { presentation.setMode(.preview) }
|
||||||
|
|
||||||
let scrollView = NSScrollView()
|
let scrollView = NSScrollView()
|
||||||
scrollView.documentView = textView
|
scrollView.documentView = textView
|
||||||
scrollView.hasVerticalScroller = true
|
scrollView.hasVerticalScroller = true
|
||||||
@@ -100,13 +135,13 @@ struct CardBodySurface: NSViewRepresentable {
|
|||||||
scrollView.findBarPosition = .aboveContent
|
scrollView.findBarPosition = .aboveContent
|
||||||
|
|
||||||
context.coordinator.textView = textView
|
context.coordinator.textView = textView
|
||||||
|
context.coordinator.session = session
|
||||||
context.coordinator.onToggleTask = onToggleTask
|
context.coordinator.onToggleTask = onToggleTask
|
||||||
context.coordinator.isTaskToggleEnabled = isTaskToggleEnabled
|
context.coordinator.isTaskToggleEnabled = isTaskToggleEnabled
|
||||||
|
|
||||||
// Deferred one turn: `makeNSView` runs inside SwiftUI's update, and `presentation` is
|
// Deferred one turn: `makeNSView` runs inside SwiftUI's update, and `presentation` is
|
||||||
// observed by a menu item (Edit ▸ Find), so writing to it here would be a mutation during
|
// observed by a menu item (Edit ▸ Find), so writing to it here would be a mutation during
|
||||||
// an update of the very graph that reads it.
|
// an update of the very graph that reads it.
|
||||||
let presentation = presentation
|
|
||||||
Task { @MainActor [weak textView] in
|
Task { @MainActor [weak textView] in
|
||||||
presentation.findInText = { [weak textView] in
|
presentation.findInText = { [weak textView] in
|
||||||
guard let textView else { return }
|
guard let textView else { return }
|
||||||
@@ -127,9 +162,15 @@ struct CardBodySurface: NSViewRepresentable {
|
|||||||
let coordinator = context.coordinator
|
let coordinator = context.coordinator
|
||||||
coordinator.onToggleTask = onToggleTask
|
coordinator.onToggleTask = onToggleTask
|
||||||
coordinator.isTaskToggleEnabled = isTaskToggleEnabled
|
coordinator.isTaskToggleEnabled = isTaskToggleEnabled
|
||||||
|
coordinator.session = session
|
||||||
|
|
||||||
guard let textView = scrollView.documentView as? NSTextView else { return }
|
guard let textView = scrollView.documentView as? CardBodyTextView else { return }
|
||||||
let pointSize = CardWindowMetrics.bodyPointSize
|
let pointSize = CardWindowMetrics.bodyPointSize
|
||||||
|
|
||||||
|
if coordinator.mode != mode {
|
||||||
|
coordinator.enter(mode, in: textView)
|
||||||
|
}
|
||||||
|
|
||||||
let key = Coordinator.RenderKey(body: body, mode: mode, cardFolder: cardFolder, pointSize: pointSize)
|
let key = Coordinator.RenderKey(body: body, mode: mode, cardFolder: cardFolder, pointSize: pointSize)
|
||||||
// **Rebuild only when the inputs changed.** SwiftUI re-runs this on every unrelated state
|
// **Rebuild only when the inputs changed.** SwiftUI re-runs this on every unrelated state
|
||||||
// change in the window; re-laying out the whole body each time would throw away the scroll
|
// change in the window; re-laying out the whole body each time would throw away the scroll
|
||||||
@@ -137,17 +178,21 @@ struct CardBodySurface: NSViewRepresentable {
|
|||||||
guard coordinator.rendered != key else { return }
|
guard coordinator.rendered != key else { return }
|
||||||
coordinator.rendered = key
|
coordinator.rendered = key
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case .preview:
|
||||||
let context = BodyMarkupRenderer.Context(pointSize: pointSize, cardFolder: cardFolder)
|
let context = BodyMarkupRenderer.Context(pointSize: pointSize, cardFolder: cardFolder)
|
||||||
let content: NSAttributedString = switch mode {
|
textView.textStorage?.setAttributedString(
|
||||||
case .preview: BodyMarkupRenderer.attributedString(for: BodyMarkup.parse(body), context: context)
|
BodyMarkupRenderer.attributedString(for: BodyMarkup.parse(body), context: context)
|
||||||
case .edit: BodyMarkupRenderer.rawText(body, context: context)
|
)
|
||||||
|
|
||||||
|
case .edit:
|
||||||
|
coordinator.show(body, in: textView, pointSize: pointSize)
|
||||||
}
|
}
|
||||||
textView.textStorage?.setAttributedString(content)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Coordinator
|
// MARK: - Coordinator
|
||||||
|
|
||||||
/// The delegate, and the render cache.
|
/// The delegate, the render cache, and the editor's session-scoped undo.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class Coordinator: NSObject, NSTextViewDelegate {
|
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||||
|
|
||||||
@@ -161,9 +206,117 @@ struct CardBodySurface: NSViewRepresentable {
|
|||||||
|
|
||||||
weak var textView: NSTextView?
|
weak var textView: NSTextView?
|
||||||
var rendered: RenderKey?
|
var rendered: RenderKey?
|
||||||
|
var session: CardBodyEditSession?
|
||||||
var onToggleTask: ((Int, Bool) -> Void)?
|
var onToggleTask: ((Int, Bool) -> Void)?
|
||||||
var isTaskToggleEnabled = true
|
var isTaskToggleEnabled = true
|
||||||
|
|
||||||
|
/// Which mode the view is currently *configured* for — `nil` until the first update, which is
|
||||||
|
/// what makes the initial configuration a mode entry like any other (and is why a card that
|
||||||
|
/// opens straight into Edit gets the caret without a special case).
|
||||||
|
private(set) var mode: CardBodyMode?
|
||||||
|
|
||||||
|
/// **The editor's own undo manager, and the whole of "session-scoped"** (05 ▸ Edit: "⌘Z here
|
||||||
|
/// is the text view's own undo — session-scoped, ending when the editor loses focus or the
|
||||||
|
/// mode flips").
|
||||||
|
///
|
||||||
|
/// Without this the text view would use the *window's* undo manager, whose stack outlives
|
||||||
|
/// every mode flip and is shared with anything else in the window that registers an
|
||||||
|
/// undoable action — so ⌘Z after leaving Edit could reach back into text the user had
|
||||||
|
/// already committed. Owning one here makes the scoping structural: `removeAllActions()` at
|
||||||
|
/// the two moments 05 names is then an emptying of a stack nothing else can see.
|
||||||
|
private let editorUndoManager = UndoManager()
|
||||||
|
|
||||||
|
/// Set while this coordinator is replacing the view's text, so the resulting change
|
||||||
|
/// notification is not mistaken for typing.
|
||||||
|
private var isSettingText = false
|
||||||
|
|
||||||
|
// MARK: Mode
|
||||||
|
|
||||||
|
/// Reconfigures the view for a mode — the only place editability, the undo stack and first
|
||||||
|
/// responder change.
|
||||||
|
func enter(_ newMode: CardBodyMode, in textView: CardBodyTextView) {
|
||||||
|
mode = newMode
|
||||||
|
// The session's undo stack ends with the mode, per 05. Emptied on the way *in* as well
|
||||||
|
// as out, so an Edit session never opens on top of the previous one's actions.
|
||||||
|
editorUndoManager.removeAllActions()
|
||||||
|
|
||||||
|
switch newMode {
|
||||||
|
case .preview:
|
||||||
|
textView.isEditable = false
|
||||||
|
|
||||||
|
case .edit:
|
||||||
|
textView.isEditable = true
|
||||||
|
textView.typingAttributes = MarkdownHighlighter.baseAttributes(
|
||||||
|
pointSize: CardWindowMetrics.bodyPointSize
|
||||||
|
)
|
||||||
|
// "**Empty body opens in Edit** with the cursor ready" (05 ▸ Mode grammar) — and the
|
||||||
|
// same courtesy for a deliberate ⌘E, which is a request to type. Deferred a turn:
|
||||||
|
// this runs inside a SwiftUI update, and making a view first responder re-enters
|
||||||
|
// AppKit's responder machinery.
|
||||||
|
Task { @MainActor [weak textView] in
|
||||||
|
guard let textView, textView.isEditable else { return }
|
||||||
|
textView.window?.makeFirstResponder(textView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts `text` in the editor and highlights it — **never replacing what the user is looking
|
||||||
|
/// at unless it actually differs**.
|
||||||
|
///
|
||||||
|
/// The equality guard is load-bearing rather than an optimization: this runs on every
|
||||||
|
/// keystroke (the buffer changed, so SwiftUI re-ran the update), and replacing the storage
|
||||||
|
/// with the string it already holds would collapse the selection, scroll the view, and throw
|
||||||
|
/// away the undo stack — on every character typed.
|
||||||
|
func show(_ text: String, in textView: CardBodyTextView, pointSize: CGFloat) {
|
||||||
|
guard let storage = textView.textStorage else { return }
|
||||||
|
|
||||||
|
if storage.string != text {
|
||||||
|
// A foreign edit arriving under a *clean* buffer, or the first fill of the editor.
|
||||||
|
// The selection is preserved where it still fits; a caret past the new end clamps
|
||||||
|
// rather than disappearing.
|
||||||
|
let selected = textView.selectedRange()
|
||||||
|
// The undo stack described text that no longer exists — an agent or a hand edit
|
||||||
|
// replaced it — and ⌘Z restoring a run of it would be this app inventing a merge.
|
||||||
|
editorUndoManager.removeAllActions()
|
||||||
|
isSettingText = true
|
||||||
|
storage.setAttributedString(NSAttributedString(
|
||||||
|
string: text,
|
||||||
|
attributes: MarkdownHighlighter.baseAttributes(pointSize: pointSize)
|
||||||
|
))
|
||||||
|
isSettingText = false
|
||||||
|
let length = (text as NSString).length
|
||||||
|
textView.setSelectedRange(NSRange(
|
||||||
|
location: min(selected.location, length),
|
||||||
|
length: min(selected.length, max(0, length - min(selected.location, length)))
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
MarkdownHighlighter.highlight(storage, pointSize: pointSize)
|
||||||
|
textView.typingAttributes = MarkdownHighlighter.baseAttributes(pointSize: pointSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: NSTextViewDelegate
|
||||||
|
|
||||||
|
/// The editor's undo manager — see `editorUndoManager`.
|
||||||
|
func undoManager(for view: NSTextView) -> UndoManager? {
|
||||||
|
editorUndoManager
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every keystroke, straight into the buffer. The session decides what that costs: a
|
||||||
|
/// restarted debounce, or a cancelled one when the change happened to restore the file's own
|
||||||
|
/// text.
|
||||||
|
func textDidChange(_ notification: Notification) {
|
||||||
|
guard !isSettingText, mode == .edit, let textView = notification.object as? NSTextView else { return }
|
||||||
|
session?.edited(textView.string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Focus leaving the editor ends the undo session (05 ▸ Edit), and is *not* a save: the
|
||||||
|
/// debounce is still running and will land on its own, which is what keeps clicking into the
|
||||||
|
/// sidebar from being a commit point the design never named.
|
||||||
|
func textDidEndEditing(_ notification: Notification) {
|
||||||
|
editorUndoManager.removeAllActions()
|
||||||
|
}
|
||||||
|
|
||||||
/// The click grammar, in one method: a checkbox writes, anything else opens, and the return
|
/// The click grammar, in one method: a checkbox writes, anything else opens, and the return
|
||||||
/// value is always `true` so the text view never falls back to its own link handling.
|
/// value is always `true` so the text view never falls back to its own link handling.
|
||||||
func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool {
|
func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool {
|
||||||
@@ -194,3 +347,62 @@ struct CardBodySurface: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - CardBodyTextView
|
||||||
|
|
||||||
|
/// The body surface's text view, subclassed for exactly two keys.
|
||||||
|
///
|
||||||
|
/// Return in Preview and Escape in Edit are **fixed grammar, not menu items** (05-card-window.md ▸
|
||||||
|
/// Mode grammar: "Return in Preview also enters Edit — the board's edit key applied to the body;
|
||||||
|
/// fixed grammar like the board's Return, not a menu item"), so they have to be intercepted where
|
||||||
|
/// the keyboard actually is. Both are guarded by editability, which is the mode: a Return in Edit is
|
||||||
|
/// a newline like any other, and an Escape in Preview means nothing here.
|
||||||
|
final class CardBodyTextView: NSTextView {
|
||||||
|
|
||||||
|
var onReturnInPreview: (() -> Void)?
|
||||||
|
var onEscapeInEdit: (() -> Void)?
|
||||||
|
|
||||||
|
/// Preview is not editable, so AppKit would send this nowhere — the mode's own key handling has
|
||||||
|
/// to come before `super`, which for a read-only text view merely beeps.
|
||||||
|
override func keyDown(with event: NSEvent) {
|
||||||
|
let isPlainReturn = event.keyCode == 36 || event.keyCode == 76
|
||||||
|
let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||||
|
.subtracting([.function, .numericPad, .capsLock])
|
||||||
|
if !isEditable, isPlainReturn, modifiers.isEmpty {
|
||||||
|
onReturnInPreview?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
super.keyDown(with: event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escape. Intercepted before `NSTextView`'s own meaning for it (text completion), and only
|
||||||
|
/// while editing — with the find bar up the bar is first responder and never reaches this.
|
||||||
|
override func cancelOperation(_ sender: Any?) {
|
||||||
|
guard isEditable, let onEscapeInEdit else {
|
||||||
|
super.cancelOperation(sender)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onEscapeInEdit()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **A file drop is never the editor's** (05-card-window.md ▸ Attachments, settled: "file drops
|
||||||
|
/// import as attachments anywhere in the window — Edit mode included, the text editor never
|
||||||
|
/// intercepts a file drop; dragged *text* lands in the Edit editor at the caret").
|
||||||
|
///
|
||||||
|
/// An editable, rich `NSTextView` would otherwise happily take a dragged file and turn it into a
|
||||||
|
/// path or an attachment cell inside the user's Markdown. Dropping the *file* types from what
|
||||||
|
/// this view accepts lets that drag fall through to the window, which is where the attachment
|
||||||
|
/// import belongs. Every text type — a plain-text drag, a URL dragged out of a browser — is left
|
||||||
|
/// exactly as AppKit offers it, so the other half of the rule is the default behaviour rather
|
||||||
|
/// than a re-implementation of it.
|
||||||
|
///
|
||||||
|
// m6-card-attachments: the window-level drop surface that catches what this declines.
|
||||||
|
override var acceptableDragTypes: [NSPasteboard.PasteboardType] {
|
||||||
|
let fileTypes: Set<NSPasteboard.PasteboardType> = [
|
||||||
|
.fileURL,
|
||||||
|
// The Carbon-era name AppKit still puts on a Finder drag alongside the modern one.
|
||||||
|
NSPasteboard.PasteboardType("NSFilenamesPboardType")
|
||||||
|
]
|
||||||
|
return super.acceptableDragTypes.filter { !fileTypes.contains($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import SwiftUI
|
|||||||
/// ### What this milestone builds, and what it deliberately does not
|
/// ### What this milestone builds, and what it deliberately does not
|
||||||
///
|
///
|
||||||
/// The *shell*: the two columns, their scrolling, the sidebar's fixed width, and the read-only
|
/// 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, its created/modified line, and
|
/// renderings of what the loader already knows — the card's title and its created/modified line —
|
||||||
/// its body as plain text. Everything that reads or writes beyond that is later work and is marked
|
/// over the body column's two live surfaces (Preview and Edit, `CardBodySurface`). Everything that
|
||||||
/// where it lands:
|
/// 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 title as an editable field (commit on Return / focus loss, Escape abandons),
|
||||||
/// - the raw-source outlet,
|
/// - the raw-source outlet,
|
||||||
@@ -43,6 +43,10 @@ struct CardWindowView: View {
|
|||||||
let cardFolder: URL?
|
let cardFolder: URL?
|
||||||
/// This window's body-column state: which mode it is in, and the find-bar hook.
|
/// This window's body-column state: which mode it is in, and the find-bar hook.
|
||||||
let bodyPresentation: CardBodyPresentation
|
let bodyPresentation: CardBodyPresentation
|
||||||
|
/// This window's Edit buffer. It holds the text **both** surfaces show: the editor writes into
|
||||||
|
/// 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
|
||||||
/// Whether a checkbox may write — `false` under the board's read-only lock.
|
/// Whether a checkbox may write — `false` under the board's read-only lock.
|
||||||
let isEditable: Bool
|
let isEditable: Bool
|
||||||
/// Commits a checkbox flip: the marker's byte offset in the body, and the state the user saw.
|
/// Commits a checkbox flip: the marker's byte offset in the body, and the state the user saw.
|
||||||
@@ -95,45 +99,34 @@ struct CardWindowView: View {
|
|||||||
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
|
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
|
||||||
.padding(.top, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
|
.padding(.top, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
|
||||||
|
|
||||||
if bodyPresentation.mode == .edit {
|
|
||||||
editPlaceholderNotice
|
|
||||||
}
|
|
||||||
|
|
||||||
CardBodySurface(
|
CardBodySurface(
|
||||||
body: card.body,
|
// The session's text, never `card.body` directly: a dirty buffer outranks the
|
||||||
|
// snapshot (05 ▸ Write rules) and a flushed one is ahead of it by a reload, so the
|
||||||
|
// buffer is the truer of the two in both modes — which is also how Preview shows the
|
||||||
|
// text that produced it the instant Edit is left.
|
||||||
|
body: bodySession.text,
|
||||||
mode: bodyPresentation.mode,
|
mode: bodyPresentation.mode,
|
||||||
cardFolder: cardFolder,
|
cardFolder: cardFolder,
|
||||||
presentation: bodyPresentation,
|
presentation: bodyPresentation,
|
||||||
|
session: bodySession,
|
||||||
isTaskToggleEnabled: isEditable,
|
isTaskToggleEnabled: isEditable,
|
||||||
onToggleTask: onToggleTask
|
onToggleTask: onToggleTask
|
||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
// **Dirty-buffer-wins, applied on every snapshot** (05 ▸ Write rules): the session takes
|
||||||
|
// disk's word for what the file says, and takes it into the editor only when the buffer has
|
||||||
|
// nothing unsaved. `initial: true` is also how the buffer is filled at all — a window opens
|
||||||
|
// by adopting its card's body.
|
||||||
|
.onChange(of: card.body, initial: true) { _, body in
|
||||||
|
bodySession.adopt(diskBody: body)
|
||||||
|
}
|
||||||
// **The opening rule, applied once** (05 ▸ Mode grammar): a card opens in Preview unless its
|
// **The opening rule, applied once** (05 ▸ Mode grammar): a card opens in Preview unless its
|
||||||
// body is empty, in which case it opens straight into Edit. `openIfNeeded` is what makes it
|
// body is empty, in which case it opens straight into Edit. `openIfNeeded` is what makes it
|
||||||
// "once" — a later reload that empties the file must not drag a reader into Edit.
|
// "once" — a later reload that empties the file must not drag a reader into Edit.
|
||||||
.task { bodyPresentation.openIfNeeded(body: card.body) }
|
.task { bodyPresentation.openIfNeeded(body: card.body) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Edit mode's honest placeholder.
|
|
||||||
///
|
|
||||||
/// **The mode is real; the editor is not.** 05's opening rule is not a rendering detail that can
|
|
||||||
/// wait — it decides which surface a brand-new card lands on — so this milestone implements the
|
|
||||||
/// *state* (`CardBodyMode`, the opening rule, the toggle) and leaves the editor itself to the
|
|
||||||
/// Edit card. What shows meanwhile is the raw Markdown, monospaced and read-only, over a line
|
|
||||||
/// that says so: a text view that looked editable and silently discarded keystrokes would be a
|
|
||||||
/// worse lie than an empty pane, and one that saved would be this milestone building the thing
|
|
||||||
/// it deliberately is not building.
|
|
||||||
private var editPlaceholderNotice: some View {
|
|
||||||
Text("Body editing arrives with the Edit surface — this is the raw Markdown, read-only.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
|
|
||||||
.padding(.vertical, CardWindowMetrics.previewPadding(bodyPointSize: bodyPointSize))
|
|
||||||
.background(.background.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "Created ⟨date⟩ · Modified ⟨date⟩ · by ⟨modified-by⟩", **omitting whichever keys are absent**
|
/// "Created ⟨date⟩ · Modified ⟨date⟩ · by ⟨modified-by⟩", **omitting whichever keys are absent**
|
||||||
/// (05 ▸ Composition) — the whole line disappears when the card carries none of the three.
|
/// (05 ▸ Composition) — the whole line disappears when the card carries none of the three.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - MarkdownHighlighter
|
||||||
|
|
||||||
|
/// The Edit editor's **lightweight Markdown syntax highlighting** (05-card-window.md ▸ Edit:
|
||||||
|
/// "headings emphasized, bold/italic rendered as such, code tinted, link targets and structural
|
||||||
|
/// markers dimmed").
|
||||||
|
///
|
||||||
|
/// ### It emits ranges, and that is the whole safety argument
|
||||||
|
///
|
||||||
|
/// "Highlighting is presentation only: the text is the raw Markdown, character for character — no
|
||||||
|
/// hidden transforms, no smart substitutions" (05 ▸ Edit). A highlighter that returned a string
|
||||||
|
/// could break that promise; one that returns `[Span]` — offsets into the text it was handed —
|
||||||
|
/// structurally cannot. `apply(_:to:pointSize:)` is the only part that touches a text storage, and it
|
||||||
|
/// calls nothing but `setAttributes`/`addAttribute`.
|
||||||
|
///
|
||||||
|
/// ### Why a line scanner rather than a swift-markdown re-parse
|
||||||
|
///
|
||||||
|
/// Preview parses with swift-markdown (`BodyMarkup`) because it renders *structure* — tables, nested
|
||||||
|
/// quotes, list nesting — and structure is what a parser is for. The editor needs something
|
||||||
|
/// different, and the difference is decisive:
|
||||||
|
///
|
||||||
|
/// - **It runs on every keystroke.** A full CommonMark parse per character, on the main actor,
|
||||||
|
/// buys a document tree that is thrown away immediately; a single pass of a handful of
|
||||||
|
/// line-anchored regexes is what the job actually needs.
|
||||||
|
/// - **The text is usually invalid.** Half the time an editor's buffer holds `**bo` or `[label](`,
|
||||||
|
/// because the user is mid-word. A parser resolves those to *paragraph text*, so emphasis would
|
||||||
|
/// pop into existence on the closing asterisk and structure would flicker with every keystroke.
|
||||||
|
/// A scanner highlights what is there: the delimiter dims as it is typed, and the run styles when
|
||||||
|
/// it closes.
|
||||||
|
/// - **Delimiters are the point here.** 05 asks for the markers themselves to be dimmed, and a
|
||||||
|
/// parsed tree deliberately discards them — swift-markdown gives the emphasized *content*, not the
|
||||||
|
/// asterisks around it.
|
||||||
|
///
|
||||||
|
/// The cost is that the scanner is line-local: it knows fenced code blocks (a running state), and
|
||||||
|
/// nothing else spanning lines. A `**bold` opened on one line and closed on the next is not styled,
|
||||||
|
/// which is a fair trade for highlighting that never lies about half-typed markup and never re-parses
|
||||||
|
/// a document to draw one line of it.
|
||||||
|
///
|
||||||
|
/// ### Scope of a pass
|
||||||
|
///
|
||||||
|
/// A pass rebuilds the whole body's attributes. That is honest for the input this app has — a card
|
||||||
|
/// body is a card, not a book — and it is what keeps the fenced-code state correct without tracking
|
||||||
|
/// which line invalidated which: the state is recomputed from the top, every time, in one linear
|
||||||
|
/// walk over the text.
|
||||||
|
enum MarkdownHighlighter {
|
||||||
|
|
||||||
|
// MARK: - Vocabulary
|
||||||
|
|
||||||
|
/// What a run of characters *is* — the five things 05 names, plus the structural markers it asks
|
||||||
|
/// to have dimmed.
|
||||||
|
enum Token: Equatable, Sendable {
|
||||||
|
/// A heading's text (`# ` already excluded — that is `.structural`).
|
||||||
|
case heading(level: Int)
|
||||||
|
/// `**bold**`'s content.
|
||||||
|
case strong
|
||||||
|
/// `*italic*`'s content.
|
||||||
|
case emphasis
|
||||||
|
/// `~~struck~~`'s content.
|
||||||
|
case strikethrough
|
||||||
|
/// An inline code span's content, or a fenced/indented code line.
|
||||||
|
case code
|
||||||
|
/// A list's bullet, number, or task checkbox — the marker itself.
|
||||||
|
case listMarker
|
||||||
|
/// A link or image's visible text.
|
||||||
|
case linkText
|
||||||
|
/// A link or image's target — "link targets … dimmed" (05 ▸ Edit).
|
||||||
|
case linkTarget
|
||||||
|
/// Every delimiter: `#`, `**`, backticks, brackets, parens, `>`, a thematic break.
|
||||||
|
case structural
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One run of text and what it is. `range` is in **UTF-16 units** (`NSRange`), because its only
|
||||||
|
/// consumer is `NSTextStorage` — the same reason `BodyMarkup` uses UTF-8 byte offsets and this
|
||||||
|
/// does not: each carries the offsets its own consumer speaks.
|
||||||
|
struct Span: Equatable, Sendable {
|
||||||
|
var range: NSRange
|
||||||
|
var token: Token
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The scan
|
||||||
|
|
||||||
|
/// Every styled run in `text`, in ascending order and never overlapping.
|
||||||
|
///
|
||||||
|
/// Pure, total, and allocation-light: any string is valid input, including one that is malformed
|
||||||
|
/// Markdown in every way at once, and the result is always a partition-compatible set of ranges
|
||||||
|
/// inside `text`.
|
||||||
|
static func spans(in text: String) -> [Span] {
|
||||||
|
let ns = text as NSString
|
||||||
|
guard ns.length > 0 else { return [] }
|
||||||
|
|
||||||
|
var spans: [Span] = []
|
||||||
|
var fence: String?
|
||||||
|
|
||||||
|
forEachLine(in: ns) { line in
|
||||||
|
if let open = fence {
|
||||||
|
// Inside a fenced block every line is code, and only the matching fence closes it.
|
||||||
|
if let closing = fenceRun(in: ns, line: line), closing.marker == open {
|
||||||
|
spans.append(Span(range: closing.range, token: .structural))
|
||||||
|
fence = nil
|
||||||
|
} else {
|
||||||
|
spans.append(Span(range: line, token: .code))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let opening = fenceRun(in: ns, line: line) {
|
||||||
|
spans.append(Span(range: opening.range, token: .structural))
|
||||||
|
// The info string (` ```swift `) is part of the fence, not of the code.
|
||||||
|
let info = NSRange(
|
||||||
|
location: opening.range.upperBound,
|
||||||
|
length: line.upperBound - opening.range.upperBound
|
||||||
|
)
|
||||||
|
if info.length > 0 { spans.append(Span(range: info, token: .linkTarget)) }
|
||||||
|
fence = opening.marker
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scanLine(line, in: ns, into: &spans)
|
||||||
|
}
|
||||||
|
|
||||||
|
return spans
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line, outside any fence. Block markers first — they decide what the rest of the line even
|
||||||
|
/// is — then the inline pass over whatever is left.
|
||||||
|
private static func scanLine(_ line: NSRange, in ns: NSString, into spans: inout [Span]) {
|
||||||
|
// An indented code block: four spaces (or a tab) with content behind them.
|
||||||
|
if firstMatch(Patterns.indentedCode, in: ns, range: line) != nil {
|
||||||
|
spans.append(Span(range: line, token: .code))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let rule = firstMatch(Patterns.thematicBreak, in: ns, range: line) {
|
||||||
|
spans.append(Span(range: rule.range, token: .structural))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = line
|
||||||
|
|
||||||
|
if let heading = firstMatch(Patterns.heading, in: ns, range: content) {
|
||||||
|
let hashes = heading.range(at: 1)
|
||||||
|
spans.append(Span(range: hashes, token: .structural))
|
||||||
|
let level = hashes.length
|
||||||
|
let rest = NSRange(location: hashes.upperBound, length: content.upperBound - hashes.upperBound)
|
||||||
|
if rest.length > 0 {
|
||||||
|
spans.append(Span(range: rest, token: .heading(level: level)))
|
||||||
|
}
|
||||||
|
// **A heading's text takes no inline pass.** It is already emphasized, and layering a
|
||||||
|
// body-sized bold run inside a larger heading font would make `# A **bold** title` read
|
||||||
|
// as a heading with a hole in it. Dimming the `#` and emphasizing the rest is the whole
|
||||||
|
// of what 05 asks for here.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let quote = firstMatch(Patterns.blockQuote, in: ns, range: content) {
|
||||||
|
spans.append(Span(range: quote.range(at: 1), token: .structural))
|
||||||
|
content = NSRange(
|
||||||
|
location: quote.range.upperBound,
|
||||||
|
length: content.upperBound - quote.range.upperBound
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let item = firstMatch(Patterns.listItem, in: ns, range: content) {
|
||||||
|
spans.append(Span(range: item.range(at: 2), token: .listMarker))
|
||||||
|
var after = NSRange(
|
||||||
|
location: item.range.upperBound,
|
||||||
|
length: content.upperBound - item.range.upperBound
|
||||||
|
)
|
||||||
|
// A task checkbox is part of the marker, not of the text: `- [x] done`.
|
||||||
|
if let box = firstMatch(Patterns.taskBox, in: ns, range: after) {
|
||||||
|
spans.append(Span(range: box.range, token: .listMarker))
|
||||||
|
after = NSRange(location: box.range.upperBound, length: after.upperBound - box.range.upperBound)
|
||||||
|
}
|
||||||
|
content = after
|
||||||
|
}
|
||||||
|
|
||||||
|
scanInlines(content, in: ns, into: &spans)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inline pass, in precedence order — a code span wins over everything inside it, a link's
|
||||||
|
/// target is never emphasis, and `**` is tried before `*` so bold does not read as two italics.
|
||||||
|
///
|
||||||
|
/// Claiming is by intersection against what earlier passes already took, which is what makes the
|
||||||
|
/// order meaningful and the output non-overlapping.
|
||||||
|
private static func scanInlines(_ range: NSRange, in ns: NSString, into spans: inout [Span]) {
|
||||||
|
guard range.length > 0 else { return }
|
||||||
|
var claimed: [NSRange] = []
|
||||||
|
|
||||||
|
func claim(_ match: NSTextCheckingResult, emit: (NSTextCheckingResult) -> [Span]) {
|
||||||
|
guard !claimed.contains(where: { NSIntersectionRange($0, match.range).length > 0 }) else { return }
|
||||||
|
claimed.append(match.range)
|
||||||
|
spans.append(contentsOf: emit(match))
|
||||||
|
}
|
||||||
|
|
||||||
|
for match in matches(Patterns.codeSpan, in: ns, range: range) {
|
||||||
|
claim(match) { match in
|
||||||
|
[
|
||||||
|
Span(range: match.range(at: 1), token: .structural),
|
||||||
|
Span(range: match.range(at: 2), token: .code),
|
||||||
|
Span(range: match.range(at: 3), token: .structural)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for match in matches(Patterns.link, in: ns, range: range) {
|
||||||
|
claim(match) { match in
|
||||||
|
var emitted: [Span] = []
|
||||||
|
// The `!` of an image, the brackets and the parens: all dimmed structure.
|
||||||
|
let openText = NSRange(location: match.range.location, length: match.range(at: 1).location - match.range.location)
|
||||||
|
if openText.length > 0 { emitted.append(Span(range: openText, token: .structural)) }
|
||||||
|
emitted.append(Span(range: match.range(at: 1), token: .linkText))
|
||||||
|
let between = NSRange(
|
||||||
|
location: match.range(at: 1).upperBound,
|
||||||
|
length: match.range(at: 2).location - match.range(at: 1).upperBound
|
||||||
|
)
|
||||||
|
if between.length > 0 { emitted.append(Span(range: between, token: .structural)) }
|
||||||
|
emitted.append(Span(range: match.range(at: 2), token: .linkTarget))
|
||||||
|
let close = NSRange(
|
||||||
|
location: match.range(at: 2).upperBound,
|
||||||
|
length: match.range.upperBound - match.range(at: 2).upperBound
|
||||||
|
)
|
||||||
|
if close.length > 0 { emitted.append(Span(range: close, token: .structural)) }
|
||||||
|
return emitted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for match in matches(Patterns.autolink, in: ns, range: range) {
|
||||||
|
claim(match) { match in
|
||||||
|
[Span(range: match.range, token: .linkTarget)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (pattern, token) in [
|
||||||
|
(Patterns.strong, Token.strong),
|
||||||
|
(Patterns.strikethrough, Token.strikethrough),
|
||||||
|
(Patterns.emphasis, Token.emphasis)
|
||||||
|
] {
|
||||||
|
for match in matches(pattern, in: ns, range: range) {
|
||||||
|
claim(match) { match in
|
||||||
|
[
|
||||||
|
Span(range: match.range(at: 1), token: .structural),
|
||||||
|
Span(range: match.range(at: 2), token: token),
|
||||||
|
Span(range: match.range(at: 3), token: .structural)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spans.sort { $0.range.location < $1.range.location }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Application
|
||||||
|
|
||||||
|
/// Lays the pass over a text storage: base attributes everywhere, then each span's own on top.
|
||||||
|
///
|
||||||
|
/// **The only mutation is attributes.** `setAttributes` resets the whole body to the base run so
|
||||||
|
/// deleted markup cannot leave its styling behind, and `addAttribute` layers each span — no
|
||||||
|
/// character is inserted, removed, or replaced, which is 05's "presentation only" enforced by
|
||||||
|
/// what this function is able to call.
|
||||||
|
///
|
||||||
|
/// Wrapped in `beginEditing`/`endEditing` so the layout manager relays once for the whole pass
|
||||||
|
/// rather than once per span.
|
||||||
|
@MainActor
|
||||||
|
static func apply(_ spans: [Span], to storage: NSTextStorage, pointSize: CGFloat) {
|
||||||
|
let full = NSRange(location: 0, length: storage.length)
|
||||||
|
storage.beginEditing()
|
||||||
|
storage.setAttributes(baseAttributes(pointSize: pointSize), range: full)
|
||||||
|
for span in spans {
|
||||||
|
let range = NSIntersectionRange(span.range, full)
|
||||||
|
guard range.length > 0 else { continue }
|
||||||
|
for (key, value) in attributes(for: span.token, pointSize: pointSize) {
|
||||||
|
storage.addAttribute(key, value: value, range: range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
storage.endEditing()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highlights `storage`'s current string in place — the editor's per-keystroke call.
|
||||||
|
@MainActor
|
||||||
|
static func highlight(_ storage: NSTextStorage, pointSize: CGFloat) {
|
||||||
|
apply(spans(in: storage.string), to: storage, pointSize: pointSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The unstyled run: monospaced, at the body size, in the label colour. Also the editor's
|
||||||
|
/// `typingAttributes`, so a character typed at the end of a styled run starts out plain and the
|
||||||
|
/// next pass — one keystroke later — decides what it really is.
|
||||||
|
@MainActor
|
||||||
|
static func baseAttributes(pointSize: CGFloat) -> [NSAttributedString.Key: Any] {
|
||||||
|
[
|
||||||
|
.font: monospaced(pointSize, weight: .regular),
|
||||||
|
.foregroundColor: NSColor.labelColor
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One token's presentation. Deliberately restrained — this is an editor, not a preview: the type
|
||||||
|
/// stays monospaced throughout so columns line up, and the differences are weight, slant and
|
||||||
|
/// colour.
|
||||||
|
@MainActor
|
||||||
|
static func attributes(for token: Token, pointSize: CGFloat) -> [NSAttributedString.Key: Any] {
|
||||||
|
switch token {
|
||||||
|
case let .heading(level):
|
||||||
|
// Emphasized, and larger for the top two levels only — enough to read as a heading in a
|
||||||
|
// monospaced grid without turning the editor into a preview.
|
||||||
|
let scale: CGFloat = level <= 1 ? 1.25 : (level == 2 ? 1.12 : 1.0)
|
||||||
|
return [
|
||||||
|
.font: monospaced((pointSize * scale).rounded(), weight: .bold),
|
||||||
|
.foregroundColor: NSColor.labelColor
|
||||||
|
]
|
||||||
|
case .strong:
|
||||||
|
return [.font: monospaced(pointSize, weight: .bold)]
|
||||||
|
case .emphasis:
|
||||||
|
return [.font: italic(monospaced(pointSize, weight: .regular))]
|
||||||
|
case .strikethrough:
|
||||||
|
return [
|
||||||
|
.strikethroughStyle: NSUnderlineStyle.single.rawValue,
|
||||||
|
.foregroundColor: NSColor.secondaryLabelColor
|
||||||
|
]
|
||||||
|
case .code:
|
||||||
|
// Tinted rather than boxed: a background behind every code line in an editor makes the
|
||||||
|
// caret hard to find.
|
||||||
|
return [.foregroundColor: NSColor.systemTeal]
|
||||||
|
case .listMarker:
|
||||||
|
return [.foregroundColor: NSColor.controlAccentColor]
|
||||||
|
case .linkText:
|
||||||
|
return [.foregroundColor: NSColor.linkColor]
|
||||||
|
case .linkTarget:
|
||||||
|
return [.foregroundColor: NSColor.tertiaryLabelColor]
|
||||||
|
case .structural:
|
||||||
|
return [.foregroundColor: NSColor.tertiaryLabelColor]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func monospaced(_ size: CGFloat, weight: NSFont.Weight) -> NSFont {
|
||||||
|
NSFont.monospacedSystemFont(ofSize: size, weight: weight)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func italic(_ font: NSFont) -> NSFont {
|
||||||
|
NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Line walking
|
||||||
|
|
||||||
|
/// Every line's range, newline **excluded** — a line's terminator is not part of anything it
|
||||||
|
/// carries, and including it would let a heading's colour bleed onto the next line's start in a
|
||||||
|
/// wrapped layout.
|
||||||
|
///
|
||||||
|
/// **`location < length`, strictly.** A position *at* the end of a text that does not end in a
|
||||||
|
/// newline is still inside the last line, so `lineRange(for:)` answers with that line's range —
|
||||||
|
/// which starts before the position asked about. Walking to `<=` therefore re-visits the last
|
||||||
|
/// line forever on any text whose final line is unterminated, which in an editor is every text
|
||||||
|
/// the user is in the middle of typing. The `upperBound > location` guard below is the same
|
||||||
|
/// promise made twice: the walk advances or it stops.
|
||||||
|
private static func forEachLine(in ns: NSString, _ visit: (NSRange) -> Void) {
|
||||||
|
var location = 0
|
||||||
|
while location < ns.length {
|
||||||
|
let line = ns.lineRange(for: NSRange(location: location, length: 0))
|
||||||
|
var content = line
|
||||||
|
// Strip the terminator (`\n`, `\r\n`, `\r`, or a Unicode line separator).
|
||||||
|
while content.length > 0 {
|
||||||
|
let last = ns.character(at: content.upperBound - 1)
|
||||||
|
guard last == 0x0A || last == 0x0D || last == 0x2028 || last == 0x2029 else { break }
|
||||||
|
content.length -= 1
|
||||||
|
}
|
||||||
|
if content.length > 0 { visit(content) }
|
||||||
|
guard line.upperBound > location else { return }
|
||||||
|
location = line.upperBound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A line's opening or closing code fence, if it has one: the run of backticks or tildes, and
|
||||||
|
/// which of the two it is (a ``` block is not closed by a ~~~ line).
|
||||||
|
private static func fenceRun(in ns: NSString, line: NSRange) -> (range: NSRange, marker: String)? {
|
||||||
|
guard let match = firstMatch(Patterns.fence, in: ns, range: line) else { return nil }
|
||||||
|
let run = match.range(at: 1)
|
||||||
|
return (range: match.range, marker: ns.substring(with: NSRange(location: run.location, length: 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Regex plumbing
|
||||||
|
|
||||||
|
private static func matches(_ pattern: NSRegularExpression, in ns: NSString, range: NSRange) -> [NSTextCheckingResult] {
|
||||||
|
pattern.matches(in: ns as String, options: [], range: range)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func firstMatch(_ pattern: NSRegularExpression, in ns: NSString, range: NSRange) -> NSTextCheckingResult? {
|
||||||
|
pattern.firstMatch(in: ns as String, options: [], range: range)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compiled once. Each is anchored the way its construct is anchored in Markdown — block
|
||||||
|
/// patterns at the start of a line, inline patterns anywhere in it.
|
||||||
|
///
|
||||||
|
/// `try!` is load-bearing rather than lazy: these are literals, so a failure here is a typo that
|
||||||
|
/// would fail on the first launch of a debug build, not a runtime condition a user can reach.
|
||||||
|
private enum Patterns {
|
||||||
|
static let heading = regex("^ {0,3}(#{1,6})(?:[ \t]|$)")
|
||||||
|
static let thematicBreak = regex("^ {0,3}(?:(?:\\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$")
|
||||||
|
static let blockQuote = regex("^[ \t]*(>+)[ \t]?")
|
||||||
|
static let listItem = regex("^([ \t]*)([-*+]|\\d{1,9}[.)])(?=[ \t])")
|
||||||
|
static let taskBox = regex("^[ \t]*\\[[ xX]\\]")
|
||||||
|
static let fence = regex("^ {0,3}(`{3,}|~{3,})")
|
||||||
|
static let indentedCode = regex("^(?: {4}|\t)[ \t]*\\S")
|
||||||
|
static let codeSpan = regex("(`+)([^`]*)(\\1)")
|
||||||
|
static let link = regex("!?\\[([^\\]\\n]*)\\]\\(([^)\\n]*)\\)")
|
||||||
|
static let autolink = regex("<(?:https?|mailto|file):[^>\\s]*>")
|
||||||
|
static let strong = regex("(\\*\\*|__)((?:(?!\\1).)+)(\\1)")
|
||||||
|
static let emphasis = regex("(?<![*_\\w])([*_])((?:(?!\\1)[^\\s])(?:(?!\\1).)*)(\\1)(?![*_\\w])")
|
||||||
|
static let strikethrough = regex("(~~)((?:(?!~~).)+)(~~)")
|
||||||
|
|
||||||
|
private static func regex(_ pattern: String) -> NSRegularExpression {
|
||||||
|
// swiftlint:disable:next force_try
|
||||||
|
try! NSRegularExpression(pattern: pattern, options: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// The card window's Edit buffer meeting disk (05-card-window.md ▸ Edit, ▸ Write rules) — the second
|
||||||
|
/// and larger of the app's two body writes, and the one whose guarantees are *negative*: the file
|
||||||
|
/// this suite cares most about is the one that was never written.
|
||||||
|
///
|
||||||
|
/// Three of 05's rules are only observable in bytes, so this suite reads bytes: an untouched session
|
||||||
|
/// leaves the file byte-identical **and its `mtime` untouched** (a stamped no-op would satisfy the
|
||||||
|
/// first and violate the promise), a real edit replaces the body span and nothing above it, and a
|
||||||
|
/// reverted or echoed edit is not written at all. Like the rest of the write suites this drives real
|
||||||
|
/// files in a temp board and never reads through the app's own snapshot. `WriterFixture`, `Ident` and
|
||||||
|
/// `Item` come from `WriterTestSupport.swift`.
|
||||||
|
|
||||||
|
// MARK: - Fixture
|
||||||
|
|
||||||
|
private let originalBody = """
|
||||||
|
# Notes
|
||||||
|
|
||||||
|
Some *prose* with a [link](https://example.com).
|
||||||
|
|
||||||
|
- [ ] a task
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
/// The card, with everything a body write must leave alone above the closing delimiter: an unknown
|
||||||
|
/// key carrying an inline comment, a second unknown key in a shape the app never writes, a `created`
|
||||||
|
/// from before today, 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
|
||||||
|
---
|
||||||
|
\(originalBody)
|
||||||
|
"""
|
||||||
|
|
||||||
|
private let cardPath = "\(Ident.lane1)/\(Ident.card1)"
|
||||||
|
private let siblingPath = "\(Ident.lane1)/\(Ident.card2)"
|
||||||
|
|
||||||
|
@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)
|
||||||
|
try fixture.item(siblingPath, Item.rich(order: "2048", title: "Untouched"))
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The card's body as it is on disk right now — split off at the closing delimiter by the same
|
||||||
|
/// parser the writer used, so "the body" means the same thing in the test as in the app.
|
||||||
|
private func body(of fixture: WriterFixture, _ relativePath: String) throws -> String {
|
||||||
|
try FrontmatterDocument.parse(fixture.indexText(relativePath)).body
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file's frontmatter lines, minus the two the stamp owns — what has to be identical, comment
|
||||||
|
/// and key order included.
|
||||||
|
private func frontmatterLines(_ text: String) -> [String] {
|
||||||
|
let lines = text.components(separatedBy: "\n")
|
||||||
|
guard let closing = lines.dropFirst().firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "---" })
|
||||||
|
else { return lines }
|
||||||
|
return lines[0 ..< closing].filter { !$0.hasPrefix("modified:") && !$0.hasPrefix("modified-by:") }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The write
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("BoardWriter ▸ writeBody")
|
||||||
|
struct WriteBodyTests {
|
||||||
|
|
||||||
|
@Test("A real edit replaces the body span and leaves every frontmatter byte alone")
|
||||||
|
func anEditReplacesOnlyTheBody() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let before = try fixture.indexText(cardPath)
|
||||||
|
|
||||||
|
let wrote = try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Replaced.\n")
|
||||||
|
|
||||||
|
#expect(wrote)
|
||||||
|
#expect(try body(of: fixture, cardPath) == "Replaced.\n")
|
||||||
|
// Key order, the unknown keys, the inline comment and `created` all survive — the round-trip
|
||||||
|
// guarantee, which a body write inherits by editing the document rather than rebuilding it.
|
||||||
|
let after = try fixture.indexText(cardPath)
|
||||||
|
#expect(frontmatterLines(after) == frontmatterLines(before))
|
||||||
|
#expect(after.contains("project: lanework # agent overlay"))
|
||||||
|
#expect(after.contains("labels: [a, b, c]"))
|
||||||
|
#expect(after.contains("created: 2026-01-01T09:00:00Z"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A body rewrite is an index.md rewrite, so it stamps modified and clears modified-by")
|
||||||
|
func theWriteStamps() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "New text.\n")
|
||||||
|
|
||||||
|
let stamped = try #require(try FrontmatterDocument.parse(fixture.indexText(cardPath)).modified.value)
|
||||||
|
#expect(stamped.timeIntervalSinceNow > -30)
|
||||||
|
#expect(!(try fixture.indexText(cardPath).contains("modified-by")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Writing the body the file already has writes nothing at all — bytes and mtime")
|
||||||
|
func anIdenticalBodyIsNeverReSerialized() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let before = try fixture.indexData(cardPath)
|
||||||
|
let mtime = try modificationDate(fixture, cardPath)
|
||||||
|
// Filesystem timestamps have coarse resolution; a write inside the same tick would be
|
||||||
|
// invisible to the `mtime` half of the assertion, so give it a moment to be able to differ.
|
||||||
|
Thread.sleep(forTimeInterval: 0.05)
|
||||||
|
|
||||||
|
let wrote = try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: originalBody)
|
||||||
|
|
||||||
|
#expect(!wrote, "an untouched body is never re-serialized (05 ▸ Write rules)")
|
||||||
|
#expect(try fixture.indexData(cardPath) == before)
|
||||||
|
// The `mtime` is the point: a no-op that still stamped `modified` would keep the *body*
|
||||||
|
// byte-identical while rewriting the file — which is the thing the rule forbids.
|
||||||
|
#expect(try modificationDate(fixture, cardPath) == mtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A body that changed under the buffer is overwritten — this is not a staleness check")
|
||||||
|
func aForeignEditIsOverwritten() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
// Somebody else rewrote the card while the buffer held unsaved keystrokes.
|
||||||
|
try fixture.item(cardPath, editableCard.replacingOccurrences(of: "# Notes", with: "# Theirs"))
|
||||||
|
|
||||||
|
try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Mine.\n")
|
||||||
|
|
||||||
|
// "Deliberate last-writer-wins, the same no-merge-UI philosophy as sync" (05 ▸ Write rules).
|
||||||
|
#expect(try body(of: fixture, cardPath) == "Mine.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An empty body is a legal body, and CRLF frontmatter stays CRLF")
|
||||||
|
func anEmptyBodyAndOddLineEndings() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let path = "\(Ident.lane1)/\(Ident.card3)"
|
||||||
|
try fixture.item(path, "---\r\nschema: 1\r\norder: 3072\r\n---\r\nold body\r\n")
|
||||||
|
|
||||||
|
try BoardWriter.writeBody(inItemFolder: fixture.url(path), body: "")
|
||||||
|
|
||||||
|
let after = try fixture.indexText(path)
|
||||||
|
#expect(try body(of: fixture, path).isEmpty)
|
||||||
|
#expect(after.contains("schema: 1\r\n"), "line endings are preserved per line, never normalized")
|
||||||
|
#expect(after.contains("modified: "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("No other file is opened, let alone rewritten, and no temp file is left behind")
|
||||||
|
func siblingsAreUntouched() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let siblingBefore = try fixture.indexData(siblingPath)
|
||||||
|
let laneBefore = try fixture.indexData(Ident.lane1)
|
||||||
|
|
||||||
|
try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Only this card.\n")
|
||||||
|
|
||||||
|
#expect(try fixture.indexData(siblingPath) == siblingBefore)
|
||||||
|
#expect(try fixture.indexData(Ident.lane1) == laneBefore)
|
||||||
|
// Hidden entries included — the writer's temps are dot-prefixed.
|
||||||
|
#expect(try fixture.entryNames(cardPath) == ["index.md"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Refusals
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("BoardWriter ▸ writeBody refusals")
|
||||||
|
struct WriteBodyRefusalTests {
|
||||||
|
|
||||||
|
@Test("Frontmatter that cannot be edited in place refuses before the body is touched")
|
||||||
|
func uneditableFrontmatterRefuses() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
// A whole-frontmatter flow mapping: readable, renderable, and unwritable — the settled
|
||||||
|
// readable-but-uneditable rule, which a body edit is no exemption from, because the write
|
||||||
|
// still has to stamp `modified` through the span editor.
|
||||||
|
let path = "\(Ident.lane1)/\(Ident.card3)"
|
||||||
|
try fixture.item(path, "---\n{schema: 1, order: 3072}\n---\nodd body\n")
|
||||||
|
let before = try fixture.indexData(path)
|
||||||
|
|
||||||
|
let error = writeFailure {
|
||||||
|
try BoardWriter.writeBody(inItemFolder: fixture.url(path), body: "new")
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
||||||
|
#expect(error?.operation == .editBody(title: nil), "the flow mapping's title is not addressable")
|
||||||
|
#expect(try fixture.indexData(path) == before)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A folder that is not a lane or a card refuses")
|
||||||
|
func strayFoldersRefuse() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
// The board root: its body is the board description, and no editor in the app opens it.
|
||||||
|
let error = writeFailure {
|
||||||
|
try BoardWriter.writeBody(inItemFolder: fixture.root, body: "nope")
|
||||||
|
}
|
||||||
|
if case .unreadable = error?.reason {} else {
|
||||||
|
Issue.record("expected an unreadable refusal, got \(String(describing: error?.reason))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A failure names the card by the title the read found")
|
||||||
|
func failuresNameTheCard() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let folder = fixture.url(cardPath)
|
||||||
|
// Unwritable folder: the read and the parse both succeed, so the operation is enriched, and
|
||||||
|
// then the atomic replace cannot land its temp file.
|
||||||
|
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: folder.path)
|
||||||
|
|
||||||
|
let error = writeFailure {
|
||||||
|
try BoardWriter.writeBody(inItemFolder: folder, body: "unwritable")
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(error?.operation == .editBody(title: "Notes"))
|
||||||
|
#expect(BannerCenter.headline(for: try #require(error)).hasPrefix("Couldn't save 'Notes'"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Through the store
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("BoardStore ▸ writeCardBody")
|
||||||
|
struct StoreWriteCardBodyTests {
|
||||||
|
|
||||||
|
@Test("A save lands on disk and reports that it did")
|
||||||
|
func theStoreWritesThrough() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
let outcome = store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "Through the store.\n")
|
||||||
|
|
||||||
|
#expect(outcome == .written)
|
||||||
|
// Read back through the loader, never through the store's snapshot: the one-way flow means
|
||||||
|
// the snapshot only catches up when the watcher's reload lands (02-architecture.md).
|
||||||
|
#expect(try body(of: fixture, cardPath) == "Through the store.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Saving what disk already says reports unchanged and writes nothing")
|
||||||
|
func anEchoWritesNothing() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let before = try fixture.indexData(cardPath)
|
||||||
|
|
||||||
|
#expect(store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: originalBody) == .unchanged)
|
||||||
|
#expect(try fixture.indexData(cardPath) == before)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A tombstoned card is still written to, and stays tombstoned")
|
||||||
|
func aTombstonedCardStillTakesTheFlush() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try BoardWriter.deleteItem(at: fixture.url(cardPath))
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
// 05 ▸ Deletion & lifecycle: "a dirty Edit buffer flushes into the tombstoned card's folder
|
||||||
|
// before the window dismisses ... so the keystrokes survive Put Back".
|
||||||
|
let outcome = store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "Typed as it went.\n")
|
||||||
|
|
||||||
|
#expect(outcome == .written)
|
||||||
|
#expect(try body(of: fixture, cardPath) == "Typed as it went.\n")
|
||||||
|
// Surgical: the write replaced the body span, so the tombstone is still standing and Put
|
||||||
|
// Back still has something to put back.
|
||||||
|
#expect(try FrontmatterDocument.parse(fixture.indexText(cardPath)).deleted.value != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A card that is not in the board at all reports vanished, and writes nowhere")
|
||||||
|
func aVanishedCardWritesNothing() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let before = try fixture.indexData(cardPath)
|
||||||
|
|
||||||
|
// "A card hard-deleted externally (folder gone) discards both — nowhere left to write" (05).
|
||||||
|
#expect(store.writeCardBody(inCard: ItemID(rawValue: Ident.card4), body: "nowhere") == .vanished)
|
||||||
|
#expect(try fixture.indexData(cardPath) == before)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A read-only board suspends the save 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)
|
||||||
|
store.enterVanishedRootLock()
|
||||||
|
|
||||||
|
// "Editor buffers kept but their debounced saves suspended" (02 § the lock's scope) — the
|
||||||
|
// buffer's owner reads this as "hold the text", not as "the write failed".
|
||||||
|
let outcome = store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "held")
|
||||||
|
|
||||||
|
#expect(outcome == .suspended(.vanishedRoot))
|
||||||
|
#expect(try fixture.indexData(cardPath) == before)
|
||||||
|
#expect(store.banners.oneShots.isEmpty, "the lock's row is the message; a refused tick posts nothing")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A failed save reports the error, and the banner has it")
|
||||||
|
func aFailedSaveReports() 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.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "cannot land")
|
||||||
|
|
||||||
|
guard case let .failed(error) = outcome else {
|
||||||
|
Issue.record("expected a failure, got \(outcome)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(error.operation == .editBody(title: "Notes"))
|
||||||
|
// `performWrite` posts every `BoardWriteError` before it rethrows — the caller never has to
|
||||||
|
// remember to, and a `try?` at a call site cannot make a failure silent.
|
||||||
|
#expect(store.banners.oneShots.contains { BannerCenter.headline(for: $0.error).hasPrefix("Couldn't save 'Notes'") })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// The Edit buffer's state machine (05-card-window.md ▸ Edit, ▸ Write rules): the three write gates,
|
||||||
|
/// the ~700 ms debounce, and dirty-buffer-wins.
|
||||||
|
///
|
||||||
|
/// The session is deliberately a buffer and a clock with a *closure* for its destination, which is
|
||||||
|
/// what lets this suite be about the rules rather than about files: the fake below records every
|
||||||
|
/// save it is asked for, so "writes nothing" is an assertion about a count rather than an inference
|
||||||
|
/// from an `mtime`. The bytes those saves put on disk are `BodyWriteTests`'.
|
||||||
|
|
||||||
|
// MARK: - The fake destination
|
||||||
|
|
||||||
|
/// A stand-in for `BoardStore.writeCardBody`, recording what it was asked to write and answering
|
||||||
|
/// with whatever outcome the test wants.
|
||||||
|
@MainActor
|
||||||
|
private final class SaveSpy {
|
||||||
|
private(set) var written: [String] = []
|
||||||
|
var outcome: CardBodyWriteOutcome = .written
|
||||||
|
|
||||||
|
var count: Int { written.count }
|
||||||
|
var last: String? { written.last }
|
||||||
|
|
||||||
|
func save(_ text: String) -> CardBodyWriteOutcome {
|
||||||
|
written.append(text)
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func makeSession(_ spy: SaveSpy, body: String = "original\n") -> CardBodyEditSession {
|
||||||
|
let session = CardBodyEditSession()
|
||||||
|
// Fast enough that a test never waits on the real 700 ms, slow enough that a keystroke arriving
|
||||||
|
// right after another can still cancel it — the `DragSession.holdTimeout` precedent.
|
||||||
|
session.debounceInterval = .milliseconds(30)
|
||||||
|
session.save = { [spy] text in spy.save(text) }
|
||||||
|
session.adopt(diskBody: body)
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls until `condition` holds or the deadline passes — the suite's shape for anything the
|
||||||
|
/// debounce has to actually elapse for.
|
||||||
|
@MainActor
|
||||||
|
private func waitUntil(_ deadline: Duration = .seconds(2), _ condition: () -> Bool) async {
|
||||||
|
let start = ContinuousClock.now
|
||||||
|
while !condition() {
|
||||||
|
guard ContinuousClock.now - start < deadline else { return }
|
||||||
|
try? await Task.sleep(for: .milliseconds(5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The three gates
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Card body ▸ the write gates")
|
||||||
|
struct CardBodyWriteGateTests {
|
||||||
|
|
||||||
|
@Test("An untouched session writes nothing, ever")
|
||||||
|
func anUntouchedSessionWritesNothing() async {
|
||||||
|
let spy = SaveSpy()
|
||||||
|
let session = makeSession(spy)
|
||||||
|
|
||||||
|
// Opened, read, and left — the flush that leaving Edit performs, on a buffer nobody typed
|
||||||
|
// into. "An untouched body is never rewritten" (05 ▸ Write rules).
|
||||||
|
#expect(session.flush() == .unchanged)
|
||||||
|
await waitUntil { spy.count > 0 }
|
||||||
|
#expect(spy.count == 0)
|
||||||
|
#expect(session.saveAttempts == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A typed-then-reverted edit writes nothing, and leaves no timer standing")
|
||||||
|
func aRevertedEditWritesNothing() async {
|
||||||
|
let spy = SaveSpy()
|
||||||
|
let session = makeSession(spy)
|
||||||
|
|
||||||
|
session.edited("original\nand more")
|
||||||
|
#expect(session.isDirty)
|
||||||
|
// Undone before the debounce could fire.
|
||||||
|
session.edited("original\n")
|
||||||
|
#expect(!session.isDirty)
|
||||||
|
|
||||||
|
// The pending save was cancelled by the revert rather than firing on a no-op — a write that
|
||||||
|
// landed here would stamp `modified` and mint a commit for nothing.
|
||||||
|
await waitUntil { spy.count > 0 }
|
||||||
|
#expect(spy.count == 0)
|
||||||
|
#expect(session.flush() == .unchanged)
|
||||||
|
#expect(spy.count == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The echo of the app's own save is not written back")
|
||||||
|
func anEchoIsNotWrittenBack() async {
|
||||||
|
let spy = SaveSpy()
|
||||||
|
let session = makeSession(spy)
|
||||||
|
|
||||||
|
session.edited("typed\n")
|
||||||
|
#expect(session.flush() == .written)
|
||||||
|
#expect(spy.written == ["typed\n"])
|
||||||
|
|
||||||
|
// The watcher's reload arrives carrying what we just wrote. The buffer is clean against it,
|
||||||
|
// so nothing is written back — which is what stops a save ringing forever round the one-way
|
||||||
|
// flow.
|
||||||
|
session.adopt(diskBody: "typed\n")
|
||||||
|
#expect(!session.isDirty)
|
||||||
|
#expect(session.flush() == .unchanged)
|
||||||
|
await waitUntil { spy.count > 1 }
|
||||||
|
#expect(spy.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An external edit under a clean buffer is not written back either")
|
||||||
|
func aForeignEditUnderACleanBufferIsNotWrittenBack() async {
|
||||||
|
let spy = SaveSpy()
|
||||||
|
let session = makeSession(spy)
|
||||||
|
|
||||||
|
// Somebody else rewrote the file while the window sat there reading it. "A clean buffer
|
||||||
|
// follows disk" (05 ▸ Write rules) — and following disk is not a reason to write to it.
|
||||||
|
session.adopt(diskBody: "theirs\n")
|
||||||
|
|
||||||
|
#expect(session.text == "theirs\n")
|
||||||
|
#expect(!session.isDirty)
|
||||||
|
#expect(session.flush() == .unchanged)
|
||||||
|
await waitUntil { spy.count > 0 }
|
||||||
|
#expect(spy.count == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dirty-buffer-wins
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Card body ▸ dirty-buffer-wins")
|
||||||
|
struct CardBodyDirtyBufferTests {
|
||||||
|
|
||||||
|
@Test("A snapshot never reloads a dirty buffer under the cursor")
|
||||||
|
func aDirtyBufferKeepsItsText() {
|
||||||
|
let spy = SaveSpy()
|
||||||
|
let session = makeSession(spy)
|
||||||
|
|
||||||
|
session.edited("mine, unsaved\n")
|
||||||
|
// The board, Preview and every other window take the new snapshot; this buffer does not.
|
||||||
|
session.adopt(diskBody: "theirs\n")
|
||||||
|
|
||||||
|
#expect(session.text == "mine, unsaved\n")
|
||||||
|
#expect(session.disk == "theirs\n", "the buffer knows what disk says — it just isn't showing it")
|
||||||
|
#expect(session.isDirty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The buffer's own save then lands over the foreign edit — last writer wins")
|
||||||
|
func theFlushOverwritesTheForeignEdit() {
|
||||||
|
let spy = SaveSpy()
|
||||||
|
let session = makeSession(spy)
|
||||||
|
|
||||||
|
session.edited("mine, unsaved\n")
|
||||||
|
session.adopt(diskBody: "theirs\n")
|
||||||
|
|
||||||
|
#expect(session.flush() == .written)
|
||||||
|
#expect(spy.written == ["mine, unsaved\n"], "deliberate last-writer-wins (05 ▸ Write rules)")
|
||||||
|
#expect(!session.isDirty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A foreign edit that happens to match the buffer settles it clean, writing nothing")
|
||||||
|
func aConvergentForeignEditNeedsNoWrite() async {
|
||||||
|
let spy = SaveSpy()
|
||||||
|
let session = makeSession(spy)
|
||||||
|
|
||||||
|
session.edited("same text\n")
|
||||||
|
// An agent wrote exactly what the user was typing. The file already says what they mean, so
|
||||||
|
// re-writing it would only stamp `modified`.
|
||||||
|
session.adopt(diskBody: "same text\n")
|
||||||
|
|
||||||
|
#expect(!session.isDirty)
|
||||||
|
await waitUntil { spy.count > 0 }
|
||||||
|
#expect(spy.count == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A failed save keeps the buffer dirty, and the text")
|
||||||
|
func aFailureKeepsTheText() {
|
||||||
|
let spy = SaveSpy()
|
||||||
|
let session = makeSession(spy)
|
||||||
|
spy.outcome = .failed(BoardWriteError(
|
||||||
|
operation: .editBody(title: "Notes"),
|
||||||
|
path: "/x/index.md",
|
||||||
|
reason: .io(message: "the disk is full")
|
||||||
|
))
|
||||||
|
|
||||||
|
session.edited("precious\n")
|
||||||
|
let outcome = session.flush()
|
||||||
|
|
||||||
|
guard case .failed = outcome else {
|
||||||
|
Issue.record("expected the failure to be reported, got \(outcome)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// "Nothing is lost while the window stays open" (02 § Write-failure surfacing): the text is
|
||||||
|
// still here, still dirty, and the next flush will try again.
|
||||||
|
#expect(session.text == "precious\n")
|
||||||
|
#expect(session.isDirty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A suspended save — the read-only lock — also keeps the buffer, and does not throw a close")
|
||||||
|
func aSuspendedSaveKeepsTheBuffer() throws {
|
||||||
|
let spy = SaveSpy()
|
||||||
|
let session = makeSession(spy)
|
||||||
|
spy.outcome = .suspended(.unwritableLocation)
|
||||||
|
|
||||||
|
session.edited("held\n")
|
||||||
|
#expect(session.flush() == .suspended(.unwritableLocation))
|
||||||
|
#expect(session.isDirty)
|
||||||
|
// The close-time guard treats it as a non-failure: no write was attempted, the lock row has
|
||||||
|
// been standing all along, and a modal offering Try Again could only fail again.
|
||||||
|
try session.flushOrThrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A real failure is what the close-time modal is raised on")
|
||||||
|
func aFailureThrowsForTheGuard() {
|
||||||
|
let spy = SaveSpy()
|
||||||
|
let session = makeSession(spy)
|
||||||
|
spy.outcome = .failed(BoardWriteError(operation: .editBody(title: nil), path: "/x", reason: .io(message: "nope")))
|
||||||
|
session.edited("unsaved\n")
|
||||||
|
|
||||||
|
#expect(throws: BoardWriteError.self) { try session.flushOrThrow() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A session with nowhere to write keeps its text rather than reporting success")
|
||||||
|
func aSessionWithNoDestinationHoldsOn() {
|
||||||
|
let session = CardBodyEditSession()
|
||||||
|
session.adopt(diskBody: "start\n")
|
||||||
|
session.edited("typed\n")
|
||||||
|
|
||||||
|
#expect(session.flush() == .vanished)
|
||||||
|
#expect(session.isDirty)
|
||||||
|
#expect(session.text == "typed\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The debounce
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Card body ▸ the debounce")
|
||||||
|
struct CardBodyDebounceTests {
|
||||||
|
|
||||||
|
@Test("Typing saves once the keystrokes stop")
|
||||||
|
func typingSavesAfterTheInterval() async {
|
||||||
|
let spy = SaveSpy()
|
||||||
|
let session = makeSession(spy)
|
||||||
|
|
||||||
|
session.edited("t\n")
|
||||||
|
#expect(spy.count == 0, "not on the keystroke itself")
|
||||||
|
|
||||||
|
await waitUntil { spy.count == 1 }
|
||||||
|
#expect(spy.written == ["t\n"])
|
||||||
|
#expect(!session.isDirty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A burst of keystrokes is one save, of the last text")
|
||||||
|
func aBurstCoalesces() async {
|
||||||
|
let spy = SaveSpy()
|
||||||
|
let session = makeSession(spy)
|
||||||
|
|
||||||
|
for text in ["a", "ab", "abc", "abcd"] {
|
||||||
|
session.edited(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitUntil { spy.count >= 1 }
|
||||||
|
// Trailing debounce: each keystroke restarts the clock, so the run costs one write rather
|
||||||
|
// than one per character.
|
||||||
|
#expect(spy.written == ["abcd"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A flush does not wait for the debounce, and the debounce does not fire behind it")
|
||||||
|
func aFlushPreemptsThePendingSave() async {
|
||||||
|
let spy = SaveSpy()
|
||||||
|
let session = makeSession(spy)
|
||||||
|
|
||||||
|
session.edited("typed\n")
|
||||||
|
#expect(session.flush() == .written)
|
||||||
|
#expect(spy.count == 1, "the flush wrote immediately — no flush lag on a mode exit (05)")
|
||||||
|
|
||||||
|
// And the timer it cancelled does not come back to write the same text a second time.
|
||||||
|
await waitUntil { spy.count > 1 }
|
||||||
|
#expect(spy.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The production interval is ~700 ms")
|
||||||
|
func theDefaultIntervalIsTheDesignsNumber() {
|
||||||
|
// The only thing the seam must not do is quietly change the number the design settled
|
||||||
|
// (05 ▸ Edit: "Saved on a ~700 ms debounce").
|
||||||
|
#expect(CardBodyEditSession().debounceInterval == .milliseconds(700))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ending the session flushes — the Edit→Preview flip is the effective Save")
|
||||||
|
func endingTheSessionFlushes() {
|
||||||
|
let spy = SaveSpy()
|
||||||
|
let session = makeSession(spy)
|
||||||
|
|
||||||
|
session.edited("typed on the way out\n")
|
||||||
|
#expect(session.endEditSession() == .written)
|
||||||
|
#expect(spy.written == ["typed on the way out\n"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The mode flip
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Card body ▸ leaving Edit flushes")
|
||||||
|
struct CardBodyModeFlushTests {
|
||||||
|
|
||||||
|
@Test("Every way out of Edit flushes first")
|
||||||
|
func leavingEditFlushes() {
|
||||||
|
let presentation = CardBodyPresentation()
|
||||||
|
var flushes = 0
|
||||||
|
presentation.flushEdits = { flushes += 1 }
|
||||||
|
presentation.openIfNeeded(body: "")
|
||||||
|
#expect(presentation.mode == .edit)
|
||||||
|
|
||||||
|
// ⌘E / the menu item's toggle …
|
||||||
|
presentation.toggleMode()
|
||||||
|
#expect(presentation.mode == .preview)
|
||||||
|
#expect(flushes == 1)
|
||||||
|
|
||||||
|
// … Escape in the editor, which is the same flip through the same door …
|
||||||
|
presentation.setMode(.edit)
|
||||||
|
#expect(flushes == 1, "entering Edit flushes nothing — there is nothing to flush yet")
|
||||||
|
presentation.setMode(.preview)
|
||||||
|
#expect(flushes == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Setting the mode it already has flushes nothing")
|
||||||
|
func aRedundantSetIsNotAFlush() {
|
||||||
|
let presentation = CardBodyPresentation()
|
||||||
|
var flushes = 0
|
||||||
|
presentation.flushEdits = { flushes += 1 }
|
||||||
|
presentation.setMode(.edit)
|
||||||
|
|
||||||
|
presentation.setMode(.edit)
|
||||||
|
presentation.setMode(.edit)
|
||||||
|
|
||||||
|
#expect(flushes == 0, "a re-published focus value must not commit an untouched buffer")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// The Edit editor's syntax highlighting (05-card-window.md ▸ Edit).
|
||||||
|
///
|
||||||
|
/// The suite exists for one promise above all others — **"highlighting is presentation only: the text
|
||||||
|
/// is the raw Markdown, character for character"** — and the whole reason the highlighter emits
|
||||||
|
/// `[Span]` rather than an attributed string is so that promise is checkable rather than merely
|
||||||
|
/// intended. The invariants below (in bounds, in order, non-overlapping, string unchanged after a
|
||||||
|
/// full application) hold for *every* input, so they are asserted over a corpus of deliberately
|
||||||
|
/// broken Markdown as well as over the tidy examples.
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func spans(_ text: String) -> [MarkdownHighlighter.Span] {
|
||||||
|
MarkdownHighlighter.spans(in: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The substrings a token claims, in order — assertions read as "what is bold here?" rather than as
|
||||||
|
/// arithmetic over offsets.
|
||||||
|
private func text(of text: String, token: MarkdownHighlighter.Token) -> [String] {
|
||||||
|
let ns = text as NSString
|
||||||
|
return spans(text).filter { $0.token == token }.map { ns.substring(with: $0.range) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every span's substring, whatever its token.
|
||||||
|
private func claimed(_ text: String, where predicate: (MarkdownHighlighter.Token) -> Bool) -> [String] {
|
||||||
|
let ns = text as NSString
|
||||||
|
return spans(text).filter { predicate($0.token) }.map { ns.substring(with: $0.range) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Invariants
|
||||||
|
|
||||||
|
@Suite("Markdown highlighter ▸ invariants")
|
||||||
|
struct MarkdownHighlighterInvariantTests {
|
||||||
|
|
||||||
|
/// Tidy Markdown, half-typed Markdown, and text that is not Markdown at all — the editor holds
|
||||||
|
/// all three, usually within a second of each other.
|
||||||
|
static let corpus: [String] = [
|
||||||
|
"",
|
||||||
|
"\n",
|
||||||
|
"plain prose with no markup at all",
|
||||||
|
"# Heading\n\nBody *text* here.\n",
|
||||||
|
"**bo",
|
||||||
|
"[label](",
|
||||||
|
"`unclosed code",
|
||||||
|
"~~~\nfence with no close\n",
|
||||||
|
"***",
|
||||||
|
"- [ ] task\n- [x] done\n - nested\n",
|
||||||
|
"> quoted **bold**\n>> deeper\n",
|
||||||
|
"| a | b |\n| - | - |\n| 1 | 2 |\n",
|
||||||
|
"snake_case_identifier and 2 * 3 * 4\n",
|
||||||
|
"```swift\nlet x = **not bold**\n```\n",
|
||||||
|
" indented code\n",
|
||||||
|
"emoji 🇬🇧 and combining é in *italics*\n",
|
||||||
|
"<https://example.com> and \n"
|
||||||
|
]
|
||||||
|
|
||||||
|
@Test("Every span lands inside the text, in order, without overlapping")
|
||||||
|
func spansPartitionCleanly() {
|
||||||
|
for sample in Self.corpus {
|
||||||
|
let length = (sample as NSString).length
|
||||||
|
var previousEnd = 0
|
||||||
|
for span in spans(sample) {
|
||||||
|
#expect(span.range.location >= 0)
|
||||||
|
#expect(span.range.upperBound <= length, "a span past the end of \(sample.debugDescription)")
|
||||||
|
#expect(span.range.location >= previousEnd, "spans overlap or go backwards in \(sample.debugDescription)")
|
||||||
|
previousEnd = span.range.upperBound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test("Applying the whole pass never changes a single character")
|
||||||
|
func applyingNeverAltersTheString() {
|
||||||
|
for sample in Self.corpus {
|
||||||
|
let storage = NSTextStorage(string: sample)
|
||||||
|
MarkdownHighlighter.highlight(storage, pointSize: 13)
|
||||||
|
#expect(storage.string == sample, "the highlighter rewrote \(sample.debugDescription)")
|
||||||
|
// And again, because an idempotent pass is what running on every keystroke amounts to.
|
||||||
|
MarkdownHighlighter.highlight(storage, pointSize: 13)
|
||||||
|
#expect(storage.string == sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test("A pass leaves no attributes from the pass before it")
|
||||||
|
func attributesAreRebuiltRatherThanAccumulated() {
|
||||||
|
let storage = NSTextStorage(string: "# Heading\n")
|
||||||
|
MarkdownHighlighter.highlight(storage, pointSize: 13)
|
||||||
|
// The user deletes the `#`: what was a heading is now prose, and must be drawn as prose.
|
||||||
|
storage.replaceCharacters(in: NSRange(location: 0, length: 2), with: "")
|
||||||
|
MarkdownHighlighter.highlight(storage, pointSize: 13)
|
||||||
|
|
||||||
|
let base = MarkdownHighlighter.baseAttributes(pointSize: 13)
|
||||||
|
let font = storage.attribute(.font, at: 0, effectiveRange: nil) as? NSFont
|
||||||
|
#expect(font == base[.font] as? NSFont, "a stale heading font would survive the edit that ended the heading")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Blocks
|
||||||
|
|
||||||
|
@Suite("Markdown highlighter ▸ blocks")
|
||||||
|
struct MarkdownHighlighterBlockTests {
|
||||||
|
|
||||||
|
@Test("A heading is its marker, dimmed, and its text, emphasized")
|
||||||
|
func headings() {
|
||||||
|
#expect(text(of: "# Title\n", token: .heading(level: 1)) == [" Title"])
|
||||||
|
#expect(text(of: "### Deeper\n", token: .heading(level: 3)) == [" Deeper"])
|
||||||
|
#expect(text(of: "# Title\n", token: .structural) == ["#"])
|
||||||
|
// Seven hashes is not a heading in CommonMark, and is not one here either.
|
||||||
|
#expect(text(of: "####### nope\n", token: .heading(level: 7)).isEmpty)
|
||||||
|
#expect(text(of: "#nospace\n", token: .heading(level: 1)).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("List markers and task boxes are the marker, not the text")
|
||||||
|
func listMarkers() {
|
||||||
|
#expect(text(of: "- item\n", token: .listMarker) == ["-"])
|
||||||
|
#expect(text(of: "1. item\n", token: .listMarker) == ["1."])
|
||||||
|
#expect(text(of: " * nested\n", token: .listMarker) == ["*"])
|
||||||
|
// The checkbox belongs to the marker: `- [x] done` reads as one control plus a label.
|
||||||
|
#expect(text(of: "- [x] done\n", token: .listMarker) == ["-", " [x]"])
|
||||||
|
#expect(text(of: "- [ ] todo\n", token: .listMarker) == ["-", " [ ]"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A thematic break is structure, and is not three list markers")
|
||||||
|
func thematicBreaks() {
|
||||||
|
#expect(text(of: "---\n", token: .structural) == ["---"])
|
||||||
|
#expect(text(of: "***\n", token: .structural) == ["***"])
|
||||||
|
#expect(text(of: "---\n", token: .listMarker).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A quote's chevrons dim and its content still highlights")
|
||||||
|
func quotes() {
|
||||||
|
#expect(text(of: "> quoted **bold**\n", token: .structural).contains(">"))
|
||||||
|
#expect(text(of: "> quoted **bold**\n", token: .strong) == ["bold"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A fenced block is code from fence to fence, whatever is inside it")
|
||||||
|
func fencedCode() {
|
||||||
|
let sample = "```swift\nlet x = **not bold**\n# not a heading\n```\nafter\n"
|
||||||
|
|
||||||
|
#expect(text(of: sample, token: .strong).isEmpty, "markup inside a fence is code, not markup")
|
||||||
|
#expect(text(of: sample, token: .heading(level: 1)).isEmpty)
|
||||||
|
#expect(text(of: sample, token: .code).contains("let x = **not bold**"))
|
||||||
|
#expect(text(of: sample, token: .code).contains("# not a heading"))
|
||||||
|
// The info string is dimmed with the fence rather than tinted as code.
|
||||||
|
#expect(text(of: sample, token: .linkTarget) == ["swift"])
|
||||||
|
// And the block ends: text after the closing fence is ordinary again.
|
||||||
|
#expect(!text(of: sample, token: .code).contains("after"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A tilde fence is not closed by a backtick fence")
|
||||||
|
func fenceMarkersMustMatch() {
|
||||||
|
let sample = "~~~\ncode\n```\nstill code\n~~~\nout\n"
|
||||||
|
#expect(text(of: sample, token: .code).contains("still code"))
|
||||||
|
#expect(!text(of: sample, token: .code).contains("out"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An unclosed fence simply runs to the end — an editor is full of half-typed blocks")
|
||||||
|
func anUnclosedFenceDoesNotBreakTheRest() {
|
||||||
|
let sample = "```\ncode\nmore code\n"
|
||||||
|
#expect(text(of: sample, token: .code) == ["code", "more code"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Indented code is code")
|
||||||
|
func indentedCode() {
|
||||||
|
#expect(text(of: " let x = 1\n", token: .code) == [" let x = 1"])
|
||||||
|
#expect(text(of: "\tlet x = 1\n", token: .code) == ["\tlet x = 1"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Inlines
|
||||||
|
|
||||||
|
@Suite("Markdown highlighter ▸ inlines")
|
||||||
|
struct MarkdownHighlighterInlineTests {
|
||||||
|
|
||||||
|
@Test("Bold, italic and strikethrough style their content and dim their delimiters")
|
||||||
|
func emphasis() {
|
||||||
|
#expect(text(of: "a **bold** b\n", token: .strong) == ["bold"])
|
||||||
|
#expect(text(of: "a **bold** b\n", token: .structural) == ["**", "**"])
|
||||||
|
#expect(text(of: "a *italic* b\n", token: .emphasis) == ["italic"])
|
||||||
|
#expect(text(of: "a _italic_ b\n", token: .emphasis) == ["italic"])
|
||||||
|
#expect(text(of: "a ~~struck~~ b\n", token: .strikethrough) == ["struck"])
|
||||||
|
// `**` is tried before `*`, so bold is bold rather than two adjacent italics.
|
||||||
|
#expect(text(of: "**bold**\n", token: .emphasis).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Intraword underscores are not emphasis")
|
||||||
|
func underscoresInWords() {
|
||||||
|
#expect(text(of: "snake_case_name here\n", token: .emphasis).isEmpty)
|
||||||
|
#expect(text(of: "2 * 3 * 4\n", token: .emphasis).isEmpty, "spaced asterisks are arithmetic")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A code span tints its content and outranks the markup inside it")
|
||||||
|
func codeSpans() {
|
||||||
|
#expect(text(of: "use `let x = **y**` here\n", token: .code) == ["let x = **y**"])
|
||||||
|
#expect(text(of: "use `let x = **y**` here\n", token: .strong).isEmpty)
|
||||||
|
#expect(claimed("`a`\n") { $0 == .structural } == ["`", "`"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A link's text reads as text and its target dims")
|
||||||
|
func links() {
|
||||||
|
let sample = "see [the docs](https://example.com/x) now\n"
|
||||||
|
#expect(text(of: sample, token: .linkText) == ["the docs"])
|
||||||
|
#expect(text(of: sample, token: .linkTarget) == ["https://example.com/x"])
|
||||||
|
// The brackets, the parens and an image's `!` are all structure.
|
||||||
|
#expect(text(of: sample, token: .structural) == ["[", "](", ")"])
|
||||||
|
#expect(text(of: "\n", token: .structural) == [""])
|
||||||
|
#expect(text(of: "<https://example.com>\n", token: .linkTarget) == ["<https://example.com>"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Emphasis inside a link's text does not eat the link")
|
||||||
|
func overlappingConstructsResolveByPrecedence() {
|
||||||
|
let sample = "[a **b** c](url)\n"
|
||||||
|
#expect(text(of: sample, token: .linkText) == ["a **b** c"])
|
||||||
|
#expect(text(of: sample, token: .linkTarget) == ["url"])
|
||||||
|
#expect(text(of: sample, token: .strong).isEmpty, "the link claimed the run first")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Half-typed markup styles what is there and invents nothing")
|
||||||
|
func halfTypedMarkup() {
|
||||||
|
// The delimiters dim as they are typed; the run styles when it closes. Nothing about this
|
||||||
|
// is an error state, which is the whole reason the editor scans lines rather than parsing.
|
||||||
|
#expect(text(of: "**bo\n", token: .strong).isEmpty)
|
||||||
|
#expect(text(of: "[label](\n", token: .linkText).isEmpty)
|
||||||
|
#expect(text(of: "`unclosed\n", token: .code).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Offsets survive text no ASCII assumption would")
|
||||||
|
func unicodeOffsets() {
|
||||||
|
// NSRange is UTF-16, and an emoji flag is two code units before the markup even starts —
|
||||||
|
// a highlighter counting characters would style the wrong run here.
|
||||||
|
let sample = "🇬🇧 flag then **bold**\n"
|
||||||
|
#expect(text(of: sample, token: .strong) == ["bold"])
|
||||||
|
#expect(text(of: "é *accented* text\n", token: .emphasis) == ["accented"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ Lanework is in early development. This list tracks what has actually shipped and
|
|||||||
|
|
||||||
- **The card window** — ⌘↩ or a double-click opens a card in its own window: two full-height, independently scrolling columns — a wide body column and a narrow attributes sidebar whose fixed width is derived from font metrics, so the window's resize flex all goes to the body. The title bar carries the card's title live and subtitles it "⟨board⟩ › ⟨lane⟩", following the card as it moves between lanes and re-reading the board's name as it's renamed. The body column shows the title, a quiet created/modified/by line built from whichever frontmatter keys exist, and the body itself; the sidebar's sections are stacked headers awaiting their content. Reopening a live card focuses the window it already has, and the window closes itself the moment its card stops being live — deleted, tombstoned, buried under a tombstoned lane, or moved to another board.
|
- **The card window** — ⌘↩ or a double-click opens a card in its own window: two full-height, independently scrolling columns — a wide body column and a narrow attributes sidebar whose fixed width is derived from font metrics, so the window's resize flex all goes to the body. The title bar carries the card's title live and subtitles it "⟨board⟩ › ⟨lane⟩", following the card as it moves between lanes and re-reading the board's name as it's renamed. The body column shows the title, a quiet created/modified/by line built from whichever frontmatter keys exist, and the body itself; the sidebar's sections are stacked headers awaiting their content. Reopening a live card focuses the window it already has, and the window closes itself the moment its card stops being live — deleted, tombstoned, buried under a tombstoned lane, or moved to another board.
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
- **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.
|
- **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
|
## Development
|
||||||
|
|||||||
Reference in New Issue
Block a user