Give the single-branch switch picker its disabled explanatory row

With branch creation relocated to the settings sheet, a single-branch
board's switch menu opened onto nothing and read as broken (ruled
2026-08-06, built with the Git tab): a bare Text — AppKit's standard
disabled item, read by VoiceOver as disabled text — now says "No other
branches" where the switch entries would be. The filter behind it becomes
the pure switchTargets(branches:current:) seam, pinned by
BranchSwitchTargetTests: current excluded, nil current passes all through,
repository order preserved.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-07 19:12:01 -04:00
parent 7d7e892617
commit 93a3423e6b
2 changed files with 106 additions and 13 deletions
+54 -10
View File
@@ -67,7 +67,7 @@ struct BoardGitBranchSurface: Equatable {
/// loud failure, ruled 2026-07-31: "with the whole git surface paused and the popover's git /// loud failure, ruled 2026-07-31: "with the whole git surface paused and the popover's git
/// section naming the state"). /// section naming the state").
/// ///
/// A sibling of the nested and unverifiable notes (`BoardInfoPopover`) and written in their /// 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 /// 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 /// 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 /// progress", and there is no tool whose job it is to finish it. What it keeps from the pause
@@ -169,22 +169,44 @@ struct BoardGitControls: View {
/// beside it. /// beside it.
/// ///
/// **Switch entries and nothing else** since the 2026-07-31 split the "New Branch" entry that /// **Switch entries and nothing else** since the 2026-07-31 split the "New Branch" entry that
/// used to close the menu is the settings sheet's standing Create field now. On a board with one /// used to close the menu is the settings sheet's standing Create field now. **A single-branch
/// branch the menu is therefore *empty*, and that is left honest rather than papered over: the /// board therefore opens onto a disabled explanatory row** (03-board-ui.md Board popover Git
/// label still reads the current branch and still disables with the rest of the git surface, so /// tab, ruled 2026-08-06 and built with the tab): the earlier posture left the menu genuinely
/// the line goes on saying the one thing it is here to say. /// empty and called that honest, but honest is not the same as legible a menu that opens onto
/// nothing reads as broken, not as complete.
private var branchRow: some View { private var branchRow: some View {
HStack(spacing: 6) { // 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") Image(systemName: "arrow.triangle.branch")
.imageScale(.small) .imageScale(.small)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Menu { Menu {
ForEach(otherBranches, id: \.self) { name in if targets.isEmpty {
// **The single-branch board's row** (03-board-ui.md Board popover Git tab,
// ruled 2026-08-06). It teaches the two things an empty menu leaves a user to
// guess at: *why* there is nothing to pick this menu holds only the **other**
// local branches, and there are none and, by standing where "New Branch" used
// to, that creation is no longer here at all; it is the settings sheet's Create
// field, one row down through Board Settings
//
// 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) { Button(name) {
Task { await git.switcher?.switchTo(name) } Task { await git.switcher?.switchTo(name) }
} }
} }
}
} label: { } label: {
Text(surface.branchLabel) Text(surface.branchLabel)
.font(.callout) .font(.callout)
@@ -204,10 +226,32 @@ struct BoardGitControls: View {
} }
} }
/// Every local branch except the one already checked out a picker offering the current branch /// This board's switch targets, off the live switcher see `switchTargets(branches:current:)`
/// would be offering a no-op, and the switch refuses one anyway. /// for the rule itself.
private var otherBranches: [String] { private var otherBranches: [String] {
(git.switcher?.branches ?? []).filter { $0 != git.branch } 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 pause // MARK: The pause
+50 -1
View File
@@ -661,7 +661,7 @@ struct BranchSwitchSequenceTests {
var asked = 0 var asked = 0
switcher.settleSessions = { asked += 1; return .proceed } switcher.settleSessions = { asked += 1; return .proceed }
// The picker never offers it (`BoardGitControls.otherBranches`), and the operation is a no-op // The picker never offers it (`BoardGitControls.switchTargets`), and the operation is a no-op
// if one ever arrives: same branch, same tree, nothing to announce. // if one ever arrives: same branch, same tree, nothing to announce.
#expect(await switcher.switchTo("main")) #expect(await switcher.switchTo("main"))
#expect(GitRepository.branchName(at: fixture.root) == "main") #expect(GitRepository.branchName(at: fixture.root) == "main")
@@ -1038,6 +1038,55 @@ struct BoardGitBranchSurfaceTests {
} }
} }
// MARK: - The switch picker's targets
/// **What the branch menu offers, and what it says when that is nothing** (03-board-ui.md Board
/// popover Git tab).
///
/// `BoardGitControls.switchTargets` is the filter as a pure function, pinned here for the reason
/// `BoardGitBranchSurface.resolve` is pinned above: the rule is the part worth asserting, and the
/// `Menu` it fills is SwiftUI. Its emptiness is load-bearing since 2026-08-06 with branch
/// *creation* relocated to the settings sheet, an empty result is what puts the disabled "No other
/// branches" row on screen in place of a menu that would otherwise open onto nothing and read as
/// broken.
@Suite("Board popover ▸ the switch picker's targets")
struct BranchSwitchTargetTests {
@Test("The branch already checked out is never offered — switching to it would be a no-op")
func theCurrentBranchIsExcluded() {
#expect(
BoardGitControls.switchTargets(branches: ["main", "redesign", "spike"], current: "main")
== ["redesign", "spike"]
)
}
@Test("A single-branch board has no targets at all — which is what the disabled row is for")
func aSingleBranchBoardHasNoTargets() {
#expect(BoardGitControls.switchTargets(branches: ["main"], current: "main").isEmpty)
// The shape a repository with an unborn HEAD is in: no branches listed at all.
#expect(BoardGitControls.switchTargets(branches: [], current: "main").isEmpty)
}
@Test("Before the first branch read every branch is a candidate, rather than none")
func anUnreadCurrentPassesEverythingThrough() {
// `HistoryStore.branch` is `nil` until `refreshBranch()` answers, which is a moment the
// popover is on screen for. Filtering everything out there would be a menu that starts empty
// and fills in the switch's own gate is what decides whether a target can be taken.
#expect(
BoardGitControls.switchTargets(branches: ["main", "redesign"], current: nil)
== ["main", "redesign"]
)
}
@Test("The repository's own order survives — the picker never re-sorts what libgit2 listed")
func orderIsPreserved() {
#expect(
BoardGitControls.switchTargets(branches: ["zeta", "alpha", "main", "beta"], current: "main")
== ["zeta", "alpha", "beta"]
)
}
}
// MARK: - Commit identity // MARK: - Commit identity
@Suite("Board popover ▸ the commit-identity fields") @Suite("Board popover ▸ the commit-identity fields")