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

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