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