Files
lanework/Kanban/App/BoardWindowHost.swift
T
rzen b35566e0fe Build lane chrome — title bar, badge, inline rename
The lane title bar becomes real: leading SF Symbol (hand-written names
render leniently, unknown ones fall back to the level default), title
or secondary untitled placeholder, a quiet count badge that counts
exactly the cards the body renders (so the m5 search filter is
followed by construction), and a new-card button. The whole bar is
the reorder drag surface — no grip — with click-vs-movement splitting
select from drag; a pure proposal function maps the drag to an
insertion index and release commits through the Writer's same-parent
degenerate reorder, compacting and retrying when midpoint precision
runs out. Clicking never edits: inline rename is Return on the sole
selected card or Board > Rename for either kind, a third transient
editor beside the placeholder that tracks its target by UUID, commits
on focus loss, discards silently when the target vanishes, and
removes the title key on an empty commit. The new-card placeholder
renders at last — the settled Cmd-N target rule (pure, tested) files
it after the anchor card, at a selected lane's bottom, or into the
last-active lane; Return commits and re-selects the lane, Cmd-Return
also opens the card window, and a failed create discards the overlay.
New Card / New Lane / Rename land in the menus with focused-editor
and read-only validation; rename gets its own WriteOperation case in
the banner vocabulary. 59 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 08:52:24 -04:00

181 lines
8.0 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
///
/// A board that will not load has nothing to show, so its window never appears: the failure joins
/// `AppModel.launchFailures`, welcome comes up, and this window dismisses itself. That is the same
/// path a failed restoration takes — "welcome appears alongside whatever did restore, the failed
/// board's recents row carrying fail-fast's specifics" — with the row-level rendering still owed.
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()
@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: a card window's identity is
// `(board, card)` and only this view holds the board half. `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).
BoardView(
store: store,
window: { windowController.window },
openCard: { cardID in
openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID))
}
)
}
// "The board in front", for the menu items that act on it (`LaneWidthCommands`).
.focusedSceneValue(\.boardStore, store)
}
}
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. Security-scoped access is claimed **before** `acquire`, because
/// acquire's first act is a full tree walk and a sandboxed read outside the scope is exactly the
/// one that gets refused. `setOpenNow` comes **after** the record exists 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
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)
openWindow(id: WindowID.welcome)
dismissWindow(id: WindowID.board, value: ref)
return
}
let recordID = appModel.boardRegistry.recordOpen(of: url, displayName: AppModel.displayName(of: store))
appModel.boardRegistry.setOpenNow(id: recordID)
appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access)
phase = .open(store)
configureWindow(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, and the
/// close interception that makes the flush unavoidable.
private func configureWindow(recordID: UUID) {
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)
)
}
windowController.onCloseRequested = {
Task { @MainActor in
await appModel.closeBoard(ref: ref, cause: .userClose)
windowController.closeAfterFlush()
}
}
}
// 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)
}
}
}