import SwiftUI /// **The Git tab's two setup controls** — add-git and the commit-identity fields (03-board-ui.md ▸ /// Board popover ▸ Git tab; 06-history-undo.md ▸ Rules ▸ Opt-in init and ▸ Interaction with external /// writers). /// /// ### One configuration home again (ruled 2026-08-07) /// /// These two lived in the popover, moved to the board settings sheet with the **2026-07-31 /// popover/sheet split**, and came back with the **2026-08-07 reversal** that retired that sheet: the /// popover is the board's one configuration surface, and its Git tab is where a board's repository is /// both operated and set up. Nothing about either control's *substance* moved in either direction — /// what moved is the container, and every rule stated below is the same rule the sheet carried, /// re-pointed at the tab. /// /// The one thing the reversal does change is what "the form is visible" means: it is the **popover's** /// visibility now, and a popover is transient where a sheet was not. That is fine for both rules that /// depend on it — add-git's inline-answer window and the identity fields' poll are both scoped to "the /// surface the user asked from is still under their eye", and a popover dismissed by a stray click /// ends that window exactly as Done ended the sheet's (06 ▸ Rules: "inline while the asking surface is /// up, banner once it is gone"). /// /// They live in their own file rather than in `BoardGitTabView.swift` because they are the *setup* /// half — the tab's postures and its daily branch face are that file's and `BoardGitControls.swift`'s /// — and because a pro-m2 card adding the remote and credential surfaces adds them beside these, /// under the same rules, rather than into the posture switch. // MARK: - Geometry /// The setup form's one figure, **derived from the body font** like every other surface's /// (10-accessibility.md ▸ Text scaling: "relative text styles everywhere, no fixed point sizes"). /// /// It is what survives `BoardSettingsSheetLayout`, which retired with the sheet 2026-08-07: the /// sheet's width and section inset were a *window's* geometry and the popover supplies both itself /// (`BoardInfoView`), but the identity form's label column is the form's own and travelled with it. enum BoardGitSetupLayout { /// The identity form's label column. 3.4 em: 44pt at the standard body, which is what those /// fields have always drawn. static func labelColumn(bodyPointSize: CGFloat) -> CGFloat { BoardMetrics.em(3.4, bodyPointSize: bodyPointSize) } } // MARK: - Add git /// **The add-git action** (06-history-undo.md ▸ Rules ▸ Opt-in init) — the one place in the app that /// creates a repository, and the reason "no silent auto-init, ever" is a checkable claim rather than /// a promise: there is no other caller of `HistoryStore.addGit`. /// /// The caption states what pressing it does, in the order it happens, because it is not undoable in /// the ordinary sense: a repository appears in the board's folder and its current state becomes the /// first commit. /// /// **It renders inline in the Git tab's no-repository posture** since the 2026-08-07 reversal — under /// the note that states the fact, where the Board Settings… door stood between 2026-07-31 and that /// day. The posture is unchanged in every other respect: the fact, then the offer. struct BoardGitAddAction: View { let git: HistoryStore /// The read-only lock's reach (02-architecture.md ▸ The lock's scope): a board that refuses /// writes refuses this one too — initializing a repository is a write, and a commit is several. /// The popover **stays open** under the lock and disables in place, which is the style popover's /// settled precedent (03 ▸ Board popover). let isEnabled: Bool var body: some View { VStack(alignment: .leading, spacing: 6) { Button("Add Git") { Task { await git.addGit() } } .disabled(!isEnabled || git.isAddingGit) Text("Creates a git repository in this board's folder and commits its current state.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) if let failure = git.lastFailure { Text(failure.message) .font(.caption) .foregroundStyle(.red) .fixedSize(horizontal: false, vertical: true) } } // **The form add-git answers at** (06 ▸ Interaction with external writers, ruled 2026-07-31 — // "Form-anchored operations answer at the form first"): inline while this control is on // screen, the banner once it is gone. Appearing claims the inline surface; disappearing gives // it up, which both dismisses the stale error and sends any answer still in flight to the // banner instead of to nobody. // // The control's visibility *is* the Git tab's, and the tab's is the popover's — a narrower // window than the sheet's was until 2026-08-07, and the right one: a popover dismissed by a // click outside is precisely the user leaving the form. Two other disappearances are not // dismissals and both are correct: switching to Info or Theme, which puts the question away // as surely as closing the popover, and a *successful* add-git flipping the mode out from // under this posture — the failure slot empties because there is nothing left to fail. .onAppear { git.noteFormVisible(true) } .onDisappear { git.noteFormVisible(false) } } } // MARK: - Commit identity /// **The name and email that repo-local `.git/config` carries** (06-history-undo.md ▸ Interaction /// with external writers: "the 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"). /// /// The fields moved to the sheet with the 2026-07-31 split and back to the popover's Git tab with the /// 2026-08-07 reversal, poll included — 06 says the visibility-scoped re-read "rides with the fields", /// so hosting the view here *is* the re-point: the `.task` below now lives and dies with the tab. /// /// ### 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 tab 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 surface stays /// open. 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 they are 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 form cadence (a focused /// field keeps its keystrokes; dismissing the surface stops the poll)." The surface the ruling /// named was the sheet; since 2026-08-07 it is the popover's Git tab, which is a *shorter* life /// than the sheet's and therefore a strictly smaller poll. private static let pollInterval: Duration = .seconds(2) var body: some View { VStack(alignment: .leading, spacing: 6) { 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 or the tab strip moving off Git. 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: BoardGitSetupLayout.labelColumn(bodyPointSize: BoardMetrics.bodyPointSize), 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) } } }