⌘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
230 lines
9.7 KiB
Swift
230 lines
9.7 KiB
Swift
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
|
|
}
|
|
}
|