Files
lanework/Kanban/App/WelcomeView.swift
T
rzen f2d9f3ad07 Give the registry live display-state write-through and record-before-load
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
2026-07-27 20:44:30 -04:00

368 lines
14 KiB
Swift

import AppKit
import SwiftUI
/// The welcome window (02-architecture.md § Windows; 03-board-ui.md § Welcome screen & templates).
///
/// ### Xcode's shape, which 03 says carries over unchanged
///
/// > Welcome: resizable, no title bar (background drag); recents list with board icon, name,
/// > location, counts; single click selects, double click opens; context menu Open / Reveal in
/// > Finder / Forget.
///
/// Branding and the two create/open actions on the left, recents on the right. Resizable — the
/// recents list gets whatever space the user grants it — and title-bar-less, with the background as
/// the drag surface (the gesture is attached at low priority, so a row or a button always wins).
///
/// ### The rows are derived, not assembled here
///
/// Everything a row *says* — which caption it wears, whether Open and Reveal are live, and which
/// failures had no row to land on — is `WelcomeRow.derive(recents:failures:)`, a pure function over
/// the registry's recents and `AppModel.launchFailures`. That is where 02's row-level failure rule
/// lives, and it is why the rule is testable. This file renders the answer.
///
/// ### What it never does
///
/// It never opens a board's `index.md` — not for a title, not for a count, not for an icon. Counts
/// and names come from the registry record, stamped at last close: "no directory scan at welcome
/// time (which would be slow or hang on big/unavailable boards)" (02 § Per-board app state). The
/// pathfinder loaded every board to build this list; that is the one thing about it that does not
/// carry over.
struct WelcomeView: View {
@Environment(AppModel.self) private var appModel
/// The selected row's record id. A row's identity is its record, so a Forget leaves this
/// pointing at nothing, which reads as "no selection" without any cleanup of its own.
@State private var selection: UUID?
/// Whether the recents list holds the keyboard, so Return can mean "open the selected row".
@FocusState private var listFocused: Bool
private var derivation: WelcomeRow.Derivation {
WelcomeRow.derive(recents: appModel.recents, failures: appModel.launchFailures)
}
private var selectedRow: WelcomeRow? {
derivation.rows.first { $0.id == selection }
}
var body: some View {
HStack(spacing: 0) {
branding
.frame(width: 300)
.frame(maxHeight: .infinity)
.padding(32)
Divider()
recents
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(minWidth: 760, minHeight: 460)
// The window has no title bar, so the background is the drag handle. `.gesture` rather than
// `.highPriorityGesture`: a click on a row or a button belongs to the row or the button.
.gesture(WindowDragGesture())
// Belt and braces over the explicit refreshes `AppModel` runs on every registry mutation:
// welcome is the one surface that can appear long after the last thing that changed the list.
.onAppear { appModel.refreshRecents() }
// What File ▸ Reveal in Finder acts on in this window's scope (11-command-nexus.md: "welcome:
// the selected recent's folder (disabled on unavailable rows)").
.focusedSceneValue(\.welcomeSelection, selectedRow)
}
// MARK: Branding and actions
private var branding: some View {
VStack(alignment: .leading, spacing: 0) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 96, height: 96)
.accessibilityHidden(true)
Text("Lanework")
.font(.system(size: 34, weight: .light))
.padding(.top, 12)
Text(versionSummary)
.font(.callout)
.foregroundStyle(.secondary)
Text("Folders and Markdown, on your terms.")
.font(.caption)
.foregroundStyle(.tertiary)
.padding(.top, 4)
Spacer(minLength: 24)
VStack(spacing: 8) {
// The menu-bar twin of this button is File ▸ New Board… (⌥⌘N) — same action, and
// deliberately the same words, because a button and a menu item that differ read as
// two features.
WelcomeActionButton(title: "New Board…", systemImage: "plus.square") {
appModel.showTemplateChooser()
}
WelcomeActionButton(title: "Open Board…", systemImage: "folder") {
appModel.presentOpenPanel()
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var versionSummary: String {
let info = Bundle.main.infoDictionary
let short = info?["CFBundleShortVersionString"] as? String ?? "—"
let build = info?["CFBundleVersion"] as? String ?? "—"
return "Version \(short) (\(build))"
}
// MARK: Recents
@ViewBuilder
private var recents: some View {
let derivation = self.derivation
VStack(spacing: 0) {
if derivation.rows.isEmpty {
emptyHint
} else {
list(derivation.rows)
}
if !derivation.unmatched.isEmpty {
Divider()
unmatchedFailures(derivation.unmatched)
}
}
.background(Color(nsColor: .controlBackgroundColor))
}
private var emptyHint: some View {
VStack(spacing: 6) {
Image(systemName: "clock")
.font(.title)
.foregroundStyle(.tertiary)
Text("No Recent Boards")
.font(.headline)
.foregroundStyle(.secondary)
Text("Boards you create or open appear here.")
.font(.caption)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func list(_ rows: [WelcomeRow]) -> some View {
List(rows, selection: $selection) { row in
RecentBoardRow(row: row)
// Single click selects (the `List` does that); the second click of a double click
// opens. `simultaneousGesture` rather than `onTapGesture` so the list's own
// selection handling still sees the first click.
.simultaneousGesture(TapGesture(count: 2).onEnded { open(row) })
.contextMenu {
Button("Open") { open(row) }
.disabled(!row.canOpen)
Button("Reveal in Finder") { reveal(row) }
.disabled(!row.canReveal)
Divider()
// Always enabled, on every row: an orphan the user can never open again is
// exactly the row that most needs erasing (02 § Graceful orphaning).
Button("Forget") { appModel.forget(boardID: row.id) }
}
}
.listStyle(.inset)
.scrollContentBackground(.hidden)
.focused($listFocused)
// Return on a selected row opens it — the list convention, and the reason the list takes
// focus on a click rather than only on Tab.
.onKeyPress(.return) {
guard let selectedRow, selectedRow.canOpen else { return .ignored }
open(selectedRow)
return .handled
}
.onTapGesture { listFocused = true }
}
/// The failures no recents row could carry — a first open of a folder that turned out not to be
/// a board fails before anything is registered, so there is no row for it to land on. A list of
/// their own, because the alternative is the silent drop 02 rules out.
private func unmatchedFailures(_ failures: [LaunchFailure]) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text("Couldn't Open")
.font(.subheadline.weight(.semibold))
ForEach(failures) { failure in
VStack(alignment: .leading, spacing: 1) {
Text(failure.displayName)
.font(.callout)
Text(failure.message)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text(failure.path)
.font(.caption)
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.middle)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// Clears exactly the failures listed here, never the ones standing on rows above: those
// are still describing a board the user can see, and one button quietly erasing both
// lists would be the drop 02 forbids wearing a different hat.
Button("Clear") {
appModel.clearLaunchFailures(ids: Set(failures.map(\.id)))
}
.controlSize(.small)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: Actions
/// Opens a row's board. Welcome closes itself on the way in — that is the board window host's
/// job ("Opening a board from welcome closes welcome"), not this view's, because the close has to
/// wait for the load to actually succeed.
private func open(_ row: WelcomeRow) {
guard let url = row.url else { return }
appModel.openBoard(at: url)
}
private func reveal(_ row: WelcomeRow) {
guard let url = row.url else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
}
}
// MARK: - Pieces
/// A full-width, leading-aligned action button — Xcode's welcome column.
private struct WelcomeActionButton: View {
let title: String
let systemImage: String
let action: () -> Void
var body: some View {
Button(action: action) {
Label(title, systemImage: systemImage)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 2)
}
.buttonStyle(.bordered)
.controlSize(.large)
}
}
/// One recents row: icon, name, location, and the one caption line carrying whichever of the three
/// things the row has to say (`WelcomeRow.Caption`).
private struct RecentBoardRow: View {
let row: WelcomeRow
var body: some View {
HStack(spacing: 12) {
// The board's own icon and tint — registry-cached with live write-through (02 §
// Per-board app state: "the row's title and icon are registry-cached too — with live
// write-through"). A record with no override, or one naming a symbol this system
// cannot draw, falls back to the board-default glyph in secondary — the same
// lenient-fallback shape every other icon site in the app uses (`ItemSymbol`,
// `Palette`), applied here to the registry's cached string instead of a live snapshot.
Image(systemName: iconName)
.font(.system(size: 22))
.foregroundStyle(iconTint)
.frame(width: 34, height: 34)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 1) {
Text(row.displayName)
.font(.headline)
.lineLimit(1)
Text(row.location)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
caption
}
Spacer(minLength: 0)
}
.padding(.vertical, 4)
// Dimmed when the board cannot be reached — the row stays, with Forget, rather than
// disappearing (02 § Graceful orphaning).
.opacity(row.isAvailable ? 1 : 0.55)
.accessibilityElement(children: .combine)
}
/// `row.icon`'s symbol if it names one this system can draw, the board default otherwise —
/// `ItemSymbol.name(_:fallback:)`'s rule, restated for a plain cached string rather than a
/// `FieldValue`: a record carries no `FieldValue`, so `missing`/`malformed`/`unrecognized`
/// have already folded into one `nil` by the time it reaches here.
private var iconName: String {
guard let icon = row.icon, ItemSymbol.exists(icon) else { return ItemSymbol.board }
return icon
}
/// `row.iconColor`'s tint, or the standard secondary one — `LaneView`'s card-face `iconTint`,
/// same fallback, same reason: an uncoloured icon is chrome, and chrome is secondary.
private var iconTint: AnyShapeStyle {
if let iconColor = row.iconColor, let color = Palette.color(named: iconColor) {
AnyShapeStyle(color)
} else {
AnyShapeStyle(.secondary)
}
}
@ViewBuilder
private var caption: some View {
switch row.caption {
case .counts:
Text(row.countsSummary)
.font(.caption)
.foregroundStyle(.tertiary)
case .unavailable:
Label("Unavailable — moved, deleted, or on a volume that isn't mounted",
systemImage: "questionmark.folder")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
case let .failed(message):
// The warning tint, and the whole of fail-fast's specifics — this row *is* the failure
// surface (02 § Launch and window lifecycle).
Label(message, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// MARK: - Settings
/// The app's preferences (⌘, — 11-command-nexus.md).
///
/// One control, which is the whole of v1: "Restore open boards at launch". The preference gates only
/// whether the registry's open-now flags are *consulted* at launch — the flags themselves are
/// maintained either way, which is what keeps crash recovery working for a user who has restoration
/// turned off and then turns it back on.
struct SettingsView: View {
@AppStorage(AppPreferences.restoreOpenBoardsAtLaunchKey)
private var restoreOpenBoardsAtLaunch = true
var body: some View {
Form {
Toggle("Restore open boards at launch", isOn: $restoreOpenBoardsAtLaunch)
}
.formStyle(.grouped)
.frame(width: 420)
.fixedSize()
}
}