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}