floating_test.go RawHistoryFolder

1package daemon
2
3import (
4	"bytes"
5	"context"
6	"errors"
7	"io"
8	"log/slog"
9	"strings"
10	"sync"
11	"testing"
12	"time"
13
14	"github.com/stretchr/testify/mock"
15	"github.com/stretchr/testify/require"
16
17	"github.com/bnema/vev/internal/domain"
18	"github.com/bnema/vev/internal/domain/layout"
19	"github.com/bnema/vev/internal/ports"
20	portsmocks "github.com/bnema/vev/internal/ports/mocks"
21	"github.com/bnema/vev/internal/protocol"
22	"github.com/bnema/vev/internal/protocol/wire"
23	scopy "github.com/bnema/vev/internal/usecase/copy"
24	themeui "github.com/bnema/vev/internal/usecase/theme"
25)
26
27type gatedFloatingOpen struct {
28	command string
29	args    []string
30	env     []string
31	dir     string
32	size    domain.Size
33}
34
35func newGatedOpenFactory(t *testing.T, result ports.PTY, openErr error) (*portsmocks.MockPTYFactory, <-chan gatedFloatingOpen, func()) {
36	t.Helper()
37	factory := portsmocks.NewMockPTYFactory(t)
38	opened := make(chan gatedFloatingOpen, 1)
39	release := make(chan struct{})
40	var releaseOnce sync.Once
41	releaseOpen := func() { releaseOnce.Do(func() { close(release) }) }
42	t.Cleanup(releaseOpen)
43	factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
44		func(_ context.Context, command string, args, env []string, dir string, geometry domain.Geometry) (ports.PTY, error) {
45			opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), env: append([]string(nil), env...), dir: dir, size: geometry.Size}
46			<-release
47			return result, openErr
48		}).Once()
49	return factory, opened, releaseOpen
50}
51
52func TestFloatingSlotTransitions(t *testing.T) {
53	first := &pane{}
54	second := &pane{}
55	tests := []struct {
56		name string
57		run  func(t *testing.T, tb *tab)
58	}{
59		{
60			name: "warming double toggle retains hidden completion",
61			run: func(t *testing.T, tb *tab) {
62				tb.mu.Lock()
63				generation := tb.beginFloatingWarmLocked(false)
64				start, gotGeneration := tb.toggleFloatingLocked()
65				if start || gotGeneration != generation || !tb.floating.desiredVisible {
66					t.Fatalf("first warming toggle = start %v, generation %d, desired %v", start, gotGeneration, tb.floating.desiredVisible)
67				}
68				start, gotGeneration = tb.toggleFloatingLocked()
69				if start || gotGeneration != generation || tb.floating.desiredVisible {
70					t.Fatalf("second warming toggle = start %v, generation %d, desired %v", start, gotGeneration, tb.floating.desiredVisible)
71				}
72				if !tb.installFloatingLocked(first, generation) || tb.floating.state != floatingHidden || tb.floating.pane != first {
73					t.Fatal("warming completion did not retain a hidden pane")
74				}
75				tb.mu.Unlock()
76			},
77		},
78		{
79			name: "open and hide retain same pane",
80			run: func(t *testing.T, tb *tab) {
81				tb.mu.Lock()
82				generation := tb.beginFloatingWarmLocked(false)
83				if !tb.installFloatingLocked(first, generation) {
84					t.Fatal("install failed")
85				}
86				start, _ := tb.toggleFloatingLocked()
87				if start || tb.floating.state != floatingVisible || tb.floating.pane != first {
88					t.Fatal("hidden open did not retain pane")
89				}
90				tb.toggleFloatingLocked()
91				if tb.floating.state != floatingHidden || tb.floating.pane != first {
92					t.Fatal("visible hide did not retain pane")
93				}
94				tb.mu.Unlock()
95			},
96		},
97		{
98			name: "current launch failure clears desired visibility",
99			run: func(t *testing.T, tb *tab) {
100				tb.mu.Lock()
101				generation := tb.beginFloatingWarmLocked(true)
102				if !tb.failFloatingLocked(generation) || tb.floating.state != floatingUninitialized || tb.floating.desiredVisible || tb.floating.pane != nil {
103					t.Fatal("current launch failure did not clear slot")
104				}
105				tb.mu.Unlock()
106			},
107		},
108		{
109			name: "current exit clears and stale completion is ignored",
110			run: func(t *testing.T, tb *tab) {
111				tb.mu.Lock()
112				generation := tb.beginFloatingWarmLocked(true)
113				if !tb.installFloatingLocked(first, generation) {
114					t.Fatal("install failed")
115				}
116				if !tb.clearFloatingLocked(first, generation) || tb.floating.state != floatingUninitialized || tb.floating.pane != nil {
117					t.Fatal("current exit did not clear slot")
118				}
119				newGeneration := tb.beginFloatingWarmLocked(true)
120				if tb.installFloatingLocked(second, generation) {
121					t.Fatal("stale completion installed")
122				}
123				if tb.floating.state != floatingWarming || tb.floating.generation != newGeneration || tb.floating.pane != nil {
124					t.Fatal("stale completion changed newer warming state")
125				}
126				tb.mu.Unlock()
127			},
128		},
129	}
130
131	for _, tt := range tests {
132		t.Run(tt.name, func(t *testing.T) { tt.run(t, &tab{}) })
133	}
134}
135
136func TestFloatingTransferabilityRejectsOnlyWarmingLaunch(t *testing.T) {
137	tests := []struct {
138		name  string
139		state floatingState
140		want  bool
141	}{
142		{name: "uninitialized", state: floatingUninitialized, want: true},
143		{name: "warming", state: floatingWarming, want: false},
144		{name: "hidden installed", state: floatingHidden, want: true},
145		{name: "visible installed", state: floatingVisible, want: true},
146	}
147	for _, tt := range tests {
148		t.Run(tt.name, func(t *testing.T) {
149			tb := &tab{floating: floatingSlot{state: tt.state, generation: 7}}
150			tb.mu.Lock()
151			got := tb.floatingTransferableLocked()
152			tb.mu.Unlock()
153			require.Equal(t, tt.want, got)
154			require.Equal(t, tt.state, tb.floating.state, "validation must not change launch state")
155			require.Equal(t, uint64(7), tb.floating.generation, "validation must preserve retry generation")
156		})
157	}
158}
159
160func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testing.T) {
161	pty := portsmocks.NewMockPTY(t)
162	readerStarted := make(chan struct{})
163	release := make(chan struct{})
164	var releaseOnce sync.Once
165	unblock := func() { releaseOnce.Do(func() { close(release) }) }
166	pty.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
167		close(readerStarted)
168		<-release
169		return 0, context.Canceled
170	}).Once()
171	pty.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Once()
172	factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil)
173	d := newTestDaemon(t, factory, stubClock{})
174	cwd := t.TempDir()
175	tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})
176	tb.ctx, tb.cancel = context.WithCancel(t.Context())
177	sess := &session{sessionCore: sessionCore{name: "work"}, cwd: cwd, env: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}, tabs: []*tab{tb}, ctx: t.Context()}
178	d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})
179	d.startFloating(sess, tb, false)
180	// Open has started while this goroutine owns tab.mu: an external factory
181	// call under that lock would deadlock this channel-controlled test.
182	tb.mu.Lock()
183	var openCall gatedFloatingOpen
184	select {
185	case openCall = <-opened:
186	case <-time.After(time.Second):
187		t.Fatal("floating Open waited for tab.mu")
188	}
189	tb.mu.Unlock()
190	d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "later", Width: 90, Height: 90}})
191	allowOpen()
192	select {
193	case <-readerStarted: // install started exactly one reader after the async Open
194	case <-time.After(time.Second):
195		t.Fatal("floating pane was not installed")
196	}
197	tb.mu.Lock()
198	floating := tb.floating.pane
199	tb.mu.Unlock()
200	require.NotNil(t, floating)
201	require.Equal(t, []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + tb.stableID + ",pane=" + floating.stableID}, openCall.env)
202	assertPaneDefaultColors(t, floating, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)
203	assertPaneColorScheme(t, floating, false)
204	require.Equal(t, "/bin/custom-shell", openCall.command)
205	require.Equal(t, []string{"-lc", "btop --utf"}, openCall.args)
206	require.Equal(t, cwd, openCall.dir)
207	require.Equal(t, domain.Size{Cols: 48, Rows: 18}, openCall.size)
208	d.teardownFloating(tb, nil)
209	unblock()
210	d.sessWg.Wait()
211}
212
213func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) {
214	initial := domain.Size{Cols: 80, Rows: 25}
215	latest := domain.Size{Cols: 79, Rows: 25}
216	cfg := domain.FloatingConfig{Width: 100, Height: 100}
217	latestGeometry := calculateContentFloatingGeometry(tabSize(latest), cfg)
218
219	readerRelease := make(chan struct{})
220	readerStarted := make(chan struct{})
221	resized := make(chan struct{})
222	floatingPTY := portsmocks.NewMockPTY(t)
223	floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
224		close(readerStarted)
225		<-readerRelease
226		return 0, io.EOF
227	}).Once()
228	floatingPTY.EXPECT().Resize(domain.Geometry{Size: rectSize(latestGeometry.Inner)}).Run(func(domain.Geometry) { close(resized) }).Return(nil).Once()
229	floatingPTY.EXPECT().Close().RunAndReturn(func() error {
230		select {
231		case <-readerRelease:
232		default:
233			close(readerRelease)
234		}
235		return nil
236	}).Once()
237	factory, opened, allowOpen := newGatedOpenFactory(t, floatingPTY, nil)
238	d := newTestDaemon(t, factory, stubClock{})
239	d.ApplyConfig(domain.Config{Floating: cfg})
240	tabCtx, cancelTab := context.WithCancel(t.Context())
241	t.Cleanup(cancelTab)
242	tb := newTab(&transactionalResizePTY{}, tabSize(initial))
243	tb.ctx, tb.cancel = tabCtx, cancelTab
244	sess := &session{sessionCore: sessionCore{id: "responsive-open", name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
245
246	d.startFloating(sess, tb, true)
247	openCall := <-opened
248	require.Equal(t, rectSize(calculateContentFloatingGeometry(tabSize(initial), cfg).Inner), openCall.size)
249	require.True(t, sess.geometry.requestResize(d, sess, nil, latest, true))
250	allowOpen()
251	select {
252	case <-resized:
253	case <-time.After(time.Second):
254		t.Fatal("floating install did not reconcile the resize committed while Open was blocked")
255	}
256	select {
257	case <-readerStarted:
258	case <-time.After(time.Second):
259		t.Fatal("floating reader did not start")
260	}
261
262	require.Eventually(t, func() bool {
263		tb.mu.Lock()
264		p := tb.floating.pane
265		visible := tb.floating.state == floatingVisible
266		tb.mu.Unlock()
267		if !visible || p == nil {
268			return false
269		}
270		p.mu.Lock()
271		defer p.mu.Unlock()
272		return p.popupGeometry == latestGeometry && p.rect == latestGeometry.Inner
273	}, time.Second, time.Millisecond, "floating install retained the stale Open geometry")
274	d.teardownFloating(tb, nil)
275	d.sessWg.Wait()
276}
277
278func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) {
279	floatingPTY := portsmocks.NewMockPTY(t)
280	readerStarted := make(chan struct{})
281	readerRelease := make(chan struct{})
282	var releaseOnce sync.Once
283	releaseReader := func() { releaseOnce.Do(func() { close(readerRelease) }) }
284	var closeCount int
285	floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
286		close(readerStarted)
287		<-readerRelease
288		return 0, io.EOF
289	}).Once()
290	floatingPTY.EXPECT().Close().RunAndReturn(func() error {
291		closeCount++
292		releaseReader()
293		return nil
294	}).Once()
295	factory, opened, releaseOpen := newGatedOpenFactory(t, floatingPTY, nil)
296	d := newTestDaemon(t, factory, stubClock{})
297	tb := newFloatingTestTab(t)
298	sessCtx, cancel := context.WithCancel(t.Context())
299	sess := &session{sessionCore: sessionCore{id: "floating-ownership", name: "work"}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancel}
300	d.mu.Lock()
301	d.sessions[sess.id] = sess
302	d.mu.Unlock()
303
304	// Keep a known worker alive while the waiter starts. Releasing it after
305	// Open is blocked deterministically exposes an uncounted launch worker.
306	d.sessWg.Add(1)
307	joined := waitGroupDone(&d.sessWg)
308	d.startFloating(sess, tb, true)
309	select {
310	case <-opened:
311	case <-time.After(time.Second):
312		t.Fatal("floating Open did not start")
313	}
314	d.sessWg.Done()
315	// Waiting while Open is blocked must retain the launch worker. Otherwise
316	// daemon shutdown can finish and a late install can Add reader goroutines.
317	launchUnaccounted := false
318	select {
319	case <-joined:
320		launchUnaccounted = true
321	case <-time.After(time.Second):
322	}
323	releaseOpen()
324	select {
325	case <-readerStarted:
326	case <-time.After(time.Second):
327		t.Fatal("floating reader did not start")
328	}
329	require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, false))
330	select {
331	case <-waitGroupDone(&d.sessWg):
332	case <-time.After(time.Second):
333		t.Fatal("floating launch, reader, and scheduler did not join")
334	}
335	tb.mu.Lock()
336	require.Nil(t, tb.floating.pane, "shutdown leaked an installed floating pane")
337	require.Equal(t, floatingUninitialized, tb.floating.state)
338	tb.mu.Unlock()
339	require.False(t, launchUnaccounted, "blocked floating launch was not accounted by sessWg")
340	require.Equal(t, 1, closeCount, "floating PTY must close exactly once")
341}
342
343func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *testing.T) {
344	pty := portsmocks.NewMockPTY(t)
345	closed := make(chan struct{})
346	pty.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once()
347	factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil)
348	d := newTestDaemon(t, factory, stubClock{})
349	tb := newFloatingTestTab(t)
350	sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
351	d.startFloating(sess, tb, false)
352	<-opened
353	d.teardownFloating(tb, nil) // invalidate before the delayed Open returns
354	allowOpen()
355	select {
356	case <-closed:
357	case <-time.After(time.Second):
358		t.Fatal("stale completion did not close its PTY")
359	}
360	d.sessWg.Wait()
361
362	first := &pane{}
363	second := &pane{}
364	tb.mu.Lock()
365	g1 := tb.beginFloatingWarmLocked(true)
366	publishPaneOwner(first, sess, tb, g1)
367	require.True(t, tb.installFloatingLocked(first, g1))
368	clearSucceeded := tb.clearFloatingLocked(first, g1)
369	require.True(t, clearSucceeded)
370	generation := tb.beginFloatingWarmLocked(true)
371	require.True(t, tb.installFloatingLocked(second, generation))
372	tb.mu.Unlock()
373	d.reapInstalledFloating(first)
374	tb.mu.Lock()
375	require.Same(t, second, tb.floating.pane, "old EOF must not clear replacement")
376	tb.mu.Unlock()
377}
378
379func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) {
380	factory := &lifetimePTYFactory{}
381	d := newTestDaemon(t, factory, stubClock{})
382	tb := newFloatingTestTab(t)
383	source := &session{sessionCore: sessionCore{id: "source", name: "source"}, tabs: []*tab{tb}, ctx: t.Context()}
384	destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, tabs: []*tab{tb}, ctx: t.Context()}
385
386	tb.mu.Lock()
387	generation := tb.beginFloatingWarmLocked(false)
388	tb.mu.Unlock()
389	d.openAndInstallFloating(source, tb, floatingLaunchSpec{
390		sessionName:  source.name,
391		size:         domain.Size{Cols: 20, Rows: 8},
392		geometry:     floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
393		paneStableID: "p_floating",
394		parentCtx:    tb.ctx,
395	}, generation)
396
397	tb.mu.Lock()
398	floating := tb.floating.pane
399	tb.mu.Unlock()
400	require.NotNil(t, floating)
401	tb.mu.Lock()
402	floating.mu.Lock()
403	floating.publishOwnerLocked(destination, tb, generation)
404	floating.mu.Unlock()
405	tb.mu.Unlock()
406
407	sourceClient := &attachedClient{render: attachmentRenderState{panes: map[*pane]capturedPaneRenderState{floating: {}}}}
408	sourceClient.initOverlays()
409	destinationClient := &attachedClient{render: attachmentRenderState{panes: map[*pane]capturedPaneRenderState{floating: {}}}}
410	destinationClient.initOverlays()
411	source.registerAttachment(sourceClient)
412	destination.registerAttachment(destinationClient)
413
414	floating.onExit()
415	d.sessWg.Wait()
416
417	tb.mu.Lock()
418	require.Equal(t, floatingUninitialized, tb.floating.state)
419	require.Nil(t, tb.floating.pane)
420	tb.mu.Unlock()
421	sourceClient.sendMu.Lock()
422	_, sourceRetained := sourceClient.render.panes[floating]
423	sourceClient.sendMu.Unlock()
424	destinationClient.sendMu.Lock()
425	_, destinationRetained := destinationClient.render.panes[floating]
426	destinationClient.sendMu.Unlock()
427	require.True(t, sourceRetained, "exit must not clean up the retired source owner")
428	require.False(t, destinationRetained, "exit must clean up the current destination owner")
429}
430
431func TestFloatingReapAndTeardownCloseMatchingPaneOnce(t *testing.T) {
432	pty, _ := newBlockingPTY(t)
433	tb := newFloatingTestTab(t)
434	p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
435	sess := &session{}
436	tb.mu.Lock()
437	g := tb.beginFloatingWarmLocked(true)
438	publishPaneOwner(p, sess, tb, g)
439	require.True(t, tb.installFloatingLocked(p, g))
440	tb.mu.Unlock()
441	d := newTestDaemon(t, nil, stubClock{})
442	d.reapInstalledFloating(p)
443	d.teardownFloating(tb, nil)
444	pty.AssertNumberOfCalls(t, "Close", 1)
445}
446
447func TestFloatingTeardownClosesInstalledPaneOnce(t *testing.T) {
448	pty, _ := newBlockingPTY(t)
449	tb := newFloatingTestTab(t)
450	p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
451	tb.mu.Lock()
452	g := tb.beginFloatingWarmLocked(false)
453	require.True(t, tb.installFloatingLocked(p, g))
454	tb.mu.Unlock()
455	d := newTestDaemon(t, nil, stubClock{})
456	d.teardownFloating(tb, nil)
457	d.teardownFloating(tb, nil)
458	pty.AssertNumberOfCalls(t, "Close", 1)
459}
460
461func TestFloatingTeardownClearsCapturedCopyMode(t *testing.T) {
462	pty, _ := newBlockingPTY(t)
463	tb := newFloatingTestTab(t)
464	p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
465	tb.mu.Lock()
466	generation := tb.beginFloatingWarmLocked(true)
467	require.True(t, tb.installFloatingLocked(p, generation))
468	tb.mu.Unlock()
469	ac := &attachedClient{}
470	ac.initOverlays()
471	ac.overlays.copyMu.Lock()
472	ac.overlays.copyMode = &scopy.Mode{}
473	ac.overlays.copyPane = p
474	ac.overlays.copyMu.Unlock()
475	d := newTestDaemon(t, nil, stubClock{})
476
477	d.teardownFloating(tb, ac)
478
479	require.False(t, ac.overlays.copyActive())
480	ac.overlays.copyMu.Lock()
481	require.Nil(t, ac.overlays.copyPane)
482	ac.overlays.copyMu.Unlock()
483}
484
485func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
486	newCase := func(t *testing.T, state floatingState) (*Daemon, *session, *attachedClient, chan wire.Envelope, *tab, *pane, func()) {
487		t.Helper()
488		normalPTY, releaseNormal := newBlockingPTY(t)
489		d, sess, ac, sends := newManualSessionWithPTYs(t, normalPTY)
490		tb := testAttachmentTab(sess)
491		require.NotNil(t, tb)
492		tb.mu.Lock()
493		normal := tb.focusedPane()
494		normal.mu.Lock()
495		normal.screen.Write([]byte("underlying-cell"))
496		normal.mu.Unlock()
497
498		floatingPTY := portsmocks.NewMockPTY(t)
499		floatingPTY.EXPECT().Close().Return(nil).Maybe()
500		releaseEOF := make(chan struct{})
501		floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
502			<-releaseEOF
503			return 0, io.EOF
504		}).Once()
505		floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8})
506		generation := tb.beginFloatingWarmLocked(state == floatingVisible)
507		publishPaneOwner(floating, sess, tb, generation)
508		require.True(t, tb.installFloatingLocked(floating, generation))
509		if state == floatingHidden {
510			require.Equal(t, floatingHidden, tb.floating.state)
511		}
512		tb.mu.Unlock()
513
514		// Establish a renderer shadow first. The EOF repaint must reset it and
515		// redraw the underlying cell rather than depending on popup damage.
516		d.paint(sess, ac, true, nil)
517		baseline := awaitFrame(t, sends, "Output")
518		baselineOutput := unmarshalTestOutput(t, baseline.Payload)
519		require.Contains(t, string(baselineOutput.Data), "underlying-cell")
520
521		reaped := make(chan struct{})
522		floating.onExit = func() {
523			d.reapInstalledFloating(floating)
524			close(reaped)
525		}
526		d.sessWg.Add(1)
527		go d.ptyReader(sess, tb, floating)
528		return d, sess, ac, sends, tb, floating, func() {
529			close(releaseEOF)
530			<-reaped
531			d.sessWg.Wait()
532			releaseNormal()
533		}
534	}
535
536	t.Run("visible matching EOF clears and fully repaints underlying content", func(t *testing.T) {
537		_, _, _, sends, tb, floating, exit := newCase(t, floatingVisible)
538		exit()
539
540		tb.mu.Lock()
541		require.Equal(t, floatingUninitialized, tb.floating.state)
542		require.Nil(t, tb.floating.pane)
543		tb.mu.Unlock()
544		repaint := awaitFrame(t, sends, "Output")
545		output := unmarshalTestOutput(t, repaint.Payload)
546		require.Zero(t, output.Base, "visible EOF must force a dependency-free full repaint")
547		require.Contains(t, string(output.Data), "underlying-cell", "repaint must restore cells previously covered by the popup")
548		require.NotNil(t, floating)
549	})
550
551	t.Run("hidden matching EOF clears without repaint", func(t *testing.T) {
552		_, _, _, sends, tb, _, exit := newCase(t, floatingHidden)
553		exit()
554
555		tb.mu.Lock()
556		require.Equal(t, floatingUninitialized, tb.floating.state)
557		require.Nil(t, tb.floating.pane)
558		tb.mu.Unlock()
559		select {
560		case frame := <-sends:
561			t.Fatalf("hidden EOF unexpectedly repainted: %#v", frame)
562		default:
563		}
564	})
565
566	t.Run("stale EOF is ignored without repaint", func(t *testing.T) {
567		_, _, _, sends, tb, stale, exit := newCase(t, floatingHidden)
568		tb.mu.Lock()
569		generation := tb.floating.generation
570		require.True(t, tb.clearFloatingLocked(stale, generation))
571		current := newPane(layout.PaneID("replacement"), newBlockingPanePTY(t), domain.Size{Cols: 20, Rows: 8})
572		currentGeneration := tb.beginFloatingWarmLocked(true)
573		require.True(t, tb.installFloatingLocked(current, currentGeneration))
574		tb.mu.Unlock()
575		exit()
576
577		tb.mu.Lock()
578		require.Same(t, current, tb.floating.pane)
579		require.Equal(t, floatingVisible, tb.floating.state)
580		tb.mu.Unlock()
581		select {
582		case frame := <-sends:
583			t.Fatalf("stale EOF unexpectedly repainted: %#v", frame)
584		default:
585		}
586	})
587}
588
589func newFloatingTestTab(t *testing.T) *tab {
590	t.Helper()
591	tb := newTabWithStableID("t_test", "p_test", newBlockingPanePTY(t), domain.Size{Cols: 80, Rows: 24})
592	tb.ctx, tb.cancel = context.WithCancel(t.Context())
593	return tb
594}
595
596func newBlockingPanePTY(t *testing.T) *portsmocks.MockPTY {
597	t.Helper()
598	p, _ := newBlockingPTY(t)
599	return p
600}
601
602func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) {
603	tests := []struct {
604		name       string
605		sessionCwd string
606		liveCwd    string
607		wantCwd    string
608	}{
609		{name: "valid session cwd", sessionCwd: "/valid", wantCwd: "/valid"},
610		{name: "invalid session cwd falls home", sessionCwd: "/invalid", wantCwd: "/home/test"},
611		{name: "live focused cwd", sessionCwd: "/invalid", liveCwd: "/live", wantCwd: "/live"},
612	}
613	for _, tt := range tests {
614		t.Run(tt.name, func(t *testing.T) {
615			factory := portsmocks.NewMockPTYFactory(t)
616			opened := make(chan string, 1)
617			factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
618				func(_ context.Context, _ string, _ []string, _ []string, cwd string, _ domain.Geometry) (ports.PTY, error) {
619					opened <- cwd
620					return nil, io.ErrUnexpectedEOF
621				}).Once()
622			d := newTestDaemon(t, factory, stubClock{})
623			d.dirOrHome = func(cwd string) string {
624				if cwd == "/invalid" {
625					return "/home/test"
626				}
627				return cwd
628			}
629			if tt.liveCwd != "" {
630				WithCwdReader(func(int) (string, error) { return tt.liveCwd, nil })(d)
631			}
632			tb := newFloatingTestTab(t)
633			sess := &session{sessionCore: sessionCore{name: "work"}, cwd: tt.sessionCwd, tabs: []*tab{tb}, ctx: t.Context()}
634			d.startFloating(sess, tb, false)
635			select {
636			case got := <-opened:
637				require.Equal(t, tt.wantCwd, got)
638			case <-time.After(time.Second):
639				t.Fatal("floating PTY was not opened")
640			}
641			d.sessWg.Wait()
642		})
643	}
644}
645
646type floatingLogHandler struct {
647	slog.Handler
648	handled chan struct{}
649	mu      sync.Mutex
650}
651
652func (h *floatingLogHandler) Handle(ctx context.Context, r slog.Record) error {
653	err := h.Handler.Handle(ctx, r)
654	h.mu.Lock()
655	select {
656	case <-h.handled:
657	default:
658		close(h.handled)
659	}
660	h.mu.Unlock()
661	return err
662}
663
664func TestFloatingLaunchFailureCapturesSessionNameBeforeConcurrentRename(t *testing.T) {
665	factory, opened, release := newGatedOpenFactory(t, nil, io.ErrUnexpectedEOF)
666	var logs bytes.Buffer
667	handled := make(chan struct{})
668	d := newTestDaemon(t, factory, stubClock{})
669	d.log = slog.New(&floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled})
670	tb := newFloatingTestTab(t)
671	sess := &session{sessionCore: sessionCore{name: "captured"}, tabs: []*tab{tb}, ctx: t.Context()}
672	d.startFloating(sess, tb, false)
673	<-opened
674	stopRename := make(chan struct{})
675	renameDone := make(chan struct{})
676	go func() {
677		defer close(renameDone)
678		for {
679			select {
680			case <-stopRename:
681				return
682			default:
683			}
684			sess.mu.Lock()
685			sess.name = "renamed"
686			sess.mu.Unlock()
687		}
688	}()
689	release()
690	select {
691	case <-handled:
692	case <-time.After(time.Second):
693		t.Fatal("floating launch failure was not logged")
694	}
695	close(stopRename)
696	<-renameDone
697	d.sessWg.Wait()
698	require.True(t, strings.Contains(logs.String(), "session=captured"), logs.String())
699}
700
701func TestFloatingInstallLogsSessionNameSafelyDuringRename(t *testing.T) {
702	floatingPTY, _ := newBlockingPTY(t)
703	factory, opened, allowOpen := newGatedOpenFactory(t, floatingPTY, nil)
704	var logs bytes.Buffer
705	handled := make(chan struct{})
706	d := newTestDaemon(t, factory, stubClock{})
707	d.log = slog.New(&floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled})
708	tb := newFloatingTestTab(t)
709	sess := &session{sessionCore: sessionCore{name: "captured"}, tabs: []*tab{tb}, ctx: t.Context()}
710
711	d.startFloating(sess, tb, false)
712	<-opened
713	renamed := make(chan struct{})
714	stopRename := make(chan struct{})
715	renameDone := make(chan struct{})
716	go func() {
717		defer close(renameDone)
718		sess.mu.Lock()
719		sess.name = "renamed"
720		sess.mu.Unlock()
721		close(renamed)
722		for {
723			select {
724			case <-stopRename:
725				return
726			default:
727			}
728			sess.mu.Lock()
729			sess.name = "renamed"
730			sess.mu.Unlock()
731		}
732	}()
733	<-renamed
734	allowOpen()
735	select {
736	case <-handled:
737	case <-time.After(time.Second):
738		t.Fatal("installed floating pane was not logged")
739	}
740	close(stopRename)
741	<-renameDone
742	require.Contains(t, logs.String(), "session=renamed", logs.String())
743
744	d.teardownFloating(tb, nil)
745	d.sessWg.Wait()
746}
747
748// TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen drives a failed
749// asynchronous PTY open through failFloatingLaunch. A user-initiated open
750// must surface a toast; a background prewarm must stay silent (the user
751// never asked for it). The failure completes on a worker goroutine, so the
752// test waits on d.sessWg (which the launch worker joins) before asserting on
753// the test goroutine -- no require inside the worker, no sleeps.
754func TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen(t *testing.T) {
755	tests := []struct {
756		name     string
757		userOpen bool
758	}{
759		{name: "user-initiated open toasts", userOpen: true},
760		{name: "background prewarm stays silent", userOpen: false},
761	}
762	for _, tc := range tests {
763		t.Run(tc.name, func(t *testing.T) {
764			spawnErr := errors.New("fork/exec: no such file or directory")
765			factory := portsmocks.NewMockPTYFactory(t)
766			factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
767				Return(nil, spawnErr).Once()
768			d := newTestDaemon(t, factory, stubClock{})
769			tb := newFloatingTestTab(t)
770			tr, _ := newCapturingTransport(t)
771			ac := &attachedClient{tr: tr, output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}}
772			ac.initOverlays()
773			sess := &session{sessionCore: sessionCore{id: "floating-spawn", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: t.Context()}
774			ac.setSession(sess)
775
776			d.startFloating(sess, tb, tc.userOpen)
777
778			// The launch worker (registered via d.sessWg.Go in launchFloating)
779			// runs failFloatingLaunch synchronously before returning, so
780			// waiting on the group is a deterministic completion signal.
781			d.sessWg.Wait()
782
783			if tc.userOpen {
784				toasts := awaitToastCount(t, ac, 1)
785				require.Equal(t, domain.NoticeFloatingSpawn, toasts[0].Code)
786				require.Equal(t, "couldn't open floating pane: command failed to start", toasts[0].Message)
787			} else {
788				ns, _ := visibleToasts(ac)
789				require.Empty(t, ns, "background prewarm failure must not toast")
790			}
791		})
792	}
793}
794
795func TestToggleFloatingStructuralErrorsAreUserErrors(t *testing.T) {
796	tests := []struct {
797		name    string
798		d       *Daemon
799		sess    *session
800		wantMsg string
801		cause   error
802	}{
803		{"nil session", newTestDaemon(t, nil, stubClock{}), nil, "couldn't open floating pane: no active session", nil},
804		{"no active tab", newTestDaemon(t, nil, stubClock{}), &session{}, "couldn't open floating pane: no active tab", layout.ErrNotFound},
805	}
806	for _, tc := range tests {
807		t.Run(tc.name, func(t *testing.T) {
808			err := tc.d.toggleFloating(tc.sess, nil)
809			require.Error(t, err)
810
811			var ue *domain.UserError
812			require.ErrorAs(t, err, &ue)
813			require.Equal(t, domain.NoticeFloatingSpawn, ue.Code)
814			require.Equal(t, domain.NoticeError, ue.Severity)
815			require.Equal(t, tc.wantMsg, ue.Msg)
816			if tc.cause != nil {
817				require.NotContains(t, ue.Msg, tc.cause.Error())
818				require.ErrorIs(t, ue, tc.cause)
819			}
820		})
821	}
822}
823
824func TestFloatingToggleUsesVisibleTarget(t *testing.T) {
825	d := newTestDaemon(t, nil, stubClock{})
826	tb := newFloatingTestTab(t)
827	sess := &session{tabs: []*tab{tb}, ctx: t.Context()}
828	tb.mu.Lock()
829	g := tb.beginFloatingWarmLocked(false)
830	floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8})
831	require.True(t, tb.installFloatingLocked(floating, g))
832	tb.mu.Unlock()
833	require.NoError(t, d.toggleFloating(sess, nil))
834	tb.mu.Lock()
835	require.Same(t, floating, tb.terminalTargetLocked())
836	tb.mu.Unlock()
837}

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