Files
lanework/Kanban/App/ClipboardManifest.swift
T
rzen 0fe92bdf38 A file copied in Finder becomes a card's attachment — ⌘V's reserved clause finally answers
The image branch's precedence ladder always had a second clause: a file URL on the
pasteboard suppresses it, "a different gesture with a different answer" that the
code deliberately declined rather than guessed at. This fills it in: one or more
file URLs paste through the same `importAttachments` a Finder drop takes — one
collision ladder, one folder refusal (`FinderDrop.partition`), one set of banners
— outranking raw image data riding beside it (a Finder-copied image file carries
both; the actual file lands, not a re-encoded copy of its bytes) while still
deferring to the app's own clipboard type. Both ⌘V surfaces read the same
`ClipboardStore.fileURLPayload`, so the board's fallback and the card window's own
branch stay in step by construction rather than by two hand-kept-in-sync checks.

Fixed a real leak in the body editor's paste yield along the way: `public.file-url`
conforms to `public.url`, which `NSTextView` legitimately reads for a pasted
hyperlink, and `NSPasteboard.availableType(from:)` matches by conformance rather
than exact type — so a Finder copy carrying a generic URL representation beside
its file URL would have been silently swallowed as text and never reached the
window's attachment branch at all. The yield now declines outright on any
file-URL pasteboard before the generic capability check runs.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 08:38:07 -04:00

285 lines
13 KiB
Swift

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 — `<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 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 `<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 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
/// **Every type identifier the pasteboard currently carries** — what the image-data branch
/// classifies (04-interactions.md ▸ Clipboard, ruled 2026-08-09; `PastedImage.flavor`).
///
/// A list rather than a set of yes/no questions, because the *rule* is a decision over a list and
/// belongs in one place: adding a flavor to `PastedImage.verbatimTypes` must not also mean adding
/// a method here. It subsumes `manifestData()`'s question too, and does not replace it — the
/// manifest is read as bytes and decoded, which a type list cannot answer.
func availableTypes() -> [String]
/// The bytes under one type, whatever it is — the general read behind `manifestData()`'s
/// specific one, added for the image branch (which knows its type only at runtime, off the
/// classification above).
func data(forType type: String) -> Data?
/// Every file URL the pasteboard's items carry, in item order — the file-URL branch's own read
/// (04-interactions.md ▸ Clipboard, ruled 2026-08-09; `ClipboardStore.pasteFiles`).
///
/// A method of its own rather than another `data(forType:)` call, because `availableTypes()`'s
/// first-item carve-out is wrong here: "is a file being offered at all" only needs the first
/// item, but "which files" does not — a Finder copy of several files is several pasteboard
/// items, each carrying `public.file-url` on its own, and this reads across all of them.
func fileURLs() -> [URL]
}
/// 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)
}
/// `NSPasteboard.types` — the **first item's** types, which is what "the clipboard's payload"
/// means for every producer this branch cares about: a screenshot, a browser's Copy Image, a
/// Finder copy, and this app's own write are all single-item writes. A multi-item pasteboard's
/// later items are deliberately not consulted; pasting the second image of a five-image copy is a
/// gesture nobody has asked for and would need a target grammar of its own.
public func availableTypes() -> [String] {
(pasteboard.types ?? []).map(\.rawValue)
}
public func data(forType type: String) -> Data? {
pasteboard.data(forType: NSPasteboard.PasteboardType(type))
}
/// `readObjects(forClasses:options:)` rather than a per-item `data(forType:)` walk: it is the
/// framework's own multi-item reconstruction of `public.file-url`, `.urlReadingFileURLsOnly`
/// keeping a web URL (`public.url`, which does not conform) from ever surfacing here.
public func fileURLs() -> [URL] {
(pasteboard.readObjects(
forClasses: [NSURL.self],
options: [.urlReadingFileURLsOnly: true]
) as? [URL]) ?? []
}
@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
}
}