v0.3.0 99dc266

palette_attach_generation_test.go RawHistoryFolder

1package client
2
3import (
4	"context"
5	"errors"
6	"io"
7	"log/slog"
8	"strings"
9	"sync"
10	"sync/atomic"
11	"testing"
12	"time"
13
14	"github.com/stretchr/testify/require"
15
16	renderer "github.com/bnema/vev-vt"
17	"github.com/bnema/vev/internal/domain"
18	"github.com/bnema/vev/internal/ports"
19)
20
21// attachPaletteClock deliberately exposes each independently-created timer.
22// Tests fire only the deadline they intend to exercise; no wall clock is used.
23type attachPaletteClock struct {
24	timers          chan *attachPaletteTimer
25	handshakeTimers chan *attachPaletteTimer
26}
27
28type attachPaletteTimer struct {
29	ch       chan time.Time
30	stopped  chan struct{}
31	stopOnce sync.Once
32	duration time.Duration
33}
34
35func newAttachPaletteClock() *attachPaletteClock {
36	return &attachPaletteClock{
37		timers:          make(chan *attachPaletteTimer, 16),
38		handshakeTimers: make(chan *attachPaletteTimer, 1),
39	}
40}
41
42func (*attachPaletteClock) Now() time.Time { return time.Time{} }
43func (c *attachPaletteClock) NewTimer(delay time.Duration) ports.Timer {
44	timer := &attachPaletteTimer{ch: make(chan time.Time, 1), stopped: make(chan struct{}), duration: delay}
45	if delay == ports.HandshakeTimeout {
46		select {
47		case c.handshakeTimers <- timer:
48		default:
49			panic("unexpected extra handshake timer")
50		}
51	} else {
52		c.timers <- timer
53	}
54	return timer
55}
56func (t *attachPaletteTimer) C() <-chan time.Time    { return t.ch }
57func (*attachPaletteTimer) Reset(time.Duration) bool { return false }
58func (t *attachPaletteTimer) Stop() bool {
59	t.stopOnce.Do(func() { close(t.stopped) })
60	return true
61}
62func (t *attachPaletteTimer) fire() { t.ch <- time.Time{} }
63
64type attachPaletteReader struct{ chunks chan []byte }
65
66func newAttachPaletteReader() *attachPaletteReader {
67	return &attachPaletteReader{chunks: make(chan []byte, 16)}
68}
69func (r *attachPaletteReader) Read(p []byte) (int, error) {
70	data := <-r.chunks
71	return copy(p, data), nil
72}
73func (r *attachPaletteReader) send(data string) { r.chunks <- []byte(data) }
74
75type attachPaletteWriter struct {
76	mu     sync.Mutex
77	writes []string
78	wrote  chan string
79	err    error
80}
81
82func newAttachPaletteWriter() *attachPaletteWriter {
83	return &attachPaletteWriter{wrote: make(chan string, 16)}
84}
85func (w *attachPaletteWriter) Write(data []byte) (int, error) {
86	copyData := string(data)
87	w.mu.Lock()
88	w.writes = append(w.writes, copyData)
89	err := w.err
90	w.mu.Unlock()
91	if err != nil {
92		return 0, err
93	}
94	w.wrote <- copyData
95	return len(data), nil
96}
97
98type attachPaletteTerminalHarness struct {
99	in     io.Reader
100	out    *attachPaletteWriter
101	resize chan domain.Size
102}
103
104func (t *attachPaletteTerminalHarness) EnterRaw() (func() error, error) {
105	return func() error { return nil }, nil
106}
107func (*attachPaletteTerminalHarness) Size() (domain.Size, error) {
108	return domain.Size{Cols: 80, Rows: 24}, nil
109}
110func (t *attachPaletteTerminalHarness) ResizeEvents() <-chan domain.Size { return t.resize }
111func (t *attachPaletteTerminalHarness) In() io.Reader                    { return t.in }
112func (t *attachPaletteTerminalHarness) Out() io.Writer                   { return t.out }
113func (*attachPaletteTerminalHarness) Flush() error                       { return nil }
114
115type attachPaletteTransport struct {
116	mu           sync.Mutex
117	frames       []ports.Frame
118	themes       []ports.Theme
119	themeCh      chan ports.Theme
120	inputCh      chan ports.Input
121	detached     chan ports.Frame
122	themeSendErr error
123	welcomed     bool
124}
125
126func newAttachPaletteTransport() *attachPaletteTransport {
127	return &attachPaletteTransport{
128		themeCh:  make(chan ports.Theme, 16),
129		inputCh:  make(chan ports.Input, 16),
130		detached: make(chan ports.Frame, 1),
131	}
132}
133func (t *attachPaletteTransport) Send(frame ports.Frame) error {
134	t.mu.Lock()
135	t.frames = append(t.frames, frame)
136	themeSendErr := t.themeSendErr
137	t.mu.Unlock()
138	switch frame.Type {
139	case ports.MsgTheme:
140		if themeSendErr != nil {
141			return themeSendErr
142		}
143		got, err := ports.UnmarshalTheme(frame.Payload)
144		if err != nil {
145			return err
146		}
147		t.mu.Lock()
148		t.themes = append(t.themes, got)
149		t.mu.Unlock()
150		t.themeCh <- got
151	case ports.MsgInput:
152		got, err := ports.UnmarshalInput(frame.Payload)
153		if err != nil {
154			return err
155		}
156		t.inputCh <- got
157	}
158	return nil
159}
160func (t *attachPaletteTransport) Recv() (ports.Frame, error) {
161	t.mu.Lock()
162	if !t.welcomed {
163		t.welcomed = true
164		t.mu.Unlock()
165		return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s"})}, nil
166	}
167	t.mu.Unlock()
168	return <-t.detached, nil
169}
170func (*attachPaletteTransport) Close() error { return nil }
171func (t *attachPaletteTransport) snapshots() []ports.Theme {
172	t.mu.Lock()
173	defer t.mu.Unlock()
174	return append([]ports.Theme(nil), t.themes...)
175}
176
177type attachPaletteHarness struct {
178	clock          *attachPaletteClock
179	handshakeTimer *attachPaletteTimer
180	reader         *attachPaletteReader
181	writer         *attachPaletteWriter
182	transport      *attachPaletteTransport
183	done           chan attachResult
184}
185
186func startAttachPaletteHarness(state *terminalThemeState) *attachPaletteHarness {
187	return startAttachPaletteHarnessWithTransport(state, newAttachPaletteTransport())
188}
189
190func startAttachPaletteHarnessWithTransport(state *terminalThemeState, transport *attachPaletteTransport) *attachPaletteHarness {
191	return startAttachPaletteHarnessWithInput(state, transport, nil)
192}
193
194func startAttachPaletteHarnessWithInput(state *terminalThemeState, transport *attachPaletteTransport, input *terminalInputPump) *attachPaletteHarness {
195	return startAttachPaletteHarnessWithInputAndWriteError(state, transport, input, nil)
196}
197
198func startAttachPaletteHarnessWithInputAndWriteError(state *terminalThemeState, transport *attachPaletteTransport, input *terminalInputPump, writeErr error) *attachPaletteHarness {
199	clock := newAttachPaletteClock()
200	reader := newAttachPaletteReader()
201	writer := newAttachPaletteWriter()
202	writer.err = writeErr
203	term := &attachPaletteTerminalHarness{in: reader, out: writer, resize: make(chan domain.Size)}
204	ms := &milestones{}
205	runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler)}
206	attempt := &attachAttempt{
207		runner: runner, transport: transport, request: AttachRequest{Intent: ports.IntentAttach},
208		milestones: ms, themeState: state,
209		enterRaw:  func() error { ms.rawEntered = true; return nil },
210		reconnect: &reconnectUI{term: term, rawEntered: new(bool)},
211		terminalInput: func() *terminalInputPump {
212			return input
213		},
214	}
215	h := &attachPaletteHarness{
216		clock:     clock,
217		reader:    reader,
218		writer:    writer,
219		transport: transport,
220		done:      make(chan attachResult, 1),
221	}
222	go func() { h.done <- attempt.run(context.Background()) }()
223	h.handshakeTimer = <-clock.handshakeTimers
224	return h
225}
226
227func (h *attachPaletteHarness) nextWrite(t *testing.T) string {
228	t.Helper()
229	return <-h.writer.wrote
230}
231func (h *attachPaletteHarness) nextTheme(t *testing.T) ports.Theme {
232	t.Helper()
233	return <-h.transport.themeCh
234}
235func (h *attachPaletteHarness) nextTimer(t *testing.T) *attachPaletteTimer {
236	t.Helper()
237	return <-h.clock.timers
238}
239func (h *attachPaletteHarness) detach(t *testing.T) {
240	t.Helper()
241	h.transport.detached <- ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})}
242	result := <-h.done
243	require.NoError(t, result.err)
244	select {
245	case <-h.handshakeTimer.stopped:
246	default:
247		t.Fatal("handshake timer was not canceled after initial publication")
248	}
249}
250
251func paletteReply(foreground, background string, slot uint8, color string) string {
252	return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y"
253}
254
255func TestAttachHandshakeDeadlineStopsBeforeRuntimeWithoutInitialOutput(t *testing.T) {
256	h := startAttachPaletteHarness(&terminalThemeState{})
257	select {
258	case <-h.handshakeTimer.stopped:
259		// Welcome and synchronous client publications commit the handshake. A
260		// quiet runtime is allowed to produce no Output frame.
261	case <-time.After(2 * time.Second):
262		t.Fatal("handshake deadline still owned the long-lived runtime transport")
263	}
264	h.detach(t)
265}
266
267func TestAttachReconnectClearsRetainedPaletteInOnePublication(t *testing.T) {
268	t.Setenv("COLORTERM", "truecolor")
269	state := &terminalThemeState{}
270	first := startAttachPaletteHarness(state)
271	require.Equal(t, paletteColorBatch, first.nextWrite(t))
272	first.reader.send(paletteReply("#010203", "#040506", 2, "#102030"))
273	_ = first.nextTheme(t) // initial cleared snapshot
274	final := first.nextTheme(t)
275	require.True(t, final.HasForeground)
276	first.detach(t)
277
278	reconnected := startAttachPaletteHarness(state)
279	require.Equal(t, paletteColorBatch, reconnected.nextWrite(t))
280	cleared := reconnected.nextTheme(t)
281	require.True(t, cleared.HasForeground)
282	require.Equal(t, final.Foreground, cleared.Foreground)
283	require.True(t, cleared.HasBackground)
284	require.Equal(t, final.Background, cleared.Background)
285	require.Zero(t, cleared.PaletteKnown)
286	require.Equal(t, [16]renderer.RGB{}, cleared.Palette)
287
288	reconnected.reader.send(paletteReply("#111213", "#141516", 2, "#202122"))
289	_ = reconnected.nextTheme(t)
290	reconnected.detach(t)
291	require.Len(t, reconnected.transport.snapshots(), 2, "reconnect must not send a restore Theme before its cleared publication")
292}
293
294func TestAttachReconnectPreservesStandaloneEscapeBeforeClaimRevocation(t *testing.T) {
295	t.Setenv("COLORTERM", "truecolor")
296	reader := newAttachPaletteReader()
297	input := newTerminalInputPump(reader)
298	input.start()
299	t.Cleanup(input.stop)
300
301	first := startAttachPaletteHarnessWithInput(&terminalThemeState{}, newAttachPaletteTransport(), input)
302	require.Equal(t, paletteColorBatch, first.nextWrite(t))
303	_ = first.nextTheme(t)
304	_ = first.nextTimer(t) // initial palette completion deadline
305
306	// A standalone Escape is withheld as a possible DECRQM marker prefix. Its
307	// raw read is committed, leaving only the scanner-local residual to hand
308	// over when this attach is replaced.
309	reader.send("\x1b")
310	_ = first.nextTimer(t) // standalone Escape ambiguity deadline
311
312	revoked := make(chan []byte, 1)
313	allowRevocationToReturn := make(chan struct{})
314	var releaseRevocation sync.Once
315	release := func() { releaseRevocation.Do(func() { close(allowRevocationToReturn) }) }
316	defer release()
317	input.afterRevoke = func() {
318		input.mu.Lock()
319		residual := append([]byte(nil), input.residual...)
320		input.mu.Unlock()
321		revoked <- residual
322		<-allowRevocationToReturn
323	}
324
325	// Detach starts reconnect teardown. The residual must already be owned by
326	// the lifecycle reader at the precise point the old claim is revoked.
327	first.transport.detached <- ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})}
328	require.Equal(t, []byte("\x1b"), <-revoked)
329	release()
330	require.NoError(t, (<-first.done).err)
331	input.afterRevoke = nil
332
333	// The replacement attach receives the retained byte exactly once.
334	second := startAttachPaletteHarnessWithInput(&terminalThemeState{}, newAttachPaletteTransport(), input)
335	require.Equal(t, paletteColorBatch, second.nextWrite(t))
336	_ = second.nextTheme(t)
337	_ = second.nextTimer(t) // initial palette completion deadline
338	second.nextTimer(t).fire()
339	second.nextTimer(t).fire()
340	got := <-second.transport.inputCh
341	require.Equal(t, []byte("\x1b"), got.Data)
342	select {
343	case duplicate := <-second.transport.inputCh:
344		require.Failf(t, "duplicate input", "unexpected duplicate %q", duplicate.Data)
345	default:
346	}
347	second.detach(t)
348}
349
350func TestAttachSchemeReplacementAndDeadlinesProgressWhileReadBlocks(t *testing.T) {
351	t.Setenv("COLORTERM", "truecolor")
352	h := startAttachPaletteHarness(&terminalThemeState{})
353	require.Equal(t, paletteColorBatch, h.nextWrite(t))
354	initialDeadline := h.nextTimer(t)
355	h.reader.send(paletteReply("#010203", "#040506", 2, "#102030"))
356	_ = h.nextTheme(t)
357	_ = h.nextTheme(t)
358	// The initial completion timer was cancelled by the marker; it is distinct
359	// from both replacement deadlines.
360	_ = initialDeadline
361
362	h.reader.send("\x1b[?997;2n")
363	cleared := h.nextTheme(t)
364	require.True(t, cleared.SchemeKnown)
365	require.True(t, cleared.Light)
366	require.Zero(t, cleared.PaletteKnown)
367	require.Equal(t, paletteBoundaryQuery, h.nextWrite(t))
368	drainDeadline := h.nextTimer(t)
369
370	// stdin is now blocked in Read. Firing the independently-created drain
371	// deadline still starts the color batch, and its completion deadline still
372	// finalizes the partial generation.
373	drainDeadline.fire()
374	require.Equal(t, paletteColorBatch, h.nextWrite(t))
375	completionDeadline := h.nextTimer(t)
376	completionDeadline.fire()
377	final := h.nextTheme(t)
378	require.True(t, final.SchemeKnown)
379	require.True(t, final.Light)
380	require.False(t, final.HasForeground)
381	require.False(t, final.HasBackground)
382	h.detach(t)
383	require.Len(t, h.transport.snapshots(), 4, "initial and replacement generations each publish only cleared then definitive Theme snapshots")
384}
385
386func TestAttachInitialPaletteFailureRevokesInputClaim(t *testing.T) {
387	t.Setenv("COLORTERM", "truecolor")
388	for _, tc := range []struct {
389		name     string
390		start    func(*terminalInputPump) *attachPaletteHarness
391		contains string
392	}{
393		{
394			name: "publish",
395			start: func(input *terminalInputPump) *attachPaletteHarness {
396				want := errors.New("initial theme send failed")
397				transport := newAttachPaletteTransport()
398				transport.themeSendErr = want
399				return startAttachPaletteHarnessWithInput(&terminalThemeState{}, transport, input)
400			},
401			contains: "publishing theme",
402		},
403		{
404			name: "write",
405			start: func(input *terminalInputPump) *attachPaletteHarness {
406				return startAttachPaletteHarnessWithInputAndWriteError(&terminalThemeState{}, newAttachPaletteTransport(), input, errors.New("palette write failed"))
407			},
408			contains: "writing palette query",
409		},
410	} {
411		t.Run(tc.name, func(t *testing.T) {
412			input := newTerminalInputPump(nil)
413			failed := tc.start(input)
414			err := (<-failed.done).err
415			require.ErrorContains(t, err, tc.contains)
416
417			// The next attach must be able to claim the lifecycle-owned reader.
418			// Before revocation was registered at claim time this panicked above.
419			reconnected := startAttachPaletteHarnessWithInput(&terminalThemeState{}, newAttachPaletteTransport(), input)
420			require.Equal(t, paletteColorBatch, reconnected.nextWrite(t))
421			reconnected.detach(t)
422		})
423	}
424}
425
426func TestStdinPumpSameReadSchemeAndOldMarkerCannotAdvanceReplacement(t *testing.T) {
427	ctx, cancel := context.WithCancel(context.Background())
428	defer cancel()
429
430	coordinator := newPaletteGenerationCoordinator()
431	coordinator.start(ports.Theme{}, false)
432	var activeGeneration atomic.Uint64
433	activeGeneration.Store(uint64(coordinator.current.id))
434
435	// An unbuffered event channel forces the attach loop to process the scheme
436	// notification and publish its replacement generation before the scanner
437	// can deliver the old marker from the same terminal Read.
438	events := make(chan paletteGenerationEvent)
439	out := make(chan ports.Frame, 1)
440	schemeHandled := make(chan struct{})
441	pump := &stdinPump{
442		ctx:              ctx,
443		cancel:           cancel,
444		in:               strings.NewReader("\x1b[?997;2n\x1b[?2031;1$y"),
445		out:              out,
446		clock:            newAttachPaletteClock(),
447		logger:           slog.New(slog.DiscardHandler),
448		paletteEvents:    events,
449		activeGeneration: &activeGeneration,
450		afterPaletteEvent: func(event paletteGenerationEvent) {
451			if event.kind == paletteEventScheme {
452				<-schemeHandled
453			}
454		},
455	}
456	pumpDone := make(chan struct{})
457	go func() {
458		pump.run()
459		close(pumpDone)
460	}()
461
462	scheme := <-events
463	require.Equal(t, paletteEventScheme, scheme.kind)
464	oldID := scheme.id
465
466	// This models attachAttempt handling the scheme event. The replacement is
467	// draining, so a marker tagged with its ID would incorrectly start its
468	// color batch before the real drain boundary arrives.
469	coordinator.start(ports.Theme{SchemeKnown: true, Light: scheme.light}, true)
470	newID := coordinator.current.id
471	activeGeneration.Store(uint64(newID))
472	close(schemeHandled)
473	require.NotEqual(t, oldID, newID)
474	require.Equal(t, generationDraining, coordinator.current.phase)
475
476	oldMarker := <-events
477	require.Equal(t, paletteEventMarker, oldMarker.kind)
478	require.Equal(t, oldID, oldMarker.id, "one read must retain its generation tag")
479	require.Empty(t, coordinator.handle(oldMarker))
480	require.True(t, coordinator.active)
481	require.Equal(t, generationDraining, coordinator.current.phase)
482	require.Equal(t, newID, coordinator.current.id)
483
484	<-pumpDone
485}
486
487func TestAttachLateDrainCannotFinalizeReplacementEarly(t *testing.T) {
488	t.Setenv("COLORTERM", "truecolor")
489	h := startAttachPaletteHarness(&terminalThemeState{})
490	require.Equal(t, paletteColorBatch, h.nextWrite(t))
491	_ = h.nextTimer(t)
492	h.reader.send(paletteReply("#010203", "#040506", 2, "#102030"))
493	_ = h.nextTheme(t)
494	_ = h.nextTheme(t)
495
496	h.reader.send("\x1b[?997;1n")
497	_ = h.nextTheme(t)
498	require.Equal(t, paletteBoundaryQuery, h.nextWrite(t))
499	drainDeadline := h.nextTimer(t)
500	drainDeadline.fire()
501	require.Equal(t, paletteColorBatch, h.nextWrite(t))
502	completionDeadline := h.nextTimer(t)
503
504	// The drain's stale reports arrive before its late boundary and must not
505	// contaminate the replacement accumulator. Reports after that boundary are
506	// the batch's reports and are collected through the second marker.
507	h.reader.send("\x1b]10;#a1a2a3\a\x1b]11;#a4a5a6\a\x1b]4;2;#a7a8a9\a\x1b[?2031;1$y\x1b]10;#111213\a\x1b]11;#141516\a\x1b]4;3;#212223\a\x1b[?2031;1$y")
508	final := h.nextTheme(t)
509	require.Equal(t, renderer.RGB{R: 17, G: 18, B: 19}, final.Foreground)
510	require.Equal(t, renderer.RGB{R: 20, G: 21, B: 22}, final.Background)
511	require.Equal(t, uint16(1<<3), final.PaletteKnown)
512	// The marker finalization cancels, rather than fires, the independently
513	// armed completion deadline.
514	_ = completionDeadline
515	h.detach(t)
516}
517
518func TestAttachForwardsStandaloneEscapeAfterAmbiguityDeadline(t *testing.T) {
519	t.Setenv("COLORTERM", "truecolor")
520	h := startAttachPaletteHarness(&terminalThemeState{})
521	require.Equal(t, paletteColorBatch, h.nextWrite(t))
522	_ = h.nextTimer(t) // initial palette completion deadline
523	_ = h.nextTheme(t) // initial cleared publication
524
525	// A bare Escape is ambiguous with the prefix of the DECRQM completion
526	// response. It must not require another Read or EOF before reaching the
527	// daemon.
528	h.reader.send("\x1b")
529	h.nextTimer(t).fire() // DECRQM ambiguity deadline
530	h.nextTimer(t).fire() // bracketed-paste ambiguity deadline
531	input := <-h.transport.inputCh
532	require.Equal(t, []byte("\x1b"), input.Data)
533	h.detach(t)
534}
535
536func TestAttachConsumesSplitMarkerAndCancelsAmbiguityDeadline(t *testing.T) {
537	t.Setenv("COLORTERM", "truecolor")
538	h := startAttachPaletteHarness(&terminalThemeState{})
539	require.Equal(t, paletteColorBatch, h.nextWrite(t))
540	_ = h.nextTimer(t) // initial palette completion deadline
541	_ = h.nextTheme(t) // initial cleared publication
542
543	h.reader.send("before\x1b[?2031;")
544	markerDeadline := h.nextTimer(t)
545	h.reader.send("1$yafter")
546
547	final := h.nextTheme(t)
548	require.False(t, final.HasForeground)
549	var got []byte
550	for len(got) < len("beforeafter") {
551		got = append(got, (<-h.transport.inputCh).Data...)
552	}
553	require.Equal(t, []byte("beforeafter"), got)
554	<-markerDeadline.stopped
555	h.detach(t)
556}
557
558func TestAttachForwardsInputOnceAndPartialOSCDoesNotPublishTheme(t *testing.T) {
559	t.Setenv("COLORTERM", "truecolor")
560	h := startAttachPaletteHarness(&terminalThemeState{})
561	require.Equal(t, paletteColorBatch, h.nextWrite(t))
562	_ = h.nextTimer(t)
563	cleared := h.nextTheme(t)
564	require.Zero(t, cleared.PaletteKnown)
565
566	h.reader.send("\x1b]10;#0102")
567	h.reader.send("03\az")
568	input := <-h.transport.inputCh
569	require.Equal(t, []byte("z"), input.Data)
570	require.Len(t, h.transport.snapshots(), 1, "partial and incomplete OSC replies must not publish Theme")
571
572	h.reader.send("\x1b[?2031;1$y")
573	final := h.nextTheme(t)
574	require.True(t, final.HasForeground)
575	require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, final.Foreground)
576	require.Len(t, h.transport.snapshots(), 2)
577	h.detach(t)
578}

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