Files
lanework/Kanban/UI/Board/BoardInfoPopover.swift
rzen 9766e1f61c The Colors panel joins the palette — the combo ratified, and each anchor composes the halves it needs
Four rulings close Redesign Contradiction 3452893f (2026-08-06): the in-app
escape hatch is ratified in full, reversing 2026-07-29's palette-only rule —
the combo's Other… opens the system Colors panel, a pick landing on a palette
color stores the name, anything else the hex. Free-picked colors change no
contrast story: they land on the same runtime ink computation hand-written hex
always got (10 amended to say so; no warning surface is owed). Anchor
ownership: the card sidebar's background story is the combo alone — the well
grid's background half stays with the other anchors (StyleEditorView gains
showsBackground beside showsSymbols; the popover's symbol half already went to
its inline SymbolPicker). Quick-style recents stay palette-vocabulary — a
panel pick never enters them.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 22:05:48 -04:00

665 lines
34 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import SwiftUI
/// **The board popover** — "the one board-level surface" (03-board-ui.md § Board popover), and the
/// widget in the window's titlebar that opens it.
///
/// Two sections, in the design's own order: the board rename and the embedded style editor aimed at
/// the board. A third, contextual note joins them only on a board that carries an inert `.git` — see
/// `BoardGitNote` for the wording and why base's posture there is a quiet signpost rather than a
/// standing section (12-editions.md § Base and `.git`, ruled 2026-07-27, superseding the earlier
/// reserved-slot placeholder this file once carried).
///
/// ### One home, deliberately
///
/// The popover has **no toolbar item** (§ Toolbar: "the window-title widget is its committed home
/// … and a second entry would muddy it"). It has exactly two ways in: the widget, and File ▸ Board
/// Info ⌘I, which is the same widget's popover reached from the keyboard (11-command-nexus.md's
/// class **C** — "the keyboard path is reachability … not bindings").
// MARK: - Presentation state
/// Whether **this window's** board popover is open.
///
/// Per window rather than per board or per app, and that is the point: ⌘I has to mean "the board in
/// front", so the flag travels with the window through the focus system (`FocusedValues.boardInfo`)
/// exactly as the store does. Two board windows each hold their own and can never toggle each
/// other's — which a flag on the store could not promise the day a board is allowed two windows.
///
/// It is also why this is not in `TransientBoardState`: everything there is *board*-scoped and
/// shared with the board's card windows, and a popover living in one window's titlebar is not that.
@MainActor
@Observable
final class BoardInfoPresentation {
var isPresented = false
/// ⌘I's whole behaviour and the widget's alike. **Toggling, not opening**: a shortcut aimed at a
/// disclosure that could only ever open would leave the popover with no keyboard way out.
func toggle() {
isPresented.toggle()
}
}
/// The focused board window's popover, beside `FocusedValues.boardStore` — see that key for why
/// board-window menu items reach their window this way rather than through the app model.
struct FocusedBoardInfoKey: FocusedValueKey {
typealias Value = BoardInfoPresentation
}
extension FocusedValues {
var boardInfo: BoardInfoPresentation? {
get { self[FocusedBoardInfoKey.self] }
set { self[FocusedBoardInfoKey.self] = newValue }
}
}
// MARK: - The window-title widget
/// The titlebar widget: the board's name — and, on a git-mode Pro board, its branch — with a
/// trailing disclosure chevron, whose one job is this popover.
///
/// **The popover is anchored to the widget itself** — it hangs from the button rather than from the
/// window or the board — which is what makes the affordance and the surface read as one thing. A
/// `.popover` rather than a hand-driven `NSPopover` because SwiftUI's is already transient (a click
/// outside dismisses it), and because the content is SwiftUI either way; the AppKit half of this is
/// only the *placement* (`boardInfoTitlebarAccessory`).
///
/// **Whole-area clickable, not just the chevron** (the card that widened this from a 20×18 chevron
/// button to the full name/branch/chevron button): the title and branch strings sit inside the same
/// `Button`, so a click anywhere across the board's name — or its branch, when shown — opens the
/// popover exactly as a click on the chevron always has.
struct BoardInfoWidget: View {
let store: BoardStore
let recents: StyleRecents
/// The tier and the git state this board's **session** composed with — read once, at the moment
/// the widget is installed, and never re-derived (12-editions.md ▸ The entitlement: "a lapse
/// never interrupts an open session"). `git` is a reference type and `@Observable`, so add-git
/// flipping the mode, or a branch switch, redraws the widget without anything here being
/// re-created.
let tier: Tier
let git: HistoryStore?
@Bindable var presentation: BoardInfoPresentation
/// The window's settings sheet, so the popover's git section can carry the **Board Settings…**
/// row that opens it (03-board-ui.md ▸ Board popover: "A Board Settings… row opens the sheet —
/// the popover's one setup affordance"). `nil` where there is no window to present a sheet on,
/// which is the accessory-installation tests' shape and reads as a popover with no row.
let settings: BoardSettingsPresentation?
/// The widget's two strings, computed fresh on every body evaluation rather than cached anywhere.
/// That matters here specifically: `boardInfoTitlebarAccessory` builds this view exactly **once**
/// at install, so a value read anywhere but inside `body` would freeze at the widget's birth and
/// never see a later rename or branch switch. `store.snapshot` and `git.branch` are both
/// `@Observable`, so reading them here is what makes the title and branch live.
private var summary: BoardInfoTitlebarSummary {
BoardInfoTitlebarSummary(
snapshotTitle: store.snapshot.title.value,
rootURL: store.rootURL,
tier: tier,
mode: git?.mode ?? .none,
branch: git?.branch
)
}
var body: some View {
Button {
presentation.toggle()
} label: {
HStack(spacing: 4) {
Text(summary.title)
// Styled like a titlebar title, because that is what it now stands in for
// (`BoardWindowHost` hides the system title display in favor of this widget).
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.tail)
// Yields space to the branch string and chevron first when the two don't both
// fit inside the width cap below — the board's own name is the more load-bearing
// half of the pair.
.layoutPriority(1)
if let branch = summary.branch {
Text("—")
.foregroundStyle(.secondary)
Text(branch)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
Image(systemName: "chevron.down")
.imageScale(.small)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
}
.font(.system(size: 13))
// A long board name (or branch) must not swallow the whole titlebar — capped rather
// than left to grow, with the truncation above doing the rest. Height stays the
// original chevron's, which is what keeps the accessory titlebar-appropriate.
.frame(maxWidth: 400, alignment: .leading)
.frame(height: 18)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help("Board Info")
.accessibilityLabel(accessibilityLabel)
.accessibilityHint("Shows board info")
// Populates the branch line the moment a git-mode board's window opens, rather than waiting
// on the popover's own read (`BoardGitControls`'s `.task`, which only runs once the popover
// has actually been opened once). The widget is on screen from the start, so it is the
// earlier honest place to ask; `refreshBranch()` is already a no-op outside git mode, so this
// costs nothing on the other four postures.
.task { await git?.refreshBranch() }
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
BoardInfoView(store: store, recents: recents, tier: tier, git: git, settings: settings)
}
}
/// What VoiceOver reads for the button, now that it says more than "Board Info": the board's
/// name, plus the branch when the widget is showing one — `.help` keeps the shorter "Board Info"
/// wording as the hover tooltip, and `.accessibilityHint` on the widget itself still names what
/// the button does.
private var accessibilityLabel: String {
guard let branch = summary.branch else { return summary.title }
return "\(summary.title), branch \(branch)"
}
}
// MARK: - The widget's strings
/// **The window-title widget's two strings, as one pure function** of the board's on-disk title, its
/// folder, and the session's git posture — pulled out so the fallback rule and the branch-visibility
/// rule are each assertable without a widget on screen (`BoardInfoTitlebarSummaryTests`), the same
/// reason `BoardGitSection.resolve` exists one level down in this file.
///
/// **Title.** `AppModel.displayName(of:)` is the same rule applied to the window's actual title
/// (`BoardWindowHost.windowTitle` reads it, and `.navigationTitle` keeps feeding it to the Window
/// menu, Exposé, VoiceOver and restoration even though the title bar's own rendering of it is now
/// hidden — see `BoardWindowHost.configureWindow`): the on-disk `title`, falling back to the folder
/// name sans extension when absent or empty (01-storage-format.md § Board naming). Restated here
/// against the raw title string and `rootURL` rather than a `BoardStore`, so this seam is testable
/// with plain values and no fixture board on disk — the one duplication this card leaves behind
/// rather than reshaping `AppModel.displayName(of:)`'s signature to fit both call sites.
///
/// **Branch.** Shown only when the board is actually git-mode under Pro — `tier == .pro && mode ==
/// .git` with a non-`nil` branch — the same condition family `BoardGitSection.resolve`'s `.branch`
/// case covers. The free tier and an inert `.git` (mode `.none` or `.repoNested`) show no branch;
/// neither does a git-mode board whose branch has not been read yet (`HistoryStore.branch` starts
/// `nil` until `refreshBranch()` answers, which the widget's own `.task` kicks off at open).
struct BoardInfoTitlebarSummary: Equatable {
let title: String
let branch: String?
init(snapshotTitle: String?, rootURL: URL, tier: Tier, mode: BoardGitMode, branch: String?) {
if let snapshotTitle, !snapshotTitle.isEmpty {
self.title = snapshotTitle
} else {
self.title = rootURL.deletingPathExtension().lastPathComponent
}
self.branch = (tier == .pro && mode == .git) ? branch : nil
}
}
/// The widget wearing AppKit's clothes, because SwiftUI has no way to put a view in the titlebar:
/// an `NSTitlebarAccessoryViewController` hosting the button, laid out `.leading` so it sits in the
/// title bar beside the window title rather than in the window's content.
///
/// `HostedWindowController.installTitlebarAccessory` owns the rest of the lifecycle — one per
/// window, removed on detach — for the same reason it owns the delegate proxying: the window is
/// SwiftUI's, and anything hung on it has to be taken back off.
@MainActor
/// `tier`/`git` default to the free tier's posture — a popover with no git section at all — and
/// `settings` to no sheet, so that a caller with no session in hand (the accessory-installation
/// tests, which are about AppKit plumbing rather than about git) describes a board honestly rather
/// than by accident. The app's own call site passes the session's values explicitly.
func boardInfoTitlebarAccessory(
store: BoardStore,
recents: StyleRecents,
tier: Tier = .free,
git: HistoryStore? = nil,
presentation: BoardInfoPresentation,
settings: BoardSettingsPresentation? = nil
) -> NSTitlebarAccessoryViewController {
let hosting = NSHostingView(
rootView: BoardInfoWidget(
store: store,
recents: recents,
tier: tier,
git: git,
presentation: presentation,
settings: settings
)
)
// The titlebar lays its accessories out by fitting size, and a hosting view that measured itself
// as zero would be an invisible, unclickable widget. `.intrinsicContentSize` re-measures on every
// SwiftUI update, so this starting frame only has to survive the first layout pass before the
// widget's real content replaces it — but that first pass is exactly what a 20×18 placeholder
// (the old chevron-only width) would clamp now that the widget's content can run out to 400pt:
// wide enough that the widest realistic first paint is never visibly clipped before the resize.
hosting.sizingOptions = [.intrinsicContentSize]
hosting.frame = NSRect(x: 0, y: 0, width: 200, height: 18)
let controller = NSTitlebarAccessoryViewController()
controller.view = hosting
controller.layoutAttribute = .leading
return controller
}
// MARK: - The popover's content
/// Two sections and, on a `.git`-bearing board, a contextual note — one view (03-board-ui.md §
/// Board popover; 12-editions.md § Base and `.git`).
///
/// Width is the style editor's — 268 points, the number that keeps the Style… popover narrow enough
/// to sit beside a card — so the embedded editor lays out here exactly as it does at its other two
/// anchors rather than being stretched by a container with its own opinion.
struct BoardInfoView: View {
let store: BoardStore
let recents: StyleRecents
let tier: Tier
let git: HistoryStore?
let settings: BoardSettingsPresentation?
/// **The popover's own dismissal**, used by exactly one control: the Board Settings… row, whose
/// job is to close this surface and open the sheet. The popover is presented by `isPresented`, so
/// the environment action drives the same flag the widget's button does — nothing here has to be
/// handed the widget's binding to put it down.
@Environment(\.dismiss) private var dismiss
/// Whether this board carries a `.git` — checked once, off disk, when the view is built (which
/// is every time the popover opens, since `BoardInfoWidget` hands `.popover` a fresh instance).
/// See `BoardGitNote.hasGitDirectory(at:)` for why a live-updating fact isn't needed here.
///
/// **The free tier's input only.** Under Pro the section reads the session's detected mode
/// instead — a fact settled at open, which is where 06-history-undo.md puts detection — and this
/// stays what it always was: the one quiet question the free tier asks of a board's folder.
private let hasGitDirectory: Bool
/// The style editor brings its own padding, so the sections around it carry the same number by
/// hand instead of an outer padding that would double up on it — **the editor's own figure**
/// (`StyleEditorLayout.sectionSpacing`), which is font-derived, so the popover's chrome scales
/// with the grids inside it (10-accessibility.md's full-relative-scaling rule).
private var inset: CGFloat {
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
}
init(
store: BoardStore,
recents: StyleRecents,
tier: Tier = .free,
git: HistoryStore? = nil,
settings: BoardSettingsPresentation? = nil
) {
self.store = store
self.recents = recents
self.tier = tier
self.git = git
self.settings = settings
// Asked only where it is the answer: under Pro the mode already knows, and a free-tier
// board is the only one this question is for (12-editions.md ▸ The free tier and `.git`).
self.hasGitDirectory = tier == .free && BoardGitNote.hasGitDirectory(at: store.rootURL)
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
// The board's own icon, inline with its name — the same field the embedded style
// editor's symbol section below writes, offered here too since a board's identity
// is its name *and* its glyph together (03-board-ui.md § Styling ▸ Controls). No
// `undo:` — the board popover has none of its own, so this reaches the board's
// stack exactly as the embedded editor's writes do.
SymbolPicker(
current: store.snapshot.icon.value,
fallback: ItemSymbol.board,
onSelect: { name in
StyleCommand.apply(
icon: name.map { StyleChange.set($0) } ?? .remove,
to: .board,
in: store,
recents: recents
)
}
)
.disabled(!store.acceptsBoardMutations)
BoardRenameField(store: store)
}
}
.padding(inset)
Divider()
VStack(alignment: .leading, spacing: 0) {
sectionHeader("Styling")
.padding(.horizontal, inset)
.padding(.top, inset)
// Always the board, whatever is selected. The ⌥⌘S anchor is the selection-aware one
// ("nothing selected = the board"); this embed is the surface that exists *because*
// the board is a style target, so it can have no other target (§ Styling ▸
// Controls: "the board popover's target is the board itself").
// No symbol section — the inline `SymbolPicker` beside the rename field above is
// the board glyph's one surface in this popover.
StyleEditorView(store: store, recents: recents, target: .board, showsSymbols: false)
}
// Contextual, not standing (12-editions.md, settled 2026-07-27): an ordinary free-tier
// board adds nothing here at all — no header, no divider, no placeholder — and the
// popover ends at Styling, complete in itself. What a Pro board adds instead is the
// mode-aware git section (03-board-ui.md ▸ Board popover), which is a *section*, header
// and all, because under Pro git is a feature of the board rather than a signpost.
gitSection
}
// 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.
.frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width)
}
/// The popover's closing section, whichever of the six postures this board is in — see
/// `BoardGitSection`.
@ViewBuilder
private var gitSection: some View {
switch BoardGitSection.resolve(tier: tier, mode: git?.mode ?? .none, hasGitDirectory: hasGitDirectory) {
case .absent:
EmptyView()
case .proPointer:
Divider()
BoardGitNote()
.padding(inset)
case .noRepository:
// Nothing daily to show on a board with no repository — so the section is the door and
// its header. Add-git itself moved to the sheet with the 2026-07-31 split; what stays
// here is the honest signpost that this board *could* have a history and where to say so.
Divider()
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Git")
boardSettingsRow
}
.padding(inset)
case .repoNested:
Divider()
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Git")
BoardGitNestedNote()
}
.padding(inset)
case .unverifiable:
Divider()
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Git")
BoardGitUnverifiableNote()
}
.padding(inset)
case .branch:
Divider()
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Git")
if let git {
BoardGitControls(git: git, isEnabled: store.acceptsBoardMutations)
}
boardSettingsRow
}
.padding(inset)
}
}
/// **The popover's one setup affordance** (03-board-ui.md ▸ Board popover) — the sheet's first
/// door, the menu row being the second (11-command-nexus.md).
///
/// **Shown only where the sheet is reachable** (`BoardSettingsAvailability`): a popover section
/// describes *this board*, so a row pointing at a surface this board cannot have would be the
/// disabled button 06 rules out one level up. The menu row is the opposite case and stays visible
/// — a menu is an inventory of the app.
///
/// **Dismiss first, then present.** The popover is transient and the sheet is not; leaving a
/// transient surface hanging over a modal one would read as two surfaces arguing about which the
/// user is in.
///
/// Not disabled by the read-only lock: opening a configuration surface is not a mutation, and the
/// controls inside it disable themselves (the Board Info ⌘I rule).
@ViewBuilder
private var boardSettingsRow: some View {
if let settings, BoardSettingsAvailability.resolve(
tier: tier,
mode: git?.mode ?? .none,
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
) {
Button("Board Settings…") {
dismiss()
settings.present()
}
.accessibilityHint("Opens the board settings sheet")
}
}
/// The section titles, matching the style editor's own headers so the popover reads as one
/// surface rather than borrowed ones.
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.subheadline.weight(.semibold))
}
}
// MARK: - Rename
/// The board rename field (03-board-ui.md § Board popover; 01-storage-format.md § Board naming).
///
/// The exits are the inline editors' — Return and focus loss commit, Escape abandons — but the
/// **placeholder is the whole of the board-naming rule made visible**: an empty field shows the
/// folder name, because that is what the window title will say, and committing empty is how a user
/// asks for exactly that. "Untitled" appears nowhere; boards do not have it.
///
/// **It is not born focused**, unlike the three inline editors. Those are each opened by a gesture
/// that means "edit this now"; this one is one control among several on a configuration surface,
/// where the keyboard path is Tab-reachability rather than a caret waiting in the first field
/// (11-command-nexus.md's class **C**).
///
/// Every handler is idempotent, for `InlineTitleField`'s reason: the exits overlap by construction —
/// Return commits and then something takes focus, which fires the focus-loss commit an instant
/// later — and `BoardStore.renameBoard` skips an unchanged title, so the second call writes nothing.
private struct BoardRenameField: View {
let store: BoardStore
@State private var draft = ""
@FocusState private var isFocused: Bool
var body: some View {
TextField(fallbackName, text: $draft)
.textFieldStyle(.roundedBorder)
.lineLimit(1)
.focused($isFocused)
.onSubmit { store.renameBoard(draft) }
// **Escape steps outward one layer per press** (04-interactions.md ▸ Grammar): a dirty
// field abandons its edit and keeps the popover open, and an unedited one lets the press
// through to the popover's own dismissal. Reverting first is also what makes a dismissal
// safe on the paths where the press never reaches here — the focus-loss commit that
// follows sees a draft equal to what is on disk and writes nothing.
.onKeyPress(.escape) {
guard draft != committed else { return .ignored }
draft = committed
return .handled
}
.onChange(of: isFocused) { _, focused in
guard !focused else { return }
store.renameBoard(draft)
}
.onAppear { draft = committed }
// A foreign rename — an agent, a hand edit, a sync — landing behind an open popover
// updates the field, but never under the user's fingers: a draft being typed is the
// user's, and the reload is not an edit to it (02-architecture.md § Live-reload
// resilience, the same courtesy the inline editors get by tracking their UUID).
.onChange(of: committed) { _, title in
guard !isFocused else { return }
draft = title
}
// The popover can be dismissed without the field ever reporting focus loss, and a
// dismissal is a commit like any other click-away. Idempotent with the handler above.
.onDisappear { store.renameBoard(draft) }
// The read-only lock disables every mutating surface (02-architecture.md § The lock's
// scope) — and the popover *stays open* under it, which is the style popover's settled
// precedent: the lock is a condition the banner is already explaining, not a reason to
// yank a surface away. `acceptsBoardMutations` rather than `isReadOnly` alone so this
// field and the editor below it disable as one surface rather than in halves.
.disabled(!store.acceptsBoardMutations)
}
/// The `title` on disk, as the last reload read it — "" for a board that has none.
private var committed: String { store.snapshot.title.value ?? "" }
/// The folder name, sans extension: what the window title shows when `title` is absent
/// (01-storage-format.md § Board naming), and therefore the honest thing for an empty field to
/// promise. Read off `rootURL` rather than `snapshot.rootURL` so a Finder rename absorbed
/// mid-session shows here immediately.
private var fallbackName: String {
store.rootURL.deletingPathExtension().lastPathComponent
}
}
// MARK: - Git
/// **What the popover's git slot is, for one board** (03-board-ui.md ▸ Board popover;
/// 06-history-undo.md ▸ Rules; 12-editions.md ▸ The free tier and `.git`) — a pure function of two
/// facts, so the posture matrix is provable without a popover on screen.
///
/// The free tier's two cases are settled 2026-07-27 and unchanged by this card: absent on an
/// ordinary board, a one-line Pro pointer on a board carrying an inert `.git`. The Pro cases are the
/// mode, one to one — and the mode-`none` and repo-nested pair is where the design is most
/// insistent: a repo-nested board gets **prose, not a disabled button**. "The option is absent
/// because it *can't* apply, and the UI should teach that rather than look broken" (06 ▸ Rules).
/// `unverifiable` (the git-detection axis) joins as a fourth Pro case, structurally identical to
/// `repoNested` but worded as its own honest prose — a denial is not a nesting.
///
/// **The 2026-07-31 popover/sheet split thinned two of these cases without removing either.** Setup
/// left the popover for the board settings sheet, so mode `none` no longer renders an action here at
/// all (the case was called `.addGit` when it did — a name that would now be describing a control
/// that lives in another file, so it is `.noRepository`), and the git-mode case lost branch creation
/// and the identity fields. What each case still *is* is a posture, which is why the matrix and its
/// test survived the move unchanged.
enum BoardGitSection: Equatable, CaseIterable {
/// Nothing at all — the free tier's ordinary board, where "the popover is rename + style,
/// complete in itself".
case absent
/// The free tier's one-line explanation of an inert `.git`, and the app's one in-context pointer
/// to Pro (12 ▸ Tier naming).
case proPointer
/// Pro, mode `none`: a board that could have a history and has none. There is no daily surface
/// for that — the section is the header and the Board Settings… row, where add-git now lives
/// (03 ▸ Board settings sheet).
case noRepository
/// Pro, repo-nested: the honest explanation, no action — and no Board Settings… row either,
/// since nothing setup-shaped can apply (`BoardSettingsAvailability`).
case repoNested
/// Pro, unverifiable: **not** `.repoNested` — a denied ancestor check, not a found repository
/// (06 ▸ Rules ▸ Detection, "Denial is not absence"). Structurally identical to `.repoNested`
/// (no action, no Board Settings… row, `BoardSettingsAvailability` false), but its own case so
/// the view renders its own honest prose rather than the nested sentence — "unverifiable" is not
/// "nested".
case unverifiable
/// Pro, git mode: the branch/source line with the **switch** picker, the abnormal-state
/// explanation when the surface is held, and the Board Settings… row. The remote half —
/// tracking, Pull/Push, the status badges — is 07-sync-collab.md's own card and joins this same
/// posture.
case branch
static func resolve(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> BoardGitSection {
switch tier {
case .free:
// Detection never runs under the free tier, so the mode is not consulted here — the one
// question asked is whether the folder carries a `.git`, which is what the pointer is
// about (12: "any `.git` is inert … a stray like any other, preserved verbatim").
return hasGitDirectory ? .proPointer : .absent
case .pro:
switch mode {
case .none: return .noRepository
case .git: return .branch
case .repoNested: return .repoNested
case .unverifiable: return .unverifiable
}
}
}
}
/// **The repo-nested explanation** (06-history-undo.md ▸ Rules), worded as the design words it:
/// short prose in place of an action, never a hidden or greyed-out add-git.
private struct BoardGitNestedNote: View {
var body: some View {
Text("This board lives inside a repository; Lanework leaves it to that repository.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
/// **The unverifiable explanation** (06-history-undo.md ▸ Rules ▸ Detection, "Denial is not
/// absence", ruled 2026-07-31), worded as its own honest sentence rather than borrowing
/// `BoardGitNestedNote`'s — a denied ancestor check is not a found repository, and telling a user
/// their board is nested when the truth is "couldn't check" would be a lie dressed as caution.
private struct BoardGitUnverifiableNote: View {
var body: some View {
Text("Lanework couldn't verify whether this board sits inside a repository, so it isn't offering to add one here.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
/// The contextual git note — **a quiet signpost, not a feature** (12-editions.md ▸ The free tier and
/// `.git`, settled 2026-07-27, carried through the one-app collapse). The free tier has no git
/// integration (that is the Pro subscription's), so this is not a grow-in-place slot the way the old
/// `BoardGitSlot` placeholder was: there is nothing here to grow. The free tier's whole git story is
/// one line, shown only when it is true — and it is **the one in-context pointer to Pro**, the second
/// of the three places the app names it (12 ▸ Tier naming; the other two are `AboutBox` and the
/// Settings Pro section).
///
/// On an ordinary board `BoardInfoView` never instantiates this type at all — the section is
/// *absent*, matching the card window's absent History section (12: "absent, no placeholder"). Only
/// a board that carries an inert `.git` (12 ▸ the inert posture: "any `.git` is inert" — the free
/// tier never reads or writes it, whether the board's own or one a lapsed subscription left behind)
/// earns this note, worded exactly as 12 rules: an honest explanation of what the folder is, named
/// exactly where the question arises, never a standing ad for Pro.
///
/// Not `private`: `hasGitDirectory(at:)` is the pure seam `BoardInfoPopoverTests.swift` pins directly
/// (a fixture board with `.git` → true, without → false), which needs it visible past this file even
/// though nothing outside `BoardInfoPopover.swift` calls it in the app itself.
struct BoardGitNote: View {
var body: some View {
Text("This board has a git history. Lanework Pro works with it.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
/// Whether `boardRoot` carries a `.git` entry — the free tier's entire detection story, and a deliberately
/// small one: nothing in `BoardStore`, `BoardModel`, or `BoardLoader` tracks this as a live fact
/// today, because nothing needs it to be live. `FolderWatcher` filters `.git` out of the folder
/// watch by design (§ .git filtering — it exists to ignore git churn), so there is no reload
/// event this could hang off even if it wanted to; a plain, read-only `FileManager` check taken
/// once, at the moment the popover is built, is the honest amount of machinery for a single quiet
/// line. A `.git` added or removed while the popover happens to be open is stale until the next
/// open — a gap this note's own posture makes harmless, since it is a signpost, not a control.
static func hasGitDirectory(at boardRoot: URL) -> Bool {
FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path)
}
}