The popover/sheet split reverses — settings fold into the Git tab, the widget stacks name over branch
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
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
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<String>,
|
||||
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) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user