Files
lanework/Kanban/App/CardWindowHost.swift
T
rzen 142c6e75fe Implement undo and redo as forward commits
GitHistoryProvider is the second HistoryProviding implementation:
its stack IS HEAD's first-parent ancestry, reseeded on load (redo
empty), re-synced to HEAD before every crossing so agents'
self-commits become the top and ⌘Z steps back exactly one commit;
any arriving commit clears redo (a heal-only window deliberately
does not). Restores are forward commits through the ordinary
signature path — GitRestoreOperation materializes only the
current-vs-target diff as working-tree writes and resolves no
reset/checkout symbol at all; heal commits are transparent
in-session (pointer passes over, restores exclude heal-owned paths,
identity carried on landed windows via PlannedCommit.kind →
GitLandedCommit). Subjects "Undo:/Redo: <crossed subject>"; menu
labels never nest in-session; the root commit is not a step
(crossing it would restore the empty tree).

Provider binding flips: makeHistoryProvider(store, tier, git) —
free binds native everywhere, Pro binds the git provider on git
boards and NOTHING on mode-none/repo-nested (the pair disables
through existing validation); add-git mid-session live-binds via
HistoryStore.didAddGit → bindHistoryProvider (the flip only ever
adds).

SessionSettleGate is the reusable Save All / Discard / Cancel step:
restores whose diff touches an open Edit session or raw-source
buffer gate on it (Save All applies with validation — a refused
buffer cancels the whole restore focused on the offender; Discard
reverts via CardBodyEditSession.discardBuffer and reconciles against
the working tree, deliberately skipping the second flush); untouched
sessions ride through undisturbed. Built for the branch-switch card
to reuse. BoardStore gains the async performWholesale sibling.

CardHistorySection fills the m6 EmptyView slot: read-only, newest
first, follows the card across lane moves by folder-component match
(the UUID is the identity — no rename detection), absent off git
mode and off Pro.

2332 tests / 403 suites green; InertGitTests untouched.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 15:54:22 -04:00

