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