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 } } /// `//` — 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) } } }