Card 737a949f: "Add an option to card context menu to copy a link to the card folder." Implements the design ruling verbatim. - New context-menu row "Copy Link" (CardFaceView.boardMenu, board side only — trash cards are excluded, matching "sole selected live card"). Writes the clicked card's folder in one pasteboard item carrying two representations: the file:// URL under .fileURL and the plain absolute path under .string (FolderLinkPasteboard.swift). Enabled on a sole selected live card; disabled on a multi-selection and wherever edit-shaped actions already disable, per the ruling. Also exposed as a VoiceOver custom action alongside its siblings. - Menu-bar twin: Board ▸ Copy Link (BoardCommands.swift, CopyLinkCommand), no default chord — the every-function-a-menu-item contract in DESIGN/11-command-nexus.md is still current, so this is the twin that contract calls for, homed the way Open Card/Rename/ Style… already are. - DESIGN/11-command-nexus.md: new Board-menu row and an updated Card context-menu row. - Tests (KanbanTests/CopyLinkTests.swift): the target predicate's enablement (sole card / multi-selection / lane / trash / empty / inline-editing), the pasteboard write's exact bytes via a fake pasteboard (both representations, exact folder URL), and a disabled-target no-op. Caught and fixed during self-review: an early version read the context menu's widened-selection helper (targetIDs) inside the Copy Link row's .disabled(...), which reads store.selection. Since .contextMenu's content closure is evaluated on every ordinary body pass (not only when the menu opens), that resubscribed every card face on the board to every selection change — the exact O(board) regression RENDER-INSTRUMENTATION.md's isSelected/selectedCount split exists to prevent, caught by BoardRenderPerformanceTests and MarqueeRenderCostTests. Fixed by reading the already-hoisted, non-Observable `selectedCount` parameter instead, which answers the same "how many ride along" question at zero extra subscription cost. xcodegen generate, the Kanban scheme build, and the full KanbanTests suite (2816 tests) are clean. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
602 lines
28 KiB
Swift
602 lines
28 KiB
Swift
import Observation
|
|
import SwiftUI
|
|
|
|
// MARK: - The focused board
|
|
|
|
/// The frontmost board window's store, published into the focus system by `BoardWindowHost` so menu
|
|
/// items can act on "the board in front" without the app model keeping a which-window-is-key
|
|
/// register of its own.
|
|
///
|
|
/// `focusedSceneValue` rather than `focusedValue`: the value is the *window's*, not any particular
|
|
/// control's, so it stays available whatever inside the board has keyboard focus — which is what a
|
|
/// menu item validating against the selection needs.
|
|
struct FocusedBoardStoreKey: FocusedValueKey {
|
|
typealias Value = BoardStore
|
|
}
|
|
|
|
extension FocusedValues {
|
|
var boardStore: BoardStore? {
|
|
get { self[FocusedBoardStoreKey.self] }
|
|
set { self[FocusedBoardStoreKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
// MARK: - The focused board's card opener
|
|
|
|
/// How a menu item opens a card window — the board window's own `openCard` closure, published into
|
|
/// the focus system beside its store.
|
|
///
|
|
/// **It exists because a card window's identity is `(board, card)` and only `BoardWindowHost` holds
|
|
/// the board half** (02-architecture.md § Windows). Board ▸ Open Card has no window of its own to
|
|
/// derive that from, and the board *view* cannot supply it either — the item is in the menu bar. So
|
|
/// the closure travels the same route the store, the popover flag and the purge-alert host already
|
|
/// do.
|
|
///
|
|
/// A small reference type rather than a value, for `BoardInfoPresentation`'s reason: it is the
|
|
/// window's, one per window, and it is filled in after the board has loaded — `@Observable` so the
|
|
/// menu item's validation notices when it is.
|
|
@MainActor
|
|
@Observable
|
|
final class CardOpener {
|
|
|
|
/// `nil` until the window's board has loaded, which is also exactly when Open Card has nothing
|
|
/// to act on.
|
|
var open: (@MainActor (ItemID) -> Void)?
|
|
|
|
init() {}
|
|
}
|
|
|
|
struct FocusedCardOpenerKey: FocusedValueKey {
|
|
typealias Value = CardOpener
|
|
}
|
|
|
|
extension FocusedValues {
|
|
var cardOpener: CardOpener? {
|
|
get { self[FocusedCardOpenerKey.self] }
|
|
set { self[FocusedCardOpenerKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
// MARK: - Shared validation
|
|
|
|
/// The two conditions **every** board-mutating menu item disables on, in one place.
|
|
///
|
|
/// - **The read-only lock** (02-architecture.md § The lock's scope): "every mutating command
|
|
/// disables via menu validation" across every window sharing the store. An item that is going to
|
|
/// be refused should not look available.
|
|
/// - **The focused-editor rule** (04-interactions.md ▸ Grammar, settled): "while an inline title
|
|
/// editor — rename or the new-card placeholder — is focused, board-scoped menu commands (Delete,
|
|
/// New Card, Paste, Move, Style, …) disable via menu validation" and the keyboard belongs to the
|
|
/// text domain. The one carve-out the design names is Open Card ⌘↩, which stays enabled to commit
|
|
/// the edit and open the window — `OpenCardCommand` below is therefore the one item that
|
|
/// deliberately does not read this property.
|
|
///
|
|
/// Stated once rather than repeated per item, because the interesting failure mode is an item that
|
|
/// quietly forgets half of it.
|
|
extension BoardStore {
|
|
var acceptsBoardMutations: Bool {
|
|
!isReadOnly && !isEditingInline
|
|
}
|
|
}
|
|
|
|
/// **The caret-chords rule, as one expression** (04-interactions.md ▸ Grammar, settled):
|
|
///
|
|
/// > Board ▸ Move Left/Move Right ⌘←/⌘→ and the width pair ⌥⌘←/⌥⌘→ disable via menu validation
|
|
/// > whenever *any* text control has keyboard focus — inline title editors, the board search field,
|
|
/// > board-popover fields (rename, git identity, remote), and card-window fields — because an
|
|
/// > enabled menu key equivalent fires before the field ever sees the key, and ⌘←/⌘→ are the
|
|
/// > standard line-start/end caret chords.
|
|
///
|
|
/// Four text surfaces, answered three ways, and only two of them are here:
|
|
///
|
|
/// - **Inline title editors** are `acceptsBoardMutations`', through the focused-editor rule — a
|
|
/// broader lockdown that already covers these two items.
|
|
/// - **The board popover's fields** are covered by disabling while the popover is open at all —
|
|
/// coarser than per-field focus, but it is a configuration surface (04's carve-out) and no lane
|
|
/// move belongs under it. That clause covered **every** configuration field the app had since
|
|
/// 2026-08-07: the 2026-07-31 split moved most of them to a board settings sheet, whose flag this
|
|
/// function read as a third disjunct, and the reversal brought them back under the popover's own
|
|
/// open-at-all rule — branch creation, commit identity, and pro-m2's remote URL and credentials
|
|
/// among them, all in the popover's Git tab at the time. The Git tab itself retired with
|
|
/// app-managed git (`strategy/01-git-excision.md`, 2026-08-08); the popover's open-at-all rule
|
|
/// now guards only `BoardInfoTabView`'s ordinary fields.
|
|
/// - **The search field** is per-focus and exact (`BoardSearchPresentation.isFocused`), which it has
|
|
/// to be: the field's own rule is that board commands *stay enabled* while it holds the keyboard
|
|
/// (04 § Search), so these two are the narrow exception to it and nothing coarser would do.
|
|
/// - **Card-window fields** need nothing: those windows never publish a `boardStore`, so both items
|
|
/// are already scopeless there.
|
|
///
|
|
/// Stated once because the two command groups must not drift: a rule with two implementations is a
|
|
/// rule with two chances to forget a surface.
|
|
@MainActor
|
|
func caretChordsYield(
|
|
boardInfo: BoardInfoPresentation?,
|
|
search: BoardSearchPresentation?
|
|
) -> Bool {
|
|
boardInfo?.isPresented == true || search?.isFocused == true
|
|
}
|
|
|
|
// MARK: - Open Card
|
|
|
|
/// Board ▸ Open Card (⌘↩) — 11-command-nexus.md's first Board row, and **the one board command
|
|
/// enabled mid-edit** (04-interactions.md ▸ Grammar's focused-editor rule).
|
|
///
|
|
/// Two contexts, exactly as the Nexus scopes them: "sole selected live card; during an inline title
|
|
/// edit (placeholder or rename), commits it and opens". So this is the single item that must *not*
|
|
/// read `acceptsBoardMutations` — the open-editor half of that property is the very state it exists
|
|
/// to serve. It does not read the read-only lock either: opening a window is not a mutation, and the
|
|
/// commit path it may run through refuses on its own with the lock's row already standing.
|
|
///
|
|
/// The mid-edit branches mirror the pointer twins exactly rather than reimplementing them — the
|
|
/// placeholder's is `NewCardStubView.commit()` (read the lane, commit, re-select the surviving lane)
|
|
/// and the rename's is `LaneView`'s `onCommitAndOpen` (a lane rename just commits; only a card has a
|
|
/// window to open). Both stores' commits no-op against a closed editor, so this item and
|
|
/// `InlineTitleField`'s own ⌘↩ fallback compose without acting twice.
|
|
struct OpenCardCommand: View {
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
@FocusedValue(\.cardOpener) private var opener
|
|
|
|
var body: some View {
|
|
Button("Open Card") {
|
|
open()
|
|
}
|
|
.keyboardShortcut(.return, modifiers: .command)
|
|
.disabled(!isEnabled)
|
|
}
|
|
|
|
private var isEnabled: Bool {
|
|
guard let store, opener?.open != nil else { return false }
|
|
return store.isEditingInline || store.openCardTarget != nil
|
|
}
|
|
|
|
private func open() {
|
|
guard let store, let open = opener?.open else { return }
|
|
|
|
if let placeholder = store.transient.newCardPlaceholder {
|
|
// The lane is read before the commit, because every discard path clears the overlay that
|
|
// holds it — and re-checked after, because one of those paths is *the lane vanished*.
|
|
let lane = placeholder.laneID
|
|
let created = store.commitPlaceholder()
|
|
if store.snapshot.lanes.contains(where: { $0.id == lane }) {
|
|
store.select([lane], in: .board)
|
|
}
|
|
if let created { open(created) }
|
|
return
|
|
}
|
|
|
|
if let editor = store.transient.renameEditor {
|
|
let target = editor.targetID
|
|
let isCard = BoardStore.boardItem(target, in: store.snapshot)?.cardID != nil
|
|
store.commitRename()
|
|
if isCard { open(target) }
|
|
return
|
|
}
|
|
|
|
if let card = store.openCardTarget { open(card) }
|
|
}
|
|
}
|
|
|
|
// MARK: - The three edit-shaped targets, as pure predicates
|
|
|
|
/// **Everything edit-shaped refuses a trash selection** (04-interactions.md ▸ The trash: "Everything
|
|
/// edit-shaped is disabled on trash selections — Open Card, Rename, Style…"), and each of the three
|
|
/// answers that with one expression used for both its `disabled` state and its action — the
|
|
/// `newCardTarget` idiom, for its reason: two derivations of a rule are two chances to disagree.
|
|
///
|
|
/// They live on the store rather than inside the three menu rows so the grammar can be pinned
|
|
/// without a menu (`SelectionGrammarTests`) — the same reason `TrashModel`'s validation is a pure
|
|
/// function of a snapshot and a selection. A view-private predicate is a rule nobody can test.
|
|
extension BoardStore {
|
|
|
|
/// Board ▸ Open Card's target: the sole selected **board card**, or `nil`. A lane, a
|
|
/// multi-selection and a trash selection all answer `nil` — a card window is tied to one card,
|
|
/// and trash cards don't open ("double-click stops at selection; move it out first" — 03 §
|
|
/// Trash).
|
|
///
|
|
/// Deliberately free of `acceptsBoardMutations`: opening a window is not a mutation, and the
|
|
/// item's own mid-edit branch is the focused-editor rule's one carve-out (`OpenCardCommand`).
|
|
var openCardTarget: ItemID? {
|
|
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
|
|
Self.boardItem(id, in: snapshot)?.cardID != nil
|
|
else { return nil }
|
|
return id
|
|
}
|
|
|
|
/// Board ▸ Rename's target: the sole selected board item, card or lane, with the title to seed
|
|
/// the editor with — or `nil`. A trash selection never enables it, which `ItemReferenceSet`'s
|
|
/// container answers directly.
|
|
var renameTarget: (id: ItemID, title: String?)? {
|
|
guard acceptsBoardMutations else { return nil }
|
|
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
|
|
let item = Self.boardItem(id, in: snapshot)
|
|
else { return nil }
|
|
return (id: id, title: item.title)
|
|
}
|
|
|
|
/// Board ▸ Style…'s target: the selected board items, or **the board itself** when nothing is
|
|
/// selected — "Board window: selected cards or lane; nothing selected = the board".
|
|
///
|
|
/// **A trash selection disables it rather than falling through to the board**: quietly restyling
|
|
/// the board because the user had a trashed card selected would be the silent retarget 03
|
|
/// forbids.
|
|
var boardStyleTarget: StyleTarget? {
|
|
guard acceptsBoardMutations else { return nil }
|
|
guard !selection.isEmpty else { return .board }
|
|
guard selection.container == .board else { return nil }
|
|
// Re-resolved against the snapshot on the way in, so the session starts out holding only
|
|
// items that render — the same universe its own reload rule will hold it to.
|
|
let live = selection.resolved(against: snapshot).ids
|
|
return live.isEmpty ? nil : .items(live)
|
|
}
|
|
}
|
|
|
|
// MARK: - Within-lane sort
|
|
|
|
/// Board ▸ Move Up / Move Down (⌥⌘↑/⌥⌘↓) — the within-lane sort (11-command-nexus.md;
|
|
/// 04-interactions.md ▸ The map, where the chord is settled as the "⌥⌘ modifies" family's vertical
|
|
/// half alongside the lane-width pair).
|
|
///
|
|
/// **Validation and action read one answer** (`BoardStore.sortPlan`), the width pair's rule: the
|
|
/// items disable on everything the design calls inert — a lane selection, a trash selection, a
|
|
/// card selection spanning lanes ("cards never change lanes by ⌘-arrow") — and additionally on a
|
|
/// block already at its lane's end, where the only outcome would be a silent no-op.
|
|
///
|
|
/// The direction matters to that answer, which is why each item asks separately: a block at the top
|
|
/// disables Move Up while Move Down stays live.
|
|
struct MoveCardCommands: View {
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
|
|
var body: some View {
|
|
Button("Move Up") {
|
|
store?.sortSelection(.up)
|
|
}
|
|
.keyboardShortcut(.upArrow, modifiers: [.option, .command])
|
|
.disabled(!canSort(.up))
|
|
|
|
Button("Move Down") {
|
|
store?.sortSelection(.down)
|
|
}
|
|
.keyboardShortcut(.downArrow, modifiers: [.option, .command])
|
|
.disabled(!canSort(.down))
|
|
}
|
|
|
|
private func canSort(_ direction: SortMath.Direction) -> Bool {
|
|
guard let store, store.acceptsBoardMutations else { return false }
|
|
return store.sortPlan(direction) != nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Lane moves
|
|
|
|
/// Board ▸ Move Left / Move Right (⌘←/⌘→) — "Lane selection only (one slot; never into the trash)"
|
|
/// (11-command-nexus.md), closing 10-accessibility.md's lane-move defect (04 ▸ Accessibility).
|
|
///
|
|
/// **Sole lane, deliberately.** The width pair one row below explicitly batches over a multi-lane
|
|
/// selection; this row's inventory line says "Lane selection only" with no batching clause, and a
|
|
/// multi-lane move has no single unambiguous meaning ("one slot" for a discontiguous pair is not one
|
|
/// answer). So the items validate on exactly one selected live lane.
|
|
///
|
|
/// **Never into the trash** costs nothing: the column is not in the lane order, so a step
|
|
/// past the last real lane is simply off the end — which is also the disable rule at the walls,
|
|
/// following the width stepper's floor style rather than letting the store no-op silently.
|
|
///
|
|
/// **Caret chords yield to any focused text control** (04-interactions.md ▸ Grammar, settled):
|
|
/// ⌘←/⌘→ are the standard line-start/end chords, and an enabled key equivalent fires before a
|
|
/// field ever sees the key. Which surfaces that covers, and how each is answered, is
|
|
/// `caretChordsYield(boardInfo:search:)`'s doc comment — shared verbatim with the
|
|
/// width pair below.
|
|
struct MoveLaneCommands: View {
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
@FocusedValue(\.boardInfo) private var boardInfo
|
|
@FocusedValue(\.boardSearch) private var search
|
|
|
|
var body: some View {
|
|
Button("Move Left") {
|
|
move(by: -1)
|
|
}
|
|
.keyboardShortcut(.leftArrow, modifiers: .command)
|
|
.disabled(yieldsCaretChords || destination(-1) == nil)
|
|
|
|
Button("Move Right") {
|
|
move(by: 1)
|
|
}
|
|
.keyboardShortcut(.rightArrow, modifiers: .command)
|
|
.disabled(yieldsCaretChords || destination(1) == nil)
|
|
}
|
|
|
|
private var yieldsCaretChords: Bool {
|
|
caretChordsYield(boardInfo: boardInfo, search: search)
|
|
}
|
|
|
|
/// The sole selected live lane and the display slot one step would put it in — `nil` when there
|
|
/// is no such lane or it is already at that wall.
|
|
///
|
|
/// `from + delta` **is** the index `moveLane` wants: that method counts display positions among
|
|
/// the live lanes *with the moved lane already removed*, so inserting at `from - 1` puts the lane
|
|
/// before its old predecessor and at `from + 1` after its old successor — one slot each way. The
|
|
/// convention is easy to get backwards, which is why it is pinned by a test.
|
|
private func destination(_ delta: Int) -> (lane: ItemID, index: Int)? {
|
|
guard let store, store.acceptsBoardMutations else { return nil }
|
|
let selection = store.selection
|
|
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first else { return nil }
|
|
let lanes = SelectionGrammar.lanes(in: store.snapshot)
|
|
// A card id is in no lane order, so this is also the "not a lane" test.
|
|
guard let from = lanes.firstIndex(of: id) else { return nil }
|
|
let to = from + delta
|
|
guard lanes.indices.contains(to) else { return nil }
|
|
return (id, to)
|
|
}
|
|
|
|
private func move(by delta: Int) {
|
|
guard let store, let target = destination(delta) else { return }
|
|
store.moveLane(target.lane, toIndex: target.index)
|
|
}
|
|
}
|
|
|
|
// MARK: - Creation items
|
|
|
|
/// File ▸ New Card (⌘N) and File ▸ New Lane (⇧⌘N) — 11-command-nexus.md's two creation rows.
|
|
///
|
|
/// **New Card resolves its target through `NewCardTarget`**, the ⌘N target rule as a pure function,
|
|
/// and uses the *same* answer for its `disabled` state as for its action: a `nil` resolution is the
|
|
/// zero-lane board, where "card creation and card paste have no target — New Card, Return-creation,
|
|
/// and Paste with a card payload disable via menu validation until a lane exists"
|
|
/// (04-interactions.md ▸ The map). Two derivations of that condition would be two chances to
|
|
/// disagree.
|
|
///
|
|
/// **New Lane is enabled whenever the board accepts writes.** It is the way *out* of a zero-lane
|
|
/// board — "New Lane (⇧⌘N) is one way in" — so it can have no selection precondition at all. The
|
|
/// lane it creates is untitled and no editor opens on it; see `BoardStore.createLane`.
|
|
struct BoardCreationCommands: View {
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
|
|
var body: some View {
|
|
Button("New Card") {
|
|
store?.beginNewCard()
|
|
}
|
|
.keyboardShortcut("n", modifiers: .command)
|
|
.disabled(store?.newCardTarget == nil)
|
|
|
|
Button("New Lane") {
|
|
store?.createLane()
|
|
}
|
|
.keyboardShortcut("n", modifiers: [.shift, .command])
|
|
.disabled(store?.acceptsBoardMutations != true)
|
|
}
|
|
}
|
|
|
|
extension BoardStore {
|
|
|
|
/// Where ⌘N would file a card, or `nil` when it cannot — a board that refuses writes, an inline
|
|
/// editor holding the keyboard, or a board with no lanes.
|
|
///
|
|
/// On the store rather than private to the menu row because **the toolbar's New Card item is the
|
|
/// same command** (03-board-ui.md ▸ Toolbar: toolbar items mirror menu commands), and a second
|
|
/// derivation of this rule would be a second chance to disagree with it — the same reason the row
|
|
/// itself uses one answer for both its action and its validation.
|
|
var newCardTarget: NewCardTarget.Resolution? {
|
|
guard acceptsBoardMutations else { return nil }
|
|
return NewCardTarget.resolve(
|
|
selection: selection,
|
|
lastActiveLaneID: transient.lastActiveLaneID,
|
|
snapshot: snapshot
|
|
)
|
|
}
|
|
|
|
/// ⌘N's action — opening the new-card placeholder wherever `newCardTarget` says. Shared with the
|
|
/// toolbar item that mirrors the row.
|
|
func beginNewCard() {
|
|
guard let target = newCardTarget else { return }
|
|
transient.beginPlaceholder(inLane: target.laneID, after: target.anchorCardID)
|
|
}
|
|
}
|
|
|
|
// MARK: - Board Info
|
|
|
|
/// File ▸ Board Info (⌘I) — the board popover's keyboard path (11-command-nexus.md; class **C**,
|
|
/// whose "keyboard path is reachability (Board Info ⌘I + Tab-reachable controls), not bindings").
|
|
///
|
|
/// **It toggles**, because the popover's other entry point is a disclosure widget and a shortcut
|
|
/// that could only ever open would leave the surface with no keyboard way out.
|
|
///
|
|
/// Two focused values, both required: the store is what makes this a *board window* item (the
|
|
/// scope 11 gives the row), and the presentation is the window's own popover flag — see
|
|
/// `BoardInfoPresentation` for why the flag is per window rather than per board.
|
|
///
|
|
/// **Validation is scope and nothing else.** Neither the read-only lock nor the focused-editor rule
|
|
/// closes it, unlike every mutating item above: the popover is *configuration* (04-interactions.md
|
|
/// ▸ The map's carve-out), a locked board is exactly when a user wants to read its title and
|
|
/// styling, and the controls inside disable themselves.
|
|
struct BoardInfoCommand: View {
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
@FocusedValue(\.boardInfo) private var presentation
|
|
|
|
var body: some View {
|
|
Button("Board Info") {
|
|
presentation?.toggle()
|
|
}
|
|
.keyboardShortcut("i", modifiers: .command)
|
|
.disabled(store == nil || presentation == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Copy Link
|
|
|
|
extension BoardStore {
|
|
|
|
/// Board ▸ Copy Link's target: the sole selected **live board card**'s folder URL, or `nil` — "a
|
|
/// link is singular" (design ruling 2026-08-09, card 737a949f "Add an option to card context menu
|
|
/// to copy a link to the card folder"), the same board-card-only shape `openCardTarget` answers
|
|
/// for its own reason: a lane, a multi-selection and a trash selection all disable it.
|
|
///
|
|
/// **Additionally gated on `acceptsBoardMutations`**, unlike `openCardTarget` — the ruling asks
|
|
/// for this in as many words ("disabled … wherever edit-shaped actions already disable"), even
|
|
/// though writing a link to the pasteboard changes nothing on disk. Worth flagging rather than
|
|
/// silently matching: Copy Link could have stayed live under the lock the way Reveal in Finder
|
|
/// does ("not edit-shaped … inspecting a folder before a purge is exactly the errand it exists
|
|
/// for" — `CardFaceView.trashMenu`'s doc), but the ruling states the gate explicitly, so it is
|
|
/// implemented as written rather than re-litigated here (the card's DECISIONS comment flags it).
|
|
var copyLinkTarget: URL? {
|
|
guard acceptsBoardMutations else { return nil }
|
|
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
|
|
Self.boardItem(id, in: snapshot)?.cardID != nil
|
|
else { return nil }
|
|
return ItemPath.resolve([id], in: .board, snapshot: snapshot).first?.folder(under: rootURL)
|
|
}
|
|
|
|
/// Board ▸ Copy Link's action, and the menu-bar row's one write. The context menu's own Copy Link
|
|
/// row (`CardFaceView.copyLink`) does not call this: a context menu names its target by where it
|
|
/// was invoked (`targetIDs`'s standing rule, shared with Style… and Delete), not by the live
|
|
/// selection this property reads.
|
|
func copyCardLink(to pasteboard: FolderLinkPasteboard = SystemFolderLinkPasteboard()) {
|
|
guard let folder = copyLinkTarget else { return }
|
|
pasteboard.write(link: folder)
|
|
}
|
|
}
|
|
|
|
/// Board ▸ Copy Link — no default chord (11-command-nexus.md; design ruling 2026-08-09, card
|
|
/// 737a949f). The menu-bar twin the ruling asks for ("follow the codebase's CURRENT command
|
|
/// conventions … if the every-function-a-menu-item contract still governs, add the item … in the
|
|
/// matching menu"): 11-command-nexus.md's contract is still in force, so this exists beside the card
|
|
/// context menu's own row (`CardFaceView.boardMenu`) rather than instead of it.
|
|
///
|
|
/// One answer (`copyLinkTarget`) for both the row's `disabled` state and its action, `BoardRenameCommand`'s
|
|
/// shape.
|
|
struct CopyLinkCommand: View {
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
|
|
var body: some View {
|
|
Button("Copy Link") {
|
|
store?.copyCardLink()
|
|
}
|
|
.disabled(store?.copyLinkTarget == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Rename
|
|
|
|
/// Board ▸ Rename — no default chord, deliberately (11-command-nexus.md: "— (cards: Return in
|
|
/// place)"), and remappable like any other item.
|
|
///
|
|
/// It "exists for completeness and remapping" for cards, whose real path is Return, and it is a
|
|
/// **lane's only rename path**: Return on a lane creates a card, so without this item a lane could
|
|
/// never be renamed at all (04-interactions.md ▸ Selection).
|
|
///
|
|
/// Validation is the sole-selected-board-item rule — card or lane, either kind, exactly one. A
|
|
/// trash selection never enables it: "everything edit-shaped is disabled on trash selections"
|
|
/// (04 ▸ The trash), which `ItemReferenceSet`'s container answers directly.
|
|
struct BoardRenameCommand: View {
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
|
|
var body: some View {
|
|
Button("Rename") {
|
|
guard let store, let target = store.renameTarget else { return }
|
|
store.transient.beginRename(of: target.id, currentTitle: target.title)
|
|
}
|
|
.disabled(store?.renameTarget == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Style
|
|
|
|
/// Board ▸ Style… (⌥⌘S) — the style editor's menu-bar anchor (11-command-nexus.md;
|
|
/// 03-board-ui.md § Styling ▸ Controls).
|
|
///
|
|
/// **Selection-aware, with the board as the empty-selection case**: "Board window: selected cards or
|
|
/// lane; nothing selected = the board". The item does not present anything itself — it opens the
|
|
/// session (`TransientBoardState.beginStyleEditor`) and the board window's anchors decide which
|
|
/// surface hosts the popover, which is what keeps the presentation attached to what is being styled
|
|
/// rather than to the menu bar.
|
|
///
|
|
/// Validation is `acceptsBoardMutations` — the lock and the focused-editor rule, the latter naming
|
|
/// Style in its own list of board-scoped commands (04-interactions.md ▸ Grammar) — plus one rule of
|
|
/// its own: **a trash selection disables it rather than falling through to the board**
|
|
/// (`BoardStore.boardStyleTarget`, where both halves live).
|
|
struct BoardStyleCommand: View {
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
|
|
var body: some View {
|
|
Button("Style…") {
|
|
guard let store, let target = store.boardStyleTarget else { return }
|
|
store.transient.beginStyleEditor(for: target)
|
|
}
|
|
.keyboardShortcut("s", modifiers: [.option, .command])
|
|
.disabled(store?.boardStyleTarget == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Lane width items
|
|
|
|
/// Increase / Decrease Lane Width — **the width stepper's keyboard face** (03-board-ui.md § Lane,
|
|
/// 11-command-nexus.md), and therefore the *re-divide* mechanism: each step re-divides the existing
|
|
/// window width across the new unit total, compressing the siblings. They never touch the window's
|
|
/// size — window-growing behaviour belongs to the right-edge drag alone — and they are uncapped, so
|
|
/// widths beyond what the screen can fit stay reachable here even though the drag hard-stops.
|
|
///
|
|
/// **They batch over a multi-lane selection** (03-board-ui.md § Lane, settled — the styling
|
|
/// precedent): each selected lane steps one unit, one gesture, one commit
|
|
/// (`BoardStore.stepLaneWidths`); the context-menu stepper stays single-lane by nature. A card
|
|
/// selection, a trash-side selection and an empty one all disable them. Decrease additionally
|
|
/// disables when **every** selected lane is at the one-unit floor — a mixed batch stays live, its
|
|
/// floor members simply holding (the style batch's silent skip).
|
|
///
|
|
/// **⌥⌘←/⌥⌘→ yield to any focused text control** (04-interactions.md ▸ Grammar's caret-chords
|
|
/// rule) — see `MoveLaneCommands`, whose ⌘←/⌘→ carry the same obligation and whose doc comment
|
|
/// carries the mechanism.
|
|
struct LaneWidthCommands: View {
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
@FocusedValue(\.boardInfo) private var boardInfo
|
|
@FocusedValue(\.boardSearch) private var search
|
|
|
|
var body: some View {
|
|
Button("Increase Lane Width") {
|
|
step(by: 1)
|
|
}
|
|
.keyboardShortcut(.rightArrow, modifiers: [.option, .command])
|
|
.disabled(yieldsCaretChords || selectedLanes.isEmpty)
|
|
|
|
Button("Decrease Lane Width") {
|
|
step(by: -1)
|
|
}
|
|
.keyboardShortcut(.leftArrow, modifiers: [.option, .command])
|
|
.disabled(yieldsCaretChords || !canDecrease)
|
|
}
|
|
|
|
private var yieldsCaretChords: Bool {
|
|
caretChordsYield(boardInfo: boardInfo, search: search)
|
|
}
|
|
|
|
/// The selected live lanes, in snapshot order — the batch, and the items' validation.
|
|
///
|
|
/// The lock and the open-editor rule are folded in through `acceptsBoardMutations` rather than
|
|
/// left for the write to refuse: an item that is going to fail should not look available.
|
|
private var selectedLanes: [Lane] {
|
|
guard let store, store.acceptsBoardMutations else { return [] }
|
|
let selection = store.selection
|
|
guard selection.container == .board, !selection.isEmpty else { return [] }
|
|
return store.snapshot.lanes.filter { selection.ids.contains($0.id) }
|
|
}
|
|
|
|
/// `width` is ≥ 1 (03-board-ui.md § Lane), and an item whose only outcome is a no-op reads
|
|
/// better disabled than dead — which for a batch means *some* member must have room to shrink.
|
|
private var canDecrease: Bool {
|
|
selectedLanes.contains { LaneLayoutMath.displayUnits(of: $0) > 1 }
|
|
}
|
|
|
|
private func step(by delta: Int) {
|
|
guard let store else { return }
|
|
let lanes = selectedLanes
|
|
guard !lanes.isEmpty else { return }
|
|
store.stepLaneWidths(Set(lanes.map(\.id)), by: delta)
|
|
}
|
|
}
|