cmd_test.go RawHistoryFolder

1package app
2
3import (
4	"context"
5	"errors"
6	"io"
7	"math"
8	"reflect"
9	"strconv"
10	"strings"
11	"testing"
12	"time"
13
14	"github.com/bnema/vev/internal/adapters/sessionwire"
15	"github.com/bnema/vev/internal/ports"
16	portsmocks "github.com/bnema/vev/internal/ports/mocks"
17	"github.com/bnema/vev/internal/protocol"
18	"github.com/bnema/vev/internal/protocol/wire"
19	"github.com/bnema/vev/internal/usecase/daemon"
20	"github.com/stretchr/testify/mock"
21	"github.com/stretchr/testify/require"
22)
23
24// mustServerEnvelope serializes a semantic server message into the serialized
25// protobuf envelope exchanged by wire.Transport.
26func mustServerEnvelope(message protocol.ServerMessage) wire.Envelope {
27	raw, err := sessionwire.EncodeServerMessage(message)
28	if err != nil {
29		panic(err)
30	}
31	return wire.Envelope{Payload: raw}
32}
33
34// mustClientEnvelope serializes a semantic client message into the serialized
35// protobuf envelope exchanged by wire.Transport.
36func mustClientEnvelope(message protocol.ClientMessage) wire.Envelope {
37	raw, err := sessionwire.EncodeClientMessage(message)
38	if err != nil {
39		panic(err)
40	}
41	return wire.Envelope{Payload: raw}
42}
43
44// mustPreambleResponse builds the canned server preamble acceptance the
45// typed client connection expects before application traffic.
46func mustPreambleResponse() wire.Envelope {
47	raw, err := sessionwire.EncodePreambleResponseForTest()
48	if err != nil {
49		panic(err)
50	}
51	return wire.Envelope{Payload: raw}
52}
53
54// decodeClientEnvelope unwraps a sent envelope into its semantic client
55// message, failing the test on a malformed payload.
56func decodeClientEnvelope(t *testing.T, envelope wire.Envelope) protocol.ClientMessage {
57	t.Helper()
58	message, err := sessionwire.DecodeClientEnvelope(envelope.Payload)
59	require.NoError(t, err)
60	return message
61}
62
63func TestParseCmdArgs(t *testing.T) {
64	tests := []struct {
65		name    string
66		args    []string
67		want    cmdInvocation
68		wantErr bool
69	}{
70		{"bare cmd is help", []string{"cmd"}, cmdInvocation{help: true}, false},
71		{"cmd help", []string{"cmd", "--help"}, cmdInvocation{help: true}, false},
72		{"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false},
73		{"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false},
74		{"resize one-shot", []string{"cmd", "grow-pane-width"}, cmdInvocation{slug: "grow-pane-width"}, false},
75		{"consume or expel pane left", []string{"cmd", "consume-or-expel-pane-left"}, cmdInvocation{slug: "consume-or-expel-pane-left"}, false},
76		{"consume or expel pane right", []string{"cmd", "consume-or-expel-pane-right"}, cmdInvocation{slug: "consume-or-expel-pane-right"}, false},
77		{"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true},
78		{"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false},
79		{"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false},
80		{"self flag", []string{"cmd", "--self", "split-right"}, cmdInvocation{slug: "split-right", self: true}, false},
81		{"self and session conflict", []string{"cmd", "-s", "other", "--self", "split-right"}, cmdInvocation{}, true},
82		{"passthrough args", []string{"cmd", "toast", "-l", "warn", "hello"}, cmdInvocation{slug: "toast", args: []string{"-l", "warn", "hello"}}, false},
83		{"unknown slug", []string{"cmd", "frobnicate"}, cmdInvocation{}, true},
84		{"non-scriptable slug", []string{"cmd", "detach"}, cmdInvocation{}, true},
85		{"session missing value", []string{"cmd", "-s"}, cmdInvocation{}, true},
86		{"unknown leading flag", []string{"cmd", "--wat"}, cmdInvocation{}, true},
87	}
88	for _, tt := range tests {
89		t.Run(tt.name, func(t *testing.T) {
90			got, err := parseArgs(tt.args)
91			if tt.wantErr {
92				var usage *usageError
93				if !errors.As(err, &usage) {
94					t.Fatalf("parseArgs(%q) error = %v, want usage error", tt.args, err)
95				}
96				return
97			}
98			if err != nil {
99				t.Fatalf("parseArgs(%q): %v", tt.args, err)
100			}
101			if got.kind != kindCmd || !reflect.DeepEqual(got.cmd, tt.want) {
102				t.Fatalf("parseArgs(%q) = kind %v, cmd %#v, want %#v", tt.args, got.kind, got.cmd, tt.want)
103			}
104		})
105	}
106}
107
108func TestExitCodes(t *testing.T) {
109	tests := []struct {
110		name string
111		err  error
112		want int
113	}{
114		{"success", nil, 0},
115		{"command", errors.New("command failed"), 1},
116		{"usage", &usageError{msg: "bad args"}, 2},
117		{"unreachable", &exitCoded{code: 3, err: errDaemonUnreachable}, 3},
118	}
119	for _, tt := range tests {
120		t.Run(tt.name, func(t *testing.T) {
121			if got := ExitCode(tt.err); got != tt.want {
122				t.Fatalf("ExitCode(%v) = %d, want %d", tt.err, got, tt.want)
123			}
124		})
125	}
126}
127
128func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
129	called := false
130	out := new(strings.Builder)
131	err := runCmdWithDeps(context.Background(), cmdInvocation{help: true}, cmdDeps{
132		stdout: out,
133		getenv: func(string) string { return "" },
134		dial: func(context.Context, string) (wire.Transport, error) {
135			called = true
136			return nil, errors.New("must not dial")
137		},
138	})
139	if err != nil {
140		t.Fatalf("runCmdWithDeps(help): %v", err)
141	}
142	if called {
143		t.Fatal("help dialed daemon")
144	}
145	for _, want := range []string{"usage: vev cmd", "split-right", "consume-or-expel-pane-left", "consume-or-expel-pane-right", "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes", "list-panes"} {
146		if !strings.Contains(out.String(), want) {
147			t.Fatalf("help %q missing %q", out.String(), want)
148		}
149	}
150	for _, hidden := range []string{"session-picker", "resize-pane"} {
151		if strings.Contains(out.String(), hidden) {
152			t.Fatalf("help leaked non-scriptable command %q: %q", hidden, out.String())
153		}
154	}
155}
156
157func TestMoveCommandHelpUsesExactUsages(t *testing.T) {
158	for _, tt := range []struct {
159		slug, usage, desc string
160	}{
161		{"move-pane", "move-pane <destination-session> <destination-tab-id>", "Move focused pane to tab"},
162		{"move-tab", "move-tab <destination-session>", "Move active tab to session"},
163	} {
164		t.Run(tt.slug, func(t *testing.T) {
165			got := cmdHelp(cmdInvocation{slug: tt.slug})
166			want := "usage: vev cmd [-s <session>] [--self] " + tt.usage + "\n\n" + tt.desc
167			if got != want {
168				t.Fatalf("cmdHelp() = %q, want %q", got, want)
169			}
170		})
171	}
172}
173
174func TestMoveCmdPreservesPositionalArguments(t *testing.T) {
175	for _, tt := range []struct {
176		slug string
177		args []string
178	}{
179		{slug: "move-pane", args: []string{"work", "t_dest"}},
180		{slug: "move-tab", args: []string{"work"}},
181	} {
182		t.Run(tt.slug, func(t *testing.T) {
183			parsed, err := parseArgs(append([]string{"cmd", tt.slug}, tt.args...))
184			require.NoError(t, err)
185			transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
186			require.NoError(t, runCmdWithDeps(context.Background(), parsed.cmd, cmdDeps{
187				stdout: io.Discard,
188				getenv: func(string) string { return "" },
189				dial:   func(context.Context, string) (wire.Transport, error) { return transport, nil },
190			}))
191			require.Len(t, transport.sent, 2)
192			message, err := sessionwire.DecodeClientEnvelope(transport.sent[1].Payload)
193			require.NoError(t, err)
194			request := message.(protocol.CommandRequest)
195			require.Equal(t, tt.slug, request.Slug)
196			require.Equal(t, tt.args, request.Args)
197			require.Equal(t, protocol.Version, request.Version)
198		})
199	}
200}
201
202func TestRemoteCatalogCommandUsesBroker(t *testing.T) {
203	stream := newSeamBrokerStream(protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded})
204	service := newSeamBrokerService(stream)
205	err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{
206		stdout:  io.Discard,
207		getenv:  func(string) string { return "" },
208		connect: func(context.Context) (ports.BrokerService, error) { return service, nil },
209		dial: func(context.Context, string) (wire.Transport, error) {
210			t.Fatal("remote catalog bypassed the broker")
211			return nil, errors.New("unexpected dial")
212		},
213	})
214	require.NoError(t, err)
215	require.Len(t, service.openedRequests(), 1)
216	require.Equal(t, ports.BrokerStreamControl, service.openedRequests()[0].Purpose)
217}
218
219func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {
220	for _, invocation := range []cmdInvocation{
221		{slug: "move-pane"},
222		{slug: "move-pane", args: []string{"work", "t_dest", "extra"}},
223		{slug: "move-tab"},
224		{slug: "move-tab", args: []string{"work", "extra"}},
225	} {
226		t.Run(invocation.slug+"/"+strconv.Itoa(len(invocation.args)), func(t *testing.T) {
227			transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{
228				Outcome: protocol.CommandFailed, Code: protocol.ErrInvalidCommandArgs, Text: "invalid command arguments",
229			})}
230			err := runCmdWithDeps(context.Background(), invocation, cmdDeps{
231				stdout: io.Discard,
232				getenv: func(string) string { return "" },
233				dial:   func(context.Context, string) (wire.Transport, error) { return transport, nil },
234			})
235			if ExitCode(err) != 2 {
236				t.Fatalf("ExitCode(%v) = %d, want 2", err, ExitCode(err))
237			}
238		})
239	}
240}
241
242func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) {
243	for _, slug := range []string{
244		"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes",
245		"consume-or-expel-pane-left", "consume-or-expel-pane-right",
246	} {
247		t.Run(slug, func(t *testing.T) {
248			invocation, err := parseArgs([]string{"cmd", "--self", slug})
249			require.NoError(t, err)
250			transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
251			require.NoError(t, runCmdWithDeps(context.Background(), invocation.cmd, cmdDeps{
252				stdout: io.Discard,
253				getenv: func(string) string { return "session=work,tab=t_abc,pane=p_def" },
254				dial:   func(context.Context, string) (wire.Transport, error) { return transport, nil },
255			}))
256			require.Len(t, transport.sent, 2)
257			message, err := sessionwire.DecodeClientEnvelope(transport.sent[1].Payload)
258			require.NoError(t, err)
259			request := message.(protocol.CommandRequest)
260			require.Equal(t, slug, request.Slug)
261			require.True(t, request.Self)
262			require.Equal(t, "work", request.TargetSession)
263			require.Equal(t, "t_abc", request.TargetTab)
264			require.Equal(t, "p_def", request.TargetPane)
265		})
266	}
267}
268
269func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) {
270	transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded, Output: "done"})}
271	out := new(strings.Builder)
272	err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right", self: true}, cmdDeps{
273		stdout: out,
274		getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
275		dial:   func(context.Context, string) (wire.Transport, error) { return transport, nil },
276	})
277	if err != nil {
278		t.Fatalf("runCmdWithDeps: %v", err)
279	}
280	if len(transport.sent) != 2 || transport.recvCalls != 2 || transport.closeCalls != 1 {
281		t.Fatalf("transport calls: sent=%d recv=%d close=%d", len(transport.sent), transport.recvCalls, transport.closeCalls)
282	}
283	message, err := sessionwire.DecodeClientEnvelope(transport.sent[1].Payload)
284	if err != nil {
285		t.Fatalf("decode request: %v", err)
286	}
287	req, ok := message.(protocol.CommandRequest)
288	if !ok || req.Slug != "split-right" || !req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" {
289		t.Fatalf("request = %+v", req)
290	}
291	if out.String() != "done\n" {
292		t.Fatalf("output = %q", out.String())
293	}
294}
295
296func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) {
297	transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
298	err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
299		stdout: io.Discard,
300		getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
301		dial:   func(context.Context, string) (wire.Transport, error) { return transport, nil },
302	})
303	if err != nil {
304		t.Fatalf("runCmdWithDeps: %v", err)
305	}
306	message, err := sessionwire.DecodeClientEnvelope(transport.sent[1].Payload)
307	if err != nil {
308		t.Fatalf("decode request: %v", err)
309	}
310	req := message.(protocol.CommandRequest)
311	if req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" {
312		t.Fatalf("VEV locator request = %+v", req)
313	}
314}
315
316func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) {
317	transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
318	err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{
319		stdout: io.Discard,
320		getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
321		dial:   func(context.Context, string) (wire.Transport, error) { return transport, nil },
322	})
323	if err != nil {
324		t.Fatalf("runCmdWithDeps: %v", err)
325	}
326	message, err := sessionwire.DecodeClientEnvelope(transport.sent[1].Payload)
327	if err != nil {
328		t.Fatalf("decode request: %v", err)
329	}
330	req := message.(protocol.CommandRequest)
331	if req.TargetSession != "current" || req.TargetTab != "" || req.TargetPane != "" {
332		t.Fatalf("explicit targeting request = %+v", req)
333	}
334}
335
336func TestRunCmdRejectsTooManyArgumentsBeforeDialing(t *testing.T) {
337	dialed := false
338	err := runCmdWithDeps(context.Background(), cmdInvocation{
339		slug: "toast",
340		args: make([]string, math.MaxUint16+1),
341	}, cmdDeps{
342		stdout: io.Discard,
343		getenv: func(string) string { return "" },
344		dial: func(context.Context, string) (wire.Transport, error) {
345			dialed = true
346			return nil, errors.New("must not dial")
347		},
348	})
349
350	if ExitCode(err) != 2 {
351		t.Fatalf("error=%v code=%d, want usage error/2", err, ExitCode(err))
352	}
353	if dialed {
354		t.Fatal("oversized command dialed daemon")
355	}
356}
357
358func TestRunCmdTimesOutPendingRequest(t *testing.T) {
359	timerCh := make(chan time.Time, 1)
360	timer := portsmocks.NewMockTimer(t)
361	timer.EXPECT().C().Return(timerCh).Once()
362	timer.EXPECT().Stop().Return(true).Once()
363	delayCh := make(chan time.Duration, 1)
364	clock := portsmocks.NewMockClock(t)
365	clock.EXPECT().NewTimer(mock.Anything).Run(func(delay time.Duration) {
366		delayCh <- delay
367	}).Return(timer).Once()
368	recvStarted := make(chan struct{})
369	recvGate := make(chan struct{})
370	transport := &cmdTestTransport{recvStarted: recvStarted, recvGate: recvGate}
371	done := make(chan error, 1)
372	go func() {
373		done <- runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
374			stdout: io.Discard,
375			getenv: func(string) string { return "" },
376			dial:   func(context.Context, string) (wire.Transport, error) { return transport, nil },
377			clock:  clock,
378		})
379	}()
380	<-recvStarted
381	if delay := <-delayCh; delay != daemon.CommandRequestTimeout {
382		t.Fatalf("command timer delay = %s, want %s", delay, daemon.CommandRequestTimeout)
383	}
384	timerCh <- time.Time{}
385	err := <-done
386	close(recvGate)
387	if ExitCode(err) != 3 || !errors.Is(err, daemon.ErrCommandRequestTimeout) {
388		t.Fatalf("timeout error=%v code=%d", err, ExitCode(err))
389	}
390	message, decodeErr := sessionwire.DecodeClientEnvelope(transport.sent[1].Payload)
391	if decodeErr != nil {
392		t.Fatalf("decode=%v, want non-zero request ID", decodeErr)
393	}
394	request := message.(protocol.CommandRequest)
395	if request.RequestID == 0 {
396		t.Fatalf("request=%+v, want non-zero request ID", request)
397	}
398}
399
400func TestRunCmdZeroRequestIDReplyIsRejected(t *testing.T) {
401	const want = "unexpected command reply request ID 0"
402	transport := &cmdTestTransport{preserveReplyID: true, recv: mustServerEnvelope(protocol.CommandResult{
403		Outcome: protocol.CommandFailed, Code: protocol.ErrInternal, Text: want,
404	})}
405	err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
406		stdout: io.Discard,
407		getenv: func(string) string { return "" },
408		dial:   func(context.Context, string) (wire.Transport, error) { return transport, nil },
409	})
410	// A correctly typed but uncorrelated reply is an indeterminate outcome, not a
411	// definite daemon failure: the request was sent, so it is never replayed.
412	if err == nil || !errors.Is(err, errCommandOutcomeUnknown) {
413		t.Fatalf("error = %v, want an outcome-unknown rejection of reply request ID 0", err)
414	}
415	if !strings.Contains(err.Error(), want) {
416		t.Fatalf("error = %v, want message containing %q", err, want)
417	}
418	if errors.Is(err, daemon.ErrCommandRequestTimeout) {
419		t.Fatalf("error = %v, want daemon error instead of timeout", err)
420	}
421}
422
423func TestRunCmdDoesNotAutostartAndClassifiesDialFailure(t *testing.T) {
424	dials := 0
425	dialErr := errors.New("unreachable")
426	err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
427		stdout: io.Discard,
428		getenv: func(string) string { return "" },
429		dial: func(context.Context, string) (wire.Transport, error) {
430			dials++
431			return nil, dialErr
432		},
433	})
434	if ExitCode(err) != 3 || dials != 1 {
435		t.Fatalf("error=%v code=%d dials=%d, want unreachable/3 and one dial", err, ExitCode(err), dials)
436	}
437	if !errors.Is(err, dialErr) {
438		t.Fatalf("error=%v does not wrap dial error", err)
439	}
440}
441
442func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) {
443	tests := []struct {
444		name     string
445		result   protocol.CommandResult
446		wantCode int
447	}{
448		{
449			name:     "invalid command arguments are usage errors",
450			result:   protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrInvalidCommandArgs, Text: "usage: toast [-l level] <message>"},
451			wantCode: 2,
452		},
453		{
454			name:     "missing runtime target is a command failure",
455			result:   protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no live sessions"},
456			wantCode: 1,
457		},
458		{
459			name:     "resize not in split remains an exit one failure",
460			result:   protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "pane is not in a split"},
461			wantCode: 1,
462		},
463		{
464			name:     "resize minimum remains an exit one failure",
465			result:   protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "pane cannot be resized further"},
466			wantCode: 1,
467		},
468	}
469	for _, tt := range tests {
470		t.Run(tt.name, func(t *testing.T) {
471			transport := &cmdTestTransport{recv: mustServerEnvelope(tt.result)}
472			err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
473				stdout: io.Discard,
474				getenv: func(string) string { return "" },
475				dial:   func(context.Context, string) (wire.Transport, error) { return transport, nil },
476			})
477			if err == nil || ExitCode(err) != tt.wantCode || err.Error() != tt.result.Text {
478				t.Fatalf("error=%v code=%d, want message %q and code %d", err, ExitCode(err), tt.result.Text, tt.wantCode)
479			}
480		})
481	}
482}
483
484type cmdTestTransport struct {
485	sent            []wire.Envelope
486	recv            wire.Envelope
487	recvErr         error
488	recvCalls       int
489	closeCalls      int
490	recvStarted     chan struct{}
491	replyStarted    chan struct{}
492	recvGate        <-chan struct{}
493	preserveReplyID bool
494}
495
496func (t *cmdTestTransport) Send(frame wire.Envelope) error {
497	t.sent = append(t.sent, frame)
498	return nil
499}
500
501// Recv serves the server preamble response on the first call, then the
502// queued reply: typed client connections run the preamble lazily.
503func (t *cmdTestTransport) Recv() (wire.Envelope, error) {
504	t.recvCalls++
505	if t.recvStarted != nil {
506		select {
507		case <-t.recvStarted:
508		default:
509			close(t.recvStarted)
510		}
511	}
512	if t.recvCalls == 1 {
513		return mustPreambleResponse(), nil
514	}
515	if t.replyStarted != nil {
516		select {
517		case <-t.replyStarted:
518		default:
519			close(t.replyStarted)
520		}
521	}
522	if t.recvGate != nil {
523		<-t.recvGate
524	}
525	if t.recvErr == nil && !t.preserveReplyID {
526		message, err := sessionwire.DecodeServerEnvelope(t.recv.Payload)
527		if err == nil {
528			if result, ok := message.(protocol.CommandResult); ok && result.RequestID == 0 {
529				requestMessage, decodeErr := sessionwire.DecodeClientEnvelope(t.sent[1].Payload)
530				if decodeErr == nil {
531					result.RequestID = requestMessage.(protocol.CommandRequest).RequestID
532					t.recv = mustServerEnvelope(result)
533				}
534			}
535		}
536	}
537	return t.recv, t.recvErr
538}
539
540func (t *cmdTestTransport) Close() error {
541	t.closeCalls++
542	return nil
543}

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