move_tab_test.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"errors"
6	"sync"
7	"testing"
8	"time"
9
10	"github.com/stretchr/testify/require"
11
12	renderer "github.com/bnema/vev-vt/ansi"
13	"github.com/bnema/vev/internal/domain"
14	recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
15)
16
17func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) {
18	for _, destinationEphemeral := range []bool{false, true} {
19		t.Run(map[bool]string{false: "named", true: "ephemeral"}[destinationEphemeral], func(t *testing.T) {
20			movedPTY := newQuietPTY()
21			remainingPTY := newQuietPTY()
22			destinationPTY := newQuietPTY()
23			floatingPTY := newQuietPTY()
24			d, source, _, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)
25			moved := source.tabs[0]
26			moved.stableID = "moved-tab"
27			moved.name = "preserved-name"
28			remaining := source.tabs[1]
29			remaining.stableID = "remaining-tab"
30			selectTestAttachmentTab(source, 0)
31
32			destinationCtx, destinationCancel := context.WithCancel(d.serveCtx)
33			t.Cleanup(destinationCancel)
34			destinationTab := newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 100, Rows: 30})
35			destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx)
36			destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{9}, ephemeral: destinationEphemeral}, ctx: destinationCtx, cancel: destinationCancel, tabs: []*tab{destinationTab}}
37			publishTiledPaneOwners(destination, destinationTab)
38
39			floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8})
40			moved.mu.Lock()
41			moved.floating = floatingSlot{state: floatingHidden, pane: floating, generation: 7}
42			moved.mu.Unlock()
43			publishPaneOwner(floating, source, moved, 7)
44
45			d.mu.Lock()
46			d.sessions[destination.id] = destination
47			d.mu.Unlock()
48
49			tiled := moved.focusedPane()
50			tiledScreen := tiled.screen
51			tiledOwnerGeneration := tiled.ownerGeneration
52			floatingOwnerGeneration := floating.ownerGeneration
53			oldWorkerCtx := moved.ctx
54			oldTree := moved.tree
55			oldPanes := moved.panes
56			beforeDestinationActive := destination.tabs[testAttachmentTabIndex(destination)]
57
58			err := d.moveTab(moveTabRequest{
59				Source:      moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
60				SourceTabID: domain.TabStableID(moved.stableID),
61				Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
62			})
63			require.NoError(t, err)
64
65			require.Equal(t, []*tab{remaining}, source.tabs)
66			require.Zero(t, testAttachmentTabIndex(source))
67			require.Same(t, remaining, source.tabs[testAttachmentTabIndex(source)])
68			require.Equal(t, []*tab{destinationTab, moved}, destination.tabs)
69			require.Same(t, beforeDestinationActive, destination.tabs[testAttachmentTabIndex(destination)], "moved tab stays in the background")
70			require.Equal(t, destinationTab.size, moved.size, "destination geometry becomes authoritative")
71			require.Equal(t, "preserved-name", moved.name)
72			require.Same(t, oldTree, moved.tree)
73			require.Equal(t, oldPanes, moved.panes)
74			require.Same(t, movedPTY, tiled.pty)
75			require.Same(t, tiledScreen, tiled.screen)
76			require.Same(t, floating, moved.floating.pane)
77			require.Same(t, floatingPTY, floating.pty)
78			require.Equal(t, floatingHidden, moved.floating.state)
79			require.Equal(t, uint64(7), moved.floating.generation)
80			require.Equal(t, tiledOwnerGeneration+1, tiled.ownerGeneration)
81			require.Equal(t, floatingOwnerGeneration+1, floating.ownerGeneration)
82			require.Same(t, destination, tiled.ownerSnapshot().session)
83			require.Same(t, destination, floating.ownerSnapshot().session)
84			require.Error(t, oldWorkerCtx.Err(), "source-derived tab worker context is cancelled")
85			require.NoError(t, moved.ctx.Err(), "replacement tab worker context belongs to live destination")
86		})
87	}
88}
89
90func TestMoveTabRejectsSameSessionStaleMembershipAndWarming(t *testing.T) {
91	tests := []struct {
92		name    string
93		mutate  func(source, destination *session, moved *tab, req *moveTabRequest)
94		wantErr error
95	}{
96		{name: "same session", wantErr: errMovePaneInvalid, mutate: func(source, _ *session, _ *tab, req *moveTabRequest) {
97			req.Destination = req.Source
98		}},
99		{name: "stale incarnation", wantErr: errMoveStaleTarget, mutate: func(_, _ *session, _ *tab, req *moveTabRequest) {
100			req.Destination.Incarnation[0]++
101		}},
102		{name: "missing membership", wantErr: errMoveStaleTarget, mutate: func(source, _ *session, moved *tab, _ *moveTabRequest) {
103			source.tabs = source.tabs[1:]
104			_ = moved
105		}},
106		{name: "warming floating", wantErr: errMoveFloatingWarming, mutate: func(_, _ *session, moved *tab, _ *moveTabRequest) {
107			moved.floating.state = floatingWarming
108			moved.floating.generation = 3
109		}},
110		{name: "destination teardown", wantErr: errMoveStaleTarget, mutate: func(_, destination *session, _ *tab, _ *moveTabRequest) {
111			destination.teardownMu.Lock()
112			destination.teardownActive = true
113			destination.teardownMu.Unlock()
114		}},
115	}
116	for _, tt := range tests {
117		t.Run(tt.name, func(t *testing.T) {
118			d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
119			moved := source.tabs[0]
120			moved.stableID = "moved-tab"
121			destination := addMoveTabTestSession(d, "destination", "destination-tab")
122			req := moveTabRequest{
123				Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
124				Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
125			}
126			tt.mutate(source, destination, moved, &req)
127			beforeDestination := append([]*tab(nil), destination.tabs...)
128			beforeOwner := moved.focusedPane().ownerSnapshot()
129
130			err := d.moveTab(req)
131			destination.teardownMu.Lock()
132			destination.teardownActive = false
133			destination.teardownMu.Unlock()
134			require.ErrorIs(t, err, tt.wantErr)
135			var rejection *moveRejection
136			require.ErrorAs(t, err, &rejection)
137			require.Equal(t, beforeDestination, destination.tabs)
138			require.Same(t, beforeOwner, moved.focusedPane().ownerSnapshot())
139		})
140	}
141}
142
143func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing.T) {
144	for _, tt := range []struct {
145		name   string
146		mutate func(*tab)
147	}{
148		{name: "generation", mutate: func(moved *tab) { moved.floating.generation++ }},
149		{name: "warming", mutate: func(moved *tab) { moved.floating.state = floatingWarming }},
150	} {
151		t.Run(tt.name, func(t *testing.T) {
152			d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
153			moved := source.tabs[0]
154			moved.stableID = "moved-tab"
155			require.NotNil(t, d.attachCoordinator(source, nil, client, true))
156			destination := addMoveTabTestSession(d, "destination", "destination-tab")
157			floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})
158			moved.mu.Lock()
159			moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 7}
160			moved.mu.Unlock()
161			publishPaneOwner(floating, source, moved, 7)
162
163			source.layoutApplyMu.Lock()
164			admitted := make(chan struct{})
165			d.afterAttachmentEffectGateFrozen = func(action string, ac *attachedClient) {
166				if action == "move-tab" && ac == client {
167					close(admitted)
168				}
169			}
170			moveDone := make(chan error, 1)
171			go func() {
172				moveDone <- d.moveTab(moveTabRequest{
173					Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
174					Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
175				})
176			}()
177			awaitTestCompletion(t, admitted, "move-tab did not finish admission")
178			moved.mu.Lock()
179			tt.mutate(moved)
180			moved.mu.Unlock()
181			source.layoutApplyMu.Unlock()
182
183			require.Error(t, awaitTestValue(t, moveDone, "stale floating transfer did not return"))
184			require.NotContains(t, destination.tabs, moved)
185			require.Same(t, source, floating.ownerSnapshot().session)
186			d.afterAttachmentEffectGateFrozen = nil
187		})
188	}
189}
190
191func TestMoveTabWaitsForSourceResizeAndAppliesDestinationGeometryLast(t *testing.T) {
192	pty := &transactionalResizePTY{}
193	d, source, _, _ := newManualSessionWithPTYs(t, pty, newQuietPTY())
194	moved := source.tabs[0]
195	moved.stableID = "moved-tab"
196	destination := addMoveTabTestSession(d, "destination", "destination-tab")
197	destination.tabs[0].mu.Lock()
198	destination.tabs[0].size = domain.Size{Cols: 100, Rows: 30}
199	destination.tabs[0].mu.Unlock()
200	moved.mu.Lock()
201	moved.size = domain.Size{Cols: 90, Rows: 20}
202	moved.bumpLayoutGenerationLocked()
203	moved.mu.Unlock()
204
205	resizeEntered := make(chan struct{})
206	releaseResize := make(chan struct{})
207	var once sync.Once
208	pty.onResize = func() {
209		once.Do(func() {
210			close(resizeEntered)
211			<-releaseResize
212		})
213	}
214	layoutDone := make(chan bool, 1)
215	go func() { layoutDone <- source.geometry.applyTabLayout(d, source, moved) }()
216	<-resizeEntered
217	reserved := make(chan struct{})
218	d.afterMoveLifecycleReserved = func() { close(reserved) }
219	defer func() { d.afterMoveLifecycleReserved = nil }()
220	moveDone := make(chan error, 1)
221	go func() {
222		moveDone <- d.moveTab(moveTabRequest{
223			Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
224			Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
225		})
226	}()
227	<-reserved
228	select {
229	case err := <-moveDone:
230		t.Fatalf("move crossed blocked source resize: %v", err)
231	default:
232	}
233	close(releaseResize)
234	<-layoutDone // the old transaction may lose owner publication after its PTY call
235	require.NoError(t, <-moveDone)
236
237	pty.mu.Lock()
238	sizes := append([]domain.Size(nil), pty.sizes...)
239	pty.mu.Unlock()
240	require.GreaterOrEqual(t, len(sizes), 2)
241	require.Equal(t, domain.Size{Cols: 100, Rows: 30}, sizes[len(sizes)-1], "destination geometry must be the final external resize")
242}
243
244func TestMoveTabMigratesSyncAndInvalidatesSourceRetryOwner(t *testing.T) {
245	d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
246	moved := source.tabs[0]
247	moved.stableID = "moved-tab"
248	destination := addMoveTabTestSession(d, "destination", "destination-tab")
249	p := moved.focusedPane()
250	sourceCoordinator := d.ensureRenderCoordinator(source)
251	destinationCoordinator := d.ensureRenderCoordinator(destination)
252
253	p.mu.Lock()
254	p.screen.Write([]byte(renderer.SyncStartCSI))
255	generation := source.syncGen.Add(1)
256	p.syncGen = generation
257	oldLease := p.effectLeaseLocked()
258	p.resizeRetry = true
259	p.mu.Unlock()
260	sourceCoordinator.noteSyncBeginWithRenderability(p, generation, oldLease.Current, nil)
261	oldRetry := newAcceptedTabLayoutRetryToken(source, moved, []resizeMember{{session: source, tab: moved, pane: p, owner: oldLease}})
262
263	require.NoError(t, d.moveTab(moveTabRequest{
264		Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
265		Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
266	}))
267
268	require.False(t, oldLease.Current(), "source effects are revoked at owner publication")
269	require.False(t, oldRetry.current(), "source delayed retry cannot run after transfer")
270	require.Same(t, destination, p.ownerSnapshot().session)
271	sourceCoordinator.mu.Lock()
272	require.NotContains(t, sourceCoordinator.syncBatches, p)
273	sourceCoordinator.mu.Unlock()
274	destinationCoordinator.mu.Lock()
275	require.Contains(t, destinationCoordinator.syncBatches, p)
276	destinationCoordinator.mu.Unlock()
277	require.Equal(t, destination.syncGen.Load(), p.syncGen)
278	p.mu.Lock()
279	require.False(t, p.resizeRetry, "required resize work is completed under the destination owner")
280	p.mu.Unlock()
281}
282
283func TestMoveTabExitReapsOnlyDestination(t *testing.T) {
284	d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
285	moved := source.tabs[0]
286	moved.stableID = "moved-tab"
287	remaining := source.tabs[1]
288	destination := addMoveTabTestSession(d, "destination", "destination-tab")
289	p := moved.focusedPane()
290
291	require.NoError(t, d.moveTab(moveTabRequest{
292		Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
293		Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
294	}))
295	d.reapPaneOwner(p)
296
297	require.Equal(t, []*tab{remaining}, source.tabs)
298	require.NotContains(t, destination.tabs, moved, "exit removes the tab from its destination owner")
299	require.Nil(t, p.ownerSnapshot())
300}
301
302func TestMoveTabOppositeDirectionsDoNotDeadlock(t *testing.T) {
303	d, left, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
304	left.tabs[0].stableID = "left-moved"
305	right := addMoveTabTestSession(d, "right", "right-stays")
306	rightMoved := newTabWithStableID("right-moved", "right-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
307	rightMoved.ctx, rightMoved.cancel = context.WithCancel(right.ctx)
308	publishTiledPaneOwners(right, rightMoved)
309	right.tabs = append(right.tabs, rightMoved)
310
311	start := make(chan struct{})
312	done := make(chan error, 2)
313	go func() {
314		<-start
315		done <- d.moveTab(moveTabRequest{Source: moveSessionLocator{ID: left.id, Incarnation: left.incarnation}, SourceTabID: domain.TabStableID("left-moved"), Destination: moveSessionLocator{ID: right.id, Incarnation: right.incarnation}})
316	}()
317	go func() {
318		<-start
319		done <- d.moveTab(moveTabRequest{Source: moveSessionLocator{ID: right.id, Incarnation: right.incarnation}, SourceTabID: domain.TabStableID("right-moved"), Destination: moveSessionLocator{ID: left.id, Incarnation: left.incarnation}})
320	}()
321	close(start)
322	for range 2 {
323		select {
324		case err := <-done:
325			require.NoError(t, err)
326		case <-time.After(5 * time.Second):
327			t.Fatal("opposite-direction move tabs deadlocked")
328		}
329	}
330}
331
332type moveTabPurgeCatalogue struct {
333	mu      sync.Mutex
334	records map[string]domain.CatalogueRecord
335	events  []string
336}
337
338func (c *moveTabPurgeCatalogue) Records() ([]domain.CatalogueRecord, error) {
339	c.mu.Lock()
340	defer c.mu.Unlock()
341	records := make([]domain.CatalogueRecord, 0, len(c.records))
342	for _, record := range c.records {
343		records = append(records, record)
344	}
345	return records, nil
346}
347
348func (c *moveTabPurgeCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
349	c.mu.Lock()
350	defer c.mu.Unlock()
351	record, ok := c.records[name]
352	return record, ok, nil
353}
354
355func (c *moveTabPurgeCatalogue) Create(domain.CatalogueRecord) error {
356	return errors.New("move tab purge catalogue: Create is unsupported")
357}
358
359func (c *moveTabPurgeCatalogue) Replace(string, domain.CatalogueRecord) error {
360	return errors.New("move tab purge catalogue: Replace is unsupported")
361}
362
363func (c *moveTabPurgeCatalogue) Rename(string, domain.CatalogueRecord) error {
364	return errors.New("move tab purge catalogue: Rename is unsupported")
365}
366
367func (c *moveTabPurgeCatalogue) Delete(name string) error {
368	c.mu.Lock()
369	defer c.mu.Unlock()
370	delete(c.records, name)
371	c.events = append(c.events, "catalogue-delete:"+name)
372	return nil
373}
374
375func (c *moveTabPurgeCatalogue) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
376	c.mu.Lock()
377	defer c.mu.Unlock()
378	c.events = append(c.events, "metadata:"+update.Name)
379	return nil
380}
381
382func (c *moveTabPurgeCatalogue) Sync() error  { return nil }
383func (c *moveTabPurgeCatalogue) Close() error { return nil }
384
385type moveTabPurgeRepository struct {
386	noOpSnapshotRepository
387	d           *Daemon
388	source      *session
389	destination *session
390	moved       *tab
391	pane        *pane
392	client      *attachedClient
393	catalogue   *moveTabPurgeCatalogue
394	err         error
395	deleted     []domain.IncarnationID
396	outside     bool
397	dirty       bool
398}
399
400func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.IncarnationID) error {
401	r.deleted = append(r.deleted, id)
402	daemonUnlocked := r.d.mu.TryLock()
403	routingUnlocked := r.d.notices.routingMu.TryLock()
404	sourceUnlocked := r.source.mu.TryLock()
405	destinationUnlocked := r.destination.mu.TryLock()
406	tabUnlocked := r.moved.mu.TryLock()
407	paneUnlocked := r.pane.mu.TryLock()
408	sourceFenceUnlocked := r.source.layoutApplyMu.TryLock()
409	destinationFenceUnlocked := r.destination.layoutApplyMu.TryLock()
410	tabFenceUnlocked := r.moved.layoutApplyMu.TryLock()
411	roleUnlocked := r.client.lifecycle.mu.TryLock()
412	roleStable := roleUnlocked && r.client.lifecycle.phase == attachmentEffectsStable
413	r.outside = daemonUnlocked && routingUnlocked && sourceUnlocked && destinationUnlocked && tabUnlocked && paneUnlocked &&
414		sourceFenceUnlocked && destinationFenceUnlocked && tabFenceUnlocked && roleStable
415	if roleUnlocked {
416		r.client.lifecycle.mu.Unlock()
417	}
418	if tabFenceUnlocked {
419		r.moved.layoutApplyMu.Unlock()
420	}
421	if destinationFenceUnlocked {
422		r.destination.layoutApplyMu.Unlock()
423	}
424	if sourceFenceUnlocked {
425		r.source.layoutApplyMu.Unlock()
426	}
427	if paneUnlocked {
428		r.pane.mu.Unlock()
429	}
430	if tabUnlocked {
431		r.moved.mu.Unlock()
432	}
433	if destinationUnlocked {
434		r.destination.mu.Unlock()
435	}
436	if sourceUnlocked {
437		r.source.mu.Unlock()
438	}
439	if routingUnlocked {
440		r.d.notices.routingMu.Unlock()
441	}
442	if daemonUnlocked {
443		r.d.mu.Unlock()
444	}
445	r.destination.snapshotMu.Lock()
446	r.dirty = r.destination.snapDirty.Load() && r.destination.snapshotGeneration > 0
447	r.destination.snapshotMu.Unlock()
448	r.catalogue.mu.Lock()
449	r.catalogue.events = append(r.catalogue.events, "repository-delete")
450	r.catalogue.mu.Unlock()
451	return r.err
452}
453
454func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) {
455	d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
456	require.NotNil(t, d.attachCoordinator(source, nil, client, true))
457	source.incarnation = domain.IncarnationID{4}
458	moved := source.tabs[0]
459	moved.stableID = "moved-tab"
460	destination := addMoveTabTestSession(d, "destination", "destination-tab")
461	destination.ephemeral = false
462	destination.snapEligible.Store(true)
463
464	catalogue := &moveTabPurgeCatalogue{records: map[string]domain.CatalogueRecord{
465		source.name:      {Name: source.name, IncarnationID: source.incarnation},
466		destination.name: {Name: destination.name, IncarnationID: destination.incarnation},
467	}}
468	failure := errors.New("repository delete failed")
469	repository := &moveTabPurgeRepository{
470		d: d, source: source, destination: destination, moved: moved, pane: moved.focusedPane(), client: client,
471		catalogue: catalogue, err: failure,
472	}
473	WithCatalogue(catalogue, nil)(d)
474	WithSnapshotRepository(repository)(d)
475	WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil))(d)
476
477	require.NoError(t, d.moveTab(moveTabRequest{
478		Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
479		Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
480	}))
481
482	require.Equal(t, []domain.IncarnationID{source.incarnation}, repository.deleted)
483	require.True(t, repository.outside, "repository purge must run outside architecture and owner locks")
484	require.True(t, repository.dirty, "destination snapshot dirtiness must be admitted before purge")
485	catalogue.mu.Lock()
486	require.Equal(t, []string{"metadata:destination", "catalogue-delete:work", "repository-delete"}, catalogue.events)
487	catalogue.mu.Unlock()
488	require.Same(t, moved, destination.tabs[len(destination.tabs)-1], "best-effort purge failure cannot roll back committed membership")
489	require.Same(t, destination, moved.focusedPane().ownerSnapshot().session)
490}
491
492func addMoveTabTestSession(d *Daemon, id domain.SessionID, tabID string) *session {
493	ctx, cancel := context.WithCancel(d.serveCtx)
494	tb := newTabWithStableID(tabID, tabID+"-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
495	tb.ctx, tb.cancel = context.WithCancel(ctx)
496	sess := &session{sessionCore: sessionCore{id: id, name: string(id), incarnation: domain.IncarnationID{5}, ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}}
497	publishTiledPaneOwners(sess, tb)
498	d.mu.Lock()
499	d.sessions[sess.id] = sess
500	d.mu.Unlock()
501	return sess
502}

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