1package daemon
2
3import (
4 "context"
5 "encoding/base64"
6 "errors"
7 "fmt"
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 "github.com/bnema/vev/internal/domain"
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 "github.com/bnema/vev/internal/protocol"
20 "github.com/bnema/vev/internal/protocol/wire"
21 scopy "github.com/bnema/vev/internal/usecase/copy"
22 "github.com/bnema/vev/internal/usecase/keys"
23 "github.com/bnema/vev/internal/usecase/layout"
24 "github.com/bnema/vev/internal/usecase/mouse"
25 "github.com/bnema/vev/internal/usecase/picker"
26 "github.com/bnema/vev/internal/usecase/ui"
27)
28
29// --- test doubles -----------------------------------------------------------
30
31func TestAttachedKeyboardMutationSharesDispatchBoundaryWithCommand(t *testing.T) {
32 d := newTestDaemon(t, nil, stubClock{})
33 sess := addControlSession(d, "work", "t_work", "p_work")
34 ac := &attachedClient{}
35 ac.setSession(sess)
36
37 var orderMu sync.Mutex
38 var order []string
39 admitted := make(chan struct{})
40 release := make(chan struct{})
41 runner := newMockdaemonActionRunner(t)
42 runner.EXPECT().Run(mock.Anything).RunAndReturn(func(daemonActionRequest) error {
43 close(admitted)
44 <-release
45 orderMu.Lock()
46 order = append(order, "keyboard")
47 orderMu.Unlock()
48 return nil
49 }).Once()
50 handler := daemonKeyHandler{d: d, ac: ac, actions: runner}
51 keyboardDone := make(chan struct{})
52 go func() {
53 handler.Action(keys.ActionGrowPaneWidth, nil)
54 close(keyboardDone)
55 }()
56 <-admitted
57 require.False(t, sess.dispatchMu.TryLock(), "keyboard mutation must hold the session dispatch boundary")
58
59 commandDone := make(chan struct{})
60 go func() {
61 _ = sess.runMutation(func() error {
62 orderMu.Lock()
63 order = append(order, "command")
64 orderMu.Unlock()
65 return nil
66 })
67 close(commandDone)
68 }()
69 close(release)
70 <-keyboardDone
71 <-commandDone
72
73 require.Equal(t, []string{"keyboard", "command"}, order)
74}
75
76func TestConfiguredConsumeOrExpelActionsRouteThroughDaemonInput(t *testing.T) {
77 tests := []struct {
78 name string
79 binding string
80 key byte
81 focus layout.PaneID
82 wantTarget layout.PaneID
83 }{
84 {name: "left", binding: "consume-or-expel-pane-left", key: 'H', focus: "pane-3", wantTarget: "pane-3"},
85 {name: "right", binding: "consume-or-expel-pane-right", key: 'L', focus: "pane-1", wantTarget: "pane-1"},
86 }
87
88 for _, tt := range tests {
89 t.Run(tt.name, func(t *testing.T) {
90 h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
91 h.tab.mu.Lock()
92 h.tab.tree.Focus = tt.focus
93 beforeGeneration := h.tab.layoutGeneration
94 h.tab.mu.Unlock()
95 bindings, warnings := keys.BuildBindings(map[string]string{tt.binding: "alt+" + string(tt.key)})
96 require.Empty(t, warnings)
97 h.daemon.bindings.Store(bindings)
98 ac := &attachedClient{}
99 ac.setSession(h.session)
100 ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings)
101 h.session.mu.Lock()
102 h.session.registerAttachmentLocked(ac)
103 h.session.mu.Unlock()
104 invalidations := make(chan renderInvalidation, 1)
105 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
106 rc.attach(ac)
107 h.session.installRenderCoordinator(rc)
108
109 h.daemon.handleInput(h.session, ac, []byte{keys.ESC, tt.key})
110
111 h.tab.mu.Lock()
112 require.Equal(t, tt.wantTarget, h.tab.tree.Focus)
113 require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration)
114 require.Len(t, h.tab.tree.Root.Children, 2)
115 h.tab.mu.Unlock()
116 awaitInvalidation(t, invalidations)
117 requireNoInvalidation(t, invalidations)
118 })
119 }
120}
121
122func TestConfiguredConsumeOrExpelEdgeActionIsSilent(t *testing.T) {
123 h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
124 bindings, warnings := keys.BuildBindings(map[string]string{"consume-or-expel-pane-left": "alt+H"})
125 require.Empty(t, warnings)
126 h.daemon.bindings.Store(bindings)
127 ac := &attachedClient{}
128 ac.setSession(h.session)
129 ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings)
130 h.session.mu.Lock()
131 h.session.registerAttachmentLocked(ac)
132 h.session.mu.Unlock()
133 invalidations := make(chan renderInvalidation, 1)
134 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
135 rc.attach(ac)
136 h.session.installRenderCoordinator(rc)
137 before := h.snapshot()
138
139 h.daemon.handleInput(h.session, ac, []byte{keys.ESC, 'H'})
140
141 require.Equal(t, before, h.snapshot())
142 require.Empty(t, h.daemon.notices.history())
143 requireNoInvalidation(t, invalidations)
144}
145
146func TestConsumeOrExpelKeyActionPreservesRearrangementWarning(t *testing.T) {
147 tests := []struct {
148 name string
149 action keys.Action
150 }{
151 {name: "left", action: keys.ActionConsumeOrExpelPaneLeft},
152 {name: "right", action: keys.ActionConsumeOrExpelPaneRight},
153 }
154
155 for _, tt := range tests {
156 t.Run(tt.name, func(t *testing.T) {
157 d := newTestDaemon(t, nil, stubClock{})
158 sess := addControlSession(d, "work", "t_work", "p_work")
159 ac := &attachedClient{}
160 ac.setSession(sess)
161 runner := newMockdaemonActionRunner(t)
162 runner.EXPECT().Run(mock.Anything).Return(domain.UserWarn(
163 domain.NoticeLayoutTooSmall,
164 "not enough space to rearrange pane",
165 layout.ErrTooSmall,
166 )).Once()
167
168 daemonKeyHandler{d: d, ac: ac, actions: runner}.Action(tt.action, nil)
169
170 history := d.notices.history()
171 require.Len(t, history, 1)
172 require.Equal(t, domain.NoticeLayoutTooSmall, history[0].Code)
173 require.Equal(t, domain.NoticeWarn, history[0].Severity)
174 require.Equal(t, "not enough space to rearrange pane", history[0].Message)
175 })
176 }
177}
178
179// stubClock returns timers whose channel never fires, so a scheduler under it
180// blocks in its debounce loop until the session context is cancelled. Used by
181
182func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
183 d, sess, ac, sends := newManualSessionWithPTYs(t, nil, nil)
184 source := sess.tabs[0].focusedPane()
185 target := sess.tabs[1].focusedPane()
186 require.Equal(t, source.id, target.id, "tabs deliberately reuse pane-1")
187
188 source.mu.Lock()
189 source.screen.Write([]byte("source"))
190 source.mu.Unlock()
191 d.paint(sess, ac, true, nil)
192 first := awaitFrame(t, sends, "Output")
193 firstOutput := unmarshalTestOutput(t, first.Payload)
194 terminal := vt.NewScreen(ac.size.Cols, ac.size.Rows)
195 terminal.Write(firstOutput.Data)
196
197 // A clean pane relies on the attachment capture cache for its retained
198 // snapshot. Both tabs deliberately use the tab-local default pane ID.
199 target.mu.Lock()
200 target.screen.ClearDamage()
201 target.mu.Unlock()
202 // Use the real key action: it requests the mandatory complete repaint for
203 // the first target-tab frame.
204 daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2, nil)
205 require.Equal(t, 1, testAttachmentTabIndex(sess))
206
207 second := awaitFrame(t, sends, "Output")
208 secondOutput := unmarshalTestOutput(t, second.Payload)
209 require.Zero(t, secondOutput.Base, "tab switch must emit the complete target frame first")
210 terminal.Write(secondOutput.Data)
211 require.NotContains(t, strings.Join(frameRows(terminal), "\n"), "source", "the first target-tab frame must not retain source pane cells")
212}
213
214func TestClosePanePrunesAttachedCaptureFrameAndKeepsSurvivor(t *testing.T) {
215 d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
216 tb := sess.tabs[0]
217 survivor := tb.panes["pane-1"]
218 closed := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 23})
219 tb.mu.Lock()
220 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
221 tb.panes[closed.id] = closed
222 tb.mu.Unlock()
223
224 cachedSurvivor := capturedPaneRenderState{title: "survivor"}
225 ac.render.panes = map[*pane]capturedPaneRenderState{
226 survivor: cachedSurvivor,
227 closed: {title: "closed"},
228 }
229
230 require.NoError(t, d.closePane(sess, tb, closed.id, ac, false))
231 require.NotContains(t, ac.render.panes, closed, "closed pane must not remain strongly retained by its attachment")
232 require.Equal(t, cachedSurvivor, ac.render.panes[survivor], "surviving pane keeps its incremental capture")
233}
234
235func TestCloseTabPrunesAttachedCaptureFrames(t *testing.T) {
236 d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
237 closedTab, survivorTab := sess.tabs[0], sess.tabs[1]
238 closed := closedTab.panes["pane-1"]
239 survivor := survivorTab.panes["pane-1"]
240 cachedSurvivor := capturedPaneRenderState{title: "survivor"}
241 ac.render.panes = map[*pane]capturedPaneRenderState{
242 closed: {title: "closed"},
243 survivor: cachedSurvivor,
244 }
245
246 require.NoError(t, d.closeTab(sess, closedTab, false))
247 require.NotContains(t, ac.render.panes, closed, "closed tab panes must not remain strongly retained by its attachment")
248 require.Equal(t, cachedSurvivor, ac.render.panes[survivor], "surviving tab pane keeps its incremental capture")
249}
250
251func TestAltCForwardsToPTY(t *testing.T) {
252 writes := make(chan []byte, 2)
253 p, releasePTY := newBlockingPTYWithWrites(t, writes)
254 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
255
256 d.handleInput(sess, ac, []byte("\x1bc"))
257
258 requirePTYWrite(t, writes, []byte("\x1bc"))
259 releasePTY()
260}
261
262// TestWriteToPaneFailureNotifiesDroppedInput drives forwarded keystrokes into
263// a pane whose pty.Write always fails (a wedged pty, or a dead child). The
264// user's typing must not silently vanish: each failed write records a
265// NoticeInputDropped notice, and repeated failures on the same code coalesce
266// into a single toast with a rising count rather than spamming one per key.
267func TestWriteToPaneFailureNotifiesDroppedInput(t *testing.T) {
268 writeErr := errors.New("write /dev/ptmx: input/output error")
269 p, releasePTY := newBlockingPTYWithFailingWrite(t, writeErr)
270 defer releasePTY()
271 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
272
273 for range 5 {
274 d.handleInput(sess, ac, []byte("x"))
275 }
276
277 toasts := awaitToastCount(t, ac, 1)
278 require.Equal(t, domain.NoticeInputDropped, toasts[0].Code)
279 require.Equal(t, domain.NoticeError, toasts[0].Severity)
280 require.Equal(t, "input not delivered to pane", toasts[0].Message)
281 require.Equal(t, 5, toasts[0].Count, "5 failed writes must coalesce into one toast, not one per keystroke")
282
283 hist := d.notices.history()
284 require.Len(t, hist, 5, "coalescing is display-only; history keeps every occurrence")
285}
286
287func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) {
288 normal, releaseNormal := newBlockingPTY(t)
289 floatingPTY, releaseFloating := newBlockingPTY(t)
290 defer releaseNormal()
291 defer releaseFloating()
292 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
293 client := vt.NewScreen(80, 25)
294 floatingCtx, cancelFloating := context.WithCancel(sess.ctx)
295 defer cancelFloating()
296 floating := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5})
297 floating.ctx = floatingCtx
298 floating.screen.Write([]byte("popup-content"))
299 installTestFloating(testAttachmentTab(sess), floating, false)
300
301 // Establish the client shadow while the retained popup is hidden.
302 d.paint(sess, ac, true, nil)
303 mustApplyOutput(t, client, awaitFrame(t, sends, "Output"))
304
305 // Route the real Alt+F binding. Showing a retained pane must paint once.
306 d.handleInput(sess, ac, []byte("\x1bf"))
307 mustApplyOutput(t, client, awaitFrame(t, sends, "Output"))
308 require.Contains(t, strings.Join(frameRows(client), "\n"), "popup-content")
309 require.Contains(t, strings.Join(frameRows(client), "\n"), "┌")
310 select {
311 case extra := <-sends:
312 t.Fatalf("show emitted duplicate output: %#v", extra)
313 default:
314 }
315
316 // The second real key must repaint immediately, rather than waiting for
317 // unrelated output, while retaining the existing PTY and context.
318 d.handleInput(sess, ac, []byte("\x1bf"))
319 mustApplyOutput(t, client, awaitFrame(t, sends, "Output"))
320 require.NotContains(t, strings.Join(frameRows(client), "\n"), "popup-content")
321 tb := testAttachmentTab(sess)
322 tb.mu.Lock()
323 require.Equal(t, floatingHidden, tb.floating.state)
324 require.Same(t, floating, tb.floating.pane)
325 generation := tb.floating.generation
326 tb.mu.Unlock()
327 require.Same(t, floatingPTY, floating.pty)
328 select {
329 case <-floating.ctx.Done():
330 t.Fatal("hiding retained popup cancelled its context")
331 default:
332 }
333
334 // A third show uses the installed pane; it must not launch or emit a second
335 // resize/start frame.
336 d.handleInput(sess, ac, []byte("\x1bf"))
337 mustApplyOutput(t, client, awaitFrame(t, sends, "Output"))
338 require.Contains(t, strings.Join(frameRows(client), "\n"), "popup-content")
339 tb.mu.Lock()
340 require.Equal(t, floatingVisible, tb.floating.state)
341 require.Same(t, floating, tb.floating.pane)
342 require.Equal(t, generation, tb.floating.generation)
343 tb.mu.Unlock()
344 select {
345 case extra := <-sends:
346 t.Fatalf("retained show emitted duplicate output: %#v", extra)
347 default:
348 }
349}
350
351func requireFloatingInitialized(t *testing.T, tb *tab) {
352 t.Helper()
353 tb.mu.Lock()
354 defer tb.mu.Unlock()
355 require.NotEqual(t, floatingUninitialized, tb.floating.state)
356}
357
358func installTestFloating(tb *tab, p *pane, visible bool) {
359 tb.mu.Lock()
360 defer tb.mu.Unlock()
361 tb.floating.pane = p
362 tb.floating.generation++
363 if visible {
364 tb.floating.state = floatingVisible
365 } else {
366 tb.floating.state = floatingHidden
367 }
368}
369
370func TestFloatingKeyboardRoutesVisibleAndHiddenInput(t *testing.T) {
371 for _, tc := range []struct {
372 name string
373 visible bool
374 input []byte
375 }{
376 {name: "visible ordinary bytes", visible: true, input: []byte("hello")},
377 {name: "visible escape", visible: true, input: []byte("\x1b")},
378 {name: "hidden ordinary bytes", visible: false, input: []byte("hello")},
379 {name: "hidden escape", visible: false, input: []byte("\x1b")},
380 } {
381 t.Run(tc.name, func(t *testing.T) {
382 normalWrites := make(chan []byte, 1)
383 floatingWrites := make(chan []byte, 1)
384 normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
385 floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites)
386 defer releaseNormal()
387 defer releaseFloating()
388 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
389 installTestFloating(testAttachmentTab(sess), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), tc.visible)
390
391 daemonKeyHandler{d: d, ac: ac}.Forward(tc.input)
392 if tc.visible {
393 requirePTYWrite(t, floatingWrites, tc.input)
394 requireNoPTYWrite(t, normalWrites)
395 } else {
396 requirePTYWrite(t, normalWrites, tc.input)
397 requireNoPTYWrite(t, floatingWrites)
398 }
399 })
400 }
401}
402
403func TestFloatingBracketedPasteAndGlobalActions(t *testing.T) {
404 normalWrites := make(chan []byte, 1)
405 floatingWrites := make(chan []byte, 1)
406 normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
407 floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites)
408 defer releaseNormal()
409 defer releaseFloating()
410 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
411 installTestFloating(testAttachmentTab(sess), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true)
412
413 paste := []byte("\x1b[200~paste\x1bf\x1b[201~")
414 d.handleInput(sess, ac, paste)
415 requirePTYWrite(t, floatingWrites, paste)
416 d.handleInput(sess, ac, []byte("\x1b "))
417 require.True(t, ac.overlays.paletteActive(), "global palette action must still intercept")
418 requireNoPTYWrite(t, normalWrites)
419}
420
421func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) {
422 writes := make(chan []byte, 1)
423 floatingWrites := make(chan []byte, 1)
424 normal, releaseNormal := newBlockingPTYWithWrites(t, writes)
425 floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites)
426 defer releaseNormal()
427 defer releaseFloating()
428 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
429 tb := testAttachmentTab(sess)
430 second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5})
431 tb.mu.Lock()
432 tb.size = domain.Size{Cols: 81, Rows: 5}
433 tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
434 tb.panes["pane-2"] = second
435 tb.mu.Unlock()
436 installTestFloating(tb, newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true)
437
438 daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneRight, nil)
439 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
440 daemonKeyHandler{d: d, ac: ac}.Forward([]byte("x"))
441 requirePTYWrite(t, floatingWrites, []byte("x"))
442}
443
444// TestActionFocusPaneAtEdgeStaysSilent drives a directional focus move with
445// no pane on that side (the session has a single, unsplit pane). This is
446// routine navigation, not a failure, and must never produce a notice.
447func TestActionFocusPaneAtEdgeStaysSilent(t *testing.T) {
448 d, _, ac, _ := newManualSessionWithPTYs(t, nil)
449
450 daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft, nil)
451
452 require.Empty(t, d.notices.history(), "no-neighbor focus move must stay silent")
453}
454
455// TestActionFocusPaneGenuineErrorReportsNoticeInternal drives a directional
456// focus move against a tab with no layout tree at all — a genuine structural
457// error distinct from "no pane in that direction" — and asserts it reaches
458// the user as a notice instead of being silently discarded.
459func TestActionFocusPaneGenuineErrorReportsNoticeInternal(t *testing.T) {
460 d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
461 tb := testAttachmentTab(sess)
462 tb.mu.Lock()
463 tb.tree = nil
464 tb.mu.Unlock()
465
466 daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft, nil)
467
468 history := d.notices.history()
469 require.Len(t, history, 2, "the layout error and its failed direct display update must both surface")
470 require.Equal(t, domain.NoticeInternal, history[0].Code)
471 require.Equal(t, "display update failed", history[0].Message)
472 require.Equal(t, domain.NoticeInternal, history[1].Code)
473 require.Equal(t, "internal error", history[1].Message)
474}
475
476func TestFloatingVisibilityRemainsIndependentAcrossTabSwitches(t *testing.T) {
477 d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
478 first := testAttachmentTab(sess)
479 second := newTab(nil, first.size)
480 installTestFloating(first, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
481 installTestFloating(second, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), false)
482 sess.mu.Lock()
483 sess.tabs = append(sess.tabs, second)
484 sess.mu.Unlock()
485
486 daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2, nil)
487 second.mu.Lock()
488 require.Equal(t, floatingHidden, second.floating.state)
489 second.mu.Unlock()
490
491 daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab1, nil)
492 first.mu.Lock()
493 require.Equal(t, floatingVisible, first.floating.state)
494 first.mu.Unlock()
495}
496
497func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) {
498 normal := portsmocks.NewMockPTY(t)
499 floatingPTY := portsmocks.NewMockPTY(t)
500 floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
501 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
502 tb := testAttachmentTab(sess)
503 tb.mu.Lock()
504 tb.size = domain.Size{Cols: 100, Rows: 20}
505 tb.mu.Unlock()
506 floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14})
507 floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
508 installTestFloating(tb, floating, true)
509 geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())
510 raw := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1)
511
512 d.handleInput(sess, ac, raw)
513}
514
515func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) {
516 normal := portsmocks.NewMockPTY(t)
517 floatingPTY := portsmocks.NewMockPTY(t)
518 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
519 tb := testAttachmentTab(sess)
520 tb.mu.Lock()
521 tb.size = domain.Size{Cols: 100, Rows: 20}
522 tb.mu.Unlock()
523 floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14})
524 floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
525 installTestFloating(tb, floating, true)
526 geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())
527
528 border := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+clientTopBarRows+1)
529 outside := []byte("\x1b[<0;1;2M")
530 d.handleInput(sess, ac, border)
531 d.handleInput(sess, ac, outside)
532}
533
534func TestResponsiveDrawerFloatingMouseRoutesOnlyInnerClientCells(t *testing.T) {
535 normal := portsmocks.NewMockPTY(t)
536 floatingPTY := portsmocks.NewMockPTY(t)
537 floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
538 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
539 tb := testAttachmentTab(sess)
540 complete := domain.Size{Cols: 79, Rows: 25}
541 content := tabSize(complete)
542 geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig())
543 require.Equal(t, ui.PresentationDrawer, geometry.Mode)
544 tb.mu.Lock()
545 tb.size = content
546 tb.mu.Unlock()
547 floating := newPane("floating", floatingPTY, rectSize(geometry.Inner))
548 floating.popupGeometry = geometry
549 floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
550 installTestFloating(tb, floating, true)
551
552 firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1)
553 d.handleInput(sess, ac, firstInner)
554 for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} {
555 d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1))
556 }
557}
558
559func TestResponsiveDrawerCopyMouseMapsInnerAndPinsDragAwayFromChrome(t *testing.T) {
560 p, release := newBlockingPTY(t)
561 defer release()
562 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
563 tb := testAttachmentTab(sess)
564 complete := domain.Size{Cols: 79, Rows: 25}
565 content := tabSize(complete)
566 geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig())
567 tb.mu.Lock()
568 tb.size = content
569 tb.mu.Unlock()
570 floating := newPane("floating", nil, rectSize(geometry.Inner))
571 floating.popupGeometry = geometry
572 floating.screen.Write([]byte("drawer row zero\r\nsecond row"))
573 installTestFloating(tb, floating, true)
574
575 for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} {
576 d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1))
577 ac.overlays.copyMu.Lock()
578 require.False(t, ac.overlays.copyPointer.valid, "frame row %d is drawer chrome, not copy content", frameRow)
579 ac.overlays.copyMu.Unlock()
580 }
581
582 firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1)
583 d.handleInput(sess, ac, firstInner)
584 ac.overlays.copyMu.Lock()
585 pointer := ac.overlays.copyPointer
586 ac.overlays.copyMu.Unlock()
587 require.True(t, pointer.valid)
588 require.Same(t, floating, pointer.pane)
589 require.Equal(t, scopy.Pos{Row: 0, Col: 0}, pointer.press)
590 require.Equal(t, copyContentToClientRect(geometry.Inner), pointer.geometry.content)
591
592 // Motion onto the protected top row clamps against the press-owned drawer
593 // geometry instead of retargeting the underlying tiled terminal.
594 d.handleInput(sess, ac, []byte("\x1b[<32;79;1M"))
595 ac.overlays.copyMu.Lock()
596 require.NotNil(t, ac.overlays.copyMode)
597 require.Same(t, floating, ac.overlays.copyPane)
598 require.Same(t, floating, ac.overlays.copyPointer.pane)
599 require.Equal(t, pointer.geometry, ac.overlays.copyPointer.geometry)
600 selection := ac.overlays.copyMode.Selection()
601 ac.overlays.copyMu.Unlock()
602 require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor)
603 require.Equal(t, 0, selection.Active.Row, "drag above the drawer must stay pinned to its first document row")
604}
605
606func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
607 writes := make(chan []byte, 1)
608 p, releasePTY := newBlockingPTYWithWrites(t, writes)
609 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
610 pane := sess.tabs[0].focusedPane()
611 pane.screen.Write([]byte("\x1b[?2004h"))
612 require.True(t, pane.screen.BracketedPasteMode())
613
614 paste := []byte("\x1b[200~first line\nsecond line\n\x1b[201~")
615 d.handleInput(sess, ac, paste)
616
617 requirePTYWrite(t, writes, paste)
618 releasePTY()
619}
620
621func TestOverlayInputPrecedence(t *testing.T) {
622 cases := []struct {
623 name string
624 setup func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan wire.Envelope)
625 input []byte
626 check func(t *testing.T, ac *attachedClient, writes chan []byte)
627 }{
628 {
629 name: "prompt before palette copy and normal",
630 setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan wire.Envelope) {
631 t.Helper()
632 d.enterCopyMode(sess, ac)
633 d.enterPalette(sess, ac)
634 d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil })
635 },
636 input: []byte("x"),
637 check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
638 t.Helper()
639 require.Equal(t, "x", ac.overlays.prompt.Value())
640 require.Equal(t, "", ac.overlays.palette.Query())
641 require.True(t, ac.overlays.copyActive())
642 requireNoPTYWrite(t, writes)
643 },
644 },
645 {
646 name: "palette before copy and normal",
647 setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan wire.Envelope) {
648 t.Helper()
649 d.enterCopyMode(sess, ac)
650 d.enterPalette(sess, ac)
651 },
652 input: []byte("x"),
653 check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
654 t.Helper()
655 require.Equal(t, "x", ac.overlays.palette.Query())
656 require.True(t, ac.overlays.copyActive())
657 requireNoPTYWrite(t, writes)
658 },
659 },
660 {
661 name: "copy before normal",
662 setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan wire.Envelope) {
663 t.Helper()
664 d.enterCopyMode(sess, ac)
665 },
666 input: []byte("q"),
667 check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
668 t.Helper()
669 require.False(t, ac.overlays.copyActive())
670 requireNoPTYWrite(t, writes)
671 },
672 },
673 {
674 name: "normal keys when no overlay is active",
675 input: []byte("Z"),
676 check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
677 t.Helper()
678 requirePTYWrite(t, writes, []byte("Z"))
679 },
680 },
681 }
682
683 for _, tc := range cases {
684 t.Run(tc.name, func(t *testing.T) {
685 writes := make(chan []byte, 1)
686 p, releasePTY := newBlockingPTYWithWrites(t, writes)
687 p2, releasePTY2 := newBlockingPTY(t)
688 d, sess, ac, sends := newManualSessionWithPTYs(t, p, p2)
689 defer releasePTY()
690 defer releasePTY2()
691 if tc.setup != nil {
692 tc.setup(t, d, sess, ac, sends)
693 }
694
695 d.handleInput(sess, ac, tc.input)
696
697 tc.check(t, ac, writes)
698 })
699 }
700}
701
702func requirePTYWrite(t *testing.T, writes chan []byte, want []byte) {
703 t.Helper()
704 select {
705 case got := <-writes:
706 require.Equal(t, want, got)
707 case <-time.After(time.Second):
708 t.Fatalf("PTY did not receive %q", want)
709 }
710}
711
712func requireNoPTYWrite(t *testing.T, writes chan []byte) {
713 t.Helper()
714 select {
715 case got := <-writes:
716 t.Fatalf("input forwarded to PTY: %q", got)
717 default:
718 }
719}
720
721func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
722 cases := []struct {
723 name string
724 start int
725 query []byte
726 wantIndex int
727 }{
728 {name: "next advances", start: 0, query: []byte("NXT\r"), wantIndex: 1},
729 {name: "next wraps", start: 2, query: []byte("NXT\r"), wantIndex: 0},
730 {name: "previous moves back", start: 2, query: []byte("PVT\r"), wantIndex: 1},
731 {name: "previous wraps", start: 0, query: []byte("PVT\r"), wantIndex: 2},
732 }
733
734 for _, tc := range cases {
735 t.Run(tc.name, func(t *testing.T) {
736 d, sess, ac, _, releases := newManualTabSession(t, 3)
737 defer func() {
738 for _, release := range releases {
739 release()
740 }
741 }()
742 selectTestAttachmentTab(sess, tc.start)
743
744 d.handleInput(sess, ac, []byte("\x1b "))
745 d.handleInput(sess, ac, tc.query)
746
747 require.Equal(t, tc.wantIndex, testAttachmentTabIndex(sess))
748 })
749 }
750}
751
752func TestPaletteBackSessionSendsClientPreviousRouteAction(t *testing.T) {
753 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
754 defer releaseAll(releases)
755 transport := ac.transport()
756 rc := d.attachCoordinator(current, nil, ac, true)
757 token := current.captureAttachmentCapability(ac, transport)
758 token.lease = rc.attachmentLease(ac)
759 ac.installTestAttachmentCapability(token)
760 effect, admitted := ac.beginAttachmentEffect(token)
761 require.True(t, admitted)
762 defer effect.End()
763 ac.setRouteSnapshot(protocol.RecentRouteSnapshot{
764 Generation: 3,
765 Active: protocol.RouteRef{Key: 3, Generation: 3},
766 Previous: protocol.RouteRef{Key: 2, Generation: 2},
767 Entries: []protocol.RecentRouteEntry{testRouteEntry(2, 2, "recent", 2, protocol.RouteKindLocal)},
768 })
769
770 require.NoError(t, d.backSessionForAttachment(effect))
771 frame := awaitFrame(t, sends, "RouteNavigationAction")
772 action := decodeServerMessage(t, frame).(protocol.RouteNavigationAction)
773 require.Equal(t, protocol.RouteNavigationAction{SnapshotGeneration: 3, Key: 2, Generation: 2}, action)
774}
775
776func TestBackSessionWithoutClientHistoryIsNoop(t *testing.T) {
777 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
778 defer releaseAll(releases)
779
780 d.backSession(current, ac)
781
782 require.Same(t, current, ac.currentSession())
783 require.Empty(t, d.notices.history())
784 requireNoOutputFrame(t, sends)
785}
786
787func TestSwitchSourceDoesNotOwnPreviousRoute(t *testing.T) {
788 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
789 defer releaseAll(releases)
790 recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
791
792 require.NoError(t, d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1}))
793 require.Same(t, recent, ac.currentSession())
794 require.Equal(t, uint64(0), ac.routeSnapshotCopy().Generation, "route ownership starts in the client")
795}
796
797func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan wire.Envelope, []func()) {
798 t.Helper()
799 p1, release1 := newBlockingPTY(t)
800 p2, release2 := newBlockingPTY(t)
801 p3, release3 := newBlockingPTY(t)
802 d, current, ac, sends := newManualSessionWithPTYs(t, p1)
803 current.id = "current"
804 delete(d.sessions, domain.SessionID("manual"))
805 d.sessions[current.id] = current
806 recent := &session{sessionCore: sessionCore{id: "recent", name: "recent", incarnation: newTestLifecycle(t)}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
807 older := &session{sessionCore: sessionCore{id: "older", name: "older", incarnation: newTestLifecycle(t)}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}}
808 d.sessions[recent.id] = recent
809 d.sessions[older.id] = older
810 current.mruAt.Store(30)
811 recent.mruAt.Store(20)
812 older.mruAt.Store(10)
813 return d, current, ac, sends, []func(){release1, release2, release3}
814}
815
816func releaseAll(releases []func()) {
817 for _, release := range releases {
818 release()
819 }
820}
821
822func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
823 writes := make(chan []byte, 1)
824 p1, releasePTY1 := newBlockingPTY(t)
825 p2, releasePTY2 := newBlockingPTY(t)
826 p3, releasePTY3 := newBlockingPTYWithWrites(t, writes)
827 d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2, p3)
828 selectTestAttachmentTab(sess, 1)
829
830 d.handleInput(sess, ac, []byte("\x1b "))
831 d.handleInput(sess, ac, []byte("CLT\r"))
832
833 require.Equal(t, 1, sessionCount(d))
834 require.Len(t, sess.tabs, 2)
835 require.Equal(t, 1, testAttachmentTabIndex(sess), "closing middle tab selects the next remaining tab")
836 d.handleInput(sess, ac, []byte("Z"))
837 require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond)
838 requirePTYWrite(t, writes, []byte("Z"))
839
840 releasePTY1()
841 releasePTY2()
842 releasePTY3()
843}
844
845func TestAltDDetachesCurrentClient(t *testing.T) {
846 d, sess, ac, sends, releases := newManualTabSession(t, 1)
847 defer releases[0]()
848
849 d.handleInput(sess, ac, []byte("\x1b "))
850 d.handleInput(sess, ac, []byte("DET\r"))
851
852 require.Empty(t, sess.snapshotAttachments())
853 f := awaitFrame(t, sends, "Detached")
854 det := decodeServerMessage(t, f).(protocol.Detached)
855 require.Equal(t, protocol.ReasonDetach, det.Reason)
856}
857
858func TestRNSOpensPromptAndEnterPromotesEphemeralSession(t *testing.T) {
859 d, sess, ac, sends, releases := newManualTabSession(t, 1)
860 defer releases[0]()
861 sess.ephemeral = true
862 sess.name = "0"
863
864 d.handleInput(sess, ac, []byte("\x1b "))
865 awaitFrame(t, sends, "Output")
866 d.handleInput(sess, ac, []byte("RNS\r"))
867 awaitFrame(t, sends, "Output")
868 require.True(t, ac.overlays.promptActive())
869
870 d.handleInput(sess, ac, []byte("\r"))
871 awaitFrame(t, sends, "Output")
872
873 require.False(t, ac.overlays.promptActive())
874 require.False(t, sess.ephemeral)
875 require.Equal(t, "0", sess.name)
876}
877
878func TestRNTOpensPromptAndRenamesActiveTab(t *testing.T) {
879 d, sess, ac, sends, releases := newManualTabSession(t, 2)
880 defer func() {
881 for _, release := range releases {
882 release()
883 }
884 }()
885 selectTestAttachmentTab(sess, 1)
886
887 d.handleInput(sess, ac, []byte("\x1b "))
888 awaitFrame(t, sends, "Output")
889 d.handleInput(sess, ac, []byte("RNT\r"))
890 out := awaitFrame(t, sends, "Output")
891 require.True(t, ac.overlays.promptActive())
892 msg := unmarshalTestOutput(t, out.Payload)
893 require.Contains(t, string(msg.Data), "> 2")
894
895 d.handleInput(sess, ac, []byte("\x7flogs\r"))
896 awaitFrame(t, sends, "Output")
897
898 require.False(t, ac.overlays.promptActive())
899 require.Equal(t, "logs", sess.tabs[1].name)
900 require.Empty(t, sess.tabs[0].name)
901}
902
903func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
904 writes := make(chan []byte, 4)
905 p, _ := newBlockingPTYWithWrites(t, writes)
906 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
907 win := sess.tabs[0]
908 win.focusedPane().screen.Write([]byte("live"))
909
910 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
911 data := mustOutputData(t, sends)
912 require.NotNil(t, ac.overlays.copyMode)
913 require.Equal(t, 19, ac.overlays.copyMode.Cursor().Row)
914 require.Contains(t, string(data), "[SCROLL]")
915
916 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
917 mustOutputData(t, sends)
918 require.Nil(t, ac.overlays.copyMode)
919
920 d.handleInput(sess, ac, []byte("\x1b[<64;1;1Mq"))
921 mustOutputData(t, sends)
922 mustOutputData(t, sends)
923 require.Nil(t, ac.overlays.copyMode, "q after wheel in same input must be routed after copy mode is entered")
924 select {
925 case got := <-writes:
926 t.Fatalf("mouse/copy input forwarded to PTY: %q", got)
927 default:
928 }
929}
930
931func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
932 writes := make(chan []byte, 2)
933 p, _ := newBlockingPTYWithWrites(t, writes)
934 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
935 sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1049h"))
936
937 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
938 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
939
940 requirePTYWrite(t, writes, []byte("\x1b[A\x1b[A\x1b[A"))
941 requirePTYWrite(t, writes, []byte("\x1b[B\x1b[B\x1b[B"))
942 require.Nil(t, ac.overlays.copyMode)
943}
944
945func TestSGRRowOffset(t *testing.T) {
946 t.Parallel()
947
948 cases := []struct {
949 name string
950 raw []byte
951 delta int
952 want []byte
953 }{
954 {name: "decrements press row", raw: []byte("\x1b[<0;2;3M"), delta: -1, want: []byte("\x1b[<0;2;2M")},
955 {name: "increments release row", raw: []byte("\x1b[<0;2;3m"), delta: 2, want: []byte("\x1b[<0;2;5m")},
956 {name: "leaves empty unchanged", raw: []byte(""), delta: -1, want: []byte("")},
957 {name: "leaves non sgr unchanged", raw: []byte("abc"), delta: -1, want: []byte("abc")},
958 {name: "leaves malformed fields unchanged", raw: []byte("\x1b[<0;2M"), delta: -1, want: []byte("\x1b[<0;2M")},
959 {name: "leaves non numeric row unchanged", raw: []byte("\x1b[<0;2;xM"), delta: -1, want: []byte("\x1b[<0;2;xM")},
960 {name: "leaves invalid shifted row unchanged", raw: []byte("\x1b[<0;2;1M"), delta: -1, want: []byte("\x1b[<0;2;1M")},
961 {name: "handles digit width increase", raw: []byte("\x1b[<0;2;9M"), delta: 1, want: []byte("\x1b[<0;2;10M")},
962 {name: "handles digit width decrease", raw: []byte("\x1b[<0;2;10m"), delta: -1, want: []byte("\x1b[<0;2;9m")},
963 }
964
965 for _, tc := range cases {
966 t.Run(tc.name, func(t *testing.T) {
967 require.Equal(t, tc.want, sgrRowOffset(tc.raw, tc.delta))
968 })
969 }
970}
971
972func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
973 writes := make(chan []byte, 4)
974 p, _ := newBlockingPTYWithWrites(t, writes)
975 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
976
977 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
978 select {
979 case got := <-writes:
980 t.Fatalf("press without child mouse mode forwarded: %q", got)
981 default:
982 }
983 require.Nil(t, ac.overlays.copyMode, "press alone must not enter visual mode")
984
985 sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1000h"))
986 raw := []byte("\x1b[<0;2;3M")
987 d.handleInput(sess, ac, raw)
988 select {
989 case got := <-writes:
990 t.Fatalf("SGR report forwarded to child without SGR mode: %q", got)
991 default:
992 }
993
994 sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006h"))
995 d.handleInput(sess, ac, raw)
996 requirePTYWrite(t, writes, []byte("\x1b[<0;2;2M"))
997
998 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
999 select {
1000 case got := <-writes:
1001 t.Fatalf("top-row mouse report forwarded: %q", got)
1002 default:
1003 }
1004
1005 statusRowReport := []byte("\x1b[<0;1;25M")
1006 d.handleInput(sess, ac, statusRowReport)
1007 select {
1008 case got := <-writes:
1009 t.Fatalf("status-row mouse report forwarded: %q", got)
1010 default:
1011 }
1012
1013 sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006l\x1b[?1000l"))
1014 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1015 require.Nil(t, ac.overlays.copyMode, "press alone must still not enter visual mode")
1016 d.handleInput(sess, ac, []byte("\x1b[<32;1;4M"))
1017 mustOutputData(t, sends)
1018 require.NotNil(t, ac.overlays.copyMode)
1019 selection := ac.overlays.copyMode.Selection()
1020 require.True(t, selection.Enabled)
1021 lo, hi := selection.Anchor.Row, selection.Active.Row
1022 if lo > hi {
1023 lo, hi = hi, lo
1024 }
1025 require.Equal(t, 0, lo)
1026 require.Equal(t, 2, hi)
1027}
1028
1029func TestCopyModeMousePressReleaseWithoutMotionEmitsNoOSC52(t *testing.T) {
1030 p, release := newBlockingPTY(t)
1031 defer release()
1032 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1033 writeTestRow(sess.tabs[0].focusedPane().screen, 0, "alpha")
1034 d.enterCopyMode(sess, ac)
1035 mustOutputData(t, sends)
1036
1037 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<0;1;2m"))
1038 d.handleInput(sess, ac, []byte("y"))
1039
1040 for range 2 { // press repaint, then copy-mode exit repaint
1041 require.NotContains(t, string(mustOutputData(t, sends)), "\x1b]52;c;", "a click without motion has no selection to copy")
1042 }
1043 require.False(t, ac.overlays.copyActive())
1044}
1045
1046func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) {
1047 p, _ := newBlockingPTY(t)
1048 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1049 writeTestRow(sess.tabs[0].focusedPane().screen, 0, "alpha")
1050 d.enterCopyMode(sess, ac)
1051 mustOutputData(t, sends)
1052
1053 // SGR is one based: Cy2 is the first body row. Dragging from column 3
1054 // back to column 1 is the same stream as the forward endpoints 1..3.
1055 d.handleInput(sess, ac, []byte("\x1b[<0;4;2M\x1b[<32;2;2M"))
1056 mustOutputData(t, sends)
1057 d.handleInput(sess, ac, []byte("\x1b[<0;2;2m"))
1058 d.handleInput(sess, ac, []byte("y"))
1059 var msg protocol.Output
1060 require.Eventually(t, func() bool {
1061 frame := awaitFrame(t, sends, "Output")
1062 msg = unmarshalTestOutput(t, frame.Payload)
1063 return string(msg.Data) == string(scopy.OSC52("lph")[0])
1064 }, 2*time.Second, 5*time.Millisecond)
1065 require.Equal(t, scopy.OSC52("lph")[0], msg.Data)
1066 require.Nil(t, ac.overlays.copyPointer.pane)
1067}
1068
1069func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
1070 p, _ := newBlockingPTY(t)
1071 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1072 writeTestRow(sess.tabs[0].focusedPane().screen, 0, "alpha")
1073 writeTestRow(sess.tabs[0].focusedPane().screen, 1, "bravo")
1074 writeTestRow(sess.tabs[0].focusedPane().screen, 2, "charlie")
1075
1076 d.enterCopyMode(sess, ac)
1077 mustOutputData(t, sends)
1078 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M"))
1079 mustOutputData(t, sends)
1080 require.NotNil(t, ac.overlays.copyMode)
1081 selection := ac.overlays.copyMode.Selection()
1082 require.True(t, selection.Enabled)
1083 lo, hi := selection.Anchor.Row, selection.Active.Row
1084 if lo > hi {
1085 lo, hi = hi, lo
1086 }
1087 require.Equal(t, 0, lo)
1088 require.Equal(t, 1, hi)
1089
1090 d.handleInput(sess, ac, []byte("y"))
1091 data := ""
1092 require.Eventually(t, func() bool {
1093 data = string(mustOutputData(t, sends))
1094 return strings.HasPrefix(data, "\x1b]52;c;")
1095 }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data)
1096 decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a"))
1097 require.NoError(t, err)
1098 require.Equal(t, "alpha\nb", string(decoded))
1099 require.Nil(t, ac.overlays.copyMode)
1100
1101 exitPaint := string(mustOutputData(t, sends))
1102 require.NotContains(t, exitPaint, "[SELECT]")
1103}
1104
1105// TestMouseNormalScreenStatusRowClearsStalePressState covers a regression
1106// where a Press or Release landing on the status row (row >= childRows)
1107// returned before the inner event-type switch ran, leaving
1108// normalMousePressValid untouched. A later Motion on a content row would
1109// then resurrect the stale anchor and start a selection out of thin air.
1110// The fix requires Release to always clear the press state and Press on the
1111// status row to clear it too, so a following Motion is a no-op.
1112func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
1113 p, _ := newBlockingPTY(t)
1114 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1115 require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Rows(), "fixture assumption: status row is wire row 24")
1116
1117 // Press on a content row establishes a (soon to be stale) anchor.
1118 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1119 require.Nil(t, ac.overlays.copyMode)
1120
1121 // Release lands on the status row: must still clear the press state,
1122 // regardless of the row it landed on.
1123 d.handleInput(sess, ac, []byte("\x1b[<0;1;25m"))
1124
1125 // A brand new button hold starts with a Press on the status row: must
1126 // clear (not silently preserve) any prior press state.
1127 d.handleInput(sess, ac, []byte("\x1b[<0;1;25M"))
1128
1129 // Motion onto a content row must not resurrect a stale anchor.
1130 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
1131
1132 require.Nil(t, ac.overlays.copyMode, "stale press state must not resurrect a selection")
1133}
1134
1135// TestCopyModeStatusRowPressClearsDragState covers the copy-mode counterpart
1136// of the same bug class: copyMouse ignored any event whose row landed on the
1137// status row before updating copyPressRow/copyPressRowValid/copyDragging, so
1138// a Press on the status row left the previous drag state in place and a
1139// following Motion on a content row silently extended the old selection
1140// instead of being a no-op.
1141func TestCopyModeRejectedReleaseClearsPointerAndClickBeforeMotion(t *testing.T) {
1142 p, release := newBlockingPTY(t)
1143 defer release()
1144 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1145 d.enterCopyMode(sess, ac)
1146 mustOutputData(t, sends)
1147
1148 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1149 ac.overlays.copyMu.Lock()
1150 require.True(t, ac.overlays.copyPointer.valid)
1151 ac.overlays.copyMu.Unlock()
1152
1153 // Wire row 25 is below the focused pane content, so the release is not a
1154 // valid endpoint and must not leave a drag that later motion can revive.
1155 d.handleInput(sess, ac, []byte("\x1b[<0;1;25m\x1b[<32;1;3M"))
1156 ac.overlays.copyMu.Lock()
1157 require.False(t, ac.overlays.copyPointer.valid)
1158 selection := ac.overlays.copyMode.Selection()
1159 require.False(t, selection.Enabled)
1160 ac.overlays.copyMu.Unlock()
1161}
1162
1163func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
1164 p, _ := newBlockingPTY(t)
1165 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1166 writeTestRow(sess.tabs[0].focusedPane().screen, 0, "alpha")
1167 writeTestRow(sess.tabs[0].focusedPane().screen, 1, "bravo")
1168 writeTestRow(sess.tabs[0].focusedPane().screen, 2, "charlie")
1169 require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Rows(), "fixture assumption: status row is wire row 24")
1170
1171 d.enterCopyMode(sess, ac)
1172 mustOutputData(t, sends)
1173
1174 // Full drag: press row0, motion to row1 -> selection [0,1].
1175 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M"))
1176 mustOutputData(t, sends)
1177 require.NotNil(t, ac.overlays.copyMode)
1178 selection := ac.overlays.copyMode.Selection()
1179 require.True(t, selection.Enabled)
1180 lo, hi := selection.Anchor.Row, selection.Active.Row
1181 if lo > hi {
1182 lo, hi = hi, lo
1183 }
1184 require.Equal(t, 0, lo)
1185 require.Equal(t, 1, hi)
1186
1187 // Release, then a new button hold starting with Press on the status row.
1188 d.handleInput(sess, ac, []byte("\x1b[<0;1;3m"))
1189 d.handleInput(sess, ac, []byte("\x1b[<0;1;25M"))
1190
1191 // Motion onto a content row must be a no-op: the status-row press must
1192 // have cleared copyPressRowValid/copyDragging.
1193 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
1194
1195 require.NotNil(t, ac.overlays.copyMode)
1196 selection = ac.overlays.copyMode.Selection()
1197 require.True(t, selection.Enabled)
1198 lo, hi = selection.Anchor.Row, selection.Active.Row
1199 if lo > hi {
1200 lo, hi = hi, lo
1201 }
1202 require.Equal(t, 0, lo, "anchor must not have moved")
1203 require.Equal(t, 1, hi, "status-row press must invalidate the drag so the motion is a no-op")
1204}
1205
1206// TestMouseNormalScreenDragUsesPressOwnedDocumentAfterOutputEviction verifies
1207// that output eviction after Press does not change endpoint mapping: the
1208// immutable press-owned Document keeps pointer rows 0→1 mapped to rows 0→1.
1209func TestMouseNormalScreenDragUsesPressOwnedDocumentAfterOutputEviction(t *testing.T) {
1210 p, _ := newBlockingPTY(t)
1211 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1212 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 50})
1213 require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Rows(), "fixture assumption: status row is wire row 24")
1214
1215 // Press anchors document row 0 before output eviction.
1216 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1217
1218 // Output evicts five lines after Press; the Document remains immutable.
1219 for range 5 {
1220 appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("evicted"))
1221 }
1222
1223 // Motion maps the next endpoint through the press-owned Document.
1224 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
1225
1226 require.NotNil(t, ac.overlays.copyMode)
1227 selection := ac.overlays.copyMode.Selection()
1228 require.True(t, selection.Enabled)
1229 lo, hi := selection.Anchor.Row, selection.Active.Row
1230 if lo > hi {
1231 lo, hi = hi, lo
1232 }
1233 require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time")
1234 require.Equal(t, 1, hi, "the press-owned immutable document keeps the mapped endpoint stable")
1235}
1236
1237func TestMouseSplitReportPreservesOrder(t *testing.T) {
1238 writes := make(chan []byte, 2)
1239 p, _ := newBlockingPTYWithWrites(t, writes)
1240 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1241 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
1242
1243 d.handleInput(sess, ac, []byte("\x1b[<64;"))
1244 d.handleInput(sess, ac, []byte("1;1Mq"))
1245
1246 mustOutputData(t, sends)
1247 mustOutputData(t, sends)
1248 require.Nil(t, ac.overlays.copyMode)
1249 select {
1250 case got := <-writes:
1251 t.Fatalf("split mouse/copy bytes forwarded to PTY: %q", got)
1252 default:
1253 }
1254}
1255
1256func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing.T) {
1257 p1 := portsmocks.NewMockPTY(t)
1258 p1.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
1259 p2 := portsmocks.NewMockPTY(t)
1260 p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
1261 p2.EXPECT().Write([]byte("\x1b[<64;1;1M")).Return(len("\x1b[<64;1;1M"), nil).Once()
1262 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1263 d.procComm = nil
1264 tb := testAttachmentTab(sess)
1265 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
1266 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
1267 tb.mu.Lock()
1268 tb.size = domain.Size{Cols: 41, Rows: 5}
1269 tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1270 tb.tree.Focus = "pane-1"
1271 tb.panes["pane-2"] = p2pane
1272 tb.mu.Unlock()
1273
1274 d.handleInput(sess, ac, []byte("\x1b[<64;22;2M"))
1275
1276 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
1277}
1278
1279func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) {
1280 for _, tc := range []struct {
1281 name string
1282 setup func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, press *[]byte) []byte
1283 reject []byte
1284 }{
1285 {
1286 name: "split divider",
1287 setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte {
1288 tb := testAttachmentTab(sess)
1289 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
1290 tb.mu.Lock()
1291 tb.size = domain.Size{Cols: 41, Rows: 10}
1292 tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1293 tb.panes["pane-2"] = p2
1294 tb.mu.Unlock()
1295 return []byte("\x1b[<0;21;2m")
1296 },
1297 },
1298 {
1299 name: "split title bar",
1300 setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte {
1301 tb := testAttachmentTab(sess)
1302 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
1303 tb.mu.Lock()
1304 tb.size = domain.Size{Cols: 41, Rows: 10}
1305 tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1306 tb.panes["pane-2"] = p2
1307 tb.mu.Unlock()
1308 return []byte("\x1b[<0;1;1m")
1309 },
1310 },
1311 {
1312 name: "floating exterior",
1313 setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, press *[]byte) []byte {
1314 tb := testAttachmentTab(sess)
1315 floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
1316 installTestFloating(tb, floating, true)
1317 tb.mu.Lock()
1318 _, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
1319 tb.mu.Unlock()
1320 require.True(t, visible)
1321 *press = fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2)
1322 return []byte("\x1b[<0;1;1m")
1323 },
1324 },
1325 } {
1326 t.Run(tc.name, func(t *testing.T) {
1327 p, release := newBlockingPTY(t)
1328 defer release()
1329 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1330 press := []byte("\x1b[<0;1;2M")
1331 reject := tc.setup(t, d, sess, ac, &press)
1332
1333 // A content press is fresh state. The rejected release must clear it
1334 // before a following motion has an opportunity to publish copy mode.
1335 d.handleInput(sess, ac, press)
1336 ac.overlays.copyMu.Lock()
1337 require.True(t, ac.overlays.copyPointer.valid)
1338 ac.overlays.copyMu.Unlock()
1339
1340 d.handleInput(sess, ac, reject)
1341 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
1342
1343 ac.overlays.copyMu.Lock()
1344 require.False(t, ac.overlays.copyPointer.valid)
1345 require.Nil(t, ac.overlays.copyMode, "rejected release followed by motion must not publish")
1346 ac.overlays.copyMu.Unlock()
1347 })
1348 }
1349}
1350
1351func TestRejectedLeftPressAndInactiveReleaseInvalidateStalePointer(t *testing.T) {
1352 p, release := newBlockingPTY(t)
1353 defer release()
1354 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1355
1356 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1357 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) // a new rejected title-bar press
1358 ac.overlays.copyMu.Lock()
1359 require.False(t, ac.overlays.copyPointer.valid)
1360 ac.overlays.copyMu.Unlock()
1361
1362 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1363 ac.setSession(nil)
1364 d.handleMouse(ac, mouse.Event{Button: mouse.Left, Type: mouse.Release})
1365 ac.overlays.copyMu.Lock()
1366 require.False(t, ac.overlays.copyPointer.valid)
1367 ac.overlays.copyMu.Unlock()
1368}
1369
1370func TestPressAtFormerStackTitleRowIsTreatedAsExpandedContent(t *testing.T) {
1371 p, release := newBlockingPTY(t)
1372 defer release()
1373 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1374 tb := testAttachmentTab(sess)
1375 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 9})
1376 p3 := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 1})
1377 tb.mu.Lock()
1378 tb.size = domain.Size{Cols: 41, Rows: 10}
1379 tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
1380 layout.NewLeaf("pane-1"),
1381 {Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3")}, Expanded: "pane-2"},
1382 }}
1383 tb.tree.Focus = "pane-2"
1384 tb.panes["pane-2"] = p2
1385 tb.panes["pane-3"] = p3
1386 tb.mu.Unlock()
1387
1388 // pane-2 is the stack's expanded member, so it draws no title bar and its
1389 // content now starts at the row that used to hold that title bar (row 0 of
1390 // the stack, screen row 1, i.e. cy=2 in 1-based SGR coordinates). A content
1391 // press gives pane-2 a fresh candidate.
1392 d.handleInput(sess, ac, []byte("\x1b[<0;22;3M"))
1393 ac.overlays.copyMu.Lock()
1394 require.True(t, ac.overlays.copyPointer.valid)
1395 epoch := ac.overlays.copyPointerEpoch
1396 ac.overlays.copyMu.Unlock()
1397
1398 // Pressing the row that used to be the title bar must be routed as an
1399 // ordinary content press on the already-focused pane-2, not a title-bar
1400 // hit: it replaces the candidate with a fresh one rather than leaving a
1401 // stale/invalidated pointer, and focus is unchanged since pane-2 was
1402 // already focused.
1403 d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
1404 ac.overlays.copyMu.Lock()
1405 require.True(t, ac.overlays.copyPointer.valid)
1406 require.Greater(t, ac.overlays.copyPointerEpoch, epoch)
1407 ac.overlays.copyMu.Unlock()
1408 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus, "press on already-focused pane's content must preserve focus")
1409
1410 // A drag from that press is a legitimate content selection now (there is
1411 // no title bar to have blocked it), so it publishes copy mode.
1412 d.handleInput(sess, ac, []byte("\x1b[<32;22;4M"))
1413 ac.overlays.copyMu.Lock()
1414 require.NotNil(t, ac.overlays.copyMode, "drag from a content press must publish copy mode")
1415 ac.overlays.copyMu.Unlock()
1416}
1417
1418func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) {
1419 cases := []struct {
1420 name string
1421 raw []byte
1422 root *layout.Node
1423 size domain.Size
1424 }{
1425 {
1426 name: "divider",
1427 raw: []byte("\x1b[<0;21;2M"),
1428 root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}},
1429 size: domain.Size{Cols: 41, Rows: 5},
1430 },
1431 {
1432 name: "expanded title bar",
1433 raw: []byte("\x1b[<0;1;1M"),
1434 root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"},
1435 size: domain.Size{Cols: 20, Rows: 5},
1436 },
1437 }
1438 for _, tc := range cases {
1439 t.Run(tc.name, func(t *testing.T) {
1440 p1 := portsmocks.NewMockPTY(t)
1441 p1.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
1442 p2 := portsmocks.NewMockPTY(t)
1443 p2.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
1444 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1445 d.procComm = nil
1446 tb := testAttachmentTab(sess)
1447 tb.focusedPane().screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
1448 tb.mu.Lock()
1449 tb.size = tc.size
1450 tb.tree.Root = tc.root
1451 tb.tree.Focus = "pane-1"
1452 tb.panes["pane-2"] = newPane("pane-2", p2, tc.size)
1453 tb.mu.Unlock()
1454
1455 d.handleInput(sess, ac, tc.raw)
1456
1457 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
1458 })
1459 }
1460}
1461
1462func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
1463 p1 := portsmocks.NewMockPTY(t)
1464 p1.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Maybe()
1465 p2 := portsmocks.NewMockPTY(t)
1466 p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Maybe()
1467 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
1468 d.procComm = nil
1469 tb := testAttachmentTab(sess)
1470 for i := range 10 {
1471 writeTestRow(tb.focusedPane().screen, i, string(rune('a'+i)))
1472 }
1473 tb.mu.Lock()
1474 tb.size = domain.Size{Cols: 41, Rows: 10}
1475 tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1476 tb.tree.Focus = "pane-1"
1477 tb.panes["pane-2"] = newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 10})
1478 tb.mu.Unlock()
1479 d.enterCopyMode(sess, ac)
1480 mustOutputData(t, sends)
1481
1482 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;12M"))
1483 mustOutputData(t, sends)
1484
1485 selection := ac.overlays.copyMode.Selection()
1486 require.True(t, selection.Enabled)
1487 lo, hi := selection.Anchor.Row, selection.Active.Row
1488 if lo > hi {
1489 lo, hi = hi, lo
1490 }
1491 require.Equal(t, 0, lo)
1492 require.Equal(t, 9, hi)
1493}
1494
1495func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) {
1496 p1 := portsmocks.NewMockPTY(t)
1497 p1.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
1498 p2 := portsmocks.NewMockPTY(t)
1499 p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
1500 p2.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
1501 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1502 d.procComm = nil
1503 tb := testAttachmentTab(sess)
1504 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
1505 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
1506 tb.mu.Lock()
1507 tb.size = domain.Size{Cols: 41, Rows: 5}
1508 tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1509 tb.tree.Focus = "pane-1"
1510 tb.panes["pane-2"] = p2pane
1511 tb.mu.Unlock()
1512
1513 d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
1514
1515 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
1516}
1517
1518// TestMouseGatedWhileNoticesOverlayActive guards against a click on the
1519// notifications modal falling through to pane hit-testing underneath it: the
1520// modal has no mouse handler of its own, so without the gate a click would
1521// silently retarget focus and forward SGR bytes to whichever pane sits under
1522// the modal's coordinates.
1523func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) {
1524 p1 := portsmocks.NewMockPTY(t)
1525 p1.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
1526 p2 := portsmocks.NewMockPTY(t)
1527 p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
1528 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
1529 d.procComm = nil
1530 tb := testAttachmentTab(sess)
1531 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
1532 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
1533 tb.mu.Lock()
1534 tb.size = domain.Size{Cols: 41, Rows: 5}
1535 tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1536 tb.tree.Focus = "pane-1"
1537 tb.panes["pane-2"] = p2pane
1538 tb.mu.Unlock()
1539
1540 d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)})
1541 d.enterNotices(sess, ac)
1542 awaitFrame(t, sends, "Output")
1543 require.True(t, ac.overlays.noticesActive())
1544
1545 // Same coordinates that TestMouseHitTestFocusesPaneAndTranslatesSGRColumns
1546 // proves would otherwise focus pane-2 and forward the SGR report to it.
1547 d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
1548
1549 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus, "mouse must not reach pane hit-testing while the notices overlay is open")
1550 require.True(t, ac.overlays.noticesActive(), "mouse click must not close the notices overlay")
1551}
1552
1553func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {
1554 p1 := portsmocks.NewMockPTY(t)
1555 p1.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 4}}).Return(nil).Maybe()
1556 p2 := portsmocks.NewMockPTY(t)
1557 p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 4}}).Return(nil).Maybe()
1558 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1559 d.procComm = nil
1560 tb := testAttachmentTab(sess)
1561 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 3})
1562 tb.mu.Lock()
1563 tb.size = domain.Size{Cols: 20, Rows: 5}
1564 tb.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"}
1565 tb.tree.Focus = "pane-1"
1566 tb.panes["pane-2"] = p2pane
1567 tb.mu.Unlock()
1568
1569 d.handleInput(sess, ac, []byte("\x1b[<0;1;6M"))
1570
1571 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
1572 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded)
1573}
1574
1575func TestActiveCopyPressOnOtherPaneFocusesAndExitsBeforeFutureInput(t *testing.T) {
1576 p1, release := newBlockingPTY(t)
1577 defer release()
1578 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1579 tb := testAttachmentTab(sess)
1580 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
1581 tb.mu.Lock()
1582 tb.size = domain.Size{Cols: 41, Rows: 10}
1583 tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1584 tb.panes["pane-2"] = p2
1585 tb.mu.Unlock()
1586
1587 d.enterCopyMode(sess, ac)
1588 require.True(t, ac.overlays.copyActive())
1589 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1590 ac.overlays.copyMu.Lock()
1591 require.True(t, ac.overlays.copyPointer.valid)
1592 require.True(t, ac.overlays.copyClick.valid)
1593 ac.overlays.copyMu.Unlock()
1594
1595 // A press in the right split must focus it, then leave the old pane's copy
1596 // snapshot behind. The following press is therefore a fresh p2 candidate.
1597 d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
1598 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
1599 require.False(t, ac.overlays.copyActive())
1600 ac.overlays.copyMu.Lock()
1601 require.False(t, ac.overlays.copyPointer.valid)
1602 require.False(t, ac.overlays.copyClick.valid)
1603 ac.overlays.copyMu.Unlock()
1604
1605 d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
1606 ac.overlays.copyMu.Lock()
1607 pointer := ac.overlays.copyPointer
1608 ac.overlays.copyMu.Unlock()
1609 require.True(t, pointer.valid)
1610 require.Same(t, p2, pointer.pane)
1611}
1612
1613func TestCopyModeReleaseMapsFinalEndpointAndInvalidatesPointer(t *testing.T) {
1614 d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo")
1615
1616 // Motion starts an active drag; Release then arrives farther away without
1617 // another Motion and must still extend its final endpoint.
1618 copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;5;2M")
1619 copyMouseInput(d, sess, ac, "\x1b[<0;8;2m")
1620
1621 ac.overlays.copyMu.Lock()
1622 selection := ac.overlays.copyMode.Selection()
1623 pointerValid := ac.overlays.copyPointer.valid
1624 ac.overlays.copyMu.Unlock()
1625 require.True(t, selection.Enabled)
1626 require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor)
1627 require.Equal(t, scopy.Pos{Row: 0, Col: 7}, selection.Active)
1628 require.False(t, pointerValid)
1629}
1630
1631func TestCopySearchReleaseMapsFinalEndpointAndInvalidatesPointer(t *testing.T) {
1632 for _, tc := range []struct {
1633 name string
1634 release string
1635 wantCol int
1636 }{
1637 {name: "in pane", release: "\x1b[<0;8;2m", wantCol: 7},
1638 {name: "outside pane clamps", release: "\x1b[<0;99;2m", wantCol: 79},
1639 } {
1640 t.Run(tc.name, func(t *testing.T) {
1641 d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo")
1642
1643 copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;5;2M")
1644 copyMouseInput(d, sess, ac, "/")
1645 require.True(t, ac.overlays.copySearchActive())
1646
1647 // Search owns ordinary mouse input, but the release still completes
1648 // the active drag against its press-owned geometry.
1649 copyMouseInput(d, sess, ac, tc.release)
1650 copyMouseInput(d, sess, ac, "\x03")
1651
1652 ac.overlays.copyMu.Lock()
1653 selection := ac.overlays.copyMode.Selection()
1654 pointerValid := ac.overlays.copyPointer.valid
1655 ac.overlays.copyMu.Unlock()
1656 require.True(t, selection.Enabled)
1657 require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor)
1658 require.Equal(t, scopy.Pos{Row: 0, Col: tc.wantCol}, selection.Active)
1659 require.False(t, pointerValid)
1660 require.False(t, ac.overlays.copySearchActive())
1661 })
1662 }
1663}
1664
1665// TestActiveCopyMouseIgnoresStalePointerResets makes the mapping/reset race
1666// deterministic. Each path captures a copy-input snapshot, yields while that
1667// snapshot is outside copyMu, then starts a new pointer. The old event must
1668// never invalidate the new interaction.
1669func TestActiveCopyMouseIgnoresStalePointerResets(t *testing.T) {
1670 for _, tc := range []struct {
1671 name string
1672 prepare func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) mouse.Event
1673 }{
1674 {
1675 name: "press-owned release rejects mapping",
1676 prepare: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) mouse.Event {
1677 t.Helper()
1678 p := testAttachmentTab(sess).focusedPane()
1679 tb := testAttachmentTab(sess)
1680 tb.mu.Lock()
1681 geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), 1, 2)
1682 tb.mu.Unlock()
1683 require.True(t, ok)
1684 ac.overlays.copyMu.Lock()
1685 ac.overlays.beginCopyPointerLocked(copyPointerState{pane: p, document: ac.overlays.copyDocument, geometry: geometry})
1686 ac.overlays.copyMu.Unlock()
1687 return mouse.Event{Button: mouse.Left, Type: mouse.Release, Col: geometry.content.X - 1, Row: geometry.content.Y}
1688 },
1689 },
1690 {
1691 name: "current geometry disappears",
1692 prepare: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient) mouse.Event {
1693 t.Helper()
1694 tb := testAttachmentTab(sess)
1695 tb.mu.Lock()
1696 tb.tree = nil
1697 tb.mu.Unlock()
1698 return mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 1, Row: 2}
1699 },
1700 },
1701 {
1702 name: "fresh press rejects document mapping",
1703 prepare: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient) mouse.Event {
1704 t.Helper()
1705 tb := testAttachmentTab(sess)
1706 tb.mu.Lock()
1707 tb.size.Rows = 40 // valid layout coordinate, outside the old document.
1708 tb.mu.Unlock()
1709 return mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 1, Row: 30}
1710 },
1711 },
1712 } {
1713 t.Run(tc.name, func(t *testing.T) {
1714 p, release := newBlockingPTY(t)
1715 defer release()
1716 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1717 d.enterCopyMode(sess, ac)
1718 mustOutputData(t, sends)
1719 ev := tc.prepare(t, d, sess, ac)
1720
1721 var want copyPointerState
1722 d.beforeCopyMouseMap = func() {
1723 ac.overlays.copyMu.Lock()
1724 ac.overlays.beginCopyPointerLocked(copyPointerState{
1725 pane: ac.overlays.copyPane,
1726 document: ac.overlays.copyDocument,
1727 press: scopy.Pos{Row: 7, Col: 7},
1728 })
1729 want = ac.overlays.copyPointer
1730 ac.overlays.copyMu.Unlock()
1731 }
1732 defer func() { d.beforeCopyMouseMap = nil }()
1733
1734 d.handleActiveCopyMouse(sess, ac, testAttachmentTab(sess), ev)
1735
1736 ac.overlays.copyMu.Lock()
1737 defer ac.overlays.copyMu.Unlock()
1738 require.True(t, want.valid, "test seam must start the newer pointer")
1739 require.True(t, ac.overlays.copyPointer.valid)
1740 require.Equal(t, want.epoch, ac.overlays.copyPointerEpoch)
1741 require.Equal(t, want.epoch, ac.overlays.copyPointer.epoch)
1742 require.Same(t, want.pane, ac.overlays.copyPointer.pane)
1743 require.Same(t, want.document, ac.overlays.copyPointer.document)
1744 require.Equal(t, want.press, ac.overlays.copyPointer.press)
1745 })
1746 }
1747}
1748
1749func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T) {
1750 p, release := newBlockingPTY(t)
1751 defer release()
1752 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1753 pane := testAttachmentTab(sess).focusedPane()
1754 for range 8 {
1755 appendHistoryRow(t, pane.history, testRow("history"))
1756 }
1757 d.enterCopyMode(sess, ac)
1758 mustOutputData(t, sends)
1759
1760 ac.overlays.copyMu.Lock()
1761 before := ac.overlays.copyMode.Cursor()
1762 ac.overlays.copyMu.Unlock()
1763 d.beforeCopyMouseMap = func() {
1764 ac.overlays.copyMu.Lock()
1765 ac.overlays.copyMode.ViewportTop--
1766 ac.overlays.copyMu.Unlock()
1767 }
1768 defer func() { d.beforeCopyMouseMap = nil }()
1769
1770 // The seam changes ViewportTop after the map snapshot but before copyMouse
1771 // revalidates it. The stale mapped row must not move the selection cursor.
1772 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1773 ac.overlays.copyMu.Lock()
1774 require.Equal(t, before, ac.overlays.copyMode.Cursor())
1775 require.False(t, ac.overlays.copyPointer.valid)
1776 ac.overlays.copyMu.Unlock()
1777}
1778
1779func TestFreshCopyPressUsesFrameAbsoluteHitTestGeometry(t *testing.T) {
1780 for _, tc := range []struct {
1781 name string
1782 setup func(t *testing.T, d *Daemon, sess *session) (*pane, mouse.Event)
1783 }{
1784 {
1785 name: "mono",
1786 setup: func(_ *testing.T, _ *Daemon, sess *session) (*pane, mouse.Event) {
1787 return testAttachmentTab(sess).focusedPane(), mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 1, Row: 1}
1788 },
1789 },
1790 {
1791 name: "split",
1792 setup: func(_ *testing.T, _ *Daemon, sess *session) (*pane, mouse.Event) {
1793 tb := testAttachmentTab(sess)
1794 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
1795 tb.mu.Lock()
1796 tb.size = domain.Size{Cols: 41, Rows: 10}
1797 tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1798 tb.panes["pane-2"] = p2
1799 tb.mu.Unlock()
1800 return p2, mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 22, Row: 3}
1801 },
1802 },
1803 {
1804 name: "floating",
1805 setup: func(t *testing.T, d *Daemon, sess *session) (*pane, mouse.Event) {
1806 p := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
1807 installTestFloating(testAttachmentTab(sess), p, true)
1808 tb := testAttachmentTab(sess)
1809 tb.mu.Lock()
1810 _, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
1811 tb.mu.Unlock()
1812 require.True(t, visible)
1813 return p, mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: geometry.Inner.X + 1, Row: geometry.Inner.Y + 2}
1814 },
1815 },
1816 } {
1817 t.Run(tc.name, func(t *testing.T) {
1818 p, release := newBlockingPTY(t)
1819 defer release()
1820 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1821 wantPane, ev := tc.setup(t, d, sess)
1822 tb := testAttachmentTab(sess)
1823 tb.mu.Lock()
1824 wantGeometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), ev.Col, ev.Row)
1825 tb.mu.Unlock()
1826 require.True(t, ok)
1827 require.Same(t, wantPane, wantGeometry.pane)
1828
1829 d.handleMouse(ac, ev)
1830 ac.overlays.copyMu.Lock()
1831 pointer := ac.overlays.copyPointer
1832 ac.overlays.copyMu.Unlock()
1833 require.True(t, pointer.valid)
1834 require.Same(t, wantPane, pointer.pane)
1835 require.Equal(t, wantGeometry, pointer.geometry)
1836 mapped, ok := mapCopyMouse(ev, wantGeometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, false)
1837 require.True(t, ok)
1838 require.Equal(t, mapped.pos, pointer.press)
1839 })
1840 }
1841}