v0.3.0 99dc266

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/ports"
19	portsmocks "github.com/bnema/vev/internal/ports/mocks"
20	scopy "github.com/bnema/vev/internal/usecase/copy"
21	"github.com/bnema/vev/internal/usecase/layout"
22	themeui "github.com/bnema/vev/internal/usecase/theme"
23)
24
25type gatedFloatingOpen struct {
26	command string
27	args    []string
28	env     []string
29	dir     string
30	size    domain.Size
31}
32
33func newGatedOpenFactory(t *testing.T, result ports.PTY, openErr error) (*portsmocks.MockPTYFactory, <-chan gatedFloatingOpen, func()) {
34	t.Helper()
35	factory := portsmocks.NewMockPTYFactory(t)
36	opened := make(chan gatedFloatingOpen, 1)
37	release := make(chan struct{})
38	var releaseOnce sync.Once
39	releaseOpen := func() { releaseOnce.Do(func() { close(release) }) }
40	t.Cleanup(releaseOpen)
41	factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
42		func(_ context.Context, command string, args, env []string, dir string, size domain.Size) (ports.PTY, error) {
43			opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), env: append([]string(nil), env...), dir: dir, size: size}
44			<-release
45			return result, openErr
46		}).Once()
47	return factory, opened, releaseOpen
48}
49
50func TestFloatingSlotTransitions(t *testing.T) {
51	first := &pane{}
52	second := &pane{}
53	tests := []struct {
54		name string
55		run  func(t *testing.T, tb *tab)
56	}{
57		{
58			name: "warming double toggle retains hidden completion",
59			run: func(t *testing.T, tb *tab) {
60				tb.mu.Lock()
61				generation := tb.beginFloatingWarmLocked(false)
62				start, gotGeneration := tb.toggleFloatingLocked()
63				if start || gotGeneration != generation || !tb.floating.desiredVisible {
64					t.Fatalf("first warming toggle = start %v, generation %d, desired %v", start, gotGeneration, tb.floating.desiredVisible)
65				}
66				start, gotGeneration = tb.toggleFloatingLocked()
67				if start || gotGeneration != generation || tb.floating.desiredVisible {
68					t.Fatalf("second warming toggle = start %v, generation %d, desired %v", start, gotGeneration, tb.floating.desiredVisible)
69				}
70				if !tb.installFloatingLocked(first, generation) || tb.floating.state != floatingHidden || tb.floating.pane != first {
71					t.Fatal("warming completion did not retain a hidden pane")
72				}
73				tb.mu.Unlock()
74			},
75		},
76		{
77			name: "open and hide retain same pane",
78			run: func(t *testing.T, tb *tab) {
79				tb.mu.Lock()
80				generation := tb.beginFloatingWarmLocked(false)
81				if !tb.installFloatingLocked(first, generation) {
82					t.Fatal("install failed")
83				}
84				start, _ := tb.toggleFloatingLocked()
85				if start || tb.floating.state != floatingVisible || tb.floating.pane != first {
86					t.Fatal("hidden open did not retain pane")
87				}
88				tb.toggleFloatingLocked()
89				if tb.floating.state != floatingHidden || tb.floating.pane != first {
90					t.Fatal("visible hide did not retain pane")
91				}
92				tb.mu.Unlock()
93			},
94		},
95		{
96			name: "current launch failure clears desired visibility",
97			run: func(t *testing.T, tb *tab) {
98				tb.mu.Lock()
99				generation := tb.beginFloatingWarmLocked(true)
100				if !tb.failFloatingLocked(generation) || tb.floating.state != floatingUninitialized || tb.floating.desiredVisible || tb.floating.pane != nil {
101					t.Fatal("current launch failure did not clear slot")
102				}
103				tb.mu.Unlock()
104			},
105		},
106		{
107			name: "current exit clears and stale completion is ignored",
108			run: func(t *testing.T, tb *tab) {
109				tb.mu.Lock()
110				generation := tb.beginFloatingWarmLocked(true)
111				if !tb.installFloatingLocked(first, generation) {
112					t.Fatal("install failed")
113				}
114				if !tb.clearFloatingLocked(first, generation) || tb.floating.state != floatingUninitialized || tb.floating.pane != nil {
115					t.Fatal("current exit did not clear slot")
116				}
117				newGeneration := tb.beginFloatingWarmLocked(true)
118				if tb.installFloatingLocked(second, generation) {
119					t.Fatal("stale completion installed")
120				}
121				if tb.floating.state != floatingWarming || tb.floating.generation != newGeneration || tb.floating.pane != nil {
122					t.Fatal("stale completion changed newer warming state")
123				}
124				tb.mu.Unlock()
125			},
126		},
127	}
128
129	for _, tt := range tests {
130		t.Run(tt.name, func(t *testing.T) { tt.run(t, &tab{}) })
131	}
132}
133
134func TestFloatingTransferabilityRejectsOnlyWarmingLaunch(t *testing.T) {
135	tests := []struct {
136		name  string
137		state floatingState
138		want  bool
139	}{
140		{name: "uninitialized", state: floatingUninitialized, want: true},
141		{name: "warming", state: floatingWarming, want: false},
142		{name: "hidden installed", state: floatingHidden, want: true},
143		{name: "visible installed", state: floatingVisible, want: true},
144	}
145	for _, tt := range tests {
146		t.Run(tt.name, func(t *testing.T) {
147			tb := &tab{floating: floatingSlot{state: tt.state, generation: 7}}
148			tb.mu.Lock()
149			got := tb.floatingTransferableLocked()
150			tb.mu.Unlock()
151			require.Equal(t, tt.want, got)
152			require.Equal(t, tt.state, tb.floating.state, "validation must not change launch state")
153			require.Equal(t, uint64(7), tb.floating.generation, "validation must preserve retry generation")
154		})
155	}
156}
157
158func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testing.T) {
159	pty := portsmocks.NewMockPTY(t)
160	readerStarted := make(chan struct{})
161	release := make(chan struct{})
162	var releaseOnce sync.Once
163	unblock := func() { releaseOnce.Do(func() { close(release) }) }
164	pty.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
165		close(readerStarted)
166		<-release
167		return 0, context.Canceled
168	}).Once()
169	pty.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Once()
170	factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil)
171	d := newTestDaemon(t, factory, stubClock{})
172	cwd := t.TempDir()
173	tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})
174	tb.ctx, tb.cancel = context.WithCancel(t.Context())
175	sess := &session{sessionCore: sessionCore{name: "work"}, cwd: cwd, terminal: terminalEnv{TrueColor: true}, 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()}
176	d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})
177	d.ensureFloatingWarm(sess, tb)
178	// Open has started while this goroutine owns tab.mu: an external factory
179	// call under that lock would deadlock this channel-controlled test.
180	tb.mu.Lock()
181	var openCall gatedFloatingOpen
182	select {
183	case openCall = <-opened:
184	case <-time.After(time.Second):
185		t.Fatal("floating Open waited for tab.mu")
186	}
187	tb.mu.Unlock()
188	d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "later", Width: 90, Height: 90}})
189	allowOpen()
190	select {
191	case <-readerStarted: // install started exactly one reader after the async Open
192	case <-time.After(time.Second):
193		t.Fatal("floating pane was not installed")
194	}
195	tb.mu.Lock()
196	floating := tb.floating.pane
197	tb.mu.Unlock()
198	require.NotNil(t, floating)
199	require.Equal(t, []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + tb.stableID + ",pane=" + floating.stableID}, openCall.env)
200	assertPaneDefaultColors(t, floating, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)
201	assertPaneColorScheme(t, floating, false)
202	require.Equal(t, "/bin/custom-shell", openCall.command)
203	require.Equal(t, []string{"-lc", "btop --utf"}, openCall.args)
204	require.Equal(t, cwd, openCall.dir)
205	require.Equal(t, domain.Size{Cols: 48, Rows: 18}, openCall.size)
206	d.teardownFloating(tb, nil)
207	unblock()
208	d.sessWg.Wait()
209}
210
211func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) {
212	initial := domain.Size{Cols: 80, Rows: 25}
213	latest := domain.Size{Cols: 79, Rows: 25}
214	cfg := domain.FloatingConfig{Width: 100, Height: 100}
215	latestGeometry := calculateContentFloatingGeometry(tabSize(latest), cfg)
216
217	readerRelease := make(chan struct{})
218	readerStarted := make(chan struct{})
219	resized := make(chan struct{})
220	floatingPTY := portsmocks.NewMockPTY(t)
221	floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
222		close(readerStarted)
223		<-readerRelease
224		return 0, io.EOF
225	}).Once()
226	floatingPTY.EXPECT().Resize(rectSize(latestGeometry.Inner)).Run(func(domain.Size) { close(resized) }).Return(nil).Once()
227	floatingPTY.EXPECT().Close().RunAndReturn(func() error {
228		select {
229		case <-readerRelease:
230		default:
231			close(readerRelease)
232		}
233		return nil
234	}).Once()
235	factory, opened, allowOpen := newGatedOpenFactory(t, floatingPTY, nil)
236	d := newTestDaemon(t, factory, stubClock{})
237	d.ApplyConfig(domain.Config{Floating: cfg})
238	tabCtx, cancelTab := context.WithCancel(t.Context())
239	t.Cleanup(cancelTab)
240	tb := newTab(&transactionalResizePTY{}, tabSize(initial))
241	tb.ctx, tb.cancel = tabCtx, cancelTab
242	sess := &session{sessionCore: sessionCore{id: "responsive-open", name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
243
244	d.startFloating(sess, tb, true)
245	openCall := <-opened
246	require.Equal(t, rectSize(calculateContentFloatingGeometry(tabSize(initial), cfg).Inner), openCall.size)
247	require.True(t, d.requestTransactionalResize(sess, nil, latest, true))
248	allowOpen()
249	select {
250	case <-resized:
251	case <-time.After(time.Second):
252		t.Fatal("floating install did not reconcile the resize committed while Open was blocked")
253	}
254	select {
255	case <-readerStarted:
256	case <-time.After(time.Second):
257		t.Fatal("floating reader did not start")
258	}
259
260	require.Eventually(t, func() bool {
261		tb.mu.Lock()
262		p := tb.floating.pane
263		visible := tb.floating.state == floatingVisible
264		tb.mu.Unlock()
265		if !visible || p == nil {
266			return false
267		}
268		p.mu.Lock()
269		defer p.mu.Unlock()
270		return p.popupGeometry == latestGeometry && p.rect == latestGeometry.Inner
271	}, time.Second, time.Millisecond, "floating install retained the stale Open geometry")
272	d.teardownFloating(tb, nil)
273	d.sessWg.Wait()
274}
275
276func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) {
277	floatingPTY := portsmocks.NewMockPTY(t)
278	readerStarted := make(chan struct{})
279	readerRelease := make(chan struct{})
280	var releaseOnce sync.Once
281	releaseReader := func() { releaseOnce.Do(func() { close(readerRelease) }) }
282	var closeCount int
283	floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
284		close(readerStarted)
285		<-readerRelease
286		return 0, io.EOF
287	}).Once()
288	floatingPTY.EXPECT().Close().RunAndReturn(func() error {
289		closeCount++
290		releaseReader()
291		return nil
292	}).Once()
293	factory, opened, releaseOpen := newGatedOpenFactory(t, floatingPTY, nil)
294	d := newTestDaemon(t, factory, stubClock{})
295	tb := newFloatingTestTab(t)
296	sessCtx, cancel := context.WithCancel(t.Context())
297	sess := &session{sessionCore: sessionCore{id: "floating-ownership", name: "work"}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancel}
298	d.mu.Lock()
299	d.sessions[sess.id] = sess
300	d.mu.Unlock()
301
302	// Keep a known worker alive while the waiter starts. Releasing it after
303	// Open is blocked deterministically exposes an uncounted launch worker.
304	d.sessWg.Add(1)
305	joined := waitGroupDone(&d.sessWg)
306	d.startFloating(sess, tb, true)
307	select {
308	case <-opened:
309	case <-time.After(time.Second):
310		t.Fatal("floating Open did not start")
311	}
312	d.sessWg.Done()
313	// Waiting while Open is blocked must retain the launch worker. Otherwise
314	// daemon shutdown can finish and a late install can Add reader goroutines.
315	launchUnaccounted := false
316	select {
317	case <-joined:
318		launchUnaccounted = true
319	case <-time.After(time.Second):
320	}
321	releaseOpen()
322	select {
323	case <-readerStarted:
324	case <-time.After(time.Second):
325		t.Fatal("floating reader did not start")
326	}
327	require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
328	select {
329	case <-waitGroupDone(&d.sessWg):
330	case <-time.After(time.Second):
331		t.Fatal("floating launch, reader, and scheduler did not join")
332	}
333	tb.mu.Lock()
334	require.Nil(t, tb.floating.pane, "shutdown leaked an installed floating pane")
335	require.Equal(t, floatingUninitialized, tb.floating.state)
336	tb.mu.Unlock()
337	require.False(t, launchUnaccounted, "blocked floating launch was not accounted by sessWg")
338	require.Equal(t, 1, closeCount, "floating PTY must close exactly once")
339}
340
341func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *testing.T) {
342	pty := portsmocks.NewMockPTY(t)
343	closed := make(chan struct{})
344	pty.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once()
345	factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil)
346	d := newTestDaemon(t, factory, stubClock{})
347	tb := newFloatingTestTab(t)
348	sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
349	d.ensureFloatingWarm(sess, tb)
350	<-opened
351	d.teardownFloating(tb, nil) // invalidate before the delayed Open returns
352	allowOpen()
353	select {
354	case <-closed:
355	case <-time.After(time.Second):
356		t.Fatal("stale completion did not close its PTY")
357	}
358	d.sessWg.Wait()
359
360	first := &pane{}
361	second := &pane{}
362	tb.mu.Lock()
363	g1 := tb.beginFloatingWarmLocked(true)
364	publishPaneOwner(first, sess, tb, g1)
365	require.True(t, tb.installFloatingLocked(first, g1))
366	clearSucceeded := tb.clearFloatingLocked(first, g1)
367	require.True(t, clearSucceeded)
368	generation := tb.beginFloatingWarmLocked(true)
369	require.True(t, tb.installFloatingLocked(second, generation))
370	tb.mu.Unlock()
371	d.reapInstalledFloating(first)
372	tb.mu.Lock()
373	require.Same(t, second, tb.floating.pane, "old EOF must not clear replacement")
374	tb.mu.Unlock()
375}
376
377func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) {
378	factory := &lifetimePTYFactory{}
379	d := newTestDaemon(t, factory, stubClock{})
380	tb := newFloatingTestTab(t)
381	source := &session{sessionCore: sessionCore{id: "source", name: "source"}, tabs: []*tab{tb}, ctx: t.Context()}
382	destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, tabs: []*tab{tb}, ctx: t.Context()}
383
384	tb.mu.Lock()
385	generation := tb.beginFloatingWarmLocked(false)
386	tb.mu.Unlock()
387	d.openAndInstallFloating(source, tb, floatingLaunchSpec{
388		sessionName:  source.name,
389		size:         domain.Size{Cols: 20, Rows: 8},
390		geometry:     floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
391		paneStableID: "p_floating",
392		parentCtx:    tb.ctx,
393	}, generation)
394
395	tb.mu.Lock()
396	floating := tb.floating.pane
397	tb.mu.Unlock()
398	require.NotNil(t, floating)
399	tb.mu.Lock()
400	floating.mu.Lock()
401	floating.publishOwnerLocked(destination, tb, generation)
402	floating.mu.Unlock()
403	tb.mu.Unlock()
404
405	sourceClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}}
406	sourceClient.initOverlays()
407	destinationClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}}
408	destinationClient.initOverlays()
409	source.registerAttachment(sourceClient)
410	destination.registerAttachment(destinationClient)
411
412	floating.onExit()
413	d.sessWg.Wait()
414
415	tb.mu.Lock()
416	require.Equal(t, floatingUninitialized, tb.floating.state)
417	require.Nil(t, tb.floating.pane)
418	tb.mu.Unlock()
419	sourceClient.sendMu.Lock()
420	_, sourceRetained := sourceClient.captureFrames[floating]
421	sourceClient.sendMu.Unlock()
422	destinationClient.sendMu.Lock()
423	_, destinationRetained := destinationClient.captureFrames[floating]
424	destinationClient.sendMu.Unlock()
425	require.True(t, sourceRetained, "exit must not clean up the retired source owner")
426	require.False(t, destinationRetained, "exit must clean up the current destination owner")
427}
428
429func TestFloatingReapAndTeardownCloseMatchingPaneOnce(t *testing.T) {
430	pty, _ := newBlockingPTY(t)
431	tb := newFloatingTestTab(t)
432	p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
433	sess := &session{}
434	tb.mu.Lock()
435	g := tb.beginFloatingWarmLocked(true)
436	publishPaneOwner(p, sess, tb, g)
437	require.True(t, tb.installFloatingLocked(p, g))
438	tb.mu.Unlock()
439	d := newTestDaemon(t, nil, stubClock{})
440	d.reapInstalledFloating(p)
441	d.teardownFloating(tb, nil)
442	pty.AssertNumberOfCalls(t, "Close", 1)
443}
444
445func TestFloatingTeardownClosesInstalledPaneOnce(t *testing.T) {
446	pty, _ := newBlockingPTY(t)
447	tb := newFloatingTestTab(t)
448	p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
449	tb.mu.Lock()
450	g := tb.beginFloatingWarmLocked(false)
451	require.True(t, tb.installFloatingLocked(p, g))
452	tb.mu.Unlock()
453	d := newTestDaemon(t, nil, stubClock{})
454	d.teardownFloating(tb, nil)
455	d.teardownFloating(tb, nil)
456	pty.AssertNumberOfCalls(t, "Close", 1)
457}
458
459func TestFloatingTeardownClearsCapturedCopyMode(t *testing.T) {
460	pty, _ := newBlockingPTY(t)
461	tb := newFloatingTestTab(t)
462	p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
463	tb.mu.Lock()
464	generation := tb.beginFloatingWarmLocked(true)
465	require.True(t, tb.installFloatingLocked(p, generation))
466	tb.mu.Unlock()
467	ac := &attachedClient{}
468	ac.initOverlays()
469	ac.overlays.copyMu.Lock()
470	ac.overlays.copyMode = &scopy.Mode{}
471	ac.overlays.copyPane = p
472	ac.overlays.copyMu.Unlock()
473	d := newTestDaemon(t, nil, stubClock{})
474
475	d.teardownFloating(tb, ac)
476
477	require.False(t, ac.overlays.copyActive())
478	ac.overlays.copyMu.Lock()
479	require.Nil(t, ac.overlays.copyPane)
480	ac.overlays.copyMu.Unlock()
481}
482
483func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
484	newCase := func(t *testing.T, state floatingState) (*Daemon, *session, *attachedClient, chan ports.Frame, *tab, *pane, func()) {
485		t.Helper()
486		normalPTY, releaseNormal := newBlockingPTY(t)
487		d, sess, ac, sends := newManualSessionWithPTYs(t, normalPTY)
488		tb := testAttachmentTab(sess)
489		require.NotNil(t, tb)
490		tb.mu.Lock()
491		normal := tb.focusedPane()
492		normal.mu.Lock()
493		normal.screen.Write([]byte("underlying-cell"))
494		normal.mu.Unlock()
495
496		floatingPTY := portsmocks.NewMockPTY(t)
497		floatingPTY.EXPECT().Close().Return(nil).Maybe()
498		releaseEOF := make(chan struct{})
499		floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
500			<-releaseEOF
501			return 0, io.EOF
502		}).Once()
503		floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8})
504		generation := tb.beginFloatingWarmLocked(state == floatingVisible)
505		publishPaneOwner(floating, sess, tb, generation)
506		require.True(t, tb.installFloatingLocked(floating, generation))
507		if state == floatingHidden {
508			require.Equal(t, floatingHidden, tb.floating.state)
509		}
510		tb.mu.Unlock()
511
512		// Establish a renderer shadow first. The EOF repaint must reset it and
513		// redraw the underlying cell rather than depending on popup damage.
514		d.paint(sess, ac, true, nil)
515		baseline := awaitFrame(t, sends, ports.MsgOutput)
516		baselineOutput, err := ports.UnmarshalOutput(baseline.Payload)
517		require.NoError(t, err)
518		require.Contains(t, string(baselineOutput.Data), "underlying-cell")
519
520		reaped := make(chan struct{})
521		floating.onExit = func() {
522			d.reapInstalledFloating(floating)
523			close(reaped)
524		}
525		d.sessWg.Add(1)
526		go d.ptyReader(sess, tb, floating)
527		return d, sess, ac, sends, tb, floating, func() {
528			close(releaseEOF)
529			<-reaped
530			d.sessWg.Wait()
531			releaseNormal()
532		}
533	}
534
535	t.Run("visible matching EOF clears and fully repaints underlying content", func(t *testing.T) {
536		_, _, _, sends, tb, floating, exit := newCase(t, floatingVisible)
537		exit()
538
539		tb.mu.Lock()
540		require.Equal(t, floatingUninitialized, tb.floating.state)
541		require.Nil(t, tb.floating.pane)
542		tb.mu.Unlock()
543		repaint := awaitFrame(t, sends, ports.MsgOutput)
544		output, err := ports.UnmarshalOutput(repaint.Payload)
545		require.NoError(t, err)
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.Size) (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				d.procCwd = func(int) (string, error) { return tt.liveCwd, nil }
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(), 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