896 lines
52 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 deleted-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 delete the surgical body
/// write 05 ▸ Deletion & lifecycle promises ("a dirty Edit buffer flushes into the card's folder at
/// its new `.trash/` location before the window dismisses ... so the keystrokes survive a later
/// restore"). `BoardStore.writeCardBody` resolves trash 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 window's comments pane — the thread, the composer's draft buffer, and the one open inline
/// edit session (05-card-window.md ▸ The comments column).
///
/// It lives **here** rather than as another `@State` beside the body's handles, and the close
/// flush is why: the pane owes the close three things in a fixed order — the inline session's
/// flush, the draft's save, then the `comments/.trash/` purge — and this object is the one the
/// coordinator already drives (`CardSessionFlushing`). A pane held only by the view would have its
/// close work run wherever SwiftUI happened to tear the view down.
let comments = CardComments()
/// 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
/// The window's raw-source outlet, wired in by the host once the window exists.
///
/// A closure rather than a stored reference, `CardBodyEditSession.save`'s precedent: the outlet
/// is window state living beside this object (`CardWindowHost.rawSource`) rather than inside it,
/// and a session that reached into the view's state would be the wrong direction. `nil` — a
/// window that has not joined its board — holds nothing, which is true.
var rawSourceHoldsUnsavedText: (@MainActor () -> Bool)?
/// The window's raw-source outlet, as the save-or-discard step's two writes: **Apply** (which
/// validates, and answers `false` when it refuses) and **Cancel**. Wired by the host beside
/// `rawSourceHoldsUnsavedText`, and for its reason — the outlet is window state living beside
/// this object rather than inside it.
var rawSourceApply: (@MainActor () -> Bool)?
var rawSourceCancel: (@MainActor () -> Void)?
var rawSourceIsActive: (@MainActor () -> Bool)?
/// The Edit buffer's dirty text, a typed-in raw-source outlet, or an inline comment edit session
/// holding keystrokes its file has not got — see `CardSessionFlushing`.
///
/// **The composer's draft is deliberately not counted.** 05-card-window.md ▸ The comments column
/// gives it the opposite posture from every other buffer in this window — "Close and quit just
/// proceed — no DirtyBufferGuard, nothing to lose" — because it is a durable file being edited in
/// place rather than unsaved work. An inline comment edit *is* the ordinary kind, so it counts
/// exactly as the body's does (`CardComments.holdsUnsavedContent`).
var holdsUnsavedContent: Bool {
body.isDirty || rawSourceHoldsUnsavedText?() == true || comments.holdsUnsavedContent
}
/// **What a restore or a branch switch asks this window to settle** (06-history-undo.md ▸ Rules
/// ▸ Undo restore vs open Edit sessions).
///
/// ### The predicate is *open*, not *dirty*
///
/// An **open** Edit session is what needs settling even with a clean buffer, because its ~700 ms
/// saves are on disk and deliberately uncommitted — the stage-around rule's whole point — so a
/// restore landing over them would either bury text no commit protects or leave the session's
/// next debounced save to write pre-restore bytes back over the restored card, "a ⌘Z that visibly
/// doesn't happen". Same reading `CardBodyEditSession.isEditing` records for staging, applied to
/// the same fact.
///
/// An **open raw-source outlet** counts whether or not it has been typed in, and 06 says why: its
/// Apply "would write the *entire* pre-switch `index.md` byte-for-byte onto the new branch's
/// card". A buffer read from before the restore is the hazard; typing is not required for it.
var settlement: CardSessionSettlement? {
CardSessionSettlement(
needsSettling: { [self] in
body.isEditing || body.isDirty || rawSourceIsActive?() == true
},
saveAll: { [self] in
// The Edit session ends with its normal commit — "each card's Edit→Preview flip".
body.endEditSession()
// Apply validates; a refusal is the whole operation's cancellation, and the alert it
// raised is already on the offending window.
guard rawSourceIsActive?() == true else { return true }
return rawSourceApply?() ?? true
},
discard: { [self] in
// The buffer goes back to what disk says; the *disk* goes back to the target state as
// part of the restore itself, which reconciles this card's folder against the working
// tree rather than against HEAD (`GitRestoreOperation.plan`).
body.discardBuffer()
rawSourceCancel?()
}
)
}
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()
// **Saves first, purge last** — the inline comment session's flush and the draft's save land
// before `comments/.trash/` is emptied, which is the order that keeps the purge from removing
// a folder a save was about to write into (`CardComments.endSession`). It runs after the
// body's for the same reason it runs at all: this is the one place the window's whole close
// work has a fixed order.
comments.endSession()
}
}
// 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 whenever the key stops naming a card
/// **on the board**: the card moved into `.trash/` ("entering the trash counts as deleted" —
/// 05-card-window.md ▸ Deletion & lifecycle, resettled 2026-07-28), its *lane* was deleted and took
/// it along, 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()
/// This card's commit trail (05-card-window.md ▸ History). Held here for `thumbnails`' reason —
/// it must survive every snapshot — and surfaced to the view only in git mode (`cardHistory`).
@State private var history = CardHistory()
/// 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
/// The two app-wide comment bits, read here for one reason only: the window's **minimum size**
/// depends on them (`minimumSize`). The panes read them again themselves (`CardWindowView`) —
/// two readers of one `UserDefaults` key, which is what `@AppStorage` is for and is cheaper than
/// threading the pair through a view that would then have to publish them back up.
@AppStorage(AppPreferences.showCommentsKey) private var showComments = true
@AppStorage(AppPreferences.commentsBesideBodyKey) private var commentsBesideBody = true
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.
///
/// **One walk over the lanes is the whole rule** (05-card-window.md ▸ Deletion & lifecycle,
/// resettled 2026-07-28 — the materialized trash): deletion is a *move*, so a trashed card has
/// physically left its lane and answers `.dismisses` by simply not being found — "entering the
/// trash counts as deleted", with no liveness flag to read and no ancestor walk to run. A card
/// whose lane was deleted, one purged outright and one moved to another board all fall out of the
/// same absence. Only a card in one of this board's lanes 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)
// File ▸ Add Comment and the two View-menu comment toggles reach the frontmost card
// window the same way — the toggles read their own persisted bits and use this only to
// know a card window is in front at all (11-command-nexus.md scopes all three to the card
// window).
.focusedSceneValue(\.cardComments, session.comments)
// 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() }
}
/// **This card's commit trail, or nothing at all** (05-card-window.md ▸ History).
///
/// `nil` is the section's absence rule, read from the board's own git state rather than from a
/// flag: no `HistoryStore` means the free tier (12-editions.md — where the section never exists),
/// and a mode other than `git` means a board the app manages no history for. The object is held
/// by this host so it survives every snapshot, `thumbnails`' reason exactly.
private var cardHistory: CardHistory? {
guard appModel.session(for: ref.board)?.gitMode == .git else { return nil }
return history
}
/// What a trail re-read depends on: this card, and the number of commits the board has landed.
///
/// The count is the committer's own (`GitAutoCommitter.commitCount`), which advances for every
/// commit the app makes — the debounced ones, the launch catch-up, and a restore's. A foreign
/// commit an agent made *itself* moves HEAD without touching it; the trail then refreshes at the
/// next commit or the next open, which is the same freshness bound the popover's branch line has
/// and a great deal cheaper than polling HEAD from a sidebar.
private func historyReloadKey(store: BoardStore) -> String {
let commits = appModel.session(for: ref.board)?.git?.committer?.commitCount ?? 0
return "\(ref.cardID)#\(commits)"
}
/// **The minimum grows only while the comments pane is beside the body** (05-card-window.md ▸
/// Composition) — which is the whole reason the stacked mount exists, so a narrow display keeps
/// the minimum it always had.
private var minimumSize: CGSize {
CardWindowMetrics.minimumSize(
bodyPointSize: CardWindowMetrics.bodyPointSize,
commentsColumn: showComments && commentsBesideBody
)
}
@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,
comments: session.comments,
thumbnails: thumbnails,
history: cardHistory,
fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id),
onToggleTask: { offset, checked in
store.toggleTaskMarker(inCard: placement.card.id, bodyOffset: offset, checked: checked)
}
)
// **The trail, re-read when a commit lands** (05 ▸ History). The id is the pair of facts
// the answer depends on: which card this is, and how many commits this board has made —
// so the section refreshes after the app's own commits, after an agent's that the watcher
// committed, and after a ⌘Z's restore, with nothing here knowing what a committer is.
.task(id: historyReloadKey(store: store)) {
guard let cardHistory else { return }
await cardHistory.load(boardRoot: store.rootURL, cardFolderName: ref.cardID)
}
// **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
session.comments.cardFolder = folder
}
// The announcer's subject, re-derived from every snapshot for the folder's reason: a card
// renamed mid-session is announced under its new name ("New comment on '⟨card⟩'").
.onChange(of: placement.card.title.value, initial: true) { _, title in
session.comments.cardTitle = title
}
.onChange(of: store.isReadOnly, initial: true) { _, locked in
attachments.isEditable = !locked
session.comments.isEditable = !locked
}
// **The thread re-reads on every applied snapshot** (05 ▸ The comments column: "the pane
// reloads its thread from the same FSEvents stream").
//
// *Any* reload, not a filtered one, and that is a deliberate choice worth stating: the
// store's observable surface publishes `snapshotGeneration` and a `BoardModel` — it does
// not vend the changed paths, and comments are outside the snapshot entirely
// (01-storage-format.md § Enhanced schema), so there is nothing to filter *on* here.
// Re-reading one card's thread is a handful of small files and happens only while a card
// window is open; filtering would mean either widening the store's surface to carry paths,
// or the pane keeping its own watcher — a second stream over the same tree, which the
// one-way flow rules out. The path shape is read on the other side of the re-read instead,
// where there *are* two pictures to compare: the pane diffs its threads and consumes the
// ledger's comment receipts through `CommentPath.classify` to tell a foreign arrival from
// its own echo (`CardComments.reload`). `initial:` is deliberately absent: `start()`
// already did the opening read, after the residue sweep that has to precede it.
.onChange(of: store.snapshotGeneration) { _, _ in
session.comments.reload()
}
} 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()
openCommentThread(store: store)
}
/// **The card window's open, comment-side** — the crash-residue sweep, then the thread read
/// (01-storage-format.md § Enhanced schema: "crash residue sweeps at the next card-window open,
/// armed-then-cleared like every heal memo").
///
/// It runs from `start()` rather than from a `.task` on the pane, and the reason is the pane's
/// own visibility: Show Comments off means no pane at all, and the residue of a session that died
/// mid-delete must still be swept — it is the app's leftovers, not a feature of the pane. The
/// same goes for the close purge, which rides the session's end for the same reason.
///
/// The pane's two window-scoped facts are set *before* the read, because both of them are things
/// the read's results are resolved against: the folder every comment's attachments hang off, and
/// whether the lock is on.
private func openCommentThread(store: BoardStore) {
if case let .shows(placement) = Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot) {
session.comments.cardFolder = Self.cardFolder(root: store.rootURL, placement: placement)
}
session.comments.isEditable = !store.isReadOnly
session.comments.open()
}
/// 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()
}
bodyPresentation.beginEdits = { [session] in
session.body.beginEditSession()
}
// **The stage-around registry's one wire** (06-history-undo.md ▸ Rules ▸ Auto-commit). The
// buffer announces its session boundary, the app model knows which board this card belongs
// to, and the committer knows what staging is; this line is the join, and it is the only
// place all three are in scope. A free-tier board — or any board with no repository — has no
// committer, so `setEditSession` records nothing and the buffer never learns the difference.
session.body.editSessionDidChange = { [appModel, ref] isEditing in
appModel.setEditSession(isEditing, for: ref)
}
Self.configureRawSource(
rawSource,
body: session.body,
presentation: bodyPresentation,
store: store,
cardID: cardID
)
// The other half of the outlet's wiring: the session answers for this window's unsaved
// content, and the outlet is the half that does not live inside it (`CardWindowSession`).
session.rawSourceHoldsUnsavedText = { [rawSource] in rawSource.holdsUnsavedText }
// The save-or-discard step's half of the same wiring (06-history-undo.md ▸ Branch switching):
// Save All *applies* an open outlet — validation included, so a refusal cancels the whole
// operation — and Discard leaves it without writing.
session.rawSourceIsActive = { [rawSource] in rawSource.isActive }
session.rawSourceApply = { [rawSource] in rawSource.applyAndLeave() }
session.rawSourceCancel = { [rawSource] in rawSource.cancel() }
Self.configureAttachments(attachments, store: store, cardID: cardID)
Self.configureComments(session.comments, store: store, cardID: cardID)
}
/// Points the comments pane at its card — **the one place every comment gesture learns which card
/// it acts on** (05-card-window.md ▸ The comments column).
///
/// Every seam is one of the store's own bracketed methods, unchanged, which is the same rule the
/// attachments section keeps: there is deliberately no comment write of this window's own to keep
/// in step with the store's, so a post made here and a post made by anything else take one path —
/// one bracket, one undo step, one commit shape.
///
/// The store is captured **weakly**, `configureSession`'s rule: a save still landing after the
/// board window has gone should write nothing rather than resurrect a released store. A `nil`
/// store answers what a vanished card answers — an empty thread, a save that did not land, a
/// delete that did not happen — which is exactly what the pane's own guards expect.
///
/// `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 configureComments(_ comments: CardComments, store: BoardStore, cardID: ItemID) {
comments.readThread = { [weak store] in store?.commentThread(inCard: cardID) ?? .empty }
comments.readDraft = { [weak store] in store?.commentDraft(inCard: cardID) }
comments.sweepTrashResidue = { [weak store] in store?.sweepCommentTrashResidue(inCard: cardID) }
comments.purgeTrash = { [weak store] in store?.purgeCommentTrash(inCard: cardID) }
// Detection is the thread read's, the repair is the store's batch, and the notice is the
// banner surface's — "the relocation-style warning-tone notice names the repair". This
// closure is only the join, which is why it is three lines and lives here rather than on
// either side of it.
comments.displaceSquatters = { [weak store] squatters in
guard let store else { return }
store.banners.postDisplacedClaimedNames(store.displaceCommentClaimedNames(squatters))
}
comments.deleteComment = { [weak store] id in
store?.deleteComment(id, inCard: cardID) ?? false
}
comments.editComment = { [weak store] id, body in
store?.editComment(id, inCard: cardID, body: body) ?? false
}
comments.importAttachments = { [weak store] urls, target in
store?.importCommentAttachments(urls, inCard: cardID, target: target)
}
comments.removeAttachment = { [weak store] name, target in
store?.removeCommentAttachment(named: name, inCard: cardID, target: target)
}
comments.composer.save = { [weak store] text in
store?.saveCommentDraft(inCard: cardID, body: text)
}
comments.composer.post = { [weak store] in
store?.postComment(inCard: cardID)
}
// The announcer's gate: which of this thread's changes the app itself wrote, consumed once per
// reload (10-accessibility.md — "app-mediated echoes never announce", per comment). A store
// that has gone vouches for nothing, which is the conservative direction and also the one the
// announcement cannot reach anyway — a released store has no window left to speak in.
comments.vouchedComments = { [weak store] in
store?.vouchedComments(inCard: 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)
}
}
}