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:
@@ -87,17 +87,17 @@ extension BoardStore {
|
||||
/// > enabled menu key equivalent fires before the field ever sees the key, and ⌘←/⌘→ are the
|
||||
/// > standard line-start/end caret chords.
|
||||
///
|
||||
/// Five text surfaces, answered four ways, and only three of them are here:
|
||||
/// Four text surfaces, answered three ways, and only two of them are here:
|
||||
///
|
||||
/// - **Inline title editors** are `acceptsBoardMutations`', through the focused-editor rule — a
|
||||
/// broader lockdown that already covers these two items.
|
||||
/// - **The board popover's fields** are covered by disabling while the popover is open at all —
|
||||
/// coarser than per-field focus, but it is a configuration surface (04's carve-out) and no lane
|
||||
/// move belongs under it.
|
||||
/// - **The board settings sheet's fields** are the popover's rule for the popover's reason, and the
|
||||
/// surface the 2026-07-31 split moved most of those fields *to* (branch name, commit identity, and
|
||||
/// pro-m2's remote URL and credentials): the sheet is the other half of 04's configuration
|
||||
/// carve-out, and a lane move under a modal settings surface is not a gesture that exists.
|
||||
/// move belongs under it. That clause covers **every** configuration field the app has again since
|
||||
/// 2026-08-07: the 2026-07-31 split moved most of them to a board settings sheet, whose flag this
|
||||
/// function read as a third disjunct, and the reversal brought them back — branch creation, commit
|
||||
/// identity, and pro-m2's remote URL and credentials all live in the popover's Git tab, under the
|
||||
/// popover's own open-at-all rule.
|
||||
/// - **The search field** is per-focus and exact (`BoardSearchPresentation.isFocused`), which it has
|
||||
/// to be: the field's own rule is that board commands *stay enabled* while it holds the keyboard
|
||||
/// (04 § Search), so these two are the narrow exception to it and nothing coarser would do.
|
||||
@@ -109,12 +109,9 @@ extension BoardStore {
|
||||
@MainActor
|
||||
func caretChordsYield(
|
||||
boardInfo: BoardInfoPresentation?,
|
||||
boardSettings: BoardSettingsPresentation?,
|
||||
search: BoardSearchPresentation?
|
||||
) -> Bool {
|
||||
boardInfo?.isPresented == true
|
||||
|| boardSettings?.isPresented == true
|
||||
|| search?.isFocused == true
|
||||
boardInfo?.isPresented == true || search?.isFocused == true
|
||||
}
|
||||
|
||||
// MARK: - Open Card
|
||||
@@ -286,13 +283,12 @@ struct MoveCardCommands: View {
|
||||
/// **Caret chords yield to any focused text control** (04-interactions.md ▸ Grammar, settled):
|
||||
/// ⌘←/⌘→ are the standard line-start/end chords, and an enabled key equivalent fires before a
|
||||
/// field ever sees the key. Which surfaces that covers, and how each is answered, is
|
||||
/// `caretChordsYield(boardInfo:boardSettings:search:)`'s doc comment — shared verbatim with the
|
||||
/// `caretChordsYield(boardInfo:search:)`'s doc comment — shared verbatim with the
|
||||
/// width pair below.
|
||||
struct MoveLaneCommands: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
@FocusedValue(\.boardInfo) private var boardInfo
|
||||
@FocusedValue(\.boardSettings) private var boardSettings
|
||||
@FocusedValue(\.boardSearch) private var search
|
||||
|
||||
var body: some View {
|
||||
@@ -310,7 +306,7 @@ struct MoveLaneCommands: View {
|
||||
}
|
||||
|
||||
private var yieldsCaretChords: Bool {
|
||||
caretChordsYield(boardInfo: boardInfo, boardSettings: boardSettings, search: search)
|
||||
caretChordsYield(boardInfo: boardInfo, search: search)
|
||||
}
|
||||
|
||||
/// The sole selected live lane and the display slot one step would put it in — `nil` when there
|
||||
@@ -427,52 +423,6 @@ struct BoardInfoCommand: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Board Settings
|
||||
|
||||
/// Board ▸ Board Settings… — no default chord (11-command-nexus.md: "— (no default)"), and the board
|
||||
/// settings sheet's second door, the popover's row being the first (03-board-ui.md ▸ Board settings
|
||||
/// sheet).
|
||||
///
|
||||
/// **"Remappable" asks nothing of this file.** The remapping mechanism is macOS's own — System
|
||||
/// Settings ▸ Keyboard ▸ App Shortcuts, keyed on the menu item's *title* (04-interactions.md ▸
|
||||
/// Configurable bindings) — so all a chordless row owes it is a stable title, which the Nexus fixes.
|
||||
/// A `Button` with no `keyboardShortcut` is therefore the whole implementation, exactly as File ▸
|
||||
/// Save as Template and Board ▸ Rename are.
|
||||
///
|
||||
/// ### It opens; it never toggles
|
||||
///
|
||||
/// Board Info ⌘I toggles because a popover reached by a chord would otherwise have no keyboard way
|
||||
/// out. A sheet has one built in (Done, and Escape through it), and the menu is behind the sheet
|
||||
/// while it is up — so a toggling row would be a second exit nobody can reach.
|
||||
///
|
||||
/// ### Validation: scope, then reachability — and never the lock
|
||||
///
|
||||
/// The row stays **visible and disabled** where the sheet cannot exist (`BoardSettingsAvailability`,
|
||||
/// which carries the reasoning): a board nested inside someone else's repository, one whose ancestor
|
||||
/// check was denied, and one whose own repository will not open. That is standard menu validation,
|
||||
/// and it is the deliberate asymmetry with the popover row, which is *absent* there instead — a menu
|
||||
/// is an inventory of the app, a popover section is a description of this board.
|
||||
///
|
||||
/// **The free tier used to be the fourth of those**, and is not since 12-editions.md ▸ PIVOT
|
||||
/// 2026-08-07: git is tier-independent, so what this row validates on is the board in front of the
|
||||
/// user and nothing about their subscription.
|
||||
///
|
||||
/// The read-only lock does not close it, for Board Info's reason: a settings sheet is *configuration*
|
||||
/// (04-interactions.md ▸ The map's carve-out), a locked board is exactly when a user may want to read
|
||||
/// its git setup, and the controls inside disable themselves in place (03 ▸ Board settings sheet).
|
||||
struct BoardSettingsCommand: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
@FocusedValue(\.boardSettings) private var presentation
|
||||
|
||||
var body: some View {
|
||||
Button("Board Settings…") {
|
||||
presentation?.present()
|
||||
}
|
||||
.disabled(store == nil || presentation?.isReachable != true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rename
|
||||
|
||||
/// Board ▸ Rename — no default chord, deliberately (11-command-nexus.md: "— (cards: Return in
|
||||
@@ -549,7 +499,6 @@ struct LaneWidthCommands: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
@FocusedValue(\.boardInfo) private var boardInfo
|
||||
@FocusedValue(\.boardSettings) private var boardSettings
|
||||
@FocusedValue(\.boardSearch) private var search
|
||||
|
||||
var body: some View {
|
||||
@@ -567,7 +516,7 @@ struct LaneWidthCommands: View {
|
||||
}
|
||||
|
||||
private var yieldsCaretChords: Bool {
|
||||
caretChordsYield(boardInfo: boardInfo, boardSettings: boardSettings, search: search)
|
||||
caretChordsYield(boardInfo: boardInfo, search: search)
|
||||
}
|
||||
|
||||
/// The selected live lanes, in snapshot order — the batch, and the items' validation.
|
||||
|
||||
@@ -35,9 +35,11 @@ struct BoardGitBranchSurface: Equatable {
|
||||
/// its own sentence instead (`unreadableNote`), and the branch line has no answer to wait for.
|
||||
let isRepositoryUnreadable: Bool
|
||||
|
||||
/// Whether the branch controls accept a click — the popover's switch picker, and the settings
|
||||
/// sheet's create field (`BoardSettingsSheet`), which resolves this same surface so that a
|
||||
/// paused repository, a read-only board and a switch in flight close both by one rule.
|
||||
/// Whether the branch controls accept a click — the switch picker, and the New Branch… reveal
|
||||
/// behind its divider (`BoardGitControls`), so that a paused repository, a read-only board and a
|
||||
/// switch in flight close the whole branch affordance by one rule. (The settings sheet's standing
|
||||
/// Create field resolved this same surface between 2026-07-31 and the 2026-08-07 reversal; the
|
||||
/// field came back into the menu, the rule never moved.)
|
||||
let controlsEnabled: Bool
|
||||
|
||||
/// The line the branch display is read as by VoiceOver.
|
||||
@@ -108,10 +110,16 @@ struct BoardGitBranchSurface: Equatable {
|
||||
/// **The popover's git section on a board that has a repository** (03-board-ui.md ▸ Board popover;
|
||||
/// 06-history-undo.md ▸ Branch switching).
|
||||
///
|
||||
/// **The daily face, and only that** (the 2026-07-31 popover/sheet split): the branch display with
|
||||
/// its **switch** picker, and the pause explanation when the surface is held. Branch *creation* and
|
||||
/// the commit-identity fields left with the split — they are setup, and setup's home is the board
|
||||
/// settings sheet (`BoardSettingsSheet`), which the section's Board Settings… row opens.
|
||||
/// **The whole branch affordance**: the branch display with its **switch** picker, the *New Branch…*
|
||||
/// entry behind that menu's divider and the inline field it reveals, and the pause explanation when
|
||||
/// the surface is held.
|
||||
///
|
||||
/// **Creation left and came back.** The 2026-07-31 popover/sheet split moved it to the board settings
|
||||
/// sheet as a standing Create field; the **2026-08-07 reversal** retired that sheet and restored the
|
||||
/// shape the sheet's own doc comment had described as "the right shape *there*" — an entry inside the
|
||||
/// switch menu that reveals an inline field. The commit-identity fields came back the same day, to
|
||||
/// the Git tab a level up (`BoardGitSetup.swift`), which is why they are not here: they are the
|
||||
/// board's setup, and this is the branch.
|
||||
///
|
||||
/// **Shaped for the half that is not here yet.** Remote tracking, Pull/Push and the status badges are
|
||||
/// 07-sync-collab.md's own cards, and this section is arranged so they join as one more block under
|
||||
@@ -125,6 +133,20 @@ struct BoardGitControls: View {
|
||||
/// refuses a checkout most of all — it rewrites the tree the lock exists to stop describing.
|
||||
let isEnabled: Bool
|
||||
|
||||
/// Whether **New Branch…** has been picked and its field is standing open — the reveal, which is
|
||||
/// the menu entry's whole behaviour (03-board-ui.md ▸ Board popover ▸ Git tab, 2026-08-07). It
|
||||
/// starts closed on every popover open, because the popover is rebuilt fresh each time
|
||||
/// (`BoardInfoWidget`), which is exactly the transience the reveal shape is for.
|
||||
@State private var isCreatingBranch = false
|
||||
|
||||
/// The name being typed, uncommitted. Cleared by a create and by Escape's first press.
|
||||
@State private var draft = ""
|
||||
|
||||
/// Focus lands in the field the moment it appears, unlike the popover's rename field: this one is
|
||||
/// opened by a gesture that means "name a branch now", which is the inline editors' case rather
|
||||
/// than the configuration-surface case (11-command-nexus.md's class **C** distinguishes the two).
|
||||
@FocusState private var isFieldFocused: Bool
|
||||
|
||||
private var surface: BoardGitBranchSurface {
|
||||
BoardGitBranchSurface.resolve(
|
||||
branch: git.branch,
|
||||
@@ -138,6 +160,10 @@ struct BoardGitControls: View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
branchRow
|
||||
|
||||
if isCreatingBranch {
|
||||
creationField
|
||||
}
|
||||
|
||||
// **The unreadable repository names itself in its own sentence** (06 ▸ Rules, the
|
||||
// corrupt-`.git` loud failure) — checked before the pause note because it *is* a pause,
|
||||
// and the pause note's second line ("finishing it belongs to the tool that started it")
|
||||
@@ -168,12 +194,17 @@ struct BoardGitControls: View {
|
||||
/// makes "branch/source display and switching" one affordance rather than a label with a button
|
||||
/// beside it.
|
||||
///
|
||||
/// **Switch entries and nothing else** since the 2026-07-31 split — the "New Branch…" entry that
|
||||
/// used to close the menu is the settings sheet's standing Create field now. **A single-branch
|
||||
/// board therefore opens onto a disabled explanatory row** (03-board-ui.md ▸ Board popover ▸ Git
|
||||
/// tab, ruled 2026-08-06 and built with the tab): the earlier posture left the menu genuinely
|
||||
/// empty and called that honest, but honest is not the same as legible — a menu that opens onto
|
||||
/// nothing reads as broken, not as complete.
|
||||
/// **Two halves under a divider** since the 2026-08-07 reversal: the switch targets above, and
|
||||
/// **New Branch…** below, which reveals the inline field under this row rather than acting. That
|
||||
/// is the shape the 2026-07-31 split took creation *out* of — into the settings sheet's standing
|
||||
/// Create field — and the shape the reversal restored when the sheet retired; the sheet's own doc
|
||||
/// comment had called it "the right shape *there*", meaning here.
|
||||
///
|
||||
/// **The menu is therefore never empty**, which is what the divider guarantees rather than the
|
||||
/// content: New Branch… always applies to a board that has a repository. **A single-branch board
|
||||
/// still opens onto a disabled explanatory row** in the upper half (03-board-ui.md ▸ Board popover
|
||||
/// ▸ Git tab, ruled 2026-08-06 and built with the tab): an unexplained gap above the divider would
|
||||
/// read as a menu that lost something, not as a board with one branch.
|
||||
private var branchRow: some View {
|
||||
// Resolved once and handed to both halves of the menu builder: the emptiness *is* the
|
||||
// condition being rendered, so asking twice would be asking the same question of a store
|
||||
@@ -188,11 +219,12 @@ struct BoardGitControls: View {
|
||||
Menu {
|
||||
if targets.isEmpty {
|
||||
// **The single-branch board's row** (03-board-ui.md ▸ Board popover ▸ Git tab,
|
||||
// ruled 2026-08-06). It teaches the two things an empty menu leaves a user to
|
||||
// guess at: *why* there is nothing to pick — this menu holds only the **other**
|
||||
// local branches, and there are none — and, by standing where "New Branch…" used
|
||||
// to, that creation is no longer here at all; it is the settings sheet's Create
|
||||
// field, one row down through Board Settings…
|
||||
// ruled 2026-08-06). It teaches the one thing an empty upper half leaves a user
|
||||
// to guess at: *why* there is nothing to pick — this half holds only the
|
||||
// **other** local branches, and there are none. (It carried a second teaching
|
||||
// while creation lived in the settings sheet, standing where "New Branch…" used
|
||||
// to; the 2026-08-07 reversal put that entry back one line below, so the row's
|
||||
// second job is done and the sentence is now exactly what it says.)
|
||||
//
|
||||
// A bare `Text` inside a `Menu` is AppKit's standard disabled item: greyed,
|
||||
// unclickable, and read by VoiceOver as disabled text rather than as an
|
||||
@@ -207,6 +239,15 @@ struct BoardGitControls: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// **Creation, revealed rather than performed** — the entry opens the field below the
|
||||
// row and nothing else, so the act of naming a branch happens in the surface the user
|
||||
// can see and correct rather than inside a menu that has already closed.
|
||||
Button("New Branch…") {
|
||||
isCreatingBranch = true
|
||||
}
|
||||
} label: {
|
||||
Text(surface.branchLabel)
|
||||
.font(.callout)
|
||||
@@ -254,6 +295,71 @@ struct BoardGitControls: View {
|
||||
branches.filter { $0 != current }
|
||||
}
|
||||
|
||||
// MARK: The creation reveal
|
||||
|
||||
/// **What New Branch… reveals** (03-board-ui.md ▸ Board popover ▸ Git tab, restored 2026-08-07):
|
||||
/// a name field and a Create button, under the branch row, for exactly as long as the user is
|
||||
/// naming something. It is a detour off the daily face and it looks like one — which is the
|
||||
/// argument the 2026-07-31 split made *against* it in a sheet, and for it here.
|
||||
///
|
||||
/// **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-history-undo.md ▸ Branch switching, blessed 2026-07-31) — and `create()` calls
|
||||
/// exactly that method.
|
||||
///
|
||||
/// **Failures answer where they were asked**: the section's existing switcher caption below is
|
||||
/// this control's answer too (06 ▸ Rules, the form-anchored rule as the 2026-08-06 create-and-
|
||||
/// switch overlap settled it — "inline while the asking surface is up, banner once it is gone").
|
||||
/// The popover *is* the asking surface now that the sheet is retired, so there is one caption for
|
||||
/// both halves of the branch affordance and no second slot to keep in sync.
|
||||
///
|
||||
/// No `ProgressView` of its own, for the same reason: `branchRow` already shows the in-flight
|
||||
/// spinner, and a switch has one state whichever control started it.
|
||||
private var creationField: some View {
|
||||
HStack(spacing: 6) {
|
||||
TextField("New branch name", text: $draft)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1)
|
||||
.focused($isFieldFocused)
|
||||
.onAppear { isFieldFocused = true }
|
||||
.onSubmit { create() }
|
||||
// **Escape steps outward one layer per press** (04-interactions.md ▸ Grammar, the
|
||||
// rename field's rule and the retired sheet section's before it): a dirty field
|
||||
// abandons its draft and the reveal stays open; an empty one closes the reveal, which
|
||||
// is the layer above it; a further press then reaches the popover's own dismissal.
|
||||
.onKeyPress(.escape) {
|
||||
if !draft.isEmpty {
|
||||
draft = ""
|
||||
return .handled
|
||||
}
|
||||
isCreatingBranch = false
|
||||
return .handled
|
||||
}
|
||||
.accessibilityLabel("New branch name")
|
||||
|
||||
Button("Create", action: create)
|
||||
.disabled(trimmedDraft.isEmpty)
|
||||
}
|
||||
// The branch controls' one rule, applied to the reveal as it is to the picker: a paused
|
||||
// repository, a read-only board and a switch in flight close it (`BoardGitBranchSurface`).
|
||||
.disabled(!surface.controlsEnabled)
|
||||
}
|
||||
|
||||
private var trimmedDraft: String {
|
||||
draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
/// Creates the named branch and switches to it, then puts the reveal away — a create is the end
|
||||
/// of the detour, and a field left standing over a branch that now exists would be inviting a
|
||||
/// second one nobody asked for.
|
||||
private func create() {
|
||||
let name = trimmedDraft
|
||||
guard !name.isEmpty, surface.controlsEnabled else { return }
|
||||
draft = ""
|
||||
isCreatingBranch = false
|
||||
Task { await git.switcher?.createAndSwitch(to: name) }
|
||||
}
|
||||
|
||||
// MARK: The pause
|
||||
|
||||
/// **The abnormal-state surface** (06 ▸ Rules ▸ Abnormal repo states) — deferred here from the
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,21 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// **The board popover's Git tab** (03-board-ui.md § Board popover ▸ Git tab, settled 2026-08-07) —
|
||||
/// the pre-tab closing git section rehomed *whole*, and nothing more: **the daily face, and only
|
||||
/// that** (the 2026-07-31 popover/sheet split stands, unchanged by the move). A repository-facts
|
||||
/// dossier in the Info tab's register — commit counts, last-commit dates — was considered in the Git
|
||||
/// session and declined: the popover's git surface is for *operating*, and per-item history is the
|
||||
/// card window's History section (05-card-window.md).
|
||||
/// the pre-tab closing git section rehomed *whole*, and then, later the same day, **the board
|
||||
/// settings sheet's contents rehomed into it too**. A repository-facts dossier in the Info tab's
|
||||
/// register — commit counts, last-commit dates — was considered in the Git session and declined: the
|
||||
/// popover's git surface is for *operating* and *setting up*, and per-item history is the card
|
||||
/// window's History section (05-card-window.md).
|
||||
///
|
||||
/// ### Operating and setup, one surface again (ruled 2026-08-07)
|
||||
///
|
||||
/// The tab settled as "the daily face, and only that" — the 2026-07-31 popover/sheet split's half —
|
||||
/// with a **Board Settings…** row pointing at the sheet that held add-git, branch creation and the
|
||||
/// commit identity. That split is **reversed**: the sheet retires, the row with it, and the two setup
|
||||
/// controls render inline in the postures they belong to (`BoardGitSetup.swift`). Branch creation
|
||||
/// went back where it came from, the switch menu (`BoardGitControls.branchRow`). What is left is one
|
||||
/// configuration home per board — the popover — reached by the widget or Board Info ⌘I, and no
|
||||
/// surface that has to be validated into existence before a door can point at it.
|
||||
///
|
||||
/// ### No "Git" header anywhere in this tab
|
||||
///
|
||||
@@ -35,12 +45,14 @@ import SwiftUI
|
||||
/// Rules). `unverifiable` (the git-detection axis) is structurally identical to `repoNested` but
|
||||
/// worded as its own honest prose — a denial is not a nesting.
|
||||
///
|
||||
/// **The 2026-07-31 popover/sheet split thinned two of these cases without removing either.** Setup
|
||||
/// left the popover for the board settings sheet, so mode `none` no longer renders an action here at
|
||||
/// all (the case was called `.addGit` when it did — a name that would now be describing a control
|
||||
/// that lives in another file, so it is `.noRepository`), and the git-mode case lost branch creation
|
||||
/// and the identity fields. What each case still *is* is a posture, which is why the matrix and its
|
||||
/// test survived the move unchanged.
|
||||
/// **The 2026-07-31 popover/sheet split thinned two of these cases without removing either, and the
|
||||
/// 2026-08-07 reversal filled them back in.** Setup left the popover for the board settings sheet,
|
||||
/// so mode `none` rendered no action here at all for a week (the case was called `.addGit` when it
|
||||
/// did, and was renamed `.noRepository` when the control left) and the git-mode case lost branch
|
||||
/// creation and the identity fields; the reversal retired that sheet and brought all three back
|
||||
/// inline. The case name stays `.noRepository` — it describes the board, which is what a posture is
|
||||
/// for, and it survived the round trip precisely because it never named a control. What each case
|
||||
/// *is* is a posture, which is why the matrix and its test survived both moves unchanged.
|
||||
///
|
||||
/// **The 2026-08-07 tab restructure rehomed the surface, not the matrix** — the same cases, now
|
||||
/// rendered as one tab each by `BoardGitTabView` rather than as a closing section of the popover's
|
||||
@@ -55,28 +67,30 @@ import SwiftUI
|
||||
enum BoardGitSection: Equatable, CaseIterable {
|
||||
|
||||
/// Mode `none`: a board that could have a history and has none. There is no daily surface for
|
||||
/// that — the tab is one caption stating the fact above the Board Settings… door, where add-git
|
||||
/// now lives (03 ▸ Board settings sheet). The header-plus-door posture blessed 2026-08-06,
|
||||
/// restated for a surface whose header is now the tab label.
|
||||
/// that — the tab is one caption stating the fact, and **add-git directly under it** since the
|
||||
/// 2026-08-07 reversal (`BoardGitAddAction`), where the Board Settings… door stood for the week
|
||||
/// the sheet existed. The fact-then-offer posture blessed 2026-08-06, restated for a surface
|
||||
/// whose header is the tab label and whose offer is the control itself rather than a door to it.
|
||||
///
|
||||
/// **Every tier's posture since the pivot** (03 ▸ Git tab, pivot note 2026-08-07), and git stays
|
||||
/// **opt-in per board**: the door is an offer, never an auto-init.
|
||||
/// **opt-in per board**: the button is an offer, never an auto-init.
|
||||
case noRepository
|
||||
|
||||
/// Repo-nested: the honest explanation, no action — and no Board Settings… row either, since
|
||||
/// nothing setup-shaped can apply (`BoardSettingsAvailability`).
|
||||
/// Repo-nested: the honest explanation, no action — and nothing setup-shaped either, since
|
||||
/// nothing setup-shaped can apply (`BoardGitSetupSection.resolve`, empty here).
|
||||
case repoNested
|
||||
|
||||
/// Unverifiable: **not** `.repoNested` — a denied ancestor check, not a found repository
|
||||
/// (06 ▸ Rules ▸ Detection, "Denial is not absence"). Structurally identical to `.repoNested`
|
||||
/// (no action, no Board Settings… row, `BoardSettingsAvailability` false), but its own case so
|
||||
/// the view renders its own honest prose rather than the nested sentence — "unverifiable" is not
|
||||
/// (no action, no setup — `BoardGitSetupSection.resolve` empty), but its own case so the view
|
||||
/// renders its own honest prose rather than the nested sentence — "unverifiable" is not
|
||||
/// "nested".
|
||||
case unverifiable
|
||||
|
||||
/// Git mode: the branch/source line with the **switch** picker, the abnormal-state explanation
|
||||
/// when the surface is held, and the Board Settings… row. The remote half — tracking, Pull/Push,
|
||||
/// the status badges — is 07-sync-collab.md's own card and joins this same posture.
|
||||
/// Git mode: the branch/source line with the **switch** picker (which carries New Branch… again
|
||||
/// since 2026-08-07), the abnormal-state explanation when the surface is held, and the commit
|
||||
/// identity block. The remote half — tracking, Pull/Push, the status badges — is
|
||||
/// 07-sync-collab.md's own card and joins this same posture.
|
||||
case branch
|
||||
|
||||
static func resolve(mode: BoardGitMode) -> BoardGitSection {
|
||||
@@ -89,14 +103,100 @@ enum BoardGitSection: Equatable, CaseIterable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The setup inventory
|
||||
|
||||
/// **Which setup controls this tab hosts for one board** — a pure function of the mode and of whether
|
||||
/// the repository opens, so the rehomed inventory is provable without a popover on screen.
|
||||
///
|
||||
/// It is `BoardSettingsSection.resolve`'s successor, and deliberately its same shape: that enum was
|
||||
/// the *sheet's* inventory (2026-07-31 → 2026-08-07) and it retired with the sheet, but the rule it
|
||||
/// carried is about the **board**, not about the container, so it survives the reversal re-pointed at
|
||||
/// the tab. Branch creation is not a case here for the same reason it was one there and is not now:
|
||||
/// it went back into the switch menu (`BoardGitControls.branchRow`), which is a daily control with an
|
||||
/// inline reveal rather than a standing form. pro-m2's remote and credential cards each add a case
|
||||
/// here and a block in `BoardGitTabView.posture` — nothing else.
|
||||
///
|
||||
/// Ordered as the tab lays them out, and the order is trivially the postures' own: no board is ever
|
||||
/// in both modes, so the array is one element or none. It stays an array rather than an `Optional`
|
||||
/// because the pro-m2 cards land in the git-mode posture beside `.commitIdentity`.
|
||||
enum BoardGitSetupSection: String, Equatable, CaseIterable, Identifiable {
|
||||
|
||||
/// Mode `none`: **add-git** (06-history-undo.md ▸ Rules ▸ Opt-in init) — the offer, on every tier
|
||||
/// since 12-editions.md ▸ PIVOT 2026-08-07, and still never an auto-init.
|
||||
case addGit
|
||||
|
||||
/// 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 block's header — a heading VoiceOver navigates by (10-accessibility.md's navigable-header
|
||||
/// rule, carried over from the sheet's sections). `.addGit` has none: it renders directly under
|
||||
/// the no-repository note, which already states what the posture is, and a "Git" header inside
|
||||
/// the Git tab would be the surface naming itself twice (this file's opening note).
|
||||
var title: String? {
|
||||
switch self {
|
||||
case .addGit: nil
|
||||
case .commitIdentity: "Commit Identity"
|
||||
}
|
||||
}
|
||||
|
||||
/// - Parameter isRepositoryUnreadable: whether the board's `.git` exists and will not open
|
||||
/// (`HistoryStore.isRepositoryUnreadable`). Defaulted, because it can only ever be true in mode
|
||||
/// `git` — every other mode has no repository for the probe to have failed on, and a caller
|
||||
/// that has no git state to ask is describing one of those boards.
|
||||
///
|
||||
/// `nonisolated` for `BoardGitControls.switchTargets`' reason: a pure answer over plain values,
|
||||
/// reachable from a test with no actor to hop to.
|
||||
nonisolated static func resolve(
|
||||
mode: BoardGitMode,
|
||||
isRepositoryUnreadable: Bool = false
|
||||
) -> [BoardGitSetupSection] {
|
||||
switch mode {
|
||||
case .none:
|
||||
return [.addGit]
|
||||
case .git:
|
||||
// **An unreadable repository hosts no setup** (06-history-undo.md ▸ Rules, the
|
||||
// corrupt-`.git` loud failure, ruled 2026-07-31: "the whole git surface paused …
|
||||
// Lanework leaves the repository untouched"), and it lands on repo-nested's emptiness by
|
||||
// repo-nested's own reasoning, one step further along: writing an identity is a *write*
|
||||
// into the repository's own config, and there is no repository the app can open to write
|
||||
// it into. The posture is not empty, though — that is the difference the sheet could not
|
||||
// express and the tab can: `BoardGitControls` still renders, holding, with its own
|
||||
// sentence explaining the state (`BoardGitBranchSurface.unreadableNote`). What the
|
||||
// unreadable board loses is the setup block alone.
|
||||
//
|
||||
// The mode stays `.git` throughout — this is emptiness *within* git mode, never a fall
|
||||
// to mode none, which is what would let add-git be offered against an existing `.git`.
|
||||
return isRepositoryUnreadable ? [] : [.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") and no repo-local config of ours to write.
|
||||
// The posture's whole content is its explanation.
|
||||
return []
|
||||
case .unverifiable:
|
||||
// **Structurally the same emptiness as `.repoNested`, for the same reason** (06 ▸ Rules ▸
|
||||
// Detection, "Denial is not absence"): a denied ancestor check can never be told apart
|
||||
// from a repository actually being there, so add-git stays unreachable. Only the
|
||||
// posture's *prose* tells the two apart — this inventory does not, because there is
|
||||
// nothing to set up on either.
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The notes
|
||||
|
||||
/// **The no-repository caption** (03-board-ui.md ▸ Board popover ▸ Git tab) — the fact, stated, above
|
||||
/// the Board Settings… door.
|
||||
/// the add-git button that offers to change it.
|
||||
///
|
||||
/// One sentence in its siblings' register, and deliberately not a header: the tab label already says
|
||||
/// "Git", so what is left to say is what this board's git story currently *is*. "Yet" is the whole
|
||||
/// posture in a word — the door directly below it is where a user says otherwise.
|
||||
/// posture in a word — the button directly below it is where a user says otherwise. (It read "above
|
||||
/// the Board Settings… door" between 2026-07-31 and the 2026-08-07 reversal; the sentence never
|
||||
/// changed, only what stands under it.)
|
||||
private struct BoardGitNoRepositoryNote: View {
|
||||
|
||||
var body: some View {
|
||||
@@ -135,8 +235,8 @@ private struct BoardGitUnverifiableNote: View {
|
||||
|
||||
// MARK: - The tab
|
||||
|
||||
/// The Git tab's surface: whichever of the four postures this board is in, and the Board Settings…
|
||||
/// door where it applies.
|
||||
/// The Git tab's surface: whichever of the four postures this board is in, with the setup controls
|
||||
/// that posture hosts rendered inline in it (`BoardGitSetupSection`).
|
||||
struct BoardGitTabView: View {
|
||||
|
||||
let store: BoardStore
|
||||
@@ -152,21 +252,11 @@ struct BoardGitTabView: View {
|
||||
/// *tier* signal — every session composes one (12 ▸ PIVOT 2026-08-07).
|
||||
let git: HistoryStore?
|
||||
|
||||
/// The window's settings sheet, so this tab can carry the **Board Settings…** row that opens it.
|
||||
/// `nil` where there is no window to present a sheet on, which reads as a tab with no door.
|
||||
let settings: BoardSettingsPresentation?
|
||||
|
||||
/// The popover's own padding figure (`BoardInfoView.inset`), matching `BoardInfoTabView`'s and
|
||||
/// `BoardThemeTabView`'s own parameter — the tab pads by this amount instead of restating the
|
||||
/// derivation.
|
||||
let inset: CGFloat
|
||||
|
||||
/// **The popover's own dismissal**, used by exactly one control: the Board Settings… row, whose
|
||||
/// job is to close this surface and open the sheet. The popover is presented by `isPresented`, so
|
||||
/// the environment action drives the same flag the widget's button does — nothing here has to be
|
||||
/// handed the widget's binding to put it down.
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
posture
|
||||
.padding(inset)
|
||||
@@ -181,11 +271,16 @@ struct BoardGitTabView: View {
|
||||
switch BoardGitSection.resolve(mode: git?.mode ?? .none) {
|
||||
case .noRepository:
|
||||
// Nothing daily to show on a board with no repository — so the tab is the fact and the
|
||||
// door. Add-git itself moved to the sheet with the 2026-07-31 split; what stays here is
|
||||
// the honest signpost that this board *could* have a history and where to say so.
|
||||
// offer. Add-git spent a week behind the settings sheet's door (2026-07-31 → the
|
||||
// 2026-08-07 reversal) and is back inline under the note that says why it is there. A
|
||||
// `nil` git — previews, the accessory-installation tests — renders the note alone: the
|
||||
// fact is the honest thing to say about a board nothing has been detected about, and
|
||||
// there is nothing to add a repository *to*.
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
BoardGitNoRepositoryNote()
|
||||
boardSettingsRow
|
||||
if let git, setup.contains(.addGit) {
|
||||
BoardGitAddAction(git: git, isEnabled: store.acceptsBoardMutations)
|
||||
}
|
||||
}
|
||||
|
||||
case .repoNested:
|
||||
@@ -195,40 +290,59 @@ struct BoardGitTabView: View {
|
||||
BoardGitUnverifiableNote()
|
||||
|
||||
case .branch:
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// The daily face first — the branch line with its switch menu, which carries New Branch…
|
||||
// again since the 2026-08-07 reversal — then the one setup block this posture hosts.
|
||||
// The identity block is gated on the setup inventory rather than on a condition spelled
|
||||
// out here, which is what keeps "an unreadable repository hosts no setup" one rule with
|
||||
// one test (`BoardGitSetupSection.resolve`); `BoardGitControls`' own unreadable sentence
|
||||
// stands alone under it.
|
||||
//
|
||||
// `inset` as the gap rather than the 6pt row rhythm: the identity block is a *section*
|
||||
// under its own heading, and the popover's section spacing is the figure the retired
|
||||
// sheet used between its sections for the same reason (`BoardInfoView.inset`).
|
||||
VStack(alignment: .leading, spacing: inset) {
|
||||
if let git {
|
||||
BoardGitControls(git: git, isEnabled: store.acceptsBoardMutations)
|
||||
}
|
||||
boardSettingsRow
|
||||
if let git, setup.contains(.commitIdentity) {
|
||||
setupBlock(.commitIdentity) {
|
||||
BoardGitIdentityFields(git: git, isEnabled: store.acceptsBoardMutations)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **The popover's one setup affordance** (03-board-ui.md ▸ Board popover) — the sheet's first
|
||||
/// door, the menu row being the second (11-command-nexus.md).
|
||||
///
|
||||
/// **Shown only where the sheet is reachable** (`BoardSettingsAvailability`): this tab describes
|
||||
/// *this board*, so a row pointing at a surface this board cannot have would be the disabled
|
||||
/// button 06 rules out one level up. The menu row is the opposite case and stays visible — a menu
|
||||
/// is an inventory of the app.
|
||||
///
|
||||
/// **Dismiss first, then present.** The popover is transient and the sheet is not; leaving a
|
||||
/// transient surface hanging over a modal one would read as two surfaces arguing about which the
|
||||
/// user is in.
|
||||
///
|
||||
/// Not disabled by the read-only lock: opening a configuration surface is not a mutation, and the
|
||||
/// controls inside it disable themselves (the Board Info ⌘I rule).
|
||||
@ViewBuilder
|
||||
private var boardSettingsRow: some View {
|
||||
if let settings, BoardSettingsAvailability.resolve(
|
||||
/// What this board's setup half holds — the sheet's inventory rule, re-pointed at the tab it
|
||||
/// rehomed into (`BoardGitSetupSection`). Read once per posture branch rather than re-derived
|
||||
/// beside each block.
|
||||
private var setup: [BoardGitSetupSection] {
|
||||
BoardGitSetupSection.resolve(
|
||||
mode: git?.mode ?? .none,
|
||||
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
|
||||
) {
|
||||
Button("Board Settings…") {
|
||||
dismiss()
|
||||
settings.present()
|
||||
)
|
||||
}
|
||||
|
||||
/// A setup block under its own header, where the section carries one.
|
||||
///
|
||||
/// **The header is an accessibility structure, not decoration** (10-accessibility.md's
|
||||
/// navigable-header rule, which the retired sheet's sections carried and which came along with
|
||||
/// them): the rotor jumps between headings rather than walking one flat run of controls. This is
|
||||
/// the tab's *only* header — the no-repository posture's control has none, and the tab label
|
||||
/// still does the naming for the surface as a whole (this file's opening note).
|
||||
@ViewBuilder
|
||||
private func setupBlock(
|
||||
_ section: BoardGitSetupSection,
|
||||
@ViewBuilder content: () -> some View
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let title = section.title {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
}
|
||||
.accessibilityHint("Opens the board settings sheet")
|
||||
content()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import SwiftUI
|
||||
/// design session: `BoardInfoTabView`, the metrics dossier; `BoardThemeTabView`, the Solid color /
|
||||
/// Pattern picker (the Background tab's original name, before the same session widened it past the
|
||||
/// generated-only picker and folded manual styling back out to Style… ⌥⌘S); and `BoardGitTabView`,
|
||||
/// which the pre-tab body's mode-aware git section rehomed into whole — postures, notes, and the
|
||||
/// Board Settings… row, none of them re-ruled by the move.
|
||||
/// which the pre-tab body's mode-aware git section rehomed into whole — postures and notes, none of
|
||||
/// them re-ruled by the move. That tab carried a **Board Settings…** row to a separate sheet until
|
||||
/// later the same day, when the 2026-07-31 popover/sheet split was reversed and the sheet's contents
|
||||
/// rehomed into the tab (see below).
|
||||
///
|
||||
/// **Tab membership is the git posture's**, and **selection resets to Info on every open** — both
|
||||
/// the Git session's rulings. Since 12-editions.md ▸ PIVOT 2026-08-07 the first of those is a
|
||||
@@ -24,6 +26,14 @@ import SwiftUI
|
||||
/// … and a second entry would muddy it"). It has exactly two ways in: the widget, and File ▸ Board
|
||||
/// Info ⌘I, which is the same widget's popover reached from the keyboard (11-command-nexus.md's
|
||||
/// class **C** — "the keyboard path is reachability … not bindings").
|
||||
///
|
||||
/// **And it is the board's only configuration home** (ruled 2026-08-07, reversing the 2026-07-31
|
||||
/// popover/sheet split): setup briefly lived in a board settings sheet with its own menu command and
|
||||
/// a row here pointing at it. The sheet is retired, its add-git and commit-identity controls render
|
||||
/// inline in the Git tab's postures (`BoardGitSetup.swift`), branch creation went back into the
|
||||
/// switch menu (`BoardGitControls`), and Board ▸ Board Settings… left the menu bar with them. So
|
||||
/// "one home per control" — the split's own promise — is now satisfied by there being one surface,
|
||||
/// and ⌘I is the door to all of it.
|
||||
|
||||
// MARK: - Presentation state
|
||||
|
||||
@@ -64,8 +74,17 @@ extension FocusedValues {
|
||||
|
||||
// MARK: - The window-title widget
|
||||
|
||||
/// The titlebar widget: the board's name — and, on a git-mode board, its branch — with a trailing
|
||||
/// disclosure chevron, whose one job is this popover.
|
||||
/// The titlebar widget: the board's glyph beside a two-line identity block — its name, and on a
|
||||
/// git-mode board its branch under the name — with a trailing disclosure chevron, whose one job is
|
||||
/// this popover.
|
||||
///
|
||||
/// **A two-line stack since 2026-08-07** (03-board-ui.md ▸ Board popover, the window-title widget
|
||||
/// passage). It was one line reading `glyph Title — branch ⌄`, and the em-dash was the tell: a
|
||||
/// separator doing a *hierarchy's* job, with the branch competing for the same width as the name it
|
||||
/// qualifies. So the branch moved under the title in its own smaller, secondary line, the em-dash
|
||||
/// retired, and the glyph grew to span both lines — an icon sized to the block it labels rather than
|
||||
/// to whichever line it happened to sit on. A board with no branch is the single title line, vertically
|
||||
/// centred beside the same glyph, which is the same block with one row.
|
||||
///
|
||||
/// **The popover is anchored to the widget itself** — it hangs from the button rather than from the
|
||||
/// window or the board — which is what makes the affordance and the surface read as one thing. A
|
||||
@@ -94,12 +113,6 @@ struct BoardInfoWidget: View {
|
||||
|
||||
@Bindable var presentation: BoardInfoPresentation
|
||||
|
||||
/// The window's settings sheet, so the popover's Git tab can carry the **Board Settings…**
|
||||
/// row that opens it (03-board-ui.md ▸ Board popover: "A Board Settings… row opens the sheet —
|
||||
/// the popover's one setup affordance"). `nil` where there is no window to present a sheet on,
|
||||
/// which is the accessory-installation tests' shape and reads as a popover with no row.
|
||||
let settings: BoardSettingsPresentation?
|
||||
|
||||
/// The widget's two strings, computed fresh on every body evaluation rather than cached anywhere.
|
||||
/// That matters here specifically: `boardInfoTitlebarAccessory` builds this view exactly **once**
|
||||
/// at install, so a value read anywhere but inside `body` would freeze at the widget's birth and
|
||||
@@ -118,39 +131,54 @@ struct BoardInfoWidget: View {
|
||||
Button {
|
||||
presentation.toggle()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
// The board's own glyph beside its name — the identity pair the popover header
|
||||
// states, restated where the board is named all day (2026-08-07). Read inside
|
||||
// `body` for `summary`'s reason: `icon`/`iconColor` are `@Observable` fields, so a
|
||||
// restyle from the popover repaints this widget without reinstalling it. Lenient on
|
||||
// both dimensions — an unresolvable glyph draws the board default, an unresolvable
|
||||
// tint draws the standard secondary.
|
||||
//
|
||||
// **Its own font, not the container's** (the two-line rework, 2026-08-07): at 22pt
|
||||
// it draws about twice the height `.imageScale(.small)` gave it on the container's
|
||||
// 13pt, which is what lets one glyph span the title and branch lines instead of
|
||||
// sitting beside the upper one. The number is the icon's own size rather than a
|
||||
// scale factor because that is the dimension being chosen — the block's height.
|
||||
Image(systemName: ItemSymbol.name(store.snapshot.icon, fallback: ItemSymbol.board))
|
||||
.imageScale(.small)
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(iconTint)
|
||||
// Decorative beside the name it repeats — the button's own label already says
|
||||
// everything VoiceOver needs (`accessibilityLabel` below).
|
||||
.accessibilityHidden(true)
|
||||
Text(summary.title)
|
||||
// Styled like a titlebar title, because that is what it now stands in for
|
||||
// (`BoardWindowHost` hides the system title display in favor of this widget).
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
// Yields space to the branch string and chevron first when the two don't both
|
||||
// fit inside the width cap below — the board's own name is the more load-bearing
|
||||
// half of the pair.
|
||||
.layoutPriority(1)
|
||||
|
||||
if let branch = summary.branch {
|
||||
Text("—")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(branch)
|
||||
.foregroundStyle(.secondary)
|
||||
// The identity block: the name, and the branch beneath it on a git-mode board. On
|
||||
// any other board this is the single title line and the `HStack`'s own centring puts
|
||||
// it level with the glyph — the stack is the same shape with one row, never a
|
||||
// special case.
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(summary.title)
|
||||
// Styled like a titlebar title, because that is what it now stands in for
|
||||
// (`BoardWindowHost` hides the system title display in favor of this widget).
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
|
||||
if let branch = summary.branch {
|
||||
// Smaller and secondary — the qualifier under the name it qualifies. The
|
||||
// em-dash that separated the two on one line retired with the stack: a
|
||||
// hierarchy that a layout can state does not need punctuation to state it.
|
||||
Text(branch)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
// Yields space to the chevron first when the block doesn't fit inside the width cap
|
||||
// below — the board's identity is the more load-bearing half of the pair, and both
|
||||
// its lines truncate rather than the disclosure disappearing.
|
||||
.layoutPriority(1)
|
||||
|
||||
Image(systemName: "chevron.down")
|
||||
.imageScale(.small)
|
||||
@@ -159,10 +187,12 @@ struct BoardInfoWidget: View {
|
||||
}
|
||||
.font(.system(size: 13))
|
||||
// A long board name (or branch) must not swallow the whole titlebar — capped rather
|
||||
// than left to grow, with the truncation above doing the rest. Height stays the
|
||||
// original chevron's, which is what keeps the accessory titlebar-appropriate.
|
||||
// than left to grow, with the truncation above doing the rest. The height is the
|
||||
// two-line block's (2026-08-07; it was the original chevron's 18 while the widget was
|
||||
// one line), which is what keeps the accessory titlebar-appropriate: tall enough for
|
||||
// name-over-branch, and no taller than a standard title bar carries.
|
||||
.frame(maxWidth: 400, alignment: .leading)
|
||||
.frame(height: 18)
|
||||
.frame(height: 32)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -176,7 +206,7 @@ struct BoardInfoWidget: View {
|
||||
// costs nothing on the other four postures.
|
||||
.task { await git?.refreshBranch() }
|
||||
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
|
||||
BoardInfoView(store: store, recents: recents, git: git, settings: settings)
|
||||
BoardInfoView(store: store, recents: recents, git: git)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,24 +276,22 @@ struct BoardInfoTitlebarSummary: Equatable {
|
||||
/// window, removed on detach — for the same reason it owns the delegate proxying: the window is
|
||||
/// SwiftUI's, and anything hung on it has to be taken back off.
|
||||
@MainActor
|
||||
/// `git` defaults to no session and `settings` to no sheet, so that a caller with none in hand (the
|
||||
/// accessory-installation tests, which are about AppKit plumbing rather than about git) describes a
|
||||
/// board honestly rather than by accident: a Git tab in its no-repository posture, and no
|
||||
/// Board Settings… row behind it. The app's own call site passes the session's values explicitly.
|
||||
/// `git` defaults to no session, so that a caller with none in hand (the accessory-installation
|
||||
/// tests, which are about AppKit plumbing rather than about git) describes a board honestly rather
|
||||
/// than by accident: a Git tab in its no-repository posture, and a widget with no branch line. The
|
||||
/// app's own call site passes the session's value explicitly.
|
||||
func boardInfoTitlebarAccessory(
|
||||
store: BoardStore,
|
||||
recents: StyleRecents,
|
||||
git: HistoryStore? = nil,
|
||||
presentation: BoardInfoPresentation,
|
||||
settings: BoardSettingsPresentation? = nil
|
||||
presentation: BoardInfoPresentation
|
||||
) -> NSTitlebarAccessoryViewController {
|
||||
let hosting = NSHostingView(
|
||||
rootView: BoardInfoWidget(
|
||||
store: store,
|
||||
recents: recents,
|
||||
git: git,
|
||||
presentation: presentation,
|
||||
settings: settings
|
||||
presentation: presentation
|
||||
)
|
||||
)
|
||||
// The titlebar lays its accessories out by fitting size, and a hosting view that measured itself
|
||||
@@ -272,8 +300,10 @@ func boardInfoTitlebarAccessory(
|
||||
// widget's real content replaces it — but that first pass is exactly what a 20×18 placeholder
|
||||
// (the old chevron-only width) would clamp now that the widget's content can run out to 400pt:
|
||||
// wide enough that the widest realistic first paint is never visibly clipped before the resize.
|
||||
// The height is the widget's own two-line figure (2026-08-07), for the same reason the width is
|
||||
// generous — a first pass clamped to the old one-line 18 would paint a clipped block.
|
||||
hosting.sizingOptions = [.intrinsicContentSize]
|
||||
hosting.frame = NSRect(x: 0, y: 0, width: 200, height: 18)
|
||||
hosting.frame = NSRect(x: 0, y: 0, width: 200, height: 32)
|
||||
|
||||
let controller = NSTitlebarAccessoryViewController()
|
||||
controller.view = hosting
|
||||
@@ -321,7 +351,6 @@ struct BoardInfoView: View {
|
||||
let store: BoardStore
|
||||
let recents: StyleRecents
|
||||
let git: HistoryStore?
|
||||
let settings: BoardSettingsPresentation?
|
||||
|
||||
/// The selected tab, and **it resets to Info on every open** — a ruling, not an accident (the
|
||||
/// Git session, 2026-08-07, closing the question the earlier tab sessions deferred): the popover
|
||||
@@ -341,13 +370,11 @@ struct BoardInfoView: View {
|
||||
init(
|
||||
store: BoardStore,
|
||||
recents: StyleRecents,
|
||||
git: HistoryStore? = nil,
|
||||
settings: BoardSettingsPresentation? = nil
|
||||
git: HistoryStore? = nil
|
||||
) {
|
||||
self.store = store
|
||||
self.recents = recents
|
||||
self.git = git
|
||||
self.settings = settings
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -416,7 +443,7 @@ struct BoardInfoView: View {
|
||||
case .theme:
|
||||
BoardThemeTabView(store: store, inset: inset)
|
||||
case .git:
|
||||
BoardGitTabView(store: store, git: git, settings: settings, inset: inset)
|
||||
BoardGitTabView(store: store, git: git, inset: inset)
|
||||
}
|
||||
}
|
||||
// The style editor's popover width, taken from the editor rather than restated — the number
|
||||
|
||||
@@ -1,666 +0,0 @@
|
||||
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 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 fact is adopted once, from the session, at the same
|
||||
/// moment the titlebar widget adopts it (`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.
|
||||
///
|
||||
/// **The tier came out at 12 ▸ PIVOT 2026-08-07.** It was adopted here alongside the git state until
|
||||
/// that day, because the sheet was Pro's; git is tier-independent now, so what the doors validate on
|
||||
/// is the board's mode alone.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class BoardSettingsPresentation {
|
||||
|
||||
var isPresented = false
|
||||
|
||||
/// This window's board git state, `nil` on a window whose session has not been adopted yet —
|
||||
/// which reads as mode `none`, the harmless direction (an add-git sheet, offered to a board that
|
||||
/// may well already have a repository, is nothing anyone can act on before adoption lands).
|
||||
private(set) var git: HistoryStore?
|
||||
|
||||
/// Called once per window, from the same place the titlebar widget is handed the same fact.
|
||||
func adopt(git: HistoryStore?) {
|
||||
self.git = git
|
||||
}
|
||||
|
||||
/// What the sheet would show right now — and therefore, when empty, that there is no sheet to
|
||||
/// show (`BoardSettingsAvailability`).
|
||||
///
|
||||
/// **An unadopted window has none**, and that is a `nil` check rather than a mode reading: every
|
||||
/// control this sheet hosts writes *through* the git state (`section(_:)` renders nothing without
|
||||
/// one), so a window that has not been handed its session yet would otherwise offer a sheet of
|
||||
/// bare headers. It was the free tier's default that kept this shut before 12 ▸ PIVOT 2026-08-07;
|
||||
/// what keeps it shut now is the honest absence of a session, which is the only thing a `nil`
|
||||
/// ever meant here.
|
||||
var sections: [BoardSettingsSection] {
|
||||
guard let git else { return [] }
|
||||
return BoardSettingsSection.resolve(
|
||||
mode: git.mode,
|
||||
isRepositoryUnreadable: git.isRepositoryUnreadable
|
||||
)
|
||||
}
|
||||
|
||||
/// Both doors' validation: the menu row's `disabled` state and whether the popover shows its row
|
||||
/// at all. Derived from `sections` rather than from `BoardSettingsAvailability` directly, so the
|
||||
/// unadopted case above cannot answer one way here and another there.
|
||||
var isReachable: Bool {
|
||||
!sections.isEmpty
|
||||
}
|
||||
|
||||
/// **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 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.
|
||||
///
|
||||
/// **The tier axis came out at 12-editions.md ▸ PIVOT 2026-08-07**: this resolved `guard tier ==
|
||||
/// .pro else { return [] }` first and the mode second until git left the paywall. Every board can
|
||||
/// reach the setup it has now, and what it has is still the mode's answer — the sheet's whole
|
||||
/// vocabulary is repository-shaped, so a board with nothing repository-shaped to say still hosts
|
||||
/// nothing.
|
||||
///
|
||||
/// 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 {
|
||||
|
||||
/// Mode `none`: **add-git** (06-history-undo.md ▸ Rules ▸ Opt-in init) — the offer, on every
|
||||
/// tier since the pivot, and still never an auto-init (git is opt-in per board, 12 ▸ PIVOT
|
||||
/// 2026-08-07).
|
||||
case git
|
||||
|
||||
/// 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
|
||||
|
||||
/// 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"
|
||||
}
|
||||
}
|
||||
|
||||
/// - Parameter isRepositoryUnreadable: whether the board's `.git` exists and will not open
|
||||
/// (`HistoryStore.isRepositoryUnreadable`). Defaulted, because it can only ever be true in mode
|
||||
/// `git` — every other mode has no repository for the probe to have failed on, and a caller
|
||||
/// that has no git state to ask is describing one of those boards.
|
||||
static func resolve(
|
||||
mode: BoardGitMode,
|
||||
isRepositoryUnreadable: Bool = false
|
||||
) -> [BoardSettingsSection] {
|
||||
switch mode {
|
||||
case .none:
|
||||
return [.git]
|
||||
case .git:
|
||||
// **An unreadable repository hosts no setup either** (06-history-undo.md ▸ Rules, the
|
||||
// corrupt-`.git` loud failure, ruled 2026-07-31: "the whole git surface paused …
|
||||
// Lanework leaves the repository untouched"), and it lands on repo-nested's emptiness by
|
||||
// repo-nested's own reasoning, one step further along: both sections here are *writes* to
|
||||
// a repository — a branch created in it, an identity written into its config — and there
|
||||
// is no repository the app can open to write either into. An empty sheet would be the
|
||||
// greyed-out button 06 rules out one level up, so the surface simply does not exist for
|
||||
// such a board and the popover's own prose carries the explanation
|
||||
// (`BoardGitBranchSurface.unreadableNote`).
|
||||
//
|
||||
// The mode stays `.git` throughout — this is emptiness *within* git mode, never a fall
|
||||
// to mode none, which is what would let add-git be offered against an existing `.git`.
|
||||
return isRepositoryUnreadable ? [] : [.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 []
|
||||
case .unverifiable:
|
||||
// **Structurally the same emptiness as `.repoNested`, for the same reason** (06 ▸ Rules ▸
|
||||
// Detection, "Denial is not absence"): a denied ancestor check can never be told apart
|
||||
// from a repository actually being there, so add-git stays unreachable and this surface
|
||||
// does not exist for such a board either. Only the popover's *prose* tells the two apart
|
||||
// — this inventory does not, because the sheet has nothing to set up on either.
|
||||
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: a board whose mode is `none` or `git`, on **any tier** — the Pro
|
||||
/// requirement that stood beside it retired with 12-editions.md ▸ PIVOT 2026-08-07.
|
||||
///
|
||||
/// The unreachable postures are unreachable for reasons that are all the board's, and all the
|
||||
/// design's — `BoardSettingsSection.resolve`'s own comments carry them one by one: **repo-nested**
|
||||
/// and **unverifiable**, where nothing setup-shaped can apply, and **git mode over a repository that
|
||||
/// will not open**, where both sections would be writes into something the app cannot open. In each,
|
||||
/// 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(
|
||||
mode: BoardGitMode,
|
||||
isRepositoryUnreadable: Bool = false
|
||||
) -> Bool {
|
||||
!BoardSettingsSection.resolve(
|
||||
mode: mode,
|
||||
isRepositoryUnreadable: isRepositoryUnreadable
|
||||
).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) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user