CardFaceView.boardMenu/boardActions restructured to the owner's shape (card
fe66c461): Open/Copy Link/Rename/Style▸(Symbol,Color), a divider, then
Copy/Cut/Paste/Paste Special▸(Paste Image into Card), a divider, then
Navigation▸(Move Left,Move Right), a divider, then Send to Trash. Every row
routes through existing machinery — no new commands.
ClipboardStore gains copy(from:targeting:)/cut(from:targeting:) and their
canCopy/canCut twins, so Copy and Cut can widen to the clicked card exactly
as Delete and Style already do ("right-clicking something outside the
selection acts on what was clicked"), without disturbing the Edit-menu path.
LaneMoveTarget.destination is extracted out of MoveLaneCommands so the
card menu's Navigation rows validate against the identical sole-live-lane
predicate as Board ▸ Move Left/Right. Since a card id can never itself
satisfy that predicate, the two rows are wired to the real store call but
unconditionally disabled — reading the live selection per card face would
reproduce the O(board) render regression isSelected/selectedCount exist to
prevent (contextMenu's builder is not lazy).
Style ▸ Symbol and ▸ Color both open the one existing style popover — no
per-section pre-focus (StyleEditorSession has no such concept, and
StyleEditorView internals are out of scope while another pass redesigns
the pickers). Paste and Paste Image into Card reduce their .disabled
checks to selection/snapshot-free forms, proven safe by construction (a
rendered card face already guarantees a live lane / a live board card).
Journaled on the card: Copy Link kept (shipped same day, not in the
owner's list), "Delete" relabeled "Send to Trash" (board-side move, not
the permanent trash delete), quick-style recents row dropped from this
menu, Navigation's always-disabled rows, and Paste not retargeting to the
clicked card — all flagged needs owner review. DESIGN/11-command-nexus.md's
Card row is owed a rewrite, left for the main session.
Tests: LaneMoveTarget.destination (new), targeted copy/cut (new), plus
existing ClipboardStore/PasteTarget/MoveLane/CopyLink/Trash-menu/PasteImage/
PasteFile/Style/CaretChord/render-performance/equatable-gate suites —
156 tests, all passing.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
1043 lines
57 KiB
Swift
1043 lines
57 KiB
Swift
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>/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 **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 file-URL branch's reading of the same pasteboard**, as of the same `refresh()` — `true`
|
|
/// when there are file URLs to paste as attachments and no board payload outranks them
|
|
/// (04-interactions.md ▸ Clipboard, ruled 2026-08-09; `imagePayload`'s clause 2, finally with an
|
|
/// answer instead of a decline — `PastedImage.carriesFileURL` still decides the presence).
|
|
///
|
|
/// A bare `Bool` rather than a value carrying the URLs themselves: unlike a picture's `Flavor`,
|
|
/// there is no format or name to decide ahead of the paste, so there is nothing worth caching
|
|
/// beyond "is the branch live". The URLs themselves are read fresh at paste time
|
|
/// (`ClipboardPasteboard.fileURLs()`) — real work across every pasteboard item, not just the
|
|
/// first, worth doing once at the gesture rather than on every menu revalidation.
|
|
public private(set) var fileURLPayload = false
|
|
|
|
/// 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 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")
|
|
|
|
/// `<Application Support>/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, targeting: store.selection, 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, targeting: store.selection, cut: true)
|
|
}
|
|
|
|
/// **The card context menu's own Copy/Cut** (`CardFaceView`, 2026-08-09 ▸ "redesign context menu
|
|
/// for cards") — Delete's widening rule (`targetIDs`, `styleTarget`) extended to the clipboard for
|
|
/// the first time: "right-clicking something outside the selection acts on what was clicked"
|
|
/// (standard macOS context-menu targeting). Same write, same staging, same pasteboard, same armed
|
|
/// cut — `copy(from:)`/`cut(from:)` above are simply this with `store.selection` as the target;
|
|
/// this overload exists so a caller whose target is *not* the live selection (a card clicked
|
|
/// outside it) never has to fight `store.selection` to get there.
|
|
public func copy(from store: BoardStore, targeting target: ItemReferenceSet) {
|
|
write(from: store, targeting: target, cut: false)
|
|
}
|
|
|
|
/// `copy(from:targeting:)`'s cut twin — `cut(from:)`'s deferred-move behavior, on an explicit
|
|
/// target.
|
|
public func cut(from store: BoardStore, targeting target: ItemReferenceSet) {
|
|
write(from: store, targeting: target, cut: true)
|
|
}
|
|
|
|
/// The one write every gesture and every menu row shares.
|
|
///
|
|
/// 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, targeting target: ItemReferenceSet, cut: Bool) {
|
|
guard let capture = Self.capture(selection: target, 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 {
|
|
canCopy(from: store, targeting: store.selection)
|
|
}
|
|
|
|
/// `canCopy(from:)` on an explicit target — the card context menu's own reading
|
|
/// (`copy(from:targeting:)`'s doc comment), same three clauses, aimed at whatever the caller
|
|
/// widened to rather than always `store.selection`.
|
|
public func canCopy(from store: BoardStore, targeting target: ItemReferenceSet) -> Bool {
|
|
guard !store.isEditingInline else { return false }
|
|
guard !SelectionGrammar.mixesKinds(target, in: store.snapshot) else { return false }
|
|
return SelectionGrammar.kind(of: target, 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 {
|
|
canCut(from: store, targeting: store.selection)
|
|
}
|
|
|
|
/// `canCut(from:)` on an explicit target — `canCopy(from:targeting:)`'s own reasoning, plus the
|
|
/// read-only clause.
|
|
public func canCut(from store: BoardStore, targeting target: ItemReferenceSet) -> Bool {
|
|
canCopy(from: store, targeting: target) && !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 file-URL branch
|
|
|
|
// **The image branch's reserved clause 2, finally with an answer** (04-interactions.md ▸
|
|
// Clipboard, ruled 2026-08-09). A pasteboard carrying one or more file URLs — a Finder copy,
|
|
// foremost — pastes those *files themselves* into a card's `attachments/`, the same way a Finder
|
|
// drop does:
|
|
//
|
|
// - **Outranks the image branch, never the board's own payload.** `refresh()` fills `imagePayload`
|
|
// and `fileURLPayload` from the one reading that already orders them (`PastedImage.flavor`'s own
|
|
// clause order): a board payload wins outright over either, and a file URL wins over raw image
|
|
// data riding beside it — a Finder-copied image file carries both, and the actual file is the
|
|
// more honest answer than a re-encoded copy of its bytes landing under a different name.
|
|
// - **The write is `BoardStore.importAttachments(_:toCard:)`, through `FinderDrop.partition`** —
|
|
// the very split a Finder drag makes at the drop, applied to the paste the same way: files land,
|
|
// folders are named in a loss row and nothing about them is attempted. One collision ladder, one
|
|
// set of banners, no second folder-refusal rule to keep in step with the drop's.
|
|
// - **Multiple files, in pasteboard order.** Unlike the image branch's single flavor, a paste here
|
|
// can be a whole multi-select Finder copy — `ClipboardPasteboard.fileURLs()` reads every item,
|
|
// not just the first (`availableTypes()`'s own first-item carve-out does not apply to this read).
|
|
// - **It registers no undo step and announces exactly as a drop does** — the image branch's own
|
|
// reasons, unchanged: an import is not on the undo stack (13-native-undo.md ▸ Out of scope), and
|
|
// the write is app-mediated so the arrival is the row appearing and the chip counting one higher.
|
|
|
|
/// Whether ⌘V would paste one or more files into `store`'s **anchor card** — the board window's
|
|
/// branch, `canPasteImage(into:)`'s own shape one clause over.
|
|
public func canPasteFiles(into store: BoardStore) -> Bool {
|
|
guard store.acceptsBoardMutations, fileURLPayload else { return false }
|
|
return PasteTarget.card(selection: store.selection, snapshot: store.snapshot) != nil
|
|
}
|
|
|
|
/// ⌘V's file-URL branch on the board — resolves the anchor card and pastes into it.
|
|
@discardableResult
|
|
public func pasteFiles(into store: BoardStore) -> Bool {
|
|
refresh()
|
|
guard canPasteFiles(into: store),
|
|
let cardID = PasteTarget.card(selection: store.selection, snapshot: store.snapshot)
|
|
else { return false }
|
|
return pasteFiles(intoCard: cardID, in: store)
|
|
}
|
|
|
|
/// Whether ⌘V would paste files into this **named** card — the card window's branch,
|
|
/// `canPasteImage(intoCard:in:)`'s own reasoning verbatim (the lock clause is `!store.isReadOnly`
|
|
/// rather than `acceptsBoardMutations`; the card must be on the board side, since
|
|
/// `BoardStore.importAttachments` refuses a trashed one outright).
|
|
public func canPasteFiles(intoCard cardID: ItemID, in store: BoardStore) -> Bool {
|
|
guard !store.isReadOnly, fileURLPayload else { return false }
|
|
return BoardStore.boardItem(cardID, in: store.snapshot)?.cardID != nil
|
|
}
|
|
|
|
/// Pastes the pasteboard's file URLs into `cardID`'s `attachments/` — `FinderDrop.land`'s write
|
|
/// half, reached from the pasteboard instead of a drag.
|
|
///
|
|
/// - Returns: whether at least one file was handed to the import path. `false` covers every way
|
|
/// this declines — no file URLs, a card that is not there, or a pasteboard offering only
|
|
/// folders — and every one of them writes nothing; a folders-only pasteboard still posts the
|
|
/// loss row naming what was skipped, exactly as a folders-only Finder drop does.
|
|
@discardableResult
|
|
public func pasteFiles(intoCard cardID: ItemID, in store: BoardStore) -> Bool {
|
|
refresh()
|
|
guard canPasteFiles(intoCard: cardID, in: store) else { return false }
|
|
let (files, folders) = FinderDrop.partition(pasteboard.fileURLs())
|
|
if !files.isEmpty {
|
|
store.importAttachments(files, toCard: cardID)
|
|
}
|
|
store.banners.postSkippedFolders(count: folders.count)
|
|
return !files.isEmpty
|
|
}
|
|
|
|
// 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<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.
|
|
///
|
|
/// **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.
|
|
let types = pasteboard.availableTypes()
|
|
imagePayload = PastedImage.flavor(hasBoardItems: payload != nil, types: types)
|
|
// The file-URL branch's own answer, same reading: a board payload still wins outright, and a
|
|
// file URL is what the image branch's clause 2 defers to rather than nothing.
|
|
fileURLPayload = payload == nil && PastedImage.carriesFileURL(types)
|
|
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)
|
|
}
|
|
}
|