diff --git a/Kanban/App/ClipboardManifest.swift b/Kanban/App/ClipboardManifest.swift index 0f7ff05..2589973 100644 --- a/Kanban/App/ClipboardManifest.swift +++ b/Kanban/App/ClipboardManifest.swift @@ -161,6 +161,43 @@ public struct ClipboardManifest: Codable, Sendable, Equatable { entries.map { $0.title ?? "Untitled" }.joined(separator: "\n") } + // MARK: - Paste menu title + + /// **The context-menu Paste row's title** (Pipeline card 36cce96a — "the paste option should show + /// 'Paste Card' or 'Paste Lane' or 'Paste N Cards' depending on what's in pasteboard"): "Paste + /// Card"/"Paste Lane" for one entry, "Paste N Cards"/"Paste N Lanes" for several, plain "Paste" + /// for no app payload at all. One pure function of the manifest, called verbatim by both context + /// menus (`CardFaceView.pasteTitle`, `LaneView.pasteTitle`) — a title composed twice is a title + /// that drifts twice. + /// + /// **`kind` and `entries.count` are the whole of it** — exactly the two fields the type comment + /// already promises are the selection's own vocabulary and its recorded order, so this reads no + /// more of the manifest than that. There is no *mixed* shape to compose a plural for: `kind` is + /// singular by construction — `SelectionGrammar.mixesKinds` refuses a selection that would produce + /// one, so `ClipboardStore.capture` never writes a manifest naming both cards and lanes. Every + /// manifest this app ever produces is cards-only or lanes-only, which is what makes the + /// card-family/lane-family split exhaustive rather than a case among others. + /// + /// **`nil` is the caller's to pass, not this function's to look up.** Both menus already hold + /// `payload` for their own `.disabled` reads (`CardFaceView.pasteEnabled`, `LaneView.pasteEnabled` + /// via `canPaste(into:)`), so a caller with no app payload passes `nil` rather than this function + /// reaching for a store it has no seam to. Answering plain "Paste" here is also the whole of + /// **foreign pasteboard content's** title: a Finder file copy or a screenshot that the paste + /// command still accepts (`ClipboardStore.canPasteFiles`/`canPasteImage`) never decodes to a + /// `ClipboardManifest` in the first place, so it never reaches any branch but this one. + /// + /// **An empty `entries` answers plain "Paste" too**, though nothing reachable ever produces one: + /// `init?(data:)` refuses a decoded manifest with no entries, and `ClipboardStore.capture` refuses + /// an empty selection before a manifest is ever built. The clause exists so this function has no + /// partial case, not because the branch is live. + public static func pasteMenuTitle(for payload: ClipboardManifest?) -> String { + guard let payload, !payload.entries.isEmpty else { return "Paste" } + let count = payload.entries.count + let noun = payload.kind == .card ? "Card" : "Lane" + guard count > 1 else { return "Paste \(noun)" } + return "Paste \(count) \(noun)s" + } + // MARK: Coding public func encoded() -> Data? { diff --git a/Kanban/UI/Board/CardFaceView.swift b/Kanban/UI/Board/CardFaceView.swift index 8b1f13a..a33229a 100644 --- a/Kanban/UI/Board/CardFaceView.swift +++ b/Kanban/UI/Board/CardFaceView.swift @@ -897,6 +897,12 @@ struct CardFaceView: View, Equatable { /// which, again, this very face rendering already rules out. Reduces to `!isReadOnly && /// imagePayload != nil` (`pasteImageEnabled`). /// + /// **The Paste row's dynamic title (`pasteTitle`, 2026-08-09 ▸ card 36cce96a) rides the same + /// channel `pasteEnabled` already opened** rather than adding one: it reads + /// `appModel.clipboard.payload` again, the identical Observable property, and hands it to + /// `ClipboardManifest.pasteMenuTitle(for:)` — a pure function of a value already in hand, not a + /// second store read. No new subscription, no selection or snapshot touched. + /// /// Copy and Cut lean on the same style of proof: `targetIDs` is always non-empty (at minimum this /// card alone) and always homogeneous cards on the board side (`SelectionGrammar.mixesKinds` /// answers `true` only for the trash — see its own doc comment), so `canCopy`/`canCut`'s @@ -940,7 +946,7 @@ struct CardFaceView: View, Equatable { .disabled(!copyEnabled) Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) } .disabled(!store.acceptsBoardMutations) - Button("Paste") { appModel.clipboard.paste(into: store) } + Button(pasteTitle) { appModel.clipboard.paste(into: store) } .disabled(!pasteEnabled) Menu("Copy Special") { Button("Copy Link") { copyLink() } @@ -1094,7 +1100,7 @@ struct CardFaceView: View, Equatable { .disabled(!copyEnabled) Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) } .disabled(!store.acceptsBoardMutations) - Button("Paste") { appModel.clipboard.paste(into: store) } + Button(pasteTitle) { appModel.clipboard.paste(into: store) } .disabled(!pasteEnabled) Button("Send to Trash") { deleteTargets() } .disabled(!store.acceptsBoardMutations) @@ -1249,6 +1255,16 @@ struct CardFaceView: View, Equatable { store.acceptsBoardMutations && appModel.clipboard.payload != nil } + /// **The Paste row's title** — "Paste Card"/"Paste N Cards" and their lane twins, plain "Paste" + /// for anything else (`ClipboardManifest.pasteMenuTitle(for:)`'s own doc comment carries the full + /// rule). Reads exactly the same `appModel.clipboard.payload` `pasteEnabled` already reads above — + /// the render-safety proof this file's type comment states for `pasteEnabled` covers this property + /// too, since it is the identical Observable read, not a second one: no new subscription, no + /// `store.selection`/`store.snapshot` touched. + private var pasteTitle: String { + ClipboardManifest.pasteMenuTitle(for: appModel.clipboard.payload) + } + /// Paste Image into Card's enablement, `pasteEnabled`'s own reduction: `canPasteImage(intoCard:in:)` /// only ever answers `false` on its snapshot lookup for a card that is not a live board item, which /// this face rendering at all already rules out — see the type comment's "Render-safety" section. diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index 1bf9f1a..1493c12 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -664,7 +664,7 @@ struct LaneView: View, Equatable { .disabled(!copyEnabled) Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) } .disabled(!cutEnabled) - Button("Paste") { appModel.clipboard.paste(into: store) } + Button(pasteTitle) { appModel.clipboard.paste(into: store) } .disabled(!pasteEnabled) Divider() @@ -706,7 +706,7 @@ struct LaneView: View, Equatable { .disabled(!copyEnabled) Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) } .disabled(!cutEnabled) - Button("Paste") { appModel.clipboard.paste(into: store) } + Button(pasteTitle) { appModel.clipboard.paste(into: store) } .disabled(!pasteEnabled) Button("Increase Width") { store.setLaneWidth(lane.id, units: units + 1) } .disabled(!store.acceptsBoardMutations) @@ -838,6 +838,18 @@ struct LaneView: View, Equatable { appModel.clipboard.canPaste(into: store) } + /// **The Paste row's title** — `ClipboardManifest.pasteMenuTitle(for:)`'s own doc comment carries + /// the full rule ("Paste Card"/"Paste N Cards" and their lane twins, plain "Paste" otherwise), + /// shared verbatim with `CardFaceView.pasteTitle`. Reads `appModel.clipboard.payload` directly + /// rather than routing through `canPaste(into:)` as `pasteEnabled` does above: this body is already + /// unconditionally subscribed to `store.selection`/`store.snapshot` (`laneMenu`'s own + /// "Render-safety" section), so there is no render-safety reduction to preserve here and no reason + /// to read the payload a second time through a predicate that also checks board mutation and a + /// target — this only ever needs the payload itself. + private var pasteTitle: String { + ClipboardManifest.pasteMenuTitle(for: appModel.clipboard.payload) + } + private var headerContent: some View { HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) { Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane)) diff --git a/KanbanTests/ClipboardTests.swift b/KanbanTests/ClipboardTests.swift index 88efc46..9325cd0 100644 --- a/KanbanTests/ClipboardTests.swift +++ b/KanbanTests/ClipboardTests.swift @@ -287,6 +287,48 @@ struct ClipboardManifestTests { ) #expect(manifest.plainText == "First\nUntitled") } + + // MARK: - pasteMenuTitle + + private func manifest(kind: SelectionKind, entryCount: Int) -> ClipboardManifest { + ClipboardManifest( + copyID: "abc", + boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true), + kind: kind, + container: .board, + entries: (0..