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.
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
/// 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>
<string>Lanework Lanes</string>
</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>
<key>CFBundleDocumentTypes</key>
<array>
+63
View File
@@ -271,6 +271,43 @@ public final class BannerCenter {
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
/// 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.
@@ -512,6 +549,32 @@ public final class BannerCenter {
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
/// flush-before-overwrite guarantee are degraded rather than the git mechanics, and carries
/// the diagnosis as its tail.
+126 -30
View File
@@ -1425,6 +1425,25 @@ public final class BoardStore {
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.
///
/// - `.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
/// behaviour rather than something this method arranges).
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
@@ -1456,11 +1494,11 @@ public final class BoardStore {
/// because `restoreItem` is already the one expression in the app for "remove the `deleted:`
/// key" the bytes are never rewritten any other way.
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(
_ sources: [URL],
_ sources: [ItemSource],
operation: TransferOperation,
toLane laneID: ItemID,
at index: Int,
@@ -1488,25 +1526,60 @@ public final class BoardStore {
guard let ranks else { return }
for (source, rank) in zip(sources, ranks) {
let arrived: ItemID
switch operation {
case .copy:
arrived = try BoardWriter.copyItem(at: source, toParent: laneFolder, order: rank, stamps: .fork)
case .move:
arrived = try BoardWriter.moveItem(
at: source,
toParent: laneFolder,
sourceBoardRoot: Self.boardRoot(ofCardFolder: source),
destinationBoardRoot: root,
order: rank
).id
}
guard let arrived = try Self.materialize(
source,
operation: operation,
intoParent: laneFolder,
destinationBoardRoot: root,
sourceBoardRoot: Self.boardRoot(ofCardFolder:),
order: rank
) else { continue }
guard clearingTombstones else { continue }
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.
///
/// 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
/// home), so this method is cross-board by construction.
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 }
let root = rootURL
@@ -1545,20 +1638,23 @@ public final class BoardStore {
guard let ranks else { return }
for (source, rank) in zip(sources, ranks) {
switch operation {
case .copy:
let arrived = try BoardWriter.copyItem(at: source, toParent: root, order: rank, stamps: .fork)
try BoardWriter.stripTombstonedChildren(
of: root.appendingPathComponent(arrived.rawValue, isDirectory: true)
)
case .move:
_ = try BoardWriter.moveItem(
at: source,
toParent: root,
sourceBoardRoot: Self.boardRoot(ofLaneFolder: source),
destinationBoardRoot: root,
order: rank
)
guard let arrived = try Self.materialize(
source,
operation: operation,
intoParent: root,
destinationBoardRoot: root,
sourceBoardRoot: Self.boardRoot(ofLaneFolder:),
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
/// it would be a second answer to a question the snapshot can always answer, and one that a reload
/// 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 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
/// each member which is exactly what makes re-resolution across a reload a matching rule rather
/// 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 trashed
+67
View File
@@ -830,6 +830,73 @@ public enum BoardWriter: Sendable {
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
/// 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
/// 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.
/// - **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
/// 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
// A inside an inline editor stays text selection with no guard needed here.
.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
// 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
+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
///
/// 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
/// carousel.
/// (`CardFaceView`) and wears the deferred cut's dim (`cutTreatment`); what it still owes is the
/// sole-selected card's attachment carousel.
struct LaneView: View {
let store: BoardStore
@@ -97,6 +97,9 @@ struct LaneView: View {
}
.background(selectionBackground)
.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 }
// **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
@@ -746,6 +749,9 @@ private struct CardFaceView: View {
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())
// **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
+42 -24
View File
@@ -56,31 +56,12 @@ enum NewCardTarget {
let lanes = snapshot.lanes.filter { !$0.isDeleted }
guard !lanes.isEmpty else { return nil }
if selection.liveness == .live, !selection.ids.isEmpty {
// The last selected member in flatten order lane `order`, then card `order`, the
// 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
// *last* hit is the anchor. Selection is homogeneous (cards XOR lanes), so only one of
// the two branches ever fires within a walk; a sole selection is simply the degenerate
// one-member case of the same rule.
var anchor: Resolution?
for lane in lanes {
// A selected lane: creation appends at its bottom, Return consistency.
if selection.ids.contains(lane.id) {
anchor = Resolution(laneID: lane.id, anchorCardID: nil)
}
// A selected card: its lane, immediately after it paste-anchor consistency.
for card in lane.cards where !card.isDeleted && selection.ids.contains(card.id) {
anchor = Resolution(laneID: lane.id, anchorCardID: card.id)
}
}
if let 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.
if let anchor = flattenAnchor(selection: selection, snapshot: snapshot) {
return anchor
}
// Nothing selected, a tombstoned selection, or a stale one: the lane that most recently
// Nothing selected, a tombstoned selection, or a stale one the ids name nothing the board
// renders, a selection the next reload will drop. Falls through rather than refusing: the
// user pressed N and the board has lanes. The target is then the lane that most recently
// held selection or a creation, and the first lane when there is no such lane (or it has
// since gone).
if let lastActiveLaneID, let lane = lanes.first(where: { $0.id == lastActiveLaneID }) {
@@ -88,4 +69,41 @@ enum NewCardTarget {
}
return lanes.first.map { Resolution(laneID: $0.id, anchorCardID: nil) }
}
/// **The shared anchor, on its own** "a multi-selection anchors at its last member in flatten
/// order (lane `order`, then card `order`, the multi-drag order; the same anchor serves paste)".
///
/// Extracted rather than left inside `resolve` because paste needs *exactly this clause* and not
/// the two that surround it. Card paste is `resolve` verbatim (the last-active-lane fallback and
/// all), but **lane paste has a different fallback** "nothing selected = the board's right end",
/// never the last-active lane so it takes the anchor and stops. Two derivations of "the last
/// member in flatten order" would be two chances for creation and paste to disagree about the one
/// rule 04 says they share.
///
/// `nil` covers the three cases that anchor nothing, which the callers then answer their own way:
/// an empty selection, a **tombstoned** one ("a tombstoned selection never anchors paste",
/// settled and "a trashed card's live disk-lane never leaks in as 'the selected card's lane'",
/// which falls out of never looking at the trashed side at all), and a stale one whose ids name
/// nothing the board renders.
static func flattenAnchor(selection: ItemReferenceSet, snapshot: BoardModel) -> Resolution? {
guard selection.liveness == .live, !selection.ids.isEmpty else { return nil }
// The snapshot's lanes and cards are already in display order, so the flatten order is one
// walk, and the *last* hit is the anchor. Selection is homogeneous (cards XOR lanes), so only
// one of the two branches ever fires within a walk; a sole selection is simply the degenerate
// one-member case of the same rule.
var anchor: Resolution?
for lane in snapshot.lanes where !lane.isDeleted {
// A selected lane: creation appends at its bottom, Return consistency; paste lands after
// the lane itself.
if selection.ids.contains(lane.id) {
anchor = Resolution(laneID: lane.id, anchorCardID: nil)
}
// A selected card: its lane, immediately after it paste-anchor consistency.
for card in lane.cards where !card.isDeleted && selection.ids.contains(card.id) {
anchor = Resolution(laneID: lane.id, anchorCardID: card.id)
}
}
return anchor
}
}
+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
// 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.
.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())
.onTapGesture { select() }
.marqueeTarget(entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed, in: registry)