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 /// `/Clipboard//`, 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 **identification metadata only** — menu validation, the refusal's wording, the plain-text /// flavor — and never a materialization source: a paste whose staged snapshot is missing or /// unreadable **refuses whole and writes nothing** (04-interactions.md ▸ Clipboard, re-ruled /// 2026-07-29 — Finder's invariant: an item arrives whole or not at all). /// /// ### 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 member that moved to the trash or vanished /// 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, 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. /// /// **"Menu validation" is two checkpoints here, not a hook**: the commands validate by conditional /// responder attachment (`ClipboardCommands` — availability *is* the handler's presence), which /// reads the cached observables and offers AppKit no validation-time callback. So the cache is /// re-read at the two moments a command could be about to fire: a menu beginning to track (the /// mouse's path), and **⌘ going down** (the key equivalent's path — the modifier lands a beat /// before its letter, and the observation's re-render re-arms the responder inside that beat). /// Without the second checkpoint the one pasteboard writer that never deactivates this app — the /// screenshot hotkey, ⌃⇧⌘4 — would leave ⌘V dead until the next app switch, which is the image /// branch's headline gesture failing in the exact case it was built for. Both checkpoints are one /// `changeCount` read in the common case, which is why they can afford to fire on every ⌘-chord. @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 image-data branch's reading of the same pasteboard**, as of the same `refresh()` — /// `nil` when there is no picture to paste, when a board payload outranks one, or when the /// pasteboard carries file URLs (04-interactions.md ▸ Clipboard, ruled 2026-08-09; /// `PastedImage.flavor(hasBoardItems:types:)` holds the precedence and this holds its answer). /// /// Observed beside `payload` and refreshed in the same breath, for `payload`'s exact reason: the /// three surfaces that turn on it — the board's ⌘V fallback, the card window's ⌘V, and Edit ▸ /// Paste as Board Background — are menu-validated, and a computed pasteboard read would leave /// every one of them stale until something else happened to rebuild the menu. /// /// **Two readings, never two reads**: one `refresh()` reads the pasteboard once and fills both, /// which is what makes "a board payload wins" a property of the code rather than an ordering two /// call sites have to remember. public private(set) var imagePayload: PastedImage.Flavor? /// 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? /// `nonisolated(unsafe)` for one reason and one only: `deinit` is nonisolated and these are the /// tokens it has to hand back. 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 them. @ObservationIgnored private nonisolated(unsafe) var stalenessObservers: [any NSObjectProtocol] = [] /// The ⌘-down checkpoint's monitor token (see the type comment's takeover section) — same /// lifetime story as `stalenessObservers`, and `LocalModifierFlipWatch`'s warning applies: a /// token dropped on the floor is a block that keeps firing for the rest of the process. @ObservationIgnored private nonisolated(unsafe) var commandKeyToken: Any? private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "clipboard") /// `/Clipboard/`, beside the board registry — the same home, for the same /// reason (`AppStateHome`; 02-architecture.md § Per-board app state, "App-wide state has the same /// home"). public static var defaultStagingRoot: URL { AppStateHome.directory.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 the real Application Support home *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. stalenessObservers.append(NotificationCenter.default.addObserver( forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { guard let self else { return } self.refresh() self.sweep() } }) // The two validation checkpoints (type comment ▸ takeover): a menu beginning to track, and // ⌘ going down. Refresh only — the activation sweep is about reclaiming staged trees, and a // ⌘-chord is not the moment to enqueue disk work on the off chance the pasteboard moved. stalenessObservers.append(NotificationCenter.default.addObserver( forName: NSMenu.didBeginTrackingNotification, object: nil, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { self?.refresh() } }) commandKeyToken = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in // Local monitors run on the main thread, before the event reaches its window // (`LocalModifierFlipWatch`'s note) — and the event is returned unchanged, always, for // its reason too: ⌘ means things to the rest of the app. if event.modifierFlags.contains(.command) { MainActor.assumeIsolated { self?.refresh() } } return event } } deinit { for observer in stalenessObservers { NotificationCenter.default.removeObserver(observer) } if let commandKeyToken { NSEvent.removeMonitor(commandKeyToken) } } // 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 because a paste **awaits the same /// chain** (`paste(into:)`): it can never read a half-written snapshot, so it never sees a tree the /// staging has not finished. This used to lean on the manifest's fallback text instead; with /// refuse-don't-degrade the chain is the whole guarantee, and it is the stronger one. 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, container: capture.container, 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)), container: capture.container ) } // "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, in either /// container ("⌘C copies a trash card — a live copy lands wherever pasted, like copying out of /// Finder's Trash" — 04-interactions.md ▸ The trash). /// /// **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. /// /// **A mixed trash selection closes it — this is one of the two exits the kind guard moved to** /// (04-interactions.md ▸ The trash, ruled 2026-07-31 with kind-blind trash selection): "the /// pasteboard's payload types are per-kind, so Cut and Copy grey out via ordinary menu validation /// while a trash selection mixes kinds — no failed gesture, no beep". It is also what keeps /// `capture`'s single `kind` honest: the manifest names one payload type, and a set spanning both /// never reaches it. public func canCopy(from store: BoardStore) -> Bool { guard !store.isEditingInline else { return false } guard !SelectionGrammar.mixesKinds(store.selection, in: store.snapshot) else { return false } return SelectionGrammar.kind(of: store.selection, in: store.snapshot) != nil } /// Whether Edit ▸ Cut applies. Copy's conditions plus the one a *move* adds: the board must /// accept writes, since a cut mutates its source. /// /// **The trash no longer disqualifies it** (04-interactions.md ▸ The trash, resettled /// 2026-07-28): "⌘X works — it was disabled under the tombstone model: cut in the trash, paste /// into a lane is the keyboard-native restore, an ordinary folder move". So there is no /// container clause here at all, which is the pivot showing up as a deleted line. public func canCut(from store: BoardStore) -> Bool { canCopy(from: store) && !store.isReadOnly } /// 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 ▸ the image-data branch // **⌘V's fallback, not a second command** (04-interactions.md ▸ Clipboard, ruled 2026-08-09). // A pasteboard carrying raw image data and no file URL pastes the picture into a card's // `attachments/` — the screenshot, the browser's Copy Image, Preview's ⌘C. Everything about it // is deliberately the *existing* machinery seen from one branch over: // // - **The precedence is `refresh()`'s**, which fills `payload` and `imagePayload` from one read // and can therefore never let a picture divert a board paste. // - **The write is `BoardStore.importAttachments(_:toCard:)`** — the same call a Finder file drop // and the card window's ⇧⌘A make. That is what buys the Finder-style collision rename, the // `performWrite` bracket (one app-mediated reload, the read-only lock, the banner on failure), // the echo ledger's receipt, and the staging rules, without a second import path in the app to // keep in step with the first. // - **The name is minted by writing a temp file** rather than by asking the Writer for a free // name and then writing under it: `importFiles` takes source URLs and climbs its own ladder, so // handing it a file already called "Pasted Image.png" is how a paste gets Finder's answer // rather than a second implementation of it. // - **It registers no undo step**, exactly like every other attachment arrival (13-native-undo.md // ▸ Out of scope: "attachment add/remove registers no undo step in v1"). A paste that landed a // file is an import, and imports are not on the stack — half a pair would be worse than none. // - **It announces exactly as a file drop does**, which is to say the arrival is silent: the // write is app-mediated, so the reload it produces carries receipts and the announcer's ladder // is quiet by construction (10-accessibility.md — "app-mediated echoes never do"). What the // user gets is what a drop gives them: the row appearing in the attachments section and the // card face's chip counting one higher. /// Whether ⌘V would paste a picture into `store`'s **anchor card** — the board window's branch. /// /// Three clauses. The board accepts board mutations (the lock and the focused-editor rule, exactly /// as `canPaste(into:)` reads them); there is a picture on the pasteboard; and the selection /// anchors a *card*, because an attachment belongs to one. A lane selection, an empty selection /// and a trash selection all anchor no card and therefore offer nothing here — which is /// `PasteTarget.card`'s answer, so the item's availability and the paste's own refusal are the /// same expression. public func canPasteImage(into store: BoardStore) -> Bool { guard store.acceptsBoardMutations, imagePayload != nil else { return false } return PasteTarget.card(selection: store.selection, snapshot: store.snapshot) != nil } /// ⌘V's image branch on the board — resolves the anchor card and pastes into it. @discardableResult public func pasteImage(into store: BoardStore) -> Bool { refresh() guard canPasteImage(into: store), let cardID = PasteTarget.card(selection: store.selection, snapshot: store.snapshot) else { return false } return pasteImage(intoCard: cardID, in: store) } /// Whether ⌘V would paste a picture into this **named** card — the card window's branch, where /// the target is the window's own card rather than a selection's anchor. /// /// The lock clause is `!store.isReadOnly` rather than `acceptsBoardMutations`, which is /// `CardAttachments.isEditable`'s reading and the right one here: the focused-editor half of /// `acceptsBoardMutations` is about *this board window's* inline title editor, and a card window /// has no business going dead because a board window behind it is mid-rename. A focused text /// field in the card window still wins ⌘V natively, which is the rule that actually matters here /// and needs no arithmetic (`ClipboardCommands`' focused-editor note). /// /// The card must be on the **board side**: `BoardStore.importAttachments` refuses a trashed card /// outright, and offering a row that would no-op is exactly what the codebase's named predicates /// exist to prevent. public func canPasteImage(intoCard cardID: ItemID, in store: BoardStore) -> Bool { guard !store.isReadOnly, imagePayload != nil else { return false } return BoardStore.boardItem(cardID, in: store.snapshot)?.cardID != nil } /// Pastes the pasteboard's picture into `cardID`'s `attachments/`. /// /// - Returns: whether a file was handed to the import path. `false` is every way this can decline /// — no picture, a card that is not there, a pasteboard that declared a type it could not back /// up, or a temp file that would not write — and every one of them writes nothing at all. @discardableResult public func pasteImage(intoCard cardID: ItemID, in store: BoardStore) -> Bool { refresh() guard canPasteImage(intoCard: cardID, in: store), let flavor = imagePayload else { return false } guard let raw = pasteboard.data(forType: flavor.type), let bytes = PastedImage.encode(raw, as: flavor) else { // The pasteboard named a flavor it cannot produce, or produced bytes that are not an // image. Nothing is written and nothing is said: the honest outcome of a pasteboard that // lied is the one where the card is untouched. Self.logger.debug("image paste declined — the declared flavor produced no usable bytes") return false } guard let staged = Self.stageForImport(bytes, named: flavor.fileName) else { return false } defer { try? FileManager.default.removeItem(at: staged.deletingLastPathComponent()) } store.importAttachments([staged], toCard: cardID) return true } /// Writes `bytes` to a private temp folder under `name`, and answers the file's URL. /// /// **A folder per paste, not a shared scratch directory**: the file has to carry the exact name /// the import ladder will start from ("Pasted Image.png"), so two pastes in flight would collide /// on it — and the folder is what the caller removes afterwards, which is one `removeItem` /// instead of a file plus whatever else ended up beside it. /// /// The app's own container temp directory, so this needs no sandbox grant and no bookmark: the /// bytes came off the pasteboard, they are going into the board the app already holds, and this /// is the few milliseconds in between. private static func stageForImport(_ bytes: Data, named name: String) -> URL? { let folder = FileManager.default.temporaryDirectory .appendingPathComponent("PastedImage-\(UUID().uuidString)", isDirectory: true) guard (try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)) != nil else { return nil } let url = folder.appendingPathComponent(name) guard (try? bytes.write(to: url)) != nil else { try? FileManager.default.removeItem(at: folder) return nil } return url } // MARK: - Paste ▸ the board backdrop /// Whether Edit ▸ Paste as Board Background applies to `store` (03-board-ui.md § Styling ▸ /// Capabilities; the `background` mapping's `image` subkey). /// /// Two clauses and no third: a board that accepts mutations, and a picture on the pasteboard. /// There is no target to resolve — a board has exactly one backdrop — which is what makes this /// the one image-paste surface that stays live on a zero-lane board. public func canPasteBoardBackground(into store: BoardStore) -> Bool { store.acceptsBoardMutations && imagePayload != nil } /// Edit ▸ Paste as Board Background — the picture into the board folder, `background.image` /// pointed at it. /// /// The bytes are prepared exactly as the attachment branch's are (same classification, same /// format rule) and then handed to `BoardStore.applyPastedBackground(data:fileExtension:)`, which /// owns the naming, the one bracket and the undo step. Nothing about the *file* is decided here. @discardableResult public func pasteBoardBackground(into store: BoardStore) -> Bool { refresh() guard canPasteBoardBackground(into: store), let flavor = imagePayload else { return false } guard let raw = pasteboard.data(forType: flavor.type), let bytes = PastedImage.encode(raw, as: flavor) else { Self.logger.debug("background paste declined — the declared flavor produced no usable bytes") return false } return store.applyPastedBackground(data: bytes, fileExtension: flavor.fileExtension) } // 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? { 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. /// /// **A paste is a user-initiated creation, so it clears the destination's search** /// (04-interactions.md § Search, stated by mechanism — "⌘N, Return-creation, the header button, /// empty-space double-click, paste, and Finder file drops alike"). Cards and lanes alike — the /// clipboard holds one or the other, and either arrives as an item the query may well not match. /// Unqualified, too: 04 names the *mechanism*, so the armed cut's move clears exactly as the copy /// does rather than earning a sub-rule for the one case where the items were already on this /// board. It is cleared here rather than at ⌘V so the two staleness guards keep their meaning: a /// paste the pasteboard moved under lands nothing, and so clears nothing. /// /// **A paste is an import boundary, so normalization applies** (04 ▸ Clipboard, settled /// 2026-07-28 — 01-storage-format.md's loose-file carve-out): every arrival below passes /// `normalizingLooseFiles: true`, so a loose file the staged snapshot faithfully carried beside /// a card's `index.md` lands inside the pasted card's `attachments/`, Finder-renamed on /// collision. Both branches and both operations, unqualified, because 04's sentence is /// unqualified. Nothing is dropped and nothing is announced: the snapshot preserved the file, /// the paste kept it, and it is where the schema says it belongs — the carve-out's notice is for /// files the app moves *without* being asked, which is the loader's path, not this one. 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) { store.transient.noteUserCreation() 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, normalizingLooseFiles: true ) case let .lanes(index): store.receiveLanes( sources, operation: .move, at: index, normalizingLooseFiles: true ) } consumeCut() return } // **The copy path's preflight: refuse, never degrade** (04-interactions.md ▸ Clipboard, // re-ruled 2026-07-29). Every entry must have its staged snapshot on disk *before* anything is // materialized — the first one that does not refuses the whole paste, names itself from the // manifest's metadata, and writes nothing at all. All-or-nothing for the whole paste, which is // the copies-are-transactions posture (01-storage-format.md § Frontmatter) read one level up: // the transaction is the gesture, not the entry. let stagingDir = stagingRoot.appendingPathComponent(manifest.copyID, isDirectory: true) var sources: [BoardStore.ItemSource] = [] for entry in manifest.entries { let staged = stagingDir.appendingPathComponent(entry.folder, isDirectory: true) guard FileManager.default.fileExists( atPath: staged.appendingPathComponent(BoardLoader.indexFileName).path ) else { // The offending entry, named — and the destination's search is left exactly as it was. // "Any user-initiated creation on the board clears the query" (04 ▸ Search) is a rule // about creations, and this paste created nothing; the preflight therefore runs *before* // `noteUserCreation`, so a refusal costs the user neither content nor their filter. store.banners.postRefusedPaste(title: entry.title, stagedAt: staged.path) return } sources.append(.folder(staged)) } store.transient.noteUserCreation() // A card copied out of the trash needs nothing done to it on arrival: it carries no // `deleted:` key, because there is no such key any more (03-board-ui.md § Trash, resettled // 2026-07-28). The tombstone era's strip-at-materialization axis is gone with it. switch plan { case let .cards(target): store.receiveCards( sources, operation: .copy, toLane: target.laneID, at: target.index, normalizingLooseFiles: true ) case let .lanes(index): store.receiveLanes( sources, operation: .copy, at: index, normalizingLooseFiles: true ) } } /// 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 } // `ItemPath.resolve` walks the container in display order, which is the flatten order the // drop commits insert in — and the pending cut is homogeneous by container, so it is asked // for exactly the side the cut was made on. A cut made in the trash therefore hands the // paste the trash folders it must move out, which is the keyboard restore (04 ▸ The trash). let folders = ItemPath.resolve(survivors.ids, in: survivors.container, snapshot: 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 every /// caller 04 names: app activation, the front of every paste, and menu validation's two /// checkpoints — a menu beginning to track, and ⌘ going down (the type comment's takeover /// section). public func refresh() { let count = pasteboard.changeCount guard count != lastChangeCount else { return } lastChangeCount = count payload = pasteboard.manifestData().flatMap(ClipboardManifest.init(data:)) // The image branch's whole precedence, applied here so it is applied once: a board payload // outranks a picture, and a file URL means this is not the image branch's pasteboard at all. imagePayload = PastedImage.flavor( hasBoardItems: payload != nil, types: pasteboard.availableTypes() ) 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, and the *consequence* /// of a failure changed with the refuse-don't-degrade ruling: an item whose snapshot never landed /// makes the next paste **refuse whole**, naming it (`perform`'s preflight), rather than /// materializing it hollow from the manifest's embedded `index.md`. Failing to stage is therefore /// as loud as it should be, one gesture later. /// **Staging is a copy boundary**, so `comments/.trash/` does not survive it (01-storage-format.md /// § Enhanced schema: "stripped at every copy boundary (clipboard staging, Duplicate, Save as /// Template)"). Stripped from the *snapshot* rather than skipped during it, because the snapshot is /// one monolithic `copyItem` — and stripped through the Writer's own call so the rule is one /// function (`BoardWriter.stripCommentTrash`), not a second reading of it here. Best-effort like /// the copy above it: a snapshot that could not be tidied is still a snapshot, and the paste that /// materializes from it strips again at its own boundary. 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) try? BoardWriter.stripCommentTrash(under: job.destination, operation: .copy(title: nil)) } } } /// 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) } } /// Where a tree goes to die: a hidden sibling inside the staging root, so a removal is **two /// steps, the first of them atomic**. /// /// Hidden (`.`-prefixed) on purpose — `prune` lists with `.skipsHiddenFiles`, so this folder is /// invisible to the sweep that owns it and can never be mistaken for a staged copy. /// /// `nonisolated` because `prune` is: the sweep runs off the main actor by design, and a constant /// has no isolation to need. private nonisolated static let sweepFolderName = ".sweeping" /// The sweep, written **claim-then-delete** rather than delete-in-place. /// /// There is one app and macOS runs one instance of it, so this is not the concurrency guard it was /// written as (12-editions.md ▸ App-side state, re-ruled 2026-07-30 — there is no sibling app to /// race). It is kept because what it buys is cheap and still true of one process: /// /// 1. **The claim is a rename, and a rename is atomic.** A tree either leaves the staging root /// whole or stays there whole — it is never briefly *visible half-removed*, which is the one /// state a reader could misread. That covers a crash mid-delete, and it covers the developer's /// own second copy launched with `open -n`, which shares this container because it is the same /// app. /// 2. **A missing entry means already swept, never an error.** Every failure here is swallowed: /// the listing is stale by the time it is walked, and a tree that vanished between the two is /// precisely the outcome asked for. /// /// Leftovers in `.sweeping/` are collected on the next pass. A crash between the rename and the /// delete therefore costs disk until the next sweep, which is the same guarantee the staging store /// already gives about its own orphans. private nonisolated static func prune(_ root: URL, keeping keep: String?) async { let sweepFolder = root.appendingPathComponent(sweepFolderName, isDirectory: true) guard let entries = try? FileManager.default.contentsOfDirectory( at: root, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] ) else { return } var claimed: [URL] = [] for entry in entries where entry.lastPathComponent != keep { // Created lazily: a sweep with nothing to collect must not leave a folder behind as proof // it ran. if claimed.isEmpty { try? FileManager.default.createDirectory(at: sweepFolder, withIntermediateDirectories: true) } let claim = sweepFolder.appendingPathComponent(UUID().uuidString, isDirectory: true) guard (try? FileManager.default.moveItem(at: entry, to: claim)) != nil else { // Gone, or claimed by another pass. Either way it is not ours to delete and nothing // is wrong. continue } claimed.append(claim) } for claim in claimed { try? FileManager.default.removeItem(at: claim) } // Anything a previous pass claimed and did not finish — a crash between the claim and the // delete. Best-effort, and an empty or missing folder is nothing to do. if let stragglers = try? FileManager.default.contentsOfDirectory( at: sweepFolder, includingPropertiesForKeys: nil, options: [] ) { for straggler in stragglers { try? FileManager.default.removeItem(at: straggler) } try? FileManager.default.removeItem(at: sweepFolder) } } /// 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: ItemPath let entry: ClipboardManifest.Entry } /// The selection, resolved into copy subjects in the order the clipboard records them — or `nil` /// when it names nothing its container holds. /// /// **The order is `SelectionGrammar.order`'s**, which is already the right answer for every /// (container, kind) pair: flatten order for board cards, left-to-right for lanes, and the /// trash's own rank order for its cards and its lane rows alike. Deriving it here would be a /// second 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 embedded text a faithful record of the item while costing ⌘C no file I/O at all, /// even for a lane carrying two hundred cards. It is **identification metadata**, not a /// materialization source (see the type comment): the refusal's wording and the plain-text flavor /// read it, and nothing writes it. static func capture( selection: ItemReferenceSet, snapshot: BoardModel ) -> (kind: SelectionKind, container: ItemContainer, subjects: [Subject])? { guard let kind = SelectionGrammar.kind(of: selection, in: snapshot) else { return nil } let container = selection.container let ordered = SelectionGrammar.order(of: kind, in: container, snapshot: snapshot) .filter { selection.ids.contains($0) } guard !ordered.isEmpty else { return nil } var subjects: [ItemID: Subject] = [:] func addCard(_ card: Card, at path: ItemPath) { subjects[card.id] = Subject( id: card.id, path: path, entry: ClipboardManifest.Entry( id: card.id.rawValue, folder: card.id.rawValue, title: card.title.value, index: card.document.serialized(), attachmentCount: card.attachments.count ) ) } switch container { case .trash: for card in snapshot.trash { addCard(card, at: .trashCard(card.id)) } // **A trashed lane row copies and cuts like any other lane** (04-interactions.md ▸ The // trash: "a trashed lane pastes after the anchor lane (the lane-paste rule above, // verbatim)"), which makes ⌘X here the keyboard-native restore at the lane level. // // **No `cards` in the entry, and that is the opaque unit showing through**: a trashed // lane's subtree is deliberately not in the snapshot (`TrashedLane`), so there is nothing // here to describe it with — and nothing is lost by that, because the manifest's embedded // text is identification metadata only and the *content* comes from the staged folder, // which is copied whole, cards and all. An entry that guessed at a card list would be the // one place in the app claiming to know what an opaque unit holds. for lane in snapshot.trashedLanes { subjects[lane.id] = Subject( id: lane.id, path: .trashLane(lane.id), entry: ClipboardManifest.Entry( id: lane.id.rawValue, folder: lane.id.rawValue, title: lane.title.value, index: lane.document.serialized(), attachmentCount: 0 ) ) } case .board: for lane in snapshot.lanes { if kind == .lane { subjects[lane.id] = Subject( id: lane.id, path: .lane(lane.id), entry: ClipboardManifest.Entry( id: lane.id.rawValue, folder: lane.id.rawValue, title: lane.title.value, index: lane.document.serialized(), attachmentCount: 0, // Every card the lane has — "a lane carries exactly its cards", and the // trash is board-level, so there is nothing nested to strip // (04-interactions.md ▸ Drag and drop, resettled 2026-07-28). cards: lane.cards.map { card in ClipboardManifest.Entry.Card( id: card.id.rawValue, title: card.title.value, index: card.document.serialized(), attachmentCount: card.attachments.count ) } ) ) continue } for card in lane.cards { addCard(card, at: .card(lane: lane.id, id: card.id)) } } } let resolved = ordered.compactMap { subjects[$0] } guard !resolved.isEmpty else { return nil } return (kind, container, resolved) } }