v0.3.0 99dc266

routes_test.go RawHistoryFolder

1package client
2
3import (
4	"context"
5	"errors"
6	"sync"
7	"testing"
8
9	"github.com/bnema/vev/internal/domain"
10	"github.com/bnema/vev/internal/ports"
11	"github.com/stretchr/testify/require"
12)
13
14func routeTestTarget(index byte) ports.ExactSessionTarget {
15	return ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{index + 1}, SessionName: "session-" + string(rune('a'+index%26)) + string(rune('0'+index/26))}
16}
17
18func routeTestCandidate(index byte, origin ports.RouteOrigin) routeCandidate {
19	target := routeTestTarget(index)
20	return routeCandidate{
21		origin: origin,
22		target: target,
23		presentation: routePresentation{
24			name:         target.SessionName,
25			kind:         ports.RouteKindLocal,
26			reachability: ports.RouteReachabilityReachable,
27		},
28		request: AttachRequest{
29			Intent:      ports.IntentAttach,
30			SessionName: target.SessionName,
31			Origin:      origin,
32		},
33	}
34}
35
36func TestRouteCandidateRetainsRemoteOriginWithoutDiscoveryTarget(t *testing.T) {
37	target := routeTestTarget(1)
38	candidate := routeCandidateForAttach(AttachRequest{
39		Intent: ports.IntentAttach, SessionName: target.SessionName, Remote: true,
40		Origin: ports.RouteOriginRemote, OriginKey: "remote",
41	}, ports.CommittedRouteIdentity{Target: target}, nil, 0)
42
43	require.Nil(t, candidate.request.RemoteTarget)
44	require.Equal(t, "remote", candidate.request.RemoteOrigin)
45	require.Equal(t, "remote", candidate.presentation.hostLabel)
46}
47
48func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) {
49	ledger := newRouteLedger()
50	var keys []routeKey
51	for i := byte(0); i < maxRouteLedgerEntries+8; i++ {
52		candidate := routeTestCandidate(i, ports.RouteOriginLocal)
53		candidate.home = i == 0
54		identity, err := ledger.commit(candidate)
55		require.NoError(t, err)
56		keys = append(keys, identity.key)
57	}
58
59	snapshot := ledger.snapshot()
60	require.Len(t, ledger.entries, maxRouteLedgerEntries)
61	require.Len(t, snapshot.Entries, maxRouteLedgerEntries-1)
62	require.Equal(t, uint64(keys[len(keys)-1]), snapshot.Active.Key)
63	for _, entry := range snapshot.Entries {
64		require.NotEqual(t, snapshot.Active, ports.RouteRef{Key: entry.Key, Generation: entry.Generation})
65	}
66	require.Equal(t, uint64(keys[len(keys)-2]), snapshot.Previous.Key)
67	require.Equal(t, uint64(keys[0]), snapshot.Home.Key, "home is retained when older entries are evicted")
68
69	for i := 1; i < len(snapshot.Entries); i++ {
70		require.Greater(t, snapshot.Entries[i-1].Generation, snapshot.Entries[i].Generation)
71	}
72	originalName := snapshot.Entries[0].Name
73	snapshot.Entries[0].Name = "mutated"
74	fresh := ledger.snapshot()
75	require.Equal(t, originalName, fresh.Entries[0].Name)
76
77	old := ledger.activeRef()
78	_, err := ledger.commit(routeTestCandidate(maxRouteLedgerEntries+9, ports.RouteOriginLocal))
79	require.NoError(t, err)
80	require.NotEqual(t, old, ledger.activeRef())
81	require.Equal(t, old, ledger.previousRef(), "the old active route becomes the previous route")
82}
83
84func TestRouteLedgerRemembersTabsIndependentlyPerExactRoute(t *testing.T) {
85	ledger := newRouteLedger()
86	first := routeTestCandidate(1, ports.RouteOriginLocal)
87	firstIdentity, err := ledger.commit(first)
88	require.NoError(t, err)
89	firstRef := ledger.activeRef()
90	firstSnapshotGeneration := ledger.snapshot().Generation
91	require.NoError(t, ledger.updateRoutePosition(ports.RoutePosition{Target: first.target, ActiveTabID: "tab-first"}))
92	require.Equal(t, firstRef, ledger.activeRef(), "tab memory must not rotate route identity")
93	require.Equal(t, firstSnapshotGeneration, ledger.snapshot().Generation, "tab memory must not stale navigation snapshots")
94
95	second := routeTestCandidate(2, ports.RouteOriginRemote)
96	secondIdentity, err := ledger.commit(second)
97	require.NoError(t, err)
98	secondRef := ledger.activeRef()
99	secondSnapshotGeneration := ledger.snapshot().Generation
100	require.NoError(t, ledger.updateRoutePosition(ports.RoutePosition{Target: second.target, ActiveTabID: "tab-second"}))
101	require.Equal(t, secondRef, ledger.activeRef(), "tab memory must not rotate route identity")
102	require.Equal(t, secondSnapshotGeneration, ledger.snapshot().Generation, "tab memory must not stale navigation snapshots")
103
104	firstRecord, ok := ledger.lookup(firstIdentity.wire())
105	require.True(t, ok)
106	require.Equal(t, domain.TabStableID("tab-first"), firstRecord.request.PreferredTabID)
107	secondRecord, ok := ledger.lookup(secondIdentity.wire())
108	require.True(t, ok)
109	require.Equal(t, domain.TabStableID("tab-second"), secondRecord.request.PreferredTabID)
110}
111
112func TestRouteLedgerSamePeerHandoffRestoresRouteTab(t *testing.T) {
113	ledger := newRouteLedger()
114	first := routeTestCandidate(1, ports.RouteOriginLocal)
115	first.originKey = "local"
116	_, err := ledger.commit(first)
117	require.NoError(t, err)
118	require.NoError(t, ledger.updateRoutePosition(ports.RoutePosition{Target: first.target, ActiveTabID: "tab-first"}))
119
120	second := routeTestCandidate(2, ports.RouteOriginLocal)
121	second.originKey = "local"
122	_, err = ledger.commit(second)
123	require.NoError(t, err)
124
125	recreated := first.target
126	recreated.LifecycleID[0]++
127	for _, tt := range []struct {
128		name            string
129		target          ports.ExactSessionTarget
130		wantExactTarget *ports.ExactSessionTarget
131		wantTab         domain.TabStableID
132	}{
133		{name: "matching lifecycle restores remembered tab", target: first.target, wantExactTarget: &first.target, wantTab: "tab-first"},
134		{name: "recreated session does not reuse remembered tab", target: recreated, wantExactTarget: &recreated},
135	} {
136		t.Run(tt.name, func(t *testing.T) {
137			request := ledger.samePeerHandoff(second.request, ports.AttachTarget{
138				Session:           first.target.SessionName,
139				Intent:            ports.IntentAttach,
140				ExactTarget:       &tt.target,
141				EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
142			})
143			require.Equal(t, first.target.SessionName, request.SessionName)
144			require.Equal(t, tt.wantExactTarget, request.ExactTarget)
145			require.Equal(t, tt.wantTab, request.PreferredTabID)
146			require.Equal(t, ports.EnvironmentPolicyClientOwned, request.EnvironmentPolicy)
147		})
148	}
149}
150
151func TestRouteLedgerSamePeerHandoffClearsDaemonOwnedNavigationForLocalRoutes(t *testing.T) {
152	active := routeTestCandidate(2, ports.RouteOriginLocal)
153	active.originKey = "local"
154	active.request.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
155	active.request.NavigationCapabilities = ports.NavigationCapabilityHomePicker
156	active.request.StartupOverlay = ports.StartupOverlaySessionPicker
157
158	for _, tt := range []struct {
159		name    string
160		matched bool
161	}{
162		{name: "matched route", matched: true},
163		{name: "fallback route"},
164	} {
165		t.Run(tt.name, func(t *testing.T) {
166			ledger := newRouteLedger()
167			target := routeTestTarget(1)
168			if tt.matched {
169				stored := routeTestCandidate(1, ports.RouteOriginLocal)
170				stored.originKey = "local"
171				stored.request.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
172				stored.request.NavigationCapabilities = ports.NavigationCapabilityHomePicker
173				stored.request.StartupOverlay = ports.StartupOverlaySessionPicker
174				_, err := ledger.commit(stored)
175				require.NoError(t, err)
176			}
177
178			request := ledger.samePeerHandoff(active.request, ports.AttachTarget{
179				Session: target.SessionName, Intent: ports.IntentAttach, ExactTarget: &target,
180				EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
181			})
182
183			require.Equal(t, ports.EnvironmentPolicyClientOwned, request.EnvironmentPolicy)
184			require.Zero(t, request.NavigationCapabilities)
185			require.Equal(t, ports.StartupOverlayNone, request.StartupOverlay)
186			require.NoError(t, validateAttachRequest(request))
187		})
188	}
189}
190
191func TestRouteLedgerSamePeerHandoffDropsOriginalRemoteTarget(t *testing.T) {
192	ledger := newRouteLedger()
193	work := routeTestCandidate(1, ports.RouteOriginRemote)
194	work.originKey = "remote"
195	work.request.Remote = true
196	work.request.OriginKey = "remote"
197	work.request.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
198	work.request.RemoteTarget = &domain.RemoteSessionTarget{
199		Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: work.target.LifecycleID,
200		SessionName: work.target.SessionName, LiveTabID: "tab-work",
201	}
202	_, err := ledger.commit(work)
203	require.NoError(t, err)
204
205	agents := routeTestCandidate(2, ports.RouteOriginRemote)
206	agents.originKey = "remote"
207	agents.request.Remote = true
208	agents.request.OriginKey = "remote"
209	agents.request.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
210
211	request := ledger.samePeerHandoff(agents.request, ports.AttachTarget{
212		Session:           work.target.SessionName,
213		Intent:            ports.IntentAttach,
214		ExactTarget:       &work.target,
215		EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
216	})
217
218	require.True(t, request.Remote)
219	require.Equal(t, ports.RouteOriginRemote, request.Origin)
220	require.Equal(t, "remote", request.OriginKey)
221	require.Nil(t, request.RemoteTarget)
222}
223
224func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) {
225	ledger := newRouteLedger()
226	first := routeTestCandidate(0, ports.RouteOriginRemote)
227	first.originKey = "host-a"
228	firstIdentity, err := ledger.commit(first)
229	require.NoError(t, err)
230	other := routeTestCandidate(1, ports.RouteOriginLocal)
231	_, err = ledger.commit(other)
232	require.NoError(t, err)
233	otherRemote := routeTestCandidate(2, ports.RouteOriginRemote)
234	otherRemote.originKey = "host-b"
235	_, err = ledger.commit(otherRemote)
236	require.NoError(t, err)
237	active := routeTestCandidate(3, ports.RouteOriginRemote)
238	active.originKey = "host-a"
239	_, err = ledger.commit(active)
240	require.NoError(t, err)
241
242	subscription := ledger.attentionSubscription()
243
244	require.Equal(t, []ports.RouteAttentionTarget{{
245		Ref:    firstIdentity.wire(),
246		Target: first.target,
247	}}, subscription.Targets)
248}
249
250func TestCommittedIdentityRenamesActiveRouteInPlace(t *testing.T) {
251	ledger := newRouteLedger()
252	original := routeTestCandidate(0, ports.RouteOriginLocal)
253	original.target = ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "0"}
254	original.presentation.name = "0"
255	original.request.SessionName = "0"
256	identity, err := ledger.commit(original)
257	require.NoError(t, err)
258	before := ledger.snapshot()
259
260	committed, err := ledger.commitCommittedIdentity(ports.CommittedRouteIdentity{
261		Target:    ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "vps-infra"},
262		Ephemeral: false,
263	})
264	require.NoError(t, err)
265
266	snapshot := ledger.snapshot()
267	require.Equal(t, identity, committed, "a rename must retain the route's process-local identity")
268	require.Greater(t, snapshot.Generation, before.Generation, "a rename invalidates prior route snapshots")
269	require.Empty(t, snapshot.Entries, "the old ephemeral label must not remain in route history")
270	active, ok := ledger.lookup(snapshot.Active)
271	require.True(t, ok)
272	require.Equal(t, "vps-infra", active.presentation.name)
273	require.Equal(t, "vps-infra", active.target.SessionName)
274	require.False(t, active.presentation.ephemeral)
275}
276
277func TestCommittedIdentityDoesNotReassignHomeRoute(t *testing.T) {
278	ledger := newRouteLedger()
279	home := routeTestCandidate(1, ports.RouteOriginLocal)
280	home.home = true
281	homeIdentity, err := ledger.commit(home)
282	require.NoError(t, err)
283	committed, err := ledger.commitCommittedIdentity(ports.CommittedRouteIdentity{
284		Target:    routeTestTarget(2),
285		Ephemeral: true,
286	})
287	require.NoError(t, err)
288	require.Equal(t, uint64(homeIdentity.key), ledger.homeRef().Key)
289	require.NotEqual(t, ledger.homeRef(), committed.wire())
290	active, ok := ledger.lookup(ledger.activeRef())
291	require.True(t, ok)
292	require.Equal(t, committed, active.identity)
293
294	_, err = ledger.commit(routeTestCandidate(1, ports.RouteOriginLocal))
295	require.NoError(t, err)
296	require.True(t, mustRouteRecord(t, ledger, ledger.activeRef()).home, "upserting the home route must retain its marker")
297}
298
299func TestRouteLedgerConcurrentInitialAttachmentsKeepOneHome(t *testing.T) {
300	ledger := newRouteLedger()
301	const attachments = 16
302	errs := make(chan error, attachments)
303	var wg sync.WaitGroup
304	for i := byte(0); i < attachments; i++ {
305		wg.Add(1)
306		go func() {
307			defer wg.Done()
308			_, err := ledger.commitAttach(routeTestCandidate(i, ports.RouteOriginLocal))
309			errs <- err
310		}()
311	}
312	wg.Wait()
313	close(errs)
314	for err := range errs {
315		require.NoError(t, err)
316	}
317
318	ledger.mu.RLock()
319	defer ledger.mu.RUnlock()
320	homeCount := 0
321	for _, entry := range ledger.entries {
322		if entry.home {
323			homeCount++
324			require.Equal(t, entry.identity, ledger.home)
325		}
326	}
327	require.Equal(t, 1, homeCount)
328}
329
330func TestRouteLedgerTransitionRejectsConcurrentCommit(t *testing.T) {
331	ledger := newRouteLedger()
332	selected, err := ledger.commit(routeTestCandidate(1, ports.RouteOriginLocal))
333	require.NoError(t, err)
334	_, err = ledger.commit(routeTestCandidate(2, ports.RouteOriginLocal))
335	require.NoError(t, err)
336	selection, ok := ledger.navigationSelection(selected.wireNavigationAction(routeGeneration(ledger.snapshot().Generation)))
337	require.True(t, ok)
338
339	_, err = ledger.commit(routeTestCandidate(3, ports.RouteOriginLocal))
340	require.NoError(t, err)
341	candidate := routeTestCandidate(4, ports.RouteOriginLocal)
342	err = ledger.commitTransition(selected, selection.snapshotGeneration, selection.active, candidate)
343	require.ErrorIs(t, err, errRouteStaleSelection)
344	require.Equal(t, routeTestTarget(3), mustRouteRecord(t, ledger, ledger.activeRef()).target)
345}
346
347func mustRouteRecord(t *testing.T, ledger *routeLedger, ref ports.RouteRef) routeRecord {
348	t.Helper()
349	record, ok := ledger.lookup(ref)
350	require.True(t, ok)
351	return record
352}
353
354func TestRouteLedgerUpsertChangesGenerationWithoutChangingOpaqueKey(t *testing.T) {
355	ledger := newRouteLedger()
356	candidate := routeTestCandidate(1, ports.RouteOriginRemote)
357	first, err := ledger.commit(candidate)
358	require.NoError(t, err)
359
360	candidate.presentation.attention = true
361	second, err := ledger.commit(candidate)
362	require.NoError(t, err)
363	require.Equal(t, first.key, second.key)
364	require.Greater(t, second.generation, first.generation)
365	_, ok := ledger.lookup(first.wire())
366	require.False(t, ok, "old generation must not resolve")
367}
368
369func TestRouteLedgerDiscoveryCyclesAreIndependent(t *testing.T) {
370	ledger := newRouteLedger()
371	first := ledger.beginDiscoveryCycle()
372	second := ledger.beginDiscoveryCycle()
373
374	require.NotEqual(t, first, second)
375	require.False(t, ledger.discoveryCycleCurrent(first))
376	require.True(t, ledger.discoveryCycleCurrent(second))
377}
378
379type routeTestConnector struct {
380	resumeErr     error
381	attachErr     error
382	resumeCalls   int
383	attachCalls   int
384	restoreCalls  int
385	restoreErr    error
386	restoreTarget ports.ExactSessionTarget
387}
388
389func (c *routeTestConnector) Resume(context.Context, routeRecord) (routeTransitionCommit, error) {
390	c.resumeCalls++
391	if c.resumeErr != nil {
392		return routeTransitionCommit{}, c.resumeErr
393	}
394	return routeTransitionCommit{}, errors.New("unexpected resume success")
395}
396
397func (c *routeTestConnector) Restore(_ context.Context, record routeRecord) error {
398	c.restoreCalls++
399	c.restoreTarget = record.target
400	return c.restoreErr
401}
402
403func (c *routeTestConnector) Attach(_ context.Context, record routeRecord) (routeTransitionCommit, error) {
404	c.attachCalls++
405	if c.attachErr != nil {
406		return routeTransitionCommit{}, c.attachErr
407	}
408	return routeTransitionCommit{
409		identity: ports.CommittedRouteIdentity{Target: record.target},
410		presentation: routePresentation{
411			name:         record.presentation.name,
412			kind:         record.presentation.kind,
413			reachability: ports.RouteReachabilityReachable,
414		},
415		resumeToken: 123,
416	}, nil
417}
418
419func TestRouteLedgerNavigateResumesThenFallsBackAndRestoresOrigin(t *testing.T) {
420	ledger := newRouteLedger()
421	candidate := routeTestCandidate(2, ports.RouteOriginDiscovery)
422	candidate.originKey = "daemon-discovered"
423	candidate.resumeToken = 77
424	identity, err := ledger.commit(candidate)
425	require.NoError(t, err)
426	_, err = ledger.commit(routeTestCandidate(4, ports.RouteOriginLocal))
427	require.NoError(t, err)
428
429	connector := &routeTestConnector{resumeErr: errRouteResumeUnavailable}
430	action := identity.wireNavigationAction(routeGeneration(ledger.snapshot().Generation))
431	err = ledger.navigate(context.Background(), action, connector)
432	require.NoError(t, err)
433	require.Equal(t, 1, connector.resumeCalls)
434	require.Equal(t, 1, connector.attachCalls)
435	require.Zero(t, connector.restoreCalls)
436
437	active, ok := ledger.lookup(ledger.activeRef())
438	require.True(t, ok)
439	require.Equal(t, ports.RouteOriginDiscovery, active.origin)
440	require.Equal(t, "daemon-discovered", active.originKey)
441	require.Equal(t, ports.RouteOriginDiscovery, active.request.Origin)
442	require.Equal(t, candidate.target, active.target)
443	require.Greater(t, active.identity.generation, identity.generation)
444}
445
446func TestRouteLedgerNavigateIsTransactionalOnFailureOrTargetChange(t *testing.T) {
447	ledger := newRouteLedger()
448	identity, err := ledger.commit(routeTestCandidate(3, ports.RouteOriginLocal))
449	require.NoError(t, err)
450	_, err = ledger.commit(routeTestCandidate(5, ports.RouteOriginLocal))
451	require.NoError(t, err)
452	before := ledger.snapshot()
453	action := identity.wireNavigationAction(routeGeneration(before.Generation))
454
455	connector := &routeTestConnector{attachErr: errors.New("offline")}
456	err = ledger.navigate(context.Background(), action, connector)
457	require.Error(t, err)
458	require.Equal(t, before, ledger.snapshot())
459	require.Equal(t, routeTestTarget(5), connector.restoreTarget)
460	require.Equal(t, 1, connector.restoreCalls)
461
462	connector = &routeTestConnector{}
463	// Force a mismatched committed target through a small wrapper.
464	mismatch := mismatchingRouteConnector{routeTestConnector: connector}
465	err = ledger.navigate(context.Background(), action, mismatch)
466	require.ErrorIs(t, err, errRouteTargetChanged)
467	require.Equal(t, before, ledger.snapshot())
468	require.Equal(t, routeTestTarget(5), connector.restoreTarget)
469	require.Equal(t, 1, connector.restoreCalls)
470}
471
472func TestRouteLedgerNavigationRejectsStaleActionsAndActiveNoOp(t *testing.T) {
473	ledger := newRouteLedger()
474	active, err := ledger.commit(routeTestCandidate(6, ports.RouteOriginLocal))
475	require.NoError(t, err)
476	firstSnapshot := ledger.snapshot()
477	before := ledger.snapshot()
478
479	require.NoError(t, ledger.navigate(context.Background(), active.wireNavigationAction(routeGeneration(firstSnapshot.Generation)), nil))
480	require.Equal(t, before, ledger.snapshot())
481
482	_, err = ledger.commit(routeTestCandidate(7, ports.RouteOriginLocal))
483	require.NoError(t, err)
484	stale := active.wireNavigationAction(routeGeneration(firstSnapshot.Generation))
485	connector := &routeTestConnector{}
486	require.ErrorIs(t, ledger.navigate(context.Background(), stale, connector), errRouteStaleSelection)
487	require.Zero(t, connector.resumeCalls)
488	require.Zero(t, connector.attachCalls)
489}
490
491type mismatchingRouteConnector struct{ *routeTestConnector }
492
493func (c mismatchingRouteConnector) Attach(_ context.Context, record routeRecord) (routeTransitionCommit, error) {
494	return routeTransitionCommit{
495		identity:     ports.CommittedRouteIdentity{Target: routeTestTarget(99)},
496		presentation: routePresentation{name: record.presentation.name, kind: record.presentation.kind},
497	}, nil
498}
499
500func (i routeIdentity) wireNavigationAction(snapshotGeneration routeGeneration) ports.RouteNavigationAction {
501	return ports.RouteNavigationAction{SnapshotGeneration: uint64(snapshotGeneration), Key: uint64(i.key), Generation: uint64(i.generation)}
502}
503
504func TestRouteLedgerConcurrentSnapshotsAndCommits(t *testing.T) {
505	ledger := newRouteLedger()
506	var wg sync.WaitGroup
507	for worker := byte(0); worker < 4; worker++ {
508		wg.Add(1)
509		go func() {
510			defer wg.Done()
511			for i := byte(0); i < 20; i++ {
512				_, _ = ledger.commit(routeTestCandidate(worker*20+i, ports.RouteOriginLocal))
513				_ = ledger.snapshot()
514			}
515		}()
516	}
517	wg.Wait()
518	require.LessOrEqual(t, len(ledger.snapshot().Entries), maxRouteLedgerEntries-1)
519}
520
521func TestRouteLedgerSeparatesRemoteDaemonOrigins(t *testing.T) {
522	ledger := newRouteLedger()
523	firstCandidate := routeTestCandidate(40, ports.RouteOriginRemote)
524	firstCandidate.originKey = "daemon-a"
525	secondCandidate := firstCandidate
526	secondCandidate.originKey = "daemon-b"
527
528	first, err := ledger.commit(firstCandidate)
529	require.NoError(t, err)
530	second, err := ledger.commit(secondCandidate)
531	require.NoError(t, err)
532
533	require.NotEqual(t, first.key, second.key)
534	require.Len(t, ledger.snapshot().Entries, 1)
535}

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