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
+56 -12
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
/// 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
/// 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
@@ -169,20 +169,42 @@ struct BoardGitControls: View {
/// beside it.
///
/// **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
/// branch the menu is therefore *empty*, and that is left honest rather than papered over: the
/// label still reads the current branch and still disables with the rest of the git surface, so
/// the line goes on saying the one thing it is here to say.
/// used to close the menu is the settings sheet's standing Create field now. **A single-branch
/// board therefore opens onto a disabled explanatory row** (03-board-ui.md Board popover Git
/// tab, ruled 2026-08-06 and built with the tab): the earlier posture left the menu genuinely
/// 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 {
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")
.imageScale(.small)
.foregroundStyle(.secondary)
Menu {
ForEach(otherBranches, id: \.self) { name in
Button(name) {
Task { await git.switcher?.switchTo(name) }
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) {
Task { await git.switcher?.switchTo(name) }
}
}
}
} label: {
@@ -204,10 +226,32 @@ struct BoardGitControls: View {
}
}
/// 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.
/// This board's switch targets, off the live switcher see `switchTargets(branches:current:)`
/// for the rule itself.
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