v0.7.1 ff50ee4

renderer_test.go RawHistoryFolder

1package html
2
3import (
4	"errors"
5	"math"
6	"math/rand"
7	"os"
8	"testing"
9	"unicode/utf8"
10
11	"github.com/bnema/vev-vt/core"
12	"github.com/stretchr/testify/require"
13)
14
15func TestRendererSnapshotCommitsTransactionally(t *testing.T) {
16	frame := core.NewFrame(3, 1)
17	frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()})
18	frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()})
19	frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})
20
21	renderer, err := New(Options{})
22	require.NoError(t, err)
23
24	prepared, err := renderer.Prepare(frame, nil, false, Cursor{Visible: true, StyleSet: true})
25	require.NoError(t, err)
26	require.True(t, prepared.Update().Snapshot)
27	require.Len(t, prepared.Update().Rows, 1)
28	require.Equal(t, []CellUpdate{
29		{Column: 0, Width: 1, Text: "A", Style: 0},
30		{Column: 1, Width: 2, Text: "界", Style: 0},
31	}, prepared.Update().Rows[0].Cells)
32	require.NotContains(t, string(prepared.JSON()), `"kind":0,"rgb"`)
33
34	_, err = renderer.Prepare(frame, nil, false, Cursor{})
35	require.ErrorIs(t, err, ErrPendingDraw)
36	require.NoError(t, prepared.Commit())
37	require.ErrorIs(t, prepared.Commit(), ErrFinalizedDraw)
38
39	unchanged, err := renderer.Prepare(frame, nil, false, Cursor{Visible: true, StyleSet: true})
40	require.NoError(t, err)
41	require.False(t, unchanged.Update().Snapshot)
42	require.Empty(t, unchanged.Update().Rows)
43	require.NoError(t, unchanged.Abort())
44	require.ErrorIs(t, unchanged.Abort(), ErrFinalizedDraw)
45
46	var nilDraw *PreparedDraw
47	require.True(t, errors.Is(nilDraw.Commit(), ErrStaleDraw))
48}
49
50func TestPreparedJSONMatchesBrowserFixture(t *testing.T) {
51	frame := core.NewFrame(3, 1)
52	frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()})
53	frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()})
54	frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})
55	renderer, err := New(Options{})
56	require.NoError(t, err)
57	prepared, err := renderer.Prepare(frame, nil, false, Cursor{Column: 1, Visible: true, Style: 3, StyleSet: true})
58	require.NoError(t, err)
59	fixture, err := os.ReadFile("../internal/htmlharness/testdata/snapshot.json")
60	require.NoError(t, err)
61	require.JSONEq(t, string(fixture), string(prepared.JSON()))
62}
63
64func TestRendererAcceptsCellSourceWithoutFrameCopy(t *testing.T) {
65	frame := core.NewFrame(2, 1)
66	frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()})
67	snapshot := frame.Clone()
68
69	renderer, err := New(Options{})
70	require.NoError(t, err)
71	prepared, err := renderer.Prepare(cellSourceFunc{
72		columns: snapshot.Columns(),
73		rows:    snapshot.Rows(),
74		cell:    snapshot.Cell,
75	}, nil, false, Cursor{})
76	require.NoError(t, err)
77	require.True(t, prepared.Update().Snapshot)
78	require.Equal(t, []CellUpdate{{Column: 0, Width: 2, Text: "A ", Style: 0}}, prepared.Update().Rows[0].Cells)
79	require.NoError(t, prepared.Commit())
80
81	_, err = renderer.Prepare(nil, nil, false, Cursor{})
82	require.ErrorContains(t, err, "nil cell source")
83}
84
85type cellSourceFunc struct {
86	columns int
87	rows    int
88	cell    func(x, y int) core.Cell
89}
90
91func (s cellSourceFunc) Columns() int                         { return s.columns }
92func (s cellSourceFunc) Rows() int                            { return s.rows }
93func (s cellSourceFunc) Cell(x, y int) core.Cell              { return s.cell(x, y) }
94func (s cellSourceFunc) At(x, y int) core.Cell                { return s.cell(x, y) }
95func (s cellSourceFunc) Row(y int) []core.Cell                { return nil }
96func (s cellSourceFunc) WriteRow(y, x int, c []core.Cell) int { return 0 }
97
98func TestRendererNormalizesWrapPendingCursor(t *testing.T) {
99	frame := core.NewFrame(3, 2)
100	renderer, err := New(Options{})
101	require.NoError(t, err)
102	prepared, err := renderer.Prepare(frame, nil, false, Cursor{Row: 0, Column: 3, Visible: true})
103	require.NoError(t, err)
104	require.Equal(t, 2, prepared.Update().Cursor.Column)
105	require.NoError(t, prepared.Commit())
106
107	_, err = renderer.Prepare(frame, nil, false, Cursor{Row: 0, Column: 4})
108	require.ErrorContains(t, err, "outside")
109}
110
111func TestRendererEnforcesExactGeneratedByteLimit(t *testing.T) {
112	frame := core.NewFrame(1, 1)
113	renderer, err := New(Options{})
114	require.NoError(t, err)
115	prepared, err := renderer.Prepare(frame, nil, true, Cursor{})
116	require.NoError(t, err)
117	size := len(prepared.JSON())
118	require.NoError(t, prepared.Commit())
119
120	tight, err := New(Options{Limits: Limits{MaxGeneratedBytes: size}})
121	require.NoError(t, err)
122	accepted, err := tight.Prepare(frame, nil, true, Cursor{})
123	require.NoError(t, err)
124	require.NoError(t, accepted.Commit())
125
126	strict, err := New(Options{Limits: Limits{MaxGeneratedBytes: size - 1}})
127	require.NoError(t, err)
128	_, err = strict.Prepare(frame, nil, true, Cursor{})
129	require.ErrorIs(t, err, ErrLimitExceeded)
130	require.ErrorContains(t, err, "generated update is")
131}
132
133func TestRendererMergesASCIICellsIntoStyledTextRuns(t *testing.T) {
134	plain := core.DefaultStyle()
135	bold := core.DefaultStyle()
136	bold.Bold = true
137	frame := core.NewFrame(12, 1)
138	for x, r := range "ab" {
139		frame.Set(x, 0, core.Cell{Rune: r, Style: plain})
140	}
141	// x=2 stays blank (Rune 0) and joins the plain run as a space.
142	frame.Set(3, 0, core.Cell{Rune: '界', Style: plain})
143	frame.Set(4, 0, core.Cell{Continuation: true, Style: plain})
144	frame.Set(5, 0, core.Cell{Rune: 'é', Style: plain})
145	frame.Set(6, 0, core.Cell{Rune: 'c', Style: bold})
146	frame.Set(7, 0, core.Cell{Rune: 'd', Style: bold})
147	frame.Set(8, 0, core.Cell{Rune: '~', Style: plain})
148
149	renderer, err := New(Options{})
150	require.NoError(t, err)
151	prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
152	require.NoError(t, err)
153	require.Equal(t, []CellUpdate{
154		{Column: 0, Width: 3, Text: "ab ", Style: 0},
155		{Column: 3, Width: 2, Text: "界", Style: 0},
156		{Column: 5, Width: 1, Text: "é", Style: 0},
157		{Column: 6, Width: 2, Text: "cd", Style: 1},
158		{Column: 8, Width: 4, Text: "~   ", Style: 0},
159	}, prepared.Update().Rows[0].Cells)
160	require.NoError(t, prepared.Commit())
161}
162
163func TestRendererIncrementalRunsSliceChangedRowsIndependently(t *testing.T) {
164	plain := core.DefaultStyle()
165	frame := core.NewFrame(4, 3)
166	renderer, err := New(Options{})
167	require.NoError(t, err)
168	first, err := renderer.Prepare(frame, nil, false, Cursor{})
169	require.NoError(t, err)
170	require.NoError(t, first.Commit())
171
172	frame.Set(0, 0, core.Cell{Rune: 'a', Style: plain})
173	frame.Set(1, 2, core.Cell{Rune: '界', Style: plain})
174	frame.Set(2, 2, core.Cell{Continuation: true, Style: plain})
175	prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
176	require.NoError(t, err)
177	rows := prepared.Update().Rows
178	require.Len(t, rows, 2)
179	require.Equal(t, RowUpdate{Row: 0, Cells: []CellUpdate{{Column: 0, Width: 4, Text: "a   ", Style: 0}}}, rows[0])
180	require.Equal(t, RowUpdate{Row: 2, Cells: []CellUpdate{
181		{Column: 0, Width: 1, Text: " ", Style: 0},
182		{Column: 1, Width: 2, Text: "界", Style: 0},
183		{Column: 3, Width: 1, Text: " ", Style: 0},
184	}}, rows[1])
185	require.NoError(t, prepared.Commit())
186}
187
188// TestRendererRunsCoverEveryRowExactly checks the run invariants on random
189// frames: entries are contiguous, widths sum to the frame width, and every
190// entry wider than one cell is printable ASCII with one byte per column.
191func TestRendererRunsCoverEveryRowExactly(t *testing.T) {
192	rng := rand.New(rand.NewSource(1))
193	runes := []rune{0, 'a', 'b', ' ', '~', 'é', '✓', '界'}
194	styles := []core.Style{core.DefaultStyle(), func() core.Style { s := core.DefaultStyle(); s.Bold = true; return s }()}
195	for iteration := range 200 {
196		width, height := 1+rng.Intn(20), 1+rng.Intn(4)
197		frame := core.NewFrame(width, height)
198		for y := range height {
199			for x := 0; x < width; x++ {
200				r := runes[rng.Intn(len(runes))]
201				style := styles[rng.Intn(len(styles))]
202				if core.RuneWidth(r) == 2 {
203					if x+1 >= width {
204						r = 'w'
205					} else {
206						frame.Set(x, y, core.Cell{Rune: r, Style: style})
207						frame.Set(x+1, y, core.Cell{Continuation: true, Style: style})
208						x++
209						continue
210					}
211				}
212				frame.Set(x, y, core.Cell{Rune: r, Style: style})
213			}
214		}
215		renderer, err := New(Options{})
216		require.NoError(t, err)
217		prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
218		require.NoError(t, err, "iteration %d", iteration)
219		requireRunsMatchFrame(t, prepared.Update(), frame, iteration)
220
221		// An incremental update after one changed cell decodes the same way,
222		// through the renderer's reused frame page.
223		require.NoError(t, prepared.Commit())
224		y := rng.Intn(height)
225		frame.Set(0, y, core.Cell{Rune: 'Z', Style: styles[1]})
226		if width > 1 && frame.Cell(1, y).Continuation {
227			frame.Set(1, y, core.Cell{Rune: ' '})
228		}
229		prepared, err = renderer.Prepare(frame, nil, false, Cursor{})
230		require.NoError(t, err, "iteration %d", iteration)
231		update := prepared.Update()
232		require.False(t, update.Snapshot)
233		require.Len(t, update.Rows, 1)
234		requireRunsMatchFrame(t, update, frame, iteration)
235	}
236}
237
238// requireRunsMatchFrame expands every entry back into cells and compares
239// each column's text, style and width with the source frame.
240func requireRunsMatchFrame(t *testing.T, update Update, frame core.Frame, iteration int) {
241	t.Helper()
242	for _, row := range update.Rows {
243		column := 0
244		for _, cell := range row.Cells {
245			require.Equal(t, column, cell.Column, "iteration %d row %d", iteration, row.Row)
246			source := frame.Cell(column, row.Row)
247			require.Equal(t, styleFromCore(source.Style), update.Styles[cell.Style], "iteration %d (%d,%d)", iteration, column, row.Row)
248			if r, _ := utf8.DecodeRuneInString(cell.Text); r >= 0x80 {
249				require.Equal(t, string(source.Rune), cell.Text, "iteration %d (%d,%d)", iteration, column, row.Row)
250				require.Equal(t, core.RuneWidth(source.Rune), cell.Width, "iteration %d (%d,%d)", iteration, column, row.Row)
251				column += cell.Width
252				continue
253			}
254			require.Len(t, cell.Text, cell.Width, "iteration %d text %q", iteration, cell.Text)
255			for i := range len(cell.Text) {
256				source := frame.Cell(column+i, row.Row)
257				want := source.Rune
258				if want == 0 {
259					want = ' '
260				}
261				require.Equal(t, string(want), cell.Text[i:i+1], "iteration %d (%d,%d)", iteration, column+i, row.Row)
262				require.Equal(t, styleFromCore(source.Style), update.Styles[cell.Style], "iteration %d (%d,%d)", iteration, column+i, row.Row)
263			}
264			column += cell.Width
265		}
266		require.Equal(t, frame.Width, column, "iteration %d row %d", iteration, row.Row)
267	}
268}
269
270func TestRendererAcceptsMaxIntGeneratedBytesLimit(t *testing.T) {
271	renderer, err := New(Options{Limits: Limits{MaxGeneratedBytes: math.MaxInt}})
272	require.NoError(t, err)
273	prepared, err := renderer.Prepare(core.NewFrame(2, 1), nil, true, Cursor{})
274	require.NoError(t, err)
275	require.NoError(t, prepared.Commit())
276}
277
278func TestRendererUsesIncrementalDamageWithNormalCounts(t *testing.T) {
279	frame := core.NewFrame(2, 2)
280	renderer, err := New(Options{})
281	require.NoError(t, err)
282	first, err := renderer.Prepare(frame, nil, false, Cursor{})
283	require.NoError(t, err)
284	require.NoError(t, first.Commit())
285
286	frame.Set(0, 0, core.Cell{Rune: 'X', Style: core.DefaultStyle()})
287	prepared, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1}}, false, Cursor{})
288	require.NoError(t, err)
289	require.False(t, prepared.Update().Snapshot)
290	require.NoError(t, prepared.Commit())
291}
292
293func TestRendererFindsChangesOutsideDamage(t *testing.T) {
294	frame := core.NewFrame(2, 2)
295	renderer, err := New(Options{})
296	require.NoError(t, err)
297	first, err := renderer.Prepare(frame, nil, false, Cursor{})
298	require.NoError(t, err)
299	require.NoError(t, first.Commit())
300
301	frame.Set(1, 1, core.Cell{Rune: 'X', Style: core.DefaultStyle()})
302	prepared, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false, Cursor{})
303	require.NoError(t, err)
304	require.False(t, prepared.Update().Snapshot)
305	require.Equal(t, 1, prepared.Update().Rows[0].Row)
306	require.NoError(t, prepared.Commit())
307}
308
309func TestRendererSnapshotsScrollAndEmitsCursorOnlyChanges(t *testing.T) {
310	frame := core.NewFrame(2, 2)
311	renderer, err := New(Options{})
312	require.NoError(t, err)
313	first, err := renderer.Prepare(frame, nil, false, Cursor{})
314	require.NoError(t, err)
315	require.NoError(t, first.Commit())
316
317	cursorOnly, err := renderer.Prepare(frame, nil, false, Cursor{Row: 1, Column: 1, Visible: true, Style: 4, StyleSet: true})
318	require.NoError(t, err)
319	require.Empty(t, cursorOnly.Update().Rows)
320	require.NotNil(t, cursorOnly.Update().Rows)
321	require.NotNil(t, cursorOnly.Update().Styles)
322	require.Contains(t, string(cursorOnly.JSON()), `"rows":[]`)
323	require.Contains(t, string(cursorOnly.JSON()), `"styles":[]`)
324	require.Equal(t, 1, cursorOnly.Update().Cursor.Row)
325	require.NoError(t, cursorOnly.Commit())
326
327	scroll, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageScrollUp, X: 0, Y: 0, Width: 2, Height: 2, Count: 1}}, false, Cursor{})
328	require.NoError(t, err)
329	require.True(t, scroll.Update().Snapshot)
330	require.Len(t, scroll.Update().Rows, 2)
331	require.NoError(t, scroll.Abort())
332}
333
334func TestPreparedDrawReturnsOwnedUpdateAndJSON(t *testing.T) {
335	frame := core.NewFrame(1, 1)
336	renderer, err := New(Options{})
337	require.NoError(t, err)
338	prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
339	require.NoError(t, err)
340
341	update := prepared.Update()
342	encoded := prepared.JSON()
343	wantJSON := string(encoded)
344	update.Rows[0].Cells[0].Text = "mutated"
345	encoded[0] = 'X'
346	require.Equal(t, " ", prepared.Update().Rows[0].Cells[0].Text)
347	require.JSONEq(t, wantJSON, string(prepared.JSON()))
348	require.Equal(t, byte('{'), prepared.JSON()[0])
349}
350
351func TestCopiedPreparedDrawSharesFinalization(t *testing.T) {
352	frame := core.NewFrame(1, 1)
353	renderer, err := New(Options{})
354	require.NoError(t, err)
355	prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
356	require.NoError(t, err)
357	copied := *prepared
358	require.NoError(t, copied.Commit())
359	require.ErrorIs(t, prepared.Abort(), ErrFinalizedDraw)
360}
361
362func TestRendererInvalidatesCopiedDrawOnReset(t *testing.T) {
363	frame := core.NewFrame(1, 1)
364	renderer, err := New(Options{})
365	require.NoError(t, err)
366	prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
367	require.NoError(t, err)
368	copied := *prepared
369
370	renderer.Reset()
371	require.ErrorIs(t, prepared.Commit(), ErrStaleDraw)
372	require.ErrorIs(t, copied.Abort(), ErrStaleDraw)
373
374	next, err := renderer.Prepare(frame, nil, false, Cursor{})
375	require.NoError(t, err)
376	require.True(t, next.Update().Snapshot)
377	require.NoError(t, next.Commit())
378}
379
380func TestPreparedDrawIsIndependentOfLaterPrepares(t *testing.T) {
381	frame := core.NewFrame(4, 3)
382	frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()})
383	renderer, err := New(Options{})
384	require.NoError(t, err)
385
386	first, err := renderer.Prepare(frame, nil, false, Cursor{})
387	require.NoError(t, err)
388	firstUpdate, firstJSON := first.Update(), first.JSON()
389	require.NoError(t, first.Commit())
390
391	// The caller mutates its frame in place; neither the earlier draw nor the
392	// committed shadow may observe it.
393	frame.Set(1, 1, core.Cell{Rune: 'B', Style: core.DefaultStyle()})
394	second, err := renderer.Prepare(frame, nil, false, Cursor{})
395	require.NoError(t, err)
396	require.Equal(t, []int{1}, rowIndexes(second.Update()))
397	secondUpdate, secondJSON := second.Update(), second.JSON()
398	require.NoError(t, second.Commit())
399
400	frame.Set(2, 2, core.Cell{Rune: 'C', Style: core.DefaultStyle()})
401	third, err := renderer.Prepare(frame, nil, false, Cursor{})
402	require.NoError(t, err)
403	require.Equal(t, []int{2}, rowIndexes(third.Update()))
404	require.NoError(t, third.Commit())
405
406	require.Equal(t, firstUpdate, first.Update())
407	require.Equal(t, firstJSON, first.JSON())
408	require.Equal(t, secondUpdate, second.Update())
409	require.Equal(t, secondJSON, second.JSON())
410}
411
412func TestAbortThenPrepareKeepsCommittedShadow(t *testing.T) {
413	frame := core.NewFrame(3, 2)
414	renderer, err := New(Options{})
415	require.NoError(t, err)
416	first, err := renderer.Prepare(frame, nil, false, Cursor{})
417	require.NoError(t, err)
418	require.NoError(t, first.Commit())
419
420	frame.Set(0, 1, core.Cell{Rune: 'X', Style: core.DefaultStyle()})
421	aborted, err := renderer.Prepare(frame, nil, false, Cursor{})
422	require.NoError(t, err)
423	abortedJSON := aborted.JSON()
424	require.NoError(t, aborted.Abort())
425
426	retry, err := renderer.Prepare(frame, nil, false, Cursor{})
427	require.NoError(t, err)
428	require.False(t, retry.Update().Snapshot)
429	require.Equal(t, []int{1}, rowIndexes(retry.Update()))
430	require.Equal(t, abortedJSON, retry.JSON())
431	require.NoError(t, retry.Commit())
432
433	steady, err := renderer.Prepare(frame, nil, false, Cursor{})
434	require.NoError(t, err)
435	require.Empty(t, steady.Update().Rows)
436}
437
438func TestRendererKeepsEncoderScratchAt240x80(t *testing.T) {
439	frames := [2]core.Frame{core.NewFrame(240, 80), core.NewFrame(240, 80)}
440	for y := range 80 {
441		for x := range 240 {
442			frames[0].Set(x, y, core.Cell{Rune: 'a'})
443			frames[1].Set(x, y, core.Cell{Rune: 'b'})
444		}
445	}
446	renderer, err := New(Options{})
447	require.NoError(t, err)
448	for step := range 2 {
449		prepared, err := renderer.Prepare(frames[step], nil, false, Cursor{})
450		require.NoError(t, err)
451		require.NoError(t, prepared.Commit())
452	}
453	require.GreaterOrEqual(t, cap(renderer.rowStarts), 80, "row-start scratch was not retained")
454	require.GreaterOrEqual(t, cap(renderer.ends), 240, "run-end scratch was not retained")
455}
456
457// TestRendererMatchesFreshRendererAcrossTransactions drives one renderer
458// (reused spare pages and scratch) through random edits with payloads and
459// equivalent styles, aborts, resets, stale commits and failed prepares. Most
460// steps commit and keep editing, so the shadow and spare pages alternate.
461// Every update must list exactly the rows that differ from a cloned committed
462// shadow (compared cell by cell with Cell.Equal) and encode the source frame.
463func TestRendererMatchesFreshRendererAcrossTransactions(t *testing.T) {
464	rng := rand.New(rand.NewSource(7))
465	link := func(i int) core.CellPayload {
466		payload, err := core.NewCellPayload("", "https://example.test/"+string(rune('a'+i)))
467		require.NoError(t, err)
468		return payload
469	}
470	bold := core.DefaultStyle()
471	bold.Bold = true
472	equivalent := bold
473	equivalent.ForegroundRGB = core.RGB{R: 1} // inactive RGB: same style
474	for seed := range 30 {
475		width, height := 2+rng.Intn(10), 1+rng.Intn(5)
476		frame := core.NewFrame(width, height)
477		renderer, err := New(Options{})
478		require.NoError(t, err)
479		var committed core.Frame
480		var stale *PreparedDraw
481		for step := range 60 {
482			for range rng.Intn(4) {
483				cell := core.Cell{Rune: rune('a' + rng.Intn(4))}
484				switch rng.Intn(4) {
485				case 0:
486					cell.Style = bold
487				case 1:
488					cell.Style = equivalent
489				case 2:
490					cell.Payload = link(rng.Intn(3))
491				}
492				frame.Set(rng.Intn(width), rng.Intn(height), cell)
493			}
494			if stale != nil && rng.Intn(3) == 0 {
495				require.ErrorIs(t, stale.Commit(), ErrStaleDraw)
496				stale = nil
497			}
498			if rng.Intn(10) == 0 {
499				// A failed prepare must not lose or corrupt the spare page.
500				_, err := renderer.Prepare(frame, nil, false, Cursor{Row: height})
501				require.Error(t, err)
502			}
503
504			want := []int{}
505			for y := range height {
506				for x := range width {
507					if committed.Width == 0 || !frame.Cell(x, y).Equal(committed.Cell(x, y)) {
508						want = append(want, y)
509						break
510					}
511				}
512			}
513			got, err := renderer.Prepare(frame, nil, false, Cursor{})
514			require.NoError(t, err, "seed %d step %d", seed, step)
515			update := got.Update()
516			require.Equal(t, committed.Width == 0, update.Snapshot, "seed %d step %d", seed, step)
517			require.Equal(t, want, rowIndexes(update), "seed %d step %d", seed, step)
518			requireRunsMatchFrame(t, update, frame, step)
519
520			switch k := rng.Intn(20); {
521			case k == 0:
522				renderer.Reset()
523				committed = core.Frame{}
524				stale = got
525			case k < 4:
526				require.NoError(t, got.Abort())
527			default:
528				require.NoError(t, got.Commit())
529				committed = frame.Clone()
530			}
531		}
532	}
533}
534
535func rowIndexes(update Update) []int {
536	rows := make([]int, 0, len(update.Rows))
537	for _, row := range update.Rows {
538		rows = append(rows, row.Row)
539	}
540	return rows
541}

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 2

Tags 13

↑↓ moveenter openesc close