Skip to content

Commit c569b46

Browse files
committed
fix(ui): return clipboard paste to command input and keep log scroll
Address review feedback on #202: - Ctrl+V in command mode never inserted text because bubbles returns the clipboard read as an unexported message type. Add clipboard.Paste, which reads the clipboard and re-enters as a public tea.PasteMsg, and intercept ctrl+v in CommandInput before delegating to the textinput. - Mouse-wheel scrolling stopped working in LogView while the filter was active; mouse messages now bypass the filter fallback to the viewport.
1 parent 5e97066 commit c569b46

6 files changed

Lines changed: 126 additions & 1 deletion

File tree

internal/app/app_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,35 @@ func TestCommandModeActivation(t *testing.T) {
526526
}
527527
}
528528

529+
func TestCommandModePaste(t *testing.T) {
530+
app := newTestApp(t)
531+
app.currentView = &MockView{name: "Dashboard"}
532+
533+
app.Update(tea.KeyPressMsg{Code: 0, Text: ":"})
534+
if !app.commandMode {
535+
t.Fatal("Expected commandMode=true after ':' key")
536+
}
537+
538+
// Ctrl+V is intercepted by the command input, which replies with the
539+
// clipboard-read command instead of bubbles' unexported paste message.
540+
_, cmd := app.Update(tea.KeyPressMsg{Code: 'v', Mod: tea.ModCtrl})
541+
if cmd == nil {
542+
t.Fatal("Expected ctrl+v in command mode to return the clipboard-read command")
543+
}
544+
545+
// On success that command yields a tea.PasteMsg with the clipboard
546+
// content (covered by internal/clipboard tests); feed the result back
547+
// through App.Update and verify it reaches the command input.
548+
app.Update(tea.PasteMsg{Content: "ec2/instances"})
549+
550+
if got := app.commandInput.Value(); got != "ec2/instances" {
551+
t.Errorf("Command input value = %q, want %q", got, "ec2/instances")
552+
}
553+
if !app.commandMode {
554+
t.Error("Expected command mode to stay active after paste")
555+
}
556+
}
557+
529558
func TestUnhandledKeyDelegatesToCurrentView(t *testing.T) {
530559
app := newTestApp(t)
531560
dashboard := &MockView{name: "Dashboard"}

internal/clipboard/clipboard.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,26 @@ type CopyFailedMsg struct {
3535
var (
3636
terminalClipboardWriter io.StringWriter = os.Stdout
3737
nativeClipboardWrite = clipboard.WriteAll
38+
nativeClipboardRead = clipboard.ReadAll
3839
)
3940

41+
// Paste reads the system clipboard and delivers its content as a tea.PasteMsg,
42+
// the same message bracketed paste produces, so it can be routed to any focused
43+
// text input. Bubbles' built-in ctrl+v returns the clipboard read as an
44+
// unexported message type that message routing outside the input's own view
45+
// cannot forward; this command exists so callers get a public type instead.
46+
// The returned command is a no-op when the clipboard is unavailable.
47+
func Paste() tea.Cmd {
48+
return func() tea.Msg {
49+
value, err := nativeClipboardRead()
50+
if err != nil {
51+
log.Debug("native clipboard read failed", "error", err)
52+
return nil
53+
}
54+
return tea.PasteMsg{Content: value}
55+
}
56+
}
57+
4058
// Copy copies the given value to the clipboard and returns a tea.Cmd that sends a CopiedMsg.
4159
// It writes to both OSC52 (terminal clipboard) and native system clipboard for maximum compatibility.
4260
func Copy(label, value string) tea.Cmd {

internal/clipboard/clipboard_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,19 @@ import (
44
"errors"
55
"strings"
66
"testing"
7+
8+
tea "charm.land/bubbletea/v2"
79
)
810

11+
func withClipboardReader(t *testing.T, nativeReader func() (string, error)) {
12+
t.Helper()
13+
originalNativeRead := nativeClipboardRead
14+
nativeClipboardRead = nativeReader
15+
t.Cleanup(func() {
16+
nativeClipboardRead = originalNativeRead
17+
})
18+
}
19+
920
type failingStringWriter struct{}
1021

1122
func (failingStringWriter) WriteString(string) (int, error) {
@@ -130,6 +141,33 @@ func TestCopyARN(t *testing.T) {
130141
}
131142
}
132143

144+
func TestPasteReturnsPasteMsg(t *testing.T) {
145+
withClipboardReader(t, func() (string, error) { return "i-1234567890abcdef0", nil })
146+
147+
cmd := Paste()
148+
if cmd == nil {
149+
t.Fatal("Paste should return a non-nil command")
150+
}
151+
152+
msg := cmd()
153+
pasteMsg, ok := msg.(tea.PasteMsg)
154+
if !ok {
155+
t.Fatalf("expected tea.PasteMsg, got %T", msg)
156+
}
157+
if pasteMsg.Content != "i-1234567890abcdef0" {
158+
t.Errorf("expected Content 'i-1234567890abcdef0', got %q", pasteMsg.Content)
159+
}
160+
}
161+
162+
func TestPasteReturnsNilWhenClipboardUnavailable(t *testing.T) {
163+
withClipboardReader(t, func() (string, error) { return "", errors.New("native clipboard unavailable") })
164+
165+
msg := Paste()()
166+
if msg != nil {
167+
t.Fatalf("expected nil msg when clipboard read fails, got %T", msg)
168+
}
169+
}
170+
133171
func TestNoARN(t *testing.T) {
134172
cmd := NoARN()
135173
if cmd == nil {

internal/view/command_input.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"charm.land/lipgloss/v2"
1212

1313
"github.com/clawscli/claws/internal/action"
14+
"github.com/clawscli/claws/internal/clipboard"
1415
"github.com/clawscli/claws/internal/config"
1516
navmsg "github.com/clawscli/claws/internal/msg"
1617
"github.com/clawscli/claws/internal/registry"
@@ -117,6 +118,11 @@ func (c *CommandInput) IsActive() bool {
117118
return c.active
118119
}
119120

121+
// Value returns the current input text.
122+
func (c *CommandInput) Value() string {
123+
return c.textInput.Value()
124+
}
125+
120126
// Update handles input updates
121127
func (c *CommandInput) Update(msg tea.Msg) (tea.Cmd, *NavigateMsg) {
122128
switch msg := msg.(type) {
@@ -126,6 +132,13 @@ func (c *CommandInput) Update(msg tea.Msg) (tea.Cmd, *NavigateMsg) {
126132
c.Deactivate()
127133
return nil, nil
128134

135+
case "ctrl+v":
136+
// Intercept before textInput.Update: its built-in paste replies
137+
// with an unexported message type that command-mode routing in
138+
// the app cannot forward back here. clipboard.Paste re-enters
139+
// as a tea.PasteMsg, which routes to this input like any key.
140+
return clipboard.Paste(), nil
141+
129142
case "enter":
130143
cmd, nav := c.executeCommand()
131144
c.Deactivate()

internal/view/log_view.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,9 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
383383
}
384384

385385
// Route paste and other textinput-bound messages to the active filter.
386-
if v.filterActive {
386+
// Mouse events skip the filter so the viewport keeps scrolling while
387+
// the filter is open.
388+
if _, isMouse := msg.(tea.MouseMsg); v.filterActive && !isMouse {
387389
return v.updateFilterInput(msg)
388390
}
389391

internal/view/log_view_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,31 @@ func TestLogViewFilterStatusLine(t *testing.T) {
507507
}
508508
}
509509

510+
func TestLogViewMouseWheelScrollsWhileFiltering(t *testing.T) {
511+
ctx := context.Background()
512+
lv := NewLogView(ctx, "/aws/test")
513+
lv.SetSize(80, 10)
514+
lv.loading = false
515+
for i := range 100 {
516+
lv.logs = append(lv.logs, logEntry{timestamp: time.Now(), message: fmt.Sprintf("log line %d", i)})
517+
}
518+
lv.updateViewportContent()
519+
520+
lv.filterActive = true
521+
lv.filterInput.Focus()
522+
523+
before := lv.vp.Model.YOffset()
524+
lv.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown})
525+
after := lv.vp.Model.YOffset()
526+
527+
if after <= before {
528+
t.Errorf("Expected viewport to scroll down while filter is active, YOffset %d -> %d", before, after)
529+
}
530+
if lv.filterInput.Value() != "" {
531+
t.Errorf("Expected filter input unchanged after mouse wheel, got %q", lv.filterInput.Value())
532+
}
533+
}
534+
510535
func TestLogViewFilterUnicode(t *testing.T) {
511536
ctx := context.Background()
512537
lv := NewLogView(ctx, "/aws/test")

0 commit comments

Comments
 (0)