Phase 2 completes the lanes-in-trash card. TrashEntry merges the trash's two kinds by rank in exactly ONE place (ItemPath.resolve's own merge deleted in favor of it — the three-merge-points finding shrinks instead of growing). TrashLaneRowView renders the opaque row — tertiary plate, level-default lane glyph never the lane's own icon, title + card count, no accents, no expansion; the column badge counts rendered rows. Selection grammar: kind-homogeneous trash selections — ranges skip the other kind, ⇧-extension stops at the kind boundary, plain arrows walk the merged order, marquee stays card-only (now load-bearing: rows register frames for arrows), Select All card-scoped; successor-on-purge crosses kinds like navigation as the interim for open Gap 7b5cbc90. Drag: TrashDrop accepts lane sessions (drop on shown trash deletes), restoreLanes routes a trash-sourced strip drop as an arrival-ranked within-board move with an undo step. Clipboard: ⌘X/⌘V lane restore via opaque lane subjects; fixed boardRoot(ofLaneFolder:) returning .trash as the root — a same-board restore looked like an import and would have reminted the lane it was restoring (pinned by test). A11y: row = one flattened "title, deleted lane, N cards" element with Delete/Reveal actions; BoardDiff crossings read lanes as deleted/restored, shown-trash churn digested at row level. Agent guide stays v7 — the literal already teaches lanes-trash-by-move and kind stamping; drift-guard pins those lines. README trash paragraph notes lanes. Both schemes 1893 tests / 322 suites green. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
729 lines
37 KiB
Swift
729 lines
37 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
|
|
/// `<group container>/Library/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 store is shared by every installed edition** (12-editions.md ▸ Both editions installed, ruled
|
|
/// 2026-07-29): the group container is one container, so ⌘C in base pastes full-fidelity in Pro. The
|
|
/// lifecycle below is unchanged by that — both editions read the same machine-wide pasteboard, so both
|
|
/// sweeps compute the same keep set — with one property made explicit: the sweep tolerates the sibling
|
|
/// sweeping alongside it (`prune`).
|
|
///
|
|
/// ### 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 (which reads the cached `payload`), 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.
|
|
@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 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 this is the
|
|
/// token it has to hand back. It is 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 it.
|
|
@ObservationIgnored private nonisolated(unsafe) var activationObserver: (any NSObjectProtocol)?
|
|
|
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "clipboard")
|
|
|
|
/// `<group container>/Library/Application Support/Clipboard/`, beside the board registry — the
|
|
/// same home, for the same reason, and now the same *shared* home (12-editions.md ▸ Both editions
|
|
/// installed, ruled 2026-07-29):
|
|
///
|
|
/// > The clipboard staging store homes in the group container beside the registry, so ⌘C in one
|
|
/// > edition pastes **full-fidelity** in the other — snapshot, attachments and all.
|
|
///
|
|
/// Nothing about the lifecycle changes: both editions read the same pasteboard, so both sweeps
|
|
/// compute the same answer from the same input. The shared home is also what keeps the refusal a
|
|
/// rare corner rather than the structural cross-edition outcome — a copy in one edition pastes
|
|
/// full-fidelity in the other, so neither has to reach for bytes that are not there.
|
|
public static var defaultStagingRoot: URL {
|
|
AppGroup.stateDirectory.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 shared App Group container *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.
|
|
activationObserver = NotificationCenter.default.addObserver(
|
|
forName: NSApplication.didBecomeActiveNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
MainActor.assumeIsolated {
|
|
guard let self else { return }
|
|
self.refresh()
|
|
self.sweep()
|
|
}
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
if let activationObserver {
|
|
NotificationCenter.default.removeObserver(activationObserver)
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
public func canCopy(from store: BoardStore) -> Bool {
|
|
guard !store.isEditingInline 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
|
|
|
|
/// 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 the three
|
|
/// callers 04 names: menu validation (through the cached `payload`), app activation, and the
|
|
/// front of every paste.
|
|
public func refresh() {
|
|
let count = pasteboard.changeCount
|
|
guard count != lastChangeCount else { return }
|
|
lastChangeCount = count
|
|
payload = pasteboard.manifestData().flatMap(ClipboardManifest.init(data:))
|
|
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.
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 to be safe against **the sibling edition sweeping the same directory at the
|
|
/// same time** (12-editions.md ▸ Both editions installed: "keep the sweep tolerant of the sibling
|
|
/// app's concurrent sweep — atomic removals, missing-entry = already swept").
|
|
///
|
|
/// The staging root is now shared by every installed edition, and each edition sweeps on its own
|
|
/// launches, activations, copies and pastes. Both compute the *same* answer — the keep set is the
|
|
/// one `copyID` the machine-wide pasteboard names — so they never disagree about what should go;
|
|
/// what they can do is arrive at the same doomed tree together. Two properties make that a
|
|
/// non-event:
|
|
///
|
|
/// 1. **The claim is a rename, and a rename is atomic.** `moveItem` into `.sweeping/` either
|
|
/// happens or does not; exactly one sweeper can win it, and the loser's failure is the signal
|
|
/// that somebody else owns the tree now. Deleting in place would instead have two processes
|
|
/// walking one directory tree as it disappeared under them — the case where a half-removed tree
|
|
/// is briefly *visible*, which is the only way a concurrent sweep could corrupt a paste.
|
|
/// 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 the sibling's sweep claimed it first. 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 — including the sibling app's, whose
|
|
// claims are as much ours to collect as our own, since a claimed tree is unreachable by
|
|
// either. 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)
|
|
}
|
|
}
|