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) } } }