Four scenes (welcome, restore bootstrap, board group, card group) with system restoration disabled in favor of the registry's open-now flags: set when a window actually opens, cleared only on user close, so quit — and crash — leave exactly the restoration set behind. AppModel joins windows to sessions (shared store, registry record, card refs, held security scope); CloseFlushCoordinator pins 02's strict close order as a seam-injected machine (card sessions end, windows drain, store flushes, record stamps, teardown) with named slots where m6/m7 flushes land. HostedWindowController proxies — never replaces — SwiftUI's window delegate to intercept windowShouldClose for the flush, report frames, and place saved frames onto live screens. Card windows are (board path, case-folded card id) values: reopen focuses, and a snapshot-pure fate function dismisses on delete, tombstone, tombstoned lane, or cross-board move. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
237 lines
10 KiB
Swift
237 lines
10 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
import os
|
|
|
|
// MARK: - Fate
|
|
|
|
/// What the current snapshot says about a card window: render this card, or go away.
|
|
///
|
|
/// A named decision rather than a scattering of `if`s, because 05-card-window.md ▸ Deletion &
|
|
/// lifecycle and 02-architecture.md § Live-reload resilience state the same rule from two directions
|
|
/// and both have to be true of one piece of code. Making it a value also makes it a *pure* function
|
|
/// of a snapshot, which is the only way the tombstoned-lane case gets tested without a window.
|
|
public enum CardWindowFate: Equatable {
|
|
case shows(Card)
|
|
case dismisses
|
|
}
|
|
|
|
// MARK: - The session seam
|
|
|
|
/// A card window's editor session — m4's no-op stand-in for the thing 05-card-window.md will build.
|
|
///
|
|
/// It exists so the close flush has something real to call and something real to be *ordered against*
|
|
/// (see `CardSessionFlushing`). The only behaviour it has is the one the ordering depends on: ending
|
|
/// twice does nothing the second time, which matters because two paths legitimately end a session —
|
|
/// the board's close flush drives it for every card window, and a card window closed on its own runs
|
|
/// it from its disappear.
|
|
@MainActor
|
|
final class CardWindowSession: CardSessionFlushing {
|
|
|
|
private var hasEnded = false
|
|
|
|
func endSession() async {
|
|
guard !hasEnded else { return }
|
|
hasEnded = true
|
|
// m6: commit the open Edit session here (06-history-undo.md's session granularity), flushing
|
|
// the debounced body save first.
|
|
}
|
|
}
|
|
|
|
// MARK: - CardWindowHost
|
|
|
|
/// One card window (05-card-window.md).
|
|
///
|
|
/// ### Its whole identity is `(board, card)`
|
|
///
|
|
/// Which is why this host is mostly a set of dismissal rules. The window follows its card between
|
|
/// lanes for free — the key names neither — and it dismisses in the three cases where the key stops
|
|
/// naming anything: the card is tombstoned, its *lane* is tombstoned (effective liveness is
|
|
/// ancestor-walked, 02 § Live-reload resilience), or the card is simply not in this board's snapshot
|
|
/// any more, which is what a cross-board move looks like from here.
|
|
///
|
|
/// ### It can never outlive its board window
|
|
///
|
|
/// "The board window owns the board" (02 § Components) — so a card window whose board has no live
|
|
/// store, or whose board session has gone, dismisses immediately rather than becoming an orphan with
|
|
/// a store it acquired by itself. That covers the ordinary case (the board window closed and its
|
|
/// flush dismissed this one) and the odd one (the system restoring a card window from a previous
|
|
/// launch, which scene restoration is disabled precisely to prevent).
|
|
///
|
|
/// The content is a placeholder — the two-column composition, the sidebar, Edit/Preview and the rest
|
|
/// are the card-window milestone's.
|
|
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
|
|
|
|
private enum Phase {
|
|
case opening
|
|
case open(BoardStore)
|
|
case closing
|
|
}
|
|
|
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "card-window")
|
|
|
|
// MARK: - The lifecycle rule
|
|
|
|
/// Whether a card window keyed on `cardID` still has a card, given this board's snapshot.
|
|
///
|
|
/// The three dismissal cases collapse into two lines: a card that is not in the snapshot is gone
|
|
/// (deleted outright, or moved to another board — the board half of the key no longer names it),
|
|
/// and a card whose **effective** liveness is trashed renders nowhere, whether the tombstone is
|
|
/// its own or its lane's. Only a live card in a live lane keeps its window.
|
|
///
|
|
/// Takes the id as the ref stores it — a raw folder name — and compares it as an `ItemID`, so two
|
|
/// case-spellings of one UUID are one card here exactly as they are everywhere else.
|
|
static func cardWindowFate(cardID: String, in snapshot: BoardModel) -> CardWindowFate {
|
|
let identity = ItemID(rawValue: cardID)
|
|
for lane in snapshot.lanes {
|
|
guard let card = lane.cards.first(where: { $0.id == identity }) else { continue }
|
|
return lane.isDeleted || card.isDeleted ? .dismisses : .shows(card)
|
|
}
|
|
return .dismisses
|
|
}
|
|
|
|
// MARK: - View
|
|
|
|
var body: some View {
|
|
content
|
|
.frame(minWidth: 360, minHeight: 240)
|
|
.background(WindowAccessor(controller: windowController))
|
|
.navigationTitle(windowTitle)
|
|
.task { start() }
|
|
.onChange(of: shouldDismiss, initial: true) { _, dismisses in
|
|
guard dismisses else { return }
|
|
dismissWindow(id: WindowID.card, value: ref)
|
|
}
|
|
.onDisappear { finish() }
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
if let card {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(card.title.value ?? "Untitled")
|
|
.font(.title)
|
|
// The untitled placeholder is styling, not a title: a card with no `title` key
|
|
// shows the word in secondary, never as if somebody had typed it
|
|
// (01-storage-format.md § Frontmatter).
|
|
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.padding(24)
|
|
} else {
|
|
Color.clear
|
|
}
|
|
}
|
|
|
|
private var card: Card? {
|
|
guard case let .open(store) = phase,
|
|
case let .shows(card) = Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot)
|
|
else { return nil }
|
|
return card
|
|
}
|
|
|
|
private var windowTitle: String {
|
|
card?.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)
|
|
configureWindow()
|
|
}
|
|
|
|
/// Size and placement: the last-used card-window size, cascaded (05-card-window.md, "New windows
|
|
/// open at the last-used card-window size, cascaded").
|
|
///
|
|
/// The size is app-wide rather than per-board or per-card — 02 § Per-board app state files "the
|
|
/// last-used card-window size" under App-wide state explicitly. Per-*card* frame restoration is a
|
|
/// separate promise in 05 ("frames restore per card across relaunch where state restoration
|
|
/// allows") and belongs to the card-window milestone, which owns the per-card record it needs.
|
|
private func configureWindow() {
|
|
windowController.onAttach = { window in
|
|
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)
|
|
}
|
|
|
|
windowController.onFrameChanged = { frame in
|
|
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
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
}
|