diff --git a/DESIGN/04-interactions.md b/DESIGN/04-interactions.md index f15f5e9..754f47d 100644 --- a/DESIGN/04-interactions.md +++ b/DESIGN/04-interactions.md @@ -25,7 +25,7 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s ## Clipboard -- ⌘X/⌘C/⌘V on cards **and lanes** (resettled — lanes joined the clipboard so cross-board structure transfer has a keyboard path under the every-function contract; the cards-XOR-lanes selection rule means the clipboard holds cards or lanes, never both). Hybrid clipboard: pasteboard carries a JSON manifest + plain text; full folder snapshots staged app-side (02-architecture.md ▸ Per-board app state's app-wide home) so paste reproduces the item byte-for-byte — cards, attachments and all — across boards. Each manifest entry embeds the full `index.md` — identification metadata (menu validation, refusal wording, the plain-text flavor's source), **never a materialization source** since the 2026-07-29 refuse-don't-degrade ruling below (a lane entry embeds its cards' too). **Staging lifecycle** (settled): snapshots are staged **eagerly at ⌘C/⌘X time** — copy captures the source as it is at the gesture, immune to later deletion or unmount — and the store holds at most the *current* copy: a new Lanework copy replaces the previous snapshot, and a sweep at launch and on each copy purges entries the pasteboard no longer references (another app taking the pasteboard orphans the snapshot; the next sweep collects it). The snapshot survives relaunch exactly as long as the pasteboard still points at it — a copy made before quitting pastes whole after restart. **A paste is an import boundary, so normalization applies** (settled 2026-07-28 — 01-storage-format.md's loose-file rule): loose files the staged snapshot carries beside a card's `index.md` land in the pasted card's `attachments/`, Finder-renamed on collision — nothing the snapshot preserved is dropped on arrival. **A paste whose staged snapshot is missing or unreadable refuses loudly — never degrades** (re-ruled 2026-07-29, retiring the degraded embedded-`index.md` fallback and its loss row; Finder's invariant adopted, and 01's leniency doctrine applied — proceed-partially-lose-a-little is never a verdict): the paste produces **nothing**, and a one-shot failure banner names it from the manifest's metadata ("The copied cards are no longer available" / "Couldn't paste 'Fix login' — the copied content is gone"; BannerCenter owns the phrasing). An item arrives **whole — index, attachments, loose files, and comments when they ship — or not at all**; a hollowed card is never materialized, so the loss-accounting problem (what didn't arrive, and whether the totals are honest) dissolves rather than being solved. The refusal is transactional — all-or-nothing for the whole paste, the copies-are-transactions posture (01). With eager staging and the shared-store sweep discipline this is a rare corner, not a flow: the refusal names it, and ⌘C again is the recovery. **The pasteboard is re-read lazily, and the brief lie is accepted** (settled): changeCount is checked on activation, on menu validation, and before paste — no timers; a background app taking the pasteboard while Lanework stays frontmost can leave Edit ▸ Paste enabled until the next check, and the paste itself re-validates and no-ops — nothing stale ever lands, which is the guarantee that matters. +- ⌘X/⌘C/⌘V on cards **and lanes** (resettled — lanes joined the clipboard so cross-board structure transfer has a keyboard path under the every-function contract; the cards-XOR-lanes selection rule means the clipboard holds cards or lanes, never both). Hybrid clipboard: pasteboard carries a JSON manifest + plain text; full folder snapshots staged app-side (02-architecture.md ▸ Per-board app state's app-wide home) so paste reproduces the item byte-for-byte — cards, attachments and all — across boards. Each manifest entry embeds the full `index.md` — identification metadata (menu validation, refusal wording, the plain-text flavor's source), **never a materialization source** since the 2026-07-29 refuse-don't-degrade ruling below (a lane entry embeds its cards' too). **Staging lifecycle** (settled): snapshots are staged **eagerly at ⌘C/⌘X time** — copy captures the source as it is at the gesture, immune to later deletion or unmount — and the store holds at most the *current* copy: a new Lanework copy replaces the previous snapshot, and a sweep at launch and on each copy purges entries the pasteboard no longer references (another app taking the pasteboard orphans the snapshot; the next sweep collects it). The snapshot survives relaunch exactly as long as the pasteboard still points at it — a copy made before quitting pastes whole after restart. **A paste is an import boundary, so normalization applies** (settled 2026-07-28 — 01-storage-format.md's loose-file rule): loose files the staged snapshot carries beside a card's `index.md` land in the pasted card's `attachments/`, Finder-renamed on collision — nothing the snapshot preserved is dropped on arrival. **A paste whose staged snapshot is missing or unreadable refuses loudly — never degrades** (re-ruled 2026-07-29, retiring the degraded embedded-`index.md` fallback and its loss row; Finder's invariant adopted, and 01's leniency doctrine applied — proceed-partially-lose-a-little is never a verdict): the paste produces **nothing**, and a one-shot failure banner names it from the manifest's metadata ("The copied cards are no longer available" / "Couldn't paste 'Fix login' — the copied content is gone"; BannerCenter owns the phrasing). An item arrives **whole — index, attachments, loose files, and comments when they ship — or not at all**; a hollowed card is never materialized, so the loss-accounting problem (what didn't arrive, and whether the totals are honest) dissolves rather than being solved. The refusal is transactional — all-or-nothing for the whole paste, the copies-are-transactions posture (01). With eager staging and the shared-store sweep discipline this is a rare corner, not a flow: the refusal names it, and ⌘C again is the recovery. **The pasteboard is re-read lazily, and the brief lie is accepted** (settled): changeCount is checked on activation, on menu validation, and before paste — no timers. Menu validation is two concrete checkpoints (refined 2026-08-09, with the image branch): a menu beginning to track, and **⌘ going down** — commands validate by conditional responder attachment, so the key-equivalent path needs a re-read *before* the chord's letter lands, and the modifier's own beat is that moment; without it, the one pasteboard writer that never deactivates the app (the screenshot hotkey) left ⌘V dead until the next app switch. A background app taking the pasteboard while Lanework stays frontmost can still leave Edit ▸ Paste enabled until the next check, and the paste itself re-validates and no-ops — nothing stale ever lands, which is the guarantee that matters. - **Cut is Finder-style deferred**: cut items dim in place until paste moves them; voided if another app takes the pasteboard or the source board closes; second paste materializes copies. **Deletion voids per item**: a cut item that is deleted (moved to the trash or destroyed) or vanishes externally before paste drops out of the pending cut — 02-architecture.md's UUID-set rule; transient state never resurrects what's gone — so paste moves only the survivors, and a cut voided down to nothing is simply void (paste disabled, no error). - Paste lands after the anchor card (or appends to a selected lane); a multi-selection anchors at its last member in flatten order — the ⌘N target rule's shared anchor (The map below). Copies keep `created` (a duplicate is a fork) and take fresh GUID/`order`/`modified`. **A trash selection never anchors paste** (settled — the ⌘N target rule's own wording, returned to the precedent it cites): ⌘V stays enabled and behaves exactly as with nothing selected — a card payload appends to the last-active lane, a lane payload lands at the board's right end; the trash is never the destination (▸ The trash), and a trashed card's live disk-lane never leaks in as "the selected card's lane". - **⌘V's image-data branch** (ruled 2026-08-09): when the pasteboard carries **raw image data and no file URL** — a screenshot, a browser's Copy Image, Preview's ⌘C — ⌘V lands the picture as a new file in a card's `attachments/`. **Precedence, in one order**: the app's own clipboard type wins outright (a Lanework copy still pastes as cards or lanes); a **file URL** on the pasteboard suppresses the branch entirely (a Finder copy is a reference to a file the user already filed — a different gesture, and this one declines rather than guessing); raw image data is the fallback. **Target**: the board window pastes onto the **anchor card** (the existing paste-target grammar — the last selected card in flatten order), so a lane, an empty and a trash selection all grey the row out rather than the app guessing a card; a card window pastes onto **its own card**, and a focused text field still wins ⌘V natively, as everywhere else. **Format**: a file-shaped flavor is kept byte for byte — PNG, JPEG, GIF, HEIC, WebP, extension its own — and everything else (TIFF, BMP: interchange encodings, not files people want in a folder) is re-encoded to PNG; PNG is preferred when several flavors are offered, which is the screenshot's case. **Name**: Finder's own — `Pasted Image.png`, then `Pasted Image 2.png` — minted by the existing import path's own collision ladder. It **is** that import path (the one Finder file drops and ⇧⌘A take), so the bracket, the read-only lock, the banners, the echo receipts and "no undo step for an attachment arrival" all hold unchanged, and the arrival announces exactly as a drop's does. A pasteboard that declares a flavor it cannot back up writes nothing at all. **Edit ▸ Paste as Board Background** is the same payload's other destination and a named row rather than a ⌘V branch — a backdrop has no selection to target (03-board-ui.md § Styling ▸ Capabilities). diff --git a/Kanban/App/ClipboardStore.swift b/Kanban/App/ClipboardStore.swift index 91afe7b..a394225 100644 --- a/Kanban/App/ClipboardStore.swift +++ b/Kanban/App/ClipboardStore.swift @@ -48,10 +48,20 @@ import os /// /// `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. +/// 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 { @@ -111,11 +121,16 @@ public final class ClipboardStore { /// than a race nobody has lost yet. @ObservationIgnored private var stagingChain: Task? - /// `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 + /// `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 it. - @ObservationIgnored private nonisolated(unsafe) var activationObserver: (any NSObjectProtocol)? + /// 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") @@ -148,7 +163,7 @@ public final class ClipboardStore { // 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( + stalenessObservers.append(NotificationCenter.default.addObserver( forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main @@ -158,12 +173,34 @@ public final class ClipboardStore { 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 { - if let activationObserver { - NotificationCenter.default.removeObserver(activationObserver) + for observer in stalenessObservers { + NotificationCenter.default.removeObserver(observer) + } + if let commandKeyToken { + NSEvent.removeMonitor(commandKeyToken) } } @@ -621,9 +658,10 @@ public final class ClipboardStore { /// 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. + /// 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 }