The titlebar widget grows from a 20×18 chevron into one button saying the board's name and, on a git-mode Pro board, its branch — click anywhere across it and the popover opens as before, anchored to the widget. BoardInfoTitlebarSummary is the pure seam for both strings (title falls back to the folder name per 01's naming rule; branch only under pro + git mode, live off the observable HistoryStore.branch). Board windows now hide the system title display through the same hideTitle slot card windows adopted — the widget says the name, so the chrome would only repeat it — while navigationTitle keeps feeding window.title to the Window menu, Exposé, VoiceOver and restoration. The widget also refreshes the branch eagerly at appearance: it used to populate only once the popover had been opened, which would have left the new branch line empty on a freshly opened board. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
982 lines
57 KiB
Swift
982 lines
57 KiB
Swift
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
|
||
|
||
/// **This window's own undo stack** — 13-native-undo.md ▸ Rules' second level (re-ruled
|
||
/// 2026-07-31): "a card window owns its own stack for the session it represents ... and
|
||
/// `window.undoManager` answers with it".
|
||
///
|
||
/// It lives here for the comments pane's reason exactly: the close owes the board one coarse step
|
||
/// folded from this stack, and a stack held only by the view would be gone by the time the fold
|
||
/// ran. Every window gesture registers into it through the store's own methods, which take it as
|
||
/// a parameter (`CardWindowUndo`).
|
||
let undo = CardWindowUndo()
|
||
|
||
/// 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)?
|
||
|
||
/// **Where the close registers this session as one board step** —
|
||
/// `BoardStore.registerCardSession(_:inCard:retiring:)`, wired by the host for
|
||
/// `CardBodyEditSession.save`'s reason: this object is a lifecycle, and it stays testable by
|
||
/// having no idea what a board is.
|
||
///
|
||
/// It answers whether the deferred `comments/.trash/` purge now has an owner — see `endSession()`.
|
||
/// `nil` (a window that never joined its board) means nothing was registered, so the purge is this
|
||
/// object's to run, which is also true.
|
||
var registerSessionStep: (@MainActor (CardWindowUndo, @escaping @MainActor () -> Void) -> 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. It is also where the body's *last* fine step joins this window's stack, which is
|
||
// why it has to precede the fold below.
|
||
body.endEditSession()
|
||
// **The saves, in the order the comments build fixed**: the inline session's flush, then the
|
||
// draft's (`CardComments.endSession`). Both may register their own last fine step, so both
|
||
// land before the fold.
|
||
comments.endSession()
|
||
// **The coarse close step, and the purge it defers** (13-native-undo.md ▸ Rules ▸ "Window
|
||
// close coarsens"; ▸ Interaction with the trash).
|
||
//
|
||
// This is the one place that knows both halves: the window's stack, which is the session's
|
||
// net effect, and the `comments/.trash/` purge, which must not run while a board step's undo
|
||
// still restores comments out of it. Registering answers whether the step took the purge on —
|
||
// and a board whose substrate keeps no steps has already run it by the time that answer comes
|
||
// back, which is how Pro keeps purging at the close flush without a word about tiers here.
|
||
let purge: @MainActor () -> Void = { [comments] in comments.purgeTrashNow() }
|
||
if registerSessionStep?(undo, purge) != true {
|
||
purge()
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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,
|
||
undo: session.undo,
|
||
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)
|
||
}
|
||
Self.configureUndo(session, store: store, cardID: cardID)
|
||
bodyPresentation.flushEdits = { [session] in
|
||
session.body.endEditSession()
|
||
}
|
||
bodyPresentation.beginEdits = { [session] in
|
||
session.body.beginEditSession()
|
||
}
|
||
// **No stage-around wire here any more** (06-history-undo.md ▸ Rules ▸ Auto-commit, widened
|
||
// 2026-07-31 — recorded because its absence is the change): the Edit→Preview flip used to open
|
||
// and close the committer's exclusion, and the unit is now the *window*, so the exclusion is
|
||
// opened by `AppModel.registerCardWindow` and released by `unregisterCardWindow` — after the
|
||
// session's own last writes. A flip that still moved it would un-hold the folder in the middle
|
||
// of a session whose comment posts and draft saves are supposed to be inside one commit.
|
||
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, on: session.undo)
|
||
}
|
||
|
||
/// Points this window's session at **its own undo stack** — the three seams the two-level model
|
||
/// is made of (13-native-undo.md ▸ Rules, re-ruled 2026-07-31).
|
||
///
|
||
/// 1. the body Edit session's one step registers on *this window's* stack, not the board's;
|
||
/// 2. the window's Undo/Redo disable under the board's read-only lock, and the stack survives it;
|
||
/// 3. the close folds the window's stack into one coarse board step, which then owes the deferred
|
||
/// `comments/.trash/` purge.
|
||
///
|
||
/// The store is captured **weakly**, `configureSession`'s rule: a session ending after the board
|
||
/// window has gone registers nothing rather than resurrecting a released store — and a window with
|
||
/// no board keeps the purge itself, which is what the `false` says.
|
||
///
|
||
/// `static`, and taking every collaborator as a parameter, for `configureComments`' reason: which
|
||
/// stack a gesture lands on 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 configureUndo(_ session: CardWindowSession, store: BoardStore, cardID: ItemID) {
|
||
session.body.registerUndo = { [weak store, undo = session.undo] priorBody, newBody in
|
||
store?.registerBodyEdit(inCard: cardID, priorBody: priorBody, newBody: newBody, on: undo)
|
||
}
|
||
session.undo.isReadOnly = { [weak store] in store?.isReadOnly ?? false }
|
||
session.registerSessionStep = { [weak store] undo, purge in
|
||
store?.registerCardSession(undo, inCard: cardID, retiring: purge) ?? false
|
||
}
|
||
}
|
||
|
||
/// 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.
|
||
///
|
||
/// - Parameter undo: **this window's stack** — where every comment gesture's fine step lands
|
||
/// (13-native-undo.md ▸ Rules ▸ two levels). Not optional and not defaulted: a comments pane
|
||
/// only ever exists inside a card window, so a call with no window would be a call with no
|
||
/// answer to which stack it meant.
|
||
static func configureComments(
|
||
_ comments: CardComments,
|
||
store: BoardStore,
|
||
cardID: ItemID,
|
||
on undo: CardWindowUndo
|
||
) {
|
||
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, on: undo) ?? false
|
||
}
|
||
comments.editComment = { [weak store] id, body in
|
||
store?.editComment(id, inCard: cardID, body: body) ?? false
|
||
}
|
||
comments.registerCommentEdit = { [weak store] id, prior, new in
|
||
store?.registerCommentEdit(id, inCard: cardID, priorBody: prior, newBody: new, on: undo)
|
||
}
|
||
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, on: undo)
|
||
}
|
||
// 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)
|
||
)
|
||
|
||
// **No title in the title bar** — the card's name is shown as part of the card's body
|
||
// instead (the large-title text at the top of the body column, `bodyColumn`), so the chrome
|
||
// does not say it twice. `window.title` itself is untouched — `.navigationTitle(windowTitle)`
|
||
// on this view still sets it every time the card renames or a new card's window opens — so
|
||
// the Window menu, Mission Control/Exposé, VoiceOver and state restoration all keep naming
|
||
// this window correctly; only the title *bar's* rendering of that string is suppressed
|
||
// (`HostedWindowController.hideTitle`). Board windows call the same thing now, for the same
|
||
// reason, once their board-popover widget has a name of its own to say
|
||
// (`BoardWindowHost.configureWindow`) — only the restore-bootstrap window still keeps
|
||
// AppKit's `.visible` default.
|
||
windowController.hideTitle()
|
||
|
||
// **This window's own stack** (13-native-undo.md ▸ Rules ▸ two levels, re-ruled 2026-07-31 —
|
||
// superseding the shared-stack wiring): "a card window owns its own stack for the session it
|
||
// represents ... and `window.undoManager` answers with it (standard per-window AppKit
|
||
// scoping)". ⌘Z with this window in front walks the gestures made *here*, newest first, and
|
||
// when they run out it beeps — "no fall-through: exhausting the window's stack ... never
|
||
// reaches board history" (06-history-undo.md ▸ Undo routing). What board history gets is the
|
||
// one coarse step this session registers when the window closes.
|
||
//
|
||
// 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
|
||
// above either stack (06 ▸ Undo routing, unchanged).
|
||
windowController.windowUndoManager = { [session] in session.undo.manager }
|
||
|
||
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 needs the store it is committing through, and a refcount that hit zero
|
||
/// first would have stopped the watcher underneath it.
|
||
///
|
||
/// **Unregistering rides behind it too** (06-history-undo.md ▸ Rules ▸ Auto-commit: "window close
|
||
/// flushes the session as one commit"), which is new in this milestone and is the whole ordering
|
||
/// the one-commit rule rests on: unregistering is what releases the committer's stage-around, and
|
||
/// releasing it before `endSession()` had written the body's last keystrokes, posted the draft and
|
||
/// purged `comments/.trash/` would leave a debounce free to fire over a half-finished session —
|
||
/// two commits where the design promises one. The board's own close flush drives the same two
|
||
/// steps in the same order through `CloseFlushCoordinator`, one window at a time.
|
||
private func finish() {
|
||
guard case let .open(store) = phase else { return }
|
||
phase = .closing
|
||
Task { @MainActor in
|
||
await session.endSession()
|
||
appModel.unregisterCardWindow(ref)
|
||
appModel.storeRegistry.release(store)
|
||
}
|
||
}
|
||
}
|