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
182 lines
9.1 KiB
Swift
182 lines
9.1 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
|
|
|
|
/// The board's cached `icon`/`iconColor` (02-architecture.md § Per-board app state: "The row's
|
|
/// title and icon are registry-cached too — with live write-through"). `nil` reads as "draw the
|
|
/// board-default glyph" — the same lenient fallback the board window's own icon field gets, and
|
|
/// exactly what an unstamped or override-free record means. The row never resolves these itself;
|
|
/// that is the renderer's job (`ItemSymbol`, `Palette`), so the same record decodes into one
|
|
/// answer for both the welcome row and File ▸ Open Recent.
|
|
let icon: String?
|
|
let iconColor: 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,
|
|
icon: record.icon,
|
|
iconColor: record.iconColor,
|
|
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)
|
|
}()
|
|
}
|