attachment_output_test.go RawHistoryFolder

1package daemon
2
3import (
4	"errors"
5	"runtime"
6	"testing"
7
8	vt "github.com/bnema/vev-vt"
9	renderer "github.com/bnema/vev-vt/ansi"
10	"github.com/bnema/vev-vt/graphics"
11	"github.com/bnema/vev/internal/domain"
12	"github.com/bnema/vev/internal/domain/layout"
13	"github.com/bnema/vev/internal/domain/terminalcap"
14	"github.com/bnema/vev/internal/protocol"
15	"github.com/bnema/vev/internal/protocol/wire"
16	"github.com/stretchr/testify/require"
17)
18
19func TestAttachmentOutputBuildsPipelinedDependencyChain(t *testing.T) {
20	stream := newOutputStateStream()
21	firstFrame := renderer.NewFrame(3, 1)
22	fillOutputStateRows(firstFrame, []string{"abc"})
23	first, ok, err := drawOutputState(t, stream, firstFrame, nil, true, 7)
24	require.NoError(t, err)
25	require.True(t, ok)
26	firstOut := unmarshalTestOutput(t, first.Payload)
27	require.Equal(t, uint64(0), firstOut.Base)
28	require.Equal(t, uint64(1), firstOut.New)
29	require.Equal(t, uint64(7), firstOut.Echo)
30
31	secondFrame := firstFrame.Clone()
32	secondFrame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
33	second, ok, err := drawOutputState(t, stream, secondFrame, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false, 8)
34	require.NoError(t, err)
35	require.True(t, ok)
36	secondOut := unmarshalTestOutput(t, second.Payload)
37	require.Equal(t, firstOut.New, secondOut.Base)
38	require.Equal(t, uint64(2), secondOut.New)
39	require.Equal(t, uint64(2), stream.outstanding())
40}
41
42func TestAttachmentOutputUsesTerminalColorProfile(t *testing.T) {
43	frame := renderer.NewFrame(1, 1)
44	frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.Style{
45		Foreground:           -1,
46		Background:           -1,
47		HasForegroundRGB:     true,
48		ForegroundRGB:        renderer.RGB{R: 255, G: 0, B: 0},
49		HasBackgroundRGB:     true,
50		BackgroundRGB:        renderer.RGB{R: 0, G: 0, B: 255},
51		HasUnderlineColorRGB: true,
52		UnderlineColorRGB:    renderer.RGB{R: 0, G: 255, B: 0},
53	}})
54
55	cases := []struct {
56		name         string
57		capabilities terminalcap.Capabilities
58		want         string
59	}{
60		{
61			name:         "truecolor",
62			capabilities: terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}},
63			want:         "\x1b[1;1H\x1b[0;38;2;255;0;0;48;2;0;0;255;58;2;0;255;0mX\x1b[0m",
64		},
65		{
66			name:         "indexed 256",
67			capabilities: terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}},
68			want:         "\x1b[1;1H\x1b[0;38;5;196;48;5;21;58;5;46mX\x1b[0m",
69		},
70	}
71
72	for _, tc := range cases {
73		t.Run(tc.name, func(t *testing.T) {
74			stream := newOutputStateStreamForCapabilities(tc.capabilities)
75			prepared, err := stream.prepare(frame, nil, true)
76			require.NoError(t, err)
77			require.Equal(t, tc.want, string(prepared.data))
78		})
79	}
80}
81
82func TestAttachmentOutputCapacityProbeDoesNotRaceWithSend(t *testing.T) {
83	stream := newOutputStateStream(1)
84	frame := renderer.NewFrame(3, 1)
85	fillOutputStateRows(frame, []string{"abc"})
86	prepared, err := stream.prepareTestOutput(frame, nil, true)
87	require.NoError(t, err)
88
89	entered := make(chan struct{})
90	release := make(chan struct{})
91	sendDone := make(chan error, 1)
92	go func() {
93		sendDone <- prepared.send(prepared.data, 0, outputFrameSender(func(wire.Envelope) error {
94			close(entered)
95			<-release
96			return nil
97		}))
98	}()
99	awaitTestCompletion(t, entered, "output send was not admitted")
100
101	probed := make(chan struct{})
102	go func() {
103		for range 100_000 {
104			_ = stream.atCapacity()
105			runtime.Gosched()
106		}
107		close(probed)
108	}()
109	close(release)
110	require.NoError(t, <-sendDone)
111	<-probed
112	require.True(t, stream.atCapacity())
113
114	stream.ack(1, 1)
115	require.False(t, stream.atCapacity())
116	stream.rebase()
117	require.False(t, stream.atCapacity())
118}
119
120func TestAttachmentOutputEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *testing.T) {
121	frame := renderer.NewFrame(3, 1)
122	fillOutputStateRows(frame, []string{"abc"})
123	newAttachment := func() *attachmentOutput {
124		stream := newOutputStateStream()
125		ac := &attachedClient{output: stream, geometry: domain.Geometry{Size: domain.Size{Cols: 3, Rows: 1}}}
126		stream.attachment = ac
127		return stream
128	}
129
130	first := newAttachment()
131	second := newAttachment()
132	firstPrepared, err := first.prepareTestOutput(frame, nil, true)
133	require.NoError(t, err)
134	var firstFrame wire.Envelope
135	require.NoError(t, firstPrepared.send(firstPrepared.data, 0, outputFrameSender(func(frame wire.Envelope) error {
136		firstFrame = frame
137		return nil
138	})))
139	firstOutput := unmarshalTestOutput(t, firstFrame.Payload)
140	require.Equal(t, uint64(1), firstOutput.Epoch)
141	require.Equal(t, uint64(1), firstOutput.New)
142	require.True(t, firstOutput.Full)
143	first.ack(firstOutput.Epoch, firstOutput.New)
144
145	secondPrepared, err := second.prepareTestOutput(frame, nil, true)
146	require.NoError(t, err)
147	require.NoError(t, secondPrepared.send(secondPrepared.data, 0, outputFrameSender(func(wire.Envelope) error { return nil })))
148	require.Equal(t, uint64(1), second.epoch)
149	require.Equal(t, uint64(1), second.next)
150	require.Equal(t, uint64(1), first.next)
151
152	changed := frame.Clone()
153	changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
154	pending, err := first.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
155	require.NoError(t, err)
156	first.rebase()
157	require.Equal(t, uint64(2), first.epoch)
158	require.Zero(t, first.next)
159	require.Zero(t, first.acked)
160	var staleSent bool
161	require.NoError(t, pending.send(pending.data, 0, outputFrameSender(func(wire.Envelope) error {
162		staleSent = true
163		return nil
164	})))
165	require.False(t, staleSent)
166	require.Zero(t, first.next)
167	first.ack(1, 1)
168	require.Zero(t, first.acked, "an ACK from the retired epoch must not advance the new epoch")
169
170	reset, err := first.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
171	require.NoError(t, err)
172	var resetFrame wire.Envelope
173	require.NoError(t, reset.send(reset.data, 0, outputFrameSender(func(frame wire.Envelope) error {
174		resetFrame = frame
175		return nil
176	})))
177	resetOutput := unmarshalTestOutput(t, resetFrame.Payload)
178	require.Equal(t, uint64(2), resetOutput.Epoch)
179	require.Zero(t, resetOutput.Base)
180	require.Equal(t, uint64(1), resetOutput.New)
181	require.True(t, resetOutput.Full)
182}
183
184func TestPreparedOutputDropsReplacedConnectionAndView(t *testing.T) {
185	for _, tt := range []struct {
186		name   string
187		change func(*attachedClient)
188	}{
189		{name: "connection generation", change: func(ac *attachedClient) { ac.lifecycle.generation.Add(1) }},
190		{name: "view revision", change: func(ac *attachedClient) {
191			ac.viewMu.Lock()
192			ac.view.revision++
193			ac.viewMu.Unlock()
194		}},
195	} {
196		t.Run(tt.name, func(t *testing.T) {
197			stream := newOutputStateStream()
198			ac := &attachedClient{output: stream, geometry: domain.Geometry{Size: domain.Size{Cols: 3, Rows: 1}}}
199			stream.attachment = ac
200			frame := renderer.NewFrame(3, 1)
201			fillOutputStateRows(frame, []string{"abc"})
202			initial, err := stream.prepareTestOutput(frame, nil, true)
203			require.NoError(t, err)
204			require.NoError(t, initial.send(initial.data, 0, outputFrameSender(func(wire.Envelope) error { return nil })))
205			changed := frame.Clone()
206			changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
207			pending, err := stream.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
208			require.NoError(t, err)
209			tt.change(ac)
210			var sent bool
211			require.NoError(t, pending.send(pending.data, 0, outputFrameSender(func(wire.Envelope) error {
212				sent = true
213				return nil
214			})))
215			require.False(t, sent)
216			require.Equal(t, uint64(1), stream.next)
217
218			retry, err := stream.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
219			require.NoError(t, err)
220			require.NoError(t, retry.send(retry.data, 0, outputFrameSender(func(wire.Envelope) error { return nil })))
221			require.Equal(t, uint64(2), stream.next)
222		})
223	}
224}
225
226func TestOutputStateAckRequiresCurrentEpoch(t *testing.T) {
227	stream := newOutputStateStream()
228	stream.next = 2
229	stream.ack(2, 1)
230	require.Zero(t, stream.acked, "a stale epoch must not retire output state")
231	stream.ack(1, 1)
232	require.Equal(t, uint64(1), stream.acked)
233	stream.ack(1, 3)
234	require.Equal(t, uint64(1), stream.acked, "a future state must not advance the ACK window")
235}
236
237func TestOutputStateSideEffectsDoNotAdvanceEpochStateOrACK(t *testing.T) {
238	stream := newOutputStateStream()
239	stream.ack(1, 0)
240	beforeEpoch, beforeNext, beforeAck := stream.epoch, stream.next, stream.acked
241	out, err := stream.sideEffect([]byte("pty"), 9)
242	require.NoError(t, err)
243	require.Equal(t, beforeEpoch, out.Epoch)
244	require.Zero(t, out.Base)
245	require.Zero(t, out.New)
246	require.False(t, out.Full)
247	require.Equal(t, uint64(9), out.Echo)
248	require.Equal(t, beforeNext, stream.next)
249	require.Equal(t, beforeAck, stream.acked)
250}
251
252func TestAttachmentOutputFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T) {
253	stream := newOutputStateStream()
254	initial := renderer.NewFrame(3, 1)
255	fillOutputStateRows(initial, []string{"abc"})
256	first, err := stream.prepareTestOutput(initial, nil, true)
257	require.NoError(t, err)
258	require.NoError(t, first.send(first.data, 0, outputFrameSender(func(wire.Envelope) error { return nil })))
259	require.Equal(t, uint64(1), stream.next)
260
261	changed := initial.Clone()
262	changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
263	pending, err := stream.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
264	require.NoError(t, err)
265	probe, err := stream.renderer.Prepare(initial, nil, false)
266	require.NoError(t, err)
267	require.Empty(t, probe.Bytes(), "preparation must not advance the renderer shadow")
268
269	sendErr := errors.New("send failed")
270	require.ErrorIs(t, pending.send(pending.data, 0, outputFrameSender(func(wire.Envelope) error { return sendErr })), sendErr)
271	require.Equal(t, uint64(1), stream.next, "failed send must not advance the state chain")
272	pending.commitNoSend()
273	probe, err = stream.renderer.Prepare(initial, nil, false)
274	require.NoError(t, err)
275	require.Empty(t, probe.Bytes(), "failed send must retain the committed renderer shadow")
276
277	retry, err := stream.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
278	require.NoError(t, err)
279	var sent wire.Envelope
280	require.NoError(t, retry.send(retry.data, 0, outputFrameSender(func(frame wire.Envelope) error {
281		sent = frame
282		return nil
283	})))
284	out := unmarshalTestOutput(t, sent.Payload)
285	require.Zero(t, out.Base, "retry after ambiguous send failure must be dependency-free")
286	require.Equal(t, uint64(2), out.New, "state numbers remain monotonic across rebases")
287	require.Equal(t, uint64(2), stream.next, "successful retry advances the chain exactly once")
288	require.NoError(t, retry.send(retry.data, 0, outputFrameSender(func(wire.Envelope) error {
289		t.Fatal("completed output sent twice")
290		return nil
291	})))
292	require.Equal(t, uint64(2), stream.next)
293	probe, err = stream.renderer.Prepare(changed, nil, false)
294	require.NoError(t, err)
295	require.Empty(t, probe.Bytes(), "successful retry commits the candidate shadow")
296}
297
298func TestAttachmentOutputNoByteCommitAdvancesShadowWithoutState(t *testing.T) {
299	stream := newOutputStateStream()
300	frame := renderer.NewFrame(3, 1)
301	fillOutputStateRows(frame, []string{"abc"})
302	initial, err := stream.prepareTestOutput(frame, nil, true)
303	require.NoError(t, err)
304	require.NoError(t, initial.send(initial.data, 0, outputFrameSender(func(wire.Envelope) error { return nil })))
305
306	noOp, err := stream.prepare(frame, nil, false)
307	require.NoError(t, err)
308	require.Empty(t, noOp.data)
309	noOp.commitNoSend()
310	noOp.commitNoSend()
311	require.Equal(t, uint64(1), stream.next, "no-byte commit must not create a state frame")
312
313	probe, err := stream.renderer.Prepare(frame, nil, false)
314	require.NoError(t, err)
315	require.Empty(t, probe.Bytes(), "no-byte commit retains the prepared visual candidate")
316}
317
318func TestAttachmentOutputRepeatedUnackedScrollRemainsClientCorrect(t *testing.T) {
319	stream := newOutputStateStream()
320	client := vt.NewScreen(4, 3)
321	initial := renderer.NewFrame(4, 3)
322	fillOutputStateRows(initial, []string{"AAAA", "BBBB", "CCCC"})
323	first, ok, err := drawOutputState(t, stream, initial, nil, true, 0)
324	require.NoError(t, err)
325	require.True(t, ok)
326	mustApplyOutput(t, client, first)
327	stream.ack(1, 1)
328
329	scrolled := renderer.NewFrame(4, 3)
330	fillOutputStateRows(scrolled, []string{"BBBB", "CCCC", "N   "})
331	damage := []renderer.Damage{
332		{Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
333		{Kind: renderer.DamageText, X: 0, Y: 2, Width: 4, Height: 1},
334	}
335	second, ok, err := drawOutputState(t, stream, scrolled, damage, false, 0)
336	require.NoError(t, err)
337	require.True(t, ok)
338	mustApplyOutput(t, client, second)
339
340	// The renderer baseline is the preceding emitted frame, so repeating stale
341	// scroll damage falls back to an overwrite instead of applying a second
342	// incompatible scroll program.
343	third, ok, err := drawOutputState(t, stream, scrolled, damage, false, 0)
344	require.NoError(t, err)
345	require.True(t, ok)
346	mustApplyOutput(t, client, third)
347	for y, want := range []string{"BBBB", "CCCC", "N   "} {
348		require.Equal(t, want, outputStateRow(client.RowCells(y)))
349	}
350}
351
352func (s *attachmentOutput) render(frame renderer.Frame, damage []renderer.Damage, reset bool) ([]byte, error) {
353	prepared, err := s.prepare(frame, damage, reset)
354	if err != nil {
355		return nil, err
356	}
357	if len(prepared.data) == 0 {
358		prepared.commitNoSend()
359		return nil, nil
360	}
361	// This helper tests renderer replay bytes without publishing a wire frame.
362	// Production state advancement happens only through preparedOutput.send.
363	prepared.draw.Commit()
364	s.forceSnapshot = false
365	return prepared.data, nil
366}
367
368func outputStateFrame(stream *attachmentOutput, data []byte, reset bool, echoAck uint64) wire.Envelope {
369	stream.next++
370	base := stream.next - 1
371	if reset {
372		base = 0
373	}
374	context := publicationTestContext()
375	context.Publication = stream.next
376	output := protocol.Output{
377		Epoch: 1, Base: base, New: stream.next, Echo: echoAck,
378		Size: domain.Size{Cols: 1, Rows: 1}, Full: reset, Data: data, Context: &context,
379	}
380	return mustServerEnvelope(output)
381}
382
383// prepareTestOutput gives replay-only fixtures explicit semantic ownership;
384// production obtains this from capturedRenderState in prepareFrame.
385func (s *attachmentOutput) prepareTestOutput(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {
386	prepared, err := s.prepare(frame, damage, reset)
387	if err == nil {
388		context := publicationTestContext()
389		prepared.context = &context
390	}
391	return prepared, err
392}
393
394func drawOutputState(t *testing.T, stream *attachmentOutput, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (wire.Envelope, bool, error) {
395	t.Helper()
396	prepared, err := stream.prepareTestOutput(frame, damage, reset)
397	if err != nil || len(prepared.data) == 0 {
398		if prepared != nil {
399			prepared.commitNoSend()
400		}
401		return wire.Envelope{}, false, err
402	}
403	var output wire.Envelope
404	err = prepared.send(prepared.data, echoAck, outputFrameSender(func(frame wire.Envelope) error {
405		output = frame
406		return nil
407	}))
408	if err != nil {
409		return wire.Envelope{}, false, err
410	}
411	return output, true, nil
412}
413
414// A resize is a state reset, but it must not turn subsequent idle or damaged
415// renders into full frames.
416func TestAttachmentOutputResizeFrameThenNoopAndDamageAreDifferential(t *testing.T) {
417	screen := vt.NewScreen(4, 2)
418	screen.Write([]byte("abcd"))
419	screen.ClearDamage()
420	screen.Resize(6, 2)
421
422	stream := newOutputStateStream()
423	resized, ok, err := drawOutputState(t, stream, captureTestFrame(screen), screen.Damage(), true, 0)
424	require.NoError(t, err)
425	require.True(t, ok)
426	resizeOutput := unmarshalTestOutput(t, resized.Payload)
427	require.Zero(t, resizeOutput.Base, "resize must emit the one reset frame")
428	screen.ClearDamage()
429
430	_, ok, err = drawOutputState(t, stream, captureTestFrame(screen), screen.Damage(), false, 0)
431	require.NoError(t, err)
432	require.False(t, ok, "an unchanged render after resize must emit nothing")
433
434	screen.Write([]byte("\x1b[1;1Hx"))
435	damaged, ok, err := drawOutputState(t, stream, captureTestFrame(screen), []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false, 0)
436	require.NoError(t, err)
437	require.True(t, ok)
438	damageOutput := unmarshalTestOutput(t, damaged.Payload)
439	require.Equal(t, resizeOutput.New, damageOutput.Base, "later damage must remain incremental")
440}
441
442func TestAttachmentOutputFailedSendKeepsTextCursorAndGraphicsSpeculative(t *testing.T) {
443	scene := graphics.NewScene(graphics.Limits{})
444	asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte("asset"), Width: 1, Height: 1})
445	require.NoError(t, err)
446	_, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: 1, Height: 1})
447	require.NoError(t, err)
448	state := &capturedRenderState{
449		route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}},
450		view:  attachmentView{tabID: "tab-1"},
451		panes: []capturedPaneRenderState{{
452			stableID: "pane-1", focused: true,
453			graphics:         scene.Snapshot(),
454			graphicsGeometry: domain.Geometry{Size: domain.Size{Cols: 1, Rows: 1}, PixelWidth: 1, PixelHeight: 1},
455			placement:        layout.Placement{Content: domain.Rect{Width: 1, Height: 1}},
456		}}}
457	graphicsState := newGraphicsOutputState()
458	output := attachmentOutputWithGraphics(graphicsState)
459	output.lastCursor = cursorOut{valid: true, row: 1, col: 1}
460	ac := &attachedClient{output: output, terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true}}
461	output.attachment = ac
462	frame := renderer.NewFrame(1, 1)
463	prepared, err := output.prepareFrame(nil, state, frame, []renderer.Damage{renderer.FullRedraw()}, true, cursorOut{row: 2, col: 3})
464	require.NoError(t, err)
465
466	sendErr := errors.New("send failed")
467	require.ErrorIs(t, prepared.send(0, outputFrameSender(func(wire.Envelope) error { return sendErr })), sendErr)
468
469	require.Equal(t, cursorOut{valid: true, row: 1, col: 1}, output.lastCursor)
470	require.Empty(t, graphicsState.assets)
471	require.Empty(t, graphicsState.placements)
472	require.NotEmpty(t, graphicsState.pendingImages)
473	require.NotEmpty(t, graphicsState.pendingPlaces)
474}
475
476func TestAttachmentOutputRebaseRetiresAttachmentState(t *testing.T) {
477	output := newOutputStateStream()
478	output.next = 3
479	output.acked = 2
480	output.lastCursor = cursorOut{valid: true, row: 2, col: 3}
481	output.lastRoutePosition = protocol.RoutePosition{ActiveTabID: "tab"}
482
483	output.rebaseAttachment()
484
485	require.Equal(t, uint64(2), output.epoch)
486	require.Zero(t, output.next)
487	require.Zero(t, output.acked)
488	require.True(t, output.forceSnapshot)
489	require.Equal(t, cursorOut{valid: true, row: 2, col: 3}, output.lastCursor, "the forced frame will reassert cursor state")
490	require.Equal(t, protocol.RoutePosition{}, output.lastRoutePosition)
491}
492
493func TestAttachmentOutputDefaultsAndNormalizesWindow(t *testing.T) {
494	for _, tt := range []struct {
495		name   string
496		window []uint8
497		want   uint64
498	}{
499		{name: "omitted", want: 8},
500		{name: "zero", window: []uint8{0}, want: 8},
501		{name: "oversized", window: []uint8{9}, want: 8},
502		{name: "exact window", window: []uint8{1}, want: 1},
503	} {
504		t.Run(tt.name, func(t *testing.T) {
505			require.Equal(t, tt.want, newOutputStateStream(tt.window...).maxOutstanding)
506		})
507	}
508
509	output := newOutputStateStream()
510	output.setWindow(2)
511	require.Equal(t, uint64(2), output.maxOutstanding)
512	require.Equal(t, uint64(2), output.maxOutstandingAtomic.Load())
513}
514
515func TestNormalizeOutputWindow(t *testing.T) {
516	for _, tt := range []struct {
517		input, want uint8
518	}{{0, 8}, {1, 1}, {8, 8}, {9, 8}, {255, 8}} {
519		require.Equal(t, tt.want, normalizeOutputWindow(tt.input))
520	}
521}

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