Files
lanework/Kanban/App/CardWindowHost.swift
T
rzen 53bc71f7fb Materialize the trash — store, undo, and the container universe
Phase 2 swaps every consumer: Liveness and its ancestor walk are gone,
replaced by ItemContainer — a UUID set plus the container side it
lives on, presence the whole test, one selection boundary instead of
the old liveness law. Deletion stages by place: board cards move to
the trash at a store-minted head rank, trash-side delete is permanent
behind its confirmation, Delete Immediately skips the trash from
anywhere, lane delete captures the subtree and removes the folder.
Restore has no method at all — moveCards resolves members in either
container, so drag-out and cut-paste are the ordinary moves 13 calls
them, registering ordinary Move steps. The delete inverse moves the
card back to its captured lane and rank; redo replays the captured
trash rank, a value the gesture actually wrote; lane undo recreates
the subtree byte-faithfully in session. Purges register nothing —
where 13's trash section contradicts its own Rules on that, Rules
wins, filed for ruling. Staleness collapsed to present-or-absent: a
container is a path, so a foreign restore fails the delete step's
expectation structurally. Legacy tombstones migrate on the loose-file
tail hook, cards oldest-first so minting above top reproduces the
retired newest-first column, lanes returning live, one folded loss
row naming both directions. Put Back, restoreByDrag,
receiveRestoredCards, TrashEntry, and the kind machinery are deleted;
the trash column renders the container correctly with its full face
rework left to phase 3.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 17:47:56 -04:00

