v0.3.0 99dc266

attention_test.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"io"
6	"sync"
7	"testing"
8	"time"
9
10	"github.com/stretchr/testify/mock"
11	"github.com/stretchr/testify/require"
12
13	renderer "github.com/bnema/vev-vt"
14	"github.com/bnema/vev/internal/domain"
15	"github.com/bnema/vev/internal/ports"
16	portsmocks "github.com/bnema/vev/internal/ports/mocks"
17	"github.com/bnema/vev/internal/usecase/keys"
18	"github.com/bnema/vev/internal/usecase/layout"
19	"github.com/bnema/vev/internal/usecase/ui"
20)
21
22func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {
23	d, sess, _, _, releases := newManualTabSession(t, 2)
24	defer releases[0]()
25	defer releases[1]()
26	selectTestAttachmentTab(sess, 0)
27
28	d.noteAttention(sess, sess.tabs[1])
29
30	sess.mu.Lock()
31	defer sess.mu.Unlock()
32	require.True(t, sess.tabs[1].attention)
33	require.False(t, sess.tabs[1].attentionAt.IsZero())
34	require.False(t, sess.tabs[0].attention)
35}
36
37func TestNoteAttentionFlagsVisibleTabUntilPainted(t *testing.T) {
38	d, sess, _, _, releases := newManualTabSession(t, 1)
39	defer releases[0]()
40	selectTestAttachmentTab(sess, 0)
41
42	d.noteAttention(sess, sess.tabs[0])
43
44	sess.mu.Lock()
45	defer sess.mu.Unlock()
46	require.True(t, sess.tabs[0].attention)
47	require.False(t, sess.tabs[0].attentionAt.IsZero())
48}
49
50func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) {
51	d, sess, ac, sends, releases := newManualTabSession(t, 1)
52	defer releases[0]()
53	selectTestAttachmentTab(sess, 0)
54	d.setAttentionFrame(0)
55	d.noteAttention(sess, sess.tabs[0])
56
57	d.paint(sess, ac, true, nil)
58	data := mustOutputData(t, sends)
59	require.NotContains(t, string(data), string(ui.AttentionGlyph))
60
61	sess.mu.Lock()
62	require.True(t, sess.tabs[0].attention)
63	sess.mu.Unlock()
64
65	d.setAttentionFrame(1)
66	d.paint(sess, ac, true, nil)
67	data = mustOutputData(t, sends)
68	require.Contains(t, string(data), string(ui.AttentionGlyph))
69
70	sess.mu.Lock()
71	require.False(t, sess.tabs[0].attention)
72	sess.mu.Unlock()
73}
74
75func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing.T) {
76	tests := []struct {
77		name string
78		data []byte
79	}{
80		{name: "bell", data: []byte("\a")},
81		{name: "osc notify", data: []byte("\x1b]777;notify;Claude;needs input\x1b\\")},
82		{name: "osc progress complete", data: []byte("\x1b]9;4;1;50\a\x1b]9;4;0;100\a")},
83	}
84
85	for _, tt := range tests {
86		t.Run(tt.name, func(t *testing.T) {
87			pty := newScriptPTY([][]byte{tt.data})
88			d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
89			selectTestAttachmentTab(sess, 0)
90			d.setAttentionFrame(1)
91
92			d.sessWg.Add(1)
93			go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
94
95			require.Eventually(t, func() bool {
96				sess.mu.Lock()
97				defer sess.mu.Unlock()
98				return sess.tabs[0].attention && !sess.tabs[0].attentionAt.IsZero()
99			}, 2*time.Second, 5*time.Millisecond)
100
101			d.paint(sess, ac, true, nil)
102			data := mustOutputData(t, sends)
103			require.Contains(t, string(data), string(ui.AttentionGlyph))
104
105			sess.mu.Lock()
106			require.False(t, sess.tabs[0].attention)
107			require.True(t, sess.tabs[0].attentionAt.IsZero())
108			sess.mu.Unlock()
109
110			cleared := mustOutputData(t, sends)
111			require.NotContains(t, string(cleared), string(ui.AttentionGlyph))
112
113			_ = pty.Close()
114			d.sessWg.Wait()
115		})
116	}
117}
118
119func TestNoteAttentionFlagsDetachedActiveTab(t *testing.T) {
120	d, sess, ac, _, releases := newManualTabSession(t, 1)
121	defer releases[0]()
122	selectTestAttachmentTab(sess, 0)
123	require.True(t, sess.detachIfCurrent(ac))
124
125	d.noteAttention(sess, sess.tabs[0])
126
127	sess.mu.Lock()
128	defer sess.mu.Unlock()
129	require.True(t, sess.tabs[0].attention)
130	require.False(t, sess.tabs[0].attentionAt.IsZero())
131}
132
133func TestPTYReaderBellMarksBackgroundTab(t *testing.T) {
134	p1, releasePTY1 := newBlockingPTY(t)
135	p2 := newScriptPTY([][]byte{[]byte("\a")})
136	d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
137	defer releasePTY1()
138	selectTestAttachmentTab(sess, 0)
139
140	d.sessWg.Add(1)
141	go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
142
143	require.Eventually(t, func() bool {
144		sess.mu.Lock()
145		defer sess.mu.Unlock()
146		return sess.tabs[1].attention && !sess.tabs[1].attentionAt.IsZero()
147	}, 2*time.Second, 5*time.Millisecond)
148
149	_ = p2.Close()
150	d.sessWg.Wait()
151}
152
153// TestNoteAttentionDoesNotBlockOnWedgedOtherClient is the regression test for
154// the reader-blocks-on-a-slow-client bug: noteAttention runs on the PTY
155// reader goroutine (see ptyReader in render.go), so it must never repaint
156// synchronously — paint ends in Transport.Send, which has no deadline, and a
157// stalled client's socket would otherwise stall the reader of an unrelated
158// session. A second session's client whose Send never returns must not stop
159// noteAttention from returning promptly.
160func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
161	d, sess, _, _, releases := newManualTabSession(t, 2)
162	defer releases[0]()
163	defer releases[1]()
164	selectTestAttachmentTab(sess, 0)
165
166	trW := portsmocks.NewMockTransport(t)
167	block := make(chan struct{})
168	t.Cleanup(func() { close(block) })
169	trW.EXPECT().Send(mock.Anything).RunAndReturn(func(ports.Frame) error {
170		<-block
171		return nil
172	}).Maybe()
173	trW.EXPECT().Close().Return(nil).Maybe()
174	acW := &attachedClient{tr: trW, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
175	acW.initOverlays()
176	sctxW, cancelW := context.WithCancel(d.serveCtx)
177	t.Cleanup(cancelW)
178	tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW)
179	sessW := &session{sessionCore: sessionCore{id: "wedged", name: "wedged", attachments: map[*attachedClient]struct{}{acW: {}}}, ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}}
180	acW.setSession(sessW)
181	acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil)
182	d.sessions[sessW.id] = sessW
183
184	done := make(chan struct{})
185	go func() {
186		d.noteAttention(sess, sess.tabs[1])
187		close(done)
188	}()
189
190	select {
191	case <-done:
192	case <-time.After(2 * time.Second):
193		t.Fatal("noteAttention blocked on a wedged other-session client's Send")
194	}
195
196	sess.mu.Lock()
197	defer sess.mu.Unlock()
198	require.True(t, sess.tabs[1].attention)
199}
200
201type scriptPTY struct {
202	mu     sync.Mutex
203	reads  [][]byte
204	closed chan struct{}
205	once   sync.Once
206}
207
208func newScriptPTY(reads [][]byte) *scriptPTY {
209	return &scriptPTY{reads: reads, closed: make(chan struct{})}
210}
211
212func (p *scriptPTY) Read(b []byte) (int, error) {
213	p.mu.Lock()
214	if len(p.reads) > 0 {
215		next := p.reads[0]
216		p.reads = p.reads[1:]
217		p.mu.Unlock()
218		return copy(b, next), nil
219	}
220	p.mu.Unlock()
221	<-p.closed
222	return 0, io.EOF
223}
224
225func (p *scriptPTY) Write(b []byte) (int, error) { return len(b), nil }
226func (p *scriptPTY) Close() error {
227	p.once.Do(func() { close(p.closed) })
228	return nil
229}
230func (p *scriptPTY) Resize(domain.Size) error     { return nil }
231func (p *scriptPTY) Pid() int                     { return 4242 }
232func (p *scriptPTY) ForegroundPgid() (int, error) { return 4242, nil }
233
234func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) {
235	d, sess, ac, sends, releases := newManualTabSession(t, 2)
236	defer releases[0]()
237	defer releases[1]()
238	selectTestAttachmentTab(sess, 0)
239	sess.mu.Lock()
240	sess.tabs[0].attention = true
241	sess.tabs[0].attentionAt = time.Unix(1, 0)
242	sess.tabs[1].attention = true
243	sess.tabs[1].attentionAt = time.Unix(2, 0)
244	sess.mu.Unlock()
245
246	d.paint(sess, ac, true, nil)
247	_ = mustOutputData(t, sends)
248	// The deferred repaint (from ackAttention) targets all attached clients,
249	// including this one, but the bars it would draw are already exactly what
250	// the full paint above just sent: no second frame should follow.
251	select {
252	case f := <-sends:
253		t.Fatalf("unexpected extra frame after ack: %v", f.Type)
254	case <-time.After(50 * time.Millisecond):
255	}
256
257	sess.mu.Lock()
258	defer sess.mu.Unlock()
259	require.False(t, sess.tabs[0].attention)
260	require.True(t, sess.tabs[0].attentionAt.IsZero())
261	require.True(t, sess.tabs[1].attention)
262	require.False(t, sess.tabs[1].attentionAt.IsZero())
263}
264
265func TestPaintPreservesBackgroundAttention(t *testing.T) {
266	d, sess, ac, sends, releases := newManualTabSession(t, 2)
267	defer releases[0]()
268	defer releases[1]()
269	selectTestAttachmentTab(sess, 0)
270	sess.mu.Lock()
271	sess.tabs[1].attention = true
272	sess.tabs[1].attentionAt = time.Unix(2, 0)
273	sess.mu.Unlock()
274	d.setAttentionFrame(1)
275
276	d.paint(sess, ac, true, nil)
277	data := mustOutputData(t, sends)
278
279	require.Contains(t, string(data), "")
280	sess.mu.Lock()
281	defer sess.mu.Unlock()
282	require.True(t, sess.tabs[1].attention)
283}
284
285func TestSwitchTabClearsAttentionEndToEnd(t *testing.T) {
286	d, sess, ac, sends, releases := newManualTabSession(t, 2)
287	defer releases[0]()
288	defer releases[1]()
289	selectTestAttachmentTab(sess, 0)
290	sess.mu.Lock()
291	sess.tabs[1].attention = true
292	sess.tabs[1].attentionAt = time.Unix(2, 0)
293	sess.mu.Unlock()
294
295	require.True(t, selectTestAttachmentTab(sess, 1))
296	d.paint(sess, ac, true, nil)
297	data := mustOutputData(t, sends)
298	// As above: the deferred all-clients repaint has nothing new to say about
299	// this client's bars, so no second frame follows.
300	select {
301	case f := <-sends:
302		t.Fatalf("unexpected extra frame after ack: %v", f.Type)
303	case <-time.After(50 * time.Millisecond):
304	}
305
306	require.NotContains(t, string(data), "")
307	sess.mu.Lock()
308	defer sess.mu.Unlock()
309	require.False(t, sess.tabs[1].attention)
310	require.True(t, sess.tabs[1].attentionAt.IsZero())
311}
312
313func TestCloseRingingTabClearsClientBar(t *testing.T) {
314	d, sess, ac, sends, releases := newManualTabSession(t, 2)
315	defer releases[0]()
316	defer releases[1]()
317	selectTestAttachmentTab(sess, 0)
318	sess.mu.Lock()
319	ringing := sess.tabs[1]
320	ringing.attention = true
321	ringing.attentionAt = time.Unix(2, 0)
322	sess.mu.Unlock()
323	d.setAttentionFrame(1)
324
325	d.paint(sess, ac, true, nil)
326	before := mustOutputData(t, sends)
327	require.Contains(t, string(before), "")
328
329	require.NoError(t, d.closeTab(sess, ringing, true))
330	after := mustOutputData(t, sends)
331	require.NotContains(t, string(after), "")
332}
333
334// TestAnimationRepaintConfinedToBarRows is the regression test for the pulse
335// animator: repaintAttachedClients must diff against the warm barCache
336// (reset=false), so a repaint with nothing new to say sends no frame at all,
337// and a repaint following only a pulse-frame advance touches the bar rows
338// without re-emitting screen content.
339func TestAnimationRepaintConfinedToBarRows(t *testing.T) {
340	d, sess, ac, sends, releases := newManualTabSession(t, 2)
341	defer releases[0]()
342	defer releases[1]()
343	selectTestAttachmentTab(sess, 0)
344	sess.tabs[0].focusedPane().screen.Write([]byte("MIDSCREENMARKER"))
345	sess.mu.Lock()
346	sess.tabs[1].attention = true
347	sess.tabs[1].attentionAt = time.Unix(1, 0)
348	sess.mu.Unlock()
349
350	d.paint(sess, ac, true, nil)
351	full := mustOutputData(t, sends)
352	require.Contains(t, string(full), "MIDSCREENMARKER")
353
354	// Nothing changed: a repaint against the warm cache must send nothing.
355	d.repaintAttachedClients(sess)
356	select {
357	case f := <-sends:
358		t.Fatalf("unexpected frame on unchanged repaint: %v", f.Type)
359	case <-time.After(50 * time.Millisecond):
360	}
361
362	// Advance the pulse frame (only the bells' rendered style changes) and
363	// repaint: the bytes sent must not repaint screen content.
364	d.advanceAttentionFrame()
365	d.repaintAttachedClients(sess)
366	data := mustOutputData(t, sends)
367	require.NotContains(t, string(data), "MIDSCREENMARKER")
368}
369
370// TestComposeFrameAttentionFrameChangeDamagesOnlyBars pins the same invariant
371// through the production composition pipeline: with a warm cache and no pane
372// damage, changing only bars.attentionFrame damages only the chrome rows.
373func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
374	d, sess, _, _, releases := newManualTabSession(t, 2)
375	defer releases[0]()
376	defer releases[1]()
377	selectTestAttachmentTab(sess, 0)
378	sess.mu.Lock()
379	sess.tabs[1].attention = true
380	sess.tabs[1].attentionAt = time.Unix(1, 0)
381	sess.mu.Unlock()
382
383	tb := sess.tabs[0]
384	pane := tb.focusedPane()
385	pane.screen.Write([]byte("ATTENTION-PANE-CONTENT"))
386	bars := d.barStateFor(sess, "")
387	area := domain.Rect{Width: pane.screen.Frame.Width, Height: pane.screen.Frame.Height}
388	placement := layout.Placement{ID: pane.id, Content: area}
389	state := capturedRenderState{
390		reset:  true,
391		layout: capturedTabLayout{area: area, focus: pane.id, placements: []layout.Placement{placement}, valid: true},
392		panes: []capturedPaneRenderState{{
393			id: pane.id, frame: pane.screen.Frame.Clone(), placement: placement, focused: true,
394			damage: []renderer.Damage{renderer.FullRedraw()},
395		}},
396		bars: bars,
397	}
398	out := composeFrame(state, composeCacheInput{})
399	require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
400	require.Len(t, state.panes, 1)
401	require.Contains(t, rowText(out.frame.Row(1)), "ATTENTION-PANE-CONTENT")
402	firstContent := rowText(out.frame.Row(1))
403
404	// The pane was included in the warmed cache; it has no new damage when the
405	// attention pulse advances, so the subsequent compose must retain it.
406	bars.attentionFrame++
407	state.reset, state.bars = false, bars
408	state.panes[0].damage = nil
409	out = composeFrame(state, out.cache)
410	damage := out.damage
411	require.Len(t, state.panes, 1, "pane state must remain present when it has no damage")
412	require.Empty(t, state.panes[0].damage)
413	require.Equal(t, firstContent, rowText(out.frame.Row(1)), "unchanged pane content must remain in the composed frame")
414	require.Contains(t, rowText(out.frame.Row(1)), "ATTENTION-PANE-CONTENT")
415	require.NotEmpty(t, damage)
416	lastRow := pane.screen.Frame.Height + 1
417	for _, dmg := range damage {
418		require.Contains(t, []int{0, lastRow}, dmg.Y, "expected damage confined to bar rows, got y=%d", dmg.Y)
419	}
420}
421
422func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
423	d, sess, ac, sends, releases := newManualTabSession(t, 3)
424	sess.mu.Lock()
425	sess.registerAttachmentLocked(ac)
426	sess.mu.Unlock()
427	d.ptys = newBlockingOpenFactory(t, d)
428	defer releases[0]()
429	defer releases[1]()
430	defer releases[2]()
431	selectTestAttachmentTab(sess, 0)
432	sess.mu.Lock()
433	sess.tabs[1].attention = true
434	sess.tabs[1].attentionAt = time.Unix(20, 0)
435	sess.tabs[2].attention = true
436	sess.tabs[2].attentionAt = time.Unix(10, 0)
437	sess.mu.Unlock()
438
439	d.handleInput(sess, ac, []byte("\x1ba"))
440
441	require.Equal(t, 2, testAttachmentTabIndex(sess))
442	requireFloatingInitialized(t, testAttachmentTab(sess))
443	_ = mustOutputData(t, sends)
444}
445
446func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
447	p1, release1 := newBlockingPTY(t)
448	p2, release2 := newBlockingPTY(t)
449	p3, release3 := newBlockingPTY(t)
450	defer release1()
451	defer release2()
452	defer release3()
453	d, sess1, ac, sends := newManualSessionWithPTYs(t, p1)
454	sctx2, cancel2 := context.WithCancel(d.serveCtx)
455	defer cancel2()
456	tab2a := newTestTabWithContext(p2, sctx2, cancel2)
457	tab2b := newTestTabWithContext(p3, sctx2, cancel2)
458	tab2a.attention = true
459	tab2a.attentionAt = time.Unix(9, 0)
460	tab2b.attention = true
461	tab2b.attentionAt = time.Unix(5, 0)
462	sess2 := &session{sessionCore: sessionCore{id: "other",
463		name: "other"}, ctx: sctx2,
464		cancel: cancel2,
465		tabs:   []*tab{tab2a, tab2b},
466	}
467	d.sessions[sess2.id] = sess2
468
469	require.NoError(t, d.jumpAttention(sess1, ac))
470
471	require.Same(t, sess2, ac.currentSession())
472	require.Contains(t, sess2.snapshotAttachments(), ac)
473	require.Empty(t, sess1.snapshotAttachments())
474	require.Equal(t, 1, testAttachmentTabIndex(sess2))
475	_ = mustOutputData(t, sends)
476}
477
478func TestJumpAttentionNoopsWithNoBells(t *testing.T) {
479	d, sess, ac, sends, releases := newManualTabSession(t, 2)
480	defer releases[0]()
481	defer releases[1]()
482	selectTestAttachmentTab(sess, 0)
483
484	require.NoError(t, d.jumpAttention(sess, ac), "no target to jump to is not a failure")
485
486	require.Same(t, sess, ac.currentSession())
487	require.Equal(t, 0, testAttachmentTabIndex(sess))
488	select {
489	case f := <-sends:
490		t.Fatalf("unexpected frame on no-op jump: %v", f.Type)
491	case <-time.After(20 * time.Millisecond):
492	}
493}
494
495// TestJumpAttentionSwitchFailureReportsNotice drives jumpAttention's
496// cross-session path once a target has already been chosen, then makes the
497// commit itself fail (the origin session's client no longer matches ac —
498// simulating a client that detached between target selection and commit).
499// Unlike the no-target case, a target existed and the switch to it failed:
500// that is a genuine error, not a benign no-op, and must reach the user.
501func TestJumpAttentionSwitchFailureReportsNotice(t *testing.T) {
502	p1, release1 := newBlockingPTY(t)
503	p2, release2 := newBlockingPTY(t)
504	defer release1()
505	defer release2()
506	d, sess1, ac, _ := newManualSessionWithPTYs(t, p1)
507	sctx2, cancel2 := context.WithCancel(d.serveCtx)
508	defer cancel2()
509	tab2 := newTestTabWithContext(p2, sctx2, cancel2)
510	tab2.attention = true
511	tab2.attentionAt = time.Unix(9, 0)
512	sess2 := &session{sessionCore: sessionCore{id: "other", name: "other"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{tab2}}
513	d.sessions[sess2.id] = sess2
514
515	sess1.mu.Lock()
516	clearAttachmentsForTestLocked(sess1)
517	sess1.mu.Unlock()
518
519	err := d.jumpAttention(sess1, ac)
520
521	require.Error(t, err)
522	require.Same(t, sess1, ac.currentSession(), "a failed switch must leave the client on its origin session")
523
524	d.reportError(sess1, err)
525	history := d.notices.history()
526	require.Len(t, history, 1)
527	require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)
528}
529
530func TestAttentionAnimatorParksAdvancesAndResets(t *testing.T) {
531	d, sess, _, sends, releases := newManualTabSession(t, 2)
532	defer releases[0]()
533	defer releases[1]()
534	clk := newManualAttentionClock()
535	d.clock = clk
536	selectTestAttachmentTab(sess, 0)
537
538	ctx, cancel := context.WithCancel(context.Background())
539	done := make(chan struct{})
540	go func() {
541		d.attentionAnimator(ctx)
542		close(done)
543	}()
544
545	select {
546	case <-clk.timers:
547		t.Fatal("parked animator requested timer with no attention")
548	case <-time.After(20 * time.Millisecond):
549	}
550
551	sess.mu.Lock()
552	sess.tabs[1].attention = true
553	sess.tabs[1].attentionAt = time.Unix(1, 0)
554	sess.mu.Unlock()
555	d.pokeAttentionTicker()
556
557	timer := clk.nextTimer(t)
558	timer.fire()
559	_ = mustOutputData(t, sends)
560	require.Equal(t, 1, d.attentionFrame())
561
562	sess.mu.Lock()
563	sess.tabs[1].attention = false
564	sess.tabs[1].attentionAt = time.Time{}
565	sess.mu.Unlock()
566	d.pokeAttentionTicker()
567
568	require.Eventually(t, func() bool { return d.attentionFrame() == 0 }, time.Second, time.Millisecond)
569	_ = mustOutputData(t, sends)
570	select {
571	case <-clk.timers:
572		t.Fatal("animator requested new timer after attention cleared")
573	case <-time.After(20 * time.Millisecond):
574	}
575
576	cancel()
577	require.Eventually(t, func() bool {
578		select {
579		case <-done:
580			return true
581		default:
582			return false
583		}
584	}, time.Second, time.Millisecond)
585}
586
587func TestPokeAttentionTickerIsNonBlockingCapOne(t *testing.T) {
588	d := New(nil, stubClock{}, nil)
589	require.Equal(t, 1, cap(d.animWake))
590	d.pokeAttentionTicker()
591	d.pokeAttentionTicker()
592	require.Len(t, d.animWake, 1)
593}
594
595type manualAttentionClock struct {
596	timers chan *manualAttentionTimer
597}
598
599func newManualAttentionClock() *manualAttentionClock {
600	return &manualAttentionClock{timers: make(chan *manualAttentionTimer, 8)}
601}
602
603func (c *manualAttentionClock) Now() time.Time { return time.Unix(100, 0) }
604func (c *manualAttentionClock) NewTimer(time.Duration) ports.Timer {
605	t := &manualAttentionTimer{ch: make(chan time.Time, 1)}
606	c.timers <- t
607	return t
608}
609func (c *manualAttentionClock) nextTimer(t *testing.T) *manualAttentionTimer {
610	t.Helper()
611	select {
612	case timer := <-c.timers:
613		return timer
614	case <-time.After(time.Second):
615		t.Fatal("timed out waiting for attention timer")
616		return nil
617	}
618}
619
620type manualAttentionTimer struct {
621	ch chan time.Time
622}
623
624func (t *manualAttentionTimer) C() <-chan time.Time      { return t.ch }
625func (t *manualAttentionTimer) Reset(time.Duration) bool { return true }
626func (t *manualAttentionTimer) Stop() bool               { return true }
627func (t *manualAttentionTimer) fire()                    { t.ch <- time.Now() }

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