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:
@@ -40,6 +40,7 @@ The single source of truth for **every command and action the app can perform**
|
||||
| Edit | Find | ⌘F | Board window: board search (04 ▸ Search); card window: find-in-text (05) |
|
||||
| Edit | Find Next / Find Previous | ⌘G / ⇧⌘G | Card window: the find bar's stepping — the rows enable only while the comments-thread find bar is up and step *that* bar; otherwise they disable and the chord falls through the responder chain to the focused text surface's own NSTextFinder stepping (pinned 2026-07-31 — routing by focus applied to find); disabled in the board window — board search is a live filter, not a cursor. **Use Selection for Find (⌘E) is deliberately absent**: the chord belongs to View ▸ Edit Body, which outranks the text view's binding; a user who wants it back remaps Edit Body system-natively |
|
||||
| Board | Open Card | ⌘↩ | Board window, sole selected live card; during an inline title edit (placeholder or rename), commits it and opens — the one board command enabled mid-edit (04 ▸ Grammar) |
|
||||
| Board | Copy Link | — (no default) | Board window, sole selected live card; writes the card folder's `file://` URL and plain absolute path to the pasteboard, one write, two representations; disabled on multi-selections and wherever edit-shaped actions disable (design ruling 2026-08-09) |
|
||||
| Board | Rename | — (cards: Return in place) | Board window, sole selected card/lane; a lane's only rename path (Return on a lane creates); exists for completeness and remapping |
|
||||
| Board | Style… (the style editor; selection-aware) | ⌥⌘S | Board window: selected cards or lane; nothing selected = the board |
|
||||
| Board | Move Up / Move Down | ⌥⌘↑ / ⌥⌘↓ | Card selection within one lane (within-lane sort, logical order; non-contiguous selections gather behind their first card on the first press); disabled when the selection spans lanes; inert on lanes and on trash cards |
|
||||
@@ -98,7 +99,7 @@ Context menus are the per-item action inventory VoiceOver reads (10 ▸ The boar
|
||||
|
||||
| Surface | Entries |
|
||||
|---|---|
|
||||
| Card | Open, Rename, Style…, quick-style recents row (03), Delete (the ⌥-alternate Delete Immediately row retired with the command, 2026-07-30) |
|
||||
| Card | Open, Copy Link (folder link to the pasteboard — twin of Board ▸ Copy Link, 2026-08-09), Rename, Style…, quick-style recents row (03), Delete (the ⌥-alternate Delete Immediately row retired with the command, 2026-07-30) |
|
||||
| Lane | One menu, invoked on the header or lane empty space (settled — a full lane still has its header): Rename, Style…, quick-style recents row (03), Width control (stepper — menu twins Increase/Decrease Lane Width), Delete |
|
||||
| Trash selection | Delete (permanent — 03's recoverability confirm), Reveal in Finder (inspection before a purge; twin of File ▸ Reveal in Finder, not edit-shaped, enabled on trash selections — 04 ▸ The trash) |
|
||||
| Attachment row | Open, Remove (system Trash) — twins of the focused section's grammar keys (Return / ⌫ — 05); Reveal in Finder — twin of File ▸ Reveal in Finder in its attachments-focused context |
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
// MARK: - Copy Link's pasteboard seam
|
||||
|
||||
/// The pasteboard Board ▸ Copy Link and the card context menu's own Copy Link row write through —
|
||||
/// shared by both so the two surfaces can never come to carry different bytes (`BoardStore.copyCardLink`,
|
||||
/// `CardFaceView.copyLink`).
|
||||
///
|
||||
/// Design ruling (2026-08-09, card 737a949f "Add an option to card context menu to copy a link to
|
||||
/// the card folder"): "writes the card FOLDER's `file://` URL to the general pasteboard in one write
|
||||
/// with two representations: file URL and plain absolute-path string, so Finder-aware surfaces get
|
||||
/// the URL and terminals/editors get the path."
|
||||
///
|
||||
/// A protocol rather than a bare `NSPasteboard` call, for `ClipboardPasteboard`'s exact reason
|
||||
/// (`ClipboardManifest.swift`): this write is a one-off outbound action — never a participant in
|
||||
/// `ClipboardStore`'s cut/copy/paste cycle, so it needs no `changeCount` and no takeover detection —
|
||||
/// but a test still has to read back what was written without racing the machine's one real
|
||||
/// pasteboard, or every other test in the run.
|
||||
@MainActor
|
||||
protocol FolderLinkPasteboard: AnyObject {
|
||||
func write(fileURL: URL, path: String)
|
||||
}
|
||||
|
||||
extension FolderLinkPasteboard {
|
||||
|
||||
/// The one call both Copy Link surfaces make: a card folder's URL and the plain path derived
|
||||
/// from that very same URL, never two separately-resolved strings that could disagree.
|
||||
func write(link folder: URL) {
|
||||
write(fileURL: folder, path: folder.path)
|
||||
}
|
||||
}
|
||||
|
||||
/// The real pasteboard — `NSPasteboard.general`, the ruling's own words.
|
||||
///
|
||||
/// **One `NSPasteboardItem` carrying both representations** — `SystemPasteboard.write(manifest:text:)`'s
|
||||
/// own shape (`ClipboardManifest.swift`) — rather than two separate items, so a paste target sees one
|
||||
/// clipboard entry and reads whichever flavor it understands: `.fileURL` for Finder-aware surfaces
|
||||
/// (open panels, other apps that resolve dropped/pasted files), `.string` for terminals and editors
|
||||
/// that only understand text and want the plain absolute path rather than a `file://` string.
|
||||
@MainActor
|
||||
final class SystemFolderLinkPasteboard: FolderLinkPasteboard {
|
||||
|
||||
private let pasteboard: NSPasteboard
|
||||
|
||||
init(_ pasteboard: NSPasteboard = .general) {
|
||||
self.pasteboard = pasteboard
|
||||
}
|
||||
|
||||
func write(fileURL: URL, path: String) {
|
||||
pasteboard.clearContents()
|
||||
let item = NSPasteboardItem()
|
||||
item.setString(fileURL.absoluteString, forType: .fileURL)
|
||||
item.setString(path, forType: .string)
|
||||
pasteboard.writeObjects([item])
|
||||
}
|
||||
}
|
||||
@@ -272,9 +272,9 @@ struct KanbanApp: App {
|
||||
}
|
||||
|
||||
// The Board menu (11-command-nexus.md), complete and in its inventoried row order — Open
|
||||
// Card, Rename, Style…, the card moves, the lane moves, the width pair. Its items act on
|
||||
// the frontmost board window, which they reach through the focus system rather than through
|
||||
// the app model — see `BoardCommands.swift`, which also owns their validation.
|
||||
// Card, Copy Link, Rename, Style…, the card moves, the lane moves, the width pair. Its items
|
||||
// act on the frontmost board window, which they reach through the focus system rather than
|
||||
// through the app model — see `BoardCommands.swift`, which also owns their validation.
|
||||
//
|
||||
// **Board Settings… came out 2026-08-07** with the sheet it opened (03 ▸ Board settings
|
||||
// sheet, marked retired; the 2026-07-31 popover/sheet split reversed): a board is configured
|
||||
@@ -283,8 +283,13 @@ struct KanbanApp: App {
|
||||
//
|
||||
// **The Board ▸ Pull/Push row (`RemoteCommands`) came out 2026-08-08** with app-managed git
|
||||
// itself (strategy/01-git-excision.md): the width pair is now the menu's last row.
|
||||
//
|
||||
// **Copy Link joined 2026-08-09** (design ruling, card 737a949f) right beside Open Card: the
|
||||
// two read-only rows — nothing here mutates the board — grouped ahead of the edit-shaped
|
||||
// block below (Rename, Style…), which is where the context menu puts the same two.
|
||||
CommandMenu("Board") {
|
||||
OpenCardCommand()
|
||||
CopyLinkCommand()
|
||||
BoardRenameCommand()
|
||||
BoardStyleCommand()
|
||||
|
||||
|
||||
@@ -425,6 +425,60 @@ struct BoardInfoCommand: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Copy Link
|
||||
|
||||
extension BoardStore {
|
||||
|
||||
/// Board ▸ Copy Link's target: the sole selected **live board card**'s folder URL, or `nil` — "a
|
||||
/// link is singular" (design ruling 2026-08-09, card 737a949f "Add an option to card context menu
|
||||
/// to copy a link to the card folder"), the same board-card-only shape `openCardTarget` answers
|
||||
/// for its own reason: a lane, a multi-selection and a trash selection all disable it.
|
||||
///
|
||||
/// **Additionally gated on `acceptsBoardMutations`**, unlike `openCardTarget` — the ruling asks
|
||||
/// for this in as many words ("disabled … wherever edit-shaped actions already disable"), even
|
||||
/// though writing a link to the pasteboard changes nothing on disk. Worth flagging rather than
|
||||
/// silently matching: Copy Link could have stayed live under the lock the way Reveal in Finder
|
||||
/// does ("not edit-shaped … inspecting a folder before a purge is exactly the errand it exists
|
||||
/// for" — `CardFaceView.trashMenu`'s doc), but the ruling states the gate explicitly, so it is
|
||||
/// implemented as written rather than re-litigated here (the card's DECISIONS comment flags it).
|
||||
var copyLinkTarget: URL? {
|
||||
guard acceptsBoardMutations else { return nil }
|
||||
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
|
||||
Self.boardItem(id, in: snapshot)?.cardID != nil
|
||||
else { return nil }
|
||||
return ItemPath.resolve([id], in: .board, snapshot: snapshot).first?.folder(under: rootURL)
|
||||
}
|
||||
|
||||
/// Board ▸ Copy Link's action, and the menu-bar row's one write. The context menu's own Copy Link
|
||||
/// row (`CardFaceView.copyLink`) does not call this: a context menu names its target by where it
|
||||
/// was invoked (`targetIDs`'s standing rule, shared with Style… and Delete), not by the live
|
||||
/// selection this property reads.
|
||||
func copyCardLink(to pasteboard: FolderLinkPasteboard = SystemFolderLinkPasteboard()) {
|
||||
guard let folder = copyLinkTarget else { return }
|
||||
pasteboard.write(link: folder)
|
||||
}
|
||||
}
|
||||
|
||||
/// Board ▸ Copy Link — no default chord (11-command-nexus.md; design ruling 2026-08-09, card
|
||||
/// 737a949f). The menu-bar twin the ruling asks for ("follow the codebase's CURRENT command
|
||||
/// conventions … if the every-function-a-menu-item contract still governs, add the item … in the
|
||||
/// matching menu"): 11-command-nexus.md's contract is still in force, so this exists beside the card
|
||||
/// context menu's own row (`CardFaceView.boardMenu`) rather than instead of it.
|
||||
///
|
||||
/// One answer (`copyLinkTarget`) for both the row's `disabled` state and its action, `BoardRenameCommand`'s
|
||||
/// shape.
|
||||
struct CopyLinkCommand: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
|
||||
var body: some View {
|
||||
Button("Copy Link") {
|
||||
store?.copyCardLink()
|
||||
}
|
||||
.disabled(store?.copyLinkTarget == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rename
|
||||
|
||||
/// Board ▸ Rename — no default chord, deliberately (11-command-nexus.md: "— (cards: Return in
|
||||
|
||||
@@ -27,6 +27,11 @@ import SwiftUI
|
||||
/// so the file-hover highlight is board-only; and the trash's context-menu Delete is *permanent*, so
|
||||
/// it needs the window's confirmation host (11-command-nexus.md ▸ Context menus' Trash cards row).
|
||||
///
|
||||
/// **A fourth board-only row joined 2026-08-09**: Copy Link (design ruling, card 737a949f) — not one
|
||||
/// of the three edit-shaped absences above (it changes nothing on disk), but "sole selected **live**
|
||||
/// card" is its own words for the same board-only scope, so it lives in `boardMenu` only and has no
|
||||
/// trash-side counterpart, absence or otherwise.
|
||||
///
|
||||
/// **Only the trash side carries the confirmation host** (settled): the board side's Delete is the
|
||||
/// ordinary staged move into `.trash/` and never stands an alert, so `board` needs nothing beyond the
|
||||
/// card opener.
|
||||
@@ -596,8 +601,8 @@ struct CardFaceView: View, Equatable {
|
||||
|
||||
// MARK: - Context menus
|
||||
|
||||
/// Open, Rename, Style…, the quick-style recents row, Delete — 11-command-nexus.md ▸ Context
|
||||
/// menus' Card row, in its order.
|
||||
/// Open, Copy Link, Rename, Style…, the quick-style recents row, Delete — 11-command-nexus.md ▸
|
||||
/// Context menus' Card row, in its order.
|
||||
@ViewBuilder
|
||||
private func boardMenu(openCard: @escaping (ItemID) -> Void) -> some View {
|
||||
// Open: Board ▸ Open Card's pointer twin (`OpenCardCommand`), restricted to the clicked card
|
||||
@@ -611,6 +616,13 @@ struct CardFaceView: View, Equatable {
|
||||
openCard(card.id)
|
||||
}
|
||||
|
||||
// Copy Link — design ruling 2026-08-09, card 737a949f: "writes the card FOLDER's file:// URL
|
||||
// to the general pasteboard … enabled on a sole selected live card only; disabled on
|
||||
// multi-selections (a link is singular)". Grouped beside Open, both read-only rows, ahead of
|
||||
// the edit-shaped block below (Board ▸ Copy Link's own doc comment, `CopyLinkCommand`).
|
||||
Button("Copy Link") { copyLink() }
|
||||
.disabled(!copyLinkEnabled)
|
||||
|
||||
Divider()
|
||||
|
||||
// Rename: Board ▸ Rename's exact store path (`BoardRenameCommand`) — `beginRename(of:
|
||||
@@ -641,6 +653,8 @@ struct CardFaceView: View, Equatable {
|
||||
@ViewBuilder
|
||||
private func boardActions(openCard: @escaping (ItemID) -> Void) -> some View {
|
||||
Button("Open") { openCard(card.id) }
|
||||
Button("Copy Link") { copyLink() }
|
||||
.disabled(!copyLinkEnabled)
|
||||
Button("Rename") { beginRename() }
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
Button("Delete") { deleteTargets() }
|
||||
@@ -698,6 +712,41 @@ struct CardFaceView: View, Equatable {
|
||||
NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
|
||||
}
|
||||
|
||||
/// Copy Link's context-menu enablement — **`selectedCount`, never `targetIDs`**. `targetIDs`
|
||||
/// reads `store.selection` directly, and `.contextMenu`'s content closure is not lazy: SwiftUI
|
||||
/// evaluates `boardMenu` (and therefore any `.disabled(...)` inside it) on every ordinary body
|
||||
/// pass, not only when the menu opens, exactly as building this row against `targetIDs` first
|
||||
/// proved the hard way — every face's body re-ran on every selection change, the precise O(board)
|
||||
/// regression `isSelected`/`selectedCount` exist to prevent (this struct's own top-of-file note;
|
||||
/// `BoardRenderPerformanceTests.selectionStillRepaints`, which caught it).
|
||||
///
|
||||
/// `selectedCount` is the render-safe answer to the same question: the parent (`LaneView`) already
|
||||
/// computes "the size of the selection this face belongs to, else 1" as a **plain, non-Observable
|
||||
/// parameter** — `targetIDs.count`'s exact widening, paid for once per lane instead of once per
|
||||
/// card-menu-construction. "A link is singular" (design ruling 2026-08-09, card 737a949f), so a
|
||||
/// count above 1 disables rather than guessing which member was meant — the same reading Style…
|
||||
/// and Delete's `targetIDs` give their own action, just checked here instead of only inside it,
|
||||
/// because unlike them this row's *enabled state itself* has to say so.
|
||||
private var copyLinkEnabled: Bool {
|
||||
store.acceptsBoardMutations && selectedCount == 1
|
||||
}
|
||||
|
||||
/// Copy Link's write — `copyLinkEnabled`'s one caller. Resolves **this card's own folder**
|
||||
/// directly rather than through `targetIDs`: `copyLinkEnabled == true` already guarantees the
|
||||
/// widened target is `card.id` alone (either this card sits outside the live selection, or it is
|
||||
/// the selection's sole member), so there is no widened set left to read `store.selection` for.
|
||||
/// `store.copyLinkTarget` is deliberately not reused either — that property reads the *live*
|
||||
/// selection for the menu-bar row (`CopyLinkCommand`), which would answer wrongly for a card
|
||||
/// clicked outside the current selection, exactly the case `TrashWriteTests`'
|
||||
/// `contextMenuDeleteIgnoresTheSelection` pins for Delete.
|
||||
private func copyLink() {
|
||||
guard copyLinkEnabled,
|
||||
let folder = ItemPath.resolve([card.id], in: role.container, snapshot: store.snapshot)
|
||||
.first?.folder(under: store.rootURL)
|
||||
else { return }
|
||||
SystemFolderLinkPasteboard().write(link: folder)
|
||||
}
|
||||
|
||||
/// VO-Space's landing: the ⌘-click funnel, on this card, **in this face's container** — so a
|
||||
/// trash card's toggle can no more mix with a board selection than a ⌘-click could.
|
||||
private func toggleSelection() {
|
||||
|
||||
@@ -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