Build the welcome screen
The welcome window becomes the real thing: Xcode-style, hidden title bar with background drag, branding and actions left, recents right — rows carrying the board symbol, name, location, and the registry's cached lane/card counts (stamped at close, never a scan at welcome time), sorted by last opened. Launch failures surface row-level per 02: a failure joins its recents row as a warning caption, an unresolvable bookmark renders unavailable with Forget its one affordance, and only a failure with no row to carry it falls back to a compact list; a board opening again heals its row. New Board (Opt-Cmd-N) opens the Pages-style template chooser — shipped with the single Basic template and the m9 seams marked — flowing through the save panel into createBoard/createLane and straight into a board window. Open Recent gains its submenu with Clear Menu (byte-identical to forgetting every row, pinned by test), and File > Duplicate forks the frontmost board to a Finder-style copy sibling: pending work flushes first through the close flush's step two alone (sessions stay open — 09's stated exception), every GUID and tombstone carries (the whole-board carve-out from copies-remint), and the copy opens in its own window while the original stays put. 36 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
+266
-58
@@ -1,25 +1,51 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// The welcome window (02-architecture.md § Windows).
|
||||
/// The welcome window (02-architecture.md § Windows; 03-board-ui.md § Welcome screen & templates).
|
||||
///
|
||||
/// ### What this is, and what it is not yet
|
||||
/// ### Xcode's shape, which 03 says carries over unchanged
|
||||
///
|
||||
/// The settled shape is Xcode's: "branding + actions left, recents right (board icon, name,
|
||||
/// location, lane/card counts, sorted by last opened)". This is the left half, plus the one thing
|
||||
/// that cannot wait — the list of boards that failed to open, because 02 § Launch and window
|
||||
/// lifecycle forbids a launch-time failure from being silently dropped and welcome is where it must
|
||||
/// surface.
|
||||
/// > 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.
|
||||
///
|
||||
/// The layout is therefore already an `HStack` with one column in it. The recents column drops in
|
||||
/// beside it; nothing here has to move.
|
||||
// m4-welcome: the recents column, New Board… / Open Recent, per-row Forget and Reveal in Finder, and
|
||||
// the row-level failure rendering 02 specifies (a failed board's own row carrying fail-fast's
|
||||
// specifics, or the unavailable state per Graceful orphaning) all land with the welcome milestone.
|
||||
/// 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
|
||||
@@ -29,14 +55,19 @@ struct WelcomeView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
failures
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(32)
|
||||
recents
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
// Fixed, with `.windowResizability(.contentSize)` on the scene: welcome is a launcher, not a
|
||||
// workspace, and Xcode's — the window this one is modelled on — does not resize either. The
|
||||
// one thing that can grow without bound is the failure list, which scrolls.
|
||||
.frame(width: 760, height: 460)
|
||||
.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
|
||||
@@ -56,12 +87,24 @@ struct WelcomeView: View {
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("Folders and Markdown, on your terms.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.top, 4)
|
||||
|
||||
Spacer(minLength: 24)
|
||||
|
||||
Button("Open Board…") {
|
||||
appModel.presentOpenPanel()
|
||||
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()
|
||||
}
|
||||
}
|
||||
.controlSize(.large)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
@@ -73,47 +116,212 @@ struct WelcomeView: View {
|
||||
return "Version \(short) (\(build))"
|
||||
}
|
||||
|
||||
// MARK: Failed opens
|
||||
// MARK: Recents
|
||||
|
||||
@ViewBuilder
|
||||
private var failures: some View {
|
||||
if appModel.launchFailures.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("No boards open")
|
||||
.font(.title3)
|
||||
Text("Open a board folder to get started.")
|
||||
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 default symbol, on every row.
|
||||
//
|
||||
// The record carries no icon. 02 § Per-board app state settles that it should — "the
|
||||
// row's title and icon are registry-cached too — with live write-through" — and today it
|
||||
// holds only the display name and the counts. An `icon`/`iconColor` stamp joining
|
||||
// `recordClose` (and the store's reload path, which is where the write-through half
|
||||
// lives) is what turns this into the board's own glyph; until then a row that guessed
|
||||
// would be worse than one that is honestly generic.
|
||||
Image(systemName: ItemSymbol.board)
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.secondary)
|
||||
.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)
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Couldn't open")
|
||||
.font(.title3)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(appModel.launchFailures) { failure in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(failure.displayName)
|
||||
.font(.headline)
|
||||
Text(failure.message)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(failure.path)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button("Clear") {
|
||||
appModel.clearLaunchFailures()
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user