The board settings sheet — setup leaves the popover for a home a stray click can't dismiss
The popover/sheet split lands: a board-scoped, titled, sectioned sheet on the board window hosts everything setup-shaped, opened from the popover's Board Settings… row and the new Board ▸ Board Settings… menu row. The three existing setup controls relocate — add-git (whose noteFormVisible lines now make the sheet the form-anchored failure surface), branch creation (a standing field; create-and-switch runs the identical settle sequence), and the commit-identity fields (the 2s visibility-scoped poll rides with them). The popover keeps the daily face and its postures; its Pro/mode-none section becomes header + door (.addGit renamed .noRepository). Availability is derived from the section inventory (Pro + mode none or git), so pro-m2's sections can't drift from the doors; the sheet's fields join the caret-chord disable set. The audit suite pins what the free fixture can reach; the sheet's own audit is manual until a tier override is ruled. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -27,7 +27,9 @@ struct BoardGitBranchSurface: Equatable {
|
||||
/// The pause's own sentence (`GitRepositoryPause.explanation`), or `nil` when the surface is live.
|
||||
let pauseExplanation: String?
|
||||
|
||||
/// Whether the branch picker and the create action accept a click.
|
||||
/// 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.
|
||||
let controlsEnabled: Bool
|
||||
|
||||
/// The line the branch display is read as by VoiceOver.
|
||||
@@ -61,16 +63,17 @@ struct BoardGitBranchSurface: Equatable {
|
||||
// MARK: - The git-mode section
|
||||
|
||||
/// **The popover's git section on a board that has a repository** (03-board-ui.md ▸ Board popover;
|
||||
/// 06-history-undo.md ▸ Branch switching, ▸ Interaction with external writers).
|
||||
/// 06-history-undo.md ▸ Branch switching).
|
||||
///
|
||||
/// Three surfaces, in the order the design lists them: the branch display with switching and
|
||||
/// creation, the pause explanation when there is one, and the commit-identity fields.
|
||||
/// **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.
|
||||
///
|
||||
/// **Shaped for the half that is not here yet.** Remote tracking, Pull/Push, push-on-commit and the
|
||||
/// authentication surface are 07-sync-collab.md's own cards, and this section is arranged so they
|
||||
/// join as one more block between the branch controls and the identity fields — nothing here is
|
||||
/// nested inside anything they would have to be pulled out of, and nothing about the branch controls
|
||||
/// assumes there is no upstream to show beside them.
|
||||
/// **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
|
||||
/// the branch controls — nothing here is nested inside anything they would have to be pulled out of,
|
||||
/// and nothing about the branch controls assumes there is no upstream to show beside them.
|
||||
struct BoardGitControls: View {
|
||||
|
||||
let git: HistoryStore
|
||||
@@ -79,9 +82,6 @@ struct BoardGitControls: View {
|
||||
/// refuses a checkout most of all — it rewrites the tree the lock exists to stop describing.
|
||||
let isEnabled: Bool
|
||||
|
||||
@State private var isNaming = false
|
||||
@State private var draftBranch = ""
|
||||
|
||||
private var surface: BoardGitBranchSurface {
|
||||
BoardGitBranchSurface.resolve(
|
||||
branch: git.branch,
|
||||
@@ -95,10 +95,6 @@ struct BoardGitControls: View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
branchRow
|
||||
|
||||
if isNaming {
|
||||
newBranchField
|
||||
}
|
||||
|
||||
if let explanation = surface.pauseExplanation {
|
||||
pauseNote(explanation)
|
||||
}
|
||||
@@ -106,8 +102,6 @@ struct BoardGitControls: View {
|
||||
if let failure = git.switcher?.lastFailure {
|
||||
caption(failure.message, tone: .red)
|
||||
}
|
||||
|
||||
BoardGitIdentityFields(git: git, isEnabled: isEnabled)
|
||||
}
|
||||
// Every read the section needs, taken when it appears rather than held live: the popover is
|
||||
// built fresh on each open (`BoardInfoWidget`), and none of these is a fact the board's
|
||||
@@ -122,8 +116,14 @@ struct BoardGitControls: View {
|
||||
// MARK: The branch line
|
||||
|
||||
/// The branch display and the switch, as one control: the line *is* the picker, which is what
|
||||
/// makes "branch/source display, branch switching and creation" one affordance rather than a label
|
||||
/// with a button beside it.
|
||||
/// 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. On a board with one
|
||||
/// branch the menu is therefore *empty*, and that is left honest rather than papered over: the
|
||||
/// label still reads the current branch and still disables with the rest of the git surface, so
|
||||
/// the line goes on saying the one thing it is here to say.
|
||||
private var branchRow: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "arrow.triangle.branch")
|
||||
@@ -136,13 +136,6 @@ struct BoardGitControls: View {
|
||||
Task { await git.switcher?.switchTo(name) }
|
||||
}
|
||||
}
|
||||
if !otherBranches.isEmpty {
|
||||
Divider()
|
||||
}
|
||||
Button("New Branch…") {
|
||||
draftBranch = ""
|
||||
isNaming = true
|
||||
}
|
||||
} label: {
|
||||
Text(surface.branchLabel)
|
||||
.font(.callout)
|
||||
@@ -152,7 +145,7 @@ struct BoardGitControls: View {
|
||||
.fixedSize()
|
||||
.disabled(!surface.controlsEnabled)
|
||||
.accessibilityLabel(surface.accessibilityLabel)
|
||||
.accessibilityHint("Switch branches or create a branch")
|
||||
.accessibilityHint("Switch branches")
|
||||
|
||||
if git.switcher?.isSwitching == true {
|
||||
ProgressView()
|
||||
@@ -168,41 +161,6 @@ struct BoardGitControls: View {
|
||||
(git.switcher?.branches ?? []).filter { $0 != git.branch }
|
||||
}
|
||||
|
||||
// MARK: Create and switch
|
||||
|
||||
/// Named inline rather than in a sheet: the popover is where the operation was asked for, and a
|
||||
/// sheet over a transient popover would dismiss the surface it came from.
|
||||
private var newBranchField: some View {
|
||||
HStack(spacing: 6) {
|
||||
TextField("New branch name", text: $draftBranch)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1)
|
||||
.onSubmit { create() }
|
||||
// Escape steps outward one layer (04-interactions.md ▸ Grammar): it abandons the
|
||||
// naming rather than dismissing the popover under it.
|
||||
.onKeyPress(.escape) {
|
||||
isNaming = false
|
||||
draftBranch = ""
|
||||
return .handled
|
||||
}
|
||||
Button("Create", action: create)
|
||||
.disabled(trimmedDraft.isEmpty)
|
||||
}
|
||||
.disabled(!surface.controlsEnabled)
|
||||
}
|
||||
|
||||
private var trimmedDraft: String {
|
||||
draftBranch.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func create() {
|
||||
let name = trimmedDraft
|
||||
guard !name.isEmpty else { return }
|
||||
isNaming = false
|
||||
draftBranch = ""
|
||||
Task { await git.switcher?.createAndSwitch(to: name) }
|
||||
}
|
||||
|
||||
// MARK: The pause
|
||||
|
||||
/// **The abnormal-state surface** (06 ▸ Rules ▸ Abnormal repo states) — deferred here from the
|
||||
@@ -231,134 +189,3 @@ struct BoardGitControls: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
// 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").
|
||||
///
|
||||
/// **Its home is the sheet, and the sheet is not built yet.** The 2026-07-31 popover/sheet split moved
|
||||
/// every setup-shaped control — add-git, branch creation, these fields, remote and credentials — out
|
||||
/// of the popover and into a board settings sheet (03-board-ui.md ▸ Board settings sheet), leaving the
|
||||
/// popover the daily face. This view is the fields, hosted where they were; the sheet's card moves
|
||||
/// them, along with the visibility-scoped poll below, which 06 says "rides with the fields".
|
||||
///
|
||||
/// ### 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 popover 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 popover
|
||||
/// stays open. The poll lives and dies with this view.
|
||||
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 surface 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) {
|
||||
Divider()
|
||||
|
||||
Text("Commit Identity")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
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.
|
||||
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: 44, alignment: .leading)
|
||||
TextField(placeholder, text: text)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1)
|
||||
.focused($focused, equals: tag)
|
||||
.onSubmit { commit() }
|
||||
.disabled(!isEnabled)
|
||||
.accessibilityLabel("Commit \(label.lowercased())")
|
||||
}
|
||||
.onChange(of: focused) { previous, _ in
|
||||
// Focus leaving *this* field is this field's commit — the inline editors' exit, applied
|
||||
// to a form where Tab moves between two of them.
|
||||
guard previous == tag else { return }
|
||||
commit()
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes both fields, and only when one of them differs from what the file says — an unchanged
|
||||
/// value must not rewrite `.git/config` every time the popover closes.
|
||||
private func commit() {
|
||||
guard isEnabled else { return }
|
||||
guard name != git.identityName || email != git.identityEmail else { return }
|
||||
Task { await git.writeIdentity(name: name, email: email) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user