Files
lanework/Kanban/App/WelcomeRow.swift
T
rzen 4b97ecf3f0 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
2026-07-27 16:50:39 -04:00

171 lines
8.4 KiB
Swift

import Foundation
// MARK: - WelcomeRow
/// One row of the welcome window's recents list: a `RecentBoard` joined with whatever launch failure
/// names the same board.
///
/// ### Why the join is a value, derived by a pure function
///
/// 02-architecture.md § Launch and window lifecycle makes the *row* the failure surface:
///
/// > A restored board that fails surfaces on welcome, row-level: its window doesn't open; welcome
/// > appears alongside whatever did restore, the failed board's recents row carrying fail-fast's
/// > specifics (load error) or the unavailable state per Graceful orphaning. Other restorations
/// > proceed unaffected — never a launch-time modal chain, never a silent drop.
///
/// That is a rule about *data*: which of three captions a row wears, which of its actions are live,
/// and — the clause a list of rows cannot express by itself — what happens to a failure that matches
/// no row at all. Derived here, every one of those cases is stateable in a test without a window,
/// which is the only way the "never a silent drop" half is checkable at all.
///
/// The same derivation feeds File ▸ Open Recent, so the submenu and the list can never disagree
/// about a board's name or about whether it can be opened.
struct WelcomeRow: Identifiable, Equatable {
/// The registry record's id — the row's identity, the selection's value, and what Forget names.
let id: UUID
/// The board's name: the record's cached `displayName`, falling back to the folder name for a
/// record that somehow carries none (01-storage-format.md § Board naming's fallback, applied to
/// the cached string rather than to a board this window must never open).
let displayName: String
/// Where the board is **now**, or `nil` when its bookmark no longer resolves. The single source
/// of the row's availability: Open and Reveal need a URL, and an orphan has none.
let url: URL?
/// The containing folder, for the row's location line — Xcode's welcome shows where a project
/// lives, not its own path repeated under its name. Home-abbreviated where it can be.
let location: String
/// The counts stamped at last close, `nil` until a close has stamped them
/// (02 § Per-board app state: registry-cached, never a directory scan at welcome time).
let laneCount: Int?
let cardCount: Int?
/// The message of the launch failure naming this board, if one does.
let failure: String?
var isAvailable: Bool { url != nil }
/// Open and Reveal in Finder both need somewhere to go; Forget is deliberately not here, because
/// it is enabled on every row — an orphan the user can never open is exactly the row that most
/// needs erasing (02 § Graceful orphaning: "recents surface it as unavailable with Forget").
var canOpen: Bool { isAvailable }
var canReveal: Bool { isAvailable }
/// The row's third line — one line, so the three states are alternatives rather than a stack.
///
/// The precedence is 02's sentence read in order: a failure is what the row is *for* at that
/// moment and outranks both the orphan state (which the failure message already describes in
/// better words) and the counts (facts about a board the user cannot currently get into).
enum Caption: Equatable {
/// The ordinary row: "3 lanes · 12 cards", or an em dash where nothing has been stamped.
case counts(lanes: Int?, cards: Int?)
/// The bookmark no longer resolves (02 § Graceful orphaning).
case unavailable
/// Fail-fast's specifics, from the open or restore that failed.
case failed(String)
}
var caption: Caption {
if let failure { return .failed(failure) }
if url == nil { return .unavailable }
return .counts(lanes: laneCount, cards: cardCount)
}
/// "3 lanes · 12 cards" — or "—" when the record has never been closed and so carries nothing.
///
/// A single em dash rather than "0 lanes · 0 cards": an unstamped record knows nothing about the
/// board's size, and zero is a claim.
var countsSummary: String {
guard let laneCount, let cardCount else { return "—" }
let lanes = "\(laneCount) lane\(laneCount == 1 ? "" : "s")"
let cards = "\(cardCount) card\(cardCount == 1 ? "" : "s")"
return "\(lanes) · \(cards)"
}
// MARK: - Derivation
/// The rows, and the failures no row could carry.
struct Derivation: Equatable {
var rows: [WelcomeRow]
/// Failures naming no record — a first open of a folder that turned out not to be a board,
/// which fails before anything is registered and so has no row to render on. They keep a
/// list of their own on welcome, because the alternative is the silent drop 02 rules out.
var unmatched: [LaunchFailure]
}
/// Joins the recents list with the launch failures, in `recents`' order (which is the registry's
/// `lastOpened` descending — this function never re-sorts, so the sort rule keeps living in
/// exactly one place).
///
/// **Matching is by path, and by both of a record's paths.** A record knows where it was last
/// seen (`lastKnownPath`) and, when its bookmark resolves, where it lives now; a bookmark follows
/// a move, so a failure recorded before one names the older spelling. Paths are standardized
/// before comparison — `/tmp/b/../b` and `/tmp/b` are one board — but never resolved through
/// symlinks: that would be a filesystem round trip per row, which is the cost the registry's
/// whole design is built to avoid at welcome time.
///
/// **The newest failure wins a row's caption** when several name it (a board that failed, was
/// retried, and failed again), because it is the one describing the state the file is in now.
/// All of them are consumed either way — a row carries one message, and the older attempts must
/// not resurface in the unmatched list as if nothing had shown them.
static func derive(recents: [RecentBoard], failures: [LaunchFailure]) -> Derivation {
var claimed: Set<UUID> = []
let rows = recents.map { recent -> WelcomeRow in
let record = recent.record
var keys: Set<String> = [pathKey(record.lastKnownPath)]
if let url = recent.url {
keys.insert(pathKey(url.path))
}
let matches = failures.filter { keys.contains(pathKey($0.path)) }
claimed.formUnion(matches.map(\.id))
return WelcomeRow(
id: record.id,
displayName: record.displayName.isEmpty
? URL(fileURLWithPath: record.lastKnownPath).deletingPathExtension().lastPathComponent
: record.displayName,
url: recent.url,
location: location(of: recent.url?.path ?? record.lastKnownPath),
laneCount: record.laneCount,
cardCount: record.cardCount,
failure: matches.last?.message
)
}
return Derivation(rows: rows, unmatched: failures.filter { !claimed.contains($0.id) })
}
/// How two paths are compared for "the same board" on this screen. Standardized only — see
/// `derive` for why nothing here touches the filesystem.
static func pathKey(_ path: String) -> String {
URL(fileURLWithPath: path).standardizedFileURL.path
}
/// The board's containing folder, with the user's home written as `~`.
static func location(of path: String) -> String {
let parent = URL(fileURLWithPath: path).deletingLastPathComponent().path
guard let home = realHomeDirectory, parent == home || parent.hasPrefix(home + "/") else {
return parent
}
return "~" + parent.dropFirst(home.count)
}
/// The user's **real** home directory.
///
/// `NSHomeDirectory()` and `FileManager.homeDirectoryForCurrentUser` both answer with the sandbox
/// container, which no board is ever inside — abbreviating against either would never once fire.
/// The password database is where the real path still lives, and this is display text only:
/// nothing is opened, resolved, or written relative to it, so being wrong costs a longer row.
private static let realHomeDirectory: String? = {
guard let entry = getpwuid(getuid()), let directory = entry.pointee.pw_dir else { return nil }
return String(cString: directory)
}()
}