Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ Pure TUI, no subcommands; the only flags are `--version` and `--help`.
samples are left exactly as written
- **Clickable card** — the repository and release links on the tool card open in the
browser by mouse click, or by hotkeys for the repo and changelog pages
- **Select and copy** — drag with the mouse in the brief card or the docs panel to
select text; releasing the button copies the plain text to the system clipboard,
with a confirmation in the status bar
- **Language stack** — the card names a repository's languages with their shares and
draws them as a proportional band in GitHub's own per-language colors
- **Tags and grouping** — one tag per tool; `space` regroups the flat list under
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/stanlyzoolo/keepkit
go 1.25.0

require (
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v1.0.0
Expand All @@ -17,7 +18,6 @@ require (

require (
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
Expand Down
12 changes: 6 additions & 6 deletions internal/model/cardlinks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ func TestMouseBriefLinkClick(t *testing.T) {
t.Fatalf("setup: links = %v, want the repo and changelog lines", links)
}
for line, url := range links {
_, cmd := m.Update(leftClick(briefX(m), line+2))
_, cmd := clickUpdate(m, briefX(m), line+2)
if cmd == nil {
t.Errorf("click on line %d (%s) dispatched no command", line, url)
}
Expand All @@ -264,7 +264,7 @@ func TestMouseBriefLinkClick(t *testing.T) {
if _, ok := links[2]; ok {
t.Fatalf("setup: line 2 unexpectedly linked")
}
updated, cmd := m.Update(leftClick(briefX(m), 2+2))
updated, cmd := clickUpdate(m, briefX(m), 2+2)
if cmd != nil {
t.Errorf("click on an unlinked line dispatched a command")
}
Expand All @@ -291,10 +291,10 @@ func TestMouseBriefLinkClick(t *testing.T) {
}
// The same screen row now shows a different content line: clicking where
// the changelog heading was before the scroll must no longer open it.
if _, cmd := m.Update(leftClick(briefX(m), logLine+2)); cmd != nil {
if _, cmd := clickUpdate(m, briefX(m), logLine+2); cmd != nil {
t.Errorf("click ignored the scroll offset and still opened a link")
}
if _, cmd := m.Update(leftClick(briefX(m), logLine-3+2)); cmd == nil {
if _, cmd := clickUpdate(m, briefX(m), logLine-3+2); cmd == nil {
t.Errorf("click at the scrolled changelog row dispatched no command")
}
})
Expand All @@ -320,7 +320,7 @@ func TestMouseBriefLinkClick(t *testing.T) {
{"below the terminal", m.height + 5},
}
for _, tt := range outside {
if _, cmd := m.Update(leftClick(briefX(m), tt.y)); cmd != nil {
if _, cmd := clickUpdate(m, briefX(m), tt.y); cmd != nil {
t.Errorf("click on the %s (y=%d) dispatched a command", tt.name, tt.y)
}
}
Expand All @@ -331,7 +331,7 @@ func TestMouseBriefLinkClick(t *testing.T) {
m.mode = modeEditNote
_, links := m.buildCard()
for line := range links {
if _, cmd := m.Update(leftClick(briefX(m), line+2)); cmd != nil {
if _, cmd := clickUpdate(m, briefX(m), line+2); cmd != nil {
t.Errorf("click on line %d opened a link while the note editor was open", line)
}
}
Expand Down
28 changes: 28 additions & 0 deletions internal/model/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import (
"os/exec"
"runtime"
"time"
"unicode/utf8"

tea "github.com/charmbracelet/bubbletea"
"github.com/atotto/clipboard"

"github.com/stanlyzoolo/keepkit/internal/launcher"
"github.com/stanlyzoolo/keepkit/internal/loader"
Expand Down Expand Up @@ -688,3 +690,29 @@ func fetchHelpCmd(name string, mode int) tea.Cmd {
return helpOutputMsg{toolName: name, mode: mode, output: cleanTerminalOutput(string(output))}
})
}

// copyDoneMsg carries the result of writing a mouse selection to the system
// clipboard. n is the number of runes copied (0 on failure); err is the
// clipboard error, nil on success.
type copyDoneMsg struct {
n int
err error
}

// writeClipboard is the seam copyCmd writes through: clipboard.WriteAll in
// production, swapped in tests so the handler can be driven without touching a
// real clipboard. The shape mirrors updater's testHomeDir / version's
// testBrewPrefix — a package-level var a test overrides, not an injected dep.
var writeClipboard = clipboard.WriteAll

// copyCmd writes text to the system clipboard off the Update thread and reports
// the outcome via copyDoneMsg. The clipboard write shells out (pbcopy / xclip /
// wl-clipboard / PowerShell) and can block, so it must not run inside Update.
func copyCmd(text string) tea.Cmd {
return safeCmd("copyCmd", func() tea.Msg {
if err := writeClipboard(text); err != nil {
return copyDoneMsg{err: err}
}
return copyDoneMsg{n: utf8.RuneCountInString(text)}
})
}
29 changes: 29 additions & 0 deletions internal/model/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -702,3 +702,32 @@ func TestTokenAcceptedRefetchesEveryRepo(t *testing.T) {
}
})
}

func TestCopyDoneMsgHandler(t *testing.T) {
prev := writeClipboard
t.Cleanup(func() { writeClipboard = prev })

base := New(nil)

t.Run("success sets the copied status", func(t *testing.T) {
writeClipboard = func(string) error { return nil }
msg := copyCmd("hello")()
done, ok := msg.(copyDoneMsg)
if !ok || done.n != 5 {
t.Fatalf("copyCmd = %#v, want copyDoneMsg{n:5}", msg)
}
updated, _ := base.Update(done)
if got := updated.(Model).statusMsg; got != "copied 5 characters" {
t.Errorf("statusMsg = %q, want %q", got, "copied 5 characters")
}
})

t.Run("failure sets the copy-failed status", func(t *testing.T) {
writeClipboard = func(string) error { return errors.New("no xclip") }
msg := copyCmd("x")()
updated, _ := base.Update(msg)
if got := updated.(Model).statusMsg; got != "copy failed: no xclip" {
t.Errorf("statusMsg = %q, want %q", got, "copy failed: no xclip")
}
})
}
24 changes: 24 additions & 0 deletions internal/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,19 @@ type Model struct {
updateLogFor string
updateOutcome updateOutcome

// Mouse-drag selection state for panels [2]/[3]. selActive runs from the
// left-button press to the release; selPanel names the panel being selected
// in (focusBrief or focusHelp). selAnchor/selCursor are content coordinates
// (see selPos); selStyled/selPlain are that panel's content lines — styled
// and ANSI-stripped — snapshotted at press time, so a motion event paints
// without re-rendering the card/README.
selActive bool
selPanel int
selAnchor selPos
selCursor selPos
selStyled []string
selPlain []string

// appVersion is the version of the running binary (ldflag or buildinfo,
// injected by WithAppVersion) and the gate for the whole self-update
// feature: empty or "dev" means no self-check request and no banner. It is
Expand Down Expand Up @@ -1214,6 +1227,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil

case copyDoneMsg:
if msg.err != nil {
return m, m.setStatus("copy failed: " + msg.err.Error())
}
return m, m.setStatus("copied " + strconv.Itoa(msg.n) + " characters")

case launchDoneMsg:
// Tab-open adapter finished. Clear the one-launch-at-a-time guard
// first, so the fallback (or the user's retry) can dispatch again.
Expand Down Expand Up @@ -1428,6 +1447,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

case tea.KeyMsg:
m.statusMsg = ""
// Any keystroke abandons an in-flight mouse drag: a release is not
// always delivered (the button can be let go over another window), and a
// key must never act on content still wearing the selection highlight.
// clearSelection is a no-op when no drag is active.
m.clearSelection()

// Every modal return funnels through flushPendingLaunch: the keystroke
// that brings the mode back to modeNormal is the single point where a
Expand Down
11 changes: 11 additions & 0 deletions internal/model/mouse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ func leftClick(x, y int) tea.MouseMsg {
return tea.MouseMsg{X: x, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}
}

func leftRelease(x, y int) tea.MouseMsg {
return tea.MouseMsg{X: x, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionRelease}
}

// clickUpdate sends a press then a release at the same cell — the events a pure
// click (no drag) produces — and returns the model and command after the release.
func clickUpdate(m Model, x, y int) (tea.Model, tea.Cmd) {
nm, _ := m.Update(leftClick(x, y))
return nm.Update(leftRelease(x, y))
}

func wheelDown(x, y int) tea.MouseMsg {
return tea.MouseMsg{X: x, Y: y, Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress}
}
Expand Down
Loading
Loading