635 lines
34 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import SwiftUI
import os
// MARK: - Fate
/// Where a card window's card is right now: the card itself, and the lane it is currently in.
///
/// **One resolution answers both of the window's questions**, which is why the lane rides along
/// rather than being looked up a second time: the card is what the window renders, and the lane is
/// half of its subtitle ("⟨board⟩ ⟨lane⟩", live-updating as the card moves — 05-card-window.md
/// ▸ Window). Two walks over the snapshot could disagree about which lane a card is in for exactly
/// one frame, and that frame is the one where the card just moved.
public struct CardPlacement: Equatable {
public let card: Card
public let lane: Lane
}
/// What the current snapshot says about a card window: render this card, or go away.
///
/// A named decision rather than a scattering of `if`s, because 05-card-window.md ▸ Deletion &
/// lifecycle and 02-architecture.md § Live-reload resilience state the same rule from two directions
/// and both have to be true of one piece of code. Making it a value also makes it a *pure* function
/// of a snapshot, which is the only way the tombstoned-lane case gets tested without a window.
public enum CardWindowFate: Equatable {
case shows(CardPlacement)
case dismisses
}
// MARK: - The session seam
/// A card window's editor session: the thing the close flush ends, and now the thing it ends *with
/// something in it* — the window's Edit buffer (05-card-window.md ▸ Edit).
///
/// The ordering it exists for is unchanged (see `CardSessionFlushing`): the board's close flush
/// drives `endSession()` for every card window before any of the board's own pending work is
/// flushed, and a card window closed on its own runs it from its disappear. Ending twice does
/// nothing the second time, which is what makes those two paths safe to both exist.
///
/// **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
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
/// 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 {
guard !hasEnded else { return }
hasEnded = true
// pro-m1: this is the boundary the auto-committer coalesces on — one commit per Edit
// session, "never per save tick" (06-history-undo.md ▸ Rules ▸ Auto-commit). The debounced
// saves inside the session are ordinary bracketed writes; what makes them one commit is that
// the committer's own debounce outlives them and this call is where the session is known to
// be over.
body.endEditSession()
}
}
// MARK: - CardWindowHost
/// One card window (05-card-window.md).
///
/// ### Its whole identity is `(board, card)`
///
/// Which is why this host is mostly a set of dismissal rules. The window follows its card between
/// lanes for free — the key names neither — and it dismisses in the three cases where the key stops
/// naming anything: the card is tombstoned, its *lane* is tombstoned (effective liveness is
/// ancestor-walked, 02 § Live-reload resilience), or the card is simply not in this board's snapshot
/// any more, which is what a cross-board move looks like from here.
///
/// ### It can never outlive its board window
///
/// "The board window owns the board" (02 § Components) — so a card window whose board has no live
/// store, or whose board session has gone, dismisses immediately rather than becoming an orphan with
/// a store it acquired by itself. That covers the ordinary case (the board window closed and its
/// flush dismissed this one) and the odd one (the system restoring a card window from a previous
/// launch, which scene restoration is disabled precisely to prevent).
///
/// ### It is the window; `CardWindowView` is the content
///
/// This file owns identity, lifecycle, the title and subtitle, and where the window opens. The
/// two-column composition inside it is `CardWindowView`'s, and what fills those columns — the title
/// field, Preview/Edit, the sidebar's five sections — arrives card by card underneath a composition
/// that does not move. The *window-scoped* state those surfaces need lives here, because a window is
/// what it is scoped to: the body column's mode (`CardBodyPresentation`) and the raw-source outlet
/// (`CardRawSourceSession`), both published through the focus system so the View menu's rows can
/// reach the frontmost card window.
struct CardWindowHost: View {
let ref: CardWindowRef
@Environment(AppModel.self) private var appModel
@Environment(\.dismissWindow) private var dismissWindow
@State private var windowController = HostedWindowController()
@State private var session = CardWindowSession()
@State private var phase: Phase = .opening
/// This window's body column — the Preview/Edit mode, and the ⌘F hook a menu item reaches
/// through the focus system (`CardBodyPresentation`).
@State private var bodyPresentation = CardBodyPresentation()
/// This window's raw-source outlet — the whole-content-area swap View ▸ Raw Source (⌥⌘E) drives
/// (`CardRawSourceSession`). Beside the body handle rather than inside it: the two are different
/// scopes, and the Edit Body row reads both.
@State private var rawSource = CardRawSourceSession()
/// This window's attachments section — the listing, the keyboard selection, and the two writes
/// it starts (05-card-window.md ▸ Attachments). Window-scoped for `CardBodyPresentation`'s
/// reason: two card windows on one board have two different selections, and the menu bar reaches
/// the frontmost one through the focus system.
@State private var attachments = CardAttachments()
/// This window's thumbnail memory. Held here rather than in the section so it survives every
/// snapshot the store applies — a cache that died with the view would regenerate every thumbnail
/// on every reload (`AttachmentThumbnailCache`).
@State private var thumbnails = AttachmentThumbnailCache()
/// 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 {
case opening
case open(BoardStore)
case closing
}
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "card-window")
// MARK: - The lifecycle rule
/// Whether a card window keyed on `cardID` still has a card, given this board's snapshot.
///
/// The three dismissal cases collapse into two lines: a card that is not in the snapshot is gone
/// (deleted outright, or moved to another board — the board half of the key no longer names it),
/// and a card whose **effective** liveness is trashed renders nowhere, whether the tombstone is
/// its own or its lane's. Only a live card in a live lane keeps its window.
///
/// Takes the id as the ref stores it — a raw folder name — and compares it as an `ItemID`, so two
/// case-spellings of one UUID are one card here exactly as they are everywhere else.
static func cardWindowFate(cardID: String, in snapshot: BoardModel) -> CardWindowFate {
let identity = ItemID(rawValue: cardID)
for lane in snapshot.lanes {
guard let card = lane.cards.first(where: { $0.id == identity }) else { continue }
return .shows(CardPlacement(card: card, lane: lane))
}
return .dismisses
}
/// The window's subtitle: "⟨board⟩ ⟨lane⟩" (05-card-window.md ▸ Window).
///
/// Pure, and taking both names as strings, because the *live* half is what matters and is the
/// easy half to lose: the board name follows a board rename through
/// `AppModel.displayName(of:)`, and the lane name follows the card between lanes because it is
/// re-derived from every snapshot rather than captured when the window opened. A window that
/// kept showing the lane its card was in an hour ago would be wrong in exactly the case the
/// subtitle exists for.
///
/// Untitled lanes render the same placeholder the board's lane header does — "Untitled" is a
/// rendering, never a value (03-board-ui.md § Card face).
static func subtitle(board: String, lane: String?) -> String {
"\(board) \(lane ?? "Untitled")"
}
// MARK: - View
var body: some View {
content
// Derived, like every other measurement in this window: the minimum is what the two
// columns need at the current text size, not a number chosen once at 13pt
// (`CardWindowMetrics`).
.frame(minWidth: minimumSize.width, minHeight: minimumSize.height)
.background(WindowAccessor(controller: windowController))
.navigationTitle(windowTitle)
// The window follows its card: both of these are re-derived from every snapshot, so a
// rename retitles the window and a lane move re-subtitles it with no notification of
// our own (05-card-window.md ▸ Window).
.navigationSubtitle(windowSubtitle)
// Edit ▸ Find (⌘F) is find-in-text in a card window (11-command-nexus.md) — the menu
// item reaches the frontmost one's body surface through this, exactly as board-window
// items reach their window's store (`FocusedBoardStoreKey`).
.focusedSceneValue(\.cardBody, bodyPresentation)
// View ▸ Raw Source (⌥⌘E) reaches the frontmost card window the same way, and Edit Body
// reads it too — "View ▸ Edit Body (⌘E) disables while source mode is active"
// (05-card-window.md ▸ Raw source outlet).
.focusedSceneValue(\.cardRawSource, rawSource)
// File ▸ Add Attachment… (⇧⌘A) and File ▸ Reveal in Finder's card-window scope reach the
// frontmost card window the same way (11-command-nexus.md).
.focusedSceneValue(\.cardAttachments, attachments)
// The raw-source outlet's detailed alert, presented over this window — a validation
// refusal on Apply, or a file that could not be opened as source. It hangs *here* rather
// than inside the editor because the second of those fires while source mode is still
// closed, when there is no editor on screen to present it from.
.alert(
rawSource.alert?.title ?? "",
isPresented: Binding(
get: { rawSource.alert != nil },
set: { presented in
guard !presented else { return }
rawSource.dismissAlert()
}
),
presenting: rawSource.alert
) { _ in
// One button, because there is one thing to do: OK returns to the text, which is
// exactly where it was. Nothing was written, so there is nothing to retry or discard.
Button("OK") { rawSource.dismissAlert() }
} message: { alert in
Text(alert.message)
}
// The one modal moment (02-architecture.md § Write-failure surfacing), presented over
// the window whose close it is holding up — which is why it hangs here and not on the
// board: the text being saved is this window's.
.dirtyBufferAlert(session.bufferGuard) { Self.copyDestination(named: windowTitle) }
.task { start() }
.onChange(of: shouldDismiss, initial: true) { _, dismisses in
guard dismisses else { return }
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() }
}
private var minimumSize: CGSize {
CardWindowMetrics.minimumSize(bodyPointSize: CardWindowMetrics.bodyPointSize)
}
@ViewBuilder
private var content: some View {
if case let .open(store) = phase, let placement {
CardWindowView(
card: placement.card,
store: store,
// The app's list, not the board's: the quick-style recents the sidebar's embedded
// editor feeds are app-wide state (02-architecture.md § Per-board app state), so
// they come from the model every window shares rather than from this board's store.
recents: appModel.styleRecents,
cardFolder: Self.cardFolder(root: store.rootURL, placement: placement),
bodyPresentation: bodyPresentation,
bodySession: session.body,
rawSource: rawSource,
// "Under the read-only lock the controls disable in place — an in-content mutation
// menu validation can't reach" (05 ▸ Preview). The checkbox is that control, and
// the store's own lock is the whole predicate — and it is the attachments section's
// predicate too ("the attachment row's ⌫/Remove shares the posture").
isEditable: !store.isReadOnly,
attachments: attachments,
thumbnails: thumbnails,
fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id),
onToggleTask: { offset, checked in
store.toggleTaskMarker(inCard: placement.card.id, bodyOffset: offset, checked: checked)
}
)
// **The listing is the snapshot's, republished** — `Card.attachments`, which the loader
// fills from `attachments/`'s top-level files in Finder order. Every write in the
// section is bracketed, so the reload that refreshes this arrives by itself and the
// section never lists a directory of its own (05 ▸ Attachments; the one-way flow).
.onChange(of: placement.card.attachments, initial: true) { _, names in
attachments.names = names
}
.onChange(of: Self.cardFolder(root: store.rootURL, placement: placement), initial: true) { _, folder in
// Re-derived from the store's *current* root, `cardFolder`'s rule: a mid-session
// folder rename moves the board, and rows resolving against where it used to be
// would open nothing.
attachments.cardFolder = folder
}
.onChange(of: store.isReadOnly, initial: true) { _, locked in
attachments.isEditable = !locked
}
} else {
// Nothing to render and nothing worth animating: this window is on its way out.
Color.clear
}
}
/// `<root>/<lane>/<card>` — the card's own folder, which is what its body's relative images and
/// links resolve against (05-card-window.md ▸ Preview).
///
/// Built off the store's *current* `rootURL` rather than the ref's captured one, for
/// `BoardStore.boardItem`'s reason: a mid-session folder rename moves the board, and a preview
/// resolving images against where the board used to be would quietly stop showing them.
static func cardFolder(root: URL, placement: CardPlacement) -> URL {
root
.appendingPathComponent(placement.lane.id.rawValue, isDirectory: true)
.appendingPathComponent(placement.card.id.rawValue, isDirectory: true)
}
/// Where this window's card is in this board's snapshot, or `nil` when it is not — which is the
/// same condition `shouldDismiss` reads, one moment before the window goes.
private var placement: CardPlacement? {
guard case let .open(store) = phase,
case let .shows(placement) = Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot)
else { return nil }
return placement
}
/// The card's title, with the face's own untitled rendering — a card with no `title` key is
/// "Untitled" in the title bar and the Window menu, never a blank strip.
private var windowTitle: String {
guard let placement else { return "" }
return placement.card.title.value ?? "Untitled"
}
private var windowSubtitle: String {
guard case let .open(store) = phase, let placement else { return "" }
return Self.subtitle(
board: AppModel.displayName(of: store),
lane: placement.lane.title.value
)
}
/// The dismissal decision, re-evaluated on every snapshot the store applies.
///
/// Two clauses, and the second is the safety net: the board's session vanishing means the board
/// window has finished tearing down, and a card window still on screen at that point has nothing
/// behind it. It is deliberately redundant with the close flush, which dismisses these windows
/// itself — a net is only useful when the thing it backs up has already failed.
private var shouldDismiss: Bool {
guard case let .open(store) = phase else { return false }
guard appModel.session(for: ref.board) != nil else { return true }
return Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot) == .dismisses
}
// MARK: - Opening
/// Joins the board's session, or dismisses.
///
/// **`liveStore(for:)` first, and no fallback to `acquire` on a closed board.** A card window
/// never opens a board: doing so would put a store — and a watcher — behind a window that,
/// by 02's ownership rule, is not allowed to exist. The `acquire` below can only hit the
/// already-open path, which is why its failure is logged rather than surfaced.
private func start() {
guard case .opening = phase else { return }
guard appModel.storeRegistry.liveStore(for: ref.boardURL) != nil else {
Self.logger.debug("card window has no live board — dismissing")
phase = .closing
dismissWindow(id: WindowID.card, value: ref)
return
}
let store: BoardStore
do throws(BoardLoadError) {
store = try appModel.storeRegistry.acquire(ref.boardURL)
} catch {
Self.logger.error("card window could not acquire its board: \(error.description, privacy: .public)")
phase = .closing
dismissWindow(id: WindowID.card, value: ref)
return
}
appModel.registerCardWindow(ref, session: session)
phase = .open(store)
configureSession(store: store)
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)
}
// The session's one undo step, at the Edit→Preview flip (13-native-undo.md ▸ Rules). Weakly,
// `save`'s rule: a session ending after the board window has gone registers nothing rather
// than resurrecting a released store — and the board's stack died with it anyway.
session.body.registerUndo = { [weak store] priorBody, newBody in
store?.registerBodyEdit(inCard: cardID, priorBody: priorBody, newBody: newBody)
}
bodyPresentation.flushEdits = { [session] in
session.body.endEditSession()
}
Self.configureRawSource(
rawSource,
body: session.body,
presentation: bodyPresentation,
store: store,
cardID: cardID
)
Self.configureAttachments(attachments, store: store, cardID: cardID)
}
/// Points the attachments section at its card — **the one place Add Attachment… and Remove
/// learn which card they act on** (05-card-window.md ▸ Attachments).
///
/// Both seams are the store's own bracketed methods, unchanged: `importAttachments(_:toCard:)`
/// is the *same* call the board window's Finder drop makes, so a file added through ⇧⌘A, through
/// the header's plus, through a drop anywhere in this window, and through a drop on the card's
/// face on the board all take one path — one collision rename, one set of banners, one commit
/// shape. There is deliberately no card-window import of its own to keep in step with it.
///
/// The store is captured **weakly**, `configureSession`'s rule: a panel still running after the
/// board window has gone should write nothing rather than resurrect a released store.
///
/// `static`, and taking every collaborator as a parameter, for `configureRawSource`'s reason:
/// the target resolution is invisible in a running window until it is wrong, and this shape is
/// what lets a test drive the real wiring rather than a re-typed copy of it.
static func configureAttachments(_ attachments: CardAttachments, store: BoardStore, cardID: ItemID) {
attachments.importFiles = { [weak store] urls in
store?.importAttachments(urls, toCard: cardID)
}
attachments.removeFile = { [weak store] name in
store?.removeAttachment(named: name, fromCard: cardID)
}
}
/// Points the raw-source outlet at its card — the outlet's three seams (05-card-window.md ▸ Raw
/// source outlet), wired in the one place that knows both a buffer and a board.
///
/// **The flush is the Preview flip**, not a second mechanism: "Entering source mode flushes any
/// pending title/body edits first" and "Leaving Edit flushes the debounce (mode flip, raw-source
/// entry, window close)" are the same sentence read from two directions, so putting the entry
/// through `setMode(.preview)` makes the flush structural — and settles the exit state at the same
/// time, because a window that genuinely left Edit on the way in has Preview waiting for it on the
/// way out (`CardRawSourceSession`). The unconditional `flush()` behind it costs nothing on a
/// clean buffer and covers the case where the mode was already Preview with a save still owed (a
/// tick suspended under the read-only lock, say).
///
/// The store is captured **weakly**, `configureSession`'s rule: an outlet still holding a closure
/// after the board window has gone should write nothing rather than resurrect a released store.
///
/// `static`, and taking every collaborator as a parameter, for the reason the fate and subtitle
/// rules are: the ordering above is the whole of "flush, *then* read fresh", it is invisible in a
/// running window until it is wrong, and this shape is what lets a test drive the real wiring
/// rather than a re-typed copy of it.
static func configureRawSource(
_ rawSource: CardRawSourceSession,
body: CardBodyEditSession,
presentation: CardBodyPresentation,
store: BoardStore,
cardID: ItemID
) {
rawSource.flushPendingEdits = { [body, presentation] in
presentation.setMode(.preview)
body.flush()
}
rawSource.read = { [weak store] in
guard let store else { return .vanished }
return store.readCardSource(inCard: cardID)
}
rawSource.apply = { [weak store] text in
guard let store else { return .vanished }
return store.applyCardSource(inCard: cardID, text: text)
}
}
/// Size and placement — **the remembered frame first, the cascade second** (05-card-window.md
/// ▸ Window: "New windows open at the last-used card-window size, cascaded; frames restore per
/// card across relaunch where state restoration allows").
///
/// Two memories, and they answer different questions:
///
/// - **The last-used size is app-wide.** 02 § Per-board app state files "the last-used
/// card-window size" under App-wide state explicitly, so it lives in `AppPreferences` and a
/// card window opened on any board inherits the size of the last one the user sized.
/// - **The frame is per card**, and lives on the board's registry record beside the board
/// window's own frame — same home, same identity-keyed record, same files-first rule (nothing
/// app-private is ever written into the board folder). "Where state restoration allows" is
/// this: AppKit's scene restoration is disabled app-wide on purpose (`KanbanApp`), so a card
/// window does not come back by itself at relaunch — but the *next* time the user opens that
/// card, it opens where they left it.
///
/// A card with a remembered frame therefore does **not** cascade: a cascade over a deliberate
/// placement would move a window the user had already put somewhere. Only the windows with
/// nothing remembered take the running cascade point, which is what keeps a burst of freshly
/// opened cards from landing on top of each other.
private func configureWindow() {
// Read once here rather than per callback: this window's board has a session by now (the
// caller just registered against it), and the record id is what both memories are keyed on.
let recordID = appModel.session(for: ref.board)?.recordID
// The window's customizable toolbar — Edit Body · Raw Source · Add Attachment, "the
// window's three committed functions" (03-board-ui.md ▸ Toolbar; 05-card-window.md ▸
// Window). It carries the three window-scoped handles above rather than a store, which is
// why it is installed here and not at attach: those are this window's, and so is it.
windowController.installToolbar(
CardToolbar.controller(body: bodyPresentation, rawSource: rawSource, attachments: attachments)
)
// **The board's stack, not one of this window's own** (13-native-undo.md ▸ Rules: "not
// per-window: every window over a board (board window, its card windows) shares the store
// and shares the stack"). Same closure shape as the board window's, and deliberately the
// same object: ⌘Z with a card window in front crosses the board step the user last made,
// wherever they made it. The card's *text* surfaces are untouched by this — the body editor
// and the raw-source editor each vend their own manager to the responder chain, which is
// what keeps typing undo out of the board's stack (06-history-undo.md ▸ Undo routing).
windowController.boardUndoManager = { appModel.session(for: ref.board)?.undoManager }
windowController.onAttach = { window in
if let recordID,
let saved = appModel.boardRegistry.cardWindowFrame(id: recordID, cardID: ref.cardIdentity) {
// Repositioned onto a live screen when the saved one is gone — the board window's
// own rule, shared rather than restated (`HostedWindowController.placement`).
window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true)
return
}
if let size = AppPreferences.lastCardWindowSize {
window.setContentSize(size)
}
// `cascadeTopLeft(from:)` both places this window and returns the origin for the next
// one, so the running point is the whole cascade.
appModel.cardCascadePoint = window.cascadeTopLeft(from: appModel.cardCascadePoint)
}
if let window = windowController.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
if let recordID {
appModel.boardRegistry.updateCardWindowFrame(
id: recordID,
cardID: ref.cardIdentity,
frame: WindowFrame(x: frame.origin.x, y: frame.origin.y, width: frame.width, height: frame.height)
)
}
guard let window = windowController.window else { return }
let size = window.contentRect(forFrameRect: frame).size
guard size != AppPreferences.lastCardWindowSize else { return }
AppPreferences.setLastCardWindowSize(size)
}
}
// 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.
///
/// The release rides **behind** the session's end rather than beside it: a session that has
/// something to commit (m6) needs the store it is committing through, and a refcount that hit
/// zero first would have stopped the watcher underneath it. In m4 the hook is a no-op and the
/// ordering costs one run-loop turn — the point is that the shape is already right.
private func finish() {
guard case let .open(store) = phase else { return }
phase = .closing
appModel.unregisterCardWindow(ref)
Task { @MainActor in
await session.endSession()
appModel.storeRegistry.release(store)
}
}
}