The popover/sheet split lands: a board-scoped, titled, sectioned sheet on the board window hosts everything setup-shaped, opened from the popover's Board Settings… row and the new Board ▸ Board Settings… menu row. The three existing setup controls relocate — add-git (whose noteFormVisible lines now make the sheet the form-anchored failure surface), branch creation (a standing field; create-and-switch runs the identical settle sequence), and the commit-identity fields (the 2s visibility-scoped poll rides with them). The popover keeps the daily face and its postures; its Pro/mode-none section becomes header + door (.addGit renamed .noRepository). Availability is derived from the section inventory (Pro + mode none or git), so pro-m2's sections can't drift from the doors; the sheet's fields join the caret-chord disable set. The audit suite pins what the free fixture can reach; the sheet's own audit is manual until a tier override is ruled. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
620 lines
28 KiB
Swift
620 lines
28 KiB
Swift
import SwiftUI
|
|
|
|
/// **The board settings sheet** — "the setup home" (03-board-ui.md ▸ Board settings sheet, ruled
|
|
/// 2026-07-31: the popover/sheet split, 04-interactions.md's configuration carve-out).
|
|
///
|
|
/// ### Why there are two configuration surfaces and not one
|
|
///
|
|
/// The split's reasons are mechanical, not aesthetic (04 ▸ The map): setup flows fire confirmation
|
|
/// alerts, run inline network probes, and accept drag-in key import — "acts that need a surface a
|
|
/// stray click can't dismiss". So the **popover** keeps the daily face (rename, styling, the branch
|
|
/// display and its switch picker, the posture lines) and this sheet takes everything setup-shaped.
|
|
/// **Each control has exactly one home**: "the popover never duplicates a sheet control, the sheet
|
|
/// never hosts the daily surface."
|
|
///
|
|
/// ### What is here, and what joins it
|
|
///
|
|
/// Three sections ship with the sheet itself, and each of them *moved* here rather than being written
|
|
/// here: **add-git** (mode `none`), **branch creation** (mode `git`), and the **commit-identity**
|
|
/// fields (mode `git`). 03's inventory for this surface is longer — add/change remote with its inline
|
|
/// verify probe, the HTTPS credential fields, the whole SSH surface with its drag-in key import and
|
|
/// TOFU confirms, push-on-commit — and every one of those is a pro-m2 card that joins as **one more
|
|
/// section** (`BoardSettingsSection`), which is the only thing the shape here has to promise.
|
|
///
|
|
/// ### Undo routing needs nothing from this file
|
|
///
|
|
/// "The settings sheet's text fields own ⌘Z/⇧⌘Z as field-local text undo while focused"
|
|
/// (06-history-undo.md ▸ Undo routing). A SwiftUI sheet is hosted in its own `NSWindow`, so a focused
|
|
/// field's editor supplies its own undo manager through the responder chain exactly as the popover's
|
|
/// rename field does — the platform's first-responder rule, working by construction. Nothing here
|
|
/// wires it, and nothing here may quietly take it away: a board-level ⌘Z reaching a typo would be
|
|
/// 06's "reflexive undo over a typo must never become a tree checkout".
|
|
|
|
// MARK: - Presentation state
|
|
|
|
/// Whether **this window's** settings sheet is open, and the session posture that decides whether it
|
|
/// may open at all.
|
|
///
|
|
/// `BoardInfoPresentation`'s sibling in every respect (see it for why the flag is per *window* rather
|
|
/// than per board or per app): one per window, `@State` in `BoardWindowHost`, published through the
|
|
/// focus system so Board ▸ Board Settings… means "the board in front".
|
|
///
|
|
/// **It carries the session's tier and git state** where the popover flag carries nothing, and for a
|
|
/// reason the popover does not have: both of this sheet's doors have to *validate*, and one of them
|
|
/// is a menu row with no view around it to ask. The facts are adopted once, from the session, at the
|
|
/// same moment the titlebar widget adopts them (`BoardWindowHost.configureWindow`) and never
|
|
/// re-derived — 12-editions.md ▸ The entitlement, "a lapse never interrupts an open session". The
|
|
/// *mode* inside the git state is `@Observable` and does move, by add-git alone, which is exactly the
|
|
/// transition this sheet is where the user performs: the sections re-resolve under it live.
|
|
@MainActor
|
|
@Observable
|
|
final class BoardSettingsPresentation {
|
|
|
|
var isPresented = false
|
|
|
|
/// The tier this window's board composed under — the free tier's default until a session says
|
|
/// otherwise, which is the harmless direction (an unreachable sheet).
|
|
private(set) var tier: Tier = .free
|
|
|
|
/// This window's board git state, `nil` under the free tier and on a window whose session has not
|
|
/// been adopted yet.
|
|
private(set) var git: HistoryStore?
|
|
|
|
/// Called once per window, from the same place the titlebar widget is handed the same two facts.
|
|
func adopt(tier: Tier, git: HistoryStore?) {
|
|
self.tier = tier
|
|
self.git = git
|
|
}
|
|
|
|
/// What the sheet would show right now — and therefore, when empty, that there is no sheet to
|
|
/// show (`BoardSettingsAvailability`).
|
|
var sections: [BoardSettingsSection] {
|
|
BoardSettingsSection.resolve(tier: tier, mode: git?.mode ?? .none)
|
|
}
|
|
|
|
/// Both doors' validation: the menu row's `disabled` state and whether the popover shows its row
|
|
/// at all.
|
|
var isReachable: Bool {
|
|
BoardSettingsAvailability.resolve(tier: tier, mode: git?.mode ?? .none)
|
|
}
|
|
|
|
/// **Opening, not toggling** — unlike ⌘I. A sheet is modal to its window and carries its own
|
|
/// dismissal (Done, and Escape through it), so a command that could also *close* it would be a
|
|
/// second exit for a surface that already has the platform's; and neither door is reachable while
|
|
/// the sheet is up anyway (the menu is behind it, the popover is dismissed by it).
|
|
///
|
|
/// The guard is not defensive dressing: both doors validate on `isReachable` already, and a
|
|
/// third path that forgot to would present a sheet with no sections in it.
|
|
func present() {
|
|
guard isReachable else { return }
|
|
isPresented = true
|
|
}
|
|
|
|
func dismiss() {
|
|
isPresented = false
|
|
}
|
|
}
|
|
|
|
/// The focused board window's settings sheet, beside `FocusedValues.boardInfo` — see
|
|
/// `FocusedBoardStoreKey` for why board-window menu items reach their window this way.
|
|
struct FocusedBoardSettingsKey: FocusedValueKey {
|
|
typealias Value = BoardSettingsPresentation
|
|
}
|
|
|
|
extension FocusedValues {
|
|
var boardSettings: BoardSettingsPresentation? {
|
|
get { self[FocusedBoardSettingsKey.self] }
|
|
set { self[FocusedBoardSettingsKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
// MARK: - What the sheet holds
|
|
|
|
/// **The sheet's inventory for one board**, as a pure function of the tier and the mode — the shape
|
|
/// `BoardGitSection.resolve` has one surface over, and for the same reason: the *contents* are the
|
|
/// part worth pinning and the SwiftUI that renders them is not.
|
|
///
|
|
/// Ordered as the sheet lays them out, top to bottom. pro-m2's cards each add a case here and a
|
|
/// branch in `BoardSettingsSheet.section(_:)` — nothing else.
|
|
enum BoardSettingsSection: String, Equatable, CaseIterable, Identifiable {
|
|
|
|
/// Pro, mode `none`: **add-git** (06-history-undo.md ▸ Rules ▸ Opt-in init).
|
|
case git
|
|
|
|
/// Pro, mode `git`: **branch creation**. Switching stays in the popover (03 ▸ Board popover);
|
|
/// create-and-switch runs 06's identical settle sequence from here.
|
|
case branch
|
|
|
|
/// Pro, mode `git`: the **commit identity** name/email that repo-local `.git/config` carries
|
|
/// (06 ▸ Interaction with external writers).
|
|
case commitIdentity
|
|
|
|
var id: String { rawValue }
|
|
|
|
/// The section header — a heading VoiceOver navigates by (10-accessibility.md ▸ Board settings
|
|
/// sheet: "titled and sectioned with headers VoiceOver can navigate by").
|
|
var title: String {
|
|
switch self {
|
|
case .git: "Git"
|
|
case .branch: "Branch"
|
|
case .commitIdentity: "Commit Identity"
|
|
}
|
|
}
|
|
|
|
static func resolve(tier: Tier, mode: BoardGitMode) -> [BoardSettingsSection] {
|
|
// **The free tier has no setup to host** (12-editions.md ▸ The free tier and `.git`): git is
|
|
// the Pro subscription's, "any `.git` is inert", and 03 gives the free tier's whole git story
|
|
// as the popover's one-line pointer. There is nothing for a sheet to be about.
|
|
guard tier == .pro else { return [] }
|
|
switch mode {
|
|
case .none:
|
|
return [.git]
|
|
case .git:
|
|
return [.branch, .commitIdentity]
|
|
case .repoNested:
|
|
// **Nothing setup-shaped can apply** (06 ▸ Rules): the board lives inside a repository
|
|
// Lanework leaves alone, so there is no add-git (the design is insistent that the option
|
|
// is *absent*, "prose, not a disabled button"), no branch of ours to create, and no
|
|
// repo-local config of ours to write. An empty sheet would be the greyed-out button one
|
|
// level up — so the popover's prose stands and this surface simply does not exist for
|
|
// such a board.
|
|
return []
|
|
}
|
|
}
|
|
}
|
|
|
|
/// **Whether the sheet is reachable at all**, for both of its doors — Board ▸ Board Settings…'s
|
|
/// `disabled` state and whether the popover renders its Board Settings… row.
|
|
///
|
|
/// Reachable **iff the sheet has something to show**, which is the rule rather than a shortcut: a
|
|
/// surface whose whole job is hosting setup controls has no honest empty state, and deriving the
|
|
/// answer from the inventory is what keeps the two from drifting when pro-m2's sections land. In
|
|
/// today's terms that reads: Pro, on a board whose mode is `none` or `git`.
|
|
///
|
|
/// The two unreachable postures are unreachable for *different* reasons, and both are the design's:
|
|
///
|
|
/// - **The free tier**: no setup exists there at all (03-board-ui.md ▸ Board popover;
|
|
/// 12-editions.md). The popover's own postures are untouched by this card — an ordinary board
|
|
/// shows nothing, a board carrying an inert `.git` shows the one-line Pro pointer.
|
|
/// - **Pro, repo-nested**: `BoardSettingsSection.resolve`'s own comment carries this one — nothing
|
|
/// setup-shaped can apply, so the popover's prose stands and no door opens.
|
|
///
|
|
/// The menu **row stays visible and disabled** either way (standard menu validation — a command that
|
|
/// does not apply here is still a command this app has), while the **popover row appears only where
|
|
/// the sheet is reachable**: a menu is an inventory of the app, a popover section is a description of
|
|
/// this board.
|
|
enum BoardSettingsAvailability {
|
|
|
|
static func resolve(tier: Tier, mode: BoardGitMode) -> Bool {
|
|
!BoardSettingsSection.resolve(tier: tier, mode: mode).isEmpty
|
|
}
|
|
}
|
|
|
|
// MARK: - The sheet
|
|
|
|
/// The sheet itself: a header naming it and the board, the sections, and one Done.
|
|
struct BoardSettingsSheet: View {
|
|
|
|
let store: BoardStore
|
|
let presentation: BoardSettingsPresentation
|
|
|
|
private var bodyPointSize: CGFloat { BoardMetrics.bodyPointSize }
|
|
|
|
private var width: CGFloat {
|
|
BoardSettingsSheetLayout.width(bodyPointSize: bodyPointSize)
|
|
}
|
|
|
|
private var inset: CGFloat {
|
|
BoardSettingsSheetLayout.inset(bodyPointSize: bodyPointSize)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
header
|
|
|
|
Divider()
|
|
|
|
// No scroll container, deliberately: three sections fit any screen at any text size, and
|
|
// a `ScrollView` inside a content-sized sheet has to be given a height — a decision worth
|
|
// making when pro-m2's credential and SSH sections make it real, not before.
|
|
VStack(alignment: .leading, spacing: inset) {
|
|
ForEach(presentation.sections) { section in
|
|
self.section(section)
|
|
}
|
|
}
|
|
.padding(inset)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
Divider()
|
|
|
|
footer
|
|
}
|
|
.frame(width: width)
|
|
}
|
|
|
|
// MARK: The chrome
|
|
|
|
/// **Titled** (10-accessibility.md ▸ Board settings sheet) — the surface's own name, plus the
|
|
/// board's so a user with two boards open knows which one this is about.
|
|
///
|
|
/// The board's name is `AppModel.displayName(of:)` — the title-falls-back-to-the-folder-name rule
|
|
/// (01-storage-format.md ▸ Board naming), called rather than restated: `BoardInfoTitlebarSummary`
|
|
/// restates it only because it must answer without a `BoardStore`, and this sheet has one.
|
|
private var header: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Board Settings")
|
|
.font(.headline)
|
|
.accessibilityAddTraits(.isHeader)
|
|
Text(AppModel.displayName(of: store))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(inset)
|
|
}
|
|
|
|
/// **The one exit, twice** — the button and Escape.
|
|
///
|
|
/// `.cancelAction` is what wires Escape to it, and the naming is deliberate rather than sloppy:
|
|
/// nothing on this sheet is staged, so there is nothing a Cancel could roll back — every control
|
|
/// writes when it is used, and the button says Done because that is what dismissing means here.
|
|
/// Not `.defaultAction`, so Return stays the focused field's (the branch name field submits with
|
|
/// it).
|
|
private var footer: some View {
|
|
HStack {
|
|
Spacer()
|
|
Button("Done") {
|
|
presentation.dismiss()
|
|
}
|
|
.keyboardShortcut(.cancelAction)
|
|
}
|
|
.padding(inset)
|
|
}
|
|
|
|
// MARK: The sections
|
|
|
|
@ViewBuilder
|
|
private func section(_ section: BoardSettingsSection) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(section.title)
|
|
.font(.subheadline.weight(.semibold))
|
|
// What "sectioned" buys a VoiceOver user: headings the rotor jumps between, rather
|
|
// than one flat run of controls (10 ▸ Board settings sheet — "the sheet exists partly
|
|
// *because* Tab-walking two dozen controls in an untitled popover failed this bar").
|
|
.accessibilityAddTraits(.isHeader)
|
|
|
|
if let git = presentation.git {
|
|
switch section {
|
|
case .git:
|
|
BoardGitAddAction(git: git, isEnabled: store.acceptsBoardMutations)
|
|
case .branch:
|
|
BoardSettingsBranchSection(git: git, isEnabled: store.acceptsBoardMutations)
|
|
case .commitIdentity:
|
|
BoardGitIdentityFields(git: git, isEnabled: store.acceptsBoardMutations)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
// MARK: - Geometry
|
|
|
|
/// The sheet's two figures, **derived from the body font** like every other surface's
|
|
/// (10-accessibility.md ▸ Text scaling: "relative text styles everywhere, no fixed point sizes"), and
|
|
/// stated here rather than inline so they are one decision.
|
|
///
|
|
/// A sheet is a window the app sizes, so a width it does not choose is a width AppKit derives from
|
|
/// whatever the widest control happened to be — which would move every time a section joined. The
|
|
/// figure is wide enough for a labeled two-column form (the identity fields) and narrower than the
|
|
/// board window's own floor, so the sheet reads as a card on the window rather than as a second one.
|
|
enum BoardSettingsSheetLayout {
|
|
|
|
/// 30 em: 390pt at the standard 13pt body.
|
|
static func width(bodyPointSize: CGFloat) -> CGFloat {
|
|
BoardMetrics.em(30, bodyPointSize: bodyPointSize)
|
|
}
|
|
|
|
/// The sheet's inset **and** the gap between two sections — one figure, because a section's
|
|
/// distance from its neighbour and from the sheet's edge are the same rhythm. 1.55 em: 20pt at
|
|
/// the standard body.
|
|
static func inset(bodyPointSize: CGFloat) -> CGFloat {
|
|
BoardMetrics.em(1.55, bodyPointSize: bodyPointSize)
|
|
}
|
|
|
|
/// 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 moved here from the popover with the 2026-07-31 split** (03-board-ui.md ▸ Board settings
|
|
/// sheet: the sheet hosts "add-git (mode none; opt-in init — 06)"), carrying the two lines below with
|
|
/// it.
|
|
private 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 sheet **stays open** under the lock and disables in place, which is the style popover's
|
|
/// settled precedent (03 ▸ Board settings sheet).
|
|
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 sheet is up, 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 section's visibility *is* the sheet's here, and stays so as pro-m2's sections arrive:
|
|
// add-git exists only on mode `none`, and a successful one flips the mode — which is the one
|
|
// disappearance that is not a dismissal, and it is the right one (the failure slot empties
|
|
// because there is nothing left to fail).
|
|
.onAppear { git.noteFormVisible(true) }
|
|
.onDisappear { git.noteFormVisible(false) }
|
|
}
|
|
}
|
|
|
|
// MARK: - Branch creation
|
|
|
|
/// **Branch creation** (06-history-undo.md ▸ Branch switching; 03-board-ui.md ▸ Board settings sheet:
|
|
/// "branch creation (switching stays in the popover; create-and-switch runs 06's identical settle
|
|
/// sequence from here)").
|
|
///
|
|
/// ### A standing field, not a reveal
|
|
///
|
|
/// In the popover this was a "New Branch…" entry inside the switch menu that revealed an inline field
|
|
/// — the right shape *there*, where the surface is a compact daily face and the field was a detour off
|
|
/// it. A form is a form: this sheet exists to hold setup controls standing, so the field stands, and
|
|
/// the reveal dance retires with the container that motivated it. The Create button validates on a
|
|
/// non-empty trimmed name, which is the only thing the dance was ever gating.
|
|
///
|
|
/// ### 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, blessed 2026-07-31) — and this
|
|
/// view calls exactly that method. The save-or-discard step it may raise is an **alert over the
|
|
/// sheet**, which is one of the mechanical reasons the sheet exists at all: "confirmation alerts
|
|
/// present over the sheet without dismissing the flow that owns them" (03).
|
|
private struct BoardSettingsBranchSection: View {
|
|
|
|
let git: HistoryStore
|
|
let isEnabled: Bool
|
|
|
|
@State private var draft = ""
|
|
|
|
/// The same four facts the popover's branch line reads, so a paused repository, a read-only board
|
|
/// and a switch in flight close this control exactly as they close that one — one rule, one
|
|
/// derivation (`BoardGitBranchSurface`).
|
|
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: 6) {
|
|
HStack(spacing: 6) {
|
|
TextField("New branch name", text: $draft)
|
|
.textFieldStyle(.roundedBorder)
|
|
.lineLimit(1)
|
|
.onSubmit { create() }
|
|
// **Escape steps outward one layer per press** (04-interactions.md ▸ Grammar),
|
|
// the rename field's rule: a dirty field abandons its draft and keeps the sheet
|
|
// up; an empty one lets the press through to the sheet's own dismissal.
|
|
.onKeyPress(.escape) {
|
|
guard !draft.isEmpty else { return .ignored }
|
|
draft = ""
|
|
return .handled
|
|
}
|
|
.accessibilityLabel("New branch name")
|
|
|
|
Button("Create", action: create)
|
|
.disabled(trimmedDraft.isEmpty)
|
|
|
|
if git.switcher?.isSwitching == true {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
.accessibilityLabel("Switching branches")
|
|
}
|
|
}
|
|
.disabled(!surface.controlsEnabled)
|
|
|
|
Text("Creates the branch from the current one and switches to it.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
// The switcher's last failure, said where it was asked for. The popover's own caption
|
|
// stays (a switch asked *there* answers there); the two can never show at once, since
|
|
// opening this sheet dismisses that popover.
|
|
if let failure = git.switcher?.lastFailure {
|
|
Text(failure.message)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
// The two reads `surface` needs that nothing else on this sheet takes — the branch name and
|
|
// the pause — asked when the sheet appears, because neither is a fact the board's watcher
|
|
// could deliver (`.git` is filtered out of the watch by design). The branch *list* is not
|
|
// read here: this section creates, and only the popover's picker needs to know what exists.
|
|
.task {
|
|
await git.refreshBranch()
|
|
await git.committer?.refreshPause()
|
|
}
|
|
}
|
|
|
|
private var trimmedDraft: String {
|
|
draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
private func create() {
|
|
let name = trimmedDraft
|
|
guard !name.isEmpty, surface.controlsEnabled else { return }
|
|
draft = ""
|
|
Task { await git.switcher?.createAndSwitch(to: name) }
|
|
}
|
|
}
|
|
|
|
// 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").
|
|
///
|
|
/// It moved here whole from the popover with the 2026-07-31 split, 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 sheet.
|
|
///
|
|
/// ### 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 sheet 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 sheet stays
|
|
/// open.
|
|
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 sheet 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) {
|
|
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 sheet 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<String>,
|
|
placeholder: String,
|
|
tag: Field
|
|
) -> some View {
|
|
HStack(spacing: 6) {
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.frame(
|
|
width: BoardSettingsSheetLayout.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 sheet 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) }
|
|
}
|
|
}
|