diff --git a/Kanban/UI/Board/BoardGitControls.swift b/Kanban/UI/Board/BoardGitControls.swift index b45db54..1d67e76 100644 --- a/Kanban/UI/Board/BoardGitControls.swift +++ b/Kanban/UI/Board/BoardGitControls.swift @@ -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 diff --git a/KanbanTests/BranchSwitchTests.swift b/KanbanTests/BranchSwitchTests.swift index 718d477..97a23cd 100644 --- a/KanbanTests/BranchSwitchTests.swift +++ b/KanbanTests/BranchSwitchTests.swift @@ -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")