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:
@@ -186,27 +186,46 @@ struct DuplicateBoardCommand: View {
|
|||||||
|
|
||||||
// MARK: - Reveal in Finder
|
// MARK: - Reveal in Finder
|
||||||
|
|
||||||
/// File ▸ Reveal in Finder — **the welcome scope** (11-command-nexus.md: "welcome: the selected
|
/// File ▸ Reveal in Finder — the welcome and board scopes (11-command-nexus.md: "Board window: the
|
||||||
/// recent's folder (disabled on unavailable rows) — the context-menu entry's required twin").
|
/// selection's folder(s), or the board root with nothing selected; … welcome: the selected recent's
|
||||||
|
/// folder (disabled on unavailable rows) — the context-menu entry's required twin").
|
||||||
///
|
///
|
||||||
/// It is here because the welcome row's context menu is: 11 files the menu-bar item as that entry's
|
/// It is here because the welcome row's context menu is: 11 files the menu-bar item as that entry's
|
||||||
/// *required* twin, so shipping one without the other would leave the context menu as the only path
|
/// *required* twin, so shipping one without the other would leave the context menu as the only path
|
||||||
/// to a command — the thing 04's contract forbids.
|
/// to a command — the thing 04's contract forbids.
|
||||||
///
|
///
|
||||||
// m5-context-menus, m6-card-window: the item's other two scopes. Board window — the selection's
|
/// **The board scope reveals either side of the trash boundary and ignores every lock.** Reveal "is
|
||||||
// folder(s), or the board root with nothing selected — arrives with the board's own context menus;
|
/// not edit-shaped and stays enabled on tombstoned selections" (04 ▸ The trash), and inspection is a
|
||||||
// card window — the card's folder, or the selected attachment's file when the attachments section is
|
/// read, so neither the read-only lock nor the focused-editor rule applies — the same posture the
|
||||||
// focused — with the card window. Each adds a focused value and a branch here; the welcome branch
|
/// trash row's own Reveal takes. A selection whose ids resolve to no folders (one the next reload
|
||||||
// does not move.
|
/// will drop) disables rather than falling back to the root: revealing the wrong thing is worse
|
||||||
|
/// than nothing, and only a genuinely empty selection means "the board".
|
||||||
|
///
|
||||||
|
// m6-card-window: the item's third scope — the card's folder, or the selected attachment's file
|
||||||
|
// when the attachments section is focused — adds a focused value and a branch here; the two below
|
||||||
|
// do not move.
|
||||||
struct RevealInFinderCommand: View {
|
struct RevealInFinderCommand: View {
|
||||||
|
|
||||||
|
@FocusedValue(\.boardStore) private var store
|
||||||
@FocusedValue(\.welcomeSelection) private var selection
|
@FocusedValue(\.welcomeSelection) private var selection
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button("Reveal in Finder") {
|
Button("Reveal in Finder") {
|
||||||
guard let url = selection?.url else { return }
|
NSWorkspace.shared.activateFileViewerSelecting(urls)
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
|
||||||
}
|
}
|
||||||
.disabled(selection?.canReveal != true)
|
.disabled(urls.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the item would reveal, and therefore whether it is enabled — one answer for both, the
|
||||||
|
/// codebase's usual shape. The board in front wins; the welcome branch stands when no board is.
|
||||||
|
private var urls: [URL] {
|
||||||
|
if let store {
|
||||||
|
let ids = store.selection.ids
|
||||||
|
guard !ids.isEmpty else { return [store.rootURL] }
|
||||||
|
return TrashModel.paths(of: ids, on: store.selection.liveness, in: store.snapshot)
|
||||||
|
.map { $0.folder(under: store.rootURL) }
|
||||||
|
}
|
||||||
|
guard let selection, selection.canReveal, let url = selection.url else { return [] }
|
||||||
|
return [url]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,14 @@ struct SaveAsTemplateCommand: View {
|
|||||||
// (05-card-window.md § Attachments) and of a whole-window Finder file drop. Validation will be scope
|
// (05-card-window.md § Attachments) and of a whole-window Finder file drop. Validation will be scope
|
||||||
// alone — a card window in front, the read-only lock aside — `BoardInfoCommand`'s shape for its own
|
// alone — a card window in front, the read-only lock aside — `BoardInfoCommand`'s shape for its own
|
||||||
// scope-only item.
|
// scope-only item.
|
||||||
|
//
|
||||||
|
// m5-context-menus, m6-card-window: the attachment row's own context menu is a second thing this
|
||||||
|
// milestone owes — Open, Remove (system Trash), Reveal in Finder (11-command-nexus.md ▸ Context
|
||||||
|
// menus' Attachment row), twinning the focused section's grammar keys (Return open / ⌫ remove — 05
|
||||||
|
// ▸ Attachments) and Reveal in Finder's attachments-focused scope, exactly the way this milestone's
|
||||||
|
// card and lane menus twin their own grammar and menu-bar commands: no new store method, no parallel
|
||||||
|
// implementation. There is no row view to hang a `.contextMenu` off yet, so nothing scaffolds here
|
||||||
|
// beyond this marker.
|
||||||
struct AddAttachmentCommand: View {
|
struct AddAttachmentCommand: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
FutureCommand(title: "Add Attachment…", key: "a", modifiers: [.shift, .command])
|
FutureCommand(title: "Add Attachment…", key: "a", modifiers: [.shift, .command])
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ import SwiftUI
|
|||||||
/// ### The lane's one context menu
|
/// ### The lane's one context menu
|
||||||
///
|
///
|
||||||
/// "The lane has one context menu (settled), invoked on the header or on lane empty space alike"
|
/// "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
|
/// (03-board-ui.md § Lane), so both surfaces attach the *same* `laneMenu`. It carries Rename, Style…,
|
||||||
/// quick-style recents row and the Width stepper today; Rename and Delete are m5's context-menus
|
/// the quick-style recents row, the Width stepper and Delete — 11-command-nexus.md ▸ Context menus'
|
||||||
/// card, and their rows go into that same builder rather than into a second menu.
|
/// Lane row, in its order, complete as of m5.
|
||||||
///
|
///
|
||||||
/// ### What is still a later card's
|
/// ### What is still a later card's
|
||||||
///
|
///
|
||||||
@@ -161,18 +161,36 @@ struct LaneView: View {
|
|||||||
|
|
||||||
// MARK: - The lane's one context menu
|
// MARK: - The lane's one context menu
|
||||||
|
|
||||||
/// Rename, Style…, the quick-style recents row, the Width control, Delete (11-command-nexus.md ▸
|
/// 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.
|
/// Context menus' Lane row, in its order, complete as of m5.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var laneMenu: some View {
|
private var laneMenu: some View {
|
||||||
// m5-context-menus: Rename (a twin of Board ▸ Rename) and Delete (a twin of File ▸ Delete)
|
// Rename: Board ▸ Rename's exact store path (`BoardRenameCommand`) — `beginRename(of:
|
||||||
// belong to the card that brings the selection model and the delete command; both are rows
|
// currentTitle:)`, seeded with the lane's live title. The menu-bar item additionally requires
|
||||||
// of *this* menu when they land, not of a second one.
|
// 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)
|
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
widthControl
|
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
|
/// 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)
|
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 {
|
private var headerContent: some View {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
|
Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
|
||||||
@@ -898,18 +926,48 @@ private struct CardFaceView: View {
|
|||||||
|
|
||||||
// MARK: - Context menu
|
// MARK: - Context menu
|
||||||
|
|
||||||
/// Open, Rename, Style…, the quick-style recents row, Delete (11-command-nexus.md ▸ Context
|
/// Open, Rename, Style…, the quick-style recents row, Delete — 11-command-nexus.md ▸ Context
|
||||||
/// menus) — the style pair today.
|
/// menus' Card row, in its order, complete as of m5.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var cardMenu: some View {
|
private var cardMenu: some View {
|
||||||
// m5-context-menus: Open (a twin of Board ▸ Open Card, always the clicked card alone —
|
// Open: Board ▸ Open Card's pointer twin (`OpenCardCommand`), restricted to the clicked card
|
||||||
// a card window is tied to one card), Rename, and Delete land with the selection-model and
|
// alone — "a card window is tied to one card" (11-command-nexus.md), so unlike Style… and
|
||||||
// delete cards, as rows of this same menu.
|
// Delete below it, this row never widens to the selection; Open never opens multiple, even
|
||||||
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What this card's menu styles: the whole selection when this card is part of it, else this card
|
Divider()
|
||||||
/// alone. Standard macOS — right-clicking outside the selection acts on what was clicked.
|
|
||||||
|
// 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 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 {
|
private var styleTarget: StyleTarget {
|
||||||
guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else {
|
guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else {
|
||||||
return .items([card.id])
|
return .items([card.id])
|
||||||
@@ -917,6 +975,16 @@ private struct CardFaceView: View {
|
|||||||
return .items(store.selection.ids)
|
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
|
// MARK: - Title row
|
||||||
|
|
||||||
private var titleRow: some View {
|
private var titleRow: some View {
|
||||||
|
|||||||
Reference in New Issue
Block a user