import AppKit import Foundation import UniformTypeIdentifiers // MARK: - The clipboard type // // `UTType.laneworkClipboard` — what a Lanework copy puts on the pasteboard under its own type, the // JSON `ClipboardManifest` below (04-interactions.md ▸ Clipboard: "the pasteboard carries a JSON // manifest + plain text"). Declared 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. The constant // itself lives with its two siblings in `PasteboardTypes.swift`, exported once by the app that // owns it. // 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 — `/…/Clipboard//`, /// 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 as **identification metadata** /// (04-interactions.md ▸ Clipboard, re-ruled 2026-07-29): menu validation, the refusal's wording, /// and the plain-text flavor read it. It is emphatically **not** a materialization source — a paste /// whose staged snapshot is missing or unreadable refuses whole and writes nothing, because "an item /// arrives whole — index, attachments, loose files — or not at all". /// /// `kind` and `container` are the selection's own vocabulary (`SelectionKind`, `ItemContainer`) rather than /// near-copies of it: a clipboard payload is a selection that was copied, and the cards-XOR-lanes and /// board-XOR-trash 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 container: ItemContainer 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 `//`, 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 refused /// paste's banner, which names the offending entry from exactly this. public var title: String? /// The complete `index.md` at copy time — **identification metadata, never materialized** /// (see the type comment). Kept because it is what lets the app answer "what was on the /// clipboard" without touching the staging store: the plain-text flavor and a refusal's wording /// both come from here, and both have to work when the snapshot is exactly what is missing. 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. /// /// Identification metadata like the rest of the entry. It used to feed the degraded paste's /// loss accounting ("Pasted 'Fix login' without its 3 attachments"), which is retired with the /// degraded paste itself — an item now arrives whole or not at all, so there is no partial /// arrival left to count. 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. /// /// **Exactly the lane's cards**, which needs no filter: "a lane carries exactly its cards — /// the trash is board-level, so there is nothing lane-nested to strip or carry" /// (04-interactions.md ▸ Drag and drop, resettled 2026-07-28). Like the lane's own `index`, the /// cards' text is identification metadata: it describes what the copy held, and nothing /// materializes from it. 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 } } /// Every file this entry's subtree carried in an `attachments/` — its own plus, for a lane, its /// cards'. Identification metadata; nothing gates on it since the degraded paste retired. public var totalAttachmentCount: 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, container: ItemContainer, entries: [Entry] ) { self.version = version self.copyID = copyID self.boardRoot = boardRoot.path self.kind = kind self.container = container 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 } }