BoardRecord carries icon/iconColor; recordOpen/recordClose stamp them with the display name, and a displayStateDelegate on the store (wired in BoardWindowHost beside onFrameChanged) syncs all three through BoardRegistry.syncDisplayState on every successful reload — welcome rows now wear the board's own icon and follow in-app renames live. recordOpen now runs before the load with the folder name as a brand-new record's provisional display name, so a first open that fails fail-fast still lands in recents carrying the failure row-level (02's rule); an existing record's cached name survives a failing retry, and the welcome fallback list remains only for failures naming no record at all. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
266 lines
14 KiB
Swift
266 lines
14 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
import os
|
|
|
|
// MARK: - BoardWindowHost
|
|
|
|
/// One board window: the thing that owns a board's session for as long as it is on screen
|
|
/// (02-architecture.md § Windows, § Launch and window lifecycle).
|
|
///
|
|
/// ### It is a lifecycle, not a layout
|
|
///
|
|
/// Almost everything here is about beginning and ending: acquiring the shared store, stamping the
|
|
/// registry, holding the board's security-scoped access, remembering the window's frame, and running
|
|
/// the close flush before any of it is let go. The board *itself* — the lane strip and everything in
|
|
/// it — is `BoardView`'s (03-board-ui.md); this file hands it the store and the window and stays out
|
|
/// of the way.
|
|
///
|
|
/// ### Failure opens welcome, on a row that already exists
|
|
///
|
|
/// A board that will not load has nothing to show, so its window never appears. But its registry
|
|
/// record is created **before** the load runs (02-architecture.md § Per-board app state, "a first
|
|
/// open that fails fail-fast still records"), so the failure that joins `AppModel.launchFailures`
|
|
/// always has a recents row waiting for it — `WelcomeRow.derive` matches the two by path, uniform
|
|
/// with the failed-restoration row. This window dismisses itself and welcome comes up.
|
|
struct BoardWindowHost: View {
|
|
|
|
let ref: BoardWindowRef
|
|
|
|
@Environment(AppModel.self) private var appModel
|
|
@Environment(\.openWindow) private var openWindow
|
|
@Environment(\.dismissWindow) private var dismissWindow
|
|
|
|
/// The window's own controller — `@State` so it outlives body evaluations and so SwiftUI keeps it
|
|
/// alive for exactly as long as this window exists.
|
|
@State private var windowController = HostedWindowController()
|
|
|
|
/// This window's board popover, open or not (03-board-ui.md § Board popover). `@State` for the
|
|
/// window controller's reason — one per window, living exactly as long as the window — which is
|
|
/// also what makes ⌘I mean "the board in front" rather than "some board": the flag reaches the
|
|
/// menu item through the focus system, like the store.
|
|
@State private var boardInfo = BoardInfoPresentation()
|
|
|
|
/// This window's purge alert, open or not (03-board-ui.md § Trash). `@State` for `boardInfo`'s
|
|
/// reason and reaching the menu bar the same way: Delete Immediately and Empty Trash… are
|
|
/// menu-bar items, and a menu item cannot present anything of its own.
|
|
@State private var trashConfirmations = TrashConfirmations()
|
|
|
|
/// How Board ▸ Open Card reaches this window's card windows. `@State` for `boardInfo`'s reason,
|
|
/// and published the same way: a menu item has no window of its own, and only this view holds
|
|
/// the board half of a card window's `(board, card)` identity — see `CardOpener`.
|
|
@State private var cardOpener = CardOpener()
|
|
|
|
@State private var phase: Phase = .opening
|
|
|
|
private enum Phase {
|
|
case opening
|
|
case open(BoardStore)
|
|
/// The load failed; this window is on its way out and must not try again.
|
|
case failed
|
|
}
|
|
|
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "board-window")
|
|
|
|
var body: some View {
|
|
content
|
|
.frame(minWidth: 640, minHeight: 400)
|
|
.background(WindowAccessor(controller: windowController))
|
|
.navigationTitle(windowTitle)
|
|
.task { await start() }
|
|
.onDisappear { endSessionIfStillOpen() }
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
switch phase {
|
|
case .opening, .failed:
|
|
// Nothing to render and nothing worth animating: this window either becomes a board in a
|
|
// moment or dismisses itself.
|
|
Color.clear
|
|
case let .open(store):
|
|
VStack(spacing: 0) {
|
|
BannerStripView(rows: store.bannerRows) { store.banners.dismiss($0) }
|
|
// The window is handed to the board as a closure, not a value: `WindowAccessor`
|
|
// attaches after this body first runs, and the lane-resize drag needs the *live*
|
|
// window to grow at its right edge (03-board-ui.md § Lane).
|
|
//
|
|
// `openCard` is the host's too, for a different reason — see the property below.
|
|
BoardView(
|
|
store: store,
|
|
window: { windowController.window },
|
|
confirmations: trashConfirmations,
|
|
openCard: openCard
|
|
)
|
|
}
|
|
// "The board in front", for the menu items that act on it (`LaneWidthCommands`), and
|
|
// beside it the window's own popover flag, which is what File ▸ Board Info toggles, and
|
|
// its purge-alert host, which the trash's two confirmed commands raise.
|
|
.focusedSceneValue(\.boardStore, store)
|
|
// The window's identity beside its store — File ▸ Duplicate flushes a *session*, which
|
|
// is keyed on the window rather than on the board it is showing.
|
|
.focusedSceneValue(\.boardWindowRef, ref)
|
|
.focusedSceneValue(\.boardInfo, boardInfo)
|
|
.focusedSceneValue(\.trashConfirmations, trashConfirmations)
|
|
// Board ▸ Open Card's second half — the same closure `BoardView` gets, so the menu item
|
|
// and the double-click open one window per card by construction.
|
|
.focusedSceneValue(\.cardOpener, cardOpener)
|
|
}
|
|
}
|
|
|
|
/// Opens a card's window. `openWindow(value:)` with a ref that already has a window focuses it,
|
|
/// so "at most one card window per card (reopen focuses)" needs no bookkeeping here
|
|
/// (02-architecture.md § Windows).
|
|
private var openCard: (ItemID) -> Void {
|
|
{ cardID in
|
|
openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID))
|
|
}
|
|
}
|
|
|
|
private var windowTitle: String {
|
|
guard case let .open(store) = phase else { return "" }
|
|
return AppModel.displayName(of: store)
|
|
}
|
|
|
|
// MARK: - Opening
|
|
|
|
/// Acquires the board and starts its session, or fails it out to welcome.
|
|
///
|
|
/// The order is load-bearing, and it now has one more step than the load itself does. Security-
|
|
/// scoped access is claimed **before** anything else, because the record and the load both need
|
|
/// it. The registry record comes **before** `acquire` — "the registry record is created before
|
|
/// loading" (02 § Per-board app state) — so a fail-fast failure always has a row to land on;
|
|
/// `acquire`'s own first act is the tree walk, and a sandboxed read outside the claimed scope is
|
|
/// exactly the one that gets refused. `setOpenNow` comes **after** the load succeeds and after
|
|
/// the window has demonstrably opened — a flag set on a board that never appeared would hand the
|
|
/// next launch a restoration set describing a failure.
|
|
private func start() async {
|
|
guard case .opening = phase else { return }
|
|
|
|
// Claimed even on the failure path: an unclaimed stash is a scope nobody balances.
|
|
let access = appModel.claimPendingAccess(for: ref)
|
|
let url = access?.url ?? ref.url
|
|
|
|
// Record before load (settled, 02 § Per-board app state). `displayName` is omitted — an
|
|
// existing record's cached title survives untouched, and a brand-new one takes the folder
|
|
// name, both `recordOpen`'s own rule now. This is also this open's one bookmark mint: a
|
|
// successful load below replaces the name through `syncDisplayState`, which never re-mints.
|
|
let recordID = appModel.boardRegistry.recordOpen(of: url)
|
|
|
|
let store: BoardStore
|
|
do throws(BoardLoadError) {
|
|
store = try appModel.storeRegistry.acquire(url)
|
|
} catch {
|
|
Self.logger.error("board failed to open: \(error.description, privacy: .public)")
|
|
access?.stop()
|
|
phase = .failed
|
|
appModel.recordLaunchFailure(path: ref.path, message: error.description)
|
|
// The record above just changed the registry — welcome, about to appear, must not
|
|
// render the stale list `AppModel` cached before this open began, or the failure would
|
|
// fall through to the unmatched-failures list for want of a row that already exists.
|
|
appModel.refreshRecents()
|
|
openWindow(id: WindowID.welcome)
|
|
dismissWindow(id: WindowID.board, value: ref)
|
|
return
|
|
}
|
|
|
|
// The load succeeded — the frontmatter can be trusted now, so it replaces whatever
|
|
// provisional or stale name the record above was carrying. Through `syncDisplayState`,
|
|
// deliberately not a second `recordOpen`: this is a display-state refresh, not a second
|
|
// open, and it must not mint this board's bookmark again (02 § Per-board app state, "one
|
|
// bookmark per open board").
|
|
appModel.boardRegistry.syncDisplayState(
|
|
id: recordID,
|
|
title: AppModel.displayName(of: store),
|
|
icon: store.snapshot.icon.value,
|
|
iconColor: store.snapshot.iconColor.value
|
|
)
|
|
appModel.boardRegistry.setOpenNow(id: recordID)
|
|
appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access)
|
|
phase = .open(store)
|
|
|
|
configureWindow(store: store, recordID: recordID)
|
|
|
|
// "Opening a board from welcome closes welcome" (02 § Launch and window lifecycle). Harmless
|
|
// when welcome is not open, which is the ordinary case.
|
|
dismissWindow(id: WindowID.welcome)
|
|
}
|
|
|
|
/// Wires the window: the saved frame on the way in, frame changes on the way back out, the
|
|
/// close interception that makes the flush unavoidable, and the title-bar widget.
|
|
private func configureWindow(store: BoardStore, recordID: UUID) {
|
|
// Filled in here rather than at declaration because the closure captures `openWindow`, an
|
|
// environment action; until the board has loaded there is also nothing for Open Card to act
|
|
// on, which is exactly what the item's `nil` check reads.
|
|
cardOpener.open = openCard
|
|
|
|
windowController.onAttach = { window in
|
|
guard let saved = appModel.boardRegistry.record(id: recordID)?.windowFrame else { return }
|
|
window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true)
|
|
}
|
|
// The window may already be attached — `viewDidMoveToWindow` fires well before this task's
|
|
// load returns — so the placement is applied directly too rather than waiting for a callback
|
|
// that has already happened.
|
|
if let window = windowController.window {
|
|
windowController.onAttach?(window)
|
|
}
|
|
|
|
windowController.onFrameChanged = { frame in
|
|
appModel.boardRegistry.updateWindowFrame(
|
|
id: recordID,
|
|
frame: WindowFrame(x: frame.origin.x, y: frame.origin.y, width: frame.width, height: frame.height)
|
|
)
|
|
}
|
|
|
|
// The registry's live write-through (02-architecture.md § Per-board app state) — wired
|
|
// the same way `onFrameChanged` just was: a closure that reaches into the registry,
|
|
// captured weakly on both sides so neither the store nor this closure's own home keeps
|
|
// the other alive past its window. `syncDisplayState` in `start()` already stamped the
|
|
// values current as of this open, so nothing is fired here immediately; this only fires
|
|
// on the reloads that follow.
|
|
store.displayStateDelegate = { [weak appModel, weak store] in
|
|
guard let appModel, let store else { return }
|
|
appModel.boardRegistry.syncDisplayState(
|
|
id: recordID,
|
|
title: AppModel.displayName(of: store),
|
|
icon: store.snapshot.icon.value,
|
|
iconColor: store.snapshot.iconColor.value
|
|
)
|
|
}
|
|
|
|
windowController.onCloseRequested = {
|
|
Task { @MainActor in
|
|
await appModel.closeBoard(ref: ref, cause: .userClose)
|
|
windowController.closeAfterFlush()
|
|
}
|
|
}
|
|
|
|
// The window-title widget (03-board-ui.md § Board popover) — **board windows only**, which
|
|
// is why it is installed here rather than in `WindowAccessor`: welcome, the bootstrap and
|
|
// card windows share that machinery and have no board to describe. It goes in after the
|
|
// load rather than at attach because it carries the store; the controller installs it once,
|
|
// whichever of the two arrives second.
|
|
windowController.installTitlebarAccessory(
|
|
boardInfoTitlebarAccessory(store: store, recents: appModel.styleRecents, presentation: boardInfo)
|
|
)
|
|
}
|
|
|
|
// MARK: - Closing
|
|
|
|
/// The safety net behind the close interception.
|
|
///
|
|
/// `windowShouldClose` covers ⌘W, File ▸ Close and the red button — every way a *user* closes a
|
|
/// window. It does not cover a window torn down some other way (a programmatic dismiss, a scene
|
|
/// SwiftUI decides to end), and a board whose session outlived its window would leave a watcher
|
|
/// running over nothing. So the disappear runs the same sequence; `AppModel.closeBoard` is
|
|
/// idempotent precisely so these two can both fire without the flush running twice.
|
|
///
|
|
/// Deliberately **not** the quit path: quit is `AppDelegate`'s, and it must complete before the
|
|
/// app exits rather than in a task nobody waits for.
|
|
private func endSessionIfStillOpen() {
|
|
guard appModel.session(for: ref) != nil else { return }
|
|
Task { @MainActor in
|
|
await appModel.closeBoard(ref: ref, cause: .userClose)
|
|
}
|
|
}
|
|
}
|