Every settled change on a git-mode board commits, debounced 2s past drag/typing churn, staged whole-root with .gitignore respected. GitCommitOperation reaches the vendored libgit2 directly (same 1.9.2 pin SwiftGitX resolves — importable, not duplicated) for signature-capable commits; add-git's config materialization is gone, identity resolves at commit time (repo-local config, else derived default) per the 2026-07-31 ruling in 06. CommitAttribution classifies per file off EchoLedger receipts: user identity on app-mediated windows, Lanework External <[email protected]> on foreign, the modified-by refinement (<slug>@agents.lanework .invalid) when every foreign file agrees, heal-marked receipts split into their own commit — window split foreign → heal → user. Edit-session granularity: ~700ms saves stay uncommitted, staging excludes open session folders (closure-resolved so mid-session moves stage around the new location), session end nudges the debounce so each session lands exactly one body commit. Flush-before-overwrite gates on known-foreign windows and commits synchronously ahead of the write; close/quit flush the pipeline via CloseFlushCoordinator's committerFlush. index.lock backs off briefly then re-debounces silently; clean tree no-ops; genuine failures ride the standing history-suspension banner and retry next debounce. Abnormal repo states (detached HEAD, merge/rebase/cherry-pick in progress) hold the engine with a 15s re-check; unborn HEAD commits "Initial board state" whole-tree; dirty tree at open catches up through the same engine. Message seam (CommitMessageComposing) ships interim — the semantic composer is the next card. Discovery diffs HEAD against an in-memory index with rename detection (git status alone never pairs a bare mv), and a failed survey reads as "could not look", never "nothing changed". 46 new tests / 8 suites, all real repositories via bundled libgit2. 2240 tests / 383 suites green; InertGitTests untouched. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
809 lines
46 KiB
Swift
809 lines
46 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
|
||
|
||
/// 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 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
|
||
}
|
||
|
||
private var hasEnded = false
|
||
|
||
/// Both `let`s, wired to each other through a local — the guard's two closures need the buffer,
|
||
/// and a stored property cannot be referenced from another's initializer.
|
||
///
|
||
/// (They are also `let` rather than `lazy var` for a SwiftUI reason worth recording: `@State`
|
||
/// projects a `Binding` through dynamic member lookup for every *settable* property of its
|
||
/// value, so a `lazy var` here would make `session.bufferGuard` at the call site resolve to a
|
||
/// binding rather than to the guard.)
|
||
init() {
|
||
let body = CardBodyEditSession()
|
||
self.body = body
|
||
bufferGuard = DirtyBufferGuard(
|
||
attemptSave: { () throws(BoardWriteError) -> Void in try body.flushOrThrow() },
|
||
// "Save a copy elsewhere" writes the *buffer*, not the card: the destination is
|
||
// somewhere outside the board the user picked in a panel, so what lands there is the
|
||
// text they were typing, as a file, and nothing about frontmatter or identity travels
|
||
// with it.
|
||
writeCopy: { url in try Data(body.text.utf8).write(to: url) }
|
||
)
|
||
}
|
||
|
||
func endSession() async {
|
||
guard !hasEnded else { return }
|
||
hasEnded = true
|
||
// pro-m1: this is the boundary the auto-committer coalesces on — one commit per Edit
|
||
// session, "never per save tick" (06-history-undo.md ▸ Rules ▸ Auto-commit). The debounced
|
||
// saves inside the session are ordinary bracketed writes; what makes them one commit is that
|
||
// the committer's own debounce outlives them and this call is where the session is known to
|
||
// be over.
|
||
body.endEditSession()
|
||
// **Saves first, purge last** — the inline comment session's flush and the draft's save land
|
||
// before `comments/.trash/` is emptied, which is the order that keeps the purge from removing
|
||
// a folder a save was about to write into (`CardComments.endSession`). It runs after the
|
||
// body's for the same reason it runs at all: this is the one place the window's whole close
|
||
// work has a fixed order.
|
||
comments.endSession()
|
||
}
|
||
}
|
||
|
||
// MARK: - CardWindowHost
|
||
|
||
/// One card window (05-card-window.md).
|
||
///
|
||
/// ### Its whole identity is `(board, card)`
|
||
///
|
||
/// Which is why this host is mostly a set of dismissal rules. The window follows its card between
|
||
/// lanes for free — the key names neither — and it dismisses whenever the key stops naming a card
|
||
/// **on the board**: the card moved into `.trash/` ("entering the trash counts as deleted" —
|
||
/// 05-card-window.md ▸ Deletion & lifecycle, resettled 2026-07-28), its *lane* was deleted and took
|
||
/// it along, or the card is simply not in this board's snapshot any more, which is what a cross-board
|
||
/// move looks like from here.
|
||
///
|
||
/// ### It can never outlive its board window
|
||
///
|
||
/// "The board window owns the board" (02 § Components) — so a card window whose board has no live
|
||
/// store, or whose board session has gone, dismisses immediately rather than becoming an orphan with
|
||
/// a store it acquired by itself. That covers the ordinary case (the board window closed and its
|
||
/// flush dismissed this one) and the odd one (the system restoring a card window from a previous
|
||
/// launch, which scene restoration is disabled precisely to prevent).
|
||
///
|
||
/// ### It is the window; `CardWindowView` is the content
|
||
///
|
||
/// This file owns identity, lifecycle, the title and subtitle, and where the window opens. The
|
||
/// two-column composition inside it is `CardWindowView`'s, and what fills those columns — the title
|
||
/// field, Preview/Edit, the sidebar's five sections — arrives card by card underneath a composition
|
||
/// that does not move. The *window-scoped* state those surfaces need lives here, because a window is
|
||
/// what it is scoped to: the body column's mode (`CardBodyPresentation`) and the raw-source outlet
|
||
/// (`CardRawSourceSession`), both published through the focus system so the View menu's rows can
|
||
/// reach the frontmost card window.
|
||
struct CardWindowHost: View {
|
||
|
||
let ref: CardWindowRef
|
||
|
||
@Environment(AppModel.self) private var appModel
|
||
@Environment(\.dismissWindow) private var dismissWindow
|
||
|
||
@State private var windowController = HostedWindowController()
|
||
@State private var session = CardWindowSession()
|
||
@State private var phase: Phase = .opening
|
||
/// This window's body column — the Preview/Edit mode, and the ⌘F hook a menu item reaches
|
||
/// through the focus system (`CardBodyPresentation`).
|
||
@State private var bodyPresentation = CardBodyPresentation()
|
||
/// This window's raw-source outlet — the whole-content-area swap View ▸ Raw Source (⌥⌘E) drives
|
||
/// (`CardRawSourceSession`). Beside the body handle rather than inside it: the two are different
|
||
/// scopes, and the Edit Body row reads both.
|
||
@State private var rawSource = CardRawSourceSession()
|
||
/// This window's attachments section — the listing, the keyboard selection, and the two writes
|
||
/// it starts (05-card-window.md ▸ Attachments). Window-scoped for `CardBodyPresentation`'s
|
||
/// reason: two card windows on one board have two different selections, and the menu bar reaches
|
||
/// the frontmost one through the focus system.
|
||
@State private var attachments = CardAttachments()
|
||
/// This window's thumbnail memory. Held here rather than in the section so it survives every
|
||
/// snapshot the store applies — a cache that died with the view would regenerate every thumbnail
|
||
/// on every reload (`AttachmentThumbnailCache`).
|
||
@State private var thumbnails = AttachmentThumbnailCache()
|
||
/// 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() }
|
||
}
|
||
|
||
/// **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,
|
||
fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id),
|
||
onToggleTask: { offset, checked in
|
||
store.toggleTaskMarker(inCard: placement.card.id, bodyOffset: offset, checked: checked)
|
||
}
|
||
)
|
||
// **The listing is the snapshot's, republished** — `Card.attachments`, which the loader
|
||
// fills from `attachments/`'s top-level files in Finder order. Every write in the
|
||
// section is bracketed, so the reload that refreshes this arrives by itself and the
|
||
// section never lists a directory of its own (05 ▸ Attachments; the one-way flow).
|
||
.onChange(of: placement.card.attachments, initial: true) { _, names in
|
||
attachments.names = names
|
||
}
|
||
.onChange(of: Self.cardFolder(root: store.rootURL, placement: placement), initial: true) { _, folder in
|
||
// Re-derived from the store's *current* root, `cardFolder`'s rule: a mid-session
|
||
// folder rename moves the board, and rows resolving against where it used to be
|
||
// would open nothing.
|
||
attachments.cardFolder = folder
|
||
session.comments.cardFolder = folder
|
||
}
|
||
// The announcer's subject, re-derived from every snapshot for the folder's reason: a card
|
||
// renamed mid-session is announced under its new name ("New comment on '⟨card⟩'").
|
||
.onChange(of: placement.card.title.value, initial: true) { _, title in
|
||
session.comments.cardTitle = title
|
||
}
|
||
.onChange(of: store.isReadOnly, initial: true) { _, locked in
|
||
attachments.isEditable = !locked
|
||
session.comments.isEditable = !locked
|
||
}
|
||
// **The thread re-reads on every applied snapshot** (05 ▸ The comments column: "the pane
|
||
// reloads its thread from the same FSEvents stream").
|
||
//
|
||
// *Any* reload, not a filtered one, and that is a deliberate choice worth stating: the
|
||
// store's observable surface publishes `snapshotGeneration` and a `BoardModel` — it does
|
||
// not vend the changed paths, and comments are outside the snapshot entirely
|
||
// (01-storage-format.md § Enhanced schema), so there is nothing to filter *on* here.
|
||
// Re-reading one card's thread is a handful of small files and happens only while a card
|
||
// window is open; filtering would mean either widening the store's surface to carry paths,
|
||
// or the pane keeping its own watcher — a second stream over the same tree, which the
|
||
// one-way flow rules out. The path shape is read on the other side of the re-read instead,
|
||
// where there *are* two pictures to compare: the pane diffs its threads and consumes the
|
||
// ledger's comment receipts through `CommentPath.classify` to tell a foreign arrival from
|
||
// its own echo (`CardComments.reload`). `initial:` is deliberately absent: `start()`
|
||
// already did the opening read, after the residue sweep that has to precede it.
|
||
.onChange(of: store.snapshotGeneration) { _, _ in
|
||
session.comments.reload()
|
||
}
|
||
} else {
|
||
// Nothing to render and nothing worth animating: this window is on its way out.
|
||
Color.clear
|
||
}
|
||
}
|
||
|
||
/// `<root>/<lane>/<card>` — the card's own folder, which is what its body's relative images and
|
||
/// links resolve against (05-card-window.md ▸ Preview).
|
||
///
|
||
/// Built off the store's *current* `rootURL` rather than the ref's captured one, for
|
||
/// `BoardStore.boardItem`'s reason: a mid-session folder rename moves the board, and a preview
|
||
/// resolving images against where the board used to be would quietly stop showing them.
|
||
static func cardFolder(root: URL, placement: CardPlacement) -> URL {
|
||
root
|
||
.appendingPathComponent(placement.lane.id.rawValue, isDirectory: true)
|
||
.appendingPathComponent(placement.card.id.rawValue, isDirectory: true)
|
||
}
|
||
|
||
/// Where this window's card is in this board's snapshot, or `nil` when it is not — which is the
|
||
/// same condition `shouldDismiss` reads, one moment before the window goes.
|
||
private var placement: CardPlacement? {
|
||
guard case let .open(store) = phase,
|
||
case let .shows(placement) = Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot)
|
||
else { return nil }
|
||
return placement
|
||
}
|
||
|
||
/// The card's title, with the face's own untitled rendering — a card with no `title` key is
|
||
/// "Untitled" in the title bar and the Window menu, never a blank strip.
|
||
private var windowTitle: String {
|
||
guard let placement else { return "" }
|
||
return placement.card.title.value ?? "Untitled"
|
||
}
|
||
|
||
private var windowSubtitle: String {
|
||
guard case let .open(store) = phase, let placement else { return "" }
|
||
return Self.subtitle(
|
||
board: AppModel.displayName(of: store),
|
||
lane: placement.lane.title.value
|
||
)
|
||
}
|
||
|
||
/// The dismissal decision, re-evaluated on every snapshot the store applies.
|
||
///
|
||
/// Two clauses, and the second is the safety net: the board's session vanishing means the board
|
||
/// window has finished tearing down, and a card window still on screen at that point has nothing
|
||
/// behind it. It is deliberately redundant with the close flush, which dismisses these windows
|
||
/// itself — a net is only useful when the thing it backs up has already failed.
|
||
private var shouldDismiss: Bool {
|
||
guard case let .open(store) = phase else { return false }
|
||
guard appModel.session(for: ref.board) != nil else { return true }
|
||
return Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot) == .dismisses
|
||
}
|
||
|
||
// MARK: - Opening
|
||
|
||
/// Joins the board's session, or dismisses.
|
||
///
|
||
/// **`liveStore(for:)` first, and no fallback to `acquire` on a closed board.** A card window
|
||
/// never opens a board: doing so would put a store — and a watcher — behind a window that,
|
||
/// by 02's ownership rule, is not allowed to exist. The `acquire` below can only hit the
|
||
/// already-open path, which is why its failure is logged rather than surfaced.
|
||
private func start() {
|
||
guard case .opening = phase else { return }
|
||
|
||
guard appModel.storeRegistry.liveStore(for: ref.boardURL) != nil else {
|
||
Self.logger.debug("card window has no live board — dismissing")
|
||
phase = .closing
|
||
dismissWindow(id: WindowID.card, value: ref)
|
||
return
|
||
}
|
||
|
||
let store: BoardStore
|
||
do throws(BoardLoadError) {
|
||
store = try appModel.storeRegistry.acquire(ref.boardURL)
|
||
} catch {
|
||
Self.logger.error("card window could not acquire its board: \(error.description, privacy: .public)")
|
||
phase = .closing
|
||
dismissWindow(id: WindowID.card, value: ref)
|
||
return
|
||
}
|
||
|
||
appModel.registerCardWindow(ref, session: session)
|
||
phase = .open(store)
|
||
configureSession(store: store)
|
||
configureWindow()
|
||
openCommentThread(store: store)
|
||
}
|
||
|
||
/// **The card window's open, comment-side** — the crash-residue sweep, then the thread read
|
||
/// (01-storage-format.md § Enhanced schema: "crash residue sweeps at the next card-window open,
|
||
/// armed-then-cleared like every heal memo").
|
||
///
|
||
/// It runs from `start()` rather than from a `.task` on the pane, and the reason is the pane's
|
||
/// own visibility: Show Comments off means no pane at all, and the residue of a session that died
|
||
/// mid-delete must still be swept — it is the app's leftovers, not a feature of the pane. The
|
||
/// same goes for the close purge, which rides the session's end for the same reason.
|
||
///
|
||
/// The pane's two window-scoped facts are set *before* the read, because both of them are things
|
||
/// the read's results are resolved against: the folder every comment's attachments hang off, and
|
||
/// whether the lock is on.
|
||
private func openCommentThread(store: BoardStore) {
|
||
if case let .shows(placement) = Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot) {
|
||
session.comments.cardFolder = Self.cardFolder(root: store.rootURL, placement: placement)
|
||
}
|
||
session.comments.isEditable = !store.isReadOnly
|
||
session.comments.open()
|
||
}
|
||
|
||
/// Points this window's Edit buffer at its card, and the mode flip at the buffer.
|
||
///
|
||
/// Both are seams the two types deliberately leave open (`CardBodyEditSession.save`,
|
||
/// `CardBodyPresentation.flushEdits`) so that neither the buffer nor the mode has to know what a
|
||
/// board is — this is the one place that knows both, which is also the only place that could
|
||
/// wire them wrongly, and it is four lines long.
|
||
///
|
||
/// The store is captured **weakly**: it outlives this window by refcount, not by ownership, and a
|
||
/// debounced save that fired after the board had gone should write nothing rather than resurrect
|
||
/// a store the registry has released.
|
||
private func configureSession(store: BoardStore) {
|
||
let cardID = ref.cardIdentity
|
||
session.body.save = { [weak store] text in
|
||
guard let store else { return .vanished }
|
||
return store.writeCardBody(inCard: cardID, body: text)
|
||
}
|
||
// The session's one undo step, at the Edit→Preview flip (13-native-undo.md ▸ Rules). Weakly,
|
||
// `save`'s rule: a session ending after the board window has gone registers nothing rather
|
||
// than resurrecting a released store — and the board's stack died with it anyway.
|
||
session.body.registerUndo = { [weak store] priorBody, newBody in
|
||
store?.registerBodyEdit(inCard: cardID, priorBody: priorBody, newBody: newBody)
|
||
}
|
||
bodyPresentation.flushEdits = { [session] in
|
||
session.body.endEditSession()
|
||
}
|
||
bodyPresentation.beginEdits = { [session] in
|
||
session.body.beginEditSession()
|
||
}
|
||
// **The stage-around registry's one wire** (06-history-undo.md ▸ Rules ▸ Auto-commit). The
|
||
// buffer announces its session boundary, the app model knows which board this card belongs
|
||
// to, and the committer knows what staging is; this line is the join, and it is the only
|
||
// place all three are in scope. A free-tier board — or any board with no repository — has no
|
||
// committer, so `setEditSession` records nothing and the buffer never learns the difference.
|
||
session.body.editSessionDidChange = { [appModel, ref] isEditing in
|
||
appModel.setEditSession(isEditing, for: ref)
|
||
}
|
||
Self.configureRawSource(
|
||
rawSource,
|
||
body: session.body,
|
||
presentation: bodyPresentation,
|
||
store: store,
|
||
cardID: cardID
|
||
)
|
||
// The other half of the outlet's wiring: the session answers for this window's unsaved
|
||
// content, and the outlet is the half that does not live inside it (`CardWindowSession`).
|
||
session.rawSourceHoldsUnsavedText = { [rawSource] in rawSource.holdsUnsavedText }
|
||
Self.configureAttachments(attachments, store: store, cardID: cardID)
|
||
Self.configureComments(session.comments, store: store, cardID: cardID)
|
||
}
|
||
|
||
/// Points the comments pane at its card — **the one place every comment gesture learns which card
|
||
/// it acts on** (05-card-window.md ▸ The comments column).
|
||
///
|
||
/// Every seam is one of the store's own bracketed methods, unchanged, which is the same rule the
|
||
/// attachments section keeps: there is deliberately no comment write of this window's own to keep
|
||
/// in step with the store's, so a post made here and a post made by anything else take one path —
|
||
/// one bracket, one undo step, one commit shape.
|
||
///
|
||
/// The store is captured **weakly**, `configureSession`'s rule: a save still landing after the
|
||
/// board window has gone should write nothing rather than resurrect a released store. A `nil`
|
||
/// store answers what a vanished card answers — an empty thread, a save that did not land, a
|
||
/// delete that did not happen — which is exactly what the pane's own guards expect.
|
||
///
|
||
/// `static`, and taking every collaborator as a parameter, for `configureRawSource`'s reason: the
|
||
/// target resolution is invisible in a running window until it is wrong, and this shape is what
|
||
/// lets a test drive the real wiring rather than a re-typed copy of it.
|
||
static func configureComments(_ comments: CardComments, store: BoardStore, cardID: ItemID) {
|
||
comments.readThread = { [weak store] in store?.commentThread(inCard: cardID) ?? .empty }
|
||
comments.readDraft = { [weak store] in store?.commentDraft(inCard: cardID) }
|
||
comments.sweepTrashResidue = { [weak store] in store?.sweepCommentTrashResidue(inCard: cardID) }
|
||
comments.purgeTrash = { [weak store] in store?.purgeCommentTrash(inCard: cardID) }
|
||
// Detection is the thread read's, the repair is the store's batch, and the notice is the
|
||
// banner surface's — "the relocation-style warning-tone notice names the repair". This
|
||
// closure is only the join, which is why it is three lines and lives here rather than on
|
||
// either side of it.
|
||
comments.displaceSquatters = { [weak store] squatters in
|
||
guard let store else { return }
|
||
store.banners.postDisplacedClaimedNames(store.displaceCommentClaimedNames(squatters))
|
||
}
|
||
comments.deleteComment = { [weak store] id in
|
||
store?.deleteComment(id, inCard: cardID) ?? false
|
||
}
|
||
comments.editComment = { [weak store] id, body in
|
||
store?.editComment(id, inCard: cardID, body: body) ?? false
|
||
}
|
||
comments.importAttachments = { [weak store] urls, target in
|
||
store?.importCommentAttachments(urls, inCard: cardID, target: target)
|
||
}
|
||
comments.removeAttachment = { [weak store] name, target in
|
||
store?.removeCommentAttachment(named: name, inCard: cardID, target: target)
|
||
}
|
||
comments.composer.save = { [weak store] text in
|
||
store?.saveCommentDraft(inCard: cardID, body: text)
|
||
}
|
||
comments.composer.post = { [weak store] in
|
||
store?.postComment(inCard: cardID)
|
||
}
|
||
// The announcer's gate: which of this thread's changes the app itself wrote, consumed once per
|
||
// reload (10-accessibility.md — "app-mediated echoes never announce", per comment). A store
|
||
// that has gone vouches for nothing, which is the conservative direction and also the one the
|
||
// announcement cannot reach anyway — a released store has no window left to speak in.
|
||
comments.vouchedComments = { [weak store] in
|
||
store?.vouchedComments(inCard: cardID) ?? []
|
||
}
|
||
}
|
||
|
||
/// Points the attachments section at its card — **the one place Add Attachment… and Remove
|
||
/// learn which card they act on** (05-card-window.md ▸ Attachments).
|
||
///
|
||
/// Both seams are the store's own bracketed methods, unchanged: `importAttachments(_:toCard:)`
|
||
/// is the *same* call the board window's Finder drop makes, so a file added through ⇧⌘A, through
|
||
/// the header's plus, through a drop anywhere in this window, and through a drop on the card's
|
||
/// face on the board all take one path — one collision rename, one set of banners, one commit
|
||
/// shape. There is deliberately no card-window import of its own to keep in step with it.
|
||
///
|
||
/// The store is captured **weakly**, `configureSession`'s rule: a panel still running after the
|
||
/// board window has gone should write nothing rather than resurrect a released store.
|
||
///
|
||
/// `static`, and taking every collaborator as a parameter, for `configureRawSource`'s reason:
|
||
/// the target resolution is invisible in a running window until it is wrong, and this shape is
|
||
/// what lets a test drive the real wiring rather than a re-typed copy of it.
|
||
static func configureAttachments(_ attachments: CardAttachments, store: BoardStore, cardID: ItemID) {
|
||
attachments.importFiles = { [weak store] urls in
|
||
store?.importAttachments(urls, toCard: cardID)
|
||
}
|
||
attachments.removeFile = { [weak store] name in
|
||
store?.removeAttachment(named: name, fromCard: cardID)
|
||
}
|
||
}
|
||
|
||
/// Points the raw-source outlet at its card — the outlet's three seams (05-card-window.md ▸ Raw
|
||
/// source outlet), wired in the one place that knows both a buffer and a board.
|
||
///
|
||
/// **The flush is the Preview flip**, not a second mechanism: "Entering source mode flushes any
|
||
/// pending title/body edits first" and "Leaving Edit flushes the debounce (mode flip, raw-source
|
||
/// entry, window close)" are the same sentence read from two directions, so putting the entry
|
||
/// through `setMode(.preview)` makes the flush structural — and settles the exit state at the same
|
||
/// time, because a window that genuinely left Edit on the way in has Preview waiting for it on the
|
||
/// way out (`CardRawSourceSession`). The unconditional `flush()` behind it costs nothing on a
|
||
/// clean buffer and covers the case where the mode was already Preview with a save still owed (a
|
||
/// tick suspended under the read-only lock, say).
|
||
///
|
||
/// The store is captured **weakly**, `configureSession`'s rule: an outlet still holding a closure
|
||
/// after the board window has gone should write nothing rather than resurrect a released store.
|
||
///
|
||
/// `static`, and taking every collaborator as a parameter, for the reason the fate and subtitle
|
||
/// rules are: the ordering above is the whole of "flush, *then* read fresh", it is invisible in a
|
||
/// running window until it is wrong, and this shape is what lets a test drive the real wiring
|
||
/// rather than a re-typed copy of it.
|
||
static func configureRawSource(
|
||
_ rawSource: CardRawSourceSession,
|
||
body: CardBodyEditSession,
|
||
presentation: CardBodyPresentation,
|
||
store: BoardStore,
|
||
cardID: ItemID
|
||
) {
|
||
rawSource.flushPendingEdits = { [body, presentation] in
|
||
presentation.setMode(.preview)
|
||
body.flush()
|
||
}
|
||
rawSource.read = { [weak store] in
|
||
guard let store else { return .vanished }
|
||
return store.readCardSource(inCard: cardID)
|
||
}
|
||
rawSource.apply = { [weak store] text in
|
||
guard let store else { return .vanished }
|
||
return store.applyCardSource(inCard: cardID, text: text)
|
||
}
|
||
}
|
||
|
||
/// Size and placement — **the remembered frame first, the cascade second** (05-card-window.md
|
||
/// ▸ Window: "New windows open at the last-used card-window size, cascaded; frames restore per
|
||
/// card across relaunch where state restoration allows").
|
||
///
|
||
/// Two memories, and they answer different questions:
|
||
///
|
||
/// - **The last-used size is app-wide.** 02 § Per-board app state files "the last-used
|
||
/// card-window size" under App-wide state explicitly, so it lives in `AppPreferences` and a
|
||
/// card window opened on any board inherits the size of the last one the user sized.
|
||
/// - **The frame is per card**, and lives on the board's registry record beside the board
|
||
/// window's own frame — same home, same identity-keyed record, same files-first rule (nothing
|
||
/// app-private is ever written into the board folder). "Where state restoration allows" is
|
||
/// this: AppKit's scene restoration is disabled app-wide on purpose (`KanbanApp`), so a card
|
||
/// window does not come back by itself at relaunch — but the *next* time the user opens that
|
||
/// card, it opens where they left it.
|
||
///
|
||
/// A card with a remembered frame therefore does **not** cascade: a cascade over a deliberate
|
||
/// placement would move a window the user had already put somewhere. Only the windows with
|
||
/// nothing remembered take the running cascade point, which is what keeps a burst of freshly
|
||
/// opened cards from landing on top of each other.
|
||
private func configureWindow() {
|
||
// Read once here rather than per callback: this window's board has a session by now (the
|
||
// caller just registered against it), and the record id is what both memories are keyed on.
|
||
let recordID = appModel.session(for: ref.board)?.recordID
|
||
|
||
// The window's customizable toolbar — Edit Body · Raw Source · Add Attachment, "the
|
||
// window's three committed functions" (03-board-ui.md ▸ Toolbar; 05-card-window.md ▸
|
||
// Window). It carries the three window-scoped handles above rather than a store, which is
|
||
// why it is installed here and not at attach: those are this window's, and so is it.
|
||
windowController.installToolbar(
|
||
CardToolbar.controller(body: bodyPresentation, rawSource: rawSource, attachments: attachments)
|
||
)
|
||
|
||
// **The board's stack, not one of this window's own** (13-native-undo.md ▸ Rules: "not
|
||
// per-window: every window over a board (board window, its card windows) shares the store
|
||
// and shares the stack"). Same closure shape as the board window's, and deliberately the
|
||
// same object: ⌘Z with a card window in front crosses the board step the user last made,
|
||
// wherever they made it. The card's *text* surfaces are untouched by this — the body editor
|
||
// and the raw-source editor each vend their own manager to the responder chain, which is
|
||
// what keeps typing undo out of the board's stack (06-history-undo.md ▸ Undo routing).
|
||
windowController.boardUndoManager = { appModel.session(for: ref.board)?.undoManager }
|
||
|
||
windowController.onAttach = { window in
|
||
if let recordID,
|
||
let saved = appModel.boardRegistry.cardWindowFrame(id: recordID, cardID: ref.cardIdentity) {
|
||
// Repositioned onto a live screen when the saved one is gone — the board window's
|
||
// own rule, shared rather than restated (`HostedWindowController.placement`).
|
||
window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true)
|
||
return
|
||
}
|
||
if let size = AppPreferences.lastCardWindowSize {
|
||
window.setContentSize(size)
|
||
}
|
||
// `cascadeTopLeft(from:)` both places this window and returns the origin for the next
|
||
// one, so the running point is the whole cascade.
|
||
appModel.cardCascadePoint = window.cascadeTopLeft(from: appModel.cardCascadePoint)
|
||
}
|
||
if let window = windowController.window {
|
||
windowController.onAttach?(window)
|
||
}
|
||
|
||
// **The close flushes first** (05-card-window.md ▸ Edit: "flushed on leaving Edit, entering
|
||
// source mode, and window close"). Intercepting `windowShouldClose` rather than saving from
|
||
// `onDisappear` is what makes the failure case possible at all: by the time a window has
|
||
// disappeared there is nothing left to present a modal over, and the design's one modal
|
||
// moment is precisely a close that could not save (02-architecture.md § Write-failure
|
||
// surfacing).
|
||
windowController.onCloseRequested = { closeAfterFlushing() }
|
||
|
||
windowController.onFrameChanged = { frame in
|
||
if let recordID {
|
||
appModel.boardRegistry.updateCardWindowFrame(
|
||
id: recordID,
|
||
cardID: ref.cardIdentity,
|
||
frame: WindowFrame(x: frame.origin.x, y: frame.origin.y, width: frame.width, height: frame.height)
|
||
)
|
||
}
|
||
guard let window = windowController.window else { return }
|
||
let size = window.contentRect(forFrameRect: frame).size
|
||
guard size != AppPreferences.lastCardWindowSize else { return }
|
||
AppPreferences.setLastCardWindowSize(size)
|
||
}
|
||
}
|
||
|
||
// MARK: - Closing
|
||
|
||
/// The close, held open exactly as long as the buffer needs.
|
||
///
|
||
/// A clean buffer closes immediately — which is every window that was only read, and every
|
||
/// window whose last keystroke was more than the debounce ago. A dirty one is flushed through
|
||
/// `DirtyBufferGuard`, and only a genuine write *failure* stops the close: a suspended save
|
||
/// (read-only lock) and a vanished card do not, because neither has anywhere for the text to
|
||
/// land and both were already visible to the user as the standing lock row or a card that left
|
||
/// the board (`CardBodyEditSession.flushOrThrow`).
|
||
private func closeAfterFlushing() {
|
||
guard session.body.isDirty else {
|
||
windowController.closeAfterFlush()
|
||
return
|
||
}
|
||
if session.bufferGuard.beginClose() {
|
||
windowController.closeAfterFlush()
|
||
} else {
|
||
// The alert is presenting; the close resumes from the phase change, above.
|
||
isClosePending = true
|
||
}
|
||
}
|
||
|
||
/// The save panel behind the modal's "Save a Copy…". Pre-filled with the card's name and a `.md`
|
||
/// extension, because what it writes is the Markdown body the user was typing — not the card,
|
||
/// which cannot exist outside a board.
|
||
private static func copyDestination(named title: String) -> URL? {
|
||
let panel = NSSavePanel()
|
||
panel.nameFieldStringValue = "\(title.isEmpty ? "Untitled" : title).md"
|
||
panel.canCreateDirectories = true
|
||
panel.isExtensionHidden = false
|
||
panel.allowsOtherFileTypes = true
|
||
panel.prompt = "Save"
|
||
panel.message = "Choose where to keep these changes."
|
||
guard panel.runModal() == .OK else { return nil }
|
||
return panel.url
|
||
}
|
||
|
||
/// Leaves the session and lets the store go.
|
||
///
|
||
/// The release rides **behind** the session's end rather than beside it: a session that has
|
||
/// something to commit (m6) needs the store it is committing through, and a refcount that hit
|
||
/// zero first would have stopped the watcher underneath it. In m4 the hook is a no-op and the
|
||
/// ordering costs one run-loop turn — the point is that the shape is already right.
|
||
private func finish() {
|
||
guard case let .open(store) = phase else { return }
|
||
phase = .closing
|
||
appModel.unregisterCardWindow(ref)
|
||
Task { @MainActor in
|
||
await session.endSession()
|
||
appModel.storeRegistry.release(store)
|
||
}
|
||
}
|
||
}
|