copymode_mouse_test.go RawHistoryFolder

1package daemon
2
3import (
4	"strconv"
5	"testing"
6	"time"
7
8	"github.com/stretchr/testify/require"
9
10	renderer "github.com/bnema/vev-vt"
11	"github.com/bnema/vev/internal/domain"
12	"github.com/bnema/vev/internal/ports"
13	scopy "github.com/bnema/vev/internal/usecase/copy"
14)
15
16type manualMouseClock struct{ now time.Time }
17
18func (c *manualMouseClock) Now() time.Time                   { return c.now }
19func (c *manualMouseClock) Advance(d time.Duration)          { c.now = c.now.Add(d) }
20func (*manualMouseClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
21
22func mouseCopyHarness(t *testing.T, rows ...string) (*Daemon, *session, *attachedClient, *manualMouseClock) {
23	t.Helper()
24	p, release := newBlockingPTY(t)
25	t.Cleanup(release)
26	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
27	clock := &manualMouseClock{now: time.Unix(1, 0)}
28	d.clock = clock
29	pane := testAttachmentTab(sess).focusedPane()
30	for i, row := range rows {
31		writeTestRow(pane.screen, i, row)
32	}
33	d.enterCopyMode(sess, ac)
34	return d, sess, ac, clock
35}
36
37func copyMouseInput(d *Daemon, sess *session, ac *attachedClient, raw string) {
38	d.handleInput(sess, ac, []byte(raw))
39}
40
41func TestCopyDoubleClick(t *testing.T) {
42	t.Run("same normalized position within interval selects word", func(t *testing.T) {
43		d, sess, ac, clock := mouseCopyHarness(t, "a界 beta")
44		pane := testAttachmentTab(sess).focusedPane()
45		d.exitCopyMode(ac)
46		pane.mu.Lock()
47		require.True(t, pane.screen.Cell(2, 0).Continuation)
48		pane.mu.Unlock()
49		d.enterCopyMode(sess, ac)
50
51		// SGR column 3 is the continuation cell of the wide glyph at column 2.
52		copyMouseInput(d, sess, ac, "\x1b[<0;3;2M\x1b[<0;3;2m")
53		clock.Advance(copyDoubleClickInterval)
54		copyMouseInput(d, sess, ac, "\x1b[<0;3;2M")
55
56		ac.overlays.copyMu.Lock()
57		selection := ac.overlays.copyMode.Selection()
58		pointer := ac.overlays.copyPointer
59		ac.overlays.copyMu.Unlock()
60		require.True(t, selection.Enabled)
61		require.Equal(t, scopy.Word, selection.Granularity)
62		require.Equal(t, scopy.Pos{Row: 0, Col: 1}, pointer.press)
63	})
64
65	for _, tc := range []struct {
66		name    string
67		advance time.Duration
68		second  string
69	}{
70		{name: "expired", advance: copyDoubleClickInterval + time.Nanosecond, second: "\x1b[<0;1;2M"},
71		{name: "different position", second: "\x1b[<0;2;2M"},
72	} {
73		t.Run(tc.name+" stays passive", func(t *testing.T) {
74			d, sess, ac, clock := mouseCopyHarness(t, "alpha")
75			copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")
76			clock.Advance(tc.advance)
77			copyMouseInput(d, sess, ac, tc.second)
78			ac.overlays.copyMu.Lock()
79			selection := ac.overlays.copyMode.Selection()
80			ac.overlays.copyMu.Unlock()
81			require.False(t, selection.Enabled)
82		})
83	}
84
85	t.Run("different pane is not a double click", func(t *testing.T) {
86		d := newTestDaemon(t, nil, &manualMouseClock{now: time.Unix(1, 0)})
87		rt := newOverlayRuntime(&attachedClient{})
88		p1 := newPane("one", nil, domain.Size{Cols: 4, Rows: 1})
89		p2 := newPane("two", nil, domain.Size{Cols: 4, Rows: 1})
90		rt.copyClick = copyClickCandidate{valid: true, pane: p1, pos: scopy.Pos{Row: 0, Col: 1}, at: d.clock.Now()}
91		require.False(t, d.isCopyDoubleClickLocked(rt, p2, scopy.Pos{Row: 0, Col: 1}, d.clock.Now()))
92	})
93
94	for _, text := range []string{"alpha beta", "alpha/beta"} {
95		t.Run("separator is passive "+text, func(t *testing.T) {
96			d, sess, ac, clock := mouseCopyHarness(t, text)
97			if text == "alpha/beta" {
98				cfg := domain.Defaults()
99				cfg.Copy.WordSeparators = "/"
100				d.ApplyConfig(cfg)
101				d.exitCopyMode(ac)
102				d.enterCopyMode(sess, ac)
103			}
104			copyMouseInput(d, sess, ac, "\x1b[<0;6;2M\x1b[<0;6;2m")
105			clock.Advance(time.Millisecond)
106			copyMouseInput(d, sess, ac, "\x1b[<0;6;2M")
107			ac.overlays.copyMu.Lock()
108			selection := ac.overlays.copyMode.Selection()
109			ac.overlays.copyMu.Unlock()
110			require.False(t, selection.Enabled)
111		})
112	}
113
114	t.Run("normal screen double click publishes word drag", func(t *testing.T) {
115		d, sess, ac, clock := mouseCopyHarness(t, "alpha beta")
116		d.exitCopyMode(ac)
117		copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")
118		clock.Advance(time.Millisecond)
119		copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;7;2M")
120		ac.overlays.copyMu.Lock()
121		selection := ac.overlays.copyMode.Selection()
122		text := ac.overlays.copyMode.SelectedText()
123		pointer := ac.overlays.copyPointer
124		ac.overlays.copyMu.Unlock()
125		require.Equal(t, scopy.Word, selection.Granularity)
126		require.True(t, pointer.wordDrag)
127		require.Equal(t, "alpha beta", text)
128	})
129
130	t.Run("first click drag invalidates candidate", func(t *testing.T) {
131		d, sess, ac, clock := mouseCopyHarness(t, "alpha")
132		copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;2;2M\x1b[<0;2;2m")
133		clock.Advance(time.Millisecond)
134		copyMouseInput(d, sess, ac, "\x1b[<0;1;2M")
135		ac.overlays.copyMu.Lock()
136		selection := ac.overlays.copyMode.Selection()
137		ac.overlays.copyMu.Unlock()
138		require.NotEqual(t, scopy.Word, selection.Granularity)
139	})
140}
141
142func TestCopyWordDragKeepsWordAnchorOnFirstMotion(t *testing.T) {
143	for _, tc := range []struct {
144		name                   string
145		pressCol, motionCol    int
146		wantAnchor, wantActive scopy.Pos
147	}{
148		{
149			name:       "forward from inside first word",
150			pressCol:   3,
151			motionCol:  8,
152			wantAnchor: scopy.Pos{Row: 0},
153			wantActive: scopy.Pos{Row: 0, Col: 9},
154		},
155		{
156			name:       "reverse from inside second word",
157			pressCol:   8,
158			motionCol:  3,
159			wantAnchor: scopy.Pos{Row: 0, Col: 6},
160			wantActive: scopy.Pos{Row: 0, Col: 4},
161		},
162	} {
163		t.Run(tc.name, func(t *testing.T) {
164			d, sess, ac, clock := mouseCopyHarness(t, "alpha beta")
165			copyMouseInput(d, sess, ac, "\x1b[<0;"+strconv.Itoa(tc.pressCol)+";2M\x1b[<0;"+strconv.Itoa(tc.pressCol)+";2m")
166			clock.Advance(time.Millisecond)
167			copyMouseInput(d, sess, ac, "\x1b[<0;"+strconv.Itoa(tc.pressCol)+";2M\x1b[<32;"+strconv.Itoa(tc.motionCol)+";2M")
168
169			ac.overlays.copyMu.Lock()
170			selection := ac.overlays.copyMode.Selection()
171			text := ac.overlays.copyMode.SelectedText()
172			pointer := ac.overlays.copyPointer
173			ac.overlays.copyMu.Unlock()
174			require.Equal(t, scopy.Word, selection.Granularity)
175			require.Equal(t, tc.wantAnchor, selection.Anchor)
176			require.Equal(t, tc.wantActive, selection.Active)
177			require.Equal(t, "alpha beta", text)
178			require.True(t, pointer.dragging)
179			require.True(t, pointer.wordDrag)
180		})
181	}
182}
183
184func TestCopyCharacterDragStartsAtPress(t *testing.T) {
185	d, sess, ac, _ := mouseCopyHarness(t, "alpha beta")
186	copyMouseInput(d, sess, ac, "\x1b[<0;3;2M\x1b[<32;8;2M")
187
188	ac.overlays.copyMu.Lock()
189	selection := ac.overlays.copyMode.Selection()
190	pointer := ac.overlays.copyPointer
191	ac.overlays.copyMu.Unlock()
192	require.Equal(t, scopy.Character, selection.Granularity)
193	require.Equal(t, scopy.Pos{Row: 0, Col: 2}, selection.Anchor)
194	require.Equal(t, scopy.Pos{Row: 0, Col: 7}, selection.Active)
195	require.True(t, pointer.dragging)
196	require.False(t, pointer.wordDrag)
197}
198
199func TestCopySearchReleaseInvalidatesPointerBeforeNextDrag(t *testing.T) {
200	d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo")
201
202	copyMouseInput(d, sess, ac, "\x1b[<0;1;2M")
203	ac.overlays.copyMu.Lock()
204	require.True(t, ac.overlays.copyPointer.valid)
205	epoch := ac.overlays.copyPointerEpoch
206	ac.overlays.copyMu.Unlock()
207
208	copyMouseInput(d, sess, ac, "/")
209	require.True(t, ac.overlays.copySearchActive())
210	copyMouseInput(d, sess, ac, "\x1b[<0;1;2m")
211
212	ac.overlays.copyMu.Lock()
213	require.False(t, ac.overlays.copyPointer.valid, "release must clear the pointer while search owns mouse input")
214	require.Greater(t, ac.overlays.copyPointerEpoch, epoch, "release must advance the pointer epoch")
215	ac.overlays.copyMu.Unlock()
216
217	copyMouseInput(d, sess, ac, "\x03")
218	require.False(t, ac.overlays.copySearchActive())
219	copyMouseInput(d, sess, ac, "\x1b[<0;7;2M\x1b[<32;9;2M")
220
221	ac.overlays.copyMu.Lock()
222	selection := ac.overlays.copyMode.Selection()
223	ac.overlays.copyMu.Unlock()
224	require.True(t, selection.Enabled)
225	require.Equal(t, scopy.Pos{Row: 0, Col: 6}, selection.Anchor)
226	require.Equal(t, scopy.Pos{Row: 0, Col: 8}, selection.Active)
227}
228
229func TestNormalScreenPassiveDoubleClickSetsMappedCursor(t *testing.T) {
230	for _, tc := range []struct {
231		name  string
232		text  string
233		setup func(*Daemon)
234	}{
235		{name: "whitespace", text: "alpha beta"},
236		{name: "configured separator", text: "alpha/beta", setup: func(d *Daemon) {
237			cfg := domain.Defaults()
238			cfg.Copy.WordSeparators = "/"
239			d.ApplyConfig(cfg)
240		}},
241	} {
242		t.Run(tc.name, func(t *testing.T) {
243			d, sess, ac, clock := mouseCopyHarness(t, tc.text)
244			d.exitCopyMode(ac)
245			if tc.setup != nil {
246				tc.setup(d)
247			}
248
249			copyMouseInput(d, sess, ac, "\x1b[<0;6;2M\x1b[<0;6;2m")
250			clock.Advance(time.Millisecond)
251			copyMouseInput(d, sess, ac, "\x1b[<0;6;2M")
252
253			ac.overlays.copyMu.Lock()
254			mode := ac.overlays.copyMode
255			selection := mode.Selection()
256			cursor := mode.Cursor()
257			ac.overlays.copyMu.Unlock()
258			require.False(t, selection.Enabled)
259			require.Equal(t, scopy.Pos{Row: 0, Col: 5}, cursor)
260		})
261	}
262}
263
264func TestCopyWordDragAndOSC52(t *testing.T) {
265	d, sess, ac, clock := mouseCopyHarness(t, "alpha beta", "gamma delta")
266	copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")
267	clock.Advance(time.Millisecond)
268	copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;2;3M\x1b[<0;2;3m")
269
270	ac.overlays.copyMu.Lock()
271	mode := ac.overlays.copyMode
272	selection := mode.Selection()
273	text := mode.SelectedText()
274	ac.overlays.copyMu.Unlock()
275	want := "alpha beta\ngamma"
276	require.Equal(t, scopy.Word, selection.Granularity)
277	require.Equal(t, want, text)
278	require.Equal(t, scopy.OSC52(want)[0], scopy.OSC52(text)[0])
279
280	copyMouseInput(d, sess, ac, "y")
281	require.False(t, ac.overlays.copyActive())
282}
283
284func seedCopyInteractionLocked(rt *overlayRuntime, p *pane, doc *scopy.Document) {
285	rt.beginCopyPointerLocked(copyPointerState{pane: p, document: doc})
286	rt.copyClick = copyClickCandidate{valid: true, pane: p}
287}
288
289func TestCopyPointerResetClearsClick(t *testing.T) {
290	p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1})
291	doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("word")}, 4, 1), domain.DefaultWordSeparators)
292	for _, tc := range []struct {
293		name        string
294		reset       func(*overlayRuntime)
295		holdsCopyMu bool
296	}{
297		{name: "clear mode", reset: func(rt *overlayRuntime) { rt.clearCopyModeLocked() }, holdsCopyMu: true},
298		{name: "replace publication", reset: func(rt *overlayRuntime) { rt.invalidateCopyPointerLocked(true) }, holdsCopyMu: true},
299		{name: "pane close", reset: func(rt *overlayRuntime) { rt.clearCopyModeForPane(p) }},
300	} {
301		t.Run(tc.name, func(t *testing.T) {
302			rt := newOverlayRuntime(&attachedClient{})
303			rt.copyMu.Lock()
304			rt.copyPane, rt.copyDocument, rt.copyMode = p, doc, scopy.NewMode(doc)
305			seedCopyInteractionLocked(rt, p, doc)
306			if !tc.holdsCopyMu {
307				rt.copyMu.Unlock()
308				tc.reset(rt)
309				rt.copyMu.Lock()
310			} else {
311				tc.reset(rt)
312			}
313			require.False(t, rt.copyPointer.valid)
314			require.False(t, rt.copyClick.valid)
315			rt.copyMu.Unlock()
316		})
317	}
318}
319
320func TestCopyWordSelectionYanksExactOSC52(t *testing.T) {
321	p, release := newBlockingPTY(t)
322	defer release()
323	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
324	clock := &manualMouseClock{now: time.Unix(1, 0)}
325	d.clock = clock
326	writeTestRow(testAttachmentTab(sess).focusedPane().screen, 0, "alpha beta")
327	d.enterCopyMode(sess, ac)
328	mustOutputData(t, sends)
329
330	copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m")
331	clock.Advance(time.Millisecond)
332	copyMouseInput(d, sess, ac, "\x1b[<0;1;2M")
333	mustOutputData(t, sends)
334	copyMouseInput(d, sess, ac, "y")
335	// The selection repaint can have been queued before the synchronous OSC 52
336	// output; consume it, then assert the wire payload itself byte-for-byte.
337	mustOutputData(t, sends)
338	require.Equal(t, scopy.OSC52("alpha")[0], mustOutputData(t, sends))
339}
340
341func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) {
342	for _, tc := range []struct {
343		name string
344		run  func(*Daemon, *session, *attachedClient)
345	}{
346		{name: "q", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) }},
347		{name: "escape", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("\x1bx")) }},
348		{name: "yank", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("y")) }},
349		{name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {
350			p := testAttachmentTab(sess).focusedPane()
351			p.mu.Lock()
352			doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
353			p.mu.Unlock()
354			require.True(t, d.publishCopyMode(sess, ac, testAttachmentTab(sess), p, doc, nil, nil))
355		}},
356	} {
357		t.Run(tc.name, func(t *testing.T) {
358			d, sess, ac, _ := mouseCopyHarness(t, "word")
359			ac.overlays.copyMu.Lock()
360			seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument)
361			ac.overlays.copyMu.Unlock()
362			tc.run(d, sess, ac)
363			ac.overlays.copyMu.Lock()
364			require.False(t, ac.overlays.copyPointer.valid)
365			require.False(t, ac.overlays.copyClick.valid)
366			ac.overlays.copyMu.Unlock()
367		})
368	}
369}

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