The Info tab fills in — the board's vital statistics in two honest registers

Model facts off the live snapshot (Lanes, Cards with the trash's freight as a
quiet tail, Attachments — the welcome-count live-only rule) and disk facts off
one background whole-folder walk (File, Files, Size, Created, Modified — .git
and .trash included, so the rows agree with Finder's Get Info), with a Reveal
in Finder link as the door to the folder the rows describe.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-07 13:58:15 -04:00
parent c21c53be9c
commit 5779da2b6c
4 changed files with 386 additions and 22 deletions
+5 -1
View File
@@ -53,7 +53,11 @@ One **style editor** component — a background palette grid and a curated symbo
## Board popover
**Restructure in progress (2026-08-07): the popover is going tabbed.** The symbol/name header stays at the top; below it sit three tabs — **Info**, **Background**, **Git** — each the settings surface for one aspect of board configuration, each to be designed in its own dedicated session (all three are empty placeholders until then). The bullets below describe the popover's pre-tab content; what of it rehomes into which tab — and where the embedded style editor lands — is those sessions' to settle. The rulings inside the bullets (rename semantics, the git postures, the popover/sheet split) stand; only their placement is in motion.
**Restructure in progress (2026-08-07): the popover is going tabbed.** The symbol/name header stays at the top; below it sit three tabs — **Info**, **Background**, **Git** — each the settings surface for one aspect of board configuration, each designed in its own dedicated session (Background and Git are empty placeholders until theirs). The bullets below describe the popover's pre-tab content; what of it rehomes into which tab — and where the embedded style editor lands — is those sessions' to settle. The rulings inside the bullets (rename semantics, the git postures, the popover/sheet split) stand; only their placement is in motion.
### Info tab (settled 2026-08-07)
The board's vital statistics, read-only, in two registers with one honest split: **model facts** off the live snapshot — Lanes, Cards, Attachments — counting *the board you see* (the welcome-count live-only rule; the Cards row grows a quiet "· N in Trash" tail only when the trash holds anything, counting freight the way the purge confirms do — never a standing "0 in Trash"); and **disk facts** off one background whole-folder walk — File (the `.board` folder's name), Files, Size, Created, Modified — counting *everything*, `.git` and `.trash/` included, because their job is to agree with Finder's Get Info about the same folder (a size that quietly excluded the repository would send a user hunting for missing gigabytes). Disk facts are honest-as-of-appearance, refreshed per tab visit, never live — `FolderWatcher` filters `.git` churn out of the reload stream by design, so there is no event they could honestly hang off, and a ticking size is motion without meaning on a settings surface; an em dash holds each disk row until the walk answers. Modified is the tree's newest content-modification date, directories included (a deletion-only change moves no file's mtime, only its parent folder's); Created is the folder's filesystem birth date, not frontmatter — the folder may predate any stamp in it. A **Reveal in Finder** link closes the tab — the rows describe the folder, and this is the door to it; not disabled under the read-only lock, since revealing is not a mutation.
The window-title widget opens the **board popover** — the one board-level surface, hosting:
+14 -17
View File
@@ -6,9 +6,10 @@ import SwiftUI
///
/// **Restructuring in progress (2026-08-07): the popover is going tabbed.** The symbol/name header
/// stays at the top; below it sit three tabs **Info**, **Background**, **Git** each a settings
/// surface for one aspect of board configuration, each deliberately *empty* today: their contents
/// are to be settled one per dedicated design session. The former body the embedded style editor
/// and the mode-aware git section is unrendered for the interim but parked in this file (see the
/// surface for one aspect of board configuration, each settled in its own dedicated design session.
/// **Info is settled** (same day `BoardInfoTabView`, the metrics dossier); Background and Git
/// remain deliberately empty until theirs. The former body the embedded style editor and the
/// mode-aware git section is unrendered for the interim but parked in this file (see the
/// "Parked" marks below), because its pure seams (`BoardGitSection`, the posture notes,
/// `BoardSettingsAvailability`'s caller) are settled design and will rehome into the tabs as those
/// sessions rule.
@@ -256,9 +257,9 @@ func boardInfoTitlebarAccessory(
// MARK: - Tabs
/// The popover's three aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab
/// restructure): **Info**, **Background**, **Git**. All three are placeholders empty on purpose
/// until each gets its dedicated design session; the enum exists now so the popover's shape is the
/// tabs' from day one and each session only has to fill its case in.
/// restructure): **Info**, **Background**, **Git**. Info is settled (`BoardInfoTabView`);
/// Background and Git are placeholders empty on purpose until each gets its dedicated design
/// session, which then only has to fill its case in.
enum BoardInfoTab: String, CaseIterable, Identifiable {
case info = "Info"
@@ -374,23 +375,19 @@ struct BoardInfoView: View {
.padding(.horizontal, inset)
.padding(.top, inset)
// Each tab's surface empty today, on purpose: the contents are each their own design
// session's to settle (the file-top note). The switch is already the shape those
// sessions will fill in, and the fixed placeholder height is exactly that a
// placeholder, so the popover reads as a surface awaiting content rather than a
// collapsed sliver; it goes the moment any tab has real content to size itself by.
Group {
// The selected tab's surface. Info is settled (2026-08-07 `BoardInfoTabView`);
// Background and Git stay placeholders until their own sessions, holding a fixed
// height so an empty tab reads as a surface awaiting content rather than a collapsed
// sliver `Color.clear`, because an `EmptyView` inside a frame renders nothing at all.
switch tab {
case .info:
EmptyView()
BoardInfoTabView(store: store, inset: inset)
case .background:
EmptyView()
Color.clear.frame(height: 120)
case .git:
EmptyView()
Color.clear.frame(height: 120)
}
}
.frame(maxWidth: .infinity, minHeight: 120)
}
// The style editor's popover width, taken from the editor rather than restated: the embed
// below must lay out here exactly as it does at its other two anchors, and that number is
// now font-derived.
+211
View File
@@ -0,0 +1,211 @@
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<URLResourceKey> = [
.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)
}
}
+152
View File
@@ -0,0 +1,152 @@
import Foundation
import Testing
@testable import Kanban
/// **The Info tab's two seams** (03-board-ui.md § Board popover Info tab, settled 2026-08-07),
/// pinned the way the popover's other pure seams are: `BoardInfoMetrics` against a loaded fixture
/// board, `BoardDiskFootprint` against real bytes on disk. The rows' rendering labels, the em-dash
/// placeholder, the Reveal button is SwiftUI and deliberately untested.
@Suite("Board popover ▸ Info tab metrics")
struct BoardInfoTabTests {
/// Two live lanes holding three cards (one carrying two attachments), a loose trashed card, and
/// a trashed lane holding two more the shape that exercises every counting rule at once.
private func makeFixture() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Doing"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "One"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Two"))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/sketch.png", Data([0xFF, 0xD8]))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt", Data("notes".utf8))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Done"))
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Three"))
try fixture.item(".trash/\(Ident.card4)", Item.rich(order: "1024", title: "Tossed"))
try fixture.item(
".trash/\(Ident.lane3)",
"---\nschema: 1\ntitle: Shipped\norder: 3072\nkind: lane\n---\n"
)
try fixture.item(
".trash/\(Ident.lane3)/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
Item.rich(order: "1024", title: "Held one")
)
try fixture.item(
".trash/\(Ident.lane3)/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
Item.rich(order: "2048", title: "Held two")
)
return fixture
}
@Test("Lanes and cards count the live board only — the welcome-count rule")
func lanesAndCardsAreLiveOnly() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let metrics = BoardInfoMetrics(snapshot: try BoardLoader.load(boardRoot: fixture.root).model)
#expect(metrics.lanes == 2)
#expect(metrics.cards == 3)
}
@Test("The trash tail counts freight — loose cards plus every trashed lane's held cards")
func trashedCardsCountFreight() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let metrics = BoardInfoMetrics(snapshot: try BoardLoader.load(boardRoot: fixture.root).model)
// One loose trashed card, two held by the trashed lane the purge-confirm counting rule.
#expect(metrics.trashedCards == 3)
}
@Test("Attachments count the live cards' files")
func attachmentsCountLiveCards() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let metrics = BoardInfoMetrics(snapshot: try BoardLoader.load(boardRoot: fixture.root).model)
#expect(metrics.attachments == 2)
}
@Test("An empty board is all zeros, not a crash or a lie")
func emptyBoardIsZeros() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
let metrics = BoardInfoMetrics(snapshot: try BoardLoader.load(boardRoot: fixture.root).model)
#expect(metrics.lanes == 0)
#expect(metrics.cards == 0)
#expect(metrics.trashedCards == 0)
#expect(metrics.attachments == 0)
}
}
/// **The whole-folder walk** the disk rows exist to agree with Finder's Get Info, so the walk
/// must count hidden entries (`.git`, `.trash/`) exactly as it counts the visible board.
@Suite("Board popover ▸ Info tab disk footprint")
struct BoardDiskFootprintTests {
@Test("Files counts every regular file in the tree, hidden entries included")
func filesCountsTheWholeFolder() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Doing"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "One"))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/sketch.png", Data([0xFF, 0xD8]))
try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "1024", title: "Tossed"))
let footprint = BoardDiskFootprint.measure(at: fixture.root)
// Three index.md files live (board, lane, card), one attachment, one .git file, one trashed
// card's index.md six regular files; directories are containers, not files.
#expect(footprint.files == 6)
}
@Test("Size on disk is at least the logical bytes — allocation rounds up, never down to zero")
func bytesCoverTheLogicalSize() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
let payload = Data(repeating: 0x2A, count: 4096)
try fixture.file("stray.bin", payload)
let footprint = BoardDiskFootprint.measure(at: fixture.root)
let logical = Int64(payload.count + Data(Item.board.utf8).count)
#expect(footprint.bytes >= logical)
}
@Test("Created and Modified come back from a real folder, and never out of order")
func datesAreReadAndOrdered() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
let footprint = BoardDiskFootprint.measure(at: fixture.root)
let created = try #require(footprint.created)
let modified = try #require(footprint.modified)
#expect(modified >= created)
}
@Test("A vanished folder answers empty rather than trapping — the walk has no user to ask")
func vanishedFolderAnswersEmpty() {
let gone = URL(fileURLWithPath: "/tmp/lanework-info-tab-never-existed-\(UUID().uuidString)")
let footprint = BoardDiskFootprint.measure(at: gone)
#expect(footprint.files == 0)
#expect(footprint.bytes == 0)
}
}