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 = [] let rows = recents.map { recent -> WelcomeRow in let record = recent.record var keys: Set = [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) }() }