The titlebar widget becomes a two-line identity block: the board glyph at 22pt spanning both lines, the title over the branch (git-mode only, smaller and secondary), the em-dash retired. New Branch… returns to the switch menu behind a divider, revealing an inline name field — the pre-split shape. The board settings sheet retires whole: add-git and commit identity render inline in the Git tab's postures (BoardGitSetup.swift), the availability rule collapses into BoardGitSetupSection.resolve, and Board ▸ Board Settings… leaves the menu bar. Where 07's remote/credential setup surfaces land is deliberately left open — filed on the Redesign board. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
391 lines
21 KiB
Swift
391 lines
21 KiB
Swift
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)
|
|
}
|
|
}
|