copymode_test.go RawHistoryFolder

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

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