v0.2.0 80c61e3

pane_actions_test.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"errors"
6	"io"
7	"log/slog"
8	"sync"
9	"testing"
10	"time"
11
12	"github.com/stretchr/testify/mock"
13	"github.com/stretchr/testify/require"
14
15	renderer "github.com/bnema/vev-vt"
16	"github.com/bnema/vev/internal/domain"
17	"github.com/bnema/vev/internal/ports"
18	portsmocks "github.com/bnema/vev/internal/ports/mocks"
19	scopy "github.com/bnema/vev/internal/usecase/copy"
20	"github.com/bnema/vev/internal/usecase/layout"
21)
22
23func TestLayoutGenerationMutationBookkeeping(t *testing.T) {
24	t.Run("focus and expanded member share one logical bump", func(t *testing.T) {
25		tb := newTab(nil, domain.Size{Cols: 41, Rows: 10})
26		second := newPane("pane-2", nil, domain.Size{Cols: 41, Rows: 10})
27		tb.mu.Lock()
28		tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"}, Focus: "pane-1"}
29		tb.panes[second.id] = second
30		require.Zero(t, tb.layoutGeneration)
31		require.True(t, focusPlacementLocked(tb, second.id))
32		require.Equal(t, uint64(1), tb.layoutGeneration)
33		require.False(t, focusPlacementLocked(tb, second.id))
34		require.Equal(t, uint64(1), tb.layoutGeneration)
35		tb.mu.Unlock()
36	})
37
38	t.Run("size changes bump only when the value changes", func(t *testing.T) {
39		tb := newTab(nil, domain.Size{Cols: 41, Rows: 10})
40		tb.mu.Lock()
41		if tb.size != (domain.Size{Cols: 80, Rows: 23}) {
42			tb.size = domain.Size{Cols: 80, Rows: 23}
43			tb.bumpLayoutGenerationLocked()
44		}
45		require.Equal(t, uint64(1), tb.layoutGeneration)
46		if tb.size != (domain.Size{Cols: 80, Rows: 23}) {
47			tb.size = domain.Size{Cols: 80, Rows: 23}
48			tb.bumpLayoutGenerationLocked()
49		}
50		require.Equal(t, uint64(1), tb.layoutGeneration)
51		tb.mu.Unlock()
52	})
53}
54
55type layoutRetryClock struct{ timers chan *layoutRetryTimer }
56
57type layoutRetryTimer struct {
58	ch    chan time.Time
59	delay time.Duration
60}
61
62func (c *layoutRetryClock) Now() time.Time { return time.Unix(0, 0) }
63
64func (c *layoutRetryClock) NewTimer(delay time.Duration) ports.Timer {
65	timer := &layoutRetryTimer{ch: make(chan time.Time, 1), delay: delay}
66	c.timers <- timer
67	return timer
68}
69
70func (t *layoutRetryTimer) C() <-chan time.Time    { return t.ch }
71func (*layoutRetryTimer) Reset(time.Duration) bool { return false }
72func (*layoutRetryTimer) Stop() bool               { return true }
73func (t *layoutRetryTimer) fire()                  { t.ch <- time.Unix(0, 0) }
74
75func TestLayoutRetryIsBoundedDeduplicatedAndCanceled(t *testing.T) {
76	t.Run("persistent failure is bounded and reports once", func(t *testing.T) {
77		pty := &transactionalResizePTY{errs: []error{errors.New("initial"), errors.New("retry 1"), errors.New("retry 2"), errors.New("retry 3")}}
78		d, sess, _, _ := newManualSessionWithPTYs(t, pty)
79		clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)}
80		d.clock = clock
81		tb := testAttachmentTab(sess)
82		tb.mu.Lock()
83		tb.size = domain.Size{Cols: 100, Rows: 20}
84		tb.bumpLayoutGenerationLocked()
85		tb.mu.Unlock()
86
87		d.applyTabLayout(sess, tb)
88		// A second accepted failure joins the existing worker rather than adding
89		// another timer/goroutine.
90		d.scheduleAcceptedTabLayoutRetry(sess, tb)
91		for range maxAcceptedTabLayoutRetries {
92			var timer *layoutRetryTimer
93			for timer == nil {
94				candidate := <-clock.timers
95				if candidate.delay == minOutputRenderDeadline {
96					timer = candidate
97				}
98			}
99			timer.fire()
100		}
101		require.Eventually(t, func() bool {
102			tb.layoutRetryMu.Lock()
103			defer tb.layoutRetryMu.Unlock()
104			return !tb.layoutRetryRunning
105		}, time.Second, time.Millisecond)
106		require.Len(t, pty.requested(), 1+maxAcceptedTabLayoutRetries)
107		count := 0
108		for _, notice := range d.notices.history() {
109			if notice.Code == domain.NoticeResizeFailed {
110				count++
111			}
112		}
113		require.Equal(t, 1, count, "background retries must not create a notification storm")
114	})
115
116	t.Run("tab cancellation stops a waiting retry", func(t *testing.T) {
117		pty := &transactionalResizePTY{errs: []error{errors.New("initial")}}
118		d, sess, _, _ := newManualSessionWithPTYs(t, pty)
119		clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)}
120		d.clock = clock
121		tb := testAttachmentTab(sess)
122		tb.mu.Lock()
123		tb.size = domain.Size{Cols: 100, Rows: 20}
124		tb.bumpLayoutGenerationLocked()
125		tb.mu.Unlock()
126		d.applyTabLayout(sess, tb)
127		var timer *layoutRetryTimer
128		for timer == nil {
129			candidate := <-clock.timers
130			if candidate.delay == minOutputRenderDeadline {
131				timer = candidate
132			}
133		}
134		tb.cancel()
135		timer.fire()
136		require.Eventually(t, func() bool {
137			tb.layoutRetryMu.Lock()
138			defer tb.layoutRetryMu.Unlock()
139			return !tb.layoutRetryRunning
140		}, time.Second, time.Millisecond)
141		require.Len(t, pty.requested(), 1)
142	})
143}
144
145func TestFocusDirAtDoesNotApplyUncommittedCandidate(t *testing.T) {
146	pty := &transactionalResizePTY{}
147	d, sess, _, _ := newManualSessionWithPTYs(t, pty)
148	tb := testAttachmentTab(sess)
149	second := newPane("pane-2", nil, domain.Size{Cols: 80, Rows: 23})
150	tb.mu.Lock()
151	tb.panes[second.id] = second
152	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"}
153	tb.panes["pane-1"].resizeRetry = true
154	target := tb.panes["pane-1"]
155	tb.mu.Unlock()
156
157	_, err := d.focusDirAt(sess, tb, target, layout.Right)
158	require.NoError(t, err)
159	require.Empty(t, pty.requested(), "an equality-mismatched candidate was not committed and must not apply")
160}
161
162func TestLayoutApplicationRetriesOnlyAcceptedFailedGeometry(t *testing.T) {
163	pty := &transactionalResizePTY{errs: []error{errors.New("first resize fails")}}
164	d, sess, _, _ := newManualSessionWithPTYs(t, pty)
165	clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)}
166	d.clock = clock
167	tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
168	tb.mu.Lock()
169	tb.size = domain.Size{Cols: 100, Rows: 20}
170	tb.bumpLayoutGenerationLocked()
171	tb.mu.Unlock()
172
173	secondResize := make(chan struct{})
174	var resized sync.Once
175	pty.onResize = func() {
176		if len(pty.requested()) == 2 {
177			resized.Do(func() { close(secondResize) })
178		}
179	}
180	d.applyTabLayout(sess, tb)
181
182	var timer *layoutRetryTimer
183	for timer == nil {
184		candidate := <-clock.timers
185		if candidate.delay == minOutputRenderDeadline {
186			timer = candidate
187		}
188	}
189	require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}}, pty.requested())
190	require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "failed apply must not publish a VT resize")
191	timer.fire()
192	awaitSignal(t, secondResize, "accepted failure was not retried")
193	require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}, {Cols: 100, Rows: 20}}, pty.requested())
194	require.Eventually(t, func() bool {
195		p.mu.Lock()
196		defer p.mu.Unlock()
197		return p.screen.Frame.Width == 100 && p.screen.Frame.Height == 20
198	}, time.Second, time.Millisecond, "successful retry did not publish its VT size")
199}
200
201func TestDelayedRetryUsesFreshLayoutAfterPaneResize(t *testing.T) {
202	clock := newCoordinatorMockClock(t, 8)
203	// The failed apply also posts its preserved degradation warning; its toast
204	// lifetime is unrelated to the coordinator deadlines driven below.
205	clock.clock.EXPECT().NewTimer(6 * time.Second).Return(stubTimer{}).Once()
206	failed := &transactionalResizePTY{errs: []error{errors.New("first resize fails")}}
207	d, sess, ac, _ := newManualSessionWithPTYs(t, failed)
208	d.clock = clock.clock
209	tb := testAttachmentTab(sess)
210	first := tb.focusedPane()
211	second := newPane("pane-2", nil, domain.Size{Cols: 80, Rows: 23})
212	tb.mu.Lock()
213	tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(first.id), layout.NewLeaf(second.id)}}, Focus: first.id}
214	tb.panes[second.id] = second
215	tb.bumpLayoutGenerationLocked()
216	tb.mu.Unlock()
217
218	// A client resize commits the new layout but leaves the failed PTY on the
219	// coordinator retry lane. Drive both deadlines explicitly so this ordering
220	// cannot depend on wall-clock scheduling.
221	d.resize(sess, ac, domain.Size{Cols: 80, Rows: 24})
222	rc := sess.renderCoordinator()
223	resizeTimer := awaitCoordinatorScheduledTimer(t, clock)
224	resizeDone := captureResizeCallbackDone(t, rc)
225	resizeTimer.ch <- time.Time{}
226	awaitTestCompletion(t, resizeDone, "failed client resize did not complete")
227	var retryTimer *coordinatorMockTimer
228	for retryTimer == nil {
229		candidate := awaitCoordinatorScheduledTimer(t, clock)
230		if candidate.duration == minOutputRenderDeadline {
231			retryTimer = candidate
232		}
233	}
234
235	// A later pane-resize changes the solved rectangle and repairs the failed
236	// PTY before the delayed callback fires. Retrying its old captured 40-column
237	// rectangle would regress the PTY, VT, and published pane geometry.
238	require.NoError(t, d.resizePane(daemonActionTarget{session: sess, tab: tb, pane: first}, layout.Width, resizeStepCols))
239	tb.mu.Lock()
240	placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
241	currentFirst := placementContent(placements, first.id)
242	currentSecond := placementContent(placements, second.id)
243	tb.mu.Unlock()
244	require.True(t, ok)
245	require.Equal(t, currentFirst, first.rect)
246	require.Equal(t, currentSecond, second.rect)
247	require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Frame.Width, Rows: first.screen.Frame.Height})
248	require.Equal(t, []domain.Size{{Cols: 40, Rows: 22}, rectSize(currentFirst)}, failed.requested())
249
250	rc.mu.Lock()
251	retryDone := rc.retryLane.token.done
252	rc.mu.Unlock()
253	retryTimer.ch <- time.Time{}
254	awaitTestCompletion(t, retryDone, "delayed resize retry did not complete")
255
256	require.Equal(t, []domain.Size{{Cols: 40, Rows: 22}, rectSize(currentFirst)}, failed.requested(), "delayed retry must not apply stale PTY geometry")
257	require.Equal(t, currentFirst, first.rect, "delayed retry must not publish a stale rectangle")
258	require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Frame.Width, Rows: first.screen.Frame.Height}, "delayed retry must not publish a stale VT size")
259}
260
261func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) {
262	d, sess, pty, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
263	tb := testAttachmentTab(sess)
264	tb.focusedPane().rect = domain.Rect{Width: 20, Height: 10}
265	entered := make(chan struct{})
266	release := make(chan struct{})
267	pty.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Run(func(domain.Size) {
268		close(entered)
269		<-release
270	}).Return(nil).Once()
271
272	done := make(chan struct{})
273	go func() {
274		d.applyTabLayout(sess, tb)
275		close(done)
276	}()
277	awaitSignal(t, entered, "PTY resize did not start")
278
279	locked := make(chan struct{})
280	go func() {
281		tb.mu.Lock()
282		_ = tb.layoutGeneration
283		tb.mu.Unlock()
284		close(locked)
285	}()
286	awaitSignal(t, locked, "tab lock remained held across PTY resize")
287	close(release)
288	awaitSignal(t, done, "layout application did not finish")
289}
290
291func TestLayoutApplicationRejectsStalePaneIdentity(t *testing.T) {
292	d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
293	tb := testAttachmentTab(sess)
294	oldPane := tb.focusedPane()
295	oldPane.rect = domain.Rect{Width: 20, Height: 10}
296	oldPane.screen.Resize(20, 10)
297	entered := make(chan struct{})
298	release := make(chan struct{})
299	oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Run(func(domain.Size) {
300		close(entered)
301		<-release
302	}).Return(nil).Once()
303
304	done := make(chan struct{})
305	go func() {
306		d.applyTabLayout(sess, tb)
307		close(done)
308	}()
309	awaitSignal(t, entered, "stale PTY resize did not start")
310
311	newPTY := portsmocks.NewMockPTY(t)
312	replacement := newPane("pane-1", newPTY, domain.Size{Cols: 20, Rows: 10})
313	newPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once()
314	tb.mu.Lock()
315	tb.panes["pane-1"] = replacement
316	tb.bumpLayoutGenerationLocked()
317	tb.mu.Unlock()
318	close(release)
319	awaitSignal(t, done, "stale layout did not retry")
320
321	require.Equal(t, domain.Rect{Width: 20, Height: 10}, oldPane.rect, "stale pane rectangle was published")
322	require.Equal(t, 20, oldPane.screen.Frame.Width, "stale pane screen was published")
323	require.Equal(t, domain.Rect{Width: 41, Height: 10}, replacement.rect)
324	require.Equal(t, 41, replacement.screen.Frame.Width)
325}
326
327func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
328	newBlockedPTY := func() (*transactionalResizePTY, <-chan struct{}, chan<- struct{}) {
329		entered := make(chan struct{})
330		release := make(chan struct{})
331		var once sync.Once
332		return &transactionalResizePTY{onResize: func() {
333			once.Do(func() {
334				close(entered)
335				<-release
336			})
337		}}, entered, release
338	}
339
340	t.Run("close removes a stale plan member", func(t *testing.T) {
341		first, entered, release := newBlockedPTY()
342		second := &transactionalResizePTY{}
343		d, sess, _, _ := newManualSessionWithPTYs(t, first)
344		tb := testAttachmentTab(sess)
345		p1 := tb.focusedPane()
346		p2 := newPane("pane-2", second, domain.Size{Cols: 80, Rows: 23})
347		tb.mu.Lock()
348		tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}}, Focus: p1.id}
349		tb.panes[p2.id] = p2
350		tb.bumpLayoutGenerationLocked()
351		tb.mu.Unlock()
352
353		done := make(chan struct{})
354		go func() { d.applyTabLayout(sess, tb); close(done) }()
355		awaitSignal(t, entered, "old layout apply did not block")
356		closed := make(chan error, 1)
357		go func() { closed <- d.closePane(sess, tb, p2.id, nil, false) }()
358		require.Eventually(t, func() bool {
359			tb.mu.Lock()
360			defer tb.mu.Unlock()
361			return tb.panes[p2.id] == nil && tb.layoutGeneration > 1
362		}, time.Second, time.Millisecond, "close must mutate before its serialized apply waits")
363		require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect, "stale plan published before release")
364		close(release)
365		awaitSignal(t, done, "stale layout apply did not finish")
366		require.NoError(t, <-closed)
367
368		tb.mu.Lock()
369		_, exists := tb.panes[p2.id]
370		tb.mu.Unlock()
371		require.False(t, exists)
372		require.Equal(t, domain.Rect{Width: 80, Height: 23}, p2.rect, "removed pane received a stale rectangle publication")
373		require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p2.screen.Frame.Width, Rows: p2.screen.Frame.Height}, "removed pane received a stale screen publication")
374		p2.mu.Lock()
375		require.False(t, p2.resizeApplying, "removed pane gate was not cancelled")
376		p2.mu.Unlock()
377		require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect)
378		require.Equal(t, []domain.Size{{Cols: 40, Rows: 23}, {Cols: 80, Rows: 23}}, first.requested(), "the rejected plan must be followed by a fresh surviving-pane apply")
379		require.Equal(t, []domain.Size{{Cols: 39, Rows: 23}}, second.requested(), "the removed pane may receive an obsolete external resize but must never be published")
380	})
381
382	t.Run("focus expansion replaces a stale stack placement", func(t *testing.T) {
383		first, entered, release := newBlockedPTY()
384		second := &transactionalResizePTY{}
385		d, sess, _, _ := newManualSessionWithPTYs(t, first)
386		tb := testAttachmentTab(sess)
387		p1 := tb.focusedPane()
388		p2 := newPane("pane-2", second, domain.Size{Cols: 80, Rows: 23})
389		tb.mu.Lock()
390		tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p1.id}, Focus: p1.id}
391		tb.panes[p2.id] = p2
392		tb.bumpLayoutGenerationLocked()
393		tb.mu.Unlock()
394
395		done := make(chan struct{})
396		go func() { d.applyTabLayout(sess, tb); close(done) }()
397		awaitSignal(t, entered, "old stack apply did not block")
398		tb.mu.Lock()
399		require.True(t, focusPlacementLocked(tb, p2.id))
400		tb.mu.Unlock()
401		close(release)
402		awaitSignal(t, done, "stale stack apply did not retry")
403
404		require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect, "collapsed pane received stale geometry")
405		p1.mu.Lock()
406		require.False(t, p1.resizeApplying, "collapsed pane gate was not cancelled")
407		p1.mu.Unlock()
408		tb.mu.Lock()
409		placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
410		tb.mu.Unlock()
411		require.True(t, ok)
412		require.Equal(t, placementContent(placements, p2.id), p2.rect)
413		require.Equal(t, []domain.Size{{Cols: 80, Rows: 22}}, first.requested(), "the old expanded pane is applied only by the rejected attempt")
414		require.Equal(t, []domain.Size{rectSize(p2.rect)}, second.requested())
415	})
416
417	t.Run("client size rejects and retries a blocked plan", func(t *testing.T) {
418		pty, entered, release := newBlockedPTY()
419		d, sess, _, _ := newManualSessionWithPTYs(t, pty)
420		tb := testAttachmentTab(sess)
421		p := tb.focusedPane()
422		p.rect = domain.Rect{Width: 20, Height: 10}
423
424		oldDone := make(chan struct{})
425		go func() { d.applyTabLayout(sess, tb); close(oldDone) }()
426		awaitSignal(t, entered, "old size apply did not block")
427		resizeDone := make(chan bool, 1)
428		go func() { resizeDone <- d.requestTransactionalResize(sess, nil, domain.Size{Cols: 100, Rows: 30}, true) }()
429		// Session resize now stages the target privately until every external
430		// apply validates and the transaction is admitted, so neither the size
431		// nor pane geometry is visible while the old PTY call is blocked.
432		tb.mu.Lock()
433		require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size)
434		tb.mu.Unlock()
435		require.Equal(t, domain.Rect{Width: 20, Height: 10}, p.rect, "stale size published before release")
436		close(release)
437		awaitSignal(t, oldDone, "old size apply did not finish")
438		require.True(t, <-resizeDone)
439		require.Equal(t, domain.Rect{Width: 100, Height: 28}, p.rect)
440		require.Equal(t, domain.Size{Cols: 100, Rows: 28}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})
441		require.Equal(t, []domain.Size{{Cols: 80, Rows: 23}, {Cols: 100, Rows: 28}}, pty.requested())
442	})
443}
444
445func awaitSignal(t *testing.T, ch <-chan struct{}, message string) {
446	t.Helper()
447	select {
448	case <-ch:
449	case <-time.After(2 * time.Second):
450		t.Fatal(message)
451	}
452}
453
454func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
455	tests := []struct {
456		name      string
457		dir       layout.Direction
458		size      domain.Size
459		wantNew   domain.Rect
460		wantOld   domain.Rect
461		isNewSide func(oldRect, newRect domain.Rect) bool
462	}{
463		{name: "right", dir: layout.Right, size: domain.Size{Cols: 41, Rows: 10}, wantOld: domain.Rect{Width: 20, Height: 10}, wantNew: domain.Rect{X: 21, Width: 20, Height: 10}, isNewSide: func(oldRect, newRect domain.Rect) bool { return newRect.X > oldRect.X }},
464		{name: "left", dir: layout.Left, size: domain.Size{Cols: 41, Rows: 10}, wantOld: domain.Rect{X: 21, Width: 20, Height: 10}, wantNew: domain.Rect{Width: 20, Height: 10}, isNewSide: func(oldRect, newRect domain.Rect) bool { return newRect.X < oldRect.X }},
465		{name: "down", dir: layout.Down, size: domain.Size{Cols: 80, Rows: 5}, wantOld: domain.Rect{Width: 80, Height: 2}, wantNew: domain.Rect{Y: 3, Width: 80, Height: 2}, isNewSide: func(oldRect, newRect domain.Rect) bool { return newRect.Y > oldRect.Y }},
466		{name: "up", dir: layout.Up, size: domain.Size{Cols: 80, Rows: 5}, wantOld: domain.Rect{Y: 3, Width: 80, Height: 2}, wantNew: domain.Rect{Width: 80, Height: 2}, isNewSide: func(oldRect, newRect domain.Rect) bool { return newRect.Y < oldRect.Y }},
467	}
468
469	for _, tt := range tests {
470		t.Run(tt.name, func(t *testing.T) {
471			d, sess, oldPTY, factory := newSplitTestDaemon(t, tt.size)
472			newPTY := portsmocks.NewMockPTY(t)
473			oldPTY.EXPECT().Resize(rectSize(tt.wantOld)).Return(nil).Once()
474			newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
475			factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", rectSize(tt.wantNew)).Return(newPTY, nil).Once()
476
477			require.NoError(t, d.splitPane(sess, nil, tt.dir))
478
479			tb := testAttachmentTab(sess)
480			tb.mu.Lock()
481			defer tb.mu.Unlock()
482			require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
483			require.Len(t, tb.panes, 2)
484			oldRect := tb.panes["pane-1"].rect
485			newRect := tb.panes["pane-2"].rect
486			require.Equal(t, tt.wantOld, oldRect)
487			require.Equal(t, tt.wantNew, newRect)
488			require.True(t, tt.isNewSide(oldRect, newRect))
489		})
490	}
491}
492
493func TestSplitPaneActionFocusesNewPaneForInitiatingAttachment(t *testing.T) {
494	d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
495	ac := registryTestAttachment(1)
496	other := registryTestAttachment(2)
497	require.True(t, sess.registerAttachment(ac))
498	require.True(t, sess.registerAttachment(other))
499	newPTY := portsmocks.NewMockPTY(t)
500	oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
501	newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
502	factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
503
504	target := resolveDaemonActionTargetForAttachment(sess, ac)
505	require.NoError(t, (daemonActions{d: d}).Run(daemonActionRequest{
506		kind:      daemonActionSplitPane,
507		target:    target,
508		direction: layout.Right,
509	}))
510
511	_, focused := sess.paneForAttachment(ac)
512	require.NotNil(t, focused)
513	require.Equal(t, layout.PaneID("pane-2"), focused.id)
514	_, otherFocused := sess.paneForAttachment(other)
515	require.NotNil(t, otherFocused)
516	require.Equal(t, layout.PaneID("pane-1"), otherFocused.id)
517
518	target = resolveDaemonActionTargetForAttachment(sess, ac)
519	require.NoError(t, (daemonActions{d: d}).Run(daemonActionRequest{
520		kind:      daemonActionFocusPane,
521		target:    target,
522		direction: layout.Left,
523	}))
524	_, focused = sess.paneForAttachment(ac)
525	require.NotNil(t, focused)
526	require.Equal(t, layout.PaneID("pane-1"), focused.id)
527}
528
529func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
530	d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 4})
531	tb := testAttachmentTab(sess)
532	stackPTY := portsmocks.NewMockPTY(t)
533	stackPane := newPane("pane-2", stackPTY, domain.Size{Cols: 41, Rows: 2})
534	tb.panes[stackPane.id] = stackPane
535	tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-2"}, Focus: "pane-2"}
536	tb.nextPaneID = 3
537	newPTY := portsmocks.NewMockPTY(t)
538	stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Once()
539	newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
540	factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()
541
542	require.NoError(t, d.splitPane(sess, nil, layout.Right))
543
544	tb.mu.Lock()
545	defer tb.mu.Unlock()
546	placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
547	require.True(t, ok)
548	require.Equal(t, layout.PaneID("pane-3"), tb.tree.Focus)
549	require.Len(t, tb.panes, 3)
550	require.Equal(t, domain.Rect{Y: 1, Width: 20, Height: 3}, placementContent(placements, "pane-2"))
551	require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 4}, placementContent(placements, "pane-3"))
552}
553
554func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {
555	d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
556	cause := errors.New("open failed")
557	factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, cause).Once()
558
559	tb := testAttachmentTab(sess)
560	tb.mu.Lock()
561	beforeRoot, beforeFocus, beforeNext, beforePaneCount := tb.tree.Root.Leaf, tb.tree.Focus, tb.nextPaneID, len(tb.panes)
562	tb.mu.Unlock()
563
564	err := d.splitPane(sess, nil, layout.Right)
565	require.Error(t, err)
566
567	var ue *domain.UserError
568	require.ErrorAs(t, err, &ue)
569	require.Equal(t, domain.NoticePaneSpawn, ue.Code)
570	require.Equal(t, domain.NoticeError, ue.Severity)
571	require.Equal(t, "couldn't open pane: shell failed to start", ue.Msg)
572	require.ErrorIs(t, err, cause)
573	require.NotContains(t, ue.Msg, cause.Error())
574
575	tb.mu.Lock()
576	defer tb.mu.Unlock()
577	require.Equal(t, beforeRoot, tb.tree.Root.Leaf)
578	require.Equal(t, beforeFocus, tb.tree.Focus)
579	require.Equal(t, beforeNext+1, tb.nextPaneID)
580	require.Len(t, tb.panes, beforePaneCount)
581	require.Nil(t, tb.panes["pane-2"])
582}
583
584func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) {
585	d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
586	tb := testAttachmentTab(sess)
587	newPTY := portsmocks.NewMockPTY(t)
588	newPane := newPane("pane-2", newPTY, domain.Size{Cols: 41, Rows: 10})
589	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"}
590	tb.panes["pane-2"] = newPane
591	oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
592	newPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
593
594	d.applyTabLayout(sess, tb)
595
596	require.Equal(t, 20, tb.panes["pane-1"].screen.Frame.Width)
597	require.Equal(t, 20, tb.panes["pane-2"].screen.Frame.Width)
598	require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 10}, tb.panes["pane-2"].rect)
599}
600
601func TestResizeReflowsAllPanes(t *testing.T) {
602	d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
603	tb := testAttachmentTab(sess)
604	newPTY := portsmocks.NewMockPTY(t)
605	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"}
606	tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10})
607	tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
608	tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
609	oldPTY.EXPECT().Resize(domain.Size{Cols: 30, Rows: 18}).Return(nil).Once()
610	newPTY.EXPECT().Resize(domain.Size{Cols: 29, Rows: 18}).Return(nil).Once()
611
612	d.resize(sess, nil, domain.Size{Cols: 60, Rows: 20})
613
614	require.Equal(t, domain.Size{Cols: 60, Rows: 18}, tb.size)
615	require.Equal(t, domain.Rect{Width: 30, Height: 18}, tb.panes["pane-1"].rect)
616	require.Equal(t, domain.Rect{X: 31, Width: 29, Height: 18}, tb.panes["pane-2"].rect)
617	require.Equal(t, 30, tb.panes["pane-1"].screen.Frame.Width)
618	require.Equal(t, 29, tb.panes["pane-2"].screen.Frame.Width)
619}
620
621func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) {
622	tests := []struct {
623		name      string
624		direction layout.Direction
625		wantErr   error
626		wantFocus layout.PaneID
627	}{
628		{name: "moves right", direction: layout.Right, wantFocus: "pane-2"},
629		{name: "maps missing left neighbor", direction: layout.Left, wantErr: errNoNeighbor, wantFocus: "pane-1"},
630	}
631
632	for _, tt := range tests {
633		t.Run(tt.name, func(t *testing.T) {
634			d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
635			tb := testAttachmentTab(sess)
636			target := tb.panes["pane-1"]
637			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-1"}
638			tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
639			tb.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
640			tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
641
642			change, err := d.focusDirAt(sess, tb, target, tt.direction)
643
644			require.ErrorIs(t, err, tt.wantErr)
645			require.Equal(t, domain.Rect{Width: 20, Height: 10}, change.span)
646			require.Equal(t, tt.wantFocus, tb.tree.Focus)
647		})
648	}
649}
650
651func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
652	d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
653	tb := testAttachmentTab(sess)
654	newPTY := portsmocks.NewMockPTY(t)
655	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-1"}
656	tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10})
657	tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
658	tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
659	tr, _ := newCapturingTransport(t)
660	ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 41, Rows: 12}}
661	ac.initOverlays()
662	ac.setSession(sess)
663	require.True(t, sess.registerAttachment(ac))
664	ac.overlays.copyMode = &scopy.Mode{}
665
666	require.NoError(t, d.focusDir(sess, ac, layout.Right, nil))
667
668	require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
669	require.Nil(t, ac.overlays.copyMode)
670	oldPTY.AssertNotCalled(t, "Resize", mock.Anything)
671	newPTY.AssertNotCalled(t, "Resize", mock.Anything)
672}
673
674func TestClosePaneRepaintFanoutRespectsAttachmentScope(t *testing.T) {
675	for _, tt := range []struct {
676		name       string
677		attachment *attachedClient
678		wantFirst  bool
679		wantSecond bool
680	}{
681		{name: "nil attachment fans out", wantFirst: true, wantSecond: true},
682		{name: "non-nil attachment stays targeted"},
683	} {
684		t.Run(tt.name, func(t *testing.T) {
685			d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
686			tb := testAttachmentTab(sess)
687			closingPTY := portsmocks.NewMockPTY(t)
688			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"}
689			tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
690			tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
691			tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
692			oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once()
693			closingPTY.EXPECT().Close().Return(nil).Once()
694
695			clients := make([]*attachedClient, 2)
696			sends := make([]chan ports.Frame, 2)
697			for i := range clients {
698				tr, sent := newCapturingTransport(t)
699				clients[i] = &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 41, Rows: 12}}
700				clients[i].clientID[0] = byte(i + 1)
701				clients[i].initOverlays()
702				clients[i].setSession(sess)
703				require.True(t, sess.registerAttachment(clients[i]))
704				sends[i] = sent
705			}
706			if tt.name == "non-nil attachment stays targeted" {
707				tt.attachment = clients[0]
708				tt.wantFirst = true
709			}
710
711			require.NoError(t, d.closePane(sess, tb, "pane-2", tt.attachment, true))
712			for i, want := range []bool{tt.wantFirst, tt.wantSecond} {
713				select {
714				case frame := <-sends[i]:
715					require.True(t, want, "attachment %d unexpectedly repainted: %#v", i, frame)
716					require.Equal(t, ports.MsgOutput, frame.Type)
717				default:
718					require.False(t, want, "attachment %d was not repainted", i)
719				}
720			}
721		})
722	}
723}
724
725func TestFinalPaneReapClearsCopyModeForEveryAttachment(t *testing.T) {
726	d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
727	tb := testAttachmentTab(sess)
728	other := newTab(nil, domain.Size{Cols: 41, Rows: 10})
729	sess.tabs = append(sess.tabs, other)
730	d.sessions[sess.id] = sess
731	pane := tb.focusedPane()
732	publishTiledPaneOwners(sess, tb)
733	oldPTY.EXPECT().Close().Return(nil).Once()
734
735	for i := range 2 {
736		tr, _ := newCapturingTransport(t)
737		ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 41, Rows: 12}}
738		ac.clientID[0] = byte(i + 1)
739		ac.initOverlays()
740		ac.setSession(sess)
741		require.True(t, sess.registerAttachment(ac))
742		ac.overlays.copyMode = &scopy.Mode{}
743		ac.overlays.copyPane = pane
744	}
745
746	d.reapPaneOwner(pane)
747
748	require.Len(t, sess.tabs, 1)
749	for _, ac := range sess.snapshotAttachments() {
750		require.Nil(t, ac.overlays.copyMode, "final-pane reap left copy mode active for attachment %d", ac.clientID[0])
751		require.Nil(t, ac.overlays.copyPane, "final-pane reap left copy pane active for attachment %d", ac.clientID[0])
752	}
753}
754
755func TestClosePaneActionDoesNotReenterDispatchBoundary(t *testing.T) {
756	d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
757	tb := testAttachmentTab(sess)
758	closingPTY := portsmocks.NewMockPTY(t)
759	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"}
760	tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
761	tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
762	tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
763	d.sessions[sess.id] = sess
764	oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once()
765	closingPTY.EXPECT().Close().Return(nil).Once()
766
767	done := make(chan error, 1)
768	go func() {
769		done <- sess.runMutation(func() error {
770			return (daemonActions{d: d}).Run(daemonActionRequest{
771				kind:   daemonActionClosePane,
772				target: daemonActionTarget{session: sess, tab: tb, pane: tb.panes["pane-2"]},
773			})
774		})
775	}()
776	select {
777	case err := <-done:
778		require.NoError(t, err)
779	case <-time.After(time.Second):
780		t.Fatal("close pane action re-entered dispatch boundary")
781	}
782}
783
784func TestCloseFocusedPaneRemovesPaneReflowsAndIsIdempotent(t *testing.T) {
785	d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
786	tb := testAttachmentTab(sess)
787	closingPTY := portsmocks.NewMockPTY(t)
788	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"}
789	tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
790	tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
791	tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
792	oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once()
793	closingPTY.EXPECT().Close().Return(nil).Once()
794
795	require.NoError(t, d.closeFocusedPane(sess, nil))
796	require.NoError(t, d.closePane(sess, tb, "pane-2", nil, true))
797
798	require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
799	require.Len(t, tb.panes, 1)
800	require.Equal(t, domain.Rect{Width: 41, Height: 10}, tb.panes["pane-1"].rect)
801}
802
803func TestReapPaneSharesClosePathAndIsIdempotent(t *testing.T) {
804	d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
805	tb := testAttachmentTab(sess)
806	reapedPTY := portsmocks.NewMockPTY(t)
807	reaped := newPane("pane-2", reapedPTY, domain.Size{Cols: 20, Rows: 10})
808	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"}
809	tb.panes["pane-2"] = reaped
810	tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
811	tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
812	oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once()
813	reapedPTY.EXPECT().Close().Return(nil).Once()
814
815	d.reapPane(sess, tb, reaped)
816	d.reapPane(sess, tb, reaped)
817
818	require.Len(t, tb.panes, 1)
819	require.NotContains(t, tb.panes, layout.PaneID("pane-2"))
820}
821
822func TestClosePaneKeepsTabOpenWhenPendingSpawnLeafExists(t *testing.T) {
823	d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
824	tb := testAttachmentTab(sess)
825	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"}
826	oldPTY.EXPECT().Close().Return(nil).Once()
827
828	require.NoError(t, d.closePane(sess, tb, "pane-1", nil, false))
829
830	require.Len(t, sess.tabs, 1)
831	require.Empty(t, tb.panes)
832	require.True(t, layout.ContainsLeaf(tb.tree.Root, "pane-2"))
833}
834
835func TestCloseFocusedLastPaneDelegatesToCloseTab(t *testing.T) {
836	d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
837	otherPTY := portsmocks.NewMockPTY(t)
838	other := newTab(otherPTY, domain.Size{Cols: 41, Rows: 10})
839	sess.tabs = append(sess.tabs, other)
840	d.sessions[sess.id] = sess
841	oldPTY.EXPECT().Close().Return(nil).Once()
842
843	require.NoError(t, d.closeFocusedPane(sess, nil))
844
845	require.Len(t, sess.tabs, 1)
846	require.Same(t, other, sess.tabs[0])
847}
848
849func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) {
850	d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5})
851	newPTY := portsmocks.NewMockPTY(t)
852	newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
853	factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()
854
855	require.NoError(t, d.stackPane(sess, nil))
856	tb := testAttachmentTab(sess)
857	require.Equal(t, layout.Stack, tb.tree.Root.Kind)
858	require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
859	require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded)
860	require.Len(t, tb.panes, 2)
861
862	oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()
863	newPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()
864	require.NoError(t, d.toggleStack(sess, nil))
865	require.Equal(t, layout.Split, tb.tree.Root.Kind)
866	require.Equal(t, layout.Vertical, tb.tree.Root.Dir)
867}
868
869func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
870	d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3})
871	newPTY := portsmocks.NewMockPTY(t)
872	newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
873	factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 2}).Return(newPTY, nil).Once()
874	require.NoError(t, d.stackPane(sess, nil))
875	oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()
876
877	require.NoError(t, d.focusDir(sess, nil, layout.Up, nil))
878
879	tb := testAttachmentTab(sess)
880	require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
881	require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded)
882	thirdPTY := portsmocks.NewMockPTY(t)
883	factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", mock.Anything).Return(thirdPTY, nil).Maybe()
884	err := d.stackPane(sess, nil)
885	require.ErrorIs(t, err, layout.ErrTooSmall)
886	require.Len(t, tb.panes, 2)
887
888	var ue *domain.UserError
889	require.ErrorAs(t, err, &ue)
890	require.Equal(t, domain.NoticeLayoutTooSmall, ue.Code)
891	require.Equal(t, domain.NoticeWarn, ue.Severity)
892	require.Equal(t, "not enough space to split", ue.Msg)
893	require.NotContains(t, ue.Msg, layout.ErrTooSmall.Error())
894}
895
896func newSplitTestDaemon(t *testing.T, sz domain.Size) (*Daemon, *session, *portsmocks.MockPTY, *portsmocks.MockPTYFactory) {
897	t.Helper()
898	factory := portsmocks.NewMockPTYFactory(t)
899	clock := portsmocks.NewMockClock(t)
900	clock.EXPECT().Now().Return(time.Unix(0, 0)).Maybe()
901	d := New(factory, clock, slog.New(slog.NewTextHandler(io.Discard, nil)), WithShell("/bin/sh", nil))
902	oldPTY := portsmocks.NewMockPTY(t)
903	tb := newTab(oldPTY, sz)
904	tctx, cancel := context.WithCancel(context.Background())
905	t.Cleanup(cancel)
906	tb.ctx, tb.cancel = tctx, cancel
907	for _, p := range tb.panes {
908		p.ctx, p.cancel = tctx, cancel
909	}
910	sess := &session{sessionCore: sessionCore{id: "s", name: "s"}, cwd: "/work", tabs: []*tab{tb}, ctx: tctx, cancel: cancel}
911	return d, sess, oldPTY, factory
912}
913
914func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
915	writes := make(chan []byte, 1)
916	d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
917	newPTY, releaseNew := newBlockingPTYWithWrites(t, writes)
918	defer releaseNew()
919	oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
920	newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
921	factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
922
923	require.NoError(t, d.splitPane(sess, nil, layout.Right))
924	tb := testAttachmentTab(sess)
925	tb.mu.Lock()
926	tb.tree.Focus = "pane-1"
927	tb.mu.Unlock()
928	oldPTY.EXPECT().Close().Return(nil).Once()
929
930	require.NoError(t, d.closePane(sess, tb, "pane-1", nil, false))
931	require.Equal(t, layout.PaneID("pane-2"), tb.focusedPane().id)
932
933	ac := &attachedClient{}
934	ac.initOverlays()
935	ac.setSession(sess)
936	require.True(t, sess.registerAttachment(ac))
937	daemonKeyHandler{d: d, ac: ac}.Forward([]byte("Z"))
938	require.Equal(t, []byte("Z"), <-writes)
939	tb.focusedPane().screen.Write([]byte("survivor"))
940	content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
941	frame := composeFrame(capturedRenderState{
942		reset:  true,
943		layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true},
944		panes: []capturedPaneRenderState{{
945			id: tb.focusedPane().id, frame: tb.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
946		}},
947		bars: barState{status: sess.statusSegments(true)},
948	}, composeCacheInput{}).frame
949	require.Contains(t, frameRowString(frame, 1), "survivor")
950}
951
952func blockingRead(t *testing.T) func([]byte) (int, error) {
953	t.Helper()
954	ch := make(chan struct{})
955	return func([]byte) (int, error) {
956		<-ch
957		return 0, io.EOF
958	}
959}
960
961func TestSplitPaneUsesExactAuthoritativeEnvironment(t *testing.T) {
962	for _, tt := range []struct {
963		name string
964		term terminalEnv
965		want []string
966	}{
967		{
968			name: "truecolor",
969			term: terminalEnv{TrueColor: true},
970			want: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=vev"},
971		},
972		{
973			name: "no truecolor",
974			want: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=xterm-256color", "TERM_PROGRAM=vev"},
975		},
976	} {
977		t.Run(tt.name, func(t *testing.T) {
978			d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
979			d.shellOverride = false
980			sess.terminal = tt.term
981			sess.env = []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}
982			newPTY := portsmocks.NewMockPTY(t)
983			oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
984			newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
985			var command string
986			var gotEnv []string
987			factory.EXPECT().Open(mock.Anything, mock.Anything, []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn(
988				func(_ context.Context, gotCommand string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
989					command = gotCommand
990					gotEnv = append([]string(nil), env...)
991					return newPTY, nil
992				},
993			).Once()
994
995			require.NoError(t, d.splitPane(sess, nil, layout.Right))
996			tb := testAttachmentTab(sess)
997			tb.mu.Lock()
998			paneID := tb.panes["pane-2"].stableID
999			tabID := tb.stableID
1000			tb.mu.Unlock()
1001			require.Equal(t, "/usr/bin/fish", command)
1002			require.Equal(t, append(tt.want, "VEV=session=s,tab="+tabID+",pane="+paneID), gotEnv)
1003		})
1004	}
1005}

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