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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user