import AppKit import SwiftUI /// **The board popover's Info tab** (03-board-ui.md § Board popover ▸ Info tab, settled 2026-08-07) /// — the board's vital statistics, read-only, in two registers with one honest split: /// /// - **Model facts** come from the snapshot and are live: lanes, cards (with the trash's freight as /// a quiet tail), attachments. They count *the board you see* — the same live-only rule the /// welcome screen's counts follow (`AppModel.liveCounts`), because a number beside "Lanes" that /// disagreed with the lanes on screen would be the tab lying about the surface it sits on. /// - **Disk facts** come from one background walk of the whole `.board` folder and are /// honest-as-of-open: file count, size on disk, created, modified. They count *everything* — /// `.git`, `.trash/`, strays — because their job is to agree with what Finder's Get Info would /// say about the same folder (the whole-folder ruling); a "size" that quietly excluded the /// repository would send a user hunting for missing gigabytes. The two registers meeting is the /// design: Cards is the board's number, Files is the folder's, and neither pretends to be the /// other. /// /// **Reveal in Finder** closes the tab — the row set describes the folder, and this is the door to /// it (`NSWorkspace.activateFileViewerSelecting`, the welcome screen's own reveal). Not disabled /// under the read-only lock: revealing is not a mutation. // MARK: - Model facts /// The snapshot's countable facts, as one pure function of `BoardModel` — pulled out of the view so /// the counting rules (live-only, freight-inclusive trash, live-cards-only attachments) are each /// assertable against fixture boards without a popover on screen (`BoardInfoTabTests`). struct BoardInfoMetrics: Equatable { let lanes: Int let cards: Int /// Everything the trash holds, **freight included**: loose trashed cards plus each trashed /// lane's `heldCards` — the same counting rule the purge confirms use (03-board-ui.md § Trash: /// consequences count freight), so the tail here and an Empty Trash alert never disagree. let trashedCards: Int /// Attachment files across the **live** cards only. A trashed card's attachments went to the /// trash with it, and a trashed lane's are unreachable by construction (the opaque unit) — so /// live-only is not just consistent with the other model facts, it is the only rule this row /// could actually keep. let attachments: Int init(snapshot: BoardModel) { lanes = snapshot.lanes.count cards = snapshot.lanes.reduce(0) { $0 + $1.cards.count } trashedCards = snapshot.trash.count + snapshot.trashedLanes.reduce(0) { $0 + $1.heldCards } attachments = snapshot.lanes.reduce(0) { lanes, lane in lanes + lane.cards.reduce(0) { $0 + $1.attachments.count } } } } // MARK: - Disk facts /// The whole-folder walk's answers: what Finder's Get Info would say about the `.board` folder, /// gathered in one pass so the four numbers describe the same instant. /// /// **Taken once per appearance, never live.** The tab measures when it appears and shows that — /// the `hasGitDirectory` posture one section over: a read-only fact refreshed by reopening, not a /// live subscription. `FolderWatcher` deliberately filters `.git` churn out of the reload stream, /// so there is no event these numbers could honestly hang off; and a size that ticked while the /// user watched would be motion without meaning on a settings surface. struct BoardDiskFootprint: Equatable, Sendable { /// Regular files, hidden included — `.git`'s objects, `.trash/`'s cards, every `index.md`. let files: Int /// Allocated bytes across those files — *size on disk* in Finder's sense (block-rounded), which /// is what the row is labeled, falling back per-file to logical size where the volume doesn't /// report allocation. let bytes: Int64 /// The folder's own birth date. Filesystem fact, not frontmatter — the board folder may predate /// any stamp in it (a hand-made board), and the folder is what this tab describes. let created: Date? /// The newest content-modification date anywhere in the tree — files *and* directories, because /// a deletion-only change moves no file's mtime but does move its parent folder's, and "Modified" /// answering "when did this board last change" must see that case too. let modified: Date? /// One blocking `FileManager` walk — call it off the main actor (`BoardInfoTabView`'s `.task` /// detaches). Unreadable entries are skipped rather than failing the measure: a partial answer /// beside a Reveal button beats no answer, and the walk has no user to ask. static func measure(at root: URL) -> BoardDiskFootprint { let keys: Set = [ .isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey, .contentModificationDateKey, ] var files = 0 var bytes: Int64 = 0 var newest: Date? func absorb(_ url: URL) { guard let values = try? url.resourceValues(forKeys: keys) else { return } if values.isRegularFile == true { files += 1 bytes += Int64(values.totalFileAllocatedSize ?? values.fileSize ?? 0) } if let stamp = values.contentModificationDate, stamp > (newest ?? .distantPast) { newest = stamp } } // No `.skipsHiddenFiles`: counting the hidden entries is the whole-folder ruling — the walk // exists to agree with Finder, and Finder's Get Info counts them too. if let walker = FileManager.default.enumerator( at: root, includingPropertiesForKeys: Array(keys), options: [] ) { for case let url as URL in walker { absorb(url) } } absorb(root) let rootValues = try? root.resourceValues(forKeys: [.creationDateKey]) return BoardDiskFootprint( files: files, bytes: bytes, created: rootValues?.creationDate, modified: newest ) } } // MARK: - The tab /// The Info tab's surface: the row grid — identity, then the model facts, then the disk facts, /// then the dates — and the Reveal in Finder door. struct BoardInfoTabView: View { let store: BoardStore /// The popover's own padding figure (`BoardInfoView.inset`), handed in so the tab's edges match /// the header's without restating the derivation. let inset: CGFloat /// `nil` until the walk answers — the disk rows show an em dash for the gap, which on any real /// board is a blink; the placeholder exists for the enormous-`.git` outlier, where a frozen /// popover would be the worse answer. @State private var footprint: BoardDiskFootprint? var body: some View { VStack(alignment: .leading, spacing: 10) { Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 8, verticalSpacing: 4) { row("File", store.rootURL.lastPathComponent) row("Lanes", metrics.lanes.formatted()) row("Cards", cardsValue) row("Attachments", metrics.attachments.formatted()) row("Files", footprint.map { $0.files.formatted() } ?? pending) row("Size", footprint.map { $0.bytes.formatted(.byteCount(style: .file)) } ?? pending) row("Created", footprint.map { dateValue($0.created, time: false) } ?? pending) row("Modified", footprint.map { dateValue($0.modified, time: true) } ?? pending) } Button("Reveal in Finder") { NSWorkspace.shared.activateFileViewerSelecting([store.rootURL]) } .buttonStyle(.link) .accessibilityHint("Shows the board's folder in the Finder") } .font(.callout) .padding(inset) .frame(maxWidth: .infinity, alignment: .leading) // Re-measures per appearance — every tab visit, every popover open — and re-arms if a live // root recovery rebinds the board to a new folder mid-open. Detached because the walk is // blocking disk work and the popover should paint its model facts without waiting on it. .task(id: store.rootURL) { footprint = nil let root = store.rootURL footprint = await Task.detached(priority: .utility) { BoardDiskFootprint.measure(at: root) }.value } } /// Fresh per body evaluation, off the live snapshot — which is what keeps Lanes/Cards honest /// under an agent edit landing while the popover is open (`store.snapshot` is `@Observable`). private var metrics: BoardInfoMetrics { BoardInfoMetrics(snapshot: store.snapshot) } /// "23", growing a quiet tail — "23 · 5 in Trash" — only when the trash holds anything: an /// empty trash is the ordinary state, and a standing "0 in Trash" would be the empty strip the /// design never shows. private var cardsValue: String { guard metrics.trashedCards > 0 else { return metrics.cards.formatted() } return "\(metrics.cards.formatted()) · \(metrics.trashedCards.formatted()) in Trash" } /// The disk rows' placeholder while the walk is in flight — and the honest answer where a value /// truly isn't there (a filesystem that reports no birth date). private var pending: String { "—" } private func dateValue(_ date: Date?, time: Bool) -> String { guard let date else { return pending } return date.formatted(date: .abbreviated, time: time ? .shortened : .omitted) } /// One statistic: a trailing secondary label against a leading value, combined into a single /// accessibility element so VoiceOver reads "Lanes, 4" as one utterance rather than two strays. private func row(_ label: String, _ value: String) -> some View { GridRow { Text(label) .foregroundStyle(.secondary) .gridColumnAlignment(.trailing) Text(value) .lineLimit(1) .truncationMode(.middle) } .accessibilityElement(children: .combine) } }