v0.7.0 4c16dd8

floating_spare_test.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"io"
6	"sync"
7	"testing"
8	"time"
9
10	"github.com/stretchr/testify/require"
11
12	"github.com/bnema/vev/internal/domain"
13	"github.com/bnema/vev/internal/ports"
14)
15
16// spareOpen records one PTY Open issued for a floating shell.
17type spareOpen struct {
18	cwd      string
19	geometry domain.Geometry
20	pty      *sparePTY
21}
22
23type sparePTY struct {
24	ctx     context.Context
25	mu      sync.Mutex
26	resizes []domain.Geometry
27	closed  chan struct{}
28	once    sync.Once
29}
30
31func (p *sparePTY) Read([]byte) (int, error) {
32	select {
33	case <-p.ctx.Done():
34		return 0, p.ctx.Err()
35	case <-p.closed:
36		return 0, io.EOF
37	}
38}
39func (*sparePTY) Write(b []byte) (int, error) { return len(b), nil }
40func (p *sparePTY) Resize(g domain.Geometry) error {
41	p.mu.Lock()
42	p.resizes = append(p.resizes, g)
43	p.mu.Unlock()
44	return nil
45}
46func (*sparePTY) Pid() int                     { return 1 }
47func (*sparePTY) ForegroundPgid() (int, error) { return 1, nil }
48func (p *sparePTY) Close() error               { p.once.Do(func() { close(p.closed) }); return nil }
49func (p *sparePTY) isClosed() bool {
50	select {
51	case <-p.closed:
52		return true
53	default:
54		return false
55	}
56}
57
58type spareFactory struct {
59	mu    sync.Mutex
60	opens []spareOpen
61}
62
63func (f *spareFactory) Open(ctx context.Context, _ string, _ []string, _ []string, cwd string, g domain.Geometry) (ports.PTY, error) {
64	p := &sparePTY{ctx: ctx, closed: make(chan struct{})}
65	f.mu.Lock()
66	f.opens = append(f.opens, spareOpen{cwd: cwd, geometry: g, pty: p})
67	f.mu.Unlock()
68	return p, nil
69}
70
71func (f *spareFactory) snapshot() []spareOpen {
72	f.mu.Lock()
73	defer f.mu.Unlock()
74	return append([]spareOpen(nil), f.opens...)
75}
76
77// liveShells counts opened floating PTYs that are still running.
78func (f *spareFactory) liveShells() int {
79	n := 0
80	for _, open := range f.snapshot() {
81		if !open.pty.isClosed() {
82			n++
83		}
84	}
85	return n
86}
87
88func waitSpareReady(t *testing.T, sess *session) {
89	t.Helper()
90	require.Eventually(t, func() bool {
91		sess.floatingLaunchMu.Lock()
92		defer sess.floatingLaunchMu.Unlock()
93		return sess.floatingSpare != nil && !sess.floatingSpareStarting
94	}, time.Second, time.Millisecond)
95}
96
97func waitFloatingVisible(t *testing.T, tb *tab) *pane {
98	t.Helper()
99	var p *pane
100	require.Eventually(t, func() bool {
101		tb.mu.Lock()
102		defer tb.mu.Unlock()
103		p = tb.floating.pane
104		return tb.floating.state == floatingVisible && p != nil
105	}, time.Second, time.Millisecond)
106	return p
107}
108
109func TestFloatingSpareIsSharedPerSession(t *testing.T) {
110	cfg := domain.FloatingConfig{Width: 80, Height: 80}
111	tests := []struct {
112		name       string
113		claimCwd   string
114		wantReused bool
115	}{
116		{name: "same cwd claims the spare", claimCwd: "/work", wantReused: true},
117		{name: "other cwd launches a fresh shell", claimCwd: "/other", wantReused: false},
118	}
119	for _, tt := range tests {
120		t.Run(tt.name, func(t *testing.T) {
121			factory := &spareFactory{}
122			d := newTestDaemon(t, factory, stubClock{})
123			d.ApplyConfig(domain.Config{Floating: cfg})
124			cwds := map[int]string{1: "/work", 2: tt.claimCwd}
125			d.procCwd = func(pid int) (string, error) { return cwds[pid], nil }
126			d.dirOrHome = func(cwd string) string { return cwd }
127
128			first := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 120, Rows: 40})
129			second := newTab(&pidPTY{pid: 2}, domain.Size{Cols: 60, Rows: 20})
130			for _, tb := range []*tab{first, second} {
131				tb.ctx, tb.cancel = context.WithCancel(t.Context())
132			}
133			sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{first, second}, ctx: t.Context()}
134
135			// Activating both tabs starts exactly one session spare.
136			d.ensureFloatingWarm(sess, first)
137			d.ensureFloatingWarm(sess, second)
138			waitSpareReady(t, sess)
139			require.Len(t, factory.snapshot(), 1)
140			require.Equal(t, "/work", factory.snapshot()[0].cwd)
141
142			// The second (smaller) tab opens its floating pane and claims the
143			// spare that the first tab started.
144			second.mu.Lock()
145			generation := second.beginFloatingWarmLocked(true)
146			second.mu.Unlock()
147			d.launchFloating(sess, second, cfg, generation, true)
148			claimed := waitFloatingVisible(t, second)
149			waitSpareReady(t, sess)
150
151			want := calculateContentFloatingGeometry(domain.Size{Cols: 60, Rows: 20}, cfg)
152			claimed.mu.Lock()
153			require.Equal(t, want.ptyRect(), claimed.rect)
154			require.Equal(t, rectSize(want.ptyRect()), claimed.geometry.Size)
155			require.Equal(t, rectSize(want.ptyRect()).Cols, claimed.screen.Columns())
156			claimed.mu.Unlock()
157
158			opens := factory.snapshot()
159			if tt.wantReused {
160				require.Same(t, opens[0].pty, claimed.pty, "the claim must reuse the pre-started shell")
161				require.Equal(t, []domain.Geometry{claimed.geometry}, opens[0].pty.resizes, "the claim resizes the spare to the claiming tab")
162				require.Len(t, opens, 2, "one claimed shell plus one new spare")
163			} else {
164				require.True(t, opens[0].pty.isClosed(), "a spare from another cwd is discarded")
165				require.Equal(t, tt.claimCwd, opens[1].cwd)
166				require.Same(t, opens[1].pty, claimed.pty)
167				require.Len(t, opens, 3, "a fresh shell plus one new spare")
168			}
169			// Panes visible as floating plus exactly one idle spare.
170			require.Equal(t, 2, factory.liveShells())
171
172			sess.stopInMemoryLifecycle()
173			d.teardownFloating(second, nil)
174			d.sessWg.Wait()
175			require.Zero(t, factory.liveShells(), "teardown closes the spare")
176		})
177	}
178}
179
180type pidPTY struct {
181	transactionalResizePTY
182	pid int
183}
184
185func (p *pidPTY) Pid() int { return p.pid }

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