Files
lanework/Kanban/UI/Board/BoardInfoPopover.swift
T
rzen 2c6b8fe63a Remove the App Group wholesale — one sandbox, one bookmark, one flag
Phase 2 of the one-app pivot (DESIGN 12 ▸ App-side state, re-ruled
2026-07-30; reworks 566deab). AppGroup retires; what remains is
AppStateHome — ordinary sandbox Application Support as the one home for
the registry, clipboard staging and template stores, keeping the
unit-test-host redirect (the test host is the app and would sweep real
state). Scalar defaults return to UserDefaults.standard.

BoardRecord's per-edition grant slots and openNow flags collapse to one
bookmark + one isOpenNow; the legacy-key decode and adopt-in-memory
paths go (nothing shipped with group-era records), while the founding
four-keys-required / defaults-for-everything-since decode policy stays —
a bookmarkless record decodes as the born-orphan row rather than
quarantining the list. needsReopen and the pre-anchored re-grant panel
are removed whole: the only state that flow served — a record granted by
a sibling sandbox — is unrepresentable now, and a dead bookmark of our
own was already the orphan case by explicit comment. The
indexOfRecord path fallback dies with it; path is never a key again.

The cross-process freshness stamp (mtime+size re-read) and
BoardEditionPresence with its popover "Also open in…" line retire; the
clipboard prune keeps its atomic .sweeping/ claim-then-delete, reframed
for crash residue and open -n copies rather than sibling editions. The
application-groups entitlement key is gone.

1880 tests in 317 suites green (13 cross-edition tests retired with
their subject).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 17:46:32 -04:00

