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}