detached ba310a2

test(picker): make UX regressions deterministic

commit
ba310a27a5685d676f0a54f65adcc2cf3e47c678
author
brice · · 2026-07-14 11:59 UTC
parent
123b9fb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +104 -38
M internal/usecase/daemon/palette_test.go +41 -31
@@ -4,7 +4,6 @@ import (
44 "errors"
55 "sync"
66 "testing"
7- "time"
87
98 "github.com/stretchr/testify/mock"
109 "github.com/stretchr/testify/require"
@@ -533,20 +532,20 @@ func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) {
533532
534533 func TestPaletteTabCompletesSelectedCommandWithoutForwardingToPTY(t *testing.T) {
535534 tests := []struct {
536- name string
537- config map[string]string
538- recent string
539- query string
540- want string
541- wantRepaint bool
542- setupRepaints int
535+ name string
536+ config map[string]string
537+ recent string
538+ query string
539+ want string
540+ wantCompletionInvalidation bool
541+ setupInvalidations int
543542 }{
544- {name: "changed completion repaints once", query: "NX", want: "NXT", wantRepaint: true},
545- {name: "exact argument command is a no-op", query: "JRS 1", want: "JRS 1", wantRepaint: false},
546- {name: "empty query starts at registry first", want: "CNT", wantRepaint: true},
547- {name: "empty query starts at daemon MRU", recent: "NXT", want: "NXT", wantRepaint: true},
548- {name: "effective configured override completes", config: map[string]string{"new-tab": "NEW"}, want: "NEW", wantRepaint: true, setupRepaints: 1},
549- {name: "stale pre-override MRU is ignored", config: map[string]string{"new-tab": "NEW"}, recent: "CNT", want: "NEW", wantRepaint: true, setupRepaints: 1},
543+ {name: "changed completion invalidates once", query: "NX", want: "NXT", wantCompletionInvalidation: true},
544+ {name: "exact argument command is a no-op", query: "JRS 1", want: "JRS 1"},
545+ {name: "empty query starts at registry first", want: "CNT", wantCompletionInvalidation: true},
546+ {name: "empty query starts at daemon MRU", recent: "NXT", want: "NXT", wantCompletionInvalidation: true},
547+ {name: "effective configured override completes", config: map[string]string{"new-tab": "NEW"}, want: "NEW", wantCompletionInvalidation: true, setupInvalidations: 1},
548+ {name: "stale pre-override MRU is ignored", config: map[string]string{"new-tab": "NEW"}, recent: "CNT", want: "NEW", wantCompletionInvalidation: true, setupInvalidations: 1},
550549 }
551550
552551 for _, tt := range tests {
@@ -555,21 +554,25 @@ func TestPaletteTabCompletesSelectedCommandWithoutForwardingToPTY(t *testing.T)
555554 p, release := newBlockingPTYWithWrites(t, writes)
556555 defer release()
557556 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
557+ invs := installPaletteInvalidationObserver(sess)
558558 if tt.recent != "" {
559559 d.recordPaletteUse(tt.recent)
560560 }
561561 if tt.config != nil {
562562 d.ApplyConfig(domain.Config{Codes: tt.config})
563- for range tt.setupRepaints {
564- awaitFrame(t, sends, ports.MsgOutput)
563+ for range tt.setupInvalidations {
564+ awaitInvalidation(t, invs)
565565 }
566+ requireNoInvalidation(t, invs)
566567 }
567568
568569 d.handleInput(sess, ac, []byte("\x1b "))
569- awaitFrame(t, sends, ports.MsgOutput) // drain palette open repaint
570+ awaitInvalidation(t, invs)
571+ requireNoInvalidation(t, invs)
570572 if tt.query != "" {
571573 d.handleInput(sess, ac, []byte(tt.query))
572- awaitFrame(t, sends, ports.MsgOutput) // drain typed-query repaint
574+ awaitInvalidation(t, invs)
575+ requireNoInvalidation(t, invs)
573576 }
574577
575578 d.handleInput(sess, ac, []byte("\t"))
@@ -577,10 +580,11 @@ func TestPaletteTabCompletesSelectedCommandWithoutForwardingToPTY(t *testing.T)
577580 got := ac.overlays.palette.Query()
578581 ac.overlays.paletteMu.Unlock()
579582 require.Equal(t, tt.want, got)
580- if tt.wantRepaint {
581- awaitFrame(t, sends, ports.MsgOutput)
583+ if tt.wantCompletionInvalidation {
584+ awaitInvalidation(t, invs)
582585 }
583- requireNoPaletteFrame(t, sends)
586+ requireNoInvalidation(t, invs)
587+ requireNoOutputFrame(t, sends)
584588 requireNoPTYWrite(t, writes)
585589 })
586590 }
@@ -591,27 +595,33 @@ func TestPaletteBatchedTabCompletionPreservesRepaintInvalidation(t *testing.T) {
591595 p, release := newBlockingPTYWithWrites(t, writes)
592596 defer release()
593597 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
598+ invs := installPaletteInvalidationObserver(sess)
594599
595600 d.handleInput(sess, ac, []byte("\x1b "))
596- awaitFrame(t, sends, ports.MsgOutput) // drain palette-open repaint
601+ awaitInvalidation(t, invs)
602+ requireNoInvalidation(t, invs)
597603
598604 d.handleInput(sess, ac, []byte("NX\t\t"))
599605 ac.overlays.paletteMu.Lock()
600606 got := ac.overlays.palette.Query()
601607 ac.overlays.paletteMu.Unlock()
602608 require.Equal(t, "NXT", got)
603- awaitFrame(t, sends, ports.MsgOutput)
604- requireNoPaletteFrame(t, sends)
609+ awaitInvalidation(t, invs)
610+ requireNoInvalidation(t, invs)
611+ requireNoOutputFrame(t, sends)
605612 requireNoPTYWrite(t, writes)
606613 }
607614
608-func requireNoPaletteFrame(t *testing.T, sends <-chan ports.Frame) {
609- t.Helper()
610- select {
611- case frame := <-sends:
612- t.Fatalf("unexpected frame after no-op Tab: %#v", frame)
613- case <-time.After(50 * time.Millisecond):
614- }
615+func installPaletteInvalidationObserver(sess *session) chan renderInvalidation {
616+ invs := make(chan renderInvalidation, 8)
617+ sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
618+ clock: nil,
619+ wake: nil,
620+ onInvalidate: func(inv renderInvalidation) {
621+ invs <- inv
622+ },
623+ }))
624+ return invs
615625 }
616626
617627 func TestPaletteExecMethods(t *testing.T) {
@@ -4,7 +4,6 @@ import (
4 "errors"4 "errors"
5 "sync"5 "sync"
6 "testing"6 "testing"
7- "time"
8 7
9 "github.com/stretchr/testify/mock"8 "github.com/stretchr/testify/mock"
10 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
@@ -533,20 +532,20 @@ func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) {
533 532
534 func TestPaletteTabCompletesSelectedCommandWithoutForwardingToPTY(t *testing.T) {533 func TestPaletteTabCompletesSelectedCommandWithoutForwardingToPTY(t *testing.T) {
535 tests := []struct {534 tests := []struct {
536- name string535+ name string
537- config map[string]string536+ config map[string]string
538- recent string537+ recent string
539- query string538+ query string
540- want string539+ want string
541- wantRepaint bool540+ wantCompletionInvalidation bool
542- setupRepaints int541+ setupInvalidations int
543 }{542 }{
544- {name: "changed completion repaints once", query: "NX", want: "NXT", wantRepaint: true},543+ {name: "changed completion invalidates once", query: "NX", want: "NXT", wantCompletionInvalidation: true},
545- {name: "exact argument command is a no-op", query: "JRS 1", want: "JRS 1", wantRepaint: false},544+ {name: "exact argument command is a no-op", query: "JRS 1", want: "JRS 1"},
546- {name: "empty query starts at registry first", want: "CNT", wantRepaint: true},545+ {name: "empty query starts at registry first", want: "CNT", wantCompletionInvalidation: true},
547- {name: "empty query starts at daemon MRU", recent: "NXT", want: "NXT", wantRepaint: true},546+ {name: "empty query starts at daemon MRU", recent: "NXT", want: "NXT", wantCompletionInvalidation: true},
548- {name: "effective configured override completes", config: map[string]string{"new-tab": "NEW"}, want: "NEW", wantRepaint: true, setupRepaints: 1},547+ {name: "effective configured override completes", config: map[string]string{"new-tab": "NEW"}, want: "NEW", wantCompletionInvalidation: true, setupInvalidations: 1},
549- {name: "stale pre-override MRU is ignored", config: map[string]string{"new-tab": "NEW"}, recent: "CNT", want: "NEW", wantRepaint: true, setupRepaints: 1},548+ {name: "stale pre-override MRU is ignored", config: map[string]string{"new-tab": "NEW"}, recent: "CNT", want: "NEW", wantCompletionInvalidation: true, setupInvalidations: 1},
550 }549 }
551 550
552 for _, tt := range tests {551 for _, tt := range tests {
@@ -555,21 +554,25 @@ func TestPaletteTabCompletesSelectedCommandWithoutForwardingToPTY(t *testing.T)
555 p, release := newBlockingPTYWithWrites(t, writes)554 p, release := newBlockingPTYWithWrites(t, writes)
556 defer release()555 defer release()
557 d, sess, ac, sends := newManualSessionWithPTYs(t, p)556 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
557+ invs := installPaletteInvalidationObserver(sess)
558 if tt.recent != "" {558 if tt.recent != "" {
559 d.recordPaletteUse(tt.recent)559 d.recordPaletteUse(tt.recent)
560 }560 }
561 if tt.config != nil {561 if tt.config != nil {
562 d.ApplyConfig(domain.Config{Codes: tt.config})562 d.ApplyConfig(domain.Config{Codes: tt.config})
563- for range tt.setupRepaints {563+ for range tt.setupInvalidations {
564- awaitFrame(t, sends, ports.MsgOutput)564+ awaitInvalidation(t, invs)
565 }565 }
566+ requireNoInvalidation(t, invs)
566 }567 }
567 568
568 d.handleInput(sess, ac, []byte("\x1b "))569 d.handleInput(sess, ac, []byte("\x1b "))
569- awaitFrame(t, sends, ports.MsgOutput) // drain palette open repaint570+ awaitInvalidation(t, invs)
571+ requireNoInvalidation(t, invs)
570 if tt.query != "" {572 if tt.query != "" {
571 d.handleInput(sess, ac, []byte(tt.query))573 d.handleInput(sess, ac, []byte(tt.query))
572- awaitFrame(t, sends, ports.MsgOutput) // drain typed-query repaint574+ awaitInvalidation(t, invs)
575+ requireNoInvalidation(t, invs)
573 }576 }
574 577
575 d.handleInput(sess, ac, []byte("\t"))578 d.handleInput(sess, ac, []byte("\t"))
@@ -577,10 +580,11 @@ func TestPaletteTabCompletesSelectedCommandWithoutForwardingToPTY(t *testing.T)
577 got := ac.overlays.palette.Query()580 got := ac.overlays.palette.Query()
578 ac.overlays.paletteMu.Unlock()581 ac.overlays.paletteMu.Unlock()
579 require.Equal(t, tt.want, got)582 require.Equal(t, tt.want, got)
580- if tt.wantRepaint {583+ if tt.wantCompletionInvalidation {
581- awaitFrame(t, sends, ports.MsgOutput)584+ awaitInvalidation(t, invs)
582 }585 }
583- requireNoPaletteFrame(t, sends)586+ requireNoInvalidation(t, invs)
587+ requireNoOutputFrame(t, sends)
584 requireNoPTYWrite(t, writes)588 requireNoPTYWrite(t, writes)
585 })589 })
586 }590 }
@@ -591,27 +595,33 @@ func TestPaletteBatchedTabCompletionPreservesRepaintInvalidation(t *testing.T) {
591 p, release := newBlockingPTYWithWrites(t, writes)595 p, release := newBlockingPTYWithWrites(t, writes)
592 defer release()596 defer release()
593 d, sess, ac, sends := newManualSessionWithPTYs(t, p)597 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
598+ invs := installPaletteInvalidationObserver(sess)
594 599
595 d.handleInput(sess, ac, []byte("\x1b "))600 d.handleInput(sess, ac, []byte("\x1b "))
596- awaitFrame(t, sends, ports.MsgOutput) // drain palette-open repaint601+ awaitInvalidation(t, invs)
602+ requireNoInvalidation(t, invs)
597 603
598 d.handleInput(sess, ac, []byte("NX\t\t"))604 d.handleInput(sess, ac, []byte("NX\t\t"))
599 ac.overlays.paletteMu.Lock()605 ac.overlays.paletteMu.Lock()
600 got := ac.overlays.palette.Query()606 got := ac.overlays.palette.Query()
601 ac.overlays.paletteMu.Unlock()607 ac.overlays.paletteMu.Unlock()
602 require.Equal(t, "NXT", got)608 require.Equal(t, "NXT", got)
603- awaitFrame(t, sends, ports.MsgOutput)609+ awaitInvalidation(t, invs)
604- requireNoPaletteFrame(t, sends)610+ requireNoInvalidation(t, invs)
611+ requireNoOutputFrame(t, sends)
605 requireNoPTYWrite(t, writes)612 requireNoPTYWrite(t, writes)
606 }613 }
607 614
608-func requireNoPaletteFrame(t *testing.T, sends <-chan ports.Frame) {615+func installPaletteInvalidationObserver(sess *session) chan renderInvalidation {
609- t.Helper()616+ invs := make(chan renderInvalidation, 8)
610- select {617+ sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
611- case frame := <-sends:618+ clock: nil,
612- t.Fatalf("unexpected frame after no-op Tab: %#v", frame)619+ wake: nil,
613- case <-time.After(50 * time.Millisecond):620+ onInvalidate: func(inv renderInvalidation) {
614- }621+ invs <- inv
622+ },
623+ }))
624+ return invs
615 }625 }
616 626
617 func TestPaletteExecMethods(t *testing.T) {627 func TestPaletteExecMethods(t *testing.T) {
M internal/usecase/daemon/picker_test.go +63 -7
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "context"
5+ "runtime"
56 "strings"
67 "testing"
78 "time"
@@ -637,14 +638,15 @@ func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testin
637638 }
638639 }
639640
640-func TestPickerPreviewDoesNotCloneScrollback(t *testing.T) {
641- tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
642- p := tb.focusedPane()
643- p.screen.Write([]byte(strings.Repeat("history-only-marker\n", 10_000)))
644- p.screen.Write([]byte("\rNOW"))
645-
646- preview := snapshotPickerPreview(tb)
641+func TestPickerPreviewAllocationsAreIndependentOfTenThousandScrollbackRows(t *testing.T) {
642+ empty := newPickerPreviewTabWithHistoryRows(0)
643+ withScrollback := newPickerPreviewTabWithHistoryRows(10_000)
644+ require.Zero(t, empty.focusedPane().screen.History().Len())
645+ require.Equal(t, 10_000, withScrollback.focusedPane().screen.History().Len())
647646
647+ preview := snapshotPickerPreview(withScrollback)
648+ emptyPreview := snapshotPickerPreview(empty)
649+ require.Equal(t, emptyPreview, preview, "identical visible tabs must produce identical previews")
648650 require.Equal(t, 10, preview.Width)
649651 require.Equal(t, 3, preview.Height)
650652 require.Len(t, preview.Rows, preview.Height)
@@ -663,6 +665,60 @@ func TestPickerPreviewDoesNotCloneScrollback(t *testing.T) {
663665 }(), "\n")
664666 require.Contains(t, got, "NOW")
665667 require.NotContains(t, got, "history-only-marker")
668+
669+ emptyResult := benchmarkPickerPreview(empty, snapshotPickerPreview)
670+ scrollbackResult := benchmarkPickerPreview(withScrollback, snapshotPickerPreview)
671+ require.Equal(t, emptyResult.AllocsPerOp(), scrollbackResult.AllocsPerOp(), "preview allocations must not depend on retained scrollback")
672+ require.Equal(t, emptyResult.AllocedBytesPerOp(), scrollbackResult.AllocedBytesPerOp(), "preview allocated bytes must not depend on retained scrollback")
673+
674+ // The local seam deliberately clones every history row. Its larger result
675+ // demonstrates that the allocation assertion would fail if preview capture
676+ // regressed to copying scrollback, without changing production code to make
677+ // this test red.
678+ cloningEmptyResult := benchmarkPickerPreview(empty, snapshotPickerPreviewWithClonedHistoryForTest)
679+ cloningScrollbackResult := benchmarkPickerPreview(withScrollback, snapshotPickerPreviewWithClonedHistoryForTest)
680+ require.Greater(t, cloningScrollbackResult.AllocsPerOp(), cloningEmptyResult.AllocsPerOp())
681+ require.Greater(t, cloningScrollbackResult.AllocedBytesPerOp(), cloningEmptyResult.AllocedBytesPerOp())
682+}
683+
684+func newPickerPreviewTabWithHistoryRows(historyRows int) *tab {
685+ tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
686+ p := tb.focusedPane()
687+ for range historyRows {
688+ p.screen.History().Append(testRow("history-only-marker"))
689+ }
690+ p.screen.Write([]byte("NOW"))
691+ return tb
692+}
693+
694+func benchmarkPickerPreview(tb *tab, snapshot func(*tab) picker.Preview) testing.BenchmarkResult {
695+ return testing.Benchmark(func(b *testing.B) {
696+ b.ReportAllocs()
697+ for range b.N {
698+ runtime.KeepAlive(snapshot(tb))
699+ }
700+ })
701+}
702+
703+func snapshotPickerPreviewWithClonedHistoryForTest(tb *tab) picker.Preview {
704+ tb.mu.Lock()
705+ p := tb.focusedPane()
706+ if p == nil {
707+ tb.mu.Unlock()
708+ return picker.Preview{}
709+ }
710+ p.mu.Lock()
711+ history := p.screen.History().View()
712+ p.mu.Unlock()
713+ tb.mu.Unlock()
714+
715+ var cloned [][]renderer.Cell
716+ history.Range(func(row []renderer.Cell) bool {
717+ cloned = append(cloned, append([]renderer.Cell(nil), row...))
718+ return true
719+ })
720+ runtime.KeepAlive(cloned)
721+ return snapshotPickerPreview(tb)
666722 }
667723
668724 func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "runtime"
5 "strings"6 "strings"
6 "testing"7 "testing"
7 "time"8 "time"
@@ -637,14 +638,15 @@ func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testin
637 }638 }
638 }639 }
639 640
640-func TestPickerPreviewDoesNotCloneScrollback(t *testing.T) {641+func TestPickerPreviewAllocationsAreIndependentOfTenThousandScrollbackRows(t *testing.T) {
641- tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})642+ empty := newPickerPreviewTabWithHistoryRows(0)
642- p := tb.focusedPane()643+ withScrollback := newPickerPreviewTabWithHistoryRows(10_000)
643- p.screen.Write([]byte(strings.Repeat("history-only-marker\n", 10_000)))644+ require.Zero(t, empty.focusedPane().screen.History().Len())
644- p.screen.Write([]byte("\rNOW"))645+ require.Equal(t, 10_000, withScrollback.focusedPane().screen.History().Len())
645-
646- preview := snapshotPickerPreview(tb)
647 646
647+ preview := snapshotPickerPreview(withScrollback)
648+ emptyPreview := snapshotPickerPreview(empty)
649+ require.Equal(t, emptyPreview, preview, "identical visible tabs must produce identical previews")
648 require.Equal(t, 10, preview.Width)650 require.Equal(t, 10, preview.Width)
649 require.Equal(t, 3, preview.Height)651 require.Equal(t, 3, preview.Height)
650 require.Len(t, preview.Rows, preview.Height)652 require.Len(t, preview.Rows, preview.Height)
@@ -663,6 +665,60 @@ func TestPickerPreviewDoesNotCloneScrollback(t *testing.T) {
663 }(), "\n")665 }(), "\n")
664 require.Contains(t, got, "NOW")666 require.Contains(t, got, "NOW")
665 require.NotContains(t, got, "history-only-marker")667 require.NotContains(t, got, "history-only-marker")
668+
669+ emptyResult := benchmarkPickerPreview(empty, snapshotPickerPreview)
670+ scrollbackResult := benchmarkPickerPreview(withScrollback, snapshotPickerPreview)
671+ require.Equal(t, emptyResult.AllocsPerOp(), scrollbackResult.AllocsPerOp(), "preview allocations must not depend on retained scrollback")
672+ require.Equal(t, emptyResult.AllocedBytesPerOp(), scrollbackResult.AllocedBytesPerOp(), "preview allocated bytes must not depend on retained scrollback")
673+
674+ // The local seam deliberately clones every history row. Its larger result
675+ // demonstrates that the allocation assertion would fail if preview capture
676+ // regressed to copying scrollback, without changing production code to make
677+ // this test red.
678+ cloningEmptyResult := benchmarkPickerPreview(empty, snapshotPickerPreviewWithClonedHistoryForTest)
679+ cloningScrollbackResult := benchmarkPickerPreview(withScrollback, snapshotPickerPreviewWithClonedHistoryForTest)
680+ require.Greater(t, cloningScrollbackResult.AllocsPerOp(), cloningEmptyResult.AllocsPerOp())
681+ require.Greater(t, cloningScrollbackResult.AllocedBytesPerOp(), cloningEmptyResult.AllocedBytesPerOp())
682+}
683+
684+func newPickerPreviewTabWithHistoryRows(historyRows int) *tab {
685+ tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
686+ p := tb.focusedPane()
687+ for range historyRows {
688+ p.screen.History().Append(testRow("history-only-marker"))
689+ }
690+ p.screen.Write([]byte("NOW"))
691+ return tb
692+}
693+
694+func benchmarkPickerPreview(tb *tab, snapshot func(*tab) picker.Preview) testing.BenchmarkResult {
695+ return testing.Benchmark(func(b *testing.B) {
696+ b.ReportAllocs()
697+ for range b.N {
698+ runtime.KeepAlive(snapshot(tb))
699+ }
700+ })
701+}
702+
703+func snapshotPickerPreviewWithClonedHistoryForTest(tb *tab) picker.Preview {
704+ tb.mu.Lock()
705+ p := tb.focusedPane()
706+ if p == nil {
707+ tb.mu.Unlock()
708+ return picker.Preview{}
709+ }
710+ p.mu.Lock()
711+ history := p.screen.History().View()
712+ p.mu.Unlock()
713+ tb.mu.Unlock()
714+
715+ var cloned [][]renderer.Cell
716+ history.Range(func(row []renderer.Cell) bool {
717+ cloned = append(cloned, append([]renderer.Cell(nil), row...))
718+ return true
719+ })
720+ runtime.KeepAlive(cloned)
721+ return snapshotPickerPreview(tb)
666 }722 }
667 723
668 func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {724 func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {

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