HistoryStore.compose(boardRoot📒) returns non-optional and runs for every session — the nil the gate produced was the only nil it ever had. makeHistoryProvider is a one-axis decision: git-mode boards bind the git provider, everything else native, in every tier; Session.tier stays recorded, dormant. BoardGitSection shrinks to the four mode postures (.absent and .proPointer die, BoardGitNote and the .git probe with them); every board carries all three popover tabs (BoardInfoTab.available retired); the titlebar branch shows on any git-mode board; the settings sheet and card History section stop reading tier. InertGitTests is repurposed as UntouchedGitTests — the file layer still never opens .git, now load-bearing for mode-none boards. The accessibility audit reaches the settings sheet at last: the fixture board hosts it in every tier, so the free-fixture disabled-row test becomes an open-and-audit test. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
504 lines
27 KiB
Swift
504 lines
27 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
|
||
/// **The board popover** — "the one board-level surface" (03-board-ui.md § Board popover), and the
|
||
/// widget in the window's titlebar that opens it.
|
||
///
|
||
/// **Tabbed since 2026-08-07 — the restructure is complete, all three tab sessions settled.** The
|
||
/// symbol/name header stays at the top; below it sit the tabs — **Info**, **Theme**, **Git** — each
|
||
/// the settings surface for one aspect of board configuration, each settled in its own dedicated
|
||
/// 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.
|
||
///
|
||
/// **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
|
||
/// structural rule with nothing left to exclude: every board carries all three tabs (03 ▸ Board
|
||
/// popover, the same-day pivot note) — see `BoardInfoTab`.
|
||
///
|
||
/// ### One home, deliberately
|
||
///
|
||
/// The popover has **no toolbar item** (§ Toolbar: "the window-title widget is its committed home
|
||
/// … 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").
|
||
|
||
// MARK: - Presentation state
|
||
|
||
/// Whether **this window's** board popover is open.
|
||
///
|
||
/// Per window rather than per board or per app, and that is the point: ⌘I has to mean "the board in
|
||
/// front", so the flag travels with the window through the focus system (`FocusedValues.boardInfo`)
|
||
/// exactly as the store does. Two board windows each hold their own and can never toggle each
|
||
/// other's — which a flag on the store could not promise the day a board is allowed two windows.
|
||
///
|
||
/// It is also why this is not in `TransientBoardState`: everything there is *board*-scoped and
|
||
/// shared with the board's card windows, and a popover living in one window's titlebar is not that.
|
||
@MainActor
|
||
@Observable
|
||
final class BoardInfoPresentation {
|
||
|
||
var isPresented = false
|
||
|
||
/// ⌘I's whole behaviour and the widget's alike. **Toggling, not opening**: a shortcut aimed at a
|
||
/// disclosure that could only ever open would leave the popover with no keyboard way out.
|
||
func toggle() {
|
||
isPresented.toggle()
|
||
}
|
||
}
|
||
|
||
/// The focused board window's popover, beside `FocusedValues.boardStore` — see that key for why
|
||
/// board-window menu items reach their window this way rather than through the app model.
|
||
struct FocusedBoardInfoKey: FocusedValueKey {
|
||
typealias Value = BoardInfoPresentation
|
||
}
|
||
|
||
extension FocusedValues {
|
||
var boardInfo: BoardInfoPresentation? {
|
||
get { self[FocusedBoardInfoKey.self] }
|
||
set { self[FocusedBoardInfoKey.self] = newValue }
|
||
}
|
||
}
|
||
|
||
// 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 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
|
||
/// `.popover` rather than a hand-driven `NSPopover` because SwiftUI's is already transient (a click
|
||
/// outside dismisses it), and because the content is SwiftUI either way; the AppKit half of this is
|
||
/// only the *placement* (`boardInfoTitlebarAccessory`).
|
||
///
|
||
/// **Whole-area clickable, not just the chevron** (the card that widened this from a 20×18 chevron
|
||
/// button to the full name/branch/chevron button): the title and branch strings sit inside the same
|
||
/// `Button`, so a click anywhere across the board's name — or its branch, when shown — opens the
|
||
/// popover exactly as a click on the chevron always has.
|
||
struct BoardInfoWidget: View {
|
||
|
||
let store: BoardStore
|
||
let recents: StyleRecents
|
||
|
||
/// The git state this board's **session** composed with — read once, at the moment the widget is
|
||
/// installed, and never re-derived (12-editions.md ▸ The entitlement: "a lapse never interrupts
|
||
/// an open session"). `git` is a reference type and `@Observable`, so add-git flipping the mode,
|
||
/// or a branch switch, redraws the widget without anything here being re-created.
|
||
///
|
||
/// It stopped being a tier signal at 12 ▸ PIVOT 2026-08-07 — every session composes a git state
|
||
/// now, whatever the tier — so a `nil` here means only "this caller had no session to hand over"
|
||
/// (previews, the accessory-installation tests), which reads as mode `none`.
|
||
let git: HistoryStore?
|
||
|
||
@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
|
||
/// never see a later rename or branch switch. `store.snapshot` and `git.branch` are both
|
||
/// `@Observable`, so reading them here is what makes the title and branch live.
|
||
private var summary: BoardInfoTitlebarSummary {
|
||
BoardInfoTitlebarSummary(
|
||
snapshotTitle: store.snapshot.title.value,
|
||
rootURL: store.rootURL,
|
||
mode: git?.mode ?? .none,
|
||
branch: git?.branch
|
||
)
|
||
}
|
||
|
||
var body: some View {
|
||
Button {
|
||
presentation.toggle()
|
||
} label: {
|
||
HStack(spacing: 4) {
|
||
// 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.
|
||
Image(systemName: ItemSymbol.name(store.snapshot.icon, fallback: ItemSymbol.board))
|
||
.imageScale(.small)
|
||
.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)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
|
||
Image(systemName: "chevron.down")
|
||
.imageScale(.small)
|
||
.fontWeight(.semibold)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.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.
|
||
.frame(maxWidth: 400, alignment: .leading)
|
||
.frame(height: 18)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help("Board Info")
|
||
.accessibilityLabel(accessibilityLabel)
|
||
.accessibilityHint("Shows board info")
|
||
// Populates the branch line the moment a git-mode board's window opens, rather than waiting
|
||
// on the popover's own read (`BoardGitControls`'s `.task`, which only runs once the popover
|
||
// has actually been opened once). The widget is on screen from the start, so it is the
|
||
// earlier honest place to ask; `refreshBranch()` is already a no-op outside git mode, so this
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
/// What VoiceOver reads for the button, now that it says more than "Board Info": the board's
|
||
/// name, plus the branch when the widget is showing one — `.help` keeps the shorter "Board Info"
|
||
/// wording as the hover tooltip, and `.accessibilityHint` on the widget itself still names what
|
||
/// the button does.
|
||
private var accessibilityLabel: String {
|
||
guard let branch = summary.branch else { return summary.title }
|
||
return "\(summary.title), branch \(branch)"
|
||
}
|
||
|
||
/// The widget glyph's tint: the board's `iconColor` where it resolves, the quiet secondary
|
||
/// otherwise — `CardFaceView`'s `iconTint` rule at the board's own level.
|
||
private var iconTint: AnyShapeStyle {
|
||
if let color = Palette.color(for: store.snapshot.iconColor) {
|
||
return AnyShapeStyle(color)
|
||
}
|
||
return AnyShapeStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
// MARK: - The widget's strings
|
||
|
||
/// **The window-title widget's two strings, as one pure function** of the board's on-disk title, its
|
||
/// folder, and the session's git posture — pulled out so the fallback rule and the branch-visibility
|
||
/// rule are each assertable without a widget on screen (`BoardInfoTitlebarSummaryTests`), the same
|
||
/// reason `BoardGitSection.resolve` exists over in `BoardGitTabView.swift`.
|
||
///
|
||
/// **Title.** `AppModel.displayName(of:)` is the same rule applied to the window's actual title
|
||
/// (`BoardWindowHost.windowTitle` reads it, and `.navigationTitle` keeps feeding it to the Window
|
||
/// menu, Exposé, VoiceOver and restoration even though the title bar's own rendering of it is now
|
||
/// hidden — see `BoardWindowHost.configureWindow`): the on-disk `title`, falling back to the folder
|
||
/// name sans extension when absent or empty (01-storage-format.md § Board naming). Restated here
|
||
/// against the raw title string and `rootURL` rather than a `BoardStore`, so this seam is testable
|
||
/// with plain values and no fixture board on disk — the one duplication this card leaves behind
|
||
/// rather than reshaping `AppModel.displayName(of:)`'s signature to fit both call sites.
|
||
///
|
||
/// **Branch.** Shown only when the board is actually git-mode — `mode == .git` with a non-`nil`
|
||
/// branch — the same condition `BoardGitSection.resolve`'s `.branch` case covers. **The tier clause
|
||
/// is gone** (12-editions.md ▸ PIVOT 2026-08-07: git is tier-independent, so a git-mode board is a
|
||
/// git-mode board and the widget says so on every tier); the rule it read `tier == .pro && mode ==
|
||
/// .git` until that day is recorded here rather than restated in code. A board with no repository or
|
||
/// an inert one (mode `.none`, `.repoNested`, `.unverifiable`) shows no branch; neither does a
|
||
/// git-mode board whose branch has not been read yet (`HistoryStore.branch` starts `nil` until
|
||
/// `refreshBranch()` answers, which the widget's own `.task` kicks off at open).
|
||
struct BoardInfoTitlebarSummary: Equatable {
|
||
|
||
let title: String
|
||
let branch: String?
|
||
|
||
init(snapshotTitle: String?, rootURL: URL, mode: BoardGitMode, branch: String?) {
|
||
if let snapshotTitle, !snapshotTitle.isEmpty {
|
||
self.title = snapshotTitle
|
||
} else {
|
||
self.title = rootURL.deletingPathExtension().lastPathComponent
|
||
}
|
||
self.branch = mode == .git ? branch : nil
|
||
}
|
||
}
|
||
|
||
/// The widget wearing AppKit's clothes, because SwiftUI has no way to put a view in the titlebar:
|
||
/// an `NSTitlebarAccessoryViewController` hosting the button, laid out `.leading` so it sits in the
|
||
/// title bar beside the window title rather than in the window's content.
|
||
///
|
||
/// `HostedWindowController.installTitlebarAccessory` owns the rest of the lifecycle — one per
|
||
/// 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.
|
||
func boardInfoTitlebarAccessory(
|
||
store: BoardStore,
|
||
recents: StyleRecents,
|
||
git: HistoryStore? = nil,
|
||
presentation: BoardInfoPresentation,
|
||
settings: BoardSettingsPresentation? = nil
|
||
) -> NSTitlebarAccessoryViewController {
|
||
let hosting = NSHostingView(
|
||
rootView: BoardInfoWidget(
|
||
store: store,
|
||
recents: recents,
|
||
git: git,
|
||
presentation: presentation,
|
||
settings: settings
|
||
)
|
||
)
|
||
// The titlebar lays its accessories out by fitting size, and a hosting view that measured itself
|
||
// as zero would be an invisible, unclickable widget. `.intrinsicContentSize` re-measures on every
|
||
// SwiftUI update, so this starting frame only has to survive the first layout pass before the
|
||
// 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.
|
||
hosting.sizingOptions = [.intrinsicContentSize]
|
||
hosting.frame = NSRect(x: 0, y: 0, width: 200, height: 18)
|
||
|
||
let controller = NSTitlebarAccessoryViewController()
|
||
controller.view = hosting
|
||
controller.layoutAttribute = .leading
|
||
return controller
|
||
}
|
||
|
||
// MARK: - Tabs
|
||
|
||
/// The popover's three aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab
|
||
/// restructure — all three sessions settled): **Info** (`BoardInfoTabView`), **Theme**
|
||
/// (`BoardThemeTabView`), **Git** (`BoardGitTabView`). The raw values are the segmented control's own
|
||
/// labels, so the strip needs no separate label function.
|
||
enum BoardInfoTab: String, CaseIterable, Identifiable {
|
||
|
||
case info = "Info"
|
||
case theme = "Theme"
|
||
case git = "Git"
|
||
|
||
var id: Self { self }
|
||
|
||
// **Membership is the git posture's, and the posture never says "absent" any more.** The Git
|
||
// session ruled (2026-08-07) that the Git tab joins the strip only where `BoardGitSection` has
|
||
// something true to say, which then meant dropping it on a free board with no `.git` — the
|
||
// "absent, no placeholder" rule carried up to the strip. **12-editions.md ▸ PIVOT 2026-08-07**,
|
||
// the same day, retired the free-only postures wholesale: git left the paywall, `.absent` and
|
||
// `.proPointer` died with it, and 03-board-ui.md ▸ Board popover records the consequence — "the
|
||
// absent posture is unreachable and every board carries all three tabs. The membership rule
|
||
// stands structurally — the strip still asks the posture — it just never hears 'absent'
|
||
// anymore." So there is no `available(…)` filter here to ask it with: membership is `allCases`,
|
||
// in `allCases`' own order (Info, Theme, Git), which is what the filter answered anyway.
|
||
}
|
||
|
||
// MARK: - The popover's content
|
||
|
||
/// The symbol/name header, then the tab bar, then the selected tab's surface — one view
|
||
/// (03-board-ui.md § Board popover).
|
||
///
|
||
/// Width is the style editor's — the number that keeps the Style… popover narrow enough to sit
|
||
/// beside a card — kept through the restructure so the popover's footprint didn't wander while the
|
||
/// tabs filled in; all three settled tabs (Info, Theme, Git) kept it, so whether the tabbed surface
|
||
/// ever wants its own width remains open, but nothing has needed one yet.
|
||
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
|
||
/// is transient and Info is the board's face, and a remembered tab could strand selection on a
|
||
/// tab the next board's posture doesn't offer. `@State` on the popover's content, which
|
||
/// `BoardInfoWidget` hands `.popover` fresh on every open, is exactly that rule and nothing more.
|
||
@State private var tab: BoardInfoTab = .info
|
||
|
||
/// The style editor brings its own padding, so the sections around it carry the same number by
|
||
/// hand instead of an outer padding that would double up on it — **the editor's own figure**
|
||
/// (`StyleEditorLayout.sectionSpacing`), which is font-derived, so the popover's chrome scales
|
||
/// with the grids inside it (10-accessibility.md's full-relative-scaling rule).
|
||
private var inset: CGFloat {
|
||
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
||
}
|
||
|
||
init(
|
||
store: BoardStore,
|
||
recents: StyleRecents,
|
||
git: HistoryStore? = nil,
|
||
settings: BoardSettingsPresentation? = nil
|
||
) {
|
||
self.store = store
|
||
self.recents = recents
|
||
self.git = git
|
||
self.settings = settings
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack(spacing: 6) {
|
||
// The board's own icon, inline with its name — the same field the embedded style
|
||
// editor's symbol section below writes, offered here too since a board's identity
|
||
// is its name *and* its glyph together (03-board-ui.md § Styling ▸ Controls). No
|
||
// `undo:` — the board popover has none of its own, so this reaches the board's
|
||
// stack exactly as the embedded editor's writes do.
|
||
SymbolPicker(
|
||
current: store.snapshot.icon.value,
|
||
fallback: ItemSymbol.board,
|
||
onSelect: { name in
|
||
StyleCommand.apply(
|
||
icon: name.map { StyleChange.set($0) } ?? .remove,
|
||
to: .board,
|
||
in: store,
|
||
recents: recents
|
||
)
|
||
},
|
||
// The colour row — the picker's 4×2 tint grid, writing `iconColor` through
|
||
// the same funnel the glyph writes `icon`: None removes the key, a well
|
||
// writes the palette name (2026-08-07).
|
||
currentColor: store.snapshot.iconColor.value,
|
||
onSelectColor: { name in
|
||
StyleCommand.apply(
|
||
iconColor: name.map { StyleChange.set($0) } ?? .remove,
|
||
to: .board,
|
||
in: store,
|
||
recents: recents
|
||
)
|
||
}
|
||
)
|
||
.disabled(!store.acceptsBoardMutations)
|
||
BoardRenameField(store: store)
|
||
}
|
||
}
|
||
.padding(inset)
|
||
|
||
Divider()
|
||
|
||
// The tab bar: a segmented control rather than a `TabView`, because the popover is a
|
||
// compact settings surface and the segmented idiom is the macOS shape for switching
|
||
// between a handful of peer panes inside one. The label is hidden visually but stays
|
||
// the control's accessibility name. It iterates `allCases` — the strip's membership is
|
||
// still the board's git posture in principle (the Git session's ruling), and since
|
||
// 12-editions.md ▸ PIVOT 2026-08-07 that posture is never "absent", so every board
|
||
// carries all three tabs (03-board-ui.md ▸ Board popover, the same-day pivot note).
|
||
Picker("Board configuration", selection: $tab) {
|
||
ForEach(BoardInfoTab.allCases) { tab in
|
||
Text(tab.rawValue)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.labelsHidden()
|
||
.padding(.horizontal, inset)
|
||
.padding(.top, inset)
|
||
|
||
// The selected tab's surface — all three settled 2026-08-07, each in its own dedicated
|
||
// session and its own file. Each pads itself by `inset`, so the switch adds nothing.
|
||
switch tab {
|
||
case .info:
|
||
BoardInfoTabView(store: store, inset: inset)
|
||
case .theme:
|
||
BoardThemeTabView(store: store, inset: inset)
|
||
case .git:
|
||
BoardGitTabView(store: store, git: git, settings: settings, inset: inset)
|
||
}
|
||
}
|
||
// The style editor's popover width, taken from the editor rather than restated — the number
|
||
// that keeps a compact settings popover narrow enough to sit beside a card. It is
|
||
// font-derived, so the whole surface scales with the grids inside it (10-accessibility.md).
|
||
.frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width)
|
||
}
|
||
}
|
||
|
||
// MARK: - Rename
|
||
|
||
/// The board rename field (03-board-ui.md § Board popover; 01-storage-format.md § Board naming).
|
||
///
|
||
/// The exits are the inline editors' — Return and focus loss commit, Escape abandons — but the
|
||
/// **placeholder is the whole of the board-naming rule made visible**: an empty field shows the
|
||
/// folder name, because that is what the window title will say, and committing empty is how a user
|
||
/// asks for exactly that. "Untitled" appears nowhere; boards do not have it.
|
||
///
|
||
/// **It is not born focused**, unlike the three inline editors. Those are each opened by a gesture
|
||
/// that means "edit this now"; this one is one control among several on a configuration surface,
|
||
/// where the keyboard path is Tab-reachability rather than a caret waiting in the first field
|
||
/// (11-command-nexus.md's class **C**).
|
||
///
|
||
/// Every handler is idempotent, for `InlineTitleField`'s reason: the exits overlap by construction —
|
||
/// Return commits and then something takes focus, which fires the focus-loss commit an instant
|
||
/// later — and `BoardStore.renameBoard` skips an unchanged title, so the second call writes nothing.
|
||
private struct BoardRenameField: View {
|
||
|
||
let store: BoardStore
|
||
|
||
@State private var draft = ""
|
||
@FocusState private var isFocused: Bool
|
||
|
||
var body: some View {
|
||
TextField(fallbackName, text: $draft)
|
||
.textFieldStyle(.roundedBorder)
|
||
.lineLimit(1)
|
||
.focused($isFocused)
|
||
.onSubmit { store.renameBoard(draft) }
|
||
// **Escape steps outward one layer per press** (04-interactions.md ▸ Grammar): a dirty
|
||
// field abandons its edit and keeps the popover open, and an unedited one lets the press
|
||
// through to the popover's own dismissal. Reverting first is also what makes a dismissal
|
||
// safe on the paths where the press never reaches here — the focus-loss commit that
|
||
// follows sees a draft equal to what is on disk and writes nothing.
|
||
.onKeyPress(.escape) {
|
||
guard draft != committed else { return .ignored }
|
||
draft = committed
|
||
return .handled
|
||
}
|
||
.onChange(of: isFocused) { _, focused in
|
||
guard !focused else { return }
|
||
store.renameBoard(draft)
|
||
}
|
||
.onAppear { draft = committed }
|
||
// A foreign rename — an agent, a hand edit, a sync — landing behind an open popover
|
||
// updates the field, but never under the user's fingers: a draft being typed is the
|
||
// user's, and the reload is not an edit to it (02-architecture.md § Live-reload
|
||
// resilience, the same courtesy the inline editors get by tracking their UUID).
|
||
.onChange(of: committed) { _, title in
|
||
guard !isFocused else { return }
|
||
draft = title
|
||
}
|
||
// The popover can be dismissed without the field ever reporting focus loss, and a
|
||
// dismissal is a commit like any other click-away. Idempotent with the handler above.
|
||
.onDisappear { store.renameBoard(draft) }
|
||
// The read-only lock disables every mutating surface (02-architecture.md § The lock's
|
||
// scope) — and the popover *stays open* under it, which is the style popover's settled
|
||
// precedent: the lock is a condition the banner is already explaining, not a reason to
|
||
// yank a surface away. `acceptsBoardMutations` rather than `isReadOnly` alone so this
|
||
// field and the editor below it disable as one surface rather than in halves.
|
||
.disabled(!store.acceptsBoardMutations)
|
||
}
|
||
|
||
/// The `title` on disk, as the last reload read it — "" for a board that has none.
|
||
private var committed: String { store.snapshot.title.value ?? "" }
|
||
|
||
/// The folder name, sans extension: what the window title shows when `title` is absent
|
||
/// (01-storage-format.md § Board naming), and therefore the honest thing for an empty field to
|
||
/// promise. Read off `rootURL` rather than `snapshot.rootURL` so a Finder rename absorbed
|
||
/// mid-session shows here immediately.
|
||
private var fallbackName: String {
|
||
store.rootURL.deletingPathExtension().lastPathComponent
|
||
}
|
||
}
|