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:
@@ -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,22 +375,18 @@ 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 {
|
||||
switch tab {
|
||||
case .info:
|
||||
EmptyView()
|
||||
case .background:
|
||||
EmptyView()
|
||||
case .git:
|
||||
EmptyView()
|
||||
}
|
||||
// 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:
|
||||
BoardInfoTabView(store: store, inset: inset)
|
||||
case .background:
|
||||
Color.clear.frame(height: 120)
|
||||
case .git:
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user