Copy Link — a card context-menu row that puts the card folder on the pasteboard
Card 737a949f: "Add an option to card context menu to copy a link to the card folder." Implements the design ruling verbatim. - New context-menu row "Copy Link" (CardFaceView.boardMenu, board side only — trash cards are excluded, matching "sole selected live card"). Writes the clicked card's folder in one pasteboard item carrying two representations: the file:// URL under .fileURL and the plain absolute path under .string (FolderLinkPasteboard.swift). Enabled on a sole selected live card; disabled on a multi-selection and wherever edit-shaped actions already disable, per the ruling. Also exposed as a VoiceOver custom action alongside its siblings. - Menu-bar twin: Board ▸ Copy Link (BoardCommands.swift, CopyLinkCommand), no default chord — the every-function-a-menu-item contract in DESIGN/11-command-nexus.md is still current, so this is the twin that contract calls for, homed the way Open Card/Rename/ Style… already are. - DESIGN/11-command-nexus.md: new Board-menu row and an updated Card context-menu row. - Tests (KanbanTests/CopyLinkTests.swift): the target predicate's enablement (sole card / multi-selection / lane / trash / empty / inline-editing), the pasteboard write's exact bytes via a fake pasteboard (both representations, exact folder URL), and a disabled-target no-op. Caught and fixed during self-review: an early version read the context menu's widened-selection helper (targetIDs) inside the Copy Link row's .disabled(...), which reads store.selection. Since .contextMenu's content closure is evaluated on every ordinary body pass (not only when the menu opens), that resubscribed every card face on the board to every selection change — the exact O(board) regression RENDER-INSTRUMENTATION.md's isSelected/selectedCount split exists to prevent, caught by BoardRenderPerformanceTests and MarqueeRenderCostTests. Fixed by reading the already-hoisted, non-Observable `selectedCount` parameter instead, which answers the same "how many ride along" question at zero extra subscription cost. xcodegen generate, the Kanban scheme build, and the full KanbanTests suite (2816 tests) are clean. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// Board ▸ Copy Link's target predicate (`BoardStore.copyLinkTarget`) and its pasteboard write
|
||||
/// (`BoardStore.copyCardLink`, `FolderLinkPasteboard`) — design ruling 2026-08-09, card 737a949f
|
||||
/// "Add an option to card context menu to copy a link to the card folder".
|
||||
///
|
||||
/// The context menu's own click-scoped enablement (`CardFaceView.copyLinkEnabled`, which disables on
|
||||
/// a multi-selection even when the clicked card was never the reader of `copyLinkTarget`) is not
|
||||
/// pinned here — no sibling row (`styleTarget`/`targetIDs`) is either, per this suite's own
|
||||
/// `TrashWriteTests.contextMenuDeleteIgnoresTheSelection` precedent for *why* the two answers must
|
||||
/// differ. What is pinned here is the menu-bar row's answer and the actual bytes a write produces,
|
||||
/// which is what the ruling's own test list asks for: "pasteboard contents … enablement (sole vs
|
||||
/// multi selection)".
|
||||
|
||||
// MARK: - Fixture
|
||||
|
||||
@MainActor
|
||||
private func makeCopyLinkBoard() throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.board()
|
||||
try fixture.lane(Ident.lane1, order: "1024", title: "Todo")
|
||||
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "First")
|
||||
try fixture.card(Ident.card2, in: Ident.lane1, order: "2048", title: "Second")
|
||||
try fixture.lane(Ident.lane2, order: "2048", title: "Doing")
|
||||
try fixture.trashCard(Ident.indexless, order: "1024", title: "Trashed")
|
||||
return fixture
|
||||
}
|
||||
|
||||
private let copyLinkLane1 = ItemID(rawValue: Ident.lane1)
|
||||
private let copyLinkCard1 = ItemID(rawValue: Ident.card1)
|
||||
private let copyLinkCard2 = ItemID(rawValue: Ident.card2)
|
||||
private let copyLinkTrashed = ItemID(rawValue: Ident.indexless)
|
||||
|
||||
/// A pasteboard double that records exactly what was written — `FakePasteboard`'s shape
|
||||
/// (`ClipboardTests.swift`), applied to Copy Link's smaller payload, so a test never races the
|
||||
/// machine's one real pasteboard or every other test in the run.
|
||||
@MainActor
|
||||
private final class FakeFolderLinkPasteboard: FolderLinkPasteboard {
|
||||
private(set) var writeCount = 0
|
||||
private(set) var fileURL: URL?
|
||||
private(set) var path: String?
|
||||
|
||||
func write(fileURL: URL, path: String) {
|
||||
writeCount += 1
|
||||
self.fileURL = fileURL
|
||||
self.path = path
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The target predicate
|
||||
|
||||
@MainActor
|
||||
@Suite("Copy Link's target and enablement")
|
||||
struct CopyLinkTargetTests {
|
||||
|
||||
@Test("A sole selected live board card answers its own folder URL")
|
||||
func soleCardAnswersItsFolder() throws {
|
||||
let fixture = try makeCopyLinkBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.select([copyLinkCard1], in: .board)
|
||||
|
||||
let expected = fixture.root
|
||||
.appendingPathComponent(Ident.lane1, isDirectory: true)
|
||||
.appendingPathComponent(Ident.card1, isDirectory: true)
|
||||
#expect(store.copyLinkTarget == expected)
|
||||
}
|
||||
|
||||
@Test("A multi-selection disables it — a link is singular")
|
||||
func multiSelectionDisables() throws {
|
||||
let fixture = try makeCopyLinkBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.select([copyLinkCard1, copyLinkCard2], in: .board)
|
||||
#expect(store.copyLinkTarget == nil)
|
||||
}
|
||||
|
||||
@Test("A lane selection, an empty selection, and a trash selection all disable it")
|
||||
func onlyALiveBoardCardEnablesIt() throws {
|
||||
let fixture = try makeCopyLinkBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.select([copyLinkLane1], in: .board)
|
||||
#expect(store.copyLinkTarget == nil, "a lane has no singular card folder to link")
|
||||
|
||||
store.clearSelection()
|
||||
#expect(store.copyLinkTarget == nil, "nothing selected has no target either — no board fallback")
|
||||
|
||||
store.select([copyLinkTrashed], in: .trash)
|
||||
#expect(store.copyLinkTarget == nil, "\"sole selected live card\" — a trashed card is not one")
|
||||
}
|
||||
|
||||
/// The ruling's explicit gate ("disabled … wherever edit-shaped actions already disable"), even
|
||||
/// though the write itself mutates nothing on disk — `copyLinkTarget`'s own doc comment flags
|
||||
/// this as implemented-as-written rather than re-derived.
|
||||
@Test("An open inline editor disables it, exactly as the ruling asks")
|
||||
func inlineEditingDisablesIt() throws {
|
||||
let fixture = try makeCopyLinkBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.select([copyLinkCard1], in: .board)
|
||||
#expect(store.copyLinkTarget != nil, "sanity: the fixture must actually enable it first")
|
||||
|
||||
store.transient.beginPlaceholder(inLane: copyLinkLane1)
|
||||
#expect(!store.acceptsBoardMutations, "sanity: the fixture must actually be refusing mutations")
|
||||
#expect(store.copyLinkTarget == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The write
|
||||
|
||||
@MainActor
|
||||
@Suite("Copy Link's pasteboard write")
|
||||
struct CopyLinkWriteTests {
|
||||
|
||||
@Test("Both representations land, and the folder URL is exact")
|
||||
func writesBothRepresentations() throws {
|
||||
let fixture = try makeCopyLinkBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let pasteboard = FakeFolderLinkPasteboard()
|
||||
|
||||
store.select([copyLinkCard1], in: .board)
|
||||
store.copyCardLink(to: pasteboard)
|
||||
|
||||
let expectedFolder = fixture.root
|
||||
.appendingPathComponent(Ident.lane1, isDirectory: true)
|
||||
.appendingPathComponent(Ident.card1, isDirectory: true)
|
||||
#expect(pasteboard.writeCount == 1)
|
||||
#expect(pasteboard.fileURL == expectedFolder)
|
||||
#expect(pasteboard.fileURL?.isFileURL == true)
|
||||
#expect(pasteboard.path == expectedFolder.path)
|
||||
}
|
||||
|
||||
@Test("A disabled target writes nothing")
|
||||
func disabledTargetWritesNothing() throws {
|
||||
let fixture = try makeCopyLinkBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let pasteboard = FakeFolderLinkPasteboard()
|
||||
|
||||
store.select([copyLinkCard1, copyLinkCard2], in: .board)
|
||||
store.copyCardLink(to: pasteboard)
|
||||
|
||||
#expect(pasteboard.writeCount == 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The seam's shared write(link:) helper
|
||||
|
||||
@MainActor
|
||||
@Suite("FolderLinkPasteboard's default write(link:)")
|
||||
struct FolderLinkPasteboardTests {
|
||||
|
||||
@Test("write(link:) derives the plain path from the very same URL it writes as the file URL")
|
||||
func writeLinkDerivesPathFromTheSameURL() {
|
||||
let pasteboard = FakeFolderLinkPasteboard()
|
||||
let folder = URL(fileURLWithPath: "/tmp/some board/lane/card", isDirectory: true)
|
||||
|
||||
pasteboard.write(link: folder)
|
||||
|
||||
#expect(pasteboard.fileURL == folder)
|
||||
#expect(pasteboard.path == folder.path)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user