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
This commit is contained in:
2026-07-27 22:18:58 -04:00
parent 8f116934b4
commit 7eee0934ee
18 changed files with 2749 additions and 59 deletions
+13
View File
@@ -176,6 +176,19 @@ public final class AppModel {
/// concern, and nothing outside this module has any business reaching into a gesture in flight. /// concern, and nothing outside this module has any business reaching into a gesture in flight.
let dragSession = DragSession() let dragSession = DragSession()
/// The app's one clipboard (04-interactions.md Clipboard).
///
/// App-wide for the drag session's reason turned up a level: a cut/copy **outlives the board it
/// came from** the pasteboard and the staged snapshot survive the source window closing, and
/// survive the app quitting so nothing board-scoped could own it. It lives here rather than as
/// a singleton for `styleRecents`' reason (a test holds its own rather than colliding with the
/// app's, which for this one also means staying off the machine's real pasteboard), and every
/// board window reaches it through the environment.
///
/// Building it here is also the **launch sweep** (04: "a sweep at launch"): the store's `init`
/// reads the pasteboard once and collects every staged tree it no longer names.
public let clipboard = ClipboardStore()
// MARK: Sessions // MARK: Sessions
/// One open board window and everything hanging off it. /// One open board window and everything hanging off it.
+229
View File
@@ -0,0 +1,229 @@
import AppKit
import Foundation
import UniformTypeIdentifiers
// MARK: - The clipboard type
extension UTType {
/// What a Lanework copy puts on the pasteboard under its own type the JSON `ClipboardManifest`
/// (04-interactions.md Clipboard: "the pasteboard carries a JSON manifest + plain text").
/// Declared as an exported type in `Info.plist` beside the two drag types, for the same reason
/// those are: a payload nobody has declared is a payload the system will not carry.
static let laneworkClipboard = UTType(exportedAs: "dev.rzen.indie.kanban.clipboard")
}
// MARK: - The manifest
/// The JSON half of the hybrid clipboard (04-interactions.md Clipboard).
///
/// **It is self-describing twice over**, and both halves earn their keep:
///
/// - `copyID` ties the pasteboard to a staging directory `<Application Support>//Clipboard/<copyID>/`,
/// the full folder snapshots a paste reproduces byte-for-byte from and to a pending cut. It is
/// also the whole of "the snapshot survives relaunch exactly as long as the pasteboard still points
/// at it": a sweep keeps the one directory this id names and collects every other.
/// - Each `Entry` embeds the item's complete `index.md` text, so a paste still lands when the
/// snapshot is missing or unreadable "the staging-less fallback: content intact, attachments
/// absent", announced by a banner rather than discovered later.
///
/// `kind` and `side` are the selection's own vocabulary (`SelectionKind`, `Liveness`) rather than
/// near-copies of it: a clipboard payload is a selection that was copied, and the cards-XOR-lanes and
/// live-XOR-tombstoned invariants are exactly the ones those two types already carry. Their raw
/// spellings are pasteboard API a manifest written before a quit is decoded after the relaunch.
///
/// `entries` are in the order the copy read them flatten order on the live side ("lane `order`,
/// then card `order`"), the trash's own sorted order on the trashed side (`SelectionGrammar.order`)
/// which is the order a paste inserts them in.
public struct ClipboardManifest: Codable, Sendable, Equatable {
/// Bumped only if the shape below stops being readable by an older build. Nothing branches on it
/// today; a manifest whose version this build does not know is simply refused (`init?(data:)`),
/// which degrades to "there is nothing to paste" rather than to a wrong paste.
public static let currentVersion = 1
public var version: Int
/// The staging directory's name, and the pending cut's identity.
public var copyID: String
/// The **source** board's root folder. Paste needs it for nothing structural the destination
/// store owns every write but a cut's move reads its folders from there, and "pasting into the
/// source board is supported and is the within-board lane duplicate" is a claim about this value.
public var boardRoot: String
public var kind: SelectionKind
public var side: Liveness
public var entries: [Entry]
/// One copied item: where its snapshot is staged, what it is called, and its bytes.
public struct Entry: Codable, Sendable, Equatable {
/// The source item's own UUID. Never reused at the destination every materialization mints
/// fresh identities but it is what names the staged folder and what a cut's move resolves.
public var id: String
/// The staged subfolder under `<staging>/<copyID>/`, which is `id` itself: a selection is a
/// set, so its members' UUIDs are unique within one copy, and `BoardWriter.copyItem` requires
/// a UUID-shaped source folder a positional name would be refused as a stray.
public var folder: String
/// The title as written, or `nil` for an untitled item "Untitled" is a rendering, never a
/// value (03-board-ui.md § Card face). Feeds the plain-text representation and the degraded
/// paste's banner.
public var title: String?
/// The complete `index.md` at copy time the staging-less fallback's source bytes.
public var index: String
/// How many files the item's own `attachments/` held. Zero for a lane, which has none; a
/// lane's attachments are its cards' and are counted there.
public var attachmentCount: Int
/// A **lane** entry's cards, index text and all "a lane entry embeds its cards' too,
/// attachment-less". Empty for a card entry.
///
/// **Live cards only**, which is not a shortcut: a lane *copy* strips tombstoned cards
/// (04-interactions.md Clipboard, The trash), and the fallback only ever materializes a
/// copy a cut's move carries the real folder whole and never comes near this array. So the
/// embedded set is exactly what a fallback paste should produce.
public var cards: [Card]
/// One card inside a copied lane.
public struct Card: Codable, Sendable, Equatable {
public var id: String
public var title: String?
public var index: String
public var attachmentCount: Int
public init(id: String, title: String?, index: String, attachmentCount: Int) {
self.id = id
self.title = title
self.index = index
self.attachmentCount = attachmentCount
}
}
/// Everything a fallback paste of this entry would leave behind its own attachments plus,
/// for a lane, its cards'.
public var lostAttachmentCount: Int {
attachmentCount + cards.reduce(0) { $0 + $1.attachmentCount }
}
public init(
id: String,
folder: String,
title: String?,
index: String,
attachmentCount: Int,
cards: [Card] = []
) {
self.id = id
self.folder = folder
self.title = title
self.index = index
self.attachmentCount = attachmentCount
self.cards = cards
}
}
public init(
version: Int = ClipboardManifest.currentVersion,
copyID: String,
boardRoot: URL,
kind: SelectionKind,
side: Liveness,
entries: [Entry]
) {
self.version = version
self.copyID = copyID
self.boardRoot = boardRoot.path
self.kind = kind
self.side = side
self.entries = entries
}
public var rootURL: URL { URL(fileURLWithPath: boardRoot, isDirectory: true) }
/// The secondary representation one title per line, untitled items rendered as the board
/// renders them, so a V into any text field does something sane. `DragPayload.plainText`'s rule,
/// because it is the same question asked of the other transfer mechanism.
public var plainText: String {
entries.map { $0.title ?? "Untitled" }.joined(separator: "\n")
}
// MARK: Coding
public func encoded() -> Data? {
try? JSONEncoder().encode(self)
}
public init?(data: Data) {
guard let decoded = try? JSONDecoder().decode(ClipboardManifest.self, from: data),
decoded.version == Self.currentVersion,
!decoded.entries.isEmpty
else { return nil }
self = decoded
}
}
// MARK: - The pasteboard seam
/// The one thing `ClipboardStore` needs from `NSPasteboard`, behind a protocol.
///
/// It exists for testability and for nothing else: every rule the clipboard owns the sweep, the
/// at-most-one-snapshot invariant, takeover detection, the cut's voiding is a rule *about*
/// `changeCount` and the bytes under one type, and a suite that reached for `NSPasteboard.general`
/// would be racing every other app on the machine (and every other test in the run).
///
/// `changeCount` is the whole of takeover detection: it is a machine-wide counter that AppKit bumps
/// on every `clearContents()` by anyone, so a value that moved without this store moving it means
/// somebody else owns the pasteboard now (04-interactions.md Clipboard: "voided if another app
/// takes the pasteboard").
@MainActor
public protocol ClipboardPasteboard: AnyObject {
var changeCount: Int { get }
/// The bytes under the clipboard type, or `nil` when the pasteboard holds someone else's content.
func manifestData() -> Data?
/// Replaces the pasteboard with **one** item carrying both representations, and answers the
/// resulting `changeCount`.
@discardableResult
func write(manifest: Data, text: String) -> Int
}
/// The real pasteboard.
///
/// One item with two representations, written directly rather than through SwiftUI's
/// `onCopyCommand`: the item structure has to be exactly this a known `copyID` under a known type,
/// with the plain text beside it rather than in a second item and a mechanism that decides the
/// shape for us could not promise that.
@MainActor
public final class SystemPasteboard: ClipboardPasteboard {
private let pasteboard: NSPasteboard
public init(_ pasteboard: NSPasteboard = .general) {
self.pasteboard = pasteboard
}
private static let type = NSPasteboard.PasteboardType(UTType.laneworkClipboard.identifier)
public var changeCount: Int { pasteboard.changeCount }
public func manifestData() -> Data? {
pasteboard.data(forType: Self.type)
}
@discardableResult
public func write(manifest: Data, text: String) -> Int {
pasteboard.clearContents()
let item = NSPasteboardItem()
item.setData(manifest, forType: Self.type)
item.setString(text, forType: .string)
pasteboard.writeObjects([item])
return pasteboard.changeCount
}
}
+588
View File
@@ -0,0 +1,588 @@
import AppKit
import Foundation
import Observation
import os
// MARK: - ClipboardStore
/// Cut / copy / paste for cards **and lanes** the hybrid clipboard (04-interactions.md
/// Clipboard).
///
/// ### Hybrid, and what each half is for
///
/// The pasteboard carries a small JSON manifest plus a plain-text rendering; the *content* whole
/// folder trees, attachments and strays and all is **staged** under
/// `<Application Support>/<bundle id>/Clipboard/<copyID>/`, so a paste reproduces the item
/// byte-for-byte across boards rather than reconstructing it from a summary. The manifest's embedded
/// `index.md` per entry is the fallback when a snapshot is missing, and a fallback paste is **loud**:
/// a banner names exactly what was lost.
///
/// ### The staging lifecycle, settled
///
/// - **Eager**: the snapshot is taken at C/X time, so a copy captures the source as it was at the
/// gesture and is immune to a later deletion or unmount.
/// - **At most the current copy**: every copy sweeps, and a sweep keeps only the `copyID` the
/// pasteboard still names. A launch sweeps too, which is what collects the trees another app
/// orphaned by taking the pasteboard while this app was not running.
/// - **Survives relaunch exactly as long as the pasteboard points at it** which is the sweep rule
/// read from the other side, not a second mechanism.
///
/// The copies run **off the main actor** on a serialized chain (`stagingChain`): a card holding a
/// large video would otherwise freeze the app for the length of the C. The pasteboard is written
/// synchronously in front of that C is instant and Edit Paste lights up immediately and a
/// paste awaits the same chain, so it can never read a half-written snapshot, and a sweep can never
/// delete a tree a copy is still writing.
///
/// ### The deferred cut
///
/// X stages, writes the pasteboard, and arms the source board's `transient.pendingCut` the items
/// dim in place. The cut is **armed** while the pasteboard still holds its `copyID`, the source store
/// is still open, and the pending cut still names something; "deletion voids per item" needs no code
/// here at all, because `TransientBoardState.resolve` already ejects a tombstoned or vanished member
/// on every reload, so "paste moves only the survivors" is the reload rule read at paste time.
/// Voiding undims and downgrades the paste to a copy from staging.
///
/// ### Takeover detection is lazy, and there is no timer
///
/// `NSPasteboard.changeCount` is a machine-wide counter, so a value that moved without this store
/// moving it means another app owns the pasteboard now. It is checked exactly where 04 says menu
/// validation (which reads the cached `payload`), app activation, and before every paste and
/// nowhere else. `payload` is observable state rather than a computed pasteboard read precisely so
/// the menu items' enablement re-evaluates when it changes rather than whenever SwiftUI happens to
/// rebuild them.
@MainActor
@Observable
public final class ClipboardStore {
// MARK: State
/// What the pasteboard offers this app, as of the last `refresh()` `nil` when it holds
/// somebody else's content, or nothing this build can read.
///
/// **Observed**, which is the whole point: Edit Paste's availability is a function of this
/// value, and a computed pasteboard read would leave the item stale until something else
/// happened to invalidate the menu.
public private(set) var payload: ClipboardManifest?
/// The staging directory public because the tests assert on what it holds after a copy, a
/// paste and a sweep, exactly as `BoardRegistry.storageURL` is public for its tests.
@ObservationIgnored public let stagingRoot: URL
@ObservationIgnored private let pasteboard: any ClipboardPasteboard
/// The `changeCount` at the last refresh. Starts below any real value so the first refresh always
/// reads through.
@ObservationIgnored private var lastChangeCount = Int.min
/// The deferred cut awaiting its paste, or `nil` when the clipboard holds a copy (or a cut has
/// been consumed or voided).
///
/// The *members* are not here: they live in the source board's `transient.pendingCut`, where the
/// reload rule can eject vanished ones and where the dimming reads them. This is only the pairing
/// which copy, and whose board.
@ObservationIgnored private var armedCut: ArmedCut?
private struct ArmedCut {
let copyID: String
/// Weak: a board window can close mid-cut, and a closed board voids the cut by definition.
weak var source: BoardStore?
}
/// Tail of the serialized staging chain. Every mutation of the staging directory a copy's
/// snapshots, a sweep is appended here and runs strictly after the previous one, which is what
/// makes "a sweep can never delete a tree a copy is still writing" a property of the code rather
/// than a race nobody has lost yet.
@ObservationIgnored private var stagingChain: Task<Void, Never>?
/// `nonisolated(unsafe)` for one reason and one only: `deinit` is nonisolated and this is the
/// token it has to hand back. It is written exactly once, in `init` on the main actor, and read
/// exactly once, in `deinit` after the last reference is gone there is no window in which two
/// contexts could touch it.
@ObservationIgnored private nonisolated(unsafe) var activationObserver: (any NSObjectProtocol)?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "clipboard")
/// `~/Library/Application Support/<bundle id>/Clipboard/`, beside the board registry the same
/// container convention, for the same reason (02-architecture.md § Per-board app state, "App-wide
/// state has the same home").
public static var defaultStagingRoot: URL {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
.appendingPathComponent("Library/Application Support", isDirectory: true)
let bundleIdentifier = Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Kanban"
return support
.appendingPathComponent(bundleIdentifier, isDirectory: true)
.appendingPathComponent("Clipboard", isDirectory: true)
}
/// The app builds one of these with the system pasteboard and the real staging directory; a test
/// passes its own of each, for the reason `BoardRegistry` takes a storage URL at all injecting
/// them is how a suite stays out of Application Support *and* off the machine's one pasteboard.
///
/// **The launch sweep is here** (04: "a sweep at launch and on each copy"): a fresh store reads
/// the pasteboard once and collects every staged tree it no longer names, which is exactly the
/// residue a crash or a previous launch leaves behind.
public init(
pasteboard: any ClipboardPasteboard = SystemPasteboard(),
stagingRoot: URL = ClipboardStore.defaultStagingRoot,
observesActivation: Bool = true
) {
self.pasteboard = pasteboard
self.stagingRoot = stagingRoot
refresh()
sweep()
guard observesActivation else { return }
// Returning to the foreground is when another app's copy becomes this app's problem: the
// cached payload is re-read, a cut whose pasteboard entry is gone is voided and undimmed, and
// the staged tree that can never be pasted again goes now rather than lingering.
activationObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
self.refresh()
self.sweep()
}
}
}
deinit {
if let activationObserver {
NotificationCenter.default.removeObserver(activationObserver)
}
}
// MARK: - Copy and cut
/// C stages `store`'s selection and writes the pasteboard. Any pending cut is voided: its
/// pasteboard entry has just been overwritten, so the items it dimmed are staying put.
public func copy(from store: BoardStore) {
write(from: store, cut: false)
}
/// X the same write, plus the deferred move: the items stay where they are, dimmed, until a
/// paste relocates them (04: "Cut is Finder-style deferred").
public func cut(from store: BoardStore) {
write(from: store, cut: true)
}
/// The one write both gestures share.
///
/// The order is the contract: capture from the snapshot (main actor, no I/O every item's
/// `index.md` is already parsed into the snapshot and `FrontmatterDocument.serialized()` returns
/// it verbatim), schedule the snapshots behind it, then write the pasteboard, then sweep. The
/// pasteboard is written *before* the copies land, which is safe precisely because the manifest
/// carries the fallback text: a paste that somehow beat the chain would still materialize the
/// right items.
private func write(from store: BoardStore, cut: Bool) {
guard let capture = Self.capture(selection: store.selection, snapshot: store.snapshot) else { return }
let copyID = UUID().uuidString.lowercased()
let stagingDir = stagingRoot.appendingPathComponent(copyID, isDirectory: true)
let jobs = capture.subjects.map { subject in
StagingJob(
source: subject.path.folder(under: store.rootURL),
destination: stagingDir.appendingPathComponent(subject.id.rawValue, isDirectory: true)
)
}
let manifest = ClipboardManifest(
copyID: copyID,
boardRoot: store.rootURL,
kind: capture.kind,
side: capture.side,
entries: capture.subjects.map(\.entry)
)
guard let data = manifest.encoded() else { return }
stage(jobs, into: stagingDir)
// Whatever was armed is void the instant its copyID leaves the pasteboard done here rather
// than discovered later so the old board undims in the same turn as the new copy.
voidCut()
lastChangeCount = pasteboard.write(manifest: data, text: manifest.plainText)
payload = manifest
if cut {
armedCut = ArmedCut(copyID: copyID, source: store)
store.transient.pendingCut = ItemReferenceSet(
ids: Set(capture.subjects.map(\.id)),
liveness: capture.side
)
}
// "A sweep at launch and on each copy purges entries the pasteboard no longer references."
// Queued behind the snapshots above, so it can only ever collect trees older than this one.
sweep()
}
// MARK: - Availability
/// Whether Edit Copy applies a non-empty selection that still names something the board
/// renders, on either side of the live/tombstoned boundary.
///
/// **The read-only lock deliberately does not close it**: "reading, selecting, searching and
/// copying out all stay live" (02-architecture.md § The lock's scope) a copy is a read. The
/// focused-editor rule does close it: while an inline title editor holds the keyboard, C acts on
/// the text (04 Grammar). The field consumes the selector natively, so this guard is belt over
/// braces but a board command that stayed armed under an editor would be exactly the kind of
/// fall-through 04 is careful about.
public func canCopy(from store: BoardStore) -> Bool {
guard !store.isEditingInline else { return false }
return SelectionGrammar.kind(of: store.selection, in: store.snapshot) != nil
}
/// Whether Edit Cut applies. Copy's conditions, plus the two a *move* adds: the board must
/// accept writes (a cut mutates its source), and the selection must be **live** "X is
/// disabled: the move-out vocabulary is Put Back or drag-to-restore, nothing else" (04 The
/// trash).
public func canCut(from store: BoardStore) -> Bool {
canCopy(from: store) && !store.isReadOnly && store.selection.liveness == .live
}
/// Whether Edit Paste applies to `store`.
///
/// Three clauses, all 04's: the board accepts board mutations (the lock and the focused-editor
/// rule, `acceptsBoardMutations`); there is a payload at all; and for a **card** payload only
/// there is somewhere to put it, which is false on a zero-lane board. A **lane** payload is
/// always enabled, zero-lane board included: it is the other way out of one.
public func canPaste(into store: BoardStore) -> Bool {
guard store.acceptsBoardMutations, let payload else { return false }
switch payload.kind {
case .lane:
return true
case .card:
return PasteTarget.cards(
selection: store.selection,
lastActiveLaneID: store.transient.lastActiveLaneID,
snapshot: store.snapshot
) != nil
}
}
// MARK: - Paste
/// Where this paste is going, resolved **now** from the selection as it stands when V is
/// pressed, even though the paste itself completes once the staging chain has settled.
private enum Plan {
case cards(PasteTarget.Cards)
case lanes(index: Int)
}
/// V pastes into `store`, once the snapshots this pasteboard promised are actually on disk.
///
/// Returns the task so a caller that must observe the end state (the tests) can await it; the app
/// discards it. `nil` means there was nothing to do no payload, or no target which is the
/// same condition the menu item's `disabled` reads, so a disabled command that somehow fires is a
/// silent no-op rather than a surprise.
@discardableResult
public func paste(into store: BoardStore) -> Task<Void, Never>? {
refresh()
guard canPaste(into: store), let manifest = payload else { return nil }
let plan: Plan
switch manifest.kind {
case .card:
guard let target = PasteTarget.cards(
selection: store.selection,
lastActiveLaneID: store.transient.lastActiveLaneID,
snapshot: store.snapshot
) else { return nil }
plan = .cards(target)
case .lane:
plan = .lanes(index: PasteTarget.lanes(selection: store.selection, snapshot: store.snapshot))
}
let staging = stagingChain
return Task { @MainActor [weak self, weak store] in
await staging?.value
guard let self, let store else { return }
perform(manifest, plan: plan, into: store)
// The snapshots just did their job reclaim everything the pasteboard no longer points
// at rather than waiting for the next copy or launch. The current copy survives: V twice
// is a legitimate flow, and the second one needs it.
sweep()
}
}
/// The paste itself: main actor, snapshots settled.
///
/// **The armed cut is tried first and consumed on success** "first armed paste MOVES the
/// surviving originals a second paste materializes copies from staging" and everything else
/// is the copy path, which is also where a voided cut lands.
private func perform(_ manifest: ClipboardManifest, plan: Plan, into store: BoardStore) {
refresh()
// The pasteboard moved under this paste (another app copied while the chain settled): the
// payload the user asked to paste is no longer the payload, and inventing one is worse than
// doing nothing.
guard payload?.copyID == manifest.copyID else { return }
if let move = armedMove(for: manifest) {
let sources = move.folders.map(BoardStore.ItemSource.folder)
switch plan {
case let .cards(target):
store.receiveCards(
sources,
operation: .move,
toLane: target.laneID,
at: target.index,
clearingTombstones: false
)
case let .lanes(index):
store.receiveLanes(sources, operation: .move, at: index, clearingTombstones: false)
}
consumeCut()
return
}
// The copy path the staged snapshot per entry, or the embedded `index.md` where that
// snapshot is missing or unreadable. Mixed is legal and is the honest outcome of a partial
// staging failure: the entries that have snapshots arrive whole.
let stagingDir = stagingRoot.appendingPathComponent(manifest.copyID, isDirectory: true)
var sources: [BoardStore.ItemSource] = []
var losses: [BannerCenter.AttachmentLoss] = []
for entry in manifest.entries {
let staged = stagingDir.appendingPathComponent(entry.folder, isDirectory: true)
if FileManager.default.fileExists(
atPath: staged.appendingPathComponent(BoardLoader.indexFileName).path
) {
sources.append(.folder(staged))
continue
}
sources.append(.text(index: entry.index, cards: entry.cards.map(\.index)))
// "A degraded paste is loud, never silent a one-shot banner names exactly what was
// lost." An entry with no attachments lost nothing its content is intact and its bytes
// are the source bytes so it contributes no row.
if entry.lostAttachmentCount > 0 {
losses.append(BannerCenter.AttachmentLoss(
title: entry.title,
attachments: entry.lostAttachmentCount
))
}
}
// "C strips `deleted:` at materialization" (04 The trash) the trash's copy-out-only rule,
// and the one axis a paste varies that a within-board drop never does.
let clearingTombstones = manifest.side == .trashed
switch plan {
case let .cards(target):
store.receiveCards(
sources,
operation: .copy,
toLane: target.laneID,
at: target.index,
clearingTombstones: clearingTombstones
)
case let .lanes(index):
store.receiveLanes(
sources,
operation: .copy,
at: index,
clearingTombstones: clearingTombstones
)
}
store.banners.postDegradedPaste(losses)
}
/// The armed cut's surviving originals, in flatten order and as folders under the **source**
/// board's root or `nil` when the cut is not armed for this manifest.
///
/// The four ways it is not armed are 04's four ways a cut voids, and three of them are simply
/// the absence of something: another app took the pasteboard (the copyID no longer matches), the
/// source board closed (the weak reference is gone), and the cut emptied "a cut voided down to
/// nothing is simply void". The fourth, per-item deletion, is already applied: the pending cut
/// has been re-resolved against every reload since, so what is left in it *is* the survivors.
private func armedMove(for manifest: ClipboardManifest) -> (source: BoardStore, folders: [URL])? {
guard let cut = armedCut, cut.copyID == manifest.copyID, let source = cut.source else { return nil }
let survivors = source.transient.pendingCut
guard !survivors.isEmpty else { return nil }
// `TrashModel.paths` walks lanes in board order and each lane's cards in card order, which is
// the flatten order the drop commits insert in and the pending cut is homogeneous by kind,
// so only one of its two branches ever contributes.
let folders = TrashModel.paths(of: survivors.ids, on: .live, in: source.snapshot)
.map { $0.folder(under: source.rootURL) }
guard !folders.isEmpty else { return nil }
return (source, folders)
}
/// The cut has been paid out: the originals moved, so nothing is pending and nothing dims. A
/// second V then falls through to the copy path, which is exactly what 04 asks for.
private func consumeCut() {
armedCut?.source?.transient.pendingCut = .empty
armedCut = nil
}
/// The cut is void: undim and forget it. Clearing the source's pending set is what "voiding
/// undims" means in code everything else about a void cut is the absence of an arm.
private func voidCut() {
guard let cut = armedCut else { return }
cut.source?.transient.pendingCut = .empty
armedCut = nil
Self.logger.debug("pending cut voided")
}
// MARK: - Pasteboard freshness
/// Re-reads the pasteboard **if and only if it has changed**, and voids a cut the change orphaned.
///
/// One `changeCount` read in the common case, which is what makes it cheap enough for the three
/// callers 04 names: menu validation (through the cached `payload`), app activation, and the
/// front of every paste.
public func refresh() {
let count = pasteboard.changeCount
guard count != lastChangeCount else { return }
lastChangeCount = count
payload = pasteboard.manifestData().flatMap(ClipboardManifest.init(data:))
if let cut = armedCut, payload?.copyID != cut.copyID {
voidCut()
}
}
// MARK: - Staging
/// One item's snapshot: where it lives, where its copy goes.
private struct StagingJob: Sendable {
let source: URL
let destination: URL
}
/// Appends this copy's snapshots to the staging chain. Best-effort per item: one that fails to
/// copy simply falls back to the manifest's embedded `index.md` at paste time, which is the
/// degraded paste the banner already has words for.
private func stage(_ jobs: [StagingJob], into stagingDir: URL) {
enqueue { [jobs, stagingDir] in
guard (try? FileManager.default.createDirectory(
at: stagingDir,
withIntermediateDirectories: true
)) != nil else { return }
for job in jobs {
try? FileManager.default.copyItem(at: job.source, to: job.destination)
}
}
}
/// Deletes every staged tree the pasteboard no longer names **one rule, three callers**: app
/// launch (trees orphaned by a crash or a previous session), returning to the foreground (another
/// app took the pasteboard while we were away, so ours can never be pasted again), and the tail
/// of every copy and every paste.
///
/// Runs on the staging chain, off the main actor: deleting a gigabyte-scale tree is as slow as
/// writing one, and the chain is what keeps this from ever overtaking the copy that is producing
/// the tree it is being told to keep.
public func sweep() {
refresh()
let keep = payload?.copyID
let root = stagingRoot
enqueue { await Self.prune(root, keeping: keep) }
}
private nonisolated static func prune(_ root: URL, keeping keep: String?) async {
guard let entries = try? FileManager.default.contentsOfDirectory(
at: root,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
) else { return }
for entry in entries where entry.lastPathComponent != keep {
try? FileManager.default.removeItem(at: entry)
}
}
/// Appends `work` to the staging chain. `Task.detached` rather than `Task { }`: a task created in
/// a `@MainActor` method inherits that isolation and would run the file work on the main actor
/// the exact thing this chain exists to avoid.
private func enqueue(_ work: @escaping @Sendable () async -> Void) {
let previous = stagingChain
stagingChain = Task.detached(priority: .userInitiated) {
await previous?.value
await work()
}
}
/// Awaits every snapshot and sweep queued so far. The app never needs this a paste awaits the
/// chain itself but the tests use it to observe the staging directory once the dust has settled.
public func stagingSettled() async {
await stagingChain?.value
}
// MARK: - What a copy captures
/// One item a copy is about to stage: its identity, its folder, and the manifest entry it
/// produces.
struct Subject {
let id: ItemID
let path: TrashModel.ItemPath
let entry: ClipboardManifest.Entry
}
/// The selection, resolved into copy subjects in the order the clipboard records them or `nil`
/// when it names nothing the board renders on its own side.
///
/// **The order is `SelectionGrammar.order`'s**, which is already the right answer for all four
/// (side, kind) pairs: flatten order for live cards, left-to-right for live lanes, and the trash's
/// own deterministic sort for either kind of entry. Deriving it here would be a fifth definition
/// of an order the app already states once.
///
/// **The index text comes from the snapshot, not from disk.** `FrontmatterDocument` edits by line
/// span, so `serialized()` on an untouched document returns the file's bytes exactly which
/// makes the manifest's fallback text genuinely *the source bytes* while costing C no file I/O
/// at all, even for a lane carrying two hundred cards.
static func capture(
selection: ItemReferenceSet,
snapshot: BoardModel
) -> (kind: SelectionKind, side: Liveness, subjects: [Subject])? {
guard let kind = SelectionGrammar.kind(of: selection, in: snapshot) else { return nil }
let side = selection.liveness
let ordered = SelectionGrammar.order(of: kind, on: side, in: snapshot)
.filter { selection.ids.contains($0) }
guard !ordered.isEmpty else { return nil }
var subjects: [ItemID: Subject] = [:]
for lane in snapshot.lanes {
if kind == .lane, Liveness(isDeleted: lane.isDeleted) == side {
subjects[lane.id] = Subject(
id: lane.id,
path: TrashModel.ItemPath(laneID: lane.id, cardID: nil),
entry: ClipboardManifest.Entry(
id: lane.id.rawValue,
folder: lane.id.rawValue,
title: lane.title.value,
index: lane.document.serialized(),
attachmentCount: 0,
// Live cards only a lane copy strips tombstoned cards, and the fallback
// only ever materializes a copy (see `ClipboardManifest.Entry.cards`).
cards: lane.cards.filter { !$0.isDeleted }.map { card in
ClipboardManifest.Entry.Card(
id: card.id.rawValue,
title: card.title.value,
index: card.document.serialized(),
attachmentCount: card.attachments.count
)
}
)
)
}
// A tombstoned lane subsumes its subtree on both sides: its cards render nowhere live and
// have no trash row of their own, so they are nobody's copy subject.
guard kind == .card, !lane.isDeleted else { continue }
for card in lane.cards where Liveness(isDeleted: card.isDeleted) == side {
subjects[card.id] = Subject(
id: card.id,
path: TrashModel.ItemPath(laneID: lane.id, cardID: card.id),
entry: ClipboardManifest.Entry(
id: card.id.rawValue,
folder: card.id.rawValue,
title: card.title.value,
index: card.document.serialized(),
attachmentCount: card.attachments.count
)
)
}
}
let resolved = ordered.compactMap { subjects[$0] }
guard !resolved.isEmpty else { return nil }
return (kind, side, resolved)
}
}
+13
View File
@@ -68,6 +68,19 @@
<key>UTTypeDescription</key> <key>UTTypeDescription</key>
<string>Lanework Lanes</string> <string>Lanework Lanes</string>
</dict> </dict>
<!-- The clipboard manifest (04-interactions.md ▸ Clipboard). Its own type rather than either
drag type above: a clipboard payload carries cards or lanes under one identifier, plus
the staging copyID and the fallback index.md text a drag has no use for. -->
<dict>
<key>UTTypeIdentifier</key>
<string>dev.rzen.indie.kanban.clipboard</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeDescription</key>
<string>Lanework Clipboard</string>
</dict>
</array> </array>
<key>CFBundleDocumentTypes</key> <key>CFBundleDocumentTypes</key>
<array> <array>
+63
View File
@@ -271,6 +271,43 @@ public final class BannerCenter {
signposts.insert(InfoSignpost(message: message), at: 0) signposts.insert(InfoSignpost(message: message), at: 0)
} }
/// One item a degraded paste could not bring its attachments with what
/// `degradedPasteMessage(for:)` names.
///
/// `title` is the item's as written, `nil` for an untitled one: "Untitled" is a rendering, never
/// a value (03-board-ui.md § Card face), and the phrasing below says "the item" instead, exactly
/// as `actionPhrase(for:)` does for a failure whose title never got read.
public struct AttachmentLoss: Sendable, Equatable {
public let title: String?
public let attachments: Int
public init(title: String?, attachments: Int) {
self.title = title
self.attachments = attachments
}
}
/// **The degraded paste** (04-interactions.md Clipboard, settled): the staged snapshot was
/// missing or unreadable, so the paste fell back to the manifest's embedded `index.md` content
/// intact, attachments absent and this is the row that says so. "A degraded paste is loud,
/// never silent the user never discovers an empty `attachments/` later."
///
/// **A signpost, not a `oneShot`**, and the choice is the vocabulary's rather than a compromise:
/// 02-architecture.md's `oneShot` is *a write that did not happen*, carrying a `BoardWriteError`,
/// and nothing here failed the items landed, whole but for files that were never on the
/// pasteboard's side of the transfer. A signpost is the other member of the same lifecycle class
/// ("one-shots dismiss"): it reports something that already happened, it has no timeout, and only
/// the user clears it, which is the whole of "never evaporates unread". What it costs is
/// precedence a signpost ranks last and may collapse behind "+N more" which is the one place
/// this row reads quieter than 04's "loud" deserves. See the report's design-gap note.
///
/// An empty list posts nothing: a fallback that lost no attachments lost nothing at all, and a
/// banner announcing that would be noise.
public func postDegradedPaste(_ losses: [AttachmentLoss]) {
guard let message = Self.degradedPasteMessage(for: losses) else { return }
postSignpost(message)
}
/// Removes a dismissable row: a one-shot failure or a signpost. **An id that names an /// Removes a dismissable row: a one-shot failure or a signpost. **An id that names an
/// in-progress operation is ignored** rather than ending it, because "dismiss" and "cancel" are /// in-progress operation is ignored** rather than ending it, because "dismiss" and "cancel" are
/// different promises and a row that offers one must never quietly do the other. /// different promises and a row that offers one must never quietly do the other.
@@ -512,6 +549,32 @@ public final class BannerCenter {
return "\(subject): \(reason) — showing the last good view" return "\(subject): \(reason) — showing the last good view"
} }
/// The degraded paste's line 04-interactions.md's own example sentence, "Pasted 'Fix login'
/// without its 3 attachments", generalized over the two axes it can vary on.
///
/// **It names exactly what was lost**, which is what the design asks for and what decides every
/// choice below: the count is real (never "some"), the singular and the plural are both spelled,
/// and a multi-item paste totals the attachments rather than listing every title a banner is one
/// line, and "2 items" plus the true total is the honest summary where a truncated list would not
/// be. `nil` for an empty list: nothing was lost, so there is nothing to say.
///
/// The count is the item's `attachments/` as the snapshot listed it at copy time the design's
/// own vocabulary for what a card carries (01-storage-format.md § Attachments). A stray file
/// sitting loose in the card folder is not in it and is not named here; see the report's
/// design-gap note.
public nonisolated static func degradedPasteMessage(for losses: [AttachmentLoss]) -> String? {
guard !losses.isEmpty else { return nil }
let total = losses.reduce(0) { $0 + $1.attachments }
guard total > 0 else { return nil }
guard losses.count == 1, let only = losses.first else {
return "Pasted \(losses.count) items without their \(total) attachments"
}
let subject = only.title.map { "'\($0)'" } ?? "the item"
let tail = total == 1 ? "its attachment" : "its \(total) attachments"
return "Pasted \(subject) without \(tail)"
}
/// The suspended-history line. It names the *consequence* the user cares about undo and the /// The suspended-history line. It names the *consequence* the user cares about undo and the
/// flush-before-overwrite guarantee are degraded rather than the git mechanics, and carries /// flush-before-overwrite guarantee are degraded rather than the git mechanics, and carries
/// the diagnosis as its tail. /// the diagnosis as its tail.
+122 -26
View File
@@ -1425,6 +1425,25 @@ public final class BoardStore {
folder.deletingLastPathComponent().deletingLastPathComponent() folder.deletingLastPathComponent().deletingLastPathComponent()
} }
/// Where one arriving item's bytes come from.
///
/// **Two producers, one arrival path.** A drag names folders in the source board; a paste names
/// folders in the clipboard's staging directory and, when that snapshot is missing or
/// unreadable, the manifest's embedded `index.md` instead (04-interactions.md Clipboard's
/// staging-less fallback). Modelling the fallback as a second kind of *source* rather than as a
/// second arrival method is what keeps the rank insertion, the tombstone stripping and the
/// `deleted:` clearing stated once: everything downstream of "where do the bytes come from" is
/// identical, and a paste that half-falls-back mixes the two cases inside one bracket.
public enum ItemSource: Sendable, Equatable {
/// A folder on disk the source board's own, or a staged snapshot of it.
case folder(URL)
/// The manifest's embedded text: the item's `index.md`, and (for a lane) its cards'.
/// Materialized by `BoardWriter.materializeItem`, byte-faithfully.
case text(index: String, cards: [String])
}
/// A cross-board card drop, landing contiguously at `index` among `laneID`'s rendered cards. /// A cross-board card drop, landing contiguously at `index` among `laneID`'s rendered cards.
/// ///
/// - `.copy` (the default between boards) `copyItem` per folder: fresh GUIDs throughout, /// - `.copy` (the default between boards) `copyItem` per folder: fresh GUIDs throughout,
@@ -1435,7 +1454,26 @@ public final class BoardStore {
/// finest grain (01-storage-format.md's per-folder degradation, which is `moveItem`'s own /// finest grain (01-storage-format.md's per-folder degradation, which is `moveItem`'s own
/// behaviour rather than something this method arranges). /// behaviour rather than something this method arranges).
public func receiveCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) { public func receiveCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) {
receive(sources, operation: operation, toLane: laneID, at: index, clearingTombstones: false) receive(sources.map(ItemSource.folder), operation: operation, toLane: laneID, at: index, clearingTombstones: false)
}
/// **The clipboard's card arrival** `receiveCards`/`receiveRestoredCards` with the two axes a
/// paste varies independently (04-interactions.md Clipboard).
///
/// It is the same commit as a drop's, deliberately: `.copy` materializes from the staged snapshot
/// (or, per entry, from the embedded `index.md`), `.move` is the armed cut's "the -drag move
/// path identity travels" and `clearingTombstones` is the trash's copy-out rule, "`deleted:`
/// is stripped **at materialization**". A cut is live-only (X is disabled in the trash), so the
/// two flags never both fire; the parameter is not narrowed for that, because which of them is
/// reachable is the *clipboard's* rule and this method's job is only to obey both.
public func receiveCards(
_ sources: [ItemSource],
operation: TransferOperation,
toLane laneID: ItemID,
at index: Int,
clearingTombstones: Bool
) {
receive(sources, operation: operation, toLane: laneID, at: index, clearingTombstones: clearingTombstones)
} }
/// The cross-board half of drag-to-restore (04-interactions.md The trash): tombstoned rows /// The cross-board half of drag-to-restore (04-interactions.md The trash): tombstoned rows
@@ -1456,11 +1494,11 @@ public final class BoardStore {
/// because `restoreItem` is already the one expression in the app for "remove the `deleted:` /// because `restoreItem` is already the one expression in the app for "remove the `deleted:`
/// key" the bytes are never rewritten any other way. /// key" the bytes are never rewritten any other way.
public func receiveRestoredCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) { public func receiveRestoredCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) {
receive(sources, operation: operation, toLane: laneID, at: index, clearingTombstones: true) receive(sources.map(ItemSource.folder), operation: operation, toLane: laneID, at: index, clearingTombstones: true)
} }
private func receive( private func receive(
_ sources: [URL], _ sources: [ItemSource],
operation: TransferOperation, operation: TransferOperation,
toLane laneID: ItemID, toLane laneID: ItemID,
at index: Int, at index: Int,
@@ -1488,25 +1526,60 @@ public final class BoardStore {
guard let ranks else { return } guard let ranks else { return }
for (source, rank) in zip(sources, ranks) { for (source, rank) in zip(sources, ranks) {
let arrived: ItemID guard let arrived = try Self.materialize(
switch operation { source,
case .copy: operation: operation,
arrived = try BoardWriter.copyItem(at: source, toParent: laneFolder, order: rank, stamps: .fork) intoParent: laneFolder,
case .move:
arrived = try BoardWriter.moveItem(
at: source,
toParent: laneFolder,
sourceBoardRoot: Self.boardRoot(ofCardFolder: source),
destinationBoardRoot: root, destinationBoardRoot: root,
sourceBoardRoot: Self.boardRoot(ofCardFolder:),
order: rank order: rank
).id ) else { continue }
}
guard clearingTombstones else { continue } guard clearingTombstones else { continue }
try BoardWriter.restoreItem(at: laneFolder.appendingPathComponent(arrived.rawValue, isDirectory: true)) try BoardWriter.restoreItem(at: laneFolder.appendingPathComponent(arrived.rawValue, isDirectory: true))
} }
} }
} }
/// One arrival's materialization the two `ItemSource` kinds crossed with the two operations,
/// in the one place both the card path and the lane path can share.
///
/// **`.move` of a `.text` source is unreachable and answers `nil`.** A move needs a folder whose
/// identity travels, and the only producer of text sources is the clipboard's fallback, which is
/// a *copy* by construction (04-interactions.md Clipboard: an armed cut moves the surviving
/// originals, and a cut that cannot find them is void). Skipping is the standing posture for an
/// arrival that names nothing the same silent no-op every other drop commit gives a source that
/// has gone.
private static func materialize(
_ source: ItemSource,
operation: TransferOperation,
intoParent parent: URL,
destinationBoardRoot: URL,
sourceBoardRoot: (URL) -> URL,
order: Double
) throws(BoardWriteError) -> ItemID? {
switch (source, operation) {
case let (.folder(folder), .copy):
return try BoardWriter.copyItem(at: folder, toParent: parent, order: order, stamps: .fork)
case let (.folder(folder), .move):
return try BoardWriter.moveItem(
at: folder,
toParent: parent,
sourceBoardRoot: sourceBoardRoot(folder),
destinationBoardRoot: destinationBoardRoot,
order: order
).id
case let (.text(index, cards), .copy):
return try BoardWriter.materializeItem(
inParent: parent,
indexText: index,
children: cards,
order: order
)
case (.text, .move):
return nil
}
}
/// A cross-board lane drop, landing contiguously at `stripIndex` among this board's live lanes. /// A cross-board lane drop, landing contiguously at `stripIndex` among this board's live lanes.
/// ///
/// The two operations differ in exactly one place beyond identity, and it is 04-interactions.md /// The two operations differ in exactly one place beyond identity, and it is 04-interactions.md
@@ -1526,6 +1599,26 @@ public final class BoardStore {
/// not exist by drag at all ( is ignored on lane drags; the clipboard is that operation's one /// not exist by drag at all ( is ignored on lane drags; the clipboard is that operation's one
/// home), so this method is cross-board by construction. /// home), so this method is cross-board by construction.
public func receiveLanes(_ sources: [URL], operation: TransferOperation, at stripIndex: Int) { public func receiveLanes(_ sources: [URL], operation: TransferOperation, at stripIndex: Int) {
receiveLanes(sources.map(ItemSource.folder), operation: operation, at: stripIndex, clearingTombstones: false)
}
/// **The clipboard's lane arrival** `receiveLanes` with the staging-less fallback and the
/// trash's copy-out rule folded in (04-interactions.md Clipboard, The trash).
///
/// The two operations keep their drag semantics exactly, because 04 says they are the same
/// semantics: "a pasted *copy* takes fresh GUIDs throughout and **strips tombstoned cards**; a
/// cut-paste is the -drag move the folder moves whole, tombstoned cards landing in the
/// destination's trash". `clearingTombstones` adds the one thing a drag never asks for: a lane
/// *entry* copied out of the trash arrives live, its own `deleted:` removed once it is at the
/// destination the lane-level twin of `receiveRestoredCards`, and the reason the strip runs
/// first is that the two writes touch different files and the strip's target list is the one that
/// must be read before anything is rewritten.
public func receiveLanes(
_ sources: [ItemSource],
operation: TransferOperation,
at stripIndex: Int,
clearingTombstones: Bool
) {
guard !sources.isEmpty else { return } guard !sources.isEmpty else { return }
let root = rootURL let root = rootURL
@@ -1545,20 +1638,23 @@ public final class BoardStore {
guard let ranks else { return } guard let ranks else { return }
for (source, rank) in zip(sources, ranks) { for (source, rank) in zip(sources, ranks) {
switch operation { guard let arrived = try Self.materialize(
case .copy: source,
let arrived = try BoardWriter.copyItem(at: source, toParent: root, order: rank, stamps: .fork) operation: operation,
try BoardWriter.stripTombstonedChildren( intoParent: root,
of: root.appendingPathComponent(arrived.rawValue, isDirectory: true)
)
case .move:
_ = try BoardWriter.moveItem(
at: source,
toParent: root,
sourceBoardRoot: Self.boardRoot(ofLaneFolder: source),
destinationBoardRoot: root, destinationBoardRoot: root,
sourceBoardRoot: Self.boardRoot(ofLaneFolder:),
order: rank order: rank
) ) else { continue }
let laneFolder = root.appendingPathComponent(arrived.rawValue, isDirectory: true)
// The strip belongs to the copy alone: "a move never reads below its root, so the
// tombstones travel and the destination's trash quasi-lane renders them".
if operation == .copy {
try BoardWriter.stripTombstonedChildren(of: laneFolder)
}
if clearingTombstones {
try BoardWriter.restoreItem(at: laneFolder)
} }
} }
} }
+7 -1
View File
@@ -10,7 +10,13 @@ import Foundation
/// (`ItemReferenceSet`), so the kind of a selection is always re-derived from the snapshot. Storing /// (`ItemReferenceSet`), so the kind of a selection is always re-derived from the snapshot. Storing
/// it would be a second answer to a question the snapshot can always answer, and one that a reload /// it would be a second answer to a question the snapshot can always answer, and one that a reload
/// could falsify. /// could falsify.
public enum SelectionKind: Sendable, Equatable { ///
/// The **one** place a kind is written down is the clipboard manifest, which has no snapshot to
/// re-derive it from "the cards-XOR-lanes selection rule means the clipboard holds cards or lanes,
/// never both" (04-interactions.md Clipboard). Hence `String`-backed and `Codable`: those raw
/// spellings are pasteboard API, decoded after a relaunch, and they are the case names so there is
/// no second vocabulary to keep in step.
public enum SelectionKind: String, Codable, Sendable, Equatable {
case card case card
case lane case lane
} }
+6 -1
View File
@@ -9,7 +9,12 @@ import Observation
/// mixes live and tombstoned items, so the side is a property of the set as a whole rather than of /// mixes live and tombstoned items, so the side is a property of the set as a whole rather than of
/// each member which is exactly what makes re-resolution across a reload a matching rule rather /// each member which is exactly what makes re-resolution across a reload a matching rule rather
/// than a partition. /// than a partition.
public enum Liveness: Sendable, Equatable { ///
/// **`String`-backed and `Codable` because the clipboard manifest carries one** (04-interactions.md
/// Clipboard: a manifest records its entries' "source side (live/trashed)"). The raw spellings are
/// therefore pasteboard API a manifest written before a quit is decoded after the relaunch and
/// they are the case names so nothing has to remember a second vocabulary.
public enum Liveness: String, Codable, Sendable, Equatable {
case live case live
case trashed case trashed
+67
View File
@@ -830,6 +830,73 @@ public enum BoardWriter: Sendable {
return removed return removed
} }
/// Materializes an item from **supplied `index.md` text** rather than from a folder on disk
/// the clipboard's staging-less fallback (04-interactions.md Clipboard: "if the staged
/// snapshot is missing or unreadable at paste time, paste falls back to the embedded
/// `index.md` content intact, attachments absent").
///
/// **The text is written byte-faithfully, because it *is* the source bytes.** It was captured
/// verbatim at copy time and travels through the manifest untouched, so this call writes it as
/// given unknown keys, comments, blank lines, line endings, body and all rather than
/// re-serializing anything. That is the round-trip guarantee applied to a file the app is
/// minting from bytes it was handed (01-storage-format.md § Fractal layout Rules).
///
/// **Fresh identity, fork stamps** the same semantics `copyItem` gives an ordinary copy, and
/// necessarily so: this is a copy that happened to arrive as text. Every folder is a fresh mint,
/// `created` survives in the supplied bytes (a duplicate is a fork), and the root's `order` and
/// `modified` are rewritten by the closing `updateIndex`, which also clears `modified-by`.
///
/// `children` are a **lane's** cards, each its own supplied `index.md`, materialized under the
/// new root in the order given and deliberately *not* rewritten: "a nested item keeps its rank
/// among its own siblings, which travelled with it" (`copyItem`'s rule). A card passes none.
///
/// **The root gets `copyItem`'s strictness and the children get its leniency.** The root must be
/// rewritten it needs its new `order` so unparseable or uneditable text fails the call;
/// a child is never rewritten, so whatever it is arrives exactly as it was.
///
/// **All-or-nothing at the destination**, `copyItem`'s rule for its reason: any failure once the
/// folder exists removes the partial tree best-effort and rethrows, because a half-materialized
/// item is pure residue nothing was there before.
public static func materializeItem(
inParent destinationParent: URL,
indexText: String,
children: [String] = [],
order: Double?
) throws(BoardWriteError) -> ItemID {
let operation = WriteOperation.copy(title: nil)
try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation)
let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)
// The same mint the create path uses, so every identity this app materializes is materialized
// one way fresh lowercase UUIDv4, folder and all.
let root = try mintUUIDFolder(in: destinationParent, operation: operation)
do throws(BoardWriteError) {
try atomicReplace(
text: indexText,
at: root.appendingPathComponent(BoardLoader.indexFileName),
operation: operation
)
for child in children {
let childFolder = try mintUUIDFolder(in: root, operation: operation)
try atomicReplace(
text: child,
at: childFolder.appendingPathComponent(BoardLoader.indexFileName),
operation: operation
)
}
try updateIndex(inItemFolder: root, operation: operation) { document in
document.set(FrontmatterKeys.order, to: .double(rank))
}
} catch {
try? FileManager.default.removeItem(at: root)
throw error
}
return ItemID(rawValue: root.lastPathComponent)
}
// MARK: - Tombstone // MARK: - Tombstone
/// Tombstones a lane or card in place: writes `deleted: <now>` into its own `index.md` /// Tombstones a lane or card in place: writes `deleted: <now>` into its own `index.md`
+9
View File
@@ -30,6 +30,10 @@ import SwiftUI
/// outward, and Select All. The chorded commands are the menu's (`BoardCommands`); everything /// outward, and Select All. The chorded commands are the menu's (`BoardCommands`); everything
/// here is a plain key or a grammar modifier, which is exactly the split 04-interactions.md /// here is a plain key or a grammar modifier, which is exactly the split 04-interactions.md
/// Configurable bindings draws between what remaps and what does not. /// Configurable bindings draws between what remaps and what does not.
/// - **The standard Edit items the board answers as a responder** Select All, and Cut/Copy/Paste
/// beside it (`ClipboardCommands.swift`). They are not menu items of ours: the Edit menu already
/// carries those titles, and titles are the remapping mechanism's key, so a second row sharing one
/// is ruled out (04-interactions.md Configurable bindings).
/// ///
/// - **The trash quasi-lane** trailing, one fixed unit, joining and leaving the width division as /// - **The trash quasi-lane** trailing, one fixed unit, joining and leaving the width division as
/// View Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash). /// View Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash).
@@ -179,6 +183,11 @@ struct BoardView: View {
// forbids outright (04 Configurable bindings). A focused text field consumes it first, so // forbids outright (04 Configurable bindings). A focused text field consumes it first, so
// A inside an inline editor stays text selection with no guard needed here. // A inside an inline editor stays text selection with no guard needed here.
.onCommand(#selector(NSText.selectAll(_:))) { store.selectAll() } .onCommand(#selector(NSText.selectAll(_:))) { store.selectAll() }
// **Cut / Copy / Paste** (04-interactions.md Clipboard), through the same responder door
// Select All above uses and for the same titles-are-API reason. Each handler is attached only
// while its command applies, which is what makes AppKit's automatic enablement mirror the
// validation exactly see `boardClipboardCommands`.
.boardClipboardCommands(store: store, clipboard: appModel.clipboard)
// **Belt** for a session SwiftUI does report ending: immediate cleanup, gated on the physical // **Belt** for a session SwiftUI does report ending: immediate cleanup, gated on the physical
// mouse button being up. A finished session's phase events can arrive *after* the user has // mouse button being up. A finished session's phase events can arrive *after* the user has
// started the next drag, and an ungated handler would wipe the new session's state no // started the next drag, and an ungated handler would wipe the new session's state no
+78
View File
@@ -0,0 +1,78 @@
import AppKit
import SwiftUI
// MARK: - The Edit menu's clipboard row
/// Edit Cut / Copy / Paste (X / C / V) on the board 11-command-nexus.md's Edit row, whose
/// scope is "Board window: cards and lanes in the trash, C copy-out only (card and lane entries),
/// X disabled text editors: standard text clipboard".
///
/// ### Why this is a responder answer and not three menu items
///
/// **Select All's precedent, exactly** (`BoardView`): the standard Edit menu already carries these
/// three, and AppKit dispatches `cut:`/`copy:`/`paste:` down the responder chain, so the board
/// answers them as a responder. Adding items of our own would put a second "Copy"-titled row in the
/// menus, which titles-are-API forbids outright (04-interactions.md Configurable bindings) a
/// custom binding is stored against a title, and two rows sharing one would be ambiguous.
///
/// ### Availability is the handler's presence
///
/// `onCommand(_:perform:)` takes an **optional** action, and a `nil` action means the view does not
/// respond to that selector at all which is precisely what AppKit's automatic menu validation
/// reads. So attaching the handler conditionally *is* the validation: there is one condition per
/// command, it decides both whether the item is enabled and whether the gesture does anything, and
/// the two can never disagree because they are the same expression.
///
/// The conditions themselves live on `ClipboardStore` (`canCopy`/`canCut`/`canPaste`), beside the
/// gestures they gate, for the reason every rule in this codebase that can be a named predicate is
/// one: an item that is going to no-op should not look available.
///
/// ### The focused-editor rule, twice over
///
/// A focused text field consumes these selectors natively, so X/C/V inside an inline title editor
/// stay text operations without anything here doing the arithmetic. The predicates still refuse while
/// an editor is open (04 Grammar: "board-scoped menu commands disable via menu validation"),
/// which is belt over braces but a board command that stayed armed under an editor is exactly the
/// fall-through 04's fixed grammar is careful to rule out.
extension View {
/// Attaches the board's clipboard responders, each only while its command applies.
func boardClipboardCommands(store: BoardStore, clipboard: ClipboardStore) -> some View {
self
.onCommand(#selector(NSText.cut(_:)), perform: clipboard.canCut(from: store) ? {
clipboard.cut(from: store)
} : nil)
.onCommand(#selector(NSText.copy(_:)), perform: clipboard.canCopy(from: store) ? {
clipboard.copy(from: store)
} : nil)
.onCommand(#selector(NSText.paste(_:)), perform: clipboard.canPaste(into: store) ? {
clipboard.paste(into: store)
} : nil)
}
}
// MARK: - The deferred cut's treatment
extension View {
/// **Cut items dim in place until paste moves them** (04-interactions.md Clipboard).
///
/// The same reduced opacity a trash row wears while it is being dragged, and for the same reason:
/// the item is still there, still selectable, still the user's it is simply spoken for. A cut
/// item is deliberately *not* lifted out of the layout the way a dragged one is; a Finder-style
/// deferred cut promises the board looks unchanged until the paste lands.
///
/// Membership is read straight off `TransientBoardState.pendingCut`, which is where the rules
/// already live: a reload ejects a tombstoned or vanished member (so a deleted cut card undims by
/// itself), and `ClipboardStore` clears the set outright when the cut is consumed or voided.
func cutTreatment(of id: ItemID, in store: BoardStore) -> some View {
opacity(store.transient.pendingCut.ids.contains(id) ? ClipboardTreatment.dimmedOpacity : 1)
}
}
/// The one number the cut's treatment is (03-board-ui.md § Motion keeps every duration and curve in
/// `Motion`; this is neither, but it is the same "no literal at a call site" rule applied to the one
/// value three views share).
enum ClipboardTreatment {
static let dimmedOpacity: Double = 0.45
}
+8 -2
View File
@@ -27,8 +27,8 @@ import SwiftUI
/// ### What is still a later card's /// ### What is still a later card's
/// ///
/// The search-aware filtering behind the count belongs to a later milestone. The card face is real /// The search-aware filtering behind the count belongs to a later milestone. The card face is real
/// (`CardFaceView`); what it still owes is the cut treatment and the sole-selected card's attachment /// (`CardFaceView`) and wears the deferred cut's dim (`cutTreatment`); what it still owes is the
/// carousel. /// sole-selected card's attachment carousel.
struct LaneView: View { struct LaneView: View {
let store: BoardStore let store: BoardStore
@@ -97,6 +97,9 @@ struct LaneView: View {
} }
.background(selectionBackground) .background(selectionBackground)
.overlay(selectionStroke) .overlay(selectionStroke)
// The deferred cut's dim (04-interactions.md Clipboard) on the whole lane, because a cut
// lane is cut cards and all.
.cutTreatment(of: lane.id, in: store)
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { measuredHeight = $0 } .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { measuredHeight = $0 }
// **This lane's drop target**, on the whole body. It accepts *every* session type and routes // **This lane's drop target**, on the whole body. It accepts *every* session type and routes
// internally card sessions against this lane's masonry zones, lane sessions forwarded to // internally card sessions against this lane's masonry zones, lane sessions forwarded to
@@ -746,6 +749,9 @@ private struct CardFaceView: View {
lineWidth: isFileHovered ? 2.5 : 1.5 lineWidth: isFileHovered ? 2.5 : 1.5
) )
) )
// The deferred cut's dim (04-interactions.md Clipboard: "cut items dim in place until paste
// moves them"). Above `contentShape` so the face stays fully clickable while it waits.
.cutTreatment(of: card.id, in: store)
.contentShape(Rectangle()) .contentShape(Rectangle())
// **Clicking never edits** (04-interactions.md Selection, a pivot from the pathfinder's // **Clicking never edits** (04-interactions.md Selection, a pivot from the pathfinder's
// two-stage Finder rename): one click selects and that is all it does no timer, no // two-stage Finder rename): one click selects and that is all it does no timer, no
+39 -21
View File
@@ -56,16 +56,46 @@ enum NewCardTarget {
let lanes = snapshot.lanes.filter { !$0.isDeleted } let lanes = snapshot.lanes.filter { !$0.isDeleted }
guard !lanes.isEmpty else { return nil } guard !lanes.isEmpty else { return nil }
if selection.liveness == .live, !selection.ids.isEmpty { if let anchor = flattenAnchor(selection: selection, snapshot: snapshot) {
// The last selected member in flatten order lane `order`, then card `order`, the return anchor
// multi-drag order (04, settled; the same anchor serves paste). The snapshot's lanes }
// and cards are already in display order, so the flatten order is one walk, and the // Nothing selected, a tombstoned selection, or a stale one the ids name nothing the board
// *last* hit is the anchor. Selection is homogeneous (cards XOR lanes), so only one of // renders, a selection the next reload will drop. Falls through rather than refusing: the
// the two branches ever fires within a walk; a sole selection is simply the degenerate // 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. // one-member case of the same rule.
var anchor: Resolution? var anchor: Resolution?
for lane in lanes { for lane in snapshot.lanes where !lane.isDeleted {
// A selected lane: creation appends at its bottom, Return consistency. // A selected lane: creation appends at its bottom, Return consistency; paste lands after
// the lane itself.
if selection.ids.contains(lane.id) { if selection.ids.contains(lane.id) {
anchor = Resolution(laneID: lane.id, anchorCardID: nil) anchor = Resolution(laneID: lane.id, anchorCardID: nil)
} }
@@ -74,18 +104,6 @@ enum NewCardTarget {
anchor = Resolution(laneID: lane.id, anchorCardID: card.id) anchor = Resolution(laneID: lane.id, anchorCardID: card.id)
} }
} }
if let anchor { return anchor } return anchor
// The ids name nothing the board renders a selection the next reload will drop.
// Falls through to the last-active lane rather than refusing: the user pressed N and
// the board has lanes.
}
// Nothing selected, a tombstoned selection, or a stale one: 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) }
} }
} }
+76
View File
@@ -0,0 +1,76 @@
/// Where V lands (04-interactions.md Clipboard), as pure functions of the selection, the
/// last-active lane, and the snapshot (`PasteTargetTests`).
///
/// The two rules verbatim, and each clause's branch below:
///
/// > Paste lands after the anchor card (or appends to a selected lane); a multi-selection anchors at
/// > its last member in flatten order the N target rule's shared anchor. **A tombstoned
/// > selection never anchors paste**: V stays enabled and behaves exactly as with nothing selected
/// > a card payload appends to the last-active lane, a lane payload lands at the board's right end.
///
/// > **Lane paste** lands after the anchor lane the selected lane, or the selected card's lane
/// > (several selected: the last, per the shared anchor rule); nothing selected = the board's right
/// > end.
///
/// Plus 04 The map's zero-lane clause: "New Card, Return-creation, and Paste with a *card* payload
/// disable via menu validation until a lane exists Paste with a **lane** payload stays enabled
/// and lands at the board's right end".
///
/// **Pure, for `NewCardTarget`'s reason** the branches become lines of test rather than gestures to
/// drive, and the menu item's `disabled` reads the *same* answer as the paste's own target rather
/// than a second, hand-kept-in-sync condition.
///
/// ### What it deliberately reuses
///
/// The anchor is `NewCardTarget.flattenAnchor`, not a second walk: 04 says creation and paste share
/// one anchor ("the N target rule's shared anchor"), and two derivations of "the last member in
/// flatten order" would be two chances for them to disagree. The card branch goes further and reuses
/// `NewCardTarget.resolve` whole, because a card paste's *fallback* is the N rule's fallback too
/// the last-active lane, then the first lane. Only the lane branch stops at the anchor, because its
/// fallback is the board's right end instead.
enum PasteTarget {
/// Where a card payload lands: which lane, and the position among that lane's rendered cards.
struct Cards: Equatable {
let laneID: ItemID
/// A position in the lane's logical card order, counted among what it renders *now* the
/// convention every arrival path here takes (`BoardStore.receiveCards`).
let index: Int
}
/// The card payload's target, or `nil` on a **zero-lane board** which is therefore the menu
/// item's `disabled` condition as well as the paste's refusal, so the two cannot disagree.
static func cards(
selection: ItemReferenceSet,
lastActiveLaneID: ItemID?,
snapshot: BoardModel
) -> Cards? {
guard let resolution = NewCardTarget.resolve(
selection: selection,
lastActiveLaneID: lastActiveLaneID,
snapshot: snapshot
),
let lane = snapshot.lanes.first(where: { $0.id == resolution.laneID && !$0.isDeleted })
else { return nil }
let rendered = lane.cards.filter { !$0.isDeleted }
// `insertionIndex` answers `nil` for "append", which is `rendered.count` the same position
// said two ways, and the creation path's own degradation for an anchor that has since gone.
let index = BoardStore.insertionIndex(after: resolution.anchorCardID, among: rendered) ?? rendered.count
return Cards(laneID: lane.id, index: index)
}
/// The lane payload's slot among the board's live lanes **always an answer**, zero-lane board
/// included, because lane paste "stays enabled and lands at the board's right end" whatever the
/// board holds. That is what makes it the other way out of a board with no lanes.
static func lanes(selection: ItemReferenceSet, snapshot: BoardModel) -> Int {
let lanes = snapshot.lanes.filter { !$0.isDeleted }
guard let anchor = NewCardTarget.flattenAnchor(selection: selection, snapshot: snapshot),
let position = lanes.firstIndex(where: { $0.id == anchor.laneID })
else {
// Nothing selected, a tombstoned selection, or a stale one: the right end.
return lanes.count
}
return position + 1
}
}
+7 -1
View File
@@ -257,7 +257,13 @@ private struct TrashEntryRow: View {
rowFace rowFace
// The row being dragged out dims in place the source stays visible in the trash, // The row being dragged out dims in place the source stays visible in the trash,
// because a restore is not a removal until the write lands. // because a restore is not a removal until the write lands.
.opacity(drops.session.isDragging(entry.id) ? 0.45 : 1) .opacity(drops.session.isDragging(entry.id) ? ClipboardTreatment.dimmedOpacity : 1)
// The deferred cut wears the same dim wherever it lands, so the treatment is stated for
// every surface a `pendingCut` could name rather than for two of the three. In practice
// it never fires here: X is disabled on tombstoned selections (04-interactions.md The
// trash), and a pending cut is homogeneous by liveness a reload that tombstones a cut
// card *ejects* it from the set rather than moving it to the other side.
.cutTreatment(of: entry.id, in: store)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { select() } .onTapGesture { select() }
.marqueeTarget(entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed, in: registry) .marqueeTarget(entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed, in: registry)
+769
View File
@@ -0,0 +1,769 @@
import Foundation
import Testing
@testable import Kanban
/// `ClipboardStore`'s own machinery the manifest, the staging lifecycle, the sweep, the
/// changeCount-based takeover, and the deferred cut's arming and voiding (04-interactions.md
/// Clipboard). The *writes* a paste performs live in `PasteWriteTests.swift`.
///
/// Every suite here drives a real store over a real temp board, with two things injected: a fake
/// pasteboard (so a test never races the machine's one real pasteboard, nor every other test in the
/// run) and a temp staging directory (so nothing goes near Application Support). Both seams exist
/// exactly because those two claims are the ones worth pinning.
// MARK: - Test doubles
/// The pasteboard, as a value a test can shove around.
///
/// `changeCount` behaves the way `NSPasteboard`'s does a machine-wide counter that anyone's write
/// bumps because that is the whole basis of takeover detection, and a double that only counted
/// *our* writes would make the interesting case untestable.
@MainActor
final class FakePasteboard: ClipboardPasteboard {
private(set) var changeCount = 0
private(set) var text: String?
private var data: Data?
func manifestData() -> Data? { data }
@discardableResult
func write(manifest: Data, text: String) -> Int {
changeCount += 1
data = manifest
self.text = text
return changeCount
}
/// Another app copied: ownership moves, our type is gone, the counter advanced.
func takeOver() {
changeCount += 1
data = nil
text = nil
}
}
// MARK: - Fixtures
let clipboardLane1 = ItemID(rawValue: Ident.lane1)
let clipboardLane2 = ItemID(rawValue: Ident.lane2)
let clipboardCard1 = ItemID(rawValue: Ident.card1)
let clipboardCard2 = ItemID(rawValue: Ident.card2)
let clipboardCard3 = ItemID(rawValue: Ident.card3)
let clipboardCard4 = ItemID(rawValue: Ident.card4)
func tombstonedItem(order: String, title: String) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
created: 2026-01-01T09:00:00Z
deleted: 2026-03-03T09:00:00Z
---
\(title) body.
"""
}
/// Two lanes: `lane1` holds three live cards and one tombstoned one, `lane2` holds a single card.
/// `card1` carries two attachments, which is what makes "the snapshot travels whole" and "the
/// fallback lost exactly two files" both assertable.
@MainActor
func makeClipboardBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("png bytes".utf8))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt", Data("notes".utf8))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.item("\(Ident.lane1)/\(Ident.card3)", tombstonedItem(order: "3072", title: "Trashed"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
return fixture
}
/// A store plus the two seams, torn down together.
@MainActor
struct ClipboardHarness {
let fixture: WriterFixture
let staging: URL
let pasteboard: FakePasteboard
let clipboard: ClipboardStore
let store: BoardStore
init(fixture: WriterFixture) throws {
self.fixture = fixture
staging = FileManager.default.temporaryDirectory
.appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
pasteboard = FakePasteboard()
clipboard = ClipboardStore(pasteboard: pasteboard, stagingRoot: staging, observesActivation: false)
store = try BoardStore(rootURL: fixture.root)
}
func tearDown() {
try? FileManager.default.removeItem(at: staging)
fixture.tearDown()
}
/// The staged copy directories, sorted "at most the current copy" is a claim about this list.
func stagedCopyIDs() throws -> [String] {
try FileManager.default.contentsOfDirectory(atPath: staging.path).sorted()
}
}
@MainActor
func makeClipboardHarness() throws -> ClipboardHarness {
try ClipboardHarness(fixture: try makeClipboardBoard())
}
// MARK: - The manifest
@Suite("ClipboardManifest")
struct ClipboardManifestTests {
private func entry(_ id: String) -> ClipboardManifest.Entry {
ClipboardManifest.Entry(
id: id,
folder: id,
title: "First",
index: "---\nschema: 1\n---\nbody\n",
attachmentCount: 2
)
}
@Test("A manifest round-trips through JSON")
func roundTrip() throws {
let manifest = ClipboardManifest(
copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/Board.kanban", isDirectory: true),
kind: .card,
side: .live,
entries: [entry(Ident.card1)]
)
let data = try #require(manifest.encoded())
#expect(ClipboardManifest(data: data) == manifest)
}
@Test("A manifest from a future version is refused rather than half-read")
func futureVersion() throws {
var manifest = ClipboardManifest(
copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
kind: .card,
side: .live,
entries: [entry(Ident.card1)]
)
manifest.version = ClipboardManifest.currentVersion + 1
let data = try #require(manifest.encoded())
#expect(ClipboardManifest(data: data) == nil)
}
@Test("An entryless manifest is nothing to paste")
func empty() throws {
let manifest = ClipboardManifest(
copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
kind: .card,
side: .live,
entries: []
)
let data = try #require(manifest.encoded())
#expect(ClipboardManifest(data: data) == nil)
}
@Test("A lane entry's lost-attachment count totals its cards'")
func lostAttachments() {
let lane = ClipboardManifest.Entry(
id: Ident.lane1,
folder: Ident.lane1,
title: "Todo",
index: "---\nschema: 1\n---\n",
attachmentCount: 0,
cards: [
.init(id: Ident.card1, title: "First", index: "a", attachmentCount: 2),
.init(id: Ident.card2, title: "Second", index: "b", attachmentCount: 1),
]
)
#expect(lane.lostAttachmentCount == 3)
}
@Test("The plain-text rendering is the titles, untitled items rendered as the board renders them")
func plainText() {
var titled = entry(Ident.card1)
var untitled = entry(Ident.card2)
untitled.title = nil
titled.title = "First"
let manifest = ClipboardManifest(
copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
kind: .card,
side: .live,
entries: [titled, untitled]
)
#expect(manifest.plainText == "First\nUntitled")
}
}
// MARK: - Copy and staging
@MainActor
@Suite("ClipboardStore ▸ copy")
struct ClipboardCopyTests {
@Test("A copy stages the whole card folder, attachments and all")
func stagesAttachments() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let manifest = try #require(harness.clipboard.payload)
let staged = harness.staging
.appendingPathComponent(manifest.copyID, isDirectory: true)
.appendingPathComponent(Ident.card1, isDirectory: true)
#expect(FileManager.default.fileExists(atPath: staged.appendingPathComponent("index.md").path))
#expect(try Data(contentsOf: staged.appendingPathComponent("attachments/photo.png"))
== Data("png bytes".utf8))
#expect(try Data(contentsOf: staged.appendingPathComponent("attachments/notes.txt"))
== Data("notes".utf8))
}
@Test("The manifest records identity, side, kind and the source board root")
func manifestShape() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1, clipboardCard2], liveness: .live)
harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
#expect(manifest.kind == .card)
#expect(manifest.side == .live)
#expect(manifest.rootURL.path == harness.fixture.root.path)
// Flatten order lane `order`, then card `order`.
#expect(manifest.entries.map(\.id) == [Ident.card1, Ident.card2])
#expect(manifest.entries.map(\.title) == ["First", "Second"])
#expect(manifest.entries[0].attachmentCount == 2)
#expect(manifest.entries[1].attachmentCount == 0)
#expect(harness.pasteboard.text == "First\nSecond")
}
@Test("The embedded index text is the file's bytes, verbatim")
func embeddedIndexIsSourceBytes() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
let onDisk = try harness.fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
#expect(manifest.entries[0].index == onDisk)
}
@Test("A lane copy embeds its live cards and leaves the tombstoned one out")
func laneEntryEmbedsLiveCards() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardLane1], liveness: .live)
harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
#expect(manifest.kind == .lane)
#expect(manifest.entries.map(\.id) == [Ident.lane1])
#expect(manifest.entries[0].cards.map(\.id) == [Ident.card1, Ident.card2])
#expect(manifest.entries[0].lostAttachmentCount == 2)
}
@Test("A trashed selection copies out, side recorded")
func trashedSide() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardCard3], liveness: .trashed)
harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
#expect(manifest.side == .trashed)
#expect(manifest.entries.map(\.id) == [Ident.card3])
}
@Test("An empty selection copies nothing and leaves the pasteboard alone")
func emptySelection() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.clipboard.copy(from: harness.store)
#expect(harness.pasteboard.changeCount == 0)
#expect(harness.clipboard.payload == nil)
}
@Test("The store holds at most the current copy — a second copy sweeps the first")
func atMostTheCurrentCopy() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let first = try #require(harness.clipboard.payload?.copyID)
harness.store.select([clipboardCard2], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let second = try #require(harness.clipboard.payload?.copyID)
#expect(first != second)
#expect(try harness.stagedCopyIDs() == [second])
}
}
// MARK: - The sweep
@MainActor
@Suite("ClipboardStore ▸ sweep")
struct ClipboardSweepTests {
@Test("A launch sweep collects every tree the pasteboard no longer names")
func launchSweepPurgesOrphans() async throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: staging) }
// Two trees from a previous launch, and a pasteboard that names neither.
for orphan in ["one", "two"] {
try FileManager.default.createDirectory(
at: staging.appendingPathComponent(orphan, isDirectory: true),
withIntermediateDirectories: true
)
}
let clipboard = ClipboardStore(
pasteboard: FakePasteboard(),
stagingRoot: staging,
observesActivation: false
)
await clipboard.stagingSettled()
#expect(try FileManager.default.contentsOfDirectory(atPath: staging.path).isEmpty)
}
@Test("A sweep keeps the tree the pasteboard still names")
func sweepKeepsTheCurrentCopy() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
try FileManager.default.createDirectory(
at: harness.staging.appendingPathComponent("stale", isDirectory: true),
withIntermediateDirectories: true
)
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let current = try #require(harness.clipboard.payload?.copyID)
#expect(try harness.stagedCopyIDs() == [current])
}
@Test("A takeover makes our own tree an orphan, and the next sweep collects it")
func takeoverOrphansOurSnapshot() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
#expect(try harness.stagedCopyIDs().count == 1)
harness.pasteboard.takeOver()
harness.clipboard.sweep()
await harness.clipboard.stagingSettled()
#expect(try harness.stagedCopyIDs().isEmpty)
}
}
// MARK: - Takeover
@MainActor
@Suite("ClipboardStore ▸ takeover")
struct ClipboardTakeoverTests {
@Test("A changeCount that moved without us is a takeover: the payload goes")
func payloadClears() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
#expect(harness.clipboard.payload != nil)
harness.pasteboard.takeOver()
harness.clipboard.refresh()
#expect(harness.clipboard.payload == nil)
#expect(harness.clipboard.canPaste(into: harness.store) == false)
}
@Test("An unchanged changeCount is one read and no decode")
func refreshIsGuarded() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
let before = harness.pasteboard.changeCount
harness.clipboard.refresh()
harness.clipboard.refresh()
// Nothing was written, so nothing moved and the payload survived the two refreshes intact.
#expect(harness.pasteboard.changeCount == before)
#expect(harness.clipboard.payload != nil)
}
@Test("A takeover voids an armed cut and undims its items")
func takeoverVoidsTheCut() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.cut(from: harness.store)
#expect(harness.store.transient.pendingCut.ids == [clipboardCard1])
harness.pasteboard.takeOver()
harness.clipboard.refresh()
#expect(harness.store.transient.pendingCut.isEmpty)
}
}
// MARK: - Cut arming
@MainActor
@Suite("ClipboardStore ▸ cut")
struct ClipboardCutTests {
@Test("A cut arms the source board's pending set, in flatten order membership")
func armsPendingCut() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1, clipboardCard4], liveness: .live)
harness.clipboard.cut(from: harness.store)
#expect(harness.store.transient.pendingCut.ids == [clipboardCard1, clipboardCard4])
#expect(harness.store.transient.pendingCut.liveness == .live)
}
@Test("A second copy voids the pending cut — its pasteboard entry has been overwritten")
func copyVoidsTheCut() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.cut(from: harness.store)
#expect(!harness.store.transient.pendingCut.isEmpty)
harness.store.select([clipboardCard2], liveness: .live)
harness.clipboard.copy(from: harness.store)
#expect(harness.store.transient.pendingCut.isEmpty)
}
@Test("Deletion voids per item: a tombstoned cut member leaves the pending set on reload")
func deletionVoidsPerItem() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1, clipboardCard2], liveness: .live)
harness.clipboard.cut(from: harness.store)
harness.store.delete([clipboardCard1])
harness.store.handleWatcherEvent(.treeChanged(.appMediated))
await harness.store.awaitQuiescence()
#expect(harness.store.transient.pendingCut.ids == [clipboardCard2])
}
}
// MARK: - Availability
@MainActor
@Suite("ClipboardStore ▸ availability")
struct ClipboardAvailabilityTests {
@Test("Copy needs a selection that names something the board renders")
func copyNeedsASelection() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
#expect(harness.clipboard.canCopy(from: harness.store) == false)
harness.store.select([clipboardCard1], liveness: .live)
#expect(harness.clipboard.canCopy(from: harness.store))
}
@Test("Copy works on a trashed selection; cut does not")
func trashIsCopyOutOnly() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardCard3], liveness: .trashed)
#expect(harness.clipboard.canCopy(from: harness.store))
#expect(harness.clipboard.canCut(from: harness.store) == false)
}
@Test("The read-only lock blocks cut but never copy")
func lockBlocksCutOnly() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.enterVanishedRootLock()
#expect(harness.clipboard.canCopy(from: harness.store))
#expect(harness.clipboard.canCut(from: harness.store) == false)
#expect(harness.clipboard.canPaste(into: harness.store) == false)
}
@Test("An open inline editor closes all three — the focused-editor rule")
func focusedEditorClosesEverything() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
harness.store.transient.beginRename(of: clipboardCard1, currentTitle: "First")
#expect(harness.clipboard.canCopy(from: harness.store) == false)
#expect(harness.clipboard.canCut(from: harness.store) == false)
#expect(harness.clipboard.canPaste(into: harness.store) == false)
}
@Test("Paste needs a payload")
func pasteNeedsAPayload() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
#expect(harness.clipboard.canPaste(into: harness.store) == false)
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
#expect(harness.clipboard.canPaste(into: harness.store))
}
@Test("On a zero-lane board a card payload disables paste and a lane payload does not")
func zeroLaneBoard() async throws {
let source = try makeClipboardBoard()
defer { source.tearDown() }
let empty = try WriterFixture()
defer { empty.tearDown() }
try empty.item("", Item.board)
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: staging) }
let clipboard = ClipboardStore(
pasteboard: FakePasteboard(),
stagingRoot: staging,
observesActivation: false
)
let sourceStore = try BoardStore(rootURL: source.root)
let emptyStore = try BoardStore(rootURL: empty.root)
sourceStore.select([clipboardCard1], liveness: .live)
clipboard.copy(from: sourceStore)
#expect(clipboard.canPaste(into: emptyStore) == false)
sourceStore.select([clipboardLane1], liveness: .live)
clipboard.copy(from: sourceStore)
#expect(clipboard.canPaste(into: emptyStore))
}
}
// MARK: - The paste anchors
@MainActor
@Suite("PasteTarget")
struct PasteTargetTests {
private func snapshot(_ fixture: WriterFixture) throws -> BoardModel {
try BoardLoader.load(boardRoot: fixture.root).model
}
@Test("A card payload lands after the anchor card")
func afterTheAnchorCard() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let model = try snapshot(fixture)
let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardCard1], liveness: .live),
lastActiveLaneID: nil,
snapshot: model
)
#expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 1))
}
@Test("A selected lane appends to its bottom")
func appendsToASelectedLane() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let model = try snapshot(fixture)
let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardLane1], liveness: .live),
lastActiveLaneID: nil,
snapshot: model
)
// Two rendered cards the tombstoned third is not in the layout.
#expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 2))
}
@Test("A multi-selection anchors at its last member in flatten order")
func flattenOrderAnchor() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let model = try snapshot(fixture)
let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardCard4, clipboardCard1], liveness: .live),
lastActiveLaneID: nil,
snapshot: model
)
// `card4` is in the second lane, so it is last in flatten order however the set is spelled.
#expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1))
}
@Test("A tombstoned selection never anchors: it behaves as nothing selected")
func tombstonedSelectionNeverAnchors() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let model = try snapshot(fixture)
let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardCard3], liveness: .trashed),
lastActiveLaneID: clipboardLane2,
snapshot: model
)
// The last-active lane, appended never `card3`'s live disk-lane.
#expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1))
}
@Test("Nothing selected and no last-active lane falls back to the first lane")
func firstLaneFallback() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let model = try snapshot(fixture)
let target = PasteTarget.cards(selection: .empty, lastActiveLaneID: nil, snapshot: model)
#expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 2))
}
@Test("A zero-lane board has no card target at all")
func zeroLaneBoardHasNoCardTarget() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
let model = try snapshot(fixture)
#expect(PasteTarget.cards(selection: .empty, lastActiveLaneID: nil, snapshot: model) == nil)
}
@Test("A lane payload lands after the anchor lane")
func afterTheAnchorLane() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let model = try snapshot(fixture)
#expect(PasteTarget.lanes(
selection: ItemReferenceSet(ids: [clipboardLane1], liveness: .live),
snapshot: model
) == 1)
}
@Test("A selected card names its lane for a lane paste")
func aSelectedCardNamesItsLane() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let model = try snapshot(fixture)
#expect(PasteTarget.lanes(
selection: ItemReferenceSet(ids: [clipboardCard1], liveness: .live),
snapshot: model
) == 1)
}
@Test("Nothing (or something tombstoned) selected lands a lane at the board's right end")
func rightEnd() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let model = try snapshot(fixture)
#expect(PasteTarget.lanes(selection: .empty, snapshot: model) == 2)
#expect(PasteTarget.lanes(
selection: ItemReferenceSet(ids: [clipboardCard3], liveness: .trashed),
snapshot: model
) == 2)
}
@Test("A zero-lane board still has a lane slot — position zero")
func zeroLaneBoardStillTakesALane() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
let model = try BoardLoader.load(boardRoot: fixture.root).model
#expect(PasteTarget.lanes(selection: .empty, snapshot: model) == 0)
}
}
// MARK: - The degraded paste's phrasing
@Suite("BannerCenter ▸ degraded paste")
struct DegradedPasteBannerTests {
@Test("04's own example sentence")
func theExampleSentence() {
#expect(BannerCenter.degradedPasteMessage(
for: [.init(title: "Fix login", attachments: 3)]
) == "Pasted 'Fix login' without its 3 attachments")
}
@Test("One attachment is singular")
func singular() {
#expect(BannerCenter.degradedPasteMessage(
for: [.init(title: "Fix login", attachments: 1)]
) == "Pasted 'Fix login' without its attachment")
}
@Test("An untitled item is 'the item', never the Untitled rendering")
func untitled() {
#expect(BannerCenter.degradedPasteMessage(
for: [.init(title: nil, attachments: 2)]
) == "Pasted the item without its 2 attachments")
}
@Test("Several items total their attachments rather than listing titles")
func several() {
#expect(BannerCenter.degradedPasteMessage(
for: [.init(title: "A", attachments: 2), .init(title: "B", attachments: 3)]
) == "Pasted 2 items without their 5 attachments")
}
@Test("Nothing lost says nothing")
func nothingLost() {
#expect(BannerCenter.degradedPasteMessage(for: []) == nil)
#expect(BannerCenter.degradedPasteMessage(for: [.init(title: "A", attachments: 0)]) == nil)
}
@Test("Posting an empty loss list adds no row")
@MainActor
func postingNothing() {
let center = BannerCenter()
center.postDegradedPaste([])
#expect(center.signposts.isEmpty)
}
}
+646
View File
@@ -0,0 +1,646 @@
import Foundation
import Testing
@testable import Kanban
/// What a paste actually writes (04-interactions.md Clipboard) the materialization rules, the
/// armed cut's move, and the anchors applied end to end.
///
/// Like every other write suite here these drive a **real store over a real temp board** and then
/// read back through the loader or the raw bytes, never through a snapshot the store handed out: the
/// interesting claims are about the files which folder arrived, which UUID was minted, which
/// `deleted:` was stripped, which attachment travelled. `WriterFixture`, `Ident` and `Item` come from
/// `WriterTestSupport.swift`; `FakePasteboard`, `ClipboardHarness` and the board fixture come from
/// `ClipboardTests.swift`.
// MARK: - Helpers
/// The board as the loader sees it never the store's snapshot, which a paste deliberately does not
/// touch (the one-way flow: the write lands, the watcher reloads).
private func pasted(_ fixture: WriterFixture) throws -> BoardModel {
try BoardLoader.load(boardRoot: fixture.root).model
}
private func lane(_ id: ItemID, in fixture: WriterFixture) throws -> Lane? {
try pasted(fixture).lanes.first { $0.id == id }
}
/// A lane's rendered card titles, in display order.
private func pastedTitles(_ id: ItemID, in fixture: WriterFixture) throws -> [String] {
try lane(id, in: fixture)?.cards.filter { !$0.isDeleted }.compactMap(\.title.value) ?? []
}
/// A lane's rendered card folder names, in display order identity, where titles cannot tell an
/// original from its copy.
private func pastedIDs(_ id: ItemID, in fixture: WriterFixture) throws -> [String] {
try lane(id, in: fixture)?.cards.filter { !$0.isDeleted }.map(\.id.rawValue) ?? []
}
/// A fresh, empty destination board one lane holding one card, so an arrival has neighbours.
@MainActor
private func makeDestination() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane4, Item.rich(order: "1024", title: "Inbox"))
try fixture.item("\(Ident.lane4)/\(Ident.indexless)", Item.rich(order: "1024", title: "Resident"))
return fixture
}
private let destinationLane = ItemID(rawValue: Ident.lane4)
// MARK: - Copy materialization
@MainActor
@Suite("Paste ▸ copy from staging")
struct PasteFromStagingTests {
@Test("A pasted card is byte-perfect from the snapshot, attachments and all, under a fresh GUID")
func bytePerfectCopy() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
target.select([destinationLane], liveness: .live)
await harness.clipboard.paste(into: target)?.value
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
// Fresh identity "copies mint fresh ones" (01-storage-format.md).
#expect(arrived != Ident.card1)
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "First"])
// The attachments came with it, byte for byte.
#expect(try destination.data("\(Ident.lane4)/\(arrived)/attachments/photo.png")
== Data("png bytes".utf8))
#expect(try destination.data("\(Ident.lane4)/\(arrived)/attachments/notes.txt")
== Data("notes".utf8))
}
@Test("A copy keeps `created` and takes a fresh `modified` — a duplicate is a fork")
func forkStamps() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
target.select([destinationLane], liveness: .live)
await harness.clipboard.paste(into: target)?.value
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
let document = try FrontmatterDocument.parse(destination.indexText("\(Ident.lane4)/\(arrived)"))
#expect(document.created.value == ISO8601DateFormatter().date(from: "2026-01-01T09:00:00Z"))
#expect(document.modified.value != ISO8601DateFormatter().date(from: "2026-02-02T09:00:00Z"))
// The unknown keys and the body rode along untouched.
#expect(document.value(for: "project") != nil)
#expect(document.body.contains("First body"))
}
@Test("The originals stay exactly where they were")
func originalsUntouched() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
target.select([destinationLane], liveness: .live)
await harness.clipboard.paste(into: target)?.value
#expect(try pastedTitles(clipboardLane1, in: harness.fixture) == ["First", "Second"])
}
@Test("A second paste materializes a second copy")
func secondPasteCopiesAgain() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
target.select([destinationLane], liveness: .live)
await harness.clipboard.paste(into: target)?.value
target.handleWatcherEvent(.treeChanged(.appMediated))
await target.awaitQuiescence()
target.select([destinationLane], liveness: .live)
await harness.clipboard.paste(into: target)?.value
let ids = try pastedIDs(destinationLane, in: destination)
#expect(ids.count == 3)
#expect(Set(ids).count == 3)
}
@Test("Pasting into the source board is the within-board duplicate")
func pasteIntoSource() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: harness.store)?.value
// The copy landed immediately after its own original, which is the anchor rule.
#expect(try pastedTitles(clipboardLane1, in: harness.fixture) == ["First", "First", "Second"])
let ids = try pastedIDs(clipboardLane1, in: harness.fixture)
#expect(Set(ids).count == 3)
}
}
// MARK: - Lane pastes
@MainActor
@Suite("Paste ▸ lanes")
struct PasteLaneTests {
@Test("A pasted lane copy takes fresh GUIDs throughout and strips tombstoned cards")
func laneCopyStripsTombstones() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardLane1], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: target)?.value
let model = try pasted(destination)
#expect(model.lanes.count == 2)
let arrived = try #require(model.lanes.last)
#expect(arrived.id.rawValue != Ident.lane1)
#expect(arrived.title.value == "Todo")
// The tombstoned card is gone "the copy transfers content, and trash isn't content".
#expect(arrived.cards.count == 2)
#expect(arrived.cards.allSatisfy { !$0.isDeleted })
#expect(arrived.cards.map(\.id.rawValue).allSatisfy { $0 != Ident.card1 && $0 != Ident.card2 })
// The tombstoned original is still recoverable where it always was.
#expect(try lane(clipboardLane1, in: harness.fixture)?.cards.count == 3)
}
@Test("A lane paste with nothing selected lands at the board's right end")
func laneLandsAtTheRightEnd() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardLane1], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: target)?.value
#expect(try pasted(destination).lanes.compactMap(\.title.value) == ["Inbox", "Todo"])
}
@Test("A lane pastes onto a board with no lanes at all")
func laneOntoZeroLaneBoard() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let empty = try WriterFixture()
defer { empty.tearDown() }
try empty.item("", Item.board)
let target = try BoardStore(rootURL: empty.root)
harness.store.select([clipboardLane1], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: target)?.value
#expect(try pasted(empty).lanes.compactMap(\.title.value) == ["Todo"])
}
@Test("A lane cut-move carries its tombstoned cards whole")
func laneCutMoveCarriesTombstones() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardLane1], liveness: .live)
harness.clipboard.cut(from: harness.store)
await harness.clipboard.paste(into: target)?.value
let model = try pasted(destination)
let arrived = try #require(model.lanes.first { $0.id == clipboardLane1 })
// Identity travelled, and the tombstone landed in the destination's trash.
#expect(arrived.cards.count == 3)
#expect(arrived.cards.contains { $0.isDeleted })
// The lane left the source board entirely.
#expect(try pasted(harness.fixture).lanes.map(\.id) == [clipboardLane2])
}
@Test("The within-board lane duplicate — paste into the source board")
func withinBoardLaneDuplicate() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardLane1], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: harness.store)?.value
let model = try pasted(harness.fixture)
#expect(model.lanes.compactMap(\.title.value) == ["Todo", "Todo", "Doing"])
let duplicate = try #require(model.lanes.dropFirst().first)
#expect(duplicate.id != clipboardLane1)
#expect(duplicate.cards.count == 2)
}
}
// MARK: - The trash's copy-out
@MainActor
@Suite("Paste ▸ from the trash")
struct PasteFromTrashTests {
@Test("A card copied out of the trash arrives live")
func cardArrivesLive() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardCard3], liveness: .trashed)
harness.clipboard.copy(from: harness.store)
target.select([destinationLane], liveness: .live)
await harness.clipboard.paste(into: target)?.value
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Trashed"])
// And the tombstoned original stays in the source trash copy-out, never a move.
#expect(try lane(clipboardLane1, in: harness.fixture)?
.cards.first { $0.id == clipboardCard3 }?.isDeleted == true)
}
@Test("A lane entry copied out of the trash arrives live, its tombstoned interior cards stripped")
func laneEntryStripsBothWays() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
// A tombstoned lane holding one plain card and one that carries its own tombstone.
try fixture.item(Ident.lane1, tombstonedItem(order: "1024", title: "Archive"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Kept"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstonedItem(order: "2048", title: "Gone"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
let harness = try ClipboardHarness(fixture: fixture)
defer { try? FileManager.default.removeItem(at: harness.staging) }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardLane1], liveness: .trashed)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: target)?.value
let model = try pasted(destination)
let arrived = try #require(model.lanes.last)
#expect(arrived.isDeleted == false)
#expect(arrived.title.value == "Archive")
#expect(arrived.cards.count == 1)
#expect(arrived.cards.first?.title.value == "Kept")
#expect(arrived.cards.first?.isDeleted == false)
}
}
// MARK: - The deferred cut
@MainActor
@Suite("Paste ▸ the deferred cut")
struct PasteCutTests {
@Test("The first armed paste moves the originals and clears the cut")
func armedPasteMoves() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.cut(from: harness.store)
target.select([destinationLane], liveness: .live)
await harness.clipboard.paste(into: target)?.value
// Identity travelled.
#expect(try pastedIDs(destinationLane, in: destination) == [Ident.indexless, Ident.card1])
// The attachments came with the folder.
#expect(try destination.data("\(Ident.lane4)/\(Ident.card1)/attachments/photo.png")
== Data("png bytes".utf8))
// The original left the source board.
#expect(try pastedTitles(clipboardLane1, in: harness.fixture) == ["Second"])
#expect(harness.store.transient.pendingCut.isEmpty)
}
@Test("A second paste after an armed cut materializes a copy from staging")
func secondPasteAfterACutCopies() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.cut(from: harness.store)
target.select([destinationLane], liveness: .live)
await harness.clipboard.paste(into: target)?.value
target.handleWatcherEvent(.treeChanged(.appMediated))
await target.awaitQuiescence()
target.select([destinationLane], liveness: .live)
await harness.clipboard.paste(into: target)?.value
let ids = try pastedIDs(destinationLane, in: destination)
#expect(ids.count == 3)
#expect(ids.contains(Ident.card1))
// The second arrival is a fresh identity, not the moved one seen twice.
#expect(Set(ids).count == 3)
}
@Test("A voided cut downgrades to a copy: the originals stay")
func voidedCutCopies() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.cut(from: harness.store)
// The source board closes its store goes, and with it the cut's arming.
harness.store.transient.pendingCut = .empty
target.select([destinationLane], liveness: .live)
await harness.clipboard.paste(into: target)?.value
// A copy: a fresh identity at the destination, and the original still at home.
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
#expect(arrived != Ident.card1)
#expect(try pastedTitles(clipboardLane1, in: harness.fixture) == ["First", "Second"])
}
@Test("Per-item voiding: the paste moves only the survivors")
func survivorsOnly() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1, clipboardCard2], liveness: .live)
harness.clipboard.cut(from: harness.store)
// One of the two is tombstoned before the paste: the reload ejects it from the pending cut.
harness.store.delete([clipboardCard1])
harness.store.handleWatcherEvent(.treeChanged(.appMediated))
await harness.store.awaitQuiescence()
#expect(harness.store.transient.pendingCut.ids == [clipboardCard2])
target.select([destinationLane], liveness: .live)
await harness.clipboard.paste(into: target)?.value
#expect(try pastedIDs(destinationLane, in: destination) == [Ident.indexless, Ident.card2])
// The tombstoned one stayed behind, in the source board's trash.
#expect(try lane(clipboardLane1, in: harness.fixture)?
.cards.first { $0.id == clipboardCard1 }?.isDeleted == true)
}
@Test("A cut emptied down to nothing is simply void — a paste copies instead")
func emptiedCutIsVoid() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.cut(from: harness.store)
harness.store.delete([clipboardCard1])
harness.store.handleWatcherEvent(.treeChanged(.appMediated))
await harness.store.awaitQuiescence()
#expect(harness.store.transient.pendingCut.isEmpty)
target.select([destinationLane], liveness: .live)
await harness.clipboard.paste(into: target)?.value
// The staged snapshot is still there, so the paste is a copy content intact.
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "First"])
}
}
// MARK: - The staging-less fallback
@MainActor
@Suite("Paste ▸ the staging-less fallback")
struct PasteFallbackTests {
@Test("A missing snapshot falls back to the embedded index.md, byte-faithfully")
func fallbackWritesTheSourceBytes() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
// The snapshot goes a swept tree, a full disk, an unreadable container.
let copyID = try #require(harness.clipboard.payload?.copyID)
try FileManager.default.removeItem(
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
)
target.select([destinationLane], liveness: .live)
await harness.clipboard.paste(into: target)?.value
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
let document = try FrontmatterDocument.parse(destination.indexText("\(Ident.lane4)/\(arrived)"))
#expect(document.title.value == "First")
// Content intact: unknown keys, the comment's key, and the body all survived.
#expect(document.value(for: "project") != nil)
#expect(document.value(for: "labels") != nil)
#expect(document.body.contains("First body — with *markdown*"))
// `created` kept, fresh `order`.
#expect(document.created.value == ISO8601DateFormatter().date(from: "2026-01-01T09:00:00Z"))
#expect(document.order.value != 1024)
// Attachments absent which is exactly what the banner is about to say.
#expect(!destination.exists("\(Ident.lane4)/\(arrived)/attachments"))
}
@Test("A degraded paste banners, naming exactly what was lost")
func fallbackBanners() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let copyID = try #require(harness.clipboard.payload?.copyID)
try FileManager.default.removeItem(
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
)
target.select([destinationLane], liveness: .live)
await harness.clipboard.paste(into: target)?.value
#expect(target.banners.signposts.map(\.message) == ["Pasted 'First' without its 2 attachments"])
}
@Test("A fallback that lost nothing says nothing")
func fallbackWithoutAttachmentsIsSilent() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
// `card2` has no attachments, so a fallback loses nothing at all.
harness.store.select([clipboardCard2], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let copyID = try #require(harness.clipboard.payload?.copyID)
try FileManager.default.removeItem(
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
)
target.select([destinationLane], liveness: .live)
await harness.clipboard.paste(into: target)?.value
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Second"])
#expect(target.banners.signposts.isEmpty)
}
@Test("A lane's fallback materializes its embedded cards")
func laneFallbackCarriesItsCards() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardLane1], liveness: .live)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let copyID = try #require(harness.clipboard.payload?.copyID)
try FileManager.default.removeItem(
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
)
await harness.clipboard.paste(into: target)?.value
let arrived = try #require(try pasted(destination).lanes.last)
#expect(arrived.title.value == "Todo")
// The two live cards, and not the tombstoned third.
#expect(arrived.cards.count == 2)
#expect(Set(arrived.cards.compactMap(\.title.value)) == ["First", "Second"])
#expect(target.banners.signposts.map(\.message) == ["Pasted 'Todo' without its 2 attachments"])
}
@Test("A trash-sourced fallback still strips `deleted:` at materialization")
func trashedFallbackStripsDeleted() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardCard3], liveness: .trashed)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let copyID = try #require(harness.clipboard.payload?.copyID)
try FileManager.default.removeItem(
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
)
target.select([destinationLane], liveness: .live)
await harness.clipboard.paste(into: target)?.value
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Trashed"])
}
}
// MARK: - BoardWriter.materializeItem
@Suite("BoardWriter ▸ materializeItem")
struct MaterializeItemTests {
@Test("The supplied bytes land verbatim but for the rewritten order and stamps")
func writesTheSuppliedBytes() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
let id = try BoardWriter.materializeItem(
inParent: fixture.url(Ident.lane1),
indexText: Item.rich(order: "9999", title: "Pasted"),
order: 512
)
let document = try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(id.rawValue)"))
#expect(document.title.value == "Pasted")
#expect(document.order.value == 512)
#expect(document.value(for: "project") != nil)
#expect(document.value(for: "labels") != nil)
// The app-write stamps: `modified` set, `modified-by` cleared.
#expect(document.modified.value != ISO8601DateFormatter().date(from: "2026-02-02T09:00:00Z"))
#expect(document.modifiedBy.isMissing)
// `created` untouched a paste is a fork.
#expect(document.created.value == ISO8601DateFormatter().date(from: "2026-01-01T09:00:00Z"))
}
@Test("Children are materialized under fresh identities and never rewritten")
func childrenAreVerbatim() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
let id = try BoardWriter.materializeItem(
inParent: fixture.root,
indexText: Item.rich(order: "1024", title: "Lane"),
children: [Item.rich(order: "1024", title: "One"), Item.uneditable],
order: 1024
)
let lane = try #require(try BoardLoader.load(boardRoot: fixture.root).model.lanes.first)
#expect(lane.id == id)
#expect(lane.cards.count == 2)
// An uneditable child arrives exactly as it was the leniency `copyItem` extends below its
// root, applied here.
let names = try FileManager.default.contentsOfDirectory(atPath: fixture.url(id.rawValue).path)
.filter { $0 != "index.md" }
let odd = try #require(names.first { name in
(try? fixture.indexText("\(id.rawValue)/\(name)")) == Item.uneditable
})
#expect(try fixture.indexText("\(id.rawValue)/\(odd)") == Item.uneditable)
}
@Test("An unparseable root refuses and leaves nothing behind")
func unparseableRootLeavesNoResidue() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
let error = writeFailure {
_ = try BoardWriter.materializeItem(
inParent: fixture.root,
indexText: "no frontmatter here at all\n",
order: 1024
)
}
#expect(error != nil)
#expect(try fixture.entryNames("") == ["index.md"])
}
}
+2
View File
@@ -20,6 +20,8 @@ Lanework is in early development. This list tracks what has actually shipped and
- **The keyboard** — the board is fully operable without the mouse, and every board function has a menu item. Arrows walk to the nearest card in the direction — across a lane's interior masonry columns, across lanes, and into and out of the shown trash — with ⇧-arrows extending the range from the anchor and stopping dead at the liveness and card/lane-entry boundaries rather than silently reaching past them. ⌥-arrows jump: ⌥↑/⌥↓ to the lane's first/last card, ⌥←/⌥→ to the first/last lane (⌥→ reaching the trash when it's shown), and a second ⌥↑ on a lane's first card escalates into selecting the lane itself — the one keyboard entry to lane selection, from which ←/→ move between lanes, ↓ descends back into the cards, and an empty selection seeds at the first lane's first card so an arrow from nothing always means the same thing. The selection scrolls itself into view. The Board menu carries the rest: Open Card (⌘↩) — the one command that stays live mid-edit, committing the title and opening the window — Move Up/Move Down (⌥⌘↑/⌥⌘↓), which sort within a lane in logical order and gather a scattered selection into a block behind its first card on the first press, and Move Left/Move Right (⌘←/⌘→), which slide a selected lane one slot and never into the trash. Deleting picks the successor sibling Finder-style, so repeated ⌫ walks down a lane; an external deletion deliberately doesn't. - **The keyboard** — the board is fully operable without the mouse, and every board function has a menu item. Arrows walk to the nearest card in the direction — across a lane's interior masonry columns, across lanes, and into and out of the shown trash — with ⇧-arrows extending the range from the anchor and stopping dead at the liveness and card/lane-entry boundaries rather than silently reaching past them. ⌥-arrows jump: ⌥↑/⌥↓ to the lane's first/last card, ⌥←/⌥→ to the first/last lane (⌥→ reaching the trash when it's shown), and a second ⌥↑ on a lane's first card escalates into selecting the lane itself — the one keyboard entry to lane selection, from which ←/→ move between lanes, ↓ descends back into the cards, and an empty selection seeds at the first lane's first card so an arrow from nothing always means the same thing. The selection scrolls itself into view. The Board menu carries the rest: Open Card (⌘↩) — the one command that stays live mid-edit, committing the title and opening the window — Move Up/Move Down (⌥⌘↑/⌥⌘↓), which sort within a lane in logical order and gather a scattered selection into a block behind its first card on the first press, and Move Left/Move Right (⌘←/⌘→), which slide a selected lane one slot and never into the trash. Deleting picks the successor sibling Finder-style, so repeated ⌫ walks down a lane; an external deletion deliberately doesn't.
- **The clipboard** — ⌘X/⌘C/⌘V move cards *and* lanes, within a board and across boards, so structure transfers without a mouse. It's a hybrid: the pasteboard carries a small manifest plus the titles as plain text, while the real content — whole folders, attachments and strays and all — is snapshotted into Application Support the instant you press ⌘C, so a copy captures the item as it was at that moment and survives the original being deleted, its volume unmounting, or the app quitting and relaunching. The store keeps exactly one snapshot: every copy and every launch sweeps whatever the pasteboard no longer points at. If a snapshot has gone missing by the time you paste, the manifest still carries each item's full `index.md`, so the paste lands with its content intact — and says so out loud, naming exactly what was left behind ("Pasted 'Fix login' without its 2 attachments") rather than leaving you to find an empty `attachments/` later. Cut is Finder-style deferred: the items dim in place and stay put until a paste moves them, voiding if another app takes the pasteboard or the source board closes (the paste then quietly becomes a copy), and voiding *per item* if one is deleted in the meantime — so a paste moves whatever survived, and a cut emptied down to nothing simply does nothing. Paste lands after the anchor card, at a selected lane's bottom, or at the last member of a multi-selection in flatten order — the same anchor ⌘N uses — and a lane payload lands after the anchor lane or at the board's right end, which is one of the two ways out of a board with no lanes at all. Copies keep `created` and take fresh identities throughout; a pasted lane strips tombstoned cards while a cut lane carries them whole; pasting a lane back into its own board is the within-board duplicate the drag deliberately doesn't offer. The trash is copy-out only — ⌘C on a trash row (card or lane entry) yields a live copy with the tombstone stripped, ⌘X is disabled there — and the read-only lock blocks cut without ever blocking copy, because copying out is a read.
- **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched. - **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched.
- **The trash** — deletion is two-stage and Finder-shaped. File ▸ Delete (⌘⌫, or a plain ⌫) tombstones the selection in place — cards or lanes, one bracketed write for the whole batch — and View ▸ Show Trash reveals a trailing quasi-lane where tombstoned items live. It is a pure view: nothing moves on disk, so Put Back is byte-perfect and returns an item to its old position. The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it. Its order is fully deterministic (newest deletion first, ties by folder name, an unreadable timestamp sorting oldest) and its ancestor walk is absolute — a tombstoned lane is one entry subsuming everything beneath it, its count naming the cards Put Back would return, so recovering a separately deleted card inside it is deliberately two steps. Drag a row out onto any lane to restore it there. Purging is Delete Immediately (⌥⌘⌫) and Empty Trash… (⇧⌘⌫), each confirmed where the loss is real and named by count; Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style… — applies to a tombstoned selection, and a selection never mixes trashed with live. - **The trash** — deletion is two-stage and Finder-shaped. File ▸ Delete (⌘⌫, or a plain ⌫) tombstones the selection in place — cards or lanes, one bracketed write for the whole batch — and View ▸ Show Trash reveals a trailing quasi-lane where tombstoned items live. It is a pure view: nothing moves on disk, so Put Back is byte-perfect and returns an item to its old position. The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it. Its order is fully deterministic (newest deletion first, ties by folder name, an unreadable timestamp sorting oldest) and its ancestor walk is absolute — a tombstoned lane is one entry subsuming everything beneath it, its count naming the cards Put Back would return, so recovering a separately deleted card inside it is deliberately two steps. Drag a row out onto any lane to restore it there. Purging is Delete Immediately (⌥⌘⌫) and Empty Trash… (⇧⌘⌫), each confirmed where the loss is real and named by count; Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style… — applies to a tombstoned selection, and a selection never mixes trashed with live.