input_test.go RawHistoryFolder

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.procComm = 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.procComm = 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.procComm = 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.procComm = 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.procComm = 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.procComm = 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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close