v0.3.0 99dc266

clipboard_intercept_internal_test.go RawHistoryFolder

1package client
2
3import (
4	"context"
5	"errors"
6	"log/slog"
7	"testing"
8
9	"github.com/stretchr/testify/require"
10
11	"github.com/bnema/vev/internal/ports"
12)
13
14// ciFakeReader is a directly-controllable ports.ClipboardReader for
15// white-box interceptor tests (this file lives in package client, alongside
16// paste_coalescer_test.go, so it can drive pasteCoalescer.Scan directly).
17type ciFakeReader struct {
18	mime string
19	data []byte
20	err  error
21	n    int
22}
23
24func (r *ciFakeReader) ReadImage(context.Context) (string, []byte, error) {
25	r.n++
26	return r.mime, r.data, r.err
27}
28
29func newTestClipboardIntercept(reader ports.ClipboardReader) (*clipboardIntercept, *pasteCoalescer, *pcFakeClock, *pcCollector, *[]ports.ImagePush) {
30	clk := &pcFakeClock{}
31	col := &pcCollector{}
32	pc := newPasteCoalescer(clk, col.emit)
33	var images []ports.ImagePush
34	ci := &clipboardIntercept{
35		coalescer: pc,
36		reader:    reader,
37		log:       slog.New(slog.NewTextHandler(nil, &slog.HandlerOptions{Level: slog.LevelError + 1})),
38		sendImage: func(mime string, data []byte) {
39			images = append(images, ports.ImagePush{Mime: mime, Data: data})
40		},
41		next: pc.Scan,
42	}
43	return ci, pc, clk, col, &images
44}
45
46func TestClipboardInterceptCtrlVWithImageSendsNoPassthrough(t *testing.T) {
47	reader := &ciFakeReader{mime: "image/png", data: []byte("PNGDATA")}
48	ci, pc, _, col, images := newTestClipboardIntercept(reader)
49	defer pc.Close()
50
51	ci.Scan([]byte("a"))
52	ci.Scan([]byte{ctrlV})
53	ci.Scan([]byte("b"))
54
55	require.Equal(t, 1, reader.n, "clipboard must be read exactly once per Ctrl+V")
56	require.Equal(t, []ports.ImagePush{{Mime: "image/png", Data: []byte("PNGDATA")}}, *images)
57	require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, col.snapshot(), "0x16 itself must never reach the coalescer/pane")
58}
59
60func TestClipboardInterceptCtrlVNoImageForwardsByte(t *testing.T) {
61	reader := &ciFakeReader{err: ports.ErrNoClipboardImage}
62	ci, pc, _, col, images := newTestClipboardIntercept(reader)
63	defer pc.Close()
64
65	ci.Scan([]byte("a\x16b"))
66
67	require.Empty(t, *images)
68	require.Equal(t, [][]byte{[]byte("a"), {ctrlV}, []byte("b")}, col.snapshot())
69}
70
71func TestClipboardInterceptCtrlVOtherErrorForwardsByte(t *testing.T) {
72	reader := &ciFakeReader{err: errors.New("boom")}
73	ci, pc, _, col, images := newTestClipboardIntercept(reader)
74	defer pc.Close()
75
76	ci.Scan([]byte{ctrlV})
77
78	require.Empty(t, *images)
79	require.Equal(t, [][]byte{{ctrlV}}, col.snapshot())
80}
81
82func TestClipboardInterceptFailureNotifiesDaemonAndForwardsCtrlV(t *testing.T) {
83	tests := []struct {
84		name   string
85		reader ports.ClipboardReader
86		want   uint8
87	}{
88		{name: "image read failure", reader: &ciFakeReader{err: errors.New("boom")}, want: ports.ClientNoticeClipboardFallback},
89		{name: "oversized image", reader: &ciFakeReader{mime: "image/png", data: make([]byte, maxClipboardImagePush+1)}, want: ports.ClientNoticeClipboardTooLarge},
90	}
91	for _, tt := range tests {
92		t.Run(tt.name, func(t *testing.T) {
93			ci, pc, _, col, images := newTestClipboardIntercept(tt.reader)
94			defer pc.Close()
95			var actions []uint8
96			ci.sendNotice = func(action uint8) { actions = append(actions, action) }
97
98			ci.Scan([]byte{ctrlV})
99
100			require.Equal(t, []uint8{tt.want}, actions)
101			require.Empty(t, *images)
102			require.Equal(t, [][]byte{{ctrlV}}, col.snapshot())
103		})
104	}
105}
106
107func TestClipboardInterceptOversizedImageForwardsByteInsteadOfSwallowing(t *testing.T) {
108	huge := make([]byte, maxClipboardImagePush+1)
109	reader := &ciFakeReader{mime: "image/png", data: huge}
110	ci, pc, _, col, images := newTestClipboardIntercept(reader)
111	defer pc.Close()
112
113	ci.Scan([]byte{ctrlV})
114
115	require.Empty(t, *images, "an oversized image must not be sent")
116	require.Equal(t, [][]byte{{ctrlV}}, col.snapshot(), "an oversized image must fall back to forwarding Ctrl+V, not drop it")
117}
118
119func TestClipboardInterceptMultipleCtrlVInOneChunk(t *testing.T) {
120	reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
121	ci, pc, _, col, images := newTestClipboardIntercept(reader)
122	defer pc.Close()
123
124	ci.Scan([]byte("a\x16b\x16c"))
125
126	require.Equal(t, 2, reader.n)
127	require.Len(t, *images, 2)
128	require.Equal(t, [][]byte{[]byte("a"), []byte("b"), []byte("c")}, col.snapshot())
129}
130
131func TestClipboardInterceptCtrlVInsideBracketedPasteSameChunkNotIntercepted(t *testing.T) {
132	reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
133	ci, pc, _, col, images := newTestClipboardIntercept(reader)
134	defer pc.Close()
135
136	paste := []byte("\x1b[200~pa\x16ste\x1b[201~")
137	ci.Scan(paste)
138
139	require.Zero(t, reader.n, "clipboard must never be consulted for a Ctrl+V inside a paste")
140	require.Empty(t, *images)
141	require.Equal(t, [][]byte{paste}, col.snapshot(), "the paste must reach the coalescer whole, byte-identical")
142}
143
144func TestClipboardInterceptCtrlVInsideBracketedPasteAcrossChunksNotIntercepted(t *testing.T) {
145	reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
146	ci, pc, _, col, images := newTestClipboardIntercept(reader)
147	defer pc.Close()
148
149	paste := []byte("\x1b[200~pa\x16ste\x1b[201~")
150	for s := 1; s < len(paste); s++ {
151		t.Run("", func(t *testing.T) {
152			reader.n = 0
153			*images = nil
154			col.emits = nil
155			ci.Scan(paste[:s])
156			ci.Scan(paste[s:])
157			require.Zero(t, reader.n, "split at %d: clipboard must never be consulted for a Ctrl+V inside a paste", s)
158			require.Empty(t, *images)
159			require.Equal(t, [][]byte{paste}, col.snapshot(), "split at %d: the paste must reach the coalescer whole", s)
160		})
161	}
162}
163
164func TestClipboardInterceptCtrlVAfterLoneEscapePendingNotIntercepted(t *testing.T) {
165	// A trailing lone ESC (a strict prefix of the open marker) is held by
166	// the coalescer as "pending" without yet flipping Buffering() via the
167	// old (buffering-only) definition; Buffering() must also cover pending
168	// so a Ctrl+V arriving before the flush timer fires is never
169	// misinterpreted mid-marker.
170	reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
171	ci, pc, _, col, images := newTestClipboardIntercept(reader)
172	defer pc.Close()
173
174	ci.Scan([]byte{0x1b}) // held as pending, awaiting more marker bytes
175	require.True(t, pc.Buffering(), "a pending trailing marker-prefix must count as Buffering")
176
177	ci.Scan([]byte{ctrlV})
178
179	require.Zero(t, reader.n, "clipboard must not be consulted while a marker prefix is pending")
180	require.Empty(t, *images)
181	// The ESC-then-Ctrl+V recombine as ordinary bytes handed whole to the
182	// coalescer, which does not find a marker in "\x1bfamiliar" and emits
183	// it as plain text once no further marker bytes follow.
184	require.Equal(t, [][]byte{{0x1b, ctrlV}}, col.snapshot())
185}

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 12

Tags 11

↑↓ moveenter openesc close