v0.3.0 99dc266

copymode_test.go RawHistoryFolder

1package daemon
2
3import (
4	"encoding/base64"
5	"errors"
6	"fmt"
7	"io"
8	"strings"
9	"sync"
10	"testing"
11	"time"
12
13	"github.com/stretchr/testify/mock"
14	"github.com/stretchr/testify/require"
15
16	vt "github.com/bnema/vev-vt"
17	renderer "github.com/bnema/vev-vt/ansi"
18	"github.com/bnema/vev/internal/domain"
19	"github.com/bnema/vev/internal/ports"
20	portsmocks "github.com/bnema/vev/internal/ports/mocks"
21	scopy "github.com/bnema/vev/internal/usecase/copy"
22	"github.com/bnema/vev/internal/usecase/layout"
23	"github.com/bnema/vev/internal/usecase/ui"
24)
25
26// --- test doubles -----------------------------------------------------------
27
28func TestBoundedSendOutputErrTransportReturnsTransportUsedBySend(t *testing.T) {
29	d := newTestDaemon(t, nil, stubClock{})
30	ac := &attachedClient{output: newOutputStateStream()}
31	ac.initOverlays()
32	replacement := &closeTrackingTransport{}
33	sendErr := errors.New("send failed")
34	failed := &swapErrorTransport{ac: ac, replacement: replacement, err: sendErr}
35	ac.replaceTransport(failed)
36
37	used, err := d.boundedSendOutputErrTransport(ac, []byte("copy"))
38
39	require.ErrorIs(t, err, sendErr)
40	require.Same(t, failed, used)
41	require.Same(t, replacement, ac.transport())
42}
43
44func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testing.T) {
45	d := newTestDaemon(t, nil, stubClock{})
46	replacement := &closeTrackingTransport{}
47	sendErr := errors.New("owned send failed")
48	ac := &attachedClient{output: newOutputStateStream()}
49	failed := &ownedSwapErrorTransport{ac: ac, replacement: replacement, err: sendErr, sent: make(chan ports.Frame, 1)}
50	ac.replaceTransport(failed)
51	sess := &session{sessionCore: sessionCore{name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}}
52	ac.setSession(sess)
53
54	used, err := d.boundedSendOutputErrTransport(ac, []byte("copy"))
55
56	require.ErrorIs(t, err, sendErr)
57	require.Same(t, failed, used)
58	require.Same(t, replacement, ac.transport())
59	out, decodeErr := ports.UnmarshalOutput((<-failed.sent).Payload)
60	require.NoError(t, decodeErr)
61	require.Equal(t, []byte("copy"), out.Data)
62	d.detachOnSendError(sess, ac, used)
63	require.Contains(t, sess.snapshotAttachments(), ac)
64	require.False(t, replacement.Closed())
65}
66
67type swapErrorTransport struct {
68	ac          *attachedClient
69	replacement ports.Transport
70	err         error
71}
72
73func (t *swapErrorTransport) Send(ports.Frame) error {
74	t.ac.replaceTransport(t.replacement)
75	return t.err
76}
77
78func (t *swapErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
79func (t *swapErrorTransport) Close() error               { return nil }
80
81type ownedSwapErrorTransport struct {
82	ac          *attachedClient
83	replacement ports.Transport
84	err         error
85	sent        chan ports.Frame
86}
87
88func (t *ownedSwapErrorTransport) Send(f ports.Frame) error { return t.SendSynchronous(f) }
89func (t *ownedSwapErrorTransport) SendSynchronous(f ports.Frame) error {
90	t.sent <- f
91	t.ac.replaceTransport(t.replacement)
92	return t.err
93}
94func (t *ownedSwapErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
95func (t *ownedSwapErrorTransport) Close() error               { return nil }
96
97// stubClock returns timers whose channel never fires, so a scheduler under it
98// blocks in its debounce loop until the session context is cancelled. Used by
99
100func TestCopySearchModalGeometry(t *testing.T) {
101	base := domain.Size{Cols: 100, Rows: 40}
102
103	require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 100, Height: 11}, copySearchModal.Resolve(base).Bounds)
104	require.Equal(t, domain.AnchorBottom, copySearchModal.Anchor)
105	require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins)
106}
107
108func TestCopyModeDocumentCarriesPaneRowIDs(t *testing.T) {
109	p, _ := newBlockingPTY(t)
110	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
111	pane := testAttachmentTab(sess).focusedPane()
112	appendHistoryRow(t, pane.history, testRow("history"))
113	pane.screen.Write([]byte("live"))
114	historyID := pane.history.View().RowID(0)
115	liveID := pane.screen.RowID(0)
116	require.NotEqual(t, historyID, liveID)
117
118	d.enterCopyMode(sess, ac)
119	awaitFrame(t, sends, ports.MsgOutput)
120	doc := ac.overlays.copyDocument
121
122	require.Equal(t, 0, doc.FindRowID(historyID))
123	require.Equal(t, pane.history.Len(), doc.FindRowID(liveID))
124	require.Equal(t, liveID, doc.RowID(pane.history.Len()))
125}
126
127func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
128	p, release := newBlockingPTY(t)
129	defer release()
130	_, sess, _, _ := newManualSessionWithPTYs(t, p)
131	tb := testAttachmentTab(sess)
132	pane := tb.focusedPane()
133	pane.mu.Lock()
134	snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil)
135	pane.mu.Unlock()
136	mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
137
138	base := renderer.NewFrame(80, 25)
139	target := domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}
140
141	var wg sync.WaitGroup
142	wg.Add(2)
143	go func() {
144		defer wg.Done()
145		for range 200 {
146			pane.mu.Lock()
147			pane.screen.Write([]byte("output\n"))
148			pane.mu.Unlock()
149		}
150	}()
151	go func() {
152		defer wg.Done()
153		for range 200 {
154			_, _ = composeCopyClientFrame(mode, target, base, resolveStyles(nil))
155		}
156	}()
157	wg.Wait()
158}
159
160func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
161	p, release := newBlockingPTY(t)
162	_, sess, _, _ := newManualSessionWithPTYs(t, p)
163	defer release()
164	sess.name = "work"
165	tb := testAttachmentTab(sess)
166	tb.focusedPane().screen = vt.NewScreen(12, 3)
167	tb.focusedPane().screen.Write([]byte("live"))
168	snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds(), nil)
169	mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
170
171	bars := barState{status: sess.statusSegments(true)}
172	content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
173	base := composeFrame(capturedRenderState{
174		reset:  true,
175		layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true},
176		panes: []capturedPaneRenderState{{
177			id: tb.focusedPane().id, frame: tb.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
178		}},
179		bars: bars,
180	}, composeCacheInput{}).frame
181	frame, damage := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, resolveStyles(nil))
182
183	require.Equal(t, 80, frame.Width)
184	require.Equal(t, 25, frame.Height)
185	require.Equal(t, " 1 (sh)", strings.TrimRight(rowText(frame.Row(0)), " "))
186	require.Contains(t, rowText(frame.Row(1)), "live")
187	require.Contains(t, rowText(frame.Row(24)), "[SCROLL]")
188	require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
189}
190
191func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
192	writes := make(chan []byte, 1)
193	p, _ := newBlockingPTYWithWrites(t, writes)
194	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
195	installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
196	sess.tabs[0].focusedPane().screen.Write([]byte("live"))
197
198	d.handleInput(sess, ac, []byte("\x1b "))
199	awaitFrame(t, sends, ports.MsgOutput)
200	d.handleInput(sess, ac, []byte("VIS\r"))
201
202	if ac.overlays.copyMode == nil {
203		t.Fatal("scrollback mode not entered")
204	}
205	select {
206	case got := <-writes:
207		t.Fatalf("scrollback binding forwarded to PTY: %q", got)
208	default:
209	}
210	out := awaitFrame(t, sends, ports.MsgOutput)
211	msg, err := ports.UnmarshalOutput(out.Payload)
212	require.NoError(t, err)
213	if got := string(msg.Data); !strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[SELECT]") || strings.Contains(got, "[COPY]") {
214		t.Fatalf("passive scrollback paint = %q, want [SCROLL] without [SELECT]/[COPY]", got)
215	}
216
217	d.handleInput(sess, ac, []byte(" "))
218	out = awaitFrame(t, sends, ports.MsgOutput)
219	msg, err = ports.UnmarshalOutput(out.Payload)
220	require.NoError(t, err)
221	if got := string(msg.Data); !strings.Contains(got, "[SELECT]") || strings.Contains(got, "[SCROLL]") {
222		t.Fatalf("visual selection paint = %q, want [SELECT] without [SCROLL]", got)
223	}
224}
225
226func TestCopyModeSearchModalRoutesBatchedInputAfterSlash(t *testing.T) {
227	writes := make(chan []byte, 1)
228	p, _ := newBlockingPTYWithWrites(t, writes)
229	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
230	pane := sess.tabs[0].focusedPane()
231	copy(pane.screen.Frame.Row(0), testRow("alpha"))
232	copy(pane.screen.Frame.Row(1), testRow("beta alpha"))
233
234	d.enterCopyMode(sess, ac)
235	awaitFrame(t, sends, ports.MsgOutput)
236	d.handleInput(sess, ac, []byte("/alpha\r"))
237	awaitFrame(t, sends, ports.MsgOutput)
238
239	require.Nil(t, ac.overlays.copySearch)
240	require.NotNil(t, ac.overlays.copyMode)
241	require.Equal(t, 0, ac.overlays.copyMode.Cursor().Row)
242	select {
243	case got := <-writes:
244		t.Fatalf("batched visual search input forwarded to PTY: %q", got)
245	default:
246	}
247}
248
249func TestCopyModeSearchModalJumpsAndKeepsNavigation(t *testing.T) {
250	writes := make(chan []byte, 1)
251	p, _ := newBlockingPTYWithWrites(t, writes)
252	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
253	pane := sess.tabs[0].focusedPane()
254	copy(pane.screen.Frame.Row(0), testRow("alpha"))
255	copy(pane.screen.Frame.Row(1), testRow("beta alpha"))
256	copy(pane.screen.Frame.Row(2), testRow("gamma"))
257
258	d.enterCopyMode(sess, ac)
259	awaitFrame(t, sends, ports.MsgOutput)
260	d.handleInput(sess, ac, []byte("/"))
261	out := awaitFrame(t, sends, ports.MsgOutput)
262	msg, err := ports.UnmarshalOutput(out.Payload)
263	require.NoError(t, err)
264	require.Contains(t, string(msg.Data), "Search")
265	require.NotNil(t, ac.overlays.copySearch)
266
267	d.handleInput(sess, ac, []byte("alpha"))
268	out = awaitFrame(t, sends, ports.MsgOutput)
269	msg, err = ports.UnmarshalOutput(out.Payload)
270	require.NoError(t, err)
271	require.Contains(t, string(msg.Data), "/alpha")
272	require.Contains(t, string(msg.Data), "1:1  alpha")
273	require.Contains(t, string(msg.Data), "2:6  beta alpha")
274
275	d.handleInput(sess, ac, []byte("\r"))
276	awaitFrame(t, sends, ports.MsgOutput)
277	require.Nil(t, ac.overlays.copySearch)
278	require.Equal(t, 0, ac.overlays.copyMode.Cursor().Row)
279
280	d.handleInput(sess, ac, []byte("n"))
281	awaitFrame(t, sends, ports.MsgOutput)
282	require.Equal(t, 1, ac.overlays.copyMode.Cursor().Row)
283	d.handleInput(sess, ac, []byte("N"))
284	awaitFrame(t, sends, ports.MsgOutput)
285	require.Equal(t, 0, ac.overlays.copyMode.Cursor().Row)
286
287	select {
288	case got := <-writes:
289		t.Fatalf("visual search input forwarded to PTY: %q", got)
290	default:
291	}
292}
293
294func TestCopyModeSearchModalSelectionPreviewsBehindModal(t *testing.T) {
295	p, _ := newBlockingPTY(t)
296	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
297	pane := sess.tabs[0].focusedPane()
298	copy(pane.screen.Frame.Row(0), testRow("alpha"))
299	copy(pane.screen.Frame.Row(1), testRow("beta alpha"))
300	copy(pane.screen.Frame.Row(2), testRow("gamma"))
301
302	d.enterCopyMode(sess, ac)
303	awaitFrame(t, sends, ports.MsgOutput)
304
305	d.handleInput(sess, ac, []byte("/"))
306	awaitFrame(t, sends, ports.MsgOutput)
307	d.handleInput(sess, ac, []byte("alpha"))
308	awaitFrame(t, sends, ports.MsgOutput)
309	require.NotNil(t, ac.overlays.copySearch)
310	require.Equal(t, 0, ac.overlays.copyMode.Cursor().Row, "typing a query previews the selected first result behind the modal")
311
312	d.handleInput(sess, ac, []byte{0x0e})
313	awaitFrame(t, sends, ports.MsgOutput)
314	require.NotNil(t, ac.overlays.copySearch)
315	require.Equal(t, 1, ac.overlays.copyMode.Cursor().Row, "moving modal selection previews that result without Enter")
316}
317
318func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
319	p, _ := newBlockingPTY(t)
320	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
321	pane := sess.tabs[0].focusedPane()
322	installTestHistory(pane, vt.HistoryConfig{MaxRows: 4})
323	appendHistoryRow(t, pane.history, testRow("old alpha"))
324	copy(pane.screen.Frame.Row(0), testRow("live alpha"))
325
326	d.enterCopyMode(sess, ac)
327	awaitFrame(t, sends, ports.MsgOutput)
328	d.handleInput(sess, ac, []byte("/alpha"))
329	awaitFrame(t, sends, ports.MsgOutput)
330	require.NotNil(t, ac.overlays.copySearch)
331	cursor := ac.overlays.copyMode.Cursor().Row
332
333	d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
334	require.NotNil(t, ac.overlays.copySearch)
335	require.Equal(t, cursor, ac.overlays.copyMode.Cursor().Row)
336
337	d.handleInput(sess, ac, []byte("\x1b"))
338	awaitFrame(t, sends, ports.MsgOutput)
339	require.Nil(t, ac.overlays.copySearch)
340	require.NotNil(t, ac.overlays.copyMode)
341	d.handleInput(sess, ac, []byte("q"))
342	awaitFrame(t, sends, ports.MsgOutput)
343	require.Nil(t, ac.overlays.copySearch)
344	require.Nil(t, ac.overlays.copyMode)
345}
346
347func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
348	writes := make(chan []byte, 1)
349	p, _ := newBlockingPTYWithWrites(t, writes)
350	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
351	installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
352	appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old1    "))
353	appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old2    "))
354	sess.tabs[0].focusedPane().screen.Write([]byte("live"))
355
356	d.enterCopyMode(sess, ac)
357	awaitFrame(t, sends, ports.MsgOutput)
358	d.handleInput(sess, ac, []byte{'g', ' ', 'j', 'y'})
359
360	select {
361	case got := <-writes:
362		t.Fatalf("copy-mode navigation forwarded to PTY: %q", got)
363	default:
364	}
365	out := awaitFrame(t, sends, ports.MsgOutput)
366	msg, err := ports.UnmarshalOutput(out.Payload)
367	require.NoError(t, err)
368	if got, want := string(msg.Data), "\x1b]52;c;b2xkMQpvbGQy\x07"; got != want {
369		t.Fatalf("OSC52 = %q, want %q", got, want)
370	}
371	if ac.overlays.copyMode != nil {
372		t.Fatal("copy mode still active after yank")
373	}
374	live := awaitFrame(t, sends, ports.MsgOutput)
375	liveMsg, err := ports.UnmarshalOutput(live.Payload)
376	require.NoError(t, err)
377	if strings.Contains(string(liveMsg.Data), "[COPY]") || strings.Contains(string(liveMsg.Data), "[SCROLL]") {
378		t.Fatalf("live repaint still contains copy/scroll status: %q", string(liveMsg.Data))
379	}
380	if !strings.Contains(string(liveMsg.Data), "copied 9 chars to clipboard") {
381		t.Fatalf("live repaint = %q, want copy success feedback", string(liveMsg.Data))
382	}
383	if !strings.Contains(string(liveMsg.Data), "live") {
384		t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data))
385	}
386
387	d.paint(sess, ac, true, nil)
388	followup := awaitFrame(t, sends, ports.MsgOutput)
389	followupMsg, err := ports.UnmarshalOutput(followup.Payload)
390	require.NoError(t, err)
391	if strings.Contains(string(followupMsg.Data), "copied 9 chars to clipboard") {
392		t.Fatalf("copy feedback persisted after next repaint: %q", string(followupMsg.Data))
393	}
394}
395
396func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
397	p := portsmocks.NewMockPTY(t)
398	reads := make(chan []byte, 64)
399	readDone := make(chan struct{})
400	var closeOnce sync.Once
401	p.EXPECT().Read(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
402		select {
403		case data := <-reads:
404			return copy(b, data), nil
405		case <-readDone:
406			return 0, io.EOF
407		}
408	}).Maybe()
409	p.EXPECT().Write(mock.Anything).Return(0, errors.New("unexpected PTY write")).Maybe()
410	p.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
411	p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe()
412	p.EXPECT().Pid().Return(4242).Maybe()
413
414	// Rendering is coordinator-driven: advance a controllable clock through the
415	// retained resize timer and its resulting coordinator wake rather than
416	// relying on wall-clock debounce delivery.
417	clk := newCoordinatorMockClock(t, 16)
418	d := newTestDaemon(t, newFactory(t, p), clk.clock)
419	tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 16, Rows: 5}))
420	advanceRender := func() {
421		deadline := time.NewTimer(2 * time.Second)
422		defer deadline.Stop()
423		select {
424		case timer := <-clk.timers:
425			timer.ch <- time.Time{}
426		case <-deadline.C:
427			t.Fatal("coordinator did not arm a controllable render timer")
428		}
429	}
430	var hg sync.WaitGroup
431	hg.Go(func() { d.handleConn(tr) })
432	awaitFrame(t, sends, ports.MsgWelcome)
433	advanceRender() // initial coordinator invalidation
434	awaitFrame(t, sends, ports.MsgOutput)
435
436	for i := range 12 {
437		reads <- fmt.Appendf(nil, "line-%02d\r\n", i)
438	}
439	require.Eventually(t, func() bool {
440		sess := firstSession(d)
441		if sess == nil {
442			return false
443		}
444		win := testAttachmentTab(sess)
445		if win == nil {
446			return false
447		}
448		win.mu.Lock()
449		p := win.focusedPane()
450		win.mu.Unlock()
451		if p == nil {
452			return false
453		}
454		p.mu.Lock()
455		defer p.mu.Unlock()
456		return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil).Len() >= 12
457	}, 2*time.Second, 5*time.Millisecond)
458
459	sess := firstSession(d)
460	require.NotNil(t, sess)
461	ac := sess.snapshotAttachments()[0]
462	require.NotNil(t, ac)
463	d.handleInput(sess, ac, []byte("\x1b "))
464	awaitCoordinatorOutput(t, sends, clk.timers, "while advancing render clock", "controllable timers did not produce an output frame")
465	d.handleInput(sess, ac, []byte("VIS\r"))
466	awaitCoordinatorOutput(t, sends, clk.timers, "while advancing render clock", "controllable timers did not produce an output frame")
467	d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'})
468
469	var payload string
470	require.Eventually(t, func() bool {
471		out := awaitFrame(t, sends, ports.MsgOutput)
472		msg, err := ports.UnmarshalOutput(out.Payload)
473		require.NoError(t, err)
474		payload = string(msg.Data)
475		return strings.HasPrefix(payload, "\x1b]52;c;")
476	}, 2*time.Second, 5*time.Millisecond)
477	require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload)
478	require.True(t, strings.HasSuffix(payload, "\a"), "OSC52 payload = %q", payload)
479	decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a"))
480	require.NoError(t, err)
481	require.True(t, strings.HasPrefix(string(decoded), "line-00"), "decoded yank = %q", string(decoded))
482
483	releaseConn()
484	closeOnce.Do(func() { close(readDone) })
485	hg.Wait()
486	require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
487	d.sessWg.Wait()
488}
489
490func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
491	p, _ := newBlockingPTY(t)
492	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
493	installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
494	sess.tabs[0].focusedPane().screen.Write([]byte("live"))
495
496	d.enterCopyMode(sess, ac)
497	awaitFrame(t, sends, ports.MsgOutput)
498	d.handleInput(sess, ac, []byte("q"))
499
500	if ac.overlays.copyMode != nil {
501		t.Fatal("copy mode still active after q")
502	}
503	out := awaitFrame(t, sends, ports.MsgOutput)
504	msg, err := ports.UnmarshalOutput(out.Payload)
505	require.NoError(t, err)
506	if strings.Contains(string(msg.Data), "[COPY]") || !strings.Contains(string(msg.Data), "live") {
507		t.Fatalf("exit repaint = %q, want live full repaint without copy status", string(msg.Data))
508	}
509}
510
511func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
512	cases := []struct {
513		name       string
514		input      [][]byte
515		wantCursor int
516	}{
517		{name: "escape then up arrow", input: [][]byte{[]byte("\x1b"), []byte("[A")}, wantCursor: 23},
518		{name: "escape then down arrow", input: [][]byte{[]byte("g"), []byte("\x1b"), []byte("[B")}, wantCursor: 1},
519		{name: "split up arrow", input: [][]byte{[]byte("\x1b["), []byte("A")}, wantCursor: 23},
520		{name: "split SS3 up arrow", input: [][]byte{[]byte("\x1bO"), []byte("A")}, wantCursor: 23},
521		{name: "split page down", input: [][]byte{[]byte("g"), []byte("\x1b[6"), []byte("~")}, wantCursor: 23},
522	}
523	for _, tc := range cases {
524		t.Run(tc.name, func(t *testing.T) {
525			p, _ := newBlockingPTY(t)
526			d, sess, ac, sends := newManualSessionWithPTYs(t, p)
527			installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
528			appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old1    "))
529			appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old2    "))
530			sess.tabs[0].focusedPane().screen.Write([]byte("live"))
531
532			d.enterCopyMode(sess, ac)
533			awaitFrame(t, sends, ports.MsgOutput)
534			for _, input := range tc.input {
535				d.handleInput(sess, ac, input)
536			}
537
538			require.NotNil(t, ac.overlays.copyMode)
539			require.Equal(t, tc.wantCursor, ac.overlays.copyMode.Cursor().Row)
540		})
541	}
542}
543
544func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
545	p, _ := newBlockingPTY(t)
546	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
547	installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 1})
548	longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
549	sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1)
550	copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine))
551
552	d.enterCopyMode(sess, ac)
553	awaitFrame(t, sends, ports.MsgOutput)
554	d.handleInput(sess, ac, []byte{' ', 'y'})
555
556	out := awaitFrame(t, sends, ports.MsgOutput)
557	msg, err := ports.UnmarshalOutput(out.Payload)
558	require.NoError(t, err)
559	if strings.Contains(string(msg.Data), "\x1b]52;") {
560		t.Fatalf("oversized yank emitted OSC52: %q", string(msg.Data))
561	}
562	if !strings.Contains(string(msg.Data), "selection too large to copy") {
563		t.Fatalf("oversized yank repaint = %q, want too-large feedback", string(msg.Data))
564	}
565}
566
567func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
568	p, _ := newBlockingPTY(t)
569	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
570	clk := &signalClock{timers: make(chan *signalTimer, 1)}
571	d.clock = clk
572	installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
573	sess.tabs[0].focusedPane().screen.Write([]byte("live"))
574
575	d.enterCopyMode(sess, ac)
576	awaitFrame(t, sends, ports.MsgOutput)
577	d.handleInput(sess, ac, []byte("\x1b"))
578	timer := <-clk.timers
579	ac.overlays.copyMu.Lock()
580	require.NotNil(t, ac.overlays.copyMode)
581	ac.overlays.copyMu.Unlock()
582	timer.ch <- time.Now()
583	require.Eventually(t, func() bool {
584		ac.overlays.copyMu.Lock()
585		defer ac.overlays.copyMu.Unlock()
586		return ac.overlays.copyMode == nil
587	}, time.Second, 5*time.Millisecond)
588}
589
590func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
591	p, release := newBlockingPTY(t)
592	defer release()
593	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
594	clk := &signalClock{timers: make(chan *signalTimer, 1)}
595	d.clock = clk
596	installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
597	sess.tabs[0].focusedPane().screen.Write([]byte("live"))
598
599	d.enterCopyMode(sess, ac)
600	awaitFrame(t, sends, ports.MsgOutput)
601	d.handleInput(sess, ac, []byte("\x1b"))
602	timer := <-clk.timers
603	d.enterCopyMode(sess, ac)
604	awaitFrame(t, sends, ports.MsgOutput)
605
606	ac.overlays.copyMu.Lock()
607	require.Nil(t, ac.overlays.copyESC.timer)
608	require.Nil(t, ac.overlays.copyESC.done)
609	require.Empty(t, ac.overlays.copyPending)
610	ac.overlays.copyMu.Unlock()
611	d.handleInput(sess, ac, []byte("x"))
612	require.True(t, ac.overlays.copyActive(), "pending input from the replaced mode affected the new mode")
613
614	timer.ch <- time.Now()
615	require.Never(t, func() bool {
616		ac.overlays.copyMu.Lock()
617		defer ac.overlays.copyMu.Unlock()
618		return ac.overlays.copyMode == nil
619	}, 50*time.Millisecond, 5*time.Millisecond)
620	ac.overlays.copyMu.Lock()
621	require.Nil(t, ac.overlays.copyESC.timer)
622	require.Nil(t, ac.overlays.copyESC.done)
623	require.Empty(t, ac.overlays.copyPending)
624	ac.overlays.copyMu.Unlock()
625}
626
627func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
628	cases := []struct {
629		name  string
630		input []byte
631	}{
632		{name: "yank", input: []byte("y")},
633		{name: "enter", input: []byte("\r")},
634	}
635	for _, tc := range cases {
636		t.Run(tc.name, func(t *testing.T) {
637			p, _ := newBlockingPTY(t)
638			d, sess, ac, sends := newManualSessionWithPTYs(t, p)
639			installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
640			sess.tabs[0].focusedPane().screen.Write([]byte("live"))
641
642			d.enterCopyMode(sess, ac)
643			awaitFrame(t, sends, ports.MsgOutput)
644			d.handleInput(sess, ac, tc.input)
645
646			if ac.overlays.copyMode != nil {
647				t.Fatal("copy mode still active after empty yank")
648			}
649			out := awaitFrame(t, sends, ports.MsgOutput)
650			msg, err := ports.UnmarshalOutput(out.Payload)
651			require.NoError(t, err)
652			if strings.Contains(string(msg.Data), "\x1b]52;") {
653				t.Fatalf("empty yank emitted OSC52 clipboard clear: %q", string(msg.Data))
654			}
655			if strings.Contains(string(msg.Data), "[COPY]") || !strings.Contains(string(msg.Data), "live") {
656				t.Fatalf("empty yank repaint = %q, want live full repaint", string(msg.Data))
657			}
658		})
659	}
660}
661
662func TestHandleCopyInputUsesImmutableSnapshotWithoutPaneLock(t *testing.T) {
663	p, release := newBlockingPTY(t)
664	defer release()
665	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
666	pane := testAttachmentTab(sess).focusedPane()
667	pane.screen.Write([]byte("live"))
668	d.enterCopyMode(sess, ac)
669	awaitFrame(t, sends, ports.MsgOutput)
670
671	pane.mu.Lock()
672	defer pane.mu.Unlock()
673	done := make(chan struct{})
674	go func() {
675		d.handleCopyInput(ac, []byte("x"))
676		close(done)
677	}()
678	select {
679	case <-done:
680	case <-time.After(time.Second):
681		t.Fatal("immutable copy input waited for the live pane lock")
682	}
683}
684
685func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
686	p, _ := newBlockingPTY(t)
687	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
688	installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
689	sess.tabs[0].focusedPane().screen.Write([]byte("live"))
690
691	done := make(chan struct{})
692	var drain sync.WaitGroup
693	drain.Go(func() {
694		for {
695			select {
696			case <-sends:
697			case <-done:
698				return
699			}
700		}
701	})
702
703	var wg sync.WaitGroup
704	for range 100 {
705		wg.Go(func() {
706			d.enterCopyMode(sess, ac)
707			d.handleInput(sess, ac, []byte("q"))
708		})
709		wg.Go(func() {
710			d.paint(sess, ac, true, nil)
711		})
712	}
713	wg.Wait()
714	close(done)
715	drain.Wait()
716}
717
718func TestCursorTailHidesInCopyMode(t *testing.T) {
719	p, _ := newBlockingPTY(t)
720	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
721	sess.tabs[0].focusedPane().screen.Write([]byte("live"))
722
723	d.enterCopyMode(sess, ac)
724	data := mustOutputData(t, sends)
725	require.Contains(t, string(data), "\x1b[?25l")
726}
727
728func installFloatingCopyFixture(t *testing.T, sess *session, size domain.Size) *pane {
729	t.Helper()
730	floatingPTY, releaseFloating := newBlockingPTY(t)
731	t.Cleanup(releaseFloating)
732	fp := newPane("floating", floatingPTY, size)
733	installTestFloating(testAttachmentTab(sess), fp, true)
734	return fp
735}
736
737func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) {
738	cases := []struct {
739		name        string
740		useFloating bool
741		wantText    string
742	}{
743		{name: "floating source", useFloating: true, wantText: "flt-old\nflt-live"},
744		{name: "normal source", useFloating: false, wantText: "one-old\none-live"},
745	}
746	for _, tc := range cases {
747		t.Run(tc.name, func(t *testing.T) {
748			normalWrites := make(chan []byte, 1)
749			normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
750			defer releaseNormal()
751			d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
752			tb := testAttachmentTab(sess)
753			second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5})
754			tb.mu.Lock()
755			tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
756			tb.panes["pane-2"] = second
757			tb.mu.Unlock()
758			main := tb.focusedPane()
759			var want *pane
760			if tc.useFloating {
761				want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
762				appendHistoryRow(t, want.history, testRow("flt-old"))
763				want.screen.Write([]byte("flt-live"))
764			} else {
765				want = main
766				appendHistoryRow(t, want.history, testRow("one-old"))
767				want.screen.Write([]byte("one-live"))
768			}
769
770			d.enterCopyMode(sess, ac)
771			awaitFrame(t, sends, ports.MsgOutput)
772			ac.overlays.copyMu.Lock()
773			captured := ac.overlays.copyPane
774			ac.overlays.copyMu.Unlock()
775			require.Same(t, want, captured)
776
777			tb.mu.Lock()
778			focusPlacementLocked(tb, "pane-2")
779			tb.mu.Unlock()
780
781			d.handleInput(sess, ac, []byte{'g', ' ', 'j', 'y'})
782			out := awaitFrame(t, sends, ports.MsgOutput)
783			msg, err := ports.UnmarshalOutput(out.Payload)
784			require.NoError(t, err)
785			wantOSC := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(tc.wantText)) + "\x07"
786			require.Equal(t, wantOSC, string(msg.Data))
787			require.Nil(t, ac.overlays.copyMode)
788			select {
789			case got := <-normalWrites:
790				t.Fatalf("copy-mode input forwarded to normal PTY: %q", got)
791			default:
792			}
793		})
794	}
795}
796
797func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
798	normal, releaseNormal := newBlockingPTY(t)
799	defer releaseNormal()
800	d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
801	fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
802	for i := range 30 {
803		appendHistoryRow(t, fp.history, testRow(fmt.Sprintf("old-%02d", i)))
804	}
805	fp.screen.Write([]byte("live"))
806	fp.mu.Lock()
807	total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds(), nil).Len()
808	fp.mu.Unlock()
809
810	d.enterCopyMode(sess, ac)
811	awaitFrame(t, sends, ports.MsgOutput)
812	require.Equal(t, total-1, ac.overlays.copyMode.Cursor().Row)
813
814	d.copyWheel(sess, ac, -3)
815	awaitFrame(t, sends, ports.MsgOutput)
816	require.Equal(t, total-4, ac.overlays.copyMode.Cursor().Row)
817
818	d.copyWheel(sess, ac, 3)
819	awaitFrame(t, sends, ports.MsgOutput)
820	require.Nil(t, ac.overlays.copyMode, "wheel down reaching the captured bottom exits copy mode")
821}
822
823func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
824	normal, releaseNormal := newBlockingPTY(t)
825	defer releaseNormal()
826	d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
827	fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
828	for i := range 5 {
829		appendHistoryRow(t, fp.history, testRow(fmt.Sprintf("old-%d", i)))
830	}
831	fp.screen.Write([]byte("live"))
832
833	d.enterCopyMode(sess, ac)
834	awaitFrame(t, sends, ports.MsgOutput)
835	ac.overlays.copyMu.Lock()
836	viewportTop := ac.overlays.copyMode.ViewportTop
837	ac.overlays.copyMu.Unlock()
838	inner := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 23}, d.currentFloatingConfig()).Inner
839
840	// Copy body row k renders at wire row inner.Y+k+2 (top bar + content
841	// offset); clicking a row must select exactly that row.
842	press := fmt.Sprintf("\x1b[<0;%d;%dM", inner.X+3, inner.Y+3)
843	d.handleInput(sess, ac, []byte(press))
844	awaitFrame(t, sends, ports.MsgOutput)
845	require.Equal(t, viewportTop+1, ac.overlays.copyMode.Cursor().Row)
846
847	motion := fmt.Sprintf("\x1b[<32;%d;%dM", inner.X+3, inner.Y+4)
848	d.handleInput(sess, ac, []byte(motion))
849	awaitFrame(t, sends, ports.MsgOutput)
850	selection := ac.overlays.copyMode.Selection()
851	require.True(t, selection.Enabled)
852	require.Equal(t, viewportTop+1, selection.Anchor.Row)
853	require.Equal(t, viewportTop+2, selection.Active.Row)
854}
855
856func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) {
857	normal, releaseNormal := newBlockingPTY(t)
858	defer releaseNormal()
859	d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
860	tb := testAttachmentTab(sess)
861	fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
862	fp.screen.Write([]byte("flt-live"))
863
864	d.enterCopyMode(sess, ac)
865	awaitFrame(t, sends, ports.MsgOutput)
866	tb.mu.Lock()
867	generation := tb.floating.generation
868	publishPaneOwner(fp, sess, tb, generation)
869	tb.mu.Unlock()
870
871	d.reapInstalledFloating(fp)
872
873	ac.overlays.copyMu.Lock()
874	require.Nil(t, ac.overlays.copyMode)
875	require.Nil(t, ac.overlays.copyPane)
876	ac.overlays.copyMu.Unlock()
877	data := mustOutputData(t, sends)
878	require.NotContains(t, string(data), "[SCROLL]")
879	require.NotContains(t, string(data), "flt-live")
880}
881
882func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) {
883	pty, _ := newBlockingPTY(t)
884	d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
885	pane := sess.tabs[0].focusedPane()
886	copy(pane.screen.Frame.Row(0), testRow("alpha"))
887	copy(pane.screen.Frame.Row(1), testRow("bravo"))
888
889	d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M"))
890	mustOutputData(t, sends)
891	require.NotNil(t, ac.overlays.copyMode)
892	ac.overlays.copyMu.Lock()
893	captured := ac.overlays.copyPane
894	ac.overlays.copyMu.Unlock()
895	require.Same(t, pane, captured, "drag entry must capture the copy source pane")
896
897	d.handleInput(sess, ac, []byte("y"))
898	out := awaitFrame(t, sends, ports.MsgOutput)
899	msg, err := ports.UnmarshalOutput(out.Payload)
900	require.NoError(t, err)
901	want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha\nb")) + "\x07"
902	require.Equal(t, want, string(msg.Data))
903}
904
905func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) {
906	for _, tt := range []struct {
907		name       string
908		selectMode bool
909		want       string
910	}{
911		{name: "scroll", want: "[SCROLL]"},
912		{name: "selection", selectMode: true, want: "[SELECT]"},
913	} {
914		t.Run(tt.name, func(t *testing.T) {
915			base := renderer.NewFrame(20, 8)
916			pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2})
917			pane.screen.Write([]byte("copy"))
918			mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil), domain.DefaultWordSeparators))
919			if tt.selectMode {
920				mode.ToggleLineSelection()
921			}
922			styles := resolveStyles(nil)
923			styles.SurfaceBar = renderer.Style{Foreground: 7, Background: 3}
924			styles.CopyStatus = renderer.Style{Foreground: 5, Background: 4}
925
926			frame, _ := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 2}, base, styles)
927			status := frame.Row(frame.Height - 1)
928			for x := range 12 {
929				require.Truef(t, status[x].Style.Equal(styles.CopyStatus), "status content cell %d must use CopyStatus", x)
930			}
931			for x := 12; x < len(status); x++ {
932				require.Truef(t, status[x].Style.Equal(styles.SurfaceBar), "surrounding status cell %d must use SurfaceBar", x)
933			}
934			require.Contains(t, rowText(status), tt.want)
935		})
936	}
937}

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