The card window's ⌘V drops its picture branch — the attachments header now offers one instead
Raw image data on a card window's ⌘V was a keyboard shortcut with no visible trigger; the sidebar's Attachments header now grows a quiet control — beside the existing add affordance, present only while the pasteboard holds a picture this card could take — that pastes it through the exact seam the retired branch used (ClipboardStore.pasteImage(intoCard:in:), the board's "Paste Image into Card" row's own call). The file-URL branch stays on ⌘V; a Finder copy is still unambiguous. CardBodyTextView's paste-yield mechanism needed no change at all — it forwards by capability, not by picture-specific logic, so a screenshot ⌘V with the body editor focused is now a genuine no-op there, served by the new control instead. The pasteboard's re-read gains a fourth checkpoint — a window becoming key — alongside menu-tracking, ⌘-down and app activation: a persistent visible control has to read true continuously while its window is frontmost, not only at the instant a menu or chord probes it. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -495,11 +495,12 @@ struct CardWindowHost: View {
|
|||||||
.onChange(of: placement.card.hero.value, initial: true) { _, hero in
|
.onChange(of: placement.card.hero.value, initial: true) { _, hero in
|
||||||
attachments.hero = hero
|
attachments.hero = hero
|
||||||
}
|
}
|
||||||
// **⌘V in this window pastes onto this card** (04-interactions.md ▸ Clipboard's
|
// **⌘V in this window pastes a Finder-copied file onto this card** (04-interactions.md ▸
|
||||||
// image-data and file-URL branches). Here rather than inside `CardWindowView` because the
|
// Clipboard's file-URL branch; the image-data branch retired 2026-08-09 for the
|
||||||
// availability is the clipboard's observable reading and this is where a store, a card id
|
// attachments header's paste-image affordance, wired below through `configureAttachments`).
|
||||||
// and the app-wide clipboard are all in scope at once — the same join `configureAttachments`
|
// Here rather than inside `CardWindowView` because the availability is the clipboard's
|
||||||
// makes for the other two writes.
|
// observable reading and this is where a store, a card id and the app-wide clipboard are
|
||||||
|
// all in scope at once — the same join `configureAttachments` makes for the other writes.
|
||||||
.cardWindowPaste(
|
.cardWindowPaste(
|
||||||
store: store,
|
store: store,
|
||||||
cardID: placement.card.id,
|
cardID: placement.card.id,
|
||||||
@@ -724,7 +725,9 @@ struct CardWindowHost: View {
|
|||||||
session.rawSourceIsActive = { [rawSource] in rawSource.isActive }
|
session.rawSourceIsActive = { [rawSource] in rawSource.isActive }
|
||||||
session.rawSourceApply = { [rawSource] in rawSource.applyAndLeave() }
|
session.rawSourceApply = { [rawSource] in rawSource.applyAndLeave() }
|
||||||
session.rawSourceCancel = { [rawSource] in rawSource.cancel() }
|
session.rawSourceCancel = { [rawSource] in rawSource.cancel() }
|
||||||
Self.configureAttachments(attachments, store: store, cardID: cardID, undo: session.undo)
|
Self.configureAttachments(
|
||||||
|
attachments, store: store, cardID: cardID, undo: session.undo, clipboard: appModel.clipboard
|
||||||
|
)
|
||||||
Self.configureComments(session.comments, store: store, cardID: cardID, on: session.undo)
|
Self.configureComments(session.comments, store: store, cardID: cardID, on: session.undo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -827,17 +830,23 @@ struct CardWindowHost: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Points the attachments section at its card — **the one place Add Attachment… and Remove
|
/// Points the attachments section at its card — **the one place Add Attachment…, Remove and the
|
||||||
/// learn which card they act on** (05-card-window.md ▸ Attachments).
|
/// paste-image affordance learn which card they act on** (05-card-window.md ▸ Attachments).
|
||||||
///
|
///
|
||||||
/// Both seams are the store's own bracketed methods, unchanged: `importAttachments(_:toCard:)`
|
/// Every seam is the store's or the clipboard's own bracketed method, unchanged:
|
||||||
/// is the *same* call the board window's Finder drop makes, so a file added through ⇧⌘A, through
|
/// `importAttachments(_:toCard:)` is the *same* call the board window's Finder drop makes, so a
|
||||||
/// the header's plus, through a drop anywhere in this window, and through a drop on the card's
|
/// file added through ⇧⌘A, through the header's plus, through a drop anywhere in this window, and
|
||||||
/// face on the board all take one path — one collision rename, one set of banners, one commit
|
/// through a drop on the card's face on the board all take one path — one collision rename, one
|
||||||
/// shape. There is deliberately no card-window import of its own to keep in step with it.
|
/// set of banners, one commit shape. `pasteImage`/`canPasteImage` are `ClipboardStore
|
||||||
|
/// .pasteImage(intoCard:in:)`/`.canPasteImage(intoCard:in:)` the same way — the exact seam the
|
||||||
|
/// retired ⌘V image branch used, and the board's "Paste Image into Card" context-menu row still
|
||||||
|
/// uses (`CardFaceView`). There is deliberately no card-window import or paste of its own to keep
|
||||||
|
/// in step with either.
|
||||||
///
|
///
|
||||||
/// The store is captured **weakly**, `configureSession`'s rule: a panel still running after the
|
/// The store and the clipboard are captured **weakly**, `configureSession`'s rule: a panel or a
|
||||||
/// board window has gone should write nothing rather than resurrect a released store.
|
/// paste still running after the board window has gone should write nothing rather than
|
||||||
|
/// resurrect a released store — and the clipboard is app-wide and always outlives any one window
|
||||||
|
/// in practice, but the same discipline costs nothing to apply uniformly.
|
||||||
///
|
///
|
||||||
/// `static`, and taking every collaborator as a parameter, for `configureRawSource`'s reason:
|
/// `static`, and taking every collaborator as a parameter, for `configureRawSource`'s reason:
|
||||||
/// the target resolution is invisible in a running window until it is wrong, and this shape is
|
/// the target resolution is invisible in a running window until it is wrong, and this shape is
|
||||||
@@ -846,7 +855,8 @@ struct CardWindowHost: View {
|
|||||||
_ attachments: CardAttachments,
|
_ attachments: CardAttachments,
|
||||||
store: BoardStore,
|
store: BoardStore,
|
||||||
cardID: ItemID,
|
cardID: ItemID,
|
||||||
undo: CardWindowUndo
|
undo: CardWindowUndo,
|
||||||
|
clipboard: ClipboardStore
|
||||||
) {
|
) {
|
||||||
attachments.importFiles = { [weak store] urls in
|
attachments.importFiles = { [weak store] urls in
|
||||||
store?.importAttachments(urls, toCard: cardID)
|
store?.importAttachments(urls, toCard: cardID)
|
||||||
@@ -860,6 +870,18 @@ struct CardWindowHost: View {
|
|||||||
attachments.setHeroFile = { [weak store] name in
|
attachments.setHeroFile = { [weak store] name in
|
||||||
store?.setHero(name, onCard: cardID, on: undo)
|
store?.setHero(name, onCard: cardID, on: undo)
|
||||||
}
|
}
|
||||||
|
// **The retired ⌘V image branch's seam, now the header's control's** (04-interactions.md ▸
|
||||||
|
// Clipboard, re-ruled 2026-08-09). No undo step here either, `setHeroFile`'s neighbor and an
|
||||||
|
// import's own reason: an attachment arrival is not on the stack (13-native-undo.md ▸ Out of
|
||||||
|
// scope).
|
||||||
|
attachments.pasteImage = { [weak store, weak clipboard] in
|
||||||
|
guard let store, let clipboard else { return }
|
||||||
|
clipboard.pasteImage(intoCard: cardID, in: store)
|
||||||
|
}
|
||||||
|
attachments.canPasteImage = { [weak store, weak clipboard] in
|
||||||
|
guard let store, let clipboard else { return false }
|
||||||
|
return CardPasteImageAffordance.isVisible(clipboard: clipboard, cardID: cardID, in: store)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Points the raw-source outlet at its card — the outlet's three seams (05-card-window.md ▸ Raw
|
/// Points the raw-source outlet at its card — the outlet's three seams (05-card-window.md ▸ Raw
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ import os
|
|||||||
///
|
///
|
||||||
/// `NSPasteboard.changeCount` is a machine-wide counter, so a value that moved without this store
|
/// `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
|
/// 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
|
/// validation, app activation, before every paste, and a window becoming key — and nowhere else.
|
||||||
/// state rather than a computed pasteboard read precisely so the menu items' enablement re-evaluates
|
/// `payload` is observable state rather than a computed pasteboard read precisely so the menu items'
|
||||||
/// when it changes rather than whenever SwiftUI happens to rebuild them.
|
/// 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
|
/// **"Menu validation" is two checkpoints here, not a hook**: the commands validate by conditional
|
||||||
/// responder attachment (`ClipboardCommands` — availability *is* the handler's presence), which
|
/// responder attachment (`ClipboardCommands` — availability *is* the handler's presence), which
|
||||||
@@ -62,6 +62,15 @@ import os
|
|||||||
/// screenshot hotkey, ⌃⇧⌘4 — would leave ⌘V dead until the next app switch, which is the image
|
/// 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
|
/// 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.
|
/// `changeCount` read in the common case, which is why they can afford to fire on every ⌘-chord.
|
||||||
|
///
|
||||||
|
/// **A window becoming key is the fourth checkpoint** (widened 2026-08-09 for the card window's
|
||||||
|
/// paste-image affordance, `CardPasteImageAffordance`): a visible control, unlike a menu item, has to
|
||||||
|
/// read true continuously while its window is frontmost rather than only at the instant something
|
||||||
|
/// probes it. Switching between two already-open windows of this app — no menu opened, no ⌘ pressed
|
||||||
|
/// — crosses neither of the first two checkpoints and stays inside one app activation, so the third
|
||||||
|
/// checkpoint misses it too; `NSWindow.didBecomeKeyNotification` is the moment that is left. Fired
|
||||||
|
/// for every window rather than filtered to card windows, for the same cost reason: one more
|
||||||
|
/// `changeCount` read on an already-cheap path.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
public final class ClipboardStore {
|
public final class ClipboardStore {
|
||||||
@@ -196,6 +205,22 @@ public final class ClipboardStore {
|
|||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
MainActor.assumeIsolated { self?.refresh() }
|
MainActor.assumeIsolated { self?.refresh() }
|
||||||
})
|
})
|
||||||
|
// **The fourth checkpoint** (type comment ▸ takeover, widened 2026-08-09 for the card
|
||||||
|
// window's paste-image affordance): any window becoming key. The first three checkpoints
|
||||||
|
// exist for *menu* validation, which only ever runs when something is about to fire — but a
|
||||||
|
// visible control has to read true continuously while its window is frontmost, not only at
|
||||||
|
// the moment a chord or a menu happens to probe it. Switching between two already-open
|
||||||
|
// windows of this app crosses neither of those moments and stays inside one activation, so it
|
||||||
|
// needed a trigger of its own. Every window, not just card windows: cheap (one `changeCount`
|
||||||
|
// read in the common case, `refresh()`'s own guarantee) and simpler than teaching this store
|
||||||
|
// which window kind is asking.
|
||||||
|
stalenessObservers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: NSWindow.didBecomeKeyNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
MainActor.assumeIsolated { self?.refresh() }
|
||||||
|
})
|
||||||
commandKeyToken = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
commandKeyToken = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||||
// Local monitors run on the main thread, before the event reaches its window
|
// Local monitors run on the main thread, before the event reaches its window
|
||||||
// (`LocalModifierFlipWatch`'s note) — and the event is returned unchanged, always, for
|
// (`LocalModifierFlipWatch`'s note) — and the event is returned unchanged, always, for
|
||||||
|
|||||||
@@ -766,8 +766,9 @@ struct CardFaceView: View, Equatable {
|
|||||||
/// card I right-clicked, regardless of the live selection") turns out to be what was wanted.
|
/// card I right-clicked, regardless of the live selection") turns out to be what was wanted.
|
||||||
///
|
///
|
||||||
/// **Paste Image into Card is genuinely per-card** — "into card" is the row's own wording, and it
|
/// **Paste Image into Card is genuinely per-card** — "into card" is the row's own wording, and it
|
||||||
/// is `ClipboardStore.pasteImage(intoCard:in:)`, the *same* method the card window's own ⌘V
|
/// is `ClipboardStore.pasteImage(intoCard:in:)`, the *same* method the card window's own
|
||||||
/// already calls for its attachment branch. It always targets **this** card, never the widened
|
/// attachments-header affordance calls for its picture (the ⌘V image branch it once rode retired
|
||||||
|
/// 2026-08-09 for that visible control). It always targets **this** card, never the widened
|
||||||
/// selection: like Open, Rename and Style's anchor, "single-card by nature" — the existing
|
/// selection: like Open, Rename and Style's anchor, "single-card by nature" — the existing
|
||||||
/// precedent this SCOPE asks new rows to follow for such rows. "… more tbd" is left as the SCOPE
|
/// precedent this SCOPE asks new rows to follow for such rows. "… more tbd" is left as the SCOPE
|
||||||
/// asks: one row today, the submenu built to grow.
|
/// asks: one row today, the submenu built to grow.
|
||||||
|
|||||||
@@ -79,17 +79,24 @@ extension View {
|
|||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
|
|
||||||
/// **⌘V in a card window pastes onto that card** (04-interactions.md ▸ Clipboard, the image-data
|
/// **⌘V in a card window pastes a Finder-copied file onto that card** (04-interactions.md ▸
|
||||||
/// and file-URL branches; 05-card-window.md ▸ Attachments) — the file branch first, the picture
|
/// Clipboard, the file-URL branch; 05-card-window.md ▸ Attachments) — the board's own responder
|
||||||
/// branch behind it, `pasteAction`'s own precedence one window over.
|
/// shape, one window over.
|
||||||
///
|
///
|
||||||
/// The board's own responder shape, one window over and with two branches instead of three: there
|
/// **The image-data branch retired from here** (re-ruled 2026-08-09 — "instead of a special ⌘V
|
||||||
/// is no board payload a card window could paste — cards and lanes land on a *board* — so the card
|
/// handler at card window level, add a control that shows up when an image is detected in
|
||||||
/// window answers `paste:` only for the two attachment branches, and only while one of them has
|
/// pasteboard"): raw image data on this window's ⌘V is no longer implicitly an attachment. It
|
||||||
/// something to take. **One combined handler, not two `.onCommand`s for the same selector**: the
|
/// falls through to whatever the focused surface does natively — the body editor's own paste when
|
||||||
/// precedence has to be one expression for the same reason `pasteAction` is, and layering a second
|
/// it can read the pasteboard, nothing at all when it cannot (`CardBodyTextView`'s capability
|
||||||
/// responder over the same selector would leave the ordering to however SwiftUI happened to chain
|
/// yield, unchanged; a screenshot with the editor focused is now a genuine no-op there). The
|
||||||
/// them rather than to this file.
|
/// header's paste-image affordance is the one path left for the picture (`CardAttachments
|
||||||
|
/// .pasteImage`, wired in `CardWindowHost.configureAttachments`), and the board's own ⌘V and its
|
||||||
|
/// "Paste Image into Card" context-menu row are untouched — this file changes for the *card
|
||||||
|
/// window* only.
|
||||||
|
///
|
||||||
|
/// There is no board payload a card window could paste — cards and lanes land on a *board* — so
|
||||||
|
/// the card window answers `paste:` only for the file branch, and only while it has something to
|
||||||
|
/// take.
|
||||||
///
|
///
|
||||||
/// **A focused text field still wins, with nothing here doing the arithmetic.** `NSTextView`
|
/// **A focused text field still wins, with nothing here doing the arithmetic.** `NSTextView`
|
||||||
/// consumes `paste:` natively, so ⌘V in the body editor, the comment composer or an inline
|
/// consumes `paste:` natively, so ⌘V in the body editor, the comment composer or an inline
|
||||||
@@ -101,13 +108,21 @@ extension View {
|
|||||||
func cardWindowPaste(store: BoardStore, cardID: ItemID, clipboard: ClipboardStore) -> some View {
|
func cardWindowPaste(store: BoardStore, cardID: ItemID, clipboard: ClipboardStore) -> some View {
|
||||||
onCommand(
|
onCommand(
|
||||||
#selector(NSText.paste(_:)),
|
#selector(NSText.paste(_:)),
|
||||||
perform: Self.cardWindowPasteAction(store: store, cardID: cardID, clipboard: clipboard)
|
perform: CardWindowPasteRouting.action(store: store, cardID: cardID, clipboard: clipboard)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The card window's own `pasteAction` — the file branch outranking the picture branch, exactly
|
/// The card window's own `pasteAction` — one clause, now that the picture branch answers to a
|
||||||
/// as `refresh()` orders them.
|
/// control instead of to this selector (`CardPasteImageAffordance`, below).
|
||||||
private static func cardWindowPasteAction(
|
///
|
||||||
|
/// A free type rather than a `View` extension member, unlike `pasteAction` (above) staying where it
|
||||||
|
/// is: this composition is *the* regression surface for "does ⌘V still auto-attach a picture in a
|
||||||
|
/// card window", 04-interactions.md ▸ Clipboard's re-ruling, and a test needs to call it without a
|
||||||
|
/// throwaway `View` conformer standing in for one.
|
||||||
|
@MainActor
|
||||||
|
enum CardWindowPasteRouting {
|
||||||
|
static func action(
|
||||||
store: BoardStore,
|
store: BoardStore,
|
||||||
cardID: ItemID,
|
cardID: ItemID,
|
||||||
clipboard: ClipboardStore
|
clipboard: ClipboardStore
|
||||||
@@ -115,13 +130,32 @@ extension View {
|
|||||||
if clipboard.canPasteFiles(intoCard: cardID, in: store) {
|
if clipboard.canPasteFiles(intoCard: cardID, in: store) {
|
||||||
return { clipboard.pasteFiles(intoCard: cardID, in: store) }
|
return { clipboard.pasteFiles(intoCard: cardID, in: store) }
|
||||||
}
|
}
|
||||||
if clipboard.canPasteImage(intoCard: cardID, in: store) {
|
|
||||||
return { clipboard.pasteImage(intoCard: cardID, in: store) }
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - The card window's paste-image affordance
|
||||||
|
|
||||||
|
/// **The image-data branch's replacement**: a visible control rather than a silent keyboard shortcut
|
||||||
|
/// (04-interactions.md ▸ Clipboard, re-ruled 2026-08-09). The card window no longer answers `paste:`
|
||||||
|
/// for raw image data at all (`CardWindowPasteRouting`, above); the attachments section header's
|
||||||
|
/// button is the one path left for it (`CardAttachmentsSection`), and it takes exactly the seam the
|
||||||
|
/// retired branch took — `ClipboardStore.pasteImage(intoCard:in:)`, through `CardAttachments
|
||||||
|
/// .pasteImage`.
|
||||||
|
///
|
||||||
|
/// **The pure seam the control's visibility and its paste share**: `ClipboardStore
|
||||||
|
/// .canPasteImage(intoCard:in:)` is already the whole predicate — the lock, the payload, and the
|
||||||
|
/// card being on the board side — so this is a named alias for it rather than a second reading that
|
||||||
|
/// could drift from what pressing the button actually does. Named on its own so a test can say what
|
||||||
|
/// it is asserting ("the affordance shows") without borrowing a name that means "the paste would
|
||||||
|
/// succeed", even though today the two answers are one call.
|
||||||
|
@MainActor
|
||||||
|
enum CardPasteImageAffordance {
|
||||||
|
static func isVisible(clipboard: ClipboardStore, cardID: ItemID, in store: BoardStore) -> Bool {
|
||||||
|
clipboard.canPasteImage(intoCard: cardID, in: store)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Edit ▸ Paste as Board Background
|
// MARK: - Edit ▸ Paste as Board Background
|
||||||
|
|
||||||
/// **Edit ▸ Paste as Board Background** — the pasteboard's picture into the board folder, with
|
/// **Edit ▸ Paste as Board Background** — the pasteboard's picture into the board folder, with
|
||||||
|
|||||||
@@ -76,6 +76,18 @@ public final class CardAttachments {
|
|||||||
/// gesture was issued on (13-native-undo.md ▸ Rules ▸ two levels).
|
/// gesture was issued on (13-native-undo.md ▸ Rules ▸ two levels).
|
||||||
public var setHeroFile: ((String?) -> Void)?
|
public var setHeroFile: ((String?) -> Void)?
|
||||||
|
|
||||||
|
/// **The header's paste-image affordance's write** — filled by the host with `ClipboardStore
|
||||||
|
/// .pasteImage(intoCard:in:)`, the same call the card window's own ⌘V used before the control
|
||||||
|
/// replaced it, and the board's "Paste Image into Card" context-menu row still uses
|
||||||
|
/// (04-interactions.md ▸ Clipboard, re-ruled 2026-08-09). One import path behind three pointers
|
||||||
|
/// at it now, none of them a second implementation.
|
||||||
|
public var pasteImage: (() -> Void)?
|
||||||
|
|
||||||
|
/// **Whether the affordance shows at all** — filled by the host with `CardPasteImageAffordance
|
||||||
|
/// .isVisible(clipboard:cardID:in:)`, read fresh on every view evaluation rather than cached, so
|
||||||
|
/// the control's presence and `pasteImage()`'s success can never disagree.
|
||||||
|
public var canPasteImage: (() -> Bool)?
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
// MARK: - Derived
|
// MARK: - Derived
|
||||||
|
|||||||
@@ -80,7 +80,14 @@ struct CardAttachmentsSection: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: CardWindowMetrics.attachmentRowPadding(bodyPointSize: pointSize)) {
|
VStack(alignment: .leading, spacing: CardWindowMetrics.attachmentRowPadding(bodyPointSize: pointSize)) {
|
||||||
CardSidebarSectionHeader(title: "Attachments") { addAffordance }
|
CardSidebarSectionHeader(title: "Attachments") {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if attachments.canPasteImage?() == true {
|
||||||
|
pasteImageAffordance
|
||||||
|
}
|
||||||
|
addAffordance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if names.isEmpty {
|
if names.isEmpty {
|
||||||
emptyHint
|
emptyHint
|
||||||
@@ -96,6 +103,30 @@ struct CardAttachmentsSection: View {
|
|||||||
|
|
||||||
// MARK: - Header
|
// MARK: - Header
|
||||||
|
|
||||||
|
/// **The paste-image affordance** — the ⌘V image-data branch's replacement (04-interactions.md ▸
|
||||||
|
/// Clipboard, re-ruled 2026-08-09): present only while the pasteboard holds a picture this card
|
||||||
|
/// could take, rather than a permanent row that would spend most of its life disabled. Its
|
||||||
|
/// visibility and its tap read and call the exact same seam, `CardAttachments.canPasteImage`/
|
||||||
|
/// `.pasteImage` — the header's own `CardPasteImageAffordance.isVisible` — so the button can never
|
||||||
|
/// promise a paste it does not deliver.
|
||||||
|
///
|
||||||
|
/// **Present-or-absent, the hero rows' own posture** (below): a disabled picture-paste icon
|
||||||
|
/// sitting in this header the other 99% of the time a card window is open would be furniture
|
||||||
|
/// nobody reads, where "it showed up" is the whole point of the control replacing a keyboard
|
||||||
|
/// shortcut nobody could see.
|
||||||
|
private var pasteImageAffordance: some View {
|
||||||
|
Button {
|
||||||
|
attachments.pasteImage?()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "photo.badge.plus")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Paste the copied picture as an attachment")
|
||||||
|
.accessibilityLabel("Paste Image as Attachment")
|
||||||
|
}
|
||||||
|
|
||||||
/// The header's **quiet add affordance** — "a pointer twin of File ▸ Add Attachment…, no
|
/// The header's **quiet add affordance** — "a pointer twin of File ▸ Add Attachment…, no
|
||||||
/// separate behavior" (11-command-nexus.md), which is why it calls the same method the menu row
|
/// separate behavior" (11-command-nexus.md), which is why it calls the same method the menu row
|
||||||
/// does rather than opening a panel of its own.
|
/// does rather than opening a panel of its own.
|
||||||
|
|||||||
@@ -66,6 +66,19 @@ private func purgeFromTrash(_ url: URL?) -> Bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A `ClipboardStore` for the tests below that only need to satisfy `configureAttachments`'s
|
||||||
|
/// paste-image seam — the affordance itself is `PasteImageTests`' subject, not this file's. A
|
||||||
|
/// `FakePasteboard` (`ClipboardTests.swift`'s double, never the machine's) and a throwaway staging
|
||||||
|
/// directory the caller tears down alongside its board fixture.
|
||||||
|
@MainActor
|
||||||
|
private func makeScratchClipboard() throws -> (clipboard: ClipboardStore, staging: URL) {
|
||||||
|
let staging = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("CardAttachmentsTests-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
|
||||||
|
let clipboard = ClipboardStore(pasteboard: FakePasteboard(), stagingRoot: staging, observesActivation: false)
|
||||||
|
return (clipboard, staging)
|
||||||
|
}
|
||||||
|
|
||||||
/// The same, by name, for the paths that do not hand the resulting URL back (the store's).
|
/// The same, by name, for the paths that do not hand the resulting URL back (the store's).
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func purgeFromTrash(named name: String) -> Bool {
|
private func purgeFromTrash(named name: String) -> Bool {
|
||||||
@@ -246,9 +259,13 @@ struct AddAttachmentTargetTests {
|
|||||||
defer { sources.tearDown() }
|
defer { sources.tearDown() }
|
||||||
let shot = try sources.file("shot.png", Data([0x89, 0x50]))
|
let shot = try sources.file("shot.png", Data([0x89, 0x50]))
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let (clipboard, staging) = try makeScratchClipboard()
|
||||||
|
defer { try? FileManager.default.removeItem(at: staging) }
|
||||||
|
|
||||||
let attachments = CardAttachments()
|
let attachments = CardAttachments()
|
||||||
CardWindowHost.configureAttachments(attachments, store: store, cardID: card1, undo: CardWindowUndo())
|
CardWindowHost.configureAttachments(
|
||||||
|
attachments, store: store, cardID: card1, undo: CardWindowUndo(), clipboard: clipboard
|
||||||
|
)
|
||||||
attachments.importFiles?([shot])
|
attachments.importFiles?([shot])
|
||||||
|
|
||||||
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == Data([0x89, 0x50]))
|
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == Data([0x89, 0x50]))
|
||||||
@@ -264,9 +281,13 @@ struct AddAttachmentTargetTests {
|
|||||||
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/\(doomed)", Data([0x01]))
|
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/\(doomed)", Data([0x01]))
|
||||||
try fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments/\(doomed)", Data([0x02]))
|
try fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments/\(doomed)", Data([0x02]))
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let (clipboard, staging) = try makeScratchClipboard()
|
||||||
|
defer { try? FileManager.default.removeItem(at: staging) }
|
||||||
|
|
||||||
let attachments = CardAttachments()
|
let attachments = CardAttachments()
|
||||||
CardWindowHost.configureAttachments(attachments, store: store, cardID: card1, undo: CardWindowUndo())
|
CardWindowHost.configureAttachments(
|
||||||
|
attachments, store: store, cardID: card1, undo: CardWindowUndo(), clipboard: clipboard
|
||||||
|
)
|
||||||
attachments.removeFile?(doomed)
|
attachments.removeFile?(doomed)
|
||||||
|
|
||||||
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments").isEmpty)
|
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments").isEmpty)
|
||||||
@@ -283,9 +304,13 @@ struct AddAttachmentTargetTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x01]))
|
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x01]))
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let (clipboard, staging) = try makeScratchClipboard()
|
||||||
|
defer { try? FileManager.default.removeItem(at: staging) }
|
||||||
|
|
||||||
let attachments = CardAttachments()
|
let attachments = CardAttachments()
|
||||||
CardWindowHost.configureAttachments(attachments, store: store, cardID: card1, undo: CardWindowUndo())
|
CardWindowHost.configureAttachments(
|
||||||
|
attachments, store: store, cardID: card1, undo: CardWindowUndo(), clipboard: clipboard
|
||||||
|
)
|
||||||
attachments.cardFolder = cardFolder(fixture)
|
attachments.cardFolder = cardFolder(fixture)
|
||||||
attachments.names = ["shot.png"]
|
attachments.names = ["shot.png"]
|
||||||
|
|
||||||
|
|||||||
@@ -547,17 +547,154 @@ struct SetAsHeroTests {
|
|||||||
/// ever crossed or the id captured from the wrong place.
|
/// ever crossed or the id captured from the wrong place.
|
||||||
@Test("The attachment row's seam writes the window's own card")
|
@Test("The attachment row's seam writes the window's own card")
|
||||||
func theRowsSeam() throws {
|
func theRowsSeam() throws {
|
||||||
let fixture = try makeClipboardBoard()
|
let harness = try makeClipboardHarness()
|
||||||
defer { fixture.tearDown() }
|
defer { harness.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
|
||||||
|
|
||||||
let attachments = CardAttachments()
|
let attachments = CardAttachments()
|
||||||
CardWindowHost.configureAttachments(
|
CardWindowHost.configureAttachments(
|
||||||
attachments, store: store, cardID: clipboardCard1, undo: CardWindowUndo()
|
attachments, store: harness.store, cardID: clipboardCard1, undo: CardWindowUndo(),
|
||||||
|
clipboard: harness.clipboard
|
||||||
)
|
)
|
||||||
attachments.setHeroFile?("photo.png")
|
attachments.setHeroFile?("photo.png")
|
||||||
|
|
||||||
#expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).value == "photo.png")
|
#expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: harness.fixture).value == "photo.png")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The card window's paste-image affordance
|
||||||
|
|
||||||
|
/// **The header's control, wired through `configureAttachments`** — replaces the retired ⌘V
|
||||||
|
/// image-data branch (04-interactions.md ▸ Clipboard, re-ruled 2026-08-09). Driven through the real
|
||||||
|
/// wiring, `theRowsSeam`'s own reason: the test breaks if the seam is ever crossed or the id captured
|
||||||
|
/// from the wrong place.
|
||||||
|
@MainActor
|
||||||
|
@Suite("Paste ▸ the card window's paste-image affordance")
|
||||||
|
struct PasteImageAffordanceTests {
|
||||||
|
|
||||||
|
private func attachments(_ card: String, in fixture: WriterFixture) throws -> [String] {
|
||||||
|
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
for lane in model.lanes {
|
||||||
|
if let match = lane.cards.first(where: { $0.id.rawValue == card }) { return match.attachments }
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The button's write lands the pasteboard's picture on the window's own card")
|
||||||
|
func theButtonsWrite() throws {
|
||||||
|
let harness = try makeClipboardHarness()
|
||||||
|
defer { harness.tearDown() }
|
||||||
|
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
|
||||||
|
harness.clipboard.refresh()
|
||||||
|
|
||||||
|
let attachments = CardAttachments()
|
||||||
|
CardWindowHost.configureAttachments(
|
||||||
|
attachments, store: harness.store, cardID: clipboardCard4, undo: CardWindowUndo(),
|
||||||
|
clipboard: harness.clipboard
|
||||||
|
)
|
||||||
|
#expect(attachments.canPasteImage?() == true)
|
||||||
|
|
||||||
|
attachments.pasteImage?()
|
||||||
|
|
||||||
|
#expect(try self.attachments(Ident.card4, in: harness.fixture) == ["Pasted Image.png"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The button's visibility clears once the pasteboard no longer offers a picture")
|
||||||
|
func visibilityFollowsThePasteboard() throws {
|
||||||
|
let harness = try makeClipboardHarness()
|
||||||
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
|
let attachments = CardAttachments()
|
||||||
|
CardWindowHost.configureAttachments(
|
||||||
|
attachments, store: harness.store, cardID: clipboardCard1, undo: CardWindowUndo(),
|
||||||
|
clipboard: harness.clipboard
|
||||||
|
)
|
||||||
|
#expect(attachments.canPasteImage?() == false, "nothing on the pasteboard yet")
|
||||||
|
|
||||||
|
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
|
||||||
|
harness.clipboard.refresh()
|
||||||
|
#expect(attachments.canPasteImage?() == true)
|
||||||
|
|
||||||
|
// A Finder-copied file outranks the picture riding beside it — the same precedence ⌘V's own
|
||||||
|
// file branch reads (`PastedImage.flavor`'s clause order) — so the button goes quiet exactly
|
||||||
|
// where the file branch lights up instead.
|
||||||
|
harness.pasteboard.seedFileURLs(
|
||||||
|
[URL(fileURLWithPath: "/tmp/shot.png")], also: [(UTType.png.identifier, encodedImage(.png))]
|
||||||
|
)
|
||||||
|
harness.clipboard.refresh()
|
||||||
|
#expect(attachments.canPasteImage?() == false, "a file URL wins the precedence")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The button is absent under the read-only lock")
|
||||||
|
func lockedBoardsOfferNothing() throws {
|
||||||
|
let harness = try makeClipboardHarness()
|
||||||
|
defer { harness.tearDown() }
|
||||||
|
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
|
||||||
|
harness.clipboard.refresh()
|
||||||
|
harness.store.enterVanishedRootLock()
|
||||||
|
|
||||||
|
let attachments = CardAttachments()
|
||||||
|
CardWindowHost.configureAttachments(
|
||||||
|
attachments, store: harness.store, cardID: clipboardCard1, undo: CardWindowUndo(),
|
||||||
|
clipboard: harness.clipboard
|
||||||
|
)
|
||||||
|
#expect(attachments.canPasteImage?() == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The card window's ⌘V routing, image data retired
|
||||||
|
|
||||||
|
/// **`CardWindowPasteRouting.action` keeps one clause** (04-interactions.md ▸ Clipboard, re-ruled
|
||||||
|
/// 2026-08-09: "instead of a special ⌘V handler at card window level … add a control"). Driven
|
||||||
|
/// directly rather than through `canPasteFiles`/`canPasteImage` on their own, because those two
|
||||||
|
/// predicates staying correct says nothing about whether the *composition* still offers the retired
|
||||||
|
/// branch — which is exactly the regression this suite exists to catch.
|
||||||
|
@MainActor
|
||||||
|
@Suite("Paste ▸ the card window's ⌘V, image data retired")
|
||||||
|
struct CardWindowPasteRoutingTests {
|
||||||
|
|
||||||
|
private func attachmentNames(_ card: String, in fixture: WriterFixture) throws -> [String] {
|
||||||
|
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
for lane in model.lanes {
|
||||||
|
if let match = lane.cards.first(where: { $0.id.rawValue == card }) { return match.attachments }
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Raw image data alone offers the card window's ⌘V nothing")
|
||||||
|
func imageDataAloneOffersNothing() throws {
|
||||||
|
let harness = try makeClipboardHarness()
|
||||||
|
defer { harness.tearDown() }
|
||||||
|
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
|
||||||
|
harness.clipboard.refresh()
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
harness.clipboard.canPasteImage(intoCard: clipboardCard4, in: harness.store),
|
||||||
|
"the affordance would still show"
|
||||||
|
)
|
||||||
|
let action = CardWindowPasteRouting.action(
|
||||||
|
store: harness.store, cardID: clipboardCard4, clipboard: harness.clipboard
|
||||||
|
)
|
||||||
|
#expect(action == nil)
|
||||||
|
#expect(try attachmentNames(Ident.card4, in: harness.fixture).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A Finder-copied file still routes through the card window's ⌘V")
|
||||||
|
func fileURLsStillRoute() throws {
|
||||||
|
let harness = try makeClipboardHarness()
|
||||||
|
defer { harness.tearDown() }
|
||||||
|
let source = try WriterFixture()
|
||||||
|
defer { source.tearDown() }
|
||||||
|
let shot = try source.file("shot.png", Data([0x89, 0x50]))
|
||||||
|
harness.pasteboard.seedFileURLs([shot])
|
||||||
|
harness.clipboard.refresh()
|
||||||
|
|
||||||
|
let action = CardWindowPasteRouting.action(
|
||||||
|
store: harness.store, cardID: clipboardCard4, clipboard: harness.clipboard
|
||||||
|
)
|
||||||
|
#expect(action != nil)
|
||||||
|
action?()
|
||||||
|
|
||||||
|
#expect(try attachmentNames(Ident.card4, in: harness.fixture) == ["shot.png"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,8 +940,15 @@ private final class PasteCatcher: NSView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// **`CardBodyTextView` yields a paste it cannot read** (05-card-window.md ▸ Attachments, ruled
|
/// **`CardBodyTextView` yields a paste it cannot read** (05-card-window.md ▸ Attachments, ruled
|
||||||
/// 2026-08-09) — the screenshot pasteboard reaches the window's attachment branch even while the
|
/// 2026-08-09) — a screenshot pasteboard reaches *whatever responds behind the editor* even while
|
||||||
/// editor holds the keyboard, and a text paste never leaves the editor.
|
/// the editor holds the keyboard, and a text paste never leaves the editor. The mechanism is
|
||||||
|
/// capability-based (`readablePasteboardTypes`), not a picture-specific rule, which is exactly what
|
||||||
|
/// lets `CardWindowPasteRouting.action`'s image branch retire without touching this file at all
|
||||||
|
/// (re-ruled 2026-08-09 — 04-interactions.md ▸ Clipboard): in the real app today nothing answers
|
||||||
|
/// `paste:` behind the editor for an image-only pasteboard any more, so a screenshot ⌘V with the
|
||||||
|
/// body editor focused is a no-op, served instead by the attachments header's control
|
||||||
|
/// (`CardPasteImageAffordance`). `PasteCatcher` here stands for "something behind the editor still
|
||||||
|
/// takes it" in the general case, which is the claim these tests actually pin.
|
||||||
///
|
///
|
||||||
/// The pasteboard is a private named one through the view's `yieldPasteboard` seam, so the suite
|
/// The pasteboard is a private named one through the view's `yieldPasteboard` seam, so the suite
|
||||||
/// never reads the machine's — except through `super.paste`, which is AppKit's own and is exactly
|
/// never reads the machine's — except through `super.paste`, which is AppKit's own and is exactly
|
||||||
|
|||||||
Reference in New Issue
Block a user