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