Build per-surface context menus

The remaining rows of 11-command-nexus.md § Context menus, every entry a
twin of an existing command path, never a parallel implementation:

- Card: Open (the double-click's own openCard closure, always the clicked
  card alone), Rename (Board ▸ Rename's beginRename path), Style… and the
  quick-style recents (already present), Delete (File ▸ Delete's
  store.delete on the standard widened target — selection when the clicked
  card is a member, else the card alone).
- Lane (one menu, header and empty space): Rename and Delete join the
  existing Style…/recents/Width rows, in table order.
- Trash entries and welcome recents verified already exact against the
  table; the attachment row's menu is marked for m6 beside its command.
- File ▸ Reveal in Finder gains its board-window scope, the branch the m4
  comment deferred here: the selection's folders on either side of the
  trash boundary — enabled under every lock, inspection being a read — or
  the board root with nothing selected; a selection resolving to no
  folders disables rather than guessing.

904 unit tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 23:42:37 -04:00
parent c1f304d3fe
commit 6c490ec71c
3 changed files with 120 additions and 25 deletions
+83 -15
View File
@@ -20,9 +20,9 @@ import SwiftUI
/// ### The lane's one context menu
///
/// "The lane has one context menu (settled), invoked on the header or on lane empty space alike"
/// (03-board-ui.md § Lane), so both surfaces attach the *same* `laneMenu`. It carries Style, the
/// quick-style recents row and the Width stepper today; Rename and Delete are m5's context-menus
/// card, and their rows go into that same builder rather than into a second menu.
/// (03-board-ui.md § Lane), so both surfaces attach the *same* `laneMenu`. It carries Rename, Style,
/// the quick-style recents row, the Width stepper and Delete 11-command-nexus.md Context menus'
/// Lane row, in its order, complete as of m5.
///
/// ### What is still a later card's
///
@@ -161,18 +161,36 @@ struct LaneView: View {
// MARK: - The lane's one context menu
/// Rename, Style, the quick-style recents row, the Width control, Delete (11-command-nexus.md
/// Context menus) the style trio and the width stepper today.
/// Rename, Style, the quick-style recents row, the Width control, Delete 11-command-nexus.md
/// Context menus' Lane row, in its order, complete as of m5.
@ViewBuilder
private var laneMenu: some View {
// m5-context-menus: Rename (a twin of Board Rename) and Delete (a twin of File Delete)
// belong to the card that brings the selection model and the delete command; both are rows
// of *this* menu when they land, not of a second one.
// Rename: Board Rename's exact store path (`BoardRenameCommand`) `beginRename(of:
// currentTitle:)`, seeded with the lane's live title. The menu-bar item additionally requires
// this lane to be the *sole* selection; a context menu already names its target by where it
// was invoked, so standard macOS practice it acts on the clicked lane outright.
Button("Rename") {
store.transient.beginRename(of: lane.id, currentTitle: lane.title.value)
}
.disabled(!store.acceptsBoardMutations)
Divider()
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
Divider()
widthControl
Divider()
// Delete: File Delete's exact store path (`store.delete`), on the same widened target set
// Style above reads (`targetIDs`, `styleTarget`'s `Set<ItemID>` sibling below) the
// successor-selection rule is `delete(_:)`'s own, so this row gets it for free.
Button("Delete") {
store.delete(targetIDs)
}
.disabled(!store.acceptsBoardMutations)
}
/// The width stepper "the header context menu's Width control (stepper, uncapped) is the
@@ -208,6 +226,16 @@ struct LaneView: View {
return .items(store.selection.ids)
}
/// Delete's target set the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
/// because `store.delete(_:)` takes one directly (`TrashEntryRow.targetIDs`'s naming, reused here
/// on the live side).
private var targetIDs: Set<ItemID> {
guard store.selection.liveness == .live, store.selection.ids.contains(lane.id) else {
return [lane.id]
}
return store.selection.ids
}
private var headerContent: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
@@ -898,18 +926,48 @@ private struct CardFaceView: View {
// MARK: - Context menu
/// Open, Rename, Style, the quick-style recents row, Delete (11-command-nexus.md Context
/// menus) the style pair today.
/// Open, Rename, Style, the quick-style recents row, Delete 11-command-nexus.md Context
/// menus' Card row, in its order, complete as of m5.
@ViewBuilder
private var cardMenu: some View {
// m5-context-menus: Open (a twin of Board Open Card, always the clicked card alone
// a card window is tied to one card), Rename, and Delete land with the selection-model and
// delete cards, as rows of this same menu.
// Open: Board Open Card's pointer twin (`OpenCardCommand`), restricted to the clicked card
// alone "a card window is tied to one card" (11-command-nexus.md), so unlike Style and
// Delete below it, this row never widens to the selection; Open never opens multiple, even
// when the clicked card is part of one. It calls the very `openCard` closure the double-click
// gesture above uses, not `OpenCardCommand`'s mid-edit branches: there is no gesture path from
// a focused inline editor to *this* card's context menu, so there is nothing here to commit
// first only the plain open.
Button("Open") {
openCard(card.id)
}
Divider()
// Rename: Board Rename's exact store path (`BoardRenameCommand`) `beginRename(of:
// currentTitle:)`, seeded with the card's live title. The menu-bar item additionally requires
// this card to be the *sole* selection; a context menu already names its target by where it
// was invoked, so standard macOS practice it acts on the clicked card outright.
Button("Rename") {
store.transient.beginRename(of: card.id, currentTitle: card.title.value)
}
.disabled(!store.acceptsBoardMutations)
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
Divider()
// Delete: File Delete's exact store path (`store.delete`, `TrashCommands`'s twin), on the
// widened target set below (`targetIDs`) the successor-selection rule is `delete(_:)`'s own,
// so this row gets it for free.
Button("Delete") {
store.delete(targetIDs)
}
.disabled(!store.acceptsBoardMutations)
}
/// What this card's menu styles: the whole selection when this card is part of it, else this card
/// alone. Standard macOS right-clicking outside the selection acts on what was clicked.
/// What this card's menu acts on: the whole selection when this card is part of it, else this card
/// alone standard macOS context-menu targeting, shared by Style (`styleTarget`) and Delete
/// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked.
private var styleTarget: StyleTarget {
guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else {
return .items([card.id])
@@ -917,6 +975,16 @@ private struct CardFaceView: View {
return .items(store.selection.ids)
}
/// Delete's target set the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
/// because `store.delete(_:)` takes one directly (`TrashEntryRow.targetIDs`'s naming, reused here
/// on the live side).
private var targetIDs: Set<ItemID> {
guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else {
return [card.id]
}
return store.selection.ids
}
// MARK: - Title row
private var titleRow: some View {