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
58 lines
2.5 KiB
Swift
58 lines
2.5 KiB
Swift
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])
|
|
}
|
|
}
|