diff --git a/DESIGN/11-command-nexus.md b/DESIGN/11-command-nexus.md index 28ae25f..22ee206 100644 --- a/DESIGN/11-command-nexus.md +++ b/DESIGN/11-command-nexus.md @@ -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 | diff --git a/Kanban/App/FolderLinkPasteboard.swift b/Kanban/App/FolderLinkPasteboard.swift new file mode 100644 index 0000000..efa39ba --- /dev/null +++ b/Kanban/App/FolderLinkPasteboard.swift @@ -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]) + } +} diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index 3968191..d4f86fa 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -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() diff --git a/Kanban/UI/Board/BoardCommands.swift b/Kanban/UI/Board/BoardCommands.swift index 5982d9b..c86aa03 100644 --- a/Kanban/UI/Board/BoardCommands.swift +++ b/Kanban/UI/Board/BoardCommands.swift @@ -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 diff --git a/Kanban/UI/Board/CardFaceView.swift b/Kanban/UI/Board/CardFaceView.swift index 7abe00a..7c31962 100644 --- a/Kanban/UI/Board/CardFaceView.swift +++ b/Kanban/UI/Board/CardFaceView.swift @@ -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() { diff --git a/KanbanTests/CopyLinkTests.swift b/KanbanTests/CopyLinkTests.swift new file mode 100644 index 0000000..e2acc3d --- /dev/null +++ b/KanbanTests/CopyLinkTests.swift @@ -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) + } +}