diff --git a/Kanban/App/AppCommands.swift b/Kanban/App/AppCommands.swift index e58691d..ca83637 100644 --- a/Kanban/App/AppCommands.swift +++ b/Kanban/App/AppCommands.swift @@ -186,27 +186,46 @@ struct DuplicateBoardCommand: View { // MARK: - Reveal in Finder -/// File ▸ Reveal in Finder — **the welcome scope** (11-command-nexus.md: "welcome: the selected -/// recent's folder (disabled on unavailable rows) — the context-menu entry's required twin"). +/// File ▸ Reveal in Finder — the welcome and board scopes (11-command-nexus.md: "Board window: the +/// 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 /// *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. /// -// m5-context-menus, m6-card-window: the item's other two scopes. Board window — the selection's -// folder(s), or the board root with nothing selected — arrives with the board's own context menus; -// card window — the card's folder, or the selected attachment's file when the attachments section is -// focused — with the card window. Each adds a focused value and a branch here; the welcome branch -// does not move. +/// **The board scope reveals either side of the trash boundary and ignores every lock.** Reveal "is +/// not edit-shaped and stays enabled on tombstoned selections" (04 ▸ The trash), and inspection is a +/// read, so neither the read-only lock nor the focused-editor rule applies — the same posture the +/// trash row's own Reveal takes. A selection whose ids resolve to no folders (one the next reload +/// 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 { + @FocusedValue(\.boardStore) private var store @FocusedValue(\.welcomeSelection) private var selection var body: some View { Button("Reveal in Finder") { - guard let url = selection?.url else { return } - NSWorkspace.shared.activateFileViewerSelecting([url]) + NSWorkspace.shared.activateFileViewerSelecting(urls) } - .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] } } diff --git a/Kanban/App/FutureCommands.swift b/Kanban/App/FutureCommands.swift index bd971d1..eb2f096 100644 --- a/Kanban/App/FutureCommands.swift +++ b/Kanban/App/FutureCommands.swift @@ -72,6 +72,14 @@ struct SaveAsTemplateCommand: View { // (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 // 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 { var body: some View { FutureCommand(title: "Add Attachment…", key: "a", modifiers: [.shift, .command]) diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index 746aa4e..c8676d3 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -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` 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` + /// because `store.delete(_:)` takes one directly (`TrashEntryRow.targetIDs`'s naming, reused here + /// on the live side). + private var targetIDs: Set { + 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` + /// because `store.delete(_:)` takes one directly (`TrashEntryRow.targetIDs`'s naming, reused here + /// on the live side). + private var targetIDs: Set { + 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 {