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

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