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
230 lines
11 KiB
Swift
230 lines
11 KiB
Swift
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) }
|
|
}
|
|
}
|