v0.2.0 80c61e3

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

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