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:
@@ -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,22 +169,44 @@ 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
|
||||
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: {
|
||||
Text(surface.branchLabel)
|
||||
.font(.callout)
|
||||
@@ -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
|
||||
|
||||
@@ -661,7 +661,7 @@ struct BranchSwitchSequenceTests {
|
||||
var asked = 0
|
||||
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.
|
||||
#expect(await switcher.switchTo("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
|
||||
|
||||
@Suite("Board popover ▸ the commit-identity fields")
|
||||
|
||||
Reference in New Issue
Block a user