bar_refresh_test.go RawHistoryFolder

1package daemon
2
3import (
4	"bytes"
5	"context"
6	"errors"
7	"log/slog"
8	"strings"
9	"sync"
10	"testing"
11	"time"
12
13	"github.com/bnema/vev/internal/domain"
14	"github.com/bnema/vev/internal/domain/layout"
15	"github.com/bnema/vev/internal/ports"
16	portsmocks "github.com/bnema/vev/internal/ports/mocks"
17	"github.com/stretchr/testify/mock"
18	"github.com/stretchr/testify/require"
19)
20
21type fakeBarRunner struct {
22	mu    sync.Mutex
23	calls []barScriptContext
24	outs  []string
25	errs  []error
26}
27
28func (r *fakeBarRunner) run(_ context.Context, _ string, _ []string, ctx barScriptContext) (string, error) {
29	r.mu.Lock()
30	defer r.mu.Unlock()
31	r.calls = append(r.calls, ctx)
32	i := len(r.calls) - 1
33	if i < len(r.errs) && r.errs[i] != nil {
34		return "", r.errs[i]
35	}
36	if i < len(r.outs) {
37		return r.outs[i], nil
38	}
39	return "", nil
40}
41
42type blockingBarRunner struct {
43	*fakeBarRunner
44	entered chan struct{}
45	release chan struct{}
46	once    sync.Once
47}
48
49func newBlockingBarRunner(outs []string) *blockingBarRunner {
50	return &blockingBarRunner{fakeBarRunner: &fakeBarRunner{outs: outs}, entered: make(chan struct{}), release: make(chan struct{})}
51}
52
53func (r *blockingBarRunner) run(ctx context.Context, command string, env []string, barCtx barScriptContext) (string, error) {
54	r.once.Do(func() { close(r.entered) })
55	select {
56	case <-r.release:
57	case <-ctx.Done():
58		return "", ctx.Err()
59	}
60	return r.fakeBarRunner.run(ctx, command, env, barCtx)
61}
62
63func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) {
64	r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}}
65	d := newBarRefreshTestDaemon(r, 100*time.Millisecond)
66	sess := newBarRefreshTestSession()
67	sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 120, Rows: 24}}})
68
69	require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
70	waitBarRefreshIdle(t, d)
71	require.Equal(t, 2, len(r.calls))
72	require.Equal(t, "top1", d.barStateFor(sess, "").topRight)
73	require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, int64(500*time.Millisecond)), false), "clamped interval should skip sub-second tick")
74
75	require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false))
76	waitBarRefreshIdle(t, d)
77	require.Equal(t, 4, len(r.calls))
78	require.Equal(t, "top2", d.barStateFor(sess, "").topRight)
79
80	r.errs = []error{nil, nil, nil, nil, errors.New("boom"), errors.New("boom")}
81	require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(2, 0), false))
82	waitBarRefreshIdle(t, d)
83	state := d.barStateFor(sess, "")
84	require.Equal(t, "top2", state.topRight)
85	require.Equal(t, "bot2", state.bottomRight)
86}
87
88func TestBarScriptContextUsesFirstOrderedPaneAndClientCols(t *testing.T) {
89	r := &fakeBarRunner{outs: []string{"top", "bottom"}}
90	d := newBarRefreshTestDaemon(r, time.Second)
91	sess := newBarRefreshTestSession()
92	sess.name = "work"
93	sess.cwd = "/repo"
94	sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 132, Rows: 40}}})
95	sess.tabs[0].size = domain.Size{Cols: 132, Rows: 38}
96	active := sess.tabs[0].focusedPane()
97	active.stableID = "pane-default"
98	active.pty = newScriptPTY(nil)
99	WithCwdReader(func(pid int) (string, error) {
100		require.Equal(t, 4242, pid)
101		return "/pane-repo", nil
102	})(d)
103
104	require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
105	waitBarRefreshIdle(t, d)
106	require.Len(t, r.calls, 2)
107	for _, call := range r.calls {
108		require.Equal(t, "work", call.Session)
109		require.Equal(t, "tab-inactive", call.Tab)
110		require.Equal(t, "pane-default", call.Pane)
111		require.Equal(t, "/pane-repo", call.PaneCWD)
112		require.Equal(t, 132, call.Cols)
113	}
114	require.Equal(t, "top-right", r.calls[0].Anchor)
115	require.Equal(t, "bottom-right", r.calls[1].Anchor)
116}
117
118func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) {
119	r := &fakeBarRunner{}
120	d := newBarRefreshTestDaemon(r, time.Second)
121	sess := newBarRefreshTestSession()
122
123	require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
124	require.Empty(t, r.calls)
125}
126
127func TestBarScriptRefreshSkipsRunnerWhenCommandsDisabled(t *testing.T) {
128	r := &fakeBarRunner{}
129	d := newBarRefreshTestDaemon(r, time.Second)
130	d.barScripts.cfg.topRight = ""
131	d.barScripts.cfg.bottomRight = ""
132	sess := newBarRefreshTestSession()
133	sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
134	d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "old", bottomRight: "old"}
135
136	require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
137	require.Empty(t, r.calls)
138	state := d.barStateFor(sess, "")
139	require.Empty(t, state.topRight)
140	require.Empty(t, state.bottomRight)
141}
142
143func TestDefaultBarConfigDoesNotInvokeRunner(t *testing.T) {
144	r := &fakeBarRunner{}
145	d := newBarRefreshTestDaemon(r, time.Second)
146	d.barScripts.cfg = barConfigFromDomain(domain.Defaults().Bar)
147	sess := newBarRefreshTestSession()
148	sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
149
150	require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
151	require.Empty(t, r.calls)
152}
153
154func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) {
155	r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
156	d := newBarRefreshTestDaemon(r, 5*time.Second)
157	sess := newBarRefreshTestSession()
158	sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
159
160	require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
161	waitBarRefreshIdle(t, d)
162	require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, int64(500*time.Millisecond)), true))
163	require.Len(t, r.calls, 2)
164	require.Eventually(t, func() bool {
165		r.mu.Lock()
166		defer r.mu.Unlock()
167		return len(r.calls) == 4
168	}, 1500*time.Millisecond, 10*time.Millisecond)
169}
170
171func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) {
172	r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
173	d := newBarRefreshTestDaemon(r, 5*time.Second)
174	sess := newBarRefreshTestSession()
175	sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
176
177	require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false))
178	waitBarRefreshIdle(t, d)
179	sess.cwd = "/other"
180	require.False(t, d.refreshBarScriptsIfDue(sess, time.Now().Add(100*time.Millisecond), false))
181	require.Eventually(t, func() bool {
182		r.mu.Lock()
183		defer r.mu.Unlock()
184		return len(r.calls) == 4
185	}, 1500*time.Millisecond, 10*time.Millisecond)
186	require.Equal(t, "/other", r.calls[2].PaneCWD)
187}
188
189func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) {
190	r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
191	d := newBarRefreshTestDaemon(r, 5*time.Second)
192	sess := newBarRefreshTestSession()
193	sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
194
195	require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
196	waitBarRefreshIdle(t, d)
197	sess.cwd = "/other"
198	require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false))
199	waitBarRefreshIdle(t, d)
200	require.Len(t, r.calls, 4)
201	require.Equal(t, "/other", r.calls[2].PaneCWD)
202}
203
204func TestApplyConfigPreservesBarOutputsWhenBarConfigUnchanged(t *testing.T) {
205	r := &fakeBarRunner{}
206	d := newBarRefreshTestDaemon(r, time.Second)
207	sess := newBarRefreshTestSession()
208	d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "top-good", bottomRight: "bottom-good"}
209
210	d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}})
211
212	state := d.barStateFor(sess, "")
213	require.Equal(t, "top-good", state.topRight)
214	require.Equal(t, "bottom-good", state.bottomRight)
215}
216
217func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) {
218	r := &fakeBarRunner{outs: []string{"top1", "bottom1"}}
219	d := newBarRefreshTestDaemon(r, 60*time.Second)
220	sess := newBarRefreshTestSession()
221	sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
222	d.sessions = map[domain.SessionID]*session{sess.id: sess}
223
224	// Verifies refreshBarScriptsAllSessions iterates every live session and
225	// dispatches a run for each. This does not exercise force specifically:
226	// lastRefresh is zero on a fresh session, so refreshBarScriptsIfDue skips
227	// the interval check regardless of force, and a non-forcing call would
228	// pass this test too.
229	d.refreshBarScriptsAllSessions()
230	waitBarRefreshIdle(t, d)
231
232	require.Len(t, r.calls, 2, "should force a run for every live session")
233	require.Equal(t, "top1", d.barStateFor(sess, "").topRight)
234}
235
236func TestApplyConfigBumpsVersionOnlyWhenBarConfigChanges(t *testing.T) {
237	r := &fakeBarRunner{}
238	d := newBarRefreshTestDaemon(r, time.Second)
239
240	before := d.barScripts.version
241	d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}})
242	require.Equal(t, before, d.barScripts.version, "identical bar config must not invalidate")
243
244	d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "changed", BottomRight: "bottom", Interval: time.Second}})
245	require.Equal(t, before+1, d.barScripts.version, "changed bar config must invalidate")
246}
247
248func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) {
249	var logs bytes.Buffer
250	r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}}
251	d := newBarRefreshTestDaemon(r, time.Second)
252	d.log = slog.New(slog.NewTextHandler(&logs, nil))
253	sess := newBarRefreshTestSession()
254	sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
255
256	require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
257	waitBarRefreshIdle(t, d)
258	r.errs = []error{nil, nil, errors.New("boom"), errors.New("bang")}
259	require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true))
260	waitBarRefreshIdle(t, d)
261
262	state := d.barStateFor(sess, "")
263	require.Equal(t, "top-good", state.topRight)
264	require.Equal(t, "bottom-good", state.bottomRight)
265	require.True(t, strings.Contains(logs.String(), "bar script failed; keeping last good output"), logs.String())
266	require.True(t, strings.Contains(logs.String(), "anchor=top-right"), logs.String())
267}
268
269func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) {
270	r := &fakeBarRunner{outs: []string{"top", "bottom"}}
271	d := newBarRefreshTestDaemon(r, time.Second)
272	sess := newBarRefreshTestSession()
273	d.barScripts.mu.Lock()
274	d.barScripts.running[sess.id] = true
275	d.barScripts.mu.Unlock()
276
277	d.clearBarScriptsForSession(sess.id)
278	d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, nil, 0)
279
280	state := d.barStateFor(sess, "")
281	require.Empty(t, state.topRight)
282	require.Empty(t, state.bottomRight)
283}
284
285func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) {
286	r := newBlockingBarRunner([]string{"top1", "bottom1", "top2", "bottom2"})
287	d := newBarRefreshTestDaemon(r, time.Second)
288	sess := newBarRefreshTestSession()
289	sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
290
291	require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
292	select {
293	case <-r.entered:
294	case <-time.After(time.Second):
295		t.Fatal("timeout waiting for first bar script run")
296	}
297	require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true))
298	close(r.release)
299	waitBarRefreshIdle(t, d)
300	require.Eventually(t, func() bool {
301		r.mu.Lock()
302		defer r.mu.Unlock()
303		return len(r.calls) == 4
304	}, time.Second, time.Millisecond)
305}
306
307// TestBarScriptFailureLogsOnceUntilSignatureChanges covers the full
308// shouldLogBarFailure/clearBarFailure contract for a single anchor
309// (top-right; bottom-right always succeeds in this test so it never
310// contributes a log line):
311//
312//  1. an identical repeated failure does not log a second time,
313//  2. a different failure for the same session+anchor logs again, and
314//  3. a successful run clears the recorded signature, so a subsequent
315//     identical failure logs again afterward.
316//
317// Each refresh consumes two fakeBarRunner call-count entries (top-right then
318// bottom-right), so the stages are order-dependent and asserted sequentially
319// rather than table-driven.
320func TestBarScriptFailureLogsOnceUntilSignatureChanges(t *testing.T) {
321	var buf bytes.Buffer
322	var mu sync.Mutex
323	logger := slog.New(slog.NewTextHandler(&syncWriter{w: &buf, mu: &mu}, nil))
324
325	notFound := &barScriptError{exitCode: 127, stderr: "sh: vev-bar-top-right: not found", err: errors.New("exit status 127")}
326	permissionDenied := &barScriptError{exitCode: 126, stderr: "sh: vev-bar-top-right: permission denied", err: errors.New("exit status 126")}
327
328	r := &fakeBarRunner{errs: []error{
329		notFound, nil, // refresh 1: top fails
330		notFound, nil, // refresh 2: top fails identically -> must not log again
331		permissionDenied, nil, // refresh 3: top fails differently -> must log again
332		nil, nil, // refresh 4: top succeeds -> clears recorded signature
333		notFound, nil, // refresh 5: top fails with the original signature -> must log (cleared)
334	}}
335	d := newBarRefreshTestDaemon(r, time.Second)
336	d.log = logger
337	d.baseEnv = []string{"PATH=/usr/bin"}
338	sess := newBarRefreshTestSession()
339	sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
340
341	readBuf := func() string {
342		mu.Lock()
343		defer mu.Unlock()
344		return buf.String()
345	}
346
347	require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
348	waitBarRefreshIdle(t, d)
349	out := readBuf()
350	require.Equal(t, 1, strings.Count(out, "anchor=top-right"), "first failure should log")
351	require.Contains(t, out, "not found")
352	require.Contains(t, out, "PATH=/usr/bin")
353
354	require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
355	waitBarRefreshIdle(t, d)
356	out = readBuf()
357	require.Equal(t, 1, strings.Count(out, "anchor=top-right"), "identical repeated failure should not log again")
358
359	require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(20, 0), true))
360	waitBarRefreshIdle(t, d)
361	out = readBuf()
362	require.Equal(t, 2, strings.Count(out, "anchor=top-right"), "a different failure for the same anchor should log again")
363	require.Contains(t, out, "permission denied")
364
365	require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(30, 0), true))
366	waitBarRefreshIdle(t, d)
367	out = readBuf()
368	require.Equal(t, 2, strings.Count(out, "anchor=top-right"), "a successful run must not log and must clear the recorded signature")
369
370	require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(40, 0), true))
371	waitBarRefreshIdle(t, d)
372	out = readBuf()
373	require.Equal(t, 3, strings.Count(out, "anchor=top-right"),
374		"an identical failure after a successful run should log again since the recorded signature was cleared")
375}
376
377type syncWriter struct {
378	w  *bytes.Buffer
379	mu *sync.Mutex
380}
381
382func (s *syncWriter) Write(p []byte) (int, error) {
383	s.mu.Lock()
384	defer s.mu.Unlock()
385	return s.w.Write(p)
386}
387
388func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {
389	r := &fakeBarRunner{outs: []string{"top", "bottom"}}
390	d := newBarRefreshTestDaemon(r, time.Second)
391	sess := newBarRefreshTestSession()
392	d.barScripts.mu.Lock()
393	d.barScripts.running[sess.id] = true
394	d.barScripts.version = 2
395	d.barScripts.mu.Unlock()
396
397	d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, nil, 1)
398
399	state := d.barStateFor(sess, "")
400	require.Empty(t, state.topRight)
401	require.Empty(t, state.bottomRight)
402}
403
404func TestPokeSessionRenderWakesHeadlessPreviewCoordinator(t *testing.T) {
405	d := newBarRefreshTestDaemon(nil, time.Second)
406	sess := newBarRefreshTestSession()
407	var invalidations int
408	rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(renderInvalidation) { invalidations++ }})
409	sess.installRenderCoordinator(rc)
410
411	d.pokeSessionRender(sess)
412
413	require.Equal(t, 1, invalidations)
414}
415
416func TestBarScriptRefreshIsPerSession(t *testing.T) {
417	r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}}
418	d := newBarRefreshTestDaemon(r, time.Second)
419	a := newBarRefreshTestSession()
420	a.id = "a"
421	a.name = "a"
422	a.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
423	b := newBarRefreshTestSession()
424	b.id = "b"
425	b.name = "b"
426	b.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
427
428	require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false))
429	waitBarRefreshIdle(t, d)
430	require.True(t, d.refreshBarScriptsIfDue(b, time.Unix(0, 0), false))
431	waitBarRefreshIdle(t, d)
432
433	stateA := d.barStateFor(a, "")
434	stateB := d.barStateFor(b, "")
435	require.Equal(t, "top-a", stateA.topRight)
436	require.Equal(t, "bottom-a", stateA.bottomRight)
437	require.Equal(t, "top-b", stateB.topRight)
438	require.Equal(t, "bottom-b", stateB.bottomRight)
439}
440
441// pollerMockClock and pollerMockTimer wrap the generated portsmocks.MockClock
442// and portsmocks.MockTimer (see internal/ports/mocks/clock_mock.go), following
443// the coordinatorMockClock pattern in render_coordinator_test.go: the mocks
444// supply the ports.Clock/ports.Timer implementations, while a small
445// hand-owned struct retains the real channel and requested duration for each
446// timer so the test goroutine can fire ticks and assert on re-arming.
447type pollerMockClock struct {
448	clock  *portsmocks.MockClock
449	timers chan *pollerMockTimer
450}
451
452type pollerMockTimer struct {
453	mock     *portsmocks.MockTimer
454	ch       chan time.Time
455	duration time.Duration
456}
457
458func newPollerMockClock(t *testing.T, capacity int) *pollerMockClock {
459	t.Helper()
460	clk := &pollerMockClock{
461		clock:  portsmocks.NewMockClock(t),
462		timers: make(chan *pollerMockTimer, capacity),
463	}
464	clk.clock.EXPECT().Now().Return(time.Unix(0, 0)).Maybe()
465	clk.clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(d time.Duration) ports.Timer {
466		timer := &pollerMockTimer{
467			mock:     portsmocks.NewMockTimer(t),
468			duration: d,
469			ch:       make(chan time.Time, 1),
470		}
471		// Stop is called 0, 1, or 2 times per timer depending on whether it is
472		// replaced mid-loop or torn down by the poller's deferred Stop on
473		// exit, so its expectation is unconstrained.
474		timer.mock.EXPECT().C().Maybe().Return((<-chan time.Time)(timer.ch))
475		timer.mock.EXPECT().Stop().Maybe().Return(true)
476		clk.timers <- timer
477		return timer.mock
478	}).Maybe()
479	return clk
480}
481
482// TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel drives barScriptPoller
483// directly (not through Serve) with a mock clock, covering the three
484// behaviors the Task 5 rewrite touched but left untested:
485//
486//  1. after a tick fires, the loop arms a NEW timer instead of blocking
487//     forever (the regression that would silently stop bar refreshes),
488//  2. a reload signal re-arms the timer at the newly configured interval
489//     rather than the one the poller started with, and
490//  3. cancelling the context makes the goroutine return instead of leaking.
491func TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel(t *testing.T) {
492	clk := newPollerMockClock(t, 8)
493	d := newBarRefreshTestDaemon(&fakeBarRunner{}, 2*time.Second)
494	d.clock = clk.clock
495	d.barScripts.reload = make(chan struct{}, 1)
496
497	ctx, cancel := context.WithCancel(context.Background())
498	done := make(chan struct{})
499	go func() {
500		d.barScriptPoller(ctx)
501		close(done)
502	}()
503
504	// Stage 1: the poller arms its first timer at the configured interval.
505	var timer0 *pollerMockTimer
506	select {
507	case timer0 = <-clk.timers:
508	case <-time.After(time.Second):
509		t.Fatal("timed out waiting for the poller's initial timer")
510	}
511	require.Equal(t, 2*time.Second, timer0.duration)
512
513	// Stage 2: firing that timer processes the tick and re-arms a new one.
514	timer0.ch <- time.Unix(0, 0)
515	var timer1 *pollerMockTimer
516	select {
517	case timer1 = <-clk.timers:
518	case <-time.After(time.Second):
519		t.Fatal("timed out waiting for the poller to re-arm after a tick")
520	}
521	require.Equal(t, 2*time.Second, timer1.duration, "tick re-arm should keep the same interval when config is unchanged")
522
523	// Stage 3: changing the interval and signaling reload re-arms at the NEW
524	// interval instead of waiting out the timer armed under the old one.
525	d.barScripts.mu.Lock()
526	d.barScripts.cfg.interval = 7 * time.Second
527	d.barScripts.mu.Unlock()
528	d.barScripts.reload <- struct{}{}
529	var timer2 *pollerMockTimer
530	select {
531	case timer2 = <-clk.timers:
532	case <-time.After(time.Second):
533		t.Fatal("timed out waiting for the poller to re-arm after reload")
534	}
535	require.Equal(t, 7*time.Second, timer2.duration, "reload should re-arm at the newly configured interval")
536
537	// Stage 4: cancelling the context exits the goroutine instead of leaking it.
538	cancel()
539	select {
540	case <-done:
541	case <-time.After(time.Second):
542		t.Fatal("barScriptPoller did not exit after context cancellation")
543	}
544}
545
546func TestApplyConfigSignalsPollerReload(t *testing.T) {
547	r := &fakeBarRunner{}
548	d := newBarRefreshTestDaemon(r, 60*time.Second)
549	d.barScripts.reload = make(chan struct{}, 1)
550
551	cfg := domain.Defaults()
552	cfg.Bar.TopRight = "t"
553	cfg.Bar.BottomRight = "b"
554	cfg.Bar.Interval = time.Second
555	d.ApplyConfig(cfg)
556
557	select {
558	case <-d.barScripts.reload:
559	default:
560		t.Fatal("ApplyConfig should signal the poller to re-arm its timer")
561	}
562	require.Equal(t, time.Second, d.barScriptInterval())
563}
564
565func newBarRefreshTestDaemon(r barScriptExecutor, interval time.Duration) *Daemon {
566	d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{
567		cfg:         barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)},
568		runner:      r,
569		outputs:     make(map[domain.SessionID]barScriptOutputs),
570		lastRefresh: make(map[domain.SessionID]time.Time),
571		lastContext: make(map[domain.SessionID]barScriptContext),
572		running:     make(map[domain.SessionID]bool),
573		pending:     make(map[domain.SessionID]bool),
574	}}
575	return d
576}
577
578type barRefreshTestClock struct{}
579
580func (barRefreshTestClock) Now() time.Time { return time.Now() }
581func (barRefreshTestClock) NewTimer(d time.Duration) ports.Timer {
582	return realTimer{t: time.NewTimer(d)}
583}
584
585func newBarRefreshTestSession() *session {
586	inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22})
587	active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22})
588	active.tree.Focus = layout.PaneID("pane-1")
589	return &session{sessionCore: sessionCore{id: "s", name: "s"}, cwd: "/tmp", tabs: []*tab{inactive, active}, ctx: context.Background()}
590}
591
592func waitBarRefreshIdle(t *testing.T, d *Daemon) {
593	t.Helper()
594	require.Eventually(t, func() bool {
595		d.barScripts.mu.Lock()
596		defer d.barScripts.mu.Unlock()
597		return len(d.barScripts.running) == 0
598	}, time.Second, time.Millisecond)
599}
600
601type envRecordingRunner struct {
602	mu   sync.Mutex
603	envs [][]string
604}
605
606func (r *envRecordingRunner) run(_ context.Context, _ string, env []string, _ barScriptContext) (string, error) {
607	r.mu.Lock()
608	defer r.mu.Unlock()
609	r.envs = append(r.envs, append([]string(nil), env...))
610	return "out", nil
611}
612
613func (r *envRecordingRunner) captured() [][]string {
614	r.mu.Lock()
615	defer r.mu.Unlock()
616	return append([][]string(nil), r.envs...)
617}
618
619func TestBarScriptsUseSessionEnvNotDaemonBaseEnv(t *testing.T) {
620	tests := []struct {
621		name     string
622		sessEnv  []string
623		baseEnv  []string
624		wantPath string
625	}{
626		{
627			name:     "session env wins over stale daemon env",
628			sessEnv:  []string{"PATH=/home/u/.local/bin:/usr/bin"},
629			baseEnv:  []string{"PATH=/usr/bin"},
630			wantPath: "PATH=/home/u/.local/bin:/usr/bin",
631		},
632		{
633			name:     "falls back to daemon env when session env empty",
634			sessEnv:  nil,
635			baseEnv:  []string{"PATH=/usr/bin"},
636			wantPath: "PATH=/usr/bin",
637		},
638	}
639
640	for _, tc := range tests {
641		t.Run(tc.name, func(t *testing.T) {
642			r := &envRecordingRunner{}
643			d := newBarRefreshTestDaemon(r, time.Second)
644			d.baseEnv = tc.baseEnv
645			sess := newBarRefreshTestSession()
646			sess.env = tc.sessEnv
647			sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
648
649			require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
650			waitBarRefreshIdle(t, d)
651
652			envs := r.captured()
653			require.Len(t, envs, 2, "one call per anchor")
654			for _, env := range envs {
655				require.Contains(t, env, tc.wantPath)
656			}
657		})
658	}
659}

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