Files
lanework/Kanban/UI/Board/NewCardTarget.swift
T
rzen 7eee0934ee Implement the hybrid clipboard with deferred cut
⌘X/⌘C/⌘V for cards and lanes per 04-interactions.md § Clipboard:

- ClipboardStore stages full folder snapshots eagerly at the gesture into
  Application Support (at most the current copy; sweep at launch and on
  each copy purges what the pasteboard no longer references; a copy made
  before quitting pastes whole after restart) and writes the pasteboard a
  JSON manifest — every entry embedding its index.md, lane entries their
  cards' too — plus plain-text titles.
- Cut is Finder-style deferred: items dim in place off pendingCut, void on
  pasteboard takeover (changeCount, no timers), source-board close, or
  per-item external tombstoning; the first armed paste moves the surviving
  originals whole (tombstoned interior cards land in the destination's
  trash), a second paste materializes copies from staging.
- Paste anchors by the shared flatten-order rule (NewCardTarget's anchor,
  extracted); a tombstoned selection never anchors; lane paste reaches the
  right end and stays enabled on a zero-lane board; paste into the source
  board is the within-board lane duplicate; copies keep created, take
  fresh GUIDs, and strip tombstoned cards; trash-sourced copies strip
  deleted: at materialization; ⌘X is disabled on the trash side.
- A degraded paste is loud, never silent: staging gone → the embedded
  index.md fallback lands content-intact, attachments absent, and a
  BannerCenter-phrased row names what was lost.
- The standard Edit items validate through conditionally-attached
  onCommand handlers, so AppKit's enablement mirrors the availability
  predicates; text fields keep their own clipboard while focused.

879 unit tests (68 new).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 22:18:58 -04:00

110 lines
6.5 KiB
Swift

/// 04-interactions.md's **⌘N target rule** (settled), as a pure function of the three things it
/// reads — the selection, the last-active lane, and the snapshot (`NewCardTargetTests`).
///
/// The rule verbatim, and each clause's branch below:
///
/// > with a card selected, the new card is created in that card's lane, immediately after it
/// > (paste-anchor consistency); with a lane selected, appended at its bottom (Return consistency);
/// > a multi-selection anchors at its last member in flatten order (lane `order`, then card
/// > `order`, the multi-drag order; the same anchor serves paste): creation follows the last
/// > selected card, or appends to the last selected lane; … with nothing selected — or a
/// > **tombstoned** selection, which never anchors creation — the **last-active lane** — the lane
/// > that most recently held selection or a creation in this window session — falling back to the
/// > first lane. … **Zero-lane board**: card creation … disable[s] via menu validation until a
/// > lane exists.
///
/// **A pure function rather than a method on the store** for the reason every rule in this codebase
/// that can be one is: the branches are lines of test rather than UI states to drive, and the menu
/// item's `disabled` and its action then read the *same* answer instead of two hand-kept-in-sync
/// conditions.
///
/// ### What it deliberately does not decide
///
/// - **The lane header's new-card button overrides this rule entirely** (11-command-nexus.md ▸
/// Pointer grammar, settled): "the click names its target lane, selection notwithstanding". That
/// call site passes its own lane and never comes here.
/// - **Return on a selected lane** is the same target as this rule's lane branch, but it is reached
/// by grammar rather than by the menu; it also passes its lane directly.
enum NewCardTarget {
/// Where a new card goes: which lane, and which card it lands immediately after (`nil` = the
/// lane's bottom). Exactly `NewCardPlaceholder`'s two anchoring fields, because that is what
/// this resolves *into*.
struct Resolution: Equatable {
let laneID: ItemID
let anchorCardID: ItemID?
}
/// The target, or `nil` when there is none — **the zero-lane board**, where "New Card,
/// Return-creation, and Paste with a card payload disable via menu validation until a lane
/// exists". `nil` is therefore the menu item's `disabled` condition as well as its refusal, so
/// the two can never disagree.
///
/// - Parameters:
/// - selection: the board's current selection, liveness side included. A `.trashed` selection
/// "never anchors creation" and is treated exactly as an empty one — the settled precedent
/// 04 ▸ Clipboard cites for paste, applied here to its source rule ("a trashed card's live
/// disk-lane never leaks in as 'the selected card's lane'").
/// - lastActiveLaneID: `TransientBoardState.lastActiveLaneID`, already cleared by the reload
/// rule if its lane vanished — but re-checked here anyway, because a caller need not have
/// reloaded since the lane went.
static func resolve(
selection: ItemReferenceSet,
lastActiveLaneID: ItemID?,
snapshot: BoardModel
) -> Resolution? {
let lanes = snapshot.lanes.filter { !$0.isDeleted }
guard !lanes.isEmpty else { return nil }
if let anchor = flattenAnchor(selection: selection, snapshot: snapshot) {
return anchor
}
// Nothing selected, a tombstoned selection, or a stale one — the ids name nothing the board
// renders, a selection the next reload will drop. Falls through rather than refusing: the
// user pressed ⌘N and the board has lanes. The target is then the lane that most recently
// held selection or a creation, and the first lane when there is no such lane (or it has
// since gone).
if let lastActiveLaneID, let lane = lanes.first(where: { $0.id == lastActiveLaneID }) {
return Resolution(laneID: lane.id, anchorCardID: nil)
}
return lanes.first.map { Resolution(laneID: $0.id, anchorCardID: nil) }
}
/// **The shared anchor, on its own** — "a multi-selection anchors at its last member in flatten
/// order (lane `order`, then card `order`, the multi-drag order; the same anchor serves paste)".
///
/// Extracted rather than left inside `resolve` because paste needs *exactly this clause* and not
/// the two that surround it. Card paste is `resolve` verbatim (the last-active-lane fallback and
/// all), but **lane paste has a different fallback** — "nothing selected = the board's right end",
/// never the last-active lane — so it takes the anchor and stops. Two derivations of "the last
/// member in flatten order" would be two chances for creation and paste to disagree about the one
/// rule 04 says they share.
///
/// `nil` covers the three cases that anchor nothing, which the callers then answer their own way:
/// an empty selection, a **tombstoned** one ("a tombstoned selection never anchors paste",
/// settled — and "a trashed card's live disk-lane never leaks in as 'the selected card's lane'",
/// which falls out of never looking at the trashed side at all), and a stale one whose ids name
/// nothing the board renders.
static func flattenAnchor(selection: ItemReferenceSet, snapshot: BoardModel) -> Resolution? {
guard selection.liveness == .live, !selection.ids.isEmpty else { return nil }
// The snapshot's lanes and cards are already in display order, so the flatten order is one
// walk, and the *last* hit is the anchor. Selection is homogeneous (cards XOR lanes), so only
// one of the two branches ever fires within a walk; a sole selection is simply the degenerate
// one-member case of the same rule.
var anchor: Resolution?
for lane in snapshot.lanes where !lane.isDeleted {
// A selected lane: creation appends at its bottom, Return consistency; paste lands after
// the lane itself.
if selection.ids.contains(lane.id) {
anchor = Resolution(laneID: lane.id, anchorCardID: nil)
}
// A selected card: its lane, immediately after it — paste-anchor consistency.
for card in lane.cards where !card.isDeleted && selection.ids.contains(card.id) {
anchor = Resolution(laneID: lane.id, anchorCardID: card.id)
}
}
return anchor
}
}