Every settled change on a git-mode board commits, debounced 2s past drag/typing churn, staged whole-root with .gitignore respected. GitCommitOperation reaches the vendored libgit2 directly (same 1.9.2 pin SwiftGitX resolves — importable, not duplicated) for signature-capable commits; add-git's config materialization is gone, identity resolves at commit time (repo-local config, else derived default) per the 2026-07-31 ruling in 06. CommitAttribution classifies per file off EchoLedger receipts: user identity on app-mediated windows, Lanework External <[email protected]> on foreign, the modified-by refinement (<slug>@agents.lanework .invalid) when every foreign file agrees, heal-marked receipts split into their own commit — window split foreign → heal → user. Edit-session granularity: ~700ms saves stay uncommitted, staging excludes open session folders (closure-resolved so mid-session moves stage around the new location), session end nudges the debounce so each session lands exactly one body commit. Flush-before-overwrite gates on known-foreign windows and commits synchronously ahead of the write; close/quit flush the pipeline via CloseFlushCoordinator's committerFlush. index.lock backs off briefly then re-debounces silently; clean tree no-ops; genuine failures ride the standing history-suspension banner and retry next debounce. Abnormal repo states (detached HEAD, merge/rebase/cherry-pick in progress) hold the engine with a 15s re-check; unborn HEAD commits "Initial board state" whole-tree; dirty tree at open catches up through the same engine. Message seam (CommitMessageComposing) ships interim — the semantic composer is the next card. Discovery diffs HEAD against an in-memory index with rename detection (git status alone never pairs a bare mv), and a failed survey reads as "could not look", never "nothing changed". 46 new tests / 8 suites, all real repositories via bundled libgit2. 2240 tests / 383 suites green; InertGitTests untouched. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
187 lines
9.9 KiB
Swift
187 lines
9.9 KiB
Swift
import Observation
|
|
import SwiftUI
|
|
|
|
// MARK: - The mode
|
|
|
|
/// Which of the body column's two surfaces is showing (05-card-window.md ▸ Mode grammar).
|
|
///
|
|
/// **Two cases, not three.** The raw-source outlet swaps the *entire content area* — title, body
|
|
/// and sidebar — so it is a state of the window, not of the body column, and it does not belong in
|
|
/// this enum. It lives in `CardRawSourceSession`, which is also where the grammar's two open
|
|
/// questions are settled (which mode a raw exit lands in, and what an empty body after Apply does).
|
|
/// Edit Body disabling while raw source is active (11-command-nexus.md) is that window-level state's
|
|
/// rule over this one, and it is enforced on the row: `EditBodyCommand.isEnabled(body:rawSource:)`.
|
|
public enum CardBodyMode: Equatable, Sendable {
|
|
/// The rendered, selectable preview — **the resting state**.
|
|
case preview
|
|
/// The raw-Markdown editor.
|
|
case edit
|
|
|
|
/// The mode a window opens its body in: **Preview, unless the body is empty** (05 ▸ Mode
|
|
/// grammar: "a card opens in Preview — unless its body is empty, which opens straight into
|
|
/// Edit with the cursor ready (a new card has nothing to preview, so ⌘↩ during creation flows
|
|
/// title → body without a mode stop)").
|
|
///
|
|
/// Whitespace is empty (`BodyMarkup.isEmpty`): a body holding one newline previews as a blank
|
|
/// page, and stopping the user at a blank preview of a blank body is precisely the ceremony
|
|
/// the rule removes.
|
|
public static func opening(body: String) -> CardBodyMode {
|
|
BodyMarkup.isEmpty(body) ? .edit : .preview
|
|
}
|
|
|
|
/// ⌘E — View ▸ Edit Body's checkmark toggle. Also the whole of "Return in Preview enters Edit"
|
|
/// and "Escape in Edit returns to Preview": three gestures, one flip, so they cannot drift.
|
|
public var toggled: CardBodyMode {
|
|
self == .preview ? .edit : .preview
|
|
}
|
|
}
|
|
|
|
// MARK: - The window's body surface, as a handle
|
|
|
|
/// One card window's body column, reduced to what things *outside* it need: which mode it is in,
|
|
/// and how to put a find bar over whichever surface currently holds the keyboard.
|
|
///
|
|
/// `BoardSearchPresentation`'s shape and for its reason — one per window, `@State` in the host,
|
|
/// published through the focus system so a **menu item** (Edit ▸ Find, ⌘F) can reach the frontmost
|
|
/// card window without anyone keeping a which-window-is-key register. It is deliberately not on
|
|
/// `BoardStore`: the store is the *board's*, shared by every window on it, and two card windows
|
|
/// open on two cards of one board are in two different modes.
|
|
@MainActor
|
|
@Observable
|
|
public final class CardBodyPresentation {
|
|
|
|
/// The surface showing right now. Starts in Preview and is settled by `openIfNeeded(body:)`
|
|
/// the first time the window has a body to judge.
|
|
public var mode: CardBodyMode = .preview
|
|
|
|
/// Puts the standard find bar over the focused body surface — **Edit ▸ Find (⌘F) is
|
|
/// find-in-text here** (05 ▸ Preview; 11-command-nexus.md scopes ⌘F "Board window: board
|
|
/// search; card window: find-in-text"). Filled in by the surface itself, which is the only
|
|
/// thing that holds a text view to hand the action to; `nil` until one exists, which is also
|
|
/// exactly when ⌘F has nothing to find in.
|
|
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)?
|
|
|
|
/// **Opens an Edit session** — the mirror of `flushEdits`, and here for its reason exactly: this
|
|
/// is the type every path that *enters* Edit already holds, so attaching the announcement to the
|
|
/// flip makes "always" true by construction rather than by three call sites remembering.
|
|
///
|
|
/// Filled in by the window with its edit session's `beginEditSession()`. Its consumer is pro-m1's
|
|
/// auto-committer, which stages around the card's folder for as long as the session stands
|
|
/// (06-history-undo.md ▸ Rules ▸ Auto-commit).
|
|
public var beginEdits: (() -> Void)?
|
|
|
|
/// 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
|
|
/// arrives with the first snapshot — but snapshots keep arriving (a watcher reload, a lane
|
|
/// move, another window's edit). Re-running it would drag a reader back into Edit the moment
|
|
/// someone else emptied the file, and would fight a user who had just pressed ⌘E.
|
|
private var hasOpened = false
|
|
|
|
public init() {}
|
|
|
|
/// Applies the opening rule the first time it is called, and does nothing on every call after.
|
|
@discardableResult
|
|
public func openIfNeeded(body: String) -> CardBodyMode {
|
|
guard !hasOpened else { return mode }
|
|
hasOpened = true
|
|
mode = CardBodyMode.opening(body: body)
|
|
// A card that opened straight into Edit because its body was empty is in a session exactly
|
|
// like one the user pressed ⌘E in, and the committer has to stage around it either way.
|
|
if mode == .edit { beginEdits?() }
|
|
return mode
|
|
}
|
|
|
|
/// ⌘E, Return in Preview, Escape in Edit — see `CardBodyMode.toggled`.
|
|
public func toggleMode() {
|
|
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
|
|
if newMode == .edit { beginEdits?() }
|
|
}
|
|
}
|
|
|
|
// 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 **plus the raw-source clause**: with no card window in front there is no
|
|
/// `cardBody` focused value, and the row disables; with source mode active it disables too — "View ▸
|
|
/// Edit Body (⌘E) disables while source mode is active, matching its toolbar item" (05 ▸ Raw source
|
|
/// outlet; 11-command-nexus.md files the same clause on the row). The reason is that the two would
|
|
/// be editing the same bytes from two surfaces: while the whole `index.md` is open as text, a mode
|
|
/// flip in the body column beneath it has nothing to flip *to* — the column is not on screen — and
|
|
/// its buffer's next debounced save would write a body the raw buffer is also about to overwrite.
|
|
/// Cancel and Apply own the exits (03-board-ui.md ▸ Toolbar).
|
|
///
|
|
/// The read-only lock is deliberately **not** part of it — entering Edit is not a mutation, and
|
|
/// 02-architecture.md § the lock's scope keeps editor buffers alive under the lock (only their saves
|
|
/// suspend), so a locked board can still be read in the editor and its text copied out.
|
|
struct EditBodyCommand: View {
|
|
|
|
@FocusedValue(\.cardBody) private var cardBody
|
|
@FocusedValue(\.cardRawSource) private var rawSource
|
|
|
|
/// The row's validation, as a value a test can hold: a menu item's `.disabled` is otherwise only
|
|
/// observable by driving the menu bar, and "⌘E disables while raw source is active" is precisely
|
|
/// the kind of clause that regresses silently.
|
|
static func isEnabled(body: CardBodyPresentation?, rawSource: CardRawSourceSession?) -> Bool {
|
|
body != nil && rawSource?.isActive != true
|
|
}
|
|
|
|
var body: some View {
|
|
Toggle("Edit Body", isOn: Binding(
|
|
get: { cardBody?.mode == .edit },
|
|
set: { isOn in cardBody?.setMode(isOn ? .edit : .preview) }
|
|
))
|
|
.keyboardShortcut("e", modifiers: .command)
|
|
.disabled(!Self.isEnabled(body: cardBody, rawSource: rawSource))
|
|
}
|
|
}
|
|
|
|
/// The focused card window's body column, beside `FocusedValues.boardSearch` — see
|
|
/// `FocusedBoardStoreKey` for why window-scoped menu items reach their window this way.
|
|
struct FocusedCardBodyKey: FocusedValueKey {
|
|
typealias Value = CardBodyPresentation
|
|
}
|
|
|
|
extension FocusedValues {
|
|
var cardBody: CardBodyPresentation? {
|
|
get { self[FocusedCardBodyKey.self] }
|
|
set { self[FocusedCardBodyKey.self] = newValue }
|
|
}
|
|
}
|