Files
lanework/Kanban/UI/Board/BoardCommands.swift
T
rzen 53bc71f7fb Materialize the trash — store, undo, and the container universe
Phase 2 swaps every consumer: Liveness and its ancestor walk are gone,
replaced by ItemContainer — a UUID set plus the container side it
lives on, presence the whole test, one selection boundary instead of
the old liveness law. Deletion stages by place: board cards move to
the trash at a store-minted head rank, trash-side delete is permanent
behind its confirmation, Delete Immediately skips the trash from
anywhere, lane delete captures the subtree and removes the folder.
Restore has no method at all — moveCards resolves members in either
container, so drag-out and cut-paste are the ordinary moves 13 calls
them, registering ordinary Move steps. The delete inverse moves the
card back to its captured lane and rank; redo replays the captured
trash rank, a value the gesture actually wrote; lane undo recreates
the subtree byte-faithfully in session. Purges register nothing —
where 13's trash section contradicts its own Rules on that, Rules
wins, filed for ruling. Staleness collapsed to present-or-absent: a
container is a path, so a foreign restore fails the delete step's
expectation structurally. Legacy tombstones migrate on the loose-file
tail hook, cards oldest-first so minting above top reproduces the
retired newest-first column, lanes returning live, one folded loss
row naming both directions. Put Back, restoreByDrag,
receiveRestoredCards, TrashEntry, and the kind machinery are deleted;
the trash column renders the container correctly with its full face
rework left to phase 3.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 17:47:56 -04:00

518 lines
24 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: ((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 four 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.
/// - **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 || soleSelectedCard != nil
}
/// The sole selected **board card**, or `nil`. A lane, a multi-selection and a trash
/// selection all answer `nil` — "everything edit-shaped is disabled on trash selections … Open
/// Card, Rename, Style…" (04 ▸ The trash), and a card window is tied to one card.
private var soleSelectedCard: ItemID? {
guard let store else { return nil }
let selection = store.selection
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
BoardStore.boardItem(id, in: store.snapshot)?.cardID != nil
else { return nil }
return id
}
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 = soleSelectedCard { open(card) }
}
}
// 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 tombstoned 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 quasi-lane is not in the live 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: - 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 = renameTarget else { return }
store.transient.beginRename(of: target.id, currentTitle: target.title)
}
.disabled(renameTarget == nil)
}
private var renameTarget: (id: ItemID, title: String?)? {
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,
let item = BoardStore.boardItem(id, in: store.snapshot)
else { return nil }
return (id: id, title: item.title)
}
}
// 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 tombstoned selection disables it rather than falling through to the board.**
/// Everything edit-shaped is disabled on tombstoned selections (04 ▸ The trash), and quietly
/// restyling the board because the user had a trashed card selected would be the silent retarget
/// 03 forbids.
struct BoardStyleCommand: View {
@FocusedValue(\.boardStore) private var store
var body: some View {
Button("Style…") {
guard let store, let target = styleTarget else { return }
store.transient.beginStyleEditor(for: target)
}
.keyboardShortcut("s", modifiers: [.option, .command])
.disabled(styleTarget == nil)
}
private var styleTarget: StyleTarget? {
guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection
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: store.snapshot).ids
return live.isEmpty ? nil : .items(live)
}
}
// 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)
}
}