The titlebar widget becomes a two-line identity block: the board glyph at 22pt spanning both lines, the title over the branch (git-mode only, smaller and secondary), the em-dash retired. New Branch… returns to the switch menu behind a divider, revealing an inline name field — the pre-split shape. The board settings sheet retires whole: add-git and commit identity render inline in the Git tab's postures (BoardGitSetup.swift), the availability rule collapses into BoardGitSetupSection.resolve, and Board ▸ Board Settings… leaves the menu bar. Where 07's remote/credential setup surfaces land is deliberately left open — filed on the Redesign board. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
349 lines
19 KiB
Swift
349 lines
19 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
/// **The board popover's Git tab** (03-board-ui.md § Board popover ▸ Git tab, settled 2026-08-07) —
|
|
/// the pre-tab closing git section rehomed *whole*, and then, later the same day, **the board
|
|
/// settings sheet's contents rehomed into it too**. A repository-facts dossier in the Info tab's
|
|
/// register — commit counts, last-commit dates — was considered in the Git session and declined: the
|
|
/// popover's git surface is for *operating* and *setting up*, and per-item history is the card
|
|
/// window's History section (05-card-window.md).
|
|
///
|
|
/// ### Operating and setup, one surface again (ruled 2026-08-07)
|
|
///
|
|
/// The tab settled as "the daily face, and only that" — the 2026-07-31 popover/sheet split's half —
|
|
/// with a **Board Settings…** row pointing at the sheet that held add-git, branch creation and the
|
|
/// commit identity. That split is **reversed**: the sheet retires, the row with it, and the two setup
|
|
/// controls render inline in the postures they belong to (`BoardGitSetup.swift`). Branch creation
|
|
/// went back where it came from, the switch menu (`BoardGitControls.branchRow`). What is left is one
|
|
/// configuration home per board — the popover — reached by the widget or Board Info ⌘I, and no
|
|
/// surface that has to be validated into existence before a door can point at it.
|
|
///
|
|
/// ### No "Git" header anywhere in this tab
|
|
///
|
|
/// The tab's own label does the naming work the section's header used to (the design's phrasing), so
|
|
/// every posture below renders its content bare where the pre-tab section stacked it under a
|
|
/// `sectionHeader("Git")` and a `Divider()`. The tabs already sit under the popover header's own
|
|
/// divider; a second rule and a second "Git" would be the surface naming itself twice.
|
|
///
|
|
/// ### Every board has this tab (pivot 2026-08-07)
|
|
///
|
|
/// 12-editions.md ▸ PIVOT 2026-08-07 took git off the paywall, and 03's Git-tab note records what
|
|
/// that does to this surface: with no free-only postures left, every board resolves one of the four
|
|
/// mode postures below and **the tab is always in the strip**. The membership rule the Git session
|
|
/// ruled (the strip asks the posture) stands structurally in `BoardInfoTab` — it simply never hears
|
|
/// "absent" any more, because there is no longer a posture that says nothing.
|
|
|
|
// MARK: - The posture
|
|
|
|
/// **What the Git tab shows, for one board** (03-board-ui.md ▸ Board popover ▸ Git tab;
|
|
/// 06-history-undo.md ▸ Rules) — a pure function of the board's **mode**, so the posture matrix is
|
|
/// provable without a popover on screen.
|
|
///
|
|
/// The four 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) is 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, and the
|
|
/// 2026-08-07 reversal filled them back in.** Setup left the popover for the board settings sheet,
|
|
/// so mode `none` rendered no action here at all for a week (the case was called `.addGit` when it
|
|
/// did, and was renamed `.noRepository` when the control left) and the git-mode case lost branch
|
|
/// creation and the identity fields; the reversal retired that sheet and brought all three back
|
|
/// inline. The case name stays `.noRepository` — it describes the board, which is what a posture is
|
|
/// for, and it survived the round trip precisely because it never named a control. What each case
|
|
/// *is* is a posture, which is why the matrix and its test survived both moves unchanged.
|
|
///
|
|
/// **The 2026-08-07 tab restructure rehomed the surface, not the matrix** — the same cases, now
|
|
/// rendered as one tab each by `BoardGitTabView` rather than as a closing section of the popover's
|
|
/// single pane.
|
|
///
|
|
/// **The 2026-08-07 pivot took the tier axis out of it** (12-editions.md ▸ PIVOT 2026-08-07, the
|
|
/// same day). The two free-tier cases — `.absent` on an ordinary board, `.proPointer` on a board
|
|
/// carrying an inert `.git`, both settled 2026-07-27 — described a gate that no longer exists: git
|
|
/// is tier-independent, a `.git` at a board root is live in every tier, and detection runs at every
|
|
/// board open. So the free-tier branch, the `hasGitDirectory` input it asked for, and both cases are
|
|
/// **retired**, and what is left is the mode — which is what the postures were always about.
|
|
enum BoardGitSection: Equatable, CaseIterable {
|
|
|
|
/// Mode `none`: a board that could have a history and has none. There is no daily surface for
|
|
/// that — the tab is one caption stating the fact, and **add-git directly under it** since the
|
|
/// 2026-08-07 reversal (`BoardGitAddAction`), where the Board Settings… door stood for the week
|
|
/// the sheet existed. The fact-then-offer posture blessed 2026-08-06, restated for a surface
|
|
/// whose header is the tab label and whose offer is the control itself rather than a door to it.
|
|
///
|
|
/// **Every tier's posture since the pivot** (03 ▸ Git tab, pivot note 2026-08-07), and git stays
|
|
/// **opt-in per board**: the button is an offer, never an auto-init.
|
|
case noRepository
|
|
|
|
/// Repo-nested: the honest explanation, no action — and nothing setup-shaped either, since
|
|
/// nothing setup-shaped can apply (`BoardGitSetupSection.resolve`, empty here).
|
|
case repoNested
|
|
|
|
/// 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 setup — `BoardGitSetupSection.resolve` empty), but its own case so the view
|
|
/// renders its own honest prose rather than the nested sentence — "unverifiable" is not
|
|
/// "nested".
|
|
case unverifiable
|
|
|
|
/// Git mode: the branch/source line with the **switch** picker (which carries New Branch… again
|
|
/// since 2026-08-07), the abnormal-state explanation when the surface is held, and the commit
|
|
/// identity block. 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(mode: BoardGitMode) -> BoardGitSection {
|
|
switch mode {
|
|
case .none: return .noRepository
|
|
case .git: return .branch
|
|
case .repoNested: return .repoNested
|
|
case .unverifiable: return .unverifiable
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - The setup inventory
|
|
|
|
/// **Which setup controls this tab hosts for one board** — a pure function of the mode and of whether
|
|
/// the repository opens, so the rehomed inventory is provable without a popover on screen.
|
|
///
|
|
/// It is `BoardSettingsSection.resolve`'s successor, and deliberately its same shape: that enum was
|
|
/// the *sheet's* inventory (2026-07-31 → 2026-08-07) and it retired with the sheet, but the rule it
|
|
/// carried is about the **board**, not about the container, so it survives the reversal re-pointed at
|
|
/// the tab. Branch creation is not a case here for the same reason it was one there and is not now:
|
|
/// it went back into the switch menu (`BoardGitControls.branchRow`), which is a daily control with an
|
|
/// inline reveal rather than a standing form. pro-m2's remote and credential cards each add a case
|
|
/// here and a block in `BoardGitTabView.posture` — nothing else.
|
|
///
|
|
/// Ordered as the tab lays them out, and the order is trivially the postures' own: no board is ever
|
|
/// in both modes, so the array is one element or none. It stays an array rather than an `Optional`
|
|
/// because the pro-m2 cards land in the git-mode posture beside `.commitIdentity`.
|
|
enum BoardGitSetupSection: String, Equatable, CaseIterable, Identifiable {
|
|
|
|
/// Mode `none`: **add-git** (06-history-undo.md ▸ Rules ▸ Opt-in init) — the offer, on every tier
|
|
/// since 12-editions.md ▸ PIVOT 2026-08-07, and still never an auto-init.
|
|
case addGit
|
|
|
|
/// Mode `git`: the **commit identity** name/email that repo-local `.git/config` carries
|
|
/// (06 ▸ Interaction with external writers).
|
|
case commitIdentity
|
|
|
|
var id: String { rawValue }
|
|
|
|
/// The block's header — a heading VoiceOver navigates by (10-accessibility.md's navigable-header
|
|
/// rule, carried over from the sheet's sections). `.addGit` has none: it renders directly under
|
|
/// the no-repository note, which already states what the posture is, and a "Git" header inside
|
|
/// the Git tab would be the surface naming itself twice (this file's opening note).
|
|
var title: String? {
|
|
switch self {
|
|
case .addGit: nil
|
|
case .commitIdentity: "Commit Identity"
|
|
}
|
|
}
|
|
|
|
/// - Parameter isRepositoryUnreadable: whether the board's `.git` exists and will not open
|
|
/// (`HistoryStore.isRepositoryUnreadable`). Defaulted, because it can only ever be true in mode
|
|
/// `git` — every other mode has no repository for the probe to have failed on, and a caller
|
|
/// that has no git state to ask is describing one of those boards.
|
|
///
|
|
/// `nonisolated` for `BoardGitControls.switchTargets`' reason: a pure answer over plain values,
|
|
/// reachable from a test with no actor to hop to.
|
|
nonisolated static func resolve(
|
|
mode: BoardGitMode,
|
|
isRepositoryUnreadable: Bool = false
|
|
) -> [BoardGitSetupSection] {
|
|
switch mode {
|
|
case .none:
|
|
return [.addGit]
|
|
case .git:
|
|
// **An unreadable repository hosts no setup** (06-history-undo.md ▸ Rules, the
|
|
// corrupt-`.git` loud failure, ruled 2026-07-31: "the whole git surface paused …
|
|
// Lanework leaves the repository untouched"), and it lands on repo-nested's emptiness by
|
|
// repo-nested's own reasoning, one step further along: writing an identity is a *write*
|
|
// into the repository's own config, and there is no repository the app can open to write
|
|
// it into. The posture is not empty, though — that is the difference the sheet could not
|
|
// express and the tab can: `BoardGitControls` still renders, holding, with its own
|
|
// sentence explaining the state (`BoardGitBranchSurface.unreadableNote`). What the
|
|
// unreadable board loses is the setup block alone.
|
|
//
|
|
// The mode stays `.git` throughout — this is emptiness *within* git mode, never a fall
|
|
// to mode none, which is what would let add-git be offered against an existing `.git`.
|
|
return isRepositoryUnreadable ? [] : [.commitIdentity]
|
|
case .repoNested:
|
|
// **Nothing setup-shaped can apply** (06 ▸ Rules): the board lives inside a repository
|
|
// Lanework leaves alone, so there is no add-git (the design is insistent that the option
|
|
// is *absent*, "prose, not a disabled button") and no repo-local config of ours to write.
|
|
// The posture's whole content is its explanation.
|
|
return []
|
|
case .unverifiable:
|
|
// **Structurally the same emptiness as `.repoNested`, for the same reason** (06 ▸ Rules ▸
|
|
// Detection, "Denial is not absence"): a denied ancestor check can never be told apart
|
|
// from a repository actually being there, so add-git stays unreachable. Only the
|
|
// posture's *prose* tells the two apart — this inventory does not, because there is
|
|
// nothing to set up on either.
|
|
return []
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - The notes
|
|
|
|
/// **The no-repository caption** (03-board-ui.md ▸ Board popover ▸ Git tab) — the fact, stated, above
|
|
/// the add-git button that offers to change it.
|
|
///
|
|
/// One sentence in its siblings' register, and deliberately not a header: the tab label already says
|
|
/// "Git", so what is left to say is what this board's git story currently *is*. "Yet" is the whole
|
|
/// posture in a word — the button directly below it is where a user says otherwise. (It read "above
|
|
/// the Board Settings… door" between 2026-07-31 and the 2026-08-07 reversal; the sentence never
|
|
/// changed, only what stands under it.)
|
|
private struct BoardGitNoRepositoryNote: View {
|
|
|
|
var body: some View {
|
|
Text("This board has no git history yet.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
|
|
/// **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)
|
|
}
|
|
}
|
|
|
|
// MARK: - The tab
|
|
|
|
/// The Git tab's surface: whichever of the four postures this board is in, with the setup controls
|
|
/// that posture hosts rendered inline in it (`BoardGitSetupSection`).
|
|
struct BoardGitTabView: View {
|
|
|
|
let store: BoardStore
|
|
|
|
/// The git state this board's **session** composed with, handed down from `BoardInfoView` rather
|
|
/// than re-derived — 12-editions.md ▸ The entitlement ("a lapse never interrupts an open
|
|
/// session"). `git` is `@Observable`, so a branch switch or a pause landing while the tab is open
|
|
/// repaints it.
|
|
///
|
|
/// Optional because a caller with no session in hand (previews, the accessory-installation tests)
|
|
/// has none to hand over; a `nil` reads as mode `none`, which is the honest description of a
|
|
/// board nothing has detected anything about yet. Since the 2026-08-07 pivot it is no longer a
|
|
/// *tier* signal — every session composes one (12 ▸ PIVOT 2026-08-07).
|
|
let git: HistoryStore?
|
|
|
|
/// The popover's own padding figure (`BoardInfoView.inset`), matching `BoardInfoTabView`'s and
|
|
/// `BoardThemeTabView`'s own parameter — the tab pads by this amount instead of restating the
|
|
/// derivation.
|
|
let inset: CGFloat
|
|
|
|
var body: some View {
|
|
posture
|
|
.padding(inset)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
/// The tab's content, posture by posture — see `BoardGitSection`. No section header and no
|
|
/// dividers: the tab label names this surface, and the tabs already sit under the popover
|
|
/// header's rule.
|
|
@ViewBuilder
|
|
private var posture: some View {
|
|
switch BoardGitSection.resolve(mode: git?.mode ?? .none) {
|
|
case .noRepository:
|
|
// Nothing daily to show on a board with no repository — so the tab is the fact and the
|
|
// offer. Add-git spent a week behind the settings sheet's door (2026-07-31 → the
|
|
// 2026-08-07 reversal) and is back inline under the note that says why it is there. A
|
|
// `nil` git — previews, the accessory-installation tests — renders the note alone: the
|
|
// fact is the honest thing to say about a board nothing has been detected about, and
|
|
// there is nothing to add a repository *to*.
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
BoardGitNoRepositoryNote()
|
|
if let git, setup.contains(.addGit) {
|
|
BoardGitAddAction(git: git, isEnabled: store.acceptsBoardMutations)
|
|
}
|
|
}
|
|
|
|
case .repoNested:
|
|
BoardGitNestedNote()
|
|
|
|
case .unverifiable:
|
|
BoardGitUnverifiableNote()
|
|
|
|
case .branch:
|
|
// The daily face first — the branch line with its switch menu, which carries New Branch…
|
|
// again since the 2026-08-07 reversal — then the one setup block this posture hosts.
|
|
// The identity block is gated on the setup inventory rather than on a condition spelled
|
|
// out here, which is what keeps "an unreadable repository hosts no setup" one rule with
|
|
// one test (`BoardGitSetupSection.resolve`); `BoardGitControls`' own unreadable sentence
|
|
// stands alone under it.
|
|
//
|
|
// `inset` as the gap rather than the 6pt row rhythm: the identity block is a *section*
|
|
// under its own heading, and the popover's section spacing is the figure the retired
|
|
// sheet used between its sections for the same reason (`BoardInfoView.inset`).
|
|
VStack(alignment: .leading, spacing: inset) {
|
|
if let git {
|
|
BoardGitControls(git: git, isEnabled: store.acceptsBoardMutations)
|
|
}
|
|
if let git, setup.contains(.commitIdentity) {
|
|
setupBlock(.commitIdentity) {
|
|
BoardGitIdentityFields(git: git, isEnabled: store.acceptsBoardMutations)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What this board's setup half holds — the sheet's inventory rule, re-pointed at the tab it
|
|
/// rehomed into (`BoardGitSetupSection`). Read once per posture branch rather than re-derived
|
|
/// beside each block.
|
|
private var setup: [BoardGitSetupSection] {
|
|
BoardGitSetupSection.resolve(
|
|
mode: git?.mode ?? .none,
|
|
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
|
|
)
|
|
}
|
|
|
|
/// A setup block under its own header, where the section carries one.
|
|
///
|
|
/// **The header is an accessibility structure, not decoration** (10-accessibility.md's
|
|
/// navigable-header rule, which the retired sheet's sections carried and which came along with
|
|
/// them): the rotor jumps between headings rather than walking one flat run of controls. This is
|
|
/// the tab's *only* header — the no-repository posture's control has none, and the tab label
|
|
/// still does the naming for the surface as a whole (this file's opening note).
|
|
@ViewBuilder
|
|
private func setupBlock(
|
|
_ section: BoardGitSetupSection,
|
|
@ViewBuilder content: () -> some View
|
|
) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
if let title = section.title {
|
|
Text(title)
|
|
.font(.subheadline.weight(.semibold))
|
|
.accessibilityAddTraits(.isHeader)
|
|
}
|
|
content()
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|