The editing surface: the same hosted TextKit-1 text view gains an editable branch with a per-keystroke line-scanner highlighter — chosen over a parser re-parse because a mid-typing buffer is usually invalid Markdown and 05 wants the delimiters themselves dimmed; apply only sets attributes, so presentation-never-transforms is structural. Saves ride a ~700ms injectable debounce through BoardWriter.writeBody — toggleTaskMarker's idiom widened to the body span, frontmatter bytes untouched, refusing to write when disk already holds that body, which enforces all three gates (untouched, reverted, echo) at the layer that owns the bytes with one isDirty predicate above it. Mode grammar lands whole: ⌘E toggles with a checkmark, Return in Preview enters, Escape returns, and every flip flushes first; window close flushes through the existing retry/save-copy/discard modal, and the dismissal flush deliberately reaches a tombstoned card. Dirty-buffer-wins: disk always follows the snapshot, the buffer only when clean, both surfaces render the buffer. Undo is the editor's own session-scoped NSUndoManager; endEditSession names the pro-m1 one-commit-per-session boundary. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
472 lines
24 KiB
Swift
472 lines
24 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 tombstoned-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 tombstone the surgical
|
||
/// body write 05 ▸ Deletion & lifecycle promises ("a dirty Edit buffer flushes into the tombstoned
|
||
/// card's folder before the window dismisses ... so the keystrokes survive Put Back").
|
||
/// `BoardStore.writeCardBody` resolves tombstoned 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 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
|
||
|
||
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()
|
||
}
|
||
}
|
||
|
||
// 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).
|
||
///
|
||
/// ### 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 raw-source outlet, the sidebar's five sections — arrives card by card
|
||
/// underneath a composition that does not move.
|
||
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()
|
||
/// 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
|
||
|
||
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(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)
|
||
// 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() }
|
||
}
|
||
|
||
private var minimumSize: CGSize {
|
||
CardWindowMetrics.minimumSize(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var content: some View {
|
||
if case let .open(store) = phase, let placement {
|
||
CardWindowView(
|
||
card: placement.card,
|
||
cardFolder: Self.cardFolder(root: store.rootURL, placement: placement),
|
||
bodyPresentation: bodyPresentation,
|
||
bodySession: session.body,
|
||
// "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.
|
||
isEditable: !store.isReadOnly,
|
||
onToggleTask: { offset, checked in
|
||
store.toggleTaskMarker(inCard: placement.card.id, bodyOffset: offset, checked: checked)
|
||
}
|
||
)
|
||
} 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.liveItem`'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()
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
bodyPresentation.flushEdits = { [session] in
|
||
session.body.endEditSession()
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|