From 319c11f04b56e8acd4437730d8352a2560b7fa9a Mon Sep 17 00:00:00 2001 From: igaboo Date: Sat, 25 Apr 2026 16:46:50 -0700 Subject: [PATCH] feat: copy kanban card to clipboard with Opt+K When a Kanban block is focused, Opt+K copies just the selected card's text to the clipboard instead of the entire serialized board. Mirrors the existing block-level Opt+K convention but scopes to the focused card so users can lift a single card out of a board. Empty card / no-selection cases surface a status hint rather than silently no-op'ing or copying empty content. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 2 +- internal/editor/kanban.go | 23 +++++++++++++++++++++++ internal/editor/kanban_test.go | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 23bcc61..e11b063 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ notebook path/to/file.md - **Block editor** — 15 block types: paragraphs, headings (3 levels), bullet lists, numbered lists, checklists, code blocks, tables, quotes, definitions, callouts, dividers, embeds, and kanban boards. Press **/** to switch types. - **Tables** — Pipe-delimited GFM tables with per-column widths. Alt+R/C to add rows/columns, Alt+Shift+Backspace/Alt+Shift+D to delete. Press Enter on an empty row to exit the table and drop the row. -- **Kanban boards** — Visual boards with priority cards. Arrows navigate, Shift+arrows move cards, **n** new card, **p** cycle priority, **s** toggle auto-sort. Round-trips as a `kanban` fenced block. +- **Kanban boards** — Visual boards with priority cards. Arrows navigate, Shift+arrows move cards, **n** new card, **Opt+K** copy card, **p** cycle priority, **s** toggle auto-sort. Round-trips as a `kanban` fenced block. - **Callouts** — Five admonition variants (Note, Tip, Important, Warning, Caution). Ctrl+T to cycle. - **Definitions** — Term/definition pairs. Press **:** to search and jump to definitions. - **Embeds** — Reference other notes inline with `![[notebook/note]]`. Click in view mode to expand. diff --git a/internal/editor/kanban.go b/internal/editor/kanban.go index 936ccb0..cc6815b 100644 --- a/internal/editor/kanban.go +++ b/internal/editor/kanban.go @@ -7,6 +7,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/oobagi/notebook-cli/internal/block" + "github.com/oobagi/notebook-cli/internal/clipboard" "github.com/oobagi/notebook-cli/internal/format" "github.com/oobagi/notebook-cli/internal/theme" ) @@ -941,6 +942,28 @@ func (m *Model) handleKanbanKey(msg tea.KeyPressMsg) (handled bool, cmd tea.Cmd) m.kanban.sortByPriority() } return true, nil + case "alt+k": + // Copy selected card text to clipboard. Mirrors block-level Opt+K + // but scopes to the focused card instead of the whole board. + c := m.kanban.selectedCard() + if c == nil { + m.status = "No card to copy" + m.statusStyle = statusWarning + return true, m.scheduleStatusDismiss() + } + if c.Text == "" { + m.status = "Card is empty" + m.statusStyle = statusWarning + return true, m.scheduleStatusDismiss() + } + if err := clipboard.Copy(c.Text); err != nil { + m.status = "Could not copy: " + err.Error() + m.statusStyle = statusError + } else { + m.status = "Card copied to clipboard" + m.statusStyle = statusSuccess + } + return true, m.scheduleStatusDismiss() case "backspace", "delete": if m.kanban.selectedCard() != nil { m.pushUndo() diff --git a/internal/editor/kanban_test.go b/internal/editor/kanban_test.go index 3b65318..e7a148f 100644 --- a/internal/editor/kanban_test.go +++ b/internal/editor/kanban_test.go @@ -190,6 +190,40 @@ func TestKanbanAddCard(t *testing.T) { } } +func TestKanbanCopyCardToClipboard(t *testing.T) { + m := newKanbanEditor(t) + c := m.kanban.selectedCard() + if c == nil || c.Text == "" { + t.Fatalf("expected a non-empty card selected, got %+v", c) + } + out, cmd := m.Update(tea.KeyPressMsg{Code: 'k', Mod: tea.ModAlt}) + m = out.(Model) + if cmd == nil { + t.Errorf("expected status-dismiss cmd, got nil") + } + // Copy succeeds via OSC52 even when pbcopy/xclip aren't available, so + // the status should be the success message regardless of CI host. + if m.status != "Card copied to clipboard" { + t.Errorf("status = %q, want %q", m.status, "Card copied to clipboard") + } +} + +func TestKanbanCopyCardOnEmptyColumn(t *testing.T) { + // Move into "Doing" then delete its only card so the column is empty + // and no card is selected — alt+k should report nothing to copy. + m := newKanbanEditor(t) + m = pressKey(m, "right") + m = pressKey(m, "backspace") + if m.kanban.selectedCard() != nil { + t.Fatalf("expected no card selected after deleting last card") + } + out, _ := m.Update(tea.KeyPressMsg{Code: 'k', Mod: tea.ModAlt}) + m = out.(Model) + if m.status != "No card to copy" { + t.Errorf("status = %q, want %q", m.status, "No card to copy") + } +} + func TestKanbanDeleteCard(t *testing.T) { m := newKanbanEditor(t) before := len(m.kanban.cols[0].Cards)