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