320 lines
16 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.
///
/// Two sections, in the design's own order: the board rename and the embedded style editor aimed at
/// the board. A third, contextual note joins them only on a board that carries an inert `.git` — see
/// `BoardGitNote` for the wording and why base's posture there is a quiet signpost rather than a
/// standing section (12-editions.md § Base and `.git`, ruled 2026-07-27, superseding the earlier
/// reserved-slot placeholder this file once carried).
///
/// ### 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: a quiet disclosure chevron whose one job is this popover.
///
/// **The popover is anchored to the widget itself** — it hangs from the chevron 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`).
struct BoardInfoWidget: View {
let store: BoardStore
let recents: StyleRecents
@Bindable var presentation: BoardInfoPresentation
var body: some View {
Button {
presentation.toggle()
} label: {
Image(systemName: "chevron.down")
.imageScale(.small)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
// Sized like a titlebar control rather than by its glyph: the hit target has to be
// clickable at titlebar scale, where the chevron alone is a few points across.
.frame(width: 20, height: 18)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help("Board Info")
.accessibilityLabel("Board Info")
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
BoardInfoView(store: store, recents: recents)
}
}
}
/// 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
func boardInfoTitlebarAccessory(
store: BoardStore,
recents: StyleRecents,
presentation: BoardInfoPresentation
) -> NSTitlebarAccessoryViewController {
let hosting = NSHostingView(
rootView: BoardInfoWidget(
store: store,
recents: recents,
presentation: presentation
)
)
// The titlebar lays its accessories out by fitting size, and a hosting view that measured itself
// as zero would be an invisible, unclickable widget.
hosting.sizingOptions = [.intrinsicContentSize]
hosting.frame = NSRect(x: 0, y: 0, width: 20, height: 18)
let controller = NSTitlebarAccessoryViewController()
controller.view = hosting
controller.layoutAttribute = .leading
return controller
}
// MARK: - The popover's content
/// Two sections and, on a `.git`-bearing board, a contextual note — one view (03-board-ui.md §
/// Board popover; 12-editions.md § Base and `.git`).
///
/// Width is the style editor's — 268 points, the number that keeps the Style… popover narrow enough
/// to sit beside a card — so the embedded editor lays out here exactly as it does at its other two
/// anchors rather than being stretched by a container with its own opinion.
struct BoardInfoView: View {
let store: BoardStore
let recents: StyleRecents
/// Whether this board carries a `.git` — checked once, off disk, when the view is built (which
/// is every time the popover opens, since `BoardInfoWidget` hands `.popover` a fresh instance).
/// See `BoardGitNote.hasGitDirectory(at:)` for why a live-updating fact isn't needed here.
private let hasGitDirectory: Bool
/// 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) {
self.store = store
self.recents = recents
self.hasGitDirectory = BoardGitNote.hasGitDirectory(at: store.rootURL)
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Title")
BoardRenameField(store: store)
}
.padding(inset)
Divider()
VStack(alignment: .leading, spacing: 0) {
sectionHeader("Styling")
.padding(.horizontal, inset)
.padding(.top, inset)
// Always the board, whatever is selected. The ⌥⌘S anchor is the selection-aware one
// ("nothing selected = the board"); this embed is the surface that exists *because*
// the board is a style target, so it can have no other target (§ Styling ▸
// Controls: "the board popover's target is the board itself").
StyleEditorView(store: store, recents: recents, target: .board)
}
// Contextual, not standing (12-editions.md, settled 2026-07-27): an ordinary board adds
// nothing here at all — no header, no divider, no placeholder — and the popover ends at
// Styling, complete in itself. Only a board that actually carries an inert `.git` earns
// this closing note.
if hasGitDirectory {
Divider()
BoardGitNote()
.padding(inset)
}
}
// The style editor's popover width, taken from the editor rather than restated: the embed
// below must lay out here exactly as it does at its other two anchors, and that number is
// now font-derived.
.frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width)
}
/// The section titles, matching the style editor's own headers so the popover reads as one
/// surface rather than borrowed ones.
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.subheadline.weight(.semibold))
}
}
// 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
}
}
// MARK: - Git
/// The contextual git note — **a quiet signpost, not a feature** (12-editions.md ▸ The free tier and
/// `.git`, settled 2026-07-27, carried through the one-app collapse). The free tier has no git
/// integration (that is the Pro subscription's), so this is not a grow-in-place slot the way the old
/// `BoardGitSlot` placeholder was: there is nothing here to grow. The free tier's whole git story is
/// one line, shown only when it is true — and it is **the one in-context pointer to Pro**, the second
/// of the three places the app names it (12 ▸ Tier naming; the other two are `AboutBox` and the
/// Settings Pro section).
///
/// On an ordinary board `BoardInfoView` never instantiates this type at all — the section is
/// *absent*, matching the card window's absent History section (12: "absent, no placeholder"). Only
/// a board that carries an inert `.git` (12 ▸ the inert posture: "any `.git` is inert" — the free
/// tier never reads or writes it, whether the board's own or one a lapsed subscription left behind)
/// earns this note, worded exactly as 12 rules: an honest explanation of what the folder is, named
/// exactly where the question arises, never a standing ad for Pro.
///
/// Not `private`: `hasGitDirectory(at:)` is the pure seam `BoardInfoPopoverTests.swift` pins directly
/// (a fixture board with `.git` → true, without → false), which needs it visible past this file even
/// though nothing outside `BoardInfoPopover.swift` calls it in the app itself.
struct BoardGitNote: View {
var body: some View {
Text("This board has a git history. Lanework Pro works with it.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
/// Whether `boardRoot` carries a `.git` entry — the free tier's entire detection story, and a deliberately
/// small one: nothing in `BoardStore`, `BoardModel`, or `BoardLoader` tracks this as a live fact
/// today, because nothing needs it to be live. `FolderWatcher` filters `.git` out of the folder
/// watch by design (§ .git filtering — it exists to ignore git churn), so there is no reload
/// event this could hang off even if it wanted to; a plain, read-only `FileManager` check taken
/// once, at the moment the popover is built, is the honest amount of machinery for a single quiet
/// line. A `.git` added or removed while the popover happens to be open is stale until the next
/// open — a gap this note's own posture makes harmless, since it is a signpost, not a control.
static func hasGitDirectory(at boardRoot: URL) -> Bool {
FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path)
}
}