floating_spare_test.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"io"
6	"sync"
7	"testing"
8	"time"
9
10	renderer "github.com/bnema/vev-vt"
11	"github.com/stretchr/testify/require"
12
13	"github.com/bnema/vev/internal/domain"
14	"github.com/bnema/vev/internal/ports"
15	themeui "github.com/bnema/vev/internal/usecase/theme"
16)
17
18// spareOpen records one PTY Open issued for a floating shell.
19type spareOpen struct {
20	cwd      string
21	geometry domain.Geometry
22	pty      *sparePTY
23}
24
25type sparePTY struct {
26	ctx     context.Context
27	mu      sync.Mutex
28	resizes []domain.Geometry
29	closed  chan struct{}
30	once    sync.Once
31}
32
33func (p *sparePTY) Read([]byte) (int, error) {
34	select {
35	case <-p.ctx.Done():
36		return 0, p.ctx.Err()
37	case <-p.closed:
38		return 0, io.EOF
39	}
40}
41func (*sparePTY) Write(b []byte) (int, error) { return len(b), nil }
42func (p *sparePTY) Resize(g domain.Geometry) error {
43	p.mu.Lock()
44	p.resizes = append(p.resizes, g)
45	p.mu.Unlock()
46	return nil
47}
48func (*sparePTY) Pid() int                     { return 1 }
49func (*sparePTY) ForegroundPgid() (int, error) { return 1, nil }
50func (p *sparePTY) Close() error               { p.once.Do(func() { close(p.closed) }); return nil }
51func (p *sparePTY) isClosed() bool {
52	select {
53	case <-p.closed:
54		return true
55	default:
56		return false
57	}
58}
59
60type spareFactory struct {
61	mu    sync.Mutex
62	opens []spareOpen
63}
64
65func (f *spareFactory) Open(ctx context.Context, _ string, _ []string, _ []string, cwd string, g domain.Geometry) (ports.PTY, error) {
66	p := &sparePTY{ctx: ctx, closed: make(chan struct{})}
67	f.mu.Lock()
68	f.opens = append(f.opens, spareOpen{cwd: cwd, geometry: g, pty: p})
69	f.mu.Unlock()
70	return p, nil
71}
72
73func (f *spareFactory) snapshot() []spareOpen {
74	f.mu.Lock()
75	defer f.mu.Unlock()
76	return append([]spareOpen(nil), f.opens...)
77}
78
79// liveShells counts opened floating PTYs that are still running.
80func (f *spareFactory) liveShells() int {
81	n := 0
82	for _, open := range f.snapshot() {
83		if !open.pty.isClosed() {
84			n++
85		}
86	}
87	return n
88}
89
90func waitSpareReady(t *testing.T, sess *session) {
91	t.Helper()
92	require.Eventually(t, func() bool {
93		sess.floatingLaunchMu.Lock()
94		defer sess.floatingLaunchMu.Unlock()
95		return sess.floatingSpare != nil && !sess.floatingSpareStarting
96	}, time.Second, time.Millisecond)
97}
98
99func waitFloatingVisible(t *testing.T, tb *tab) *pane {
100	t.Helper()
101	var p *pane
102	require.Eventually(t, func() bool {
103		tb.mu.Lock()
104		defer tb.mu.Unlock()
105		p = tb.floating.pane
106		return tb.floating.state == floatingVisible && p != nil
107	}, time.Second, time.Millisecond)
108	return p
109}
110
111func TestFloatingSpareIsSharedPerSession(t *testing.T) {
112	cfg := domain.FloatingConfig{Width: 80, Height: 80}
113	tests := []struct {
114		name       string
115		claimCwd   string
116		wantReused bool
117	}{
118		{name: "same cwd claims the spare", claimCwd: "/work", wantReused: true},
119		{name: "other cwd launches a fresh shell", claimCwd: "/other", wantReused: false},
120	}
121	for _, tt := range tests {
122		t.Run(tt.name, func(t *testing.T) {
123			factory := &spareFactory{}
124			d := newTestDaemon(t, factory, stubClock{})
125			d.ApplyConfig(domain.Config{Floating: cfg})
126			cwds := map[int]string{1: "/work", 2: tt.claimCwd}
127			d.procCwd = func(pid int) (string, error) { return cwds[pid], nil }
128			d.dirOrHome = func(cwd string) string { return cwd }
129
130			first := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 120, Rows: 40})
131			second := newTab(&pidPTY{pid: 2}, domain.Size{Cols: 60, Rows: 20})
132			for _, tb := range []*tab{first, second} {
133				tb.ctx, tb.cancel = context.WithCancel(t.Context())
134			}
135			sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{first, second}, ctx: t.Context()}
136
137			// Activating both tabs starts exactly one session spare.
138			d.ensureFloatingWarm(sess, first)
139			d.ensureFloatingWarm(sess, second)
140			waitSpareReady(t, sess)
141			require.Len(t, factory.snapshot(), 1)
142			require.Equal(t, "/work", factory.snapshot()[0].cwd)
143
144			// The second (smaller) tab opens its floating pane and claims the
145			// spare that the first tab started.
146			second.mu.Lock()
147			generation := second.beginFloatingWarmLocked(true)
148			second.mu.Unlock()
149			d.launchFloating(sess, second, cfg, generation, true)
150			claimed := waitFloatingVisible(t, second)
151			waitSpareReady(t, sess)
152
153			want := calculateContentFloatingGeometry(domain.Size{Cols: 60, Rows: 20}, cfg)
154			claimed.mu.Lock()
155			require.Equal(t, want.ptyRect(), claimed.rect)
156			require.Equal(t, rectSize(want.ptyRect()), claimed.geometry.Size)
157			require.Equal(t, rectSize(want.ptyRect()).Cols, claimed.screen.Columns())
158			claimed.mu.Unlock()
159
160			opens := factory.snapshot()
161			if tt.wantReused {
162				require.Same(t, opens[0].pty, claimed.pty, "the claim must reuse the pre-started shell")
163				require.Equal(t, []domain.Geometry{claimed.geometry}, opens[0].pty.resizes, "the claim resizes the spare to the claiming tab")
164				require.Len(t, opens, 2, "one claimed shell plus one new spare")
165			} else {
166				require.True(t, opens[0].pty.isClosed(), "a spare from another cwd is discarded")
167				require.Equal(t, tt.claimCwd, opens[1].cwd)
168				require.Same(t, opens[1].pty, claimed.pty)
169				require.Len(t, opens, 3, "a fresh shell plus one new spare")
170			}
171			// Panes visible as floating plus exactly one idle spare.
172			require.Equal(t, 2, factory.liveShells())
173
174			sess.stopInMemoryLifecycle()
175			d.teardownFloating(second, nil)
176			d.sessWg.Wait()
177			require.Zero(t, factory.liveShells(), "teardown closes the spare")
178		})
179	}
180}
181
182type pidPTY struct {
183	transactionalResizePTY
184	pid int
185}
186
187func (p *pidPTY) Pid() int { return p.pid }
188
189// queryPTY is a floating shell that sends one terminal query at startup and
190// records what the daemon writes back.
191type queryPTY struct {
192	sparePTY
193	query   string
194	sent    chan struct{}
195	replied chan []byte
196}
197
198func (p *queryPTY) Read(b []byte) (int, error) {
199	select {
200	case <-p.sent:
201		return p.sparePTY.Read(b)
202	default:
203		close(p.sent)
204		return copy(b, p.query), nil
205	}
206}
207
208func (p *queryPTY) Write(b []byte) (int, error) {
209	p.replied <- append([]byte(nil), b...)
210	return len(b), nil
211}
212
213type queryFactory struct{ pty *queryPTY }
214
215func (f *queryFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
216	f.pty.ctx = ctx
217	return f.pty, nil
218}
219
220func TestFloatingSpareAnswersStartupQueriesBeforeClaim(t *testing.T) {
221	for _, tt := range []struct {
222		name  string
223		query string
224		want  string
225	}{
226		{name: "primary device attributes", query: "\x1b[c", want: "\x1b[?"},
227		{name: "background color from the session theme", query: "\x1b]11;?\x07", want: "\x1b]11;rgb:"},
228	} {
229		t.Run(tt.name, func(t *testing.T) {
230			pty := &queryPTY{sparePTY: sparePTY{closed: make(chan struct{})}, query: tt.query, sent: make(chan struct{}), replied: make(chan []byte, 4)}
231			d := newTestDaemon(t, &queryFactory{pty: pty}, stubClock{})
232			d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 80, Height: 80}})
233			tb := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 80, Rows: 24})
234			tb.ctx, tb.cancel = context.WithCancel(t.Context())
235			ac := &attachedClient{}
236			ac.setClientTheme(themeui.Theme{Known: true, HasFG: true, HasBG: true,
237				Foreground: renderer.RGB{R: 0xee, G: 0xee, B: 0xee}, Background: renderer.RGB{R: 0x11, G: 0x11, B: 0x11}})
238			sess := &session{sessionCore: sessionCore{name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: t.Context()}
239
240			d.ensureFloatingWarm(sess, tb)
241			select {
242			case reply := <-pty.replied:
243				require.Contains(t, string(reply), tt.want, "the unclaimed spare must answer its shell's startup query")
244			case <-time.After(5 * time.Second):
245				t.Fatal("the unclaimed spare never answered its shell's startup query")
246			}
247
248			sess.stopInMemoryLifecycle()
249			d.sessWg.Wait()
250		})
251	}
252}
253
254func TestFloatingSpareExitBeforeClaimDropsIt(t *testing.T) {
255	factory := &spareFactory{}
256	d := newTestDaemon(t, factory, stubClock{})
257	d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 80, Height: 80}})
258	tb := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 80, Rows: 24})
259	tb.ctx, tb.cancel = context.WithCancel(t.Context())
260	sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
261
262	d.ensureFloatingWarm(sess, tb)
263	waitSpareReady(t, sess)
264	spare := sess.floatingSparePane()
265	require.NotNil(t, spare)
266	require.NoError(t, factory.snapshot()[0].pty.Close(), "the spare's shell exits")
267	require.Eventually(t, func() bool { return sess.floatingSparePane() == nil }, time.Second, time.Millisecond,
268		"an exited unclaimed spare must be dropped so the next claim launches a fresh shell")
269
270	sess.stopInMemoryLifecycle()
271	d.sessWg.Wait()
272}

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