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. struct BoardGitBranchSurface: Equatable { /// What the branch line reads — the branch name, the short hash on a detached HEAD /// (`GitRepository.branchName` decides which), or the placeholder while the first read is in /// flight. 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 branch picker and the create action accept a click. let controlsEnabled: Bool /// The line the branch display is read as by VoiceOver. var accessibilityLabel: String { isReadingBranch ? "Reading branch" : "Branch \(branchLabel)" } static let placeholder = "…" /// 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." static func resolve( branch: String?, pause: GitRepositoryPause?, isSwitching: Bool, isWritable: Bool ) -> BoardGitBranchSurface { BoardGitBranchSurface( branchLabel: branch ?? placeholder, isReadingBranch: branch == nil, pauseExplanation: pause?.explanation, 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, ▸ Interaction with external writers). /// /// Three surfaces, in the order the design lists them: the branch display with switching and /// creation, the pause explanation when there is one, and the commit-identity fields. /// /// **Shaped for the half that is not here yet.** Remote tracking, Pull/Push, push-on-commit and the /// authentication surface are 07-sync-collab.md's own cards, and this section is arranged so they /// join as one more block between the branch controls and the identity fields — 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 @State private var isNaming = false @State private var draftBranch = "" 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 isNaming { newBranchField } if let explanation = surface.pauseExplanation { pauseNote(explanation) } if let failure = git.switcher?.lastFailure { caption(failure.message, tone: .red) } BoardGitIdentityFields(git: git, isEnabled: isEnabled) } // 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, branch switching and creation" one affordance rather than a label /// with a button beside it. private var branchRow: some View { 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 !otherBranches.isEmpty { Divider() } Button("New Branch…") { draftBranch = "" isNaming = true } } label: { Text(surface.branchLabel) .font(.callout) .foregroundStyle(surface.isReadingBranch ? .secondary : .primary) } .menuStyle(.borderlessButton) .fixedSize() .disabled(!surface.controlsEnabled) .accessibilityLabel(surface.accessibilityLabel) .accessibilityHint("Switch branches or create a branch") if git.switcher?.isSwitching == true { ProgressView() .controlSize(.small) .accessibilityLabel("Switching branches") } } } /// 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. private var otherBranches: [String] { (git.switcher?.branches ?? []).filter { $0 != git.branch } } // MARK: Create and switch /// Named inline rather than in a sheet: the popover is where the operation was asked for, and a /// sheet over a transient popover would dismiss the surface it came from. private var newBranchField: some View { HStack(spacing: 6) { TextField("New branch name", text: $draftBranch) .textFieldStyle(.roundedBorder) .lineLimit(1) .onSubmit { create() } // Escape steps outward one layer (04-interactions.md ▸ Grammar): it abandons the // naming rather than dismissing the popover under it. .onKeyPress(.escape) { isNaming = false draftBranch = "" return .handled } Button("Create", action: create) .disabled(trimmedDraft.isEmpty) } .disabled(!surface.controlsEnabled) } private var trimmedDraft: String { draftBranch.trimmingCharacters(in: .whitespacesAndNewlines) } private func create() { let name = trimmedDraft guard !name.isEmpty else { return } isNaming = false draftBranch = "" 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) } } // MARK: - Commit identity /// **The name and email that repo-local `.git/config` carries** (06-history-undo.md ▸ Interaction /// with external writers: "The board settings sheet's identity section … exposes name/email fields /// that write that repo-local config — the setting *is* the file, portable to any git client, /// per-board by nature"). /// /// **Its home is the sheet, and the sheet is not built yet.** The 2026-07-31 popover/sheet split moved /// every setup-shaped control — add-git, branch creation, these fields, remote and credentials — out /// of the popover and into a board settings sheet (03-board-ui.md ▸ Board settings sheet), leaving the /// popover the daily face. This view is the fields, hosted where they were; the sheet's card moves /// them, along with the visibility-scoped poll below, which 06 says "rides with the fields". /// /// ### The placeholder is the whole of the identity rule made visible /// /// An empty field shows the **derived default** — the macOS account's full name and /// `shortname@hostname` — as a placeholder, never as a value. That is the difference between "this /// repository says nothing, so the app signs commits with a sensible guess" and "this repository says /// this", and the file is where the difference lives: 06 forbids the app writing its own derived /// value into config, because it would then outrank the user's global `~/.gitconfig` for their own /// terminal commits in that board. A field pre-filled with the derived value would write it on the /// first focus loss. /// /// ### The dirty-buffer courtesy, copied from `BoardRenameField` /// /// A foreign config edit landing while the popover is open updates an *unfocused* field and never a /// focused one: "a focused field keeps the user's keystrokes" (03-board-ui.md ▸ Board popover). The /// trigger is a poll rather than a reload, and that is honest rather than lazy: `FolderWatcher` /// filters `.git` out of the watch by design, so no board event can ever carry a config change, and /// the alternative to a small periodic read is a field that is stale for as long as the popover /// stays open. The poll lives and dies with this view. private struct BoardGitIdentityFields: View { let git: HistoryStore let isEnabled: Bool @State private var name = "" @State private var email = "" @FocusState private var focused: Field? private enum Field: Hashable { case name case email } /// **The fields re-read the config at 2 s while the surface is visible** (06 ▸ Interaction with /// external writers, blessed 2026-07-31): "the watcher never delivers `.git`, so no board event /// can carry a terminal-side config edit — the unfocused-resync courtesy needs its own signal, and /// a visibility-scoped poll is the 15 s paused-state re-read's shape at sheet cadence (a focused /// field keeps its keystrokes; dismissing the sheet stops the poll)." private static let pollInterval: Duration = .seconds(2) var body: some View { VStack(alignment: .leading, spacing: 6) { Divider() Text("Commit Identity") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) field("Name", text: $name, placeholder: git.derivedIdentity?.name ?? "", tag: .name) field("Email", text: $email, placeholder: git.derivedIdentity?.email ?? "", tag: .email) if let failure = git.identityFailure { Text(failure.message) .font(.caption) .foregroundStyle(.red) .fixedSize(horizontal: false, vertical: true) } } .task { // The first read, then the courtesy poll. Cancellation is the view's disappearance, which // is the popover closing. while !Task.isCancelled { await git.refreshIdentity() try? await Task.sleep(for: Self.pollInterval) } } .onAppear { name = git.identityName email = git.identityEmail } .onChange(of: git.identityName) { _, value in guard focused != .name else { return } name = value } .onChange(of: git.identityEmail) { _, value in guard focused != .email else { return } email = value } // A dismissal is a commit like any other click-away — `BoardRenameField`'s rule, and the same // idempotence makes the overlap harmless. .onDisappear { commit() } } private func field( _ label: String, text: Binding, placeholder: String, tag: Field ) -> some View { HStack(spacing: 6) { Text(label) .font(.caption) .foregroundStyle(.secondary) .frame(width: 44, alignment: .leading) TextField(placeholder, text: text) .textFieldStyle(.roundedBorder) .lineLimit(1) .focused($focused, equals: tag) .onSubmit { commit() } .disabled(!isEnabled) .accessibilityLabel("Commit \(label.lowercased())") } .onChange(of: focused) { previous, _ in // Focus leaving *this* field is this field's commit — the inline editors' exit, applied // to a form where Tab moves between two of them. guard previous == tag else { return } commit() } } /// Writes both fields, and only when one of them differs from what the file says — an unchanged /// value must not rewrite `.git/config` every time the popover closes. private func commit() { guard isEnabled else { return } guard name != git.identityName || email != git.identityEmail else { return } Task { await git.writeIdentity(name: name, email: email) } } }