v0.29.0 21b3150

clipboard_test.go RawHistoryFolder

1package clipboard
2
3import (
4	"context"
5	"errors"
6	"os"
7	"path/filepath"
8	"strings"
9	"testing"
10
11	"github.com/bnema/dumber/internal/domain/entity"
12	"github.com/stretchr/testify/require"
13)
14
15type fakeToolkitClipboard struct {
16	writeTextCalls  int
17	readTextCalls   int
18	writeImageCalls int
19	text            string
20	image           entity.ImageData
21	writeTextErr    error
22	readTextErr     error
23	writeImageErr   error
24}
25
26func (f *fakeToolkitClipboard) WriteText(_ context.Context, text string) error {
27	f.writeTextCalls++
28	f.text = text
29	return f.writeTextErr
30}
31
32func (f *fakeToolkitClipboard) ReadText(_ context.Context) (string, error) {
33	f.readTextCalls++
34	return f.text, f.readTextErr
35}
36
37func (f *fakeToolkitClipboard) WriteImage(_ context.Context, image entity.ImageData) error {
38	f.writeImageCalls++
39	f.image = image
40	return f.writeImageErr
41}
42
43func TestAdapter_ReadTextFallsBackToToolkitClipboardWhenNoSystemTool(t *testing.T) {
44	fake := &fakeToolkitClipboard{text: "toolkit text"}
45	adapter := &Adapter{toolkit: fake}
46
47	text, err := adapter.ReadText(context.Background())
48
49	require.NoError(t, err)
50	require.Equal(t, "toolkit text", text)
51	require.Equal(t, 1, fake.readTextCalls)
52}
53
54func TestAdapter_HasTextReturnsTrueWhenToolkitFallbackHasText(t *testing.T) {
55	fake := &fakeToolkitClipboard{text: "toolkit text"}
56	adapter := &Adapter{toolkit: fake}
57
58	hasText, err := adapter.HasText(context.Background())
59
60	require.NoError(t, err)
61	require.True(t, hasText)
62	require.Equal(t, 1, fake.readTextCalls)
63}
64
65func TestAdapter_WriteTextReturnsToolkitErrorWhenNoSystemTool(t *testing.T) {
66	sentinel := errors.New("toolkit text failed")
67	fake := &fakeToolkitClipboard{writeTextErr: sentinel}
68	adapter := &Adapter{toolkit: fake}
69
70	err := adapter.WriteText(context.Background(), "hello")
71
72	require.Equal(t, sentinel, err)
73	require.Equal(t, 1, fake.writeTextCalls)
74	require.Equal(t, "hello", fake.text)
75}
76
77func TestAdapter_WriteImageReturnsToolkitErrorWhenNoSystemTool(t *testing.T) {
78	sentinel := errors.New("toolkit image failed")
79	fake := &fakeToolkitClipboard{writeImageErr: sentinel}
80	adapter := &Adapter{toolkit: fake}
81
82	err := adapter.WriteImage(context.Background(), entity.ImageData{Bytes: []byte{1}, MimeType: "image/png"})
83
84	require.Equal(t, sentinel, err)
85	require.Equal(t, 1, fake.writeImageCalls)
86	require.Equal(t, entity.ImageData{Bytes: []byte{1}, MimeType: "image/png"}, fake.image)
87}
88
89func TestNew_FallsBackToToolkitClipboardWhenSystemToolsUnavailable(t *testing.T) {
90	oldToolkitFactory := newToolkitClipboard
91	oldLookPath := lookPath
92	t.Cleanup(func() {
93		newToolkitClipboard = oldToolkitFactory
94		lookPath = oldLookPath
95	})
96
97	fake := &fakeToolkitClipboard{}
98	newToolkitClipboard = func() toolkitClipboard {
99		return fake
100	}
101	lookPath = func(_ string) (string, error) {
102		return "", errors.New("missing")
103	}
104
105	t.Setenv("WAYLAND_DISPLAY", "wayland-0")
106	t.Setenv("DISPLAY", ":0")
107
108	adapter := New().(*Adapter)
109	require.NoError(t, adapter.WriteImage(context.Background(), entity.ImageData{Bytes: []byte{1, 2, 3}}))
110	require.Equal(t, 1, fake.writeImageCalls)
111	require.Equal(t, []byte{1, 2, 3}, fake.image.Bytes)
112}
113
114func TestAdapter_WriteImagePrefersToolkitClipboardWhenAvailable(t *testing.T) {
115	oldToolkitFactory := newToolkitClipboard
116	t.Cleanup(func() {
117		newToolkitClipboard = oldToolkitFactory
118	})
119
120	dir := t.TempDir()
121	scriptPath := filepath.Join(dir, "wl-copy")
122	stdinPath := filepath.Join(dir, "stdin.bin")
123	script := "#!/bin/sh\ncat > \"" + stdinPath + "\"\n"
124	require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755))
125
126	fake := &fakeToolkitClipboard{}
127	newToolkitClipboard = func() toolkitClipboard { return fake }
128	adapter := &Adapter{copyCmd: scriptPath}
129	image := entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}
130
131	require.NoError(t, adapter.WriteImage(context.Background(), image))
132	require.Equal(t, 1, fake.writeImageCalls)
133	require.Equal(t, image, fake.image)
134
135	_, err := os.Stat(stdinPath)
136	require.Error(t, err)
137	require.True(t, os.IsNotExist(err))
138}
139
140func TestAdapter_WriteImageFallsBackToSystemClipboardWhenToolkitUnavailable(t *testing.T) {
141	dir := t.TempDir()
142	scriptPath := filepath.Join(dir, "wl-copy")
143	stdinPath := filepath.Join(dir, "stdin.bin")
144	script := "#!/bin/sh\ncat > \"" + stdinPath + "\"\n"
145	require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755))
146
147	adapter := &Adapter{copyCmd: scriptPath}
148	image := entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}
149
150	require.NoError(t, adapter.WriteImage(context.Background(), image))
151
152	stdin, err := os.ReadFile(stdinPath)
153	require.NoError(t, err)
154	require.Equal(t, image.Bytes, stdin)
155}
156
157func TestNew_FallsBackWaylandBeforeX11(t *testing.T) {
158	oldToolkitFactory := newToolkitClipboard
159	oldLookPath := lookPath
160	t.Cleanup(func() {
161		newToolkitClipboard = oldToolkitFactory
162		lookPath = oldLookPath
163	})
164
165	newToolkitClipboard = func() toolkitClipboard { return nil }
166	lookups := make([]string, 0, 2)
167	lookPath = func(name string) (string, error) {
168		lookups = append(lookups, name)
169		switch name {
170		case "wl-copy":
171			return "", errors.New("wl-copy missing")
172		case "xclip":
173			return "/usr/bin/xclip", nil
174		default:
175			return "", errors.New("unexpected lookPath call")
176		}
177	}
178
179	t.Setenv("WAYLAND_DISPLAY", "wayland-0")
180	t.Setenv("DISPLAY", ":0")
181
182	adapter := New().(*Adapter)
183	require.Equal(t, "/usr/bin/xclip", adapter.copyCmd)
184	require.Equal(t, "/usr/bin/xclip", adapter.pasteCmd)
185	require.Equal(t, []string{"wl-copy", "xclip"}, lookups)
186}
187
188func TestAdapter_WriteImageRejectsEmptyBytes(t *testing.T) {
189	oldToolkitFactory := newToolkitClipboard
190	oldLookPath := lookPath
191	t.Cleanup(func() {
192		newToolkitClipboard = oldToolkitFactory
193		lookPath = oldLookPath
194	})
195
196	fake := &fakeToolkitClipboard{}
197	newToolkitClipboard = func() toolkitClipboard { return fake }
198	lookPath = func(_ string) (string, error) { return "/usr/bin/clipboard", nil }
199
200	err := New().(*Adapter).WriteImage(context.Background(), entity.ImageData{})
201	require.Error(t, err)
202	require.Contains(t, err.Error(), "empty image data")
203	require.Zero(t, fake.writeImageCalls)
204}
205
206func TestAdapter_WriteImageWithCommandUsesImageMimeType(t *testing.T) {
207	testCases := []struct {
208		name     string
209		image    entity.ImageData
210		expected string
211	}{
212		{
213			name:     "uses provided MIME type",
214			image:    entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/jpeg"},
215			expected: "image/jpeg",
216		},
217		{
218			name:     "falls back to PNG",
219			image:    entity.ImageData{Bytes: []byte{1, 2, 3}},
220			expected: "image/png",
221		},
222	}
223
224	for _, tt := range testCases {
225		t.Run(tt.name, func(t *testing.T) {
226			dir := t.TempDir()
227			scriptPath := filepath.Join(dir, "wl-copy")
228			argsPath := filepath.Join(dir, "args.txt")
229			stdinPath := filepath.Join(dir, "stdin.bin")
230			script := "#!/bin/sh\nprintf '%s\n' \"$@\" > \"" + argsPath + "\"\ncat > \"" + stdinPath + "\"\n"
231			require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755))
232
233			adapter := &Adapter{copyCmd: scriptPath}
234			require.NoError(t, adapter.WriteImage(context.Background(), tt.image))
235
236			args, err := os.ReadFile(argsPath)
237			require.NoError(t, err)
238			require.Equal(t, []string{"--type", tt.expected}, strings.Split(strings.TrimSpace(string(args)), "\n"))
239
240			stdin, err := os.ReadFile(stdinPath)
241			require.NoError(t, err)
242			require.Equal(t, tt.image.Bytes, stdin)
243		})
244	}
245}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close