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) } }