The git surfaces leave the glass — tab, trail, branch line, and the remote pair, peeled
Step 3 of strategy/01-git-excision.md: the popover strip is Info/Theme/Sync, the titlebar widget says the name alone, the card window's History section and its slot go, Board ▸ Pull/Push comes out with the RemoteCommands scaffold, and View ▸ History re-tags from the commit trail to the deferred foreign-change journal. The Sync placeholder re-annotates to the future ops-based sync service. Four git test suites leave with the surfaces they pinned (BoardGitSetup, BoardInfoPopover, BranchSwitch, GitUndo). The git engine still compiles underneath, unreferenced by UI. 2,890 tests green. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -1,390 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - The git-mode section's state
|
||||
|
||||
/// **What the popover's git section shows on a git-mode board** — a pure function of four facts, so
|
||||
/// the surface 06-history-undo.md describes is assertable without a popover on screen.
|
||||
///
|
||||
/// It exists for the same reason `BoardGitSection.resolve` does one level up: the *posture* is the
|
||||
/// part worth pinning, and the SwiftUI that renders it is not. Three rules live here —
|
||||
///
|
||||
/// - **A paused repository names its state and disables the controls** (06 ▸ Rules ▸ Abnormal repo
|
||||
/// states: "Undo/Redo and the branch controls disable … the popover's git section names the state
|
||||
/// plainly … and says resolving it belongs to the tool that created it").
|
||||
/// - **A read-only board disables them too** (02-architecture.md ▸ The lock's scope, which names "the
|
||||
/// popover's git controls" outright).
|
||||
/// - **A switch in flight disables them**, so a second click cannot start a second checkout.
|
||||
/// - **An unreadable repository reads as broken, never as still loading** (06 ▸ Rules, the
|
||||
/// corrupt-`.git` loud failure, ruled 2026-07-31: "Never a silent placeholder discovered only in
|
||||
/// the popover").
|
||||
struct BoardGitBranchSurface: Equatable {
|
||||
|
||||
/// What the branch line reads — the branch name, the short hash on a detached HEAD
|
||||
/// (`GitRepository.branchName` decides which), the placeholder while the first read is in
|
||||
/// flight, or `unavailableLabel` when there is no readable repository for it to name.
|
||||
let branchLabel: String
|
||||
|
||||
/// Whether that label is the placeholder rather than an answer.
|
||||
let isReadingBranch: Bool
|
||||
|
||||
/// The pause's own sentence (`GitRepositoryPause.explanation`), or `nil` when the surface is live.
|
||||
let pauseExplanation: String?
|
||||
|
||||
/// **Whether the pause is the unopenable repository** — the one pause whose surface is not the
|
||||
/// pause note: nothing is in progress and no tool is coming to finish it, so the section says
|
||||
/// its own sentence instead (`unreadableNote`), and the branch line has no answer to wait for.
|
||||
let isRepositoryUnreadable: Bool
|
||||
|
||||
/// Whether the branch controls accept a click — the switch picker, and the New Branch… reveal
|
||||
/// behind its divider (`BoardGitControls`), so that a paused repository, a read-only board and a
|
||||
/// switch in flight close the whole branch affordance by one rule. (The settings sheet's standing
|
||||
/// Create field resolved this same surface between 2026-07-31 and the 2026-08-07 reversal; the
|
||||
/// field came back into the menu, the rule never moved.)
|
||||
let controlsEnabled: Bool
|
||||
|
||||
/// The line the branch display is read as by VoiceOver.
|
||||
///
|
||||
/// The broken case is spelled out rather than left to fall through "Branch \(label)": the label
|
||||
/// is a *state* there, not a name, and "Branch Unavailable" would read as a branch somebody
|
||||
/// called Unavailable.
|
||||
var accessibilityLabel: String {
|
||||
if isRepositoryUnreadable { return "Branch unavailable" }
|
||||
return isReadingBranch ? "Reading branch" : "Branch \(branchLabel)"
|
||||
}
|
||||
|
||||
static let placeholder = "…"
|
||||
|
||||
/// **What the branch line reads when the repository will not open** — an answer, not a
|
||||
/// placeholder, which is the whole of the ruling's "fails loudly" at this one control: the
|
||||
/// placeholder means "still reading" and would go on meaning it forever here.
|
||||
static let unavailableLabel = "Unavailable"
|
||||
|
||||
/// The second half of the paused sentence — 06's "says resolving it belongs to the tool that
|
||||
/// created it", said in the app's own voice and paired with the promise that makes it safe to
|
||||
/// wait: the app is not going to touch the repository behind the user's back.
|
||||
static let pauseCaption =
|
||||
"Finishing it belongs to the tool that started it; Lanework leaves the repository untouched."
|
||||
|
||||
/// **The popover's own sentence for an unreadable repository** (06 ▸ Rules, the corrupt-`.git`
|
||||
/// loud failure, ruled 2026-07-31: "with the whole git surface paused … and the popover's git
|
||||
/// section naming the state").
|
||||
///
|
||||
/// A sibling of the nested and unverifiable notes (`BoardGitTabView`) and written in their
|
||||
/// register — one sentence, the state first and the consequence after — rather than the pause
|
||||
/// note's two lines, because both of *those* lines would be wrong here: nothing is "in
|
||||
/// progress", and there is no tool whose job it is to finish it. What it keeps from the pause
|
||||
/// note is the promise that matters most on a repository the app cannot read, in the ruling's
|
||||
/// own words.
|
||||
///
|
||||
/// It is deliberately **not** the banner's sentence (`BannerCenter.repositoryUnreadableMessage`)
|
||||
/// re-used: the strip announces a condition to somebody who has not asked, and this answers
|
||||
/// somebody looking straight at the git section — the register the neighbouring notes set.
|
||||
static let unreadableNote =
|
||||
"Lanework can't read this board's git repository, so history is paused; the repository is left untouched."
|
||||
|
||||
static func resolve(
|
||||
branch: String?,
|
||||
pause: GitRepositoryPause?,
|
||||
isSwitching: Bool,
|
||||
isWritable: Bool
|
||||
) -> BoardGitBranchSurface {
|
||||
// Derived from the pause rather than passed in beside it: the pause *is* how this state is
|
||||
// carried everywhere else (`GitRepositoryPause.unreadable`, seeded by the detection-time
|
||||
// probe and refreshed by every later read), so a second parameter would be a second answer
|
||||
// to one question — and a caller could hold them apart.
|
||||
let unreadable = pause == .unreadable
|
||||
return BoardGitBranchSurface(
|
||||
branchLabel: unreadable ? unavailableLabel : (branch ?? placeholder),
|
||||
// Never "reading" on an unreadable repository: there is nothing in flight, and the line
|
||||
// the ruling forbids is exactly the one that says otherwise forever.
|
||||
isReadingBranch: !unreadable && branch == nil,
|
||||
pauseExplanation: pause?.explanation,
|
||||
isRepositoryUnreadable: unreadable,
|
||||
controlsEnabled: pause == nil && isWritable && !isSwitching
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The git-mode section
|
||||
|
||||
/// **The popover's git section on a board that has a repository** (03-board-ui.md ▸ Board popover;
|
||||
/// 06-history-undo.md ▸ Branch switching).
|
||||
///
|
||||
/// **The whole branch affordance**: the branch display with its **switch** picker, the *New Branch…*
|
||||
/// entry behind that menu's divider and the inline field it reveals, and the pause explanation when
|
||||
/// the surface is held.
|
||||
///
|
||||
/// **Creation left and came back.** The 2026-07-31 popover/sheet split moved it to the board settings
|
||||
/// sheet as a standing Create field; the **2026-08-07 reversal** retired that sheet and restored the
|
||||
/// shape the sheet's own doc comment had described as "the right shape *there*" — an entry inside the
|
||||
/// switch menu that reveals an inline field. The commit-identity fields came back the same day, to
|
||||
/// the Git tab a level up (`BoardGitSetup.swift`), which is why they are not here: they are the
|
||||
/// board's setup, and this is the branch.
|
||||
///
|
||||
/// **Shaped for the half that is not here yet.** Remote tracking, Pull/Push and the status badges are
|
||||
/// 07-sync-collab.md's own cards, and this section is arranged so they join as one more block under
|
||||
/// the branch controls — nothing here is nested inside anything they would have to be pulled out of,
|
||||
/// and nothing about the branch controls assumes there is no upstream to show beside them.
|
||||
struct BoardGitControls: View {
|
||||
|
||||
let git: HistoryStore
|
||||
|
||||
/// The read-only lock's reach (02-architecture.md ▸ The lock's scope): a board that refuses writes
|
||||
/// refuses a checkout most of all — it rewrites the tree the lock exists to stop describing.
|
||||
let isEnabled: Bool
|
||||
|
||||
/// Whether **New Branch…** has been picked and its field is standing open — the reveal, which is
|
||||
/// the menu entry's whole behaviour (03-board-ui.md ▸ Board popover ▸ Git tab, 2026-08-07). It
|
||||
/// starts closed on every popover open, because the popover is rebuilt fresh each time
|
||||
/// (`BoardInfoWidget`), which is exactly the transience the reveal shape is for.
|
||||
@State private var isCreatingBranch = false
|
||||
|
||||
/// The name being typed, uncommitted. Cleared by a create and by Escape's first press.
|
||||
@State private var draft = ""
|
||||
|
||||
/// Focus lands in the field the moment it appears, unlike the popover's rename field: this one is
|
||||
/// opened by a gesture that means "name a branch now", which is the inline editors' case rather
|
||||
/// than the configuration-surface case (11-command-nexus.md's class **C** distinguishes the two).
|
||||
@FocusState private var isFieldFocused: Bool
|
||||
|
||||
private var surface: BoardGitBranchSurface {
|
||||
BoardGitBranchSurface.resolve(
|
||||
branch: git.branch,
|
||||
pause: git.committer?.pause,
|
||||
isSwitching: git.switcher?.isSwitching ?? false,
|
||||
isWritable: isEnabled
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
branchRow
|
||||
|
||||
if isCreatingBranch {
|
||||
creationField
|
||||
}
|
||||
|
||||
// **The unreadable repository names itself in its own sentence** (06 ▸ Rules, the
|
||||
// corrupt-`.git` loud failure) — checked before the pause note because it *is* a pause,
|
||||
// and the pause note's second line ("finishing it belongs to the tool that started it")
|
||||
// would be advice about an operation nobody started.
|
||||
if surface.isRepositoryUnreadable {
|
||||
caption(BoardGitBranchSurface.unreadableNote, tone: .primary)
|
||||
} else if let explanation = surface.pauseExplanation {
|
||||
pauseNote(explanation)
|
||||
}
|
||||
|
||||
if let failure = git.switcher?.lastFailure {
|
||||
caption(failure.message, tone: .red)
|
||||
}
|
||||
}
|
||||
// Every read the section needs, taken when it appears rather than held live: the popover is
|
||||
// built fresh on each open (`BoardInfoWidget`), and none of these is a fact the board's
|
||||
// watcher could deliver — `.git` is filtered out of the watch by design.
|
||||
.task {
|
||||
await git.refreshBranch()
|
||||
await git.committer?.refreshPause()
|
||||
await git.switcher?.refreshBranches()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: The branch line
|
||||
|
||||
/// The branch display and the switch, as one control: the line *is* the picker, which is what
|
||||
/// makes "branch/source display and switching" one affordance rather than a label with a button
|
||||
/// beside it.
|
||||
///
|
||||
/// **Two halves under a divider** since the 2026-08-07 reversal: the switch targets above, and
|
||||
/// **New Branch…** below, which reveals the inline field under this row rather than acting. That
|
||||
/// is the shape the 2026-07-31 split took creation *out* of — into the settings sheet's standing
|
||||
/// Create field — and the shape the reversal restored when the sheet retired; the sheet's own doc
|
||||
/// comment had called it "the right shape *there*", meaning here.
|
||||
///
|
||||
/// **The menu is therefore never empty**, which is what the divider guarantees rather than the
|
||||
/// content: New Branch… always applies to a board that has a repository. **A single-branch board
|
||||
/// still opens onto a disabled explanatory row** in the upper half (03-board-ui.md ▸ Board popover
|
||||
/// ▸ Git tab, ruled 2026-08-06 and built with the tab): an unexplained gap above the divider would
|
||||
/// read as a menu that lost something, not as a board with one branch.
|
||||
private var branchRow: some View {
|
||||
// Resolved once and handed to both halves of the menu builder: the emptiness *is* the
|
||||
// condition being rendered, so asking twice would be asking the same question of a store
|
||||
// that could answer differently between the two reads.
|
||||
let targets = otherBranches
|
||||
|
||||
return HStack(spacing: 6) {
|
||||
Image(systemName: "arrow.triangle.branch")
|
||||
.imageScale(.small)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Menu {
|
||||
if targets.isEmpty {
|
||||
// **The single-branch board's row** (03-board-ui.md ▸ Board popover ▸ Git tab,
|
||||
// ruled 2026-08-06). It teaches the one thing an empty upper half leaves a user
|
||||
// to guess at: *why* there is nothing to pick — this half holds only the
|
||||
// **other** local branches, and there are none. (It carried a second teaching
|
||||
// while creation lived in the settings sheet, standing where "New Branch…" used
|
||||
// to; the 2026-08-07 reversal put that entry back one line below, so the row's
|
||||
// second job is done and the sentence is now exactly what it says.)
|
||||
//
|
||||
// A bare `Text` inside a `Menu` is AppKit's standard disabled item: greyed,
|
||||
// unclickable, and read by VoiceOver as disabled text rather than as an
|
||||
// actionable row — which is exactly the register a sentence explaining an absence
|
||||
// wants (06 ▸ Rules: teach, never look broken; never a disabled button pretending
|
||||
// to be a control).
|
||||
Text("No other branches")
|
||||
} else {
|
||||
ForEach(targets, id: \.self) { name in
|
||||
Button(name) {
|
||||
Task { await git.switcher?.switchTo(name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// **Creation, revealed rather than performed** — the entry opens the field below the
|
||||
// row and nothing else, so the act of naming a branch happens in the surface the user
|
||||
// can see and correct rather than inside a menu that has already closed.
|
||||
Button("New Branch…") {
|
||||
isCreatingBranch = true
|
||||
}
|
||||
} label: {
|
||||
Text(surface.branchLabel)
|
||||
.font(.callout)
|
||||
.foregroundStyle(surface.isReadingBranch ? .secondary : .primary)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
.disabled(!surface.controlsEnabled)
|
||||
.accessibilityLabel(surface.accessibilityLabel)
|
||||
.accessibilityHint("Switch branches")
|
||||
|
||||
if git.switcher?.isSwitching == true {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.accessibilityLabel("Switching branches")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This board's switch targets, off the live switcher — see `switchTargets(branches:current:)`
|
||||
/// for the rule itself.
|
||||
private var otherBranches: [String] {
|
||||
Self.switchTargets(branches: git.switcher?.branches ?? [], current: git.branch)
|
||||
}
|
||||
|
||||
/// **Every local branch except the one already checked out** — a picker offering the current
|
||||
/// branch would be offering a no-op, and the switch refuses one anyway
|
||||
/// (`BranchSwitchSequenceTests`' "a switch to the branch already checked out does nothing").
|
||||
///
|
||||
/// A pure static seam rather than a computed property alone, for `BoardGitBranchSurface.resolve`'s
|
||||
/// reason one level up: the *rule* is the part worth pinning (`BranchSwitchTargetTests`) and the
|
||||
/// `Menu` it fills is SwiftUI. It is also the predicate the disabled "No other branches" row hangs
|
||||
/// on (03-board-ui.md ▸ Board popover ▸ Git tab, 2026-08-06), which makes "when is that row
|
||||
/// shown" a question with one testable answer rather than a shape buried in a view builder.
|
||||
///
|
||||
/// A `nil` `current` — the moment before the first branch read answers — passes every branch
|
||||
/// through rather than none: the list is a set of candidates, and the switch's own gate is what
|
||||
/// decides whether one can be taken. `nonisolated` because nothing here touches the view: a pure
|
||||
/// filter over plain values, reachable from a test with no actor to hop to.
|
||||
///
|
||||
/// The repository's own ordering survives untouched — `GitBranchOperation.localBranches` is what
|
||||
/// libgit2 listed, and a picker that re-sorted it would be inventing an order the repository
|
||||
/// never had.
|
||||
nonisolated static func switchTargets(branches: [String], current: String?) -> [String] {
|
||||
branches.filter { $0 != current }
|
||||
}
|
||||
|
||||
// MARK: The creation reveal
|
||||
|
||||
/// **What New Branch… reveals** (03-board-ui.md ▸ Board popover ▸ Git tab, restored 2026-08-07):
|
||||
/// a name field and a Create button, under the branch row, for exactly as long as the user is
|
||||
/// naming something. It is a detour off the daily face and it looks like one — which is the
|
||||
/// argument the 2026-07-31 split made *against* it in a sheet, and for it here.
|
||||
///
|
||||
/// **The sequence is not this view's.** `GitBranchSwitcher.createAndSwitch(to:)` runs the
|
||||
/// identical settle → flush → stamp → switch sequence the picker's switch does — "no at-HEAD fast
|
||||
/// path" (06-history-undo.md ▸ Branch switching, blessed 2026-07-31) — and `create()` calls
|
||||
/// exactly that method.
|
||||
///
|
||||
/// **Failures answer where they were asked**: the section's existing switcher caption below is
|
||||
/// this control's answer too (06 ▸ Rules, the form-anchored rule as the 2026-08-06 create-and-
|
||||
/// switch overlap settled it — "inline while the asking surface is up, banner once it is gone").
|
||||
/// The popover *is* the asking surface now that the sheet is retired, so there is one caption for
|
||||
/// both halves of the branch affordance and no second slot to keep in sync.
|
||||
///
|
||||
/// No `ProgressView` of its own, for the same reason: `branchRow` already shows the in-flight
|
||||
/// spinner, and a switch has one state whichever control started it.
|
||||
private var creationField: some View {
|
||||
HStack(spacing: 6) {
|
||||
TextField("New branch name", text: $draft)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1)
|
||||
.focused($isFieldFocused)
|
||||
.onAppear { isFieldFocused = true }
|
||||
.onSubmit { create() }
|
||||
// **Escape steps outward one layer per press** (04-interactions.md ▸ Grammar, the
|
||||
// rename field's rule and the retired sheet section's before it): a dirty field
|
||||
// abandons its draft and the reveal stays open; an empty one closes the reveal, which
|
||||
// is the layer above it; a further press then reaches the popover's own dismissal.
|
||||
.onKeyPress(.escape) {
|
||||
if !draft.isEmpty {
|
||||
draft = ""
|
||||
return .handled
|
||||
}
|
||||
isCreatingBranch = false
|
||||
return .handled
|
||||
}
|
||||
.accessibilityLabel("New branch name")
|
||||
|
||||
Button("Create", action: create)
|
||||
.disabled(trimmedDraft.isEmpty)
|
||||
}
|
||||
// The branch controls' one rule, applied to the reveal as it is to the picker: a paused
|
||||
// repository, a read-only board and a switch in flight close it (`BoardGitBranchSurface`).
|
||||
.disabled(!surface.controlsEnabled)
|
||||
}
|
||||
|
||||
private var trimmedDraft: String {
|
||||
draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
/// Creates the named branch and switches to it, then puts the reveal away — a create is the end
|
||||
/// of the detour, and a field left standing over a branch that now exists would be inviting a
|
||||
/// second one nobody asked for.
|
||||
private func create() {
|
||||
let name = trimmedDraft
|
||||
guard !name.isEmpty, surface.controlsEnabled else { return }
|
||||
draft = ""
|
||||
isCreatingBranch = false
|
||||
Task { await git.switcher?.createAndSwitch(to: name) }
|
||||
}
|
||||
|
||||
// MARK: The pause
|
||||
|
||||
/// **The abnormal-state surface** (06 ▸ Rules ▸ Abnormal repo states) — deferred here from the
|
||||
/// auto-commit card, which built the hold this explains.
|
||||
///
|
||||
/// Two sentences, both of them the design's: what the repository is doing, and whose job it is to
|
||||
/// finish. Never a Repair button — "the app never mutates repo state it didn't create".
|
||||
private func pauseNote(_ explanation: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(explanation)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(BoardGitBranchSurface.pauseCaption)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private func caption(_ text: String, tone: Color) -> some View {
|
||||
Text(text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(tone)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
/// **The Git tab's two setup controls** — add-git and the commit-identity fields (03-board-ui.md ▸
|
||||
/// Board popover ▸ Git tab; 06-history-undo.md ▸ Rules ▸ Opt-in init and ▸ Interaction with external
|
||||
/// writers).
|
||||
///
|
||||
/// ### One configuration home again (ruled 2026-08-07)
|
||||
///
|
||||
/// These two lived in the popover, moved to the board settings sheet with the **2026-07-31
|
||||
/// popover/sheet split**, and came back with the **2026-08-07 reversal** that retired that sheet: the
|
||||
/// popover is the board's one configuration surface, and its Git tab is where a board's repository is
|
||||
/// both operated and set up. Nothing about either control's *substance* moved in either direction —
|
||||
/// what moved is the container, and every rule stated below is the same rule the sheet carried,
|
||||
/// re-pointed at the tab.
|
||||
///
|
||||
/// The one thing the reversal does change is what "the form is visible" means: it is the **popover's**
|
||||
/// visibility now, and a popover is transient where a sheet was not. That is fine for both rules that
|
||||
/// depend on it — add-git's inline-answer window and the identity fields' poll are both scoped to "the
|
||||
/// surface the user asked from is still under their eye", and a popover dismissed by a stray click
|
||||
/// ends that window exactly as Done ended the sheet's (06 ▸ Rules: "inline while the asking surface is
|
||||
/// up, banner once it is gone").
|
||||
///
|
||||
/// They live in their own file rather than in `BoardGitTabView.swift` because they are the *setup*
|
||||
/// half — the tab's postures and its daily branch face are that file's and `BoardGitControls.swift`'s
|
||||
/// — and because a pro-m2 card adding the remote and credential surfaces adds them beside these,
|
||||
/// under the same rules, rather than into the posture switch.
|
||||
|
||||
// MARK: - Geometry
|
||||
|
||||
/// The setup form's one figure, **derived from the body font** like every other surface's
|
||||
/// (10-accessibility.md ▸ Text scaling: "relative text styles everywhere, no fixed point sizes").
|
||||
///
|
||||
/// It is what survives `BoardSettingsSheetLayout`, which retired with the sheet 2026-08-07: the
|
||||
/// sheet's width and section inset were a *window's* geometry and the popover supplies both itself
|
||||
/// (`BoardInfoView`), but the identity form's label column is the form's own and travelled with it.
|
||||
enum BoardGitSetupLayout {
|
||||
|
||||
/// The identity form's label column. 3.4 em: 44pt at the standard body, which is what those
|
||||
/// fields have always drawn.
|
||||
static func labelColumn(bodyPointSize: CGFloat) -> CGFloat {
|
||||
BoardMetrics.em(3.4, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Add git
|
||||
|
||||
/// **The add-git action** (06-history-undo.md ▸ Rules ▸ Opt-in init) — the one place in the app that
|
||||
/// creates a repository, and the reason "no silent auto-init, ever" is a checkable claim rather than
|
||||
/// a promise: there is no other caller of `HistoryStore.addGit`.
|
||||
///
|
||||
/// The caption states what pressing it does, in the order it happens, because it is not undoable in
|
||||
/// the ordinary sense: a repository appears in the board's folder and its current state becomes the
|
||||
/// first commit.
|
||||
///
|
||||
/// **It renders inline in the Git tab's no-repository posture** since the 2026-08-07 reversal — under
|
||||
/// the note that states the fact, where the Board Settings… door stood between 2026-07-31 and that
|
||||
/// day. The posture is unchanged in every other respect: the fact, then the offer.
|
||||
struct BoardGitAddAction: View {
|
||||
|
||||
let git: HistoryStore
|
||||
|
||||
/// The read-only lock's reach (02-architecture.md ▸ The lock's scope): a board that refuses
|
||||
/// writes refuses this one too — initializing a repository is a write, and a commit is several.
|
||||
/// The popover **stays open** under the lock and disables in place, which is the style popover's
|
||||
/// settled precedent (03 ▸ Board popover).
|
||||
let isEnabled: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Button("Add Git") {
|
||||
Task { await git.addGit() }
|
||||
}
|
||||
.disabled(!isEnabled || git.isAddingGit)
|
||||
|
||||
Text("Creates a git repository in this board's folder and commits its current state.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if let failure = git.lastFailure {
|
||||
Text(failure.message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
// **The form add-git answers at** (06 ▸ Interaction with external writers, ruled 2026-07-31 —
|
||||
// "Form-anchored operations answer at the form first"): inline while this control is on
|
||||
// screen, the banner once it is gone. Appearing claims the inline surface; disappearing gives
|
||||
// it up, which both dismisses the stale error and sends any answer still in flight to the
|
||||
// banner instead of to nobody.
|
||||
//
|
||||
// The control's visibility *is* the Git tab's, and the tab's is the popover's — a narrower
|
||||
// window than the sheet's was until 2026-08-07, and the right one: a popover dismissed by a
|
||||
// click outside is precisely the user leaving the form. Two other disappearances are not
|
||||
// dismissals and both are correct: switching to Info or Theme, which puts the question away
|
||||
// as surely as closing the popover, and a *successful* add-git flipping the mode out from
|
||||
// under this posture — the failure slot empties because there is nothing left to fail.
|
||||
.onAppear { git.noteFormVisible(true) }
|
||||
.onDisappear { git.noteFormVisible(false) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Commit identity
|
||||
|
||||
/// **The name and email that repo-local `.git/config` carries** (06-history-undo.md ▸ Interaction
|
||||
/// with external writers: "the identity section … exposes name/email fields that write that repo-local
|
||||
/// config — the setting *is* the file, portable to any git client, per-board by nature").
|
||||
///
|
||||
/// The fields moved to the sheet with the 2026-07-31 split and back to the popover's Git tab with the
|
||||
/// 2026-08-07 reversal, poll included — 06 says the visibility-scoped re-read "rides with the fields",
|
||||
/// so hosting the view here *is* the re-point: the `.task` below now lives and dies with the tab.
|
||||
///
|
||||
/// ### The placeholder is the whole of the identity rule made visible
|
||||
///
|
||||
/// An empty field shows the **derived default** — the macOS account's full name and
|
||||
/// `shortname@hostname` — as a placeholder, never as a value. That is the difference between "this
|
||||
/// repository says nothing, so the app signs commits with a sensible guess" and "this repository says
|
||||
/// this", and the file is where the difference lives: 06 forbids the app writing its own derived
|
||||
/// value into config, because it would then outrank the user's global `~/.gitconfig` for their own
|
||||
/// terminal commits in that board. A field pre-filled with the derived value would write it on the
|
||||
/// first focus loss.
|
||||
///
|
||||
/// ### The dirty-buffer courtesy, copied from `BoardRenameField`
|
||||
///
|
||||
/// A foreign config edit landing while the tab is open updates an *unfocused* field and never a
|
||||
/// focused one: "a focused field keeps the user's keystrokes" (03-board-ui.md ▸ Board popover). The
|
||||
/// trigger is a poll rather than a reload, and that is honest rather than lazy: `FolderWatcher`
|
||||
/// filters `.git` out of the watch by design, so no board event can ever carry a config change, and
|
||||
/// the alternative to a small periodic read is a field that is stale for as long as the surface stays
|
||||
/// open.
|
||||
struct BoardGitIdentityFields: View {
|
||||
|
||||
let git: HistoryStore
|
||||
let isEnabled: Bool
|
||||
|
||||
@State private var name = ""
|
||||
@State private var email = ""
|
||||
@FocusState private var focused: Field?
|
||||
|
||||
private enum Field: Hashable {
|
||||
case name
|
||||
case email
|
||||
}
|
||||
|
||||
/// **The fields re-read the config at 2 s while they are visible** (06 ▸ Interaction with
|
||||
/// external writers, blessed 2026-07-31): "the watcher never delivers `.git`, so no board event
|
||||
/// can carry a terminal-side config edit — the unfocused-resync courtesy needs its own signal, and
|
||||
/// a visibility-scoped poll is the 15 s paused-state re-read's shape at form cadence (a focused
|
||||
/// field keeps its keystrokes; dismissing the surface stops the poll)." The surface the ruling
|
||||
/// named was the sheet; since 2026-08-07 it is the popover's Git tab, which is a *shorter* life
|
||||
/// than the sheet's and therefore a strictly smaller poll.
|
||||
private static let pollInterval: Duration = .seconds(2)
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
field("Name", text: $name, placeholder: git.derivedIdentity?.name ?? "", tag: .name)
|
||||
field("Email", text: $email, placeholder: git.derivedIdentity?.email ?? "", tag: .email)
|
||||
|
||||
if let failure = git.identityFailure {
|
||||
Text(failure.message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// The first read, then the courtesy poll. Cancellation is the view's disappearance, which
|
||||
// is the popover closing or the tab strip moving off Git.
|
||||
while !Task.isCancelled {
|
||||
await git.refreshIdentity()
|
||||
try? await Task.sleep(for: Self.pollInterval)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
name = git.identityName
|
||||
email = git.identityEmail
|
||||
}
|
||||
.onChange(of: git.identityName) { _, value in
|
||||
guard focused != .name else { return }
|
||||
name = value
|
||||
}
|
||||
.onChange(of: git.identityEmail) { _, value in
|
||||
guard focused != .email else { return }
|
||||
email = value
|
||||
}
|
||||
// A dismissal is a commit like any other click-away — `BoardRenameField`'s rule, and the same
|
||||
// idempotence makes the overlap harmless.
|
||||
.onDisappear { commit() }
|
||||
}
|
||||
|
||||
private func field(
|
||||
_ label: String,
|
||||
text: Binding<String>,
|
||||
placeholder: String,
|
||||
tag: Field
|
||||
) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(
|
||||
width: BoardGitSetupLayout.labelColumn(bodyPointSize: BoardMetrics.bodyPointSize),
|
||||
alignment: .leading
|
||||
)
|
||||
TextField(placeholder, text: text)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1)
|
||||
.focused($focused, equals: tag)
|
||||
.onSubmit { commit() }
|
||||
.disabled(!isEnabled)
|
||||
.accessibilityLabel("Commit \(label.lowercased())")
|
||||
}
|
||||
.onChange(of: focused) { previous, _ in
|
||||
// Focus leaving *this* field is this field's commit — the inline editors' exit, applied
|
||||
// to a form where Tab moves between two of them.
|
||||
guard previous == tag else { return }
|
||||
commit()
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes both fields, and only when one of them differs from what the file says — an unchanged
|
||||
/// value must not rewrite `.git/config` every time the popover closes.
|
||||
private func commit() {
|
||||
guard isEnabled else { return }
|
||||
guard name != git.identityName || email != git.identityEmail else { return }
|
||||
Task { await git.writeIdentity(name: name, email: email) }
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,17 @@ 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.
|
||||
///
|
||||
/// **Tabbed since 2026-08-07 — the restructure is complete, all three tab sessions settled.** The
|
||||
/// symbol/name header stays at the top; below it sit the tabs — **Info**, **Theme**, **Git** — each
|
||||
/// the settings surface for one aspect of board configuration, each settled in its own dedicated
|
||||
/// design session: `BoardInfoTabView`, the metrics dossier; `BoardThemeTabView`, the Solid color /
|
||||
/// Pattern picker (the Background tab's original name, before the same session widened it past the
|
||||
/// generated-only picker and folded manual styling back out to Style… ⌥⌘S); and `BoardGitTabView`,
|
||||
/// which the pre-tab body's mode-aware git section rehomed into whole — postures and notes, none of
|
||||
/// them re-ruled by the move. That tab carried a **Board Settings…** row to a separate sheet until
|
||||
/// later the same day, when the 2026-07-31 popover/sheet split was reversed and the sheet's contents
|
||||
/// rehomed into the tab (see below).
|
||||
/// **Tabbed since 2026-08-07.** The symbol/name header stays at the top; below it sit the tabs —
|
||||
/// **Info**, **Theme**, **Sync** — each the settings surface for one aspect of board configuration,
|
||||
/// each settled in its own dedicated design session: `BoardInfoTabView`, the metrics dossier;
|
||||
/// `BoardThemeTabView`, the Solid color / Pattern picker (the Background tab's original name, before
|
||||
/// the same session widened it past the generated-only picker and folded manual styling back out to
|
||||
/// Style… ⌥⌘S); and `BoardSyncTabView`, a standing placeholder for the future ops-based sync service.
|
||||
/// The Git tab that once sat here left with the git excision (strategy/01-git-excision.md,
|
||||
/// 2026-08-08).
|
||||
///
|
||||
/// **Tab membership is the git posture's**, and **selection resets to Info on every open** — both
|
||||
/// the Git session's rulings. Since 12-editions.md ▸ PIVOT 2026-08-07 the first of those is a
|
||||
/// structural rule with nothing left to exclude: every board carries all three tabs (03 ▸ Board
|
||||
/// popover, the same-day pivot note) — see `BoardInfoTab`.
|
||||
/// **Selection resets to Info on every open** — the tab session's ruling, unaffected by the git
|
||||
/// excision: see `BoardInfoTab`.
|
||||
///
|
||||
/// ### One home, deliberately
|
||||
///
|
||||
@@ -29,11 +25,9 @@ import SwiftUI
|
||||
///
|
||||
/// **And it is the board's only configuration home** (ruled 2026-08-07, reversing the 2026-07-31
|
||||
/// popover/sheet split): setup briefly lived in a board settings sheet with its own menu command and
|
||||
/// a row here pointing at it. The sheet is retired, its add-git and commit-identity controls render
|
||||
/// inline in the Git tab's postures (`BoardGitSetup.swift`), branch creation went back into the
|
||||
/// switch menu (`BoardGitControls`), and Board ▸ Board Settings… left the menu bar with them. So
|
||||
/// "one home per control" — the split's own promise — is now satisfied by there being one surface,
|
||||
/// and ⌘I is the door to all of it.
|
||||
/// a row here pointing at it. The sheet is retired and Board ▸ Board Settings… left the menu bar with
|
||||
/// it. So "one home per control" — the split's own promise — is now satisfied by there being one
|
||||
/// surface, and ⌘I is the door to all of it.
|
||||
|
||||
// MARK: - Presentation state
|
||||
|
||||
@@ -74,17 +68,13 @@ extension FocusedValues {
|
||||
|
||||
// MARK: - The window-title widget
|
||||
|
||||
/// The titlebar widget: the board's glyph beside a two-line identity block — its name, and on a
|
||||
/// git-mode board its branch under the name — with a trailing disclosure chevron, whose one job is
|
||||
/// this popover.
|
||||
/// The titlebar widget: the board's glyph beside its name, with a trailing disclosure chevron, whose
|
||||
/// one job is this popover.
|
||||
///
|
||||
/// **A two-line stack since 2026-08-07** (03-board-ui.md ▸ Board popover, the window-title widget
|
||||
/// passage). It was one line reading `glyph Title — branch ⌄`, and the em-dash was the tell: a
|
||||
/// separator doing a *hierarchy's* job, with the branch competing for the same width as the name it
|
||||
/// qualifies. So the branch moved under the title in its own smaller, secondary line, the em-dash
|
||||
/// retired, and the glyph grew to span both lines — an icon sized to the block it labels rather than
|
||||
/// to whichever line it happened to sit on. A board with no branch is the single title line, vertically
|
||||
/// centred beside the same glyph, which is the same block with one row.
|
||||
/// **Was a two-line stack** (03-board-ui.md ▸ Board popover, the window-title widget passage): a
|
||||
/// git-mode board's branch sat under the name in its own smaller, secondary line. The branch line
|
||||
/// left with app-managed git (strategy/01-git-excision.md, 2026-08-08); the widget is back to the
|
||||
/// single title line, vertically centred beside the glyph.
|
||||
///
|
||||
/// **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
|
||||
@@ -93,37 +83,25 @@ extension FocusedValues {
|
||||
/// 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.
|
||||
/// button to the full name/chevron button): the title string sits inside the same `Button`, so a
|
||||
/// click anywhere across the board's name opens the popover exactly as a click on the chevron
|
||||
/// always has.
|
||||
struct BoardInfoWidget: View {
|
||||
|
||||
let store: BoardStore
|
||||
let recents: StyleRecents
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// It stopped being a tier signal at 12 ▸ PIVOT 2026-08-07 — every session composes a git state
|
||||
/// now, whatever the tier — so a `nil` here means only "this caller had no session to hand over"
|
||||
/// (previews, the accessory-installation tests), which reads as mode `none`.
|
||||
let git: HistoryStore?
|
||||
|
||||
@Bindable var presentation: BoardInfoPresentation
|
||||
|
||||
/// 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.
|
||||
/// The widget's title string, 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. `store.snapshot` is `@Observable`, so reading it
|
||||
/// here is what makes the title live.
|
||||
private var summary: BoardInfoTitlebarSummary {
|
||||
BoardInfoTitlebarSummary(
|
||||
snapshotTitle: store.snapshot.title.value,
|
||||
rootURL: store.rootURL,
|
||||
mode: git?.mode ?? .none,
|
||||
branch: git?.branch
|
||||
rootURL: store.rootURL
|
||||
)
|
||||
}
|
||||
|
||||
@@ -151,33 +129,17 @@ struct BoardInfoWidget: View {
|
||||
// everything VoiceOver needs (`accessibilityLabel` below).
|
||||
.accessibilityHidden(true)
|
||||
|
||||
// The identity block: the name, and the branch beneath it on a git-mode board. On
|
||||
// any other board this is the single title line and the `HStack`'s own centring puts
|
||||
// it level with the glyph — the stack is the same shape with one row, never a
|
||||
// special case.
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
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)
|
||||
|
||||
if let branch = summary.branch {
|
||||
// Smaller and secondary — the qualifier under the name it qualifies. The
|
||||
// em-dash that separated the two on one line retired with the stack: a
|
||||
// hierarchy that a layout can state does not need punctuation to state it.
|
||||
Text(branch)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
// Yields space to the chevron first when the block doesn't fit inside the width cap
|
||||
// below — the board's identity is the more load-bearing half of the pair, and both
|
||||
// its lines truncate rather than the disclosure disappearing.
|
||||
// The identity block: the board's name. The `HStack`'s own centring puts it level
|
||||
// with the glyph.
|
||||
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 chevron first when the title doesn't fit inside the width cap
|
||||
// below — the board's identity is the more load-bearing half of the pair.
|
||||
.layoutPriority(1)
|
||||
|
||||
Image(systemName: "chevron.down")
|
||||
@@ -186,11 +148,9 @@ struct BoardInfoWidget: View {
|
||||
.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. The height is the
|
||||
// two-line block's (2026-08-07; it was the original chevron's 18 while the widget was
|
||||
// one line), which is what keeps the accessory titlebar-appropriate: tall enough for
|
||||
// name-over-branch, and no taller than a standard title bar carries.
|
||||
// A long board name must not swallow the whole titlebar — capped rather than left to
|
||||
// grow, with the truncation above doing the rest. The height keeps the accessory
|
||||
// titlebar-appropriate: no taller than a standard title bar carries.
|
||||
.frame(maxWidth: 400, alignment: .leading)
|
||||
.frame(height: 32)
|
||||
.contentShape(Rectangle())
|
||||
@@ -199,24 +159,16 @@ struct BoardInfoWidget: View {
|
||||
.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, git: git)
|
||||
BoardInfoView(store: store, recents: recents)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// What VoiceOver reads for the button: the board's name — `.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)"
|
||||
summary.title
|
||||
}
|
||||
|
||||
/// The widget glyph's tint: the board's `iconColor` where it resolves, the quiet secondary
|
||||
@@ -231,10 +183,9 @@ struct BoardInfoWidget: View {
|
||||
|
||||
// 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 over in `BoardGitTabView.swift`.
|
||||
/// **The window-title widget's title string, as one pure function** of the board's on-disk title and
|
||||
/// its folder — pulled out so the fallback rule is assertable without a widget on screen
|
||||
/// (`BoardInfoTitlebarSummaryTests`).
|
||||
///
|
||||
/// **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
|
||||
@@ -245,26 +196,19 @@ struct BoardInfoWidget: View {
|
||||
/// 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 — `mode == .git` with a non-`nil`
|
||||
/// branch — the same condition `BoardGitSection.resolve`'s `.branch` case covers. **The tier clause
|
||||
/// is gone** (12-editions.md ▸ PIVOT 2026-08-07: git is tier-independent, so a git-mode board is a
|
||||
/// git-mode board and the widget says so on every tier); the rule it read `tier == .pro && mode ==
|
||||
/// .git` until that day is recorded here rather than restated in code. A board with no repository or
|
||||
/// an inert one (mode `.none`, `.repoNested`, `.unverifiable`) shows 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).
|
||||
/// **Branch.** The widget once carried a second line — the board's git branch, shown on a git-mode
|
||||
/// board — as a `branch` field here. That line left with app-managed git
|
||||
/// (strategy/01-git-excision.md, 2026-08-08); the summary is the title alone now.
|
||||
struct BoardInfoTitlebarSummary: Equatable {
|
||||
|
||||
let title: String
|
||||
let branch: String?
|
||||
|
||||
init(snapshotTitle: String?, rootURL: URL, mode: BoardGitMode, branch: String?) {
|
||||
init(snapshotTitle: String?, rootURL: URL) {
|
||||
if let snapshotTitle, !snapshotTitle.isEmpty {
|
||||
self.title = snapshotTitle
|
||||
} else {
|
||||
self.title = rootURL.deletingPathExtension().lastPathComponent
|
||||
}
|
||||
self.branch = mode == .git ? branch : nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,21 +220,15 @@ struct BoardInfoTitlebarSummary: Equatable {
|
||||
/// 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
|
||||
/// `git` defaults to no session, so that a caller with none in hand (the accessory-installation
|
||||
/// tests, which are about AppKit plumbing rather than about git) describes a board honestly rather
|
||||
/// than by accident: a Git tab in its no-repository posture, and a widget with no branch line. The
|
||||
/// app's own call site passes the session's value explicitly.
|
||||
func boardInfoTitlebarAccessory(
|
||||
store: BoardStore,
|
||||
recents: StyleRecents,
|
||||
git: HistoryStore? = nil,
|
||||
presentation: BoardInfoPresentation
|
||||
) -> NSTitlebarAccessoryViewController {
|
||||
let hosting = NSHostingView(
|
||||
rootView: BoardInfoWidget(
|
||||
store: store,
|
||||
recents: recents,
|
||||
git: git,
|
||||
presentation: presentation
|
||||
)
|
||||
)
|
||||
@@ -314,31 +252,22 @@ func boardInfoTitlebarAccessory(
|
||||
// MARK: - Tabs
|
||||
|
||||
/// The popover's aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab
|
||||
/// restructure — all three original sessions settled): **Info** (`BoardInfoTabView`), **Theme**
|
||||
/// (`BoardThemeTabView`), **Git** (`BoardGitTabView`) — and **Sync** (`BoardSyncTabView`), added
|
||||
/// 2026-08-07 as a standing placeholder: the strip claims the position now, the surface says
|
||||
/// honestly that nothing lives there yet, and 07-sync-collab.md's cards are where its contents get
|
||||
/// ruled. The raw values are the segmented control's own labels, so the strip needs no separate
|
||||
/// label function.
|
||||
/// restructure): **Info** (`BoardInfoTabView`), **Theme** (`BoardThemeTabView`) — and **Sync**
|
||||
/// (`BoardSyncTabView`), added 2026-08-07 as a standing placeholder: the strip claims the position
|
||||
/// now, the surface says honestly that nothing lives there yet, and the future sync-service
|
||||
/// workstream is where its contents get ruled. A fourth tab, Git, sat here until the git excision
|
||||
/// (strategy/01-git-excision.md, 2026-08-08) removed it. The raw values are the segmented control's
|
||||
/// own labels, so the strip needs no separate label function.
|
||||
enum BoardInfoTab: String, CaseIterable, Identifiable {
|
||||
|
||||
case info = "Info"
|
||||
case theme = "Theme"
|
||||
case git = "Git"
|
||||
case sync = "Sync"
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
// **Membership is the git posture's, and the posture never says "absent" any more.** The Git
|
||||
// session ruled (2026-08-07) that the Git tab joins the strip only where `BoardGitSection` has
|
||||
// something true to say, which then meant dropping it on a free board with no `.git` — the
|
||||
// "absent, no placeholder" rule carried up to the strip. **12-editions.md ▸ PIVOT 2026-08-07**,
|
||||
// the same day, retired the free-only postures wholesale: git left the paywall, `.absent` and
|
||||
// `.proPointer` died with it, and 03-board-ui.md ▸ Board popover records the consequence — "the
|
||||
// absent posture is unreachable and every board carries all three tabs. The membership rule
|
||||
// stands structurally — the strip still asks the posture — it just never hears 'absent'
|
||||
// anymore." So there is no `available(…)` filter here to ask it with: membership is `allCases`,
|
||||
// in `allCases`' own order (Info, Theme, Git, Sync), which is what the filter answered anyway.
|
||||
// Membership is simply `allCases`, in `allCases`' own order (Info, Theme, Sync) — every board
|
||||
// carries the whole strip, unconditionally.
|
||||
}
|
||||
|
||||
// MARK: - The popover's content
|
||||
@@ -348,18 +277,16 @@ enum BoardInfoTab: String, CaseIterable, Identifiable {
|
||||
///
|
||||
/// Width is the style editor's — the number that keeps the Style… popover narrow enough to sit
|
||||
/// beside a card — kept through the restructure so the popover's footprint didn't wander while the
|
||||
/// tabs filled in; all three settled tabs (Info, Theme, Git) kept it, so whether the tabbed surface
|
||||
/// tabs filled in; every settled tab (Info, Theme, Sync) kept it, so whether the tabbed surface
|
||||
/// ever wants its own width remains open, but nothing has needed one yet.
|
||||
struct BoardInfoView: View {
|
||||
|
||||
let store: BoardStore
|
||||
let recents: StyleRecents
|
||||
let git: HistoryStore?
|
||||
|
||||
/// The selected tab, and **it resets to Info on every open** — a ruling, not an accident (the
|
||||
/// Git session, 2026-08-07, closing the question the earlier tab sessions deferred): the popover
|
||||
/// is transient and Info is the board's face, and a remembered tab could strand selection on a
|
||||
/// tab the next board's posture doesn't offer. `@State` on the popover's content, which
|
||||
/// The selected tab, and **it resets to Info on every open** — a ruling, not an accident: the
|
||||
/// popover is transient and Info is the board's face, and a remembered tab could strand
|
||||
/// selection on a tab the next board doesn't offer. `@State` on the popover's content, which
|
||||
/// `BoardInfoWidget` hands `.popover` fresh on every open, is exactly that rule and nothing more.
|
||||
@State private var tab: BoardInfoTab = .info
|
||||
|
||||
@@ -373,12 +300,10 @@ struct BoardInfoView: View {
|
||||
|
||||
init(
|
||||
store: BoardStore,
|
||||
recents: StyleRecents,
|
||||
git: HistoryStore? = nil
|
||||
recents: StyleRecents
|
||||
) {
|
||||
self.store = store
|
||||
self.recents = recents
|
||||
self.git = git
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -425,10 +350,8 @@ struct BoardInfoView: View {
|
||||
// The tab bar: a segmented control rather than a `TabView`, because the popover is a
|
||||
// compact settings surface and the segmented idiom is the macOS shape for switching
|
||||
// between a handful of peer panes inside one. The label is hidden visually but stays
|
||||
// the control's accessibility name. It iterates `allCases` — the strip's membership is
|
||||
// still the board's git posture in principle (the Git session's ruling), and since
|
||||
// 12-editions.md ▸ PIVOT 2026-08-07 that posture is never "absent", so every board
|
||||
// carries the whole strip (03-board-ui.md ▸ Board popover, the same-day pivot note).
|
||||
// the control's accessibility name. It iterates `allCases` — every board carries the
|
||||
// whole strip, unconditionally (03-board-ui.md ▸ Board popover).
|
||||
Picker("Board configuration", selection: $tab) {
|
||||
ForEach(BoardInfoTab.allCases) { tab in
|
||||
Text(tab.rawValue)
|
||||
@@ -439,7 +362,7 @@ struct BoardInfoView: View {
|
||||
.padding(.horizontal, inset)
|
||||
.padding(.top, inset)
|
||||
|
||||
// The selected tab's surface — the first three settled 2026-08-07, each in its own
|
||||
// The selected tab's surface — Info and Theme settled 2026-08-07, each in its own
|
||||
// dedicated session and its own file; Sync is that day's standing placeholder. Each pads
|
||||
// itself by `inset`, so the switch adds nothing.
|
||||
switch tab {
|
||||
@@ -447,8 +370,6 @@ struct BoardInfoView: View {
|
||||
BoardInfoTabView(store: store, inset: inset)
|
||||
case .theme:
|
||||
BoardThemeTabView(store: store, inset: inset)
|
||||
case .git:
|
||||
BoardGitTabView(store: store, git: git, inset: inset)
|
||||
case .sync:
|
||||
BoardSyncTabView(inset: inset)
|
||||
}
|
||||
|
||||
@@ -4,17 +4,17 @@ import SwiftUI
|
||||
/// added 2026-08-07, the same night the settings sheet retired).
|
||||
///
|
||||
/// The tab exists ahead of its contents, deliberately: the strip claims the position where the
|
||||
/// remote half of the git story will live — tracking, Pull/Push, the status badges, and whatever
|
||||
/// home the setup surfaces (remote, credentials, SSH) are ruled into — so that when
|
||||
/// 07-sync-collab.md's cards land they land *in* a surface rather than re-arguing the strip. None
|
||||
/// of that is ruled by this file: the open question is the Redesign board's
|
||||
/// ("Rule a home for 07's remote and credential setup surfaces"), and a placeholder that decided it
|
||||
/// by accident would be the worst way to answer it.
|
||||
/// future **ops-based sync service** will live — semantic ops, a server-side worktree API, with the
|
||||
/// phone and (eventually) the web as worktree clients speaking that protocol
|
||||
/// (strategy/01-git-excision.md ▸ Successors) — so that when the sync-service workstream's cards
|
||||
/// land they land *in* a surface rather than re-arguing the strip. **Git is gone**
|
||||
/// (strategy/01-git-excision.md, 2026-08-08): this tab was never the remote half of the git story
|
||||
/// and now carries no trace of one. None of the service's shape is ruled by this file: that is its
|
||||
/// own workstream's, with its own docs, and a placeholder that decided it by accident would be the
|
||||
/// worst way to answer it.
|
||||
///
|
||||
/// What renders meanwhile is one honest sentence in the posture notes' register
|
||||
/// (`BoardGitTabView`'s siblings): the fact, stated plainly — never a mock control, never a
|
||||
/// greyed-out preview of features that do not exist (06-history-undo.md ▸ Rules: teach, never look
|
||||
/// broken).
|
||||
/// What renders meanwhile is one honest sentence: the fact, stated plainly — never a mock control,
|
||||
/// never a greyed-out preview of features that do not exist (teach, never look broken).
|
||||
struct BoardSyncTabView: View {
|
||||
|
||||
/// The popover's own padding figure (`BoardInfoView.inset`), matching every other tab's
|
||||
|
||||
Reference in New Issue
Block a user