detached 2274b26

feat(client): track palette responses with coalesced theme sends

commit
2274b2669bfb0e6eb3aedd3f5d93040271efef39
author
brice · · 2026-07-17 08:27 UTC
parent
75e21f8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +196 -16
M internal/usecase/client/client.go +47 -16
@@ -71,11 +71,24 @@ func (s *terminalThemeState) update(update func(*ports.Theme)) ports.Theme {
7171 return s.theme
7272 }
7373
74-func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) {
74+func (s *terminalThemeState) snapshot() ports.Theme {
7575 s.mu.Lock()
7676 defer s.mu.Unlock()
77- theme := s.theme
78- return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown
77+ return s.theme
78+}
79+
80+// clearPalette invalidates entries from a previous terminal color query while
81+// retaining independently reported foreground, background, and scheme data.
82+func (s *terminalThemeState) clearPalette() ports.Theme {
83+ return s.update(func(theme *ports.Theme) {
84+ theme.PaletteKnown = 0
85+ theme.Palette = [16]renderer.RGB{}
86+ })
87+}
88+
89+func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) {
90+ theme := s.snapshot()
91+ return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown || theme.PaletteKnown != 0
7992 }
8093
8194 // milestones tracks lifecycle progress so a failure can report which stages
@@ -646,6 +659,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
646659 return welcomedResult(errLinkOffline)
647660 }
648661 case <-colorQueryCh:
662+ // A fresh query supersedes every prior OSC 4 response. Queue the
663+ // cleared snapshot before writing the query so the daemon never
664+ // applies stale palette entries while it awaits the new responses.
665+ clearedTheme := themeState.clearPalette()
666+ select {
667+ case sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(clearedTheme)}:
668+ case <-loopCtx.Done():
669+ return welcomedResult(nil)
670+ }
649671 if err := term.QueryColors(); err != nil {
650672 log.Warn("querying terminal colors", "err", err)
651673 }
@@ -876,11 +898,10 @@ func (p *stdinPump) run() {
876898 for {
877899 n, rerr := in.Read(buf)
878900 if n > 0 {
879- sendTheme := func(current ports.Theme) {
880- send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
881- }
901+ themeChanged := false
902+ queryColors := false
882903 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
883- current := themeState.update(func(current *ports.Theme) {
904+ themeState.update(func(current *ports.Theme) {
884905 switch kind {
885906 case 10:
886907 current.HasForeground = true
@@ -890,20 +911,30 @@ func (p *stdinPump) run() {
890911 current.Background = rgb
891912 }
892913 })
893- sendTheme(current)
894- }, func(int, renderer.RGB) {
895- // Palette inheritance is wired by the theme update path; retain a
896- // no-op callback here until that path owns palette updates.
914+ themeChanged = true
915+ }, func(slot int, rgb renderer.RGB) {
916+ themeState.update(func(current *ports.Theme) {
917+ current.PaletteKnown |= uint16(1) << slot
918+ current.Palette[slot] = rgb
919+ })
920+ themeChanged = true
897921 }, func(light bool) {
898- current := themeState.update(func(current *ports.Theme) {
922+ themeState.update(func(current *ports.Theme) {
899923 current.SchemeKnown = true
900924 current.Light = light
901925 })
902- if requestColors != nil {
903- requestColors()
904- }
905- sendTheme(current)
926+ themeChanged = true
927+ queryColors = true
906928 }, sink)
929+ if themeChanged {
930+ send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(themeState.snapshot())})
931+ }
932+ // Defer the re-query until this read's complete snapshot is queued;
933+ // otherwise the main loop could invalidate palette state midway
934+ // through a chunk containing multiple terminal responses.
935+ if queryColors && requestColors != nil {
936+ requestColors()
937+ }
907938 if !sendOK.Load() {
908939 return
909940 }
@@ -71,11 +71,24 @@ func (s *terminalThemeState) update(update func(*ports.Theme)) ports.Theme {
71 return s.theme71 return s.theme
72 }72 }
73 73
74-func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) {74+func (s *terminalThemeState) snapshot() ports.Theme {
75 s.mu.Lock()75 s.mu.Lock()
76 defer s.mu.Unlock()76 defer s.mu.Unlock()
77- theme := s.theme77+ return s.theme
78- return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown78+}
79+
80+// clearPalette invalidates entries from a previous terminal color query while
81+// retaining independently reported foreground, background, and scheme data.
82+func (s *terminalThemeState) clearPalette() ports.Theme {
83+ return s.update(func(theme *ports.Theme) {
84+ theme.PaletteKnown = 0
85+ theme.Palette = [16]renderer.RGB{}
86+ })
87+}
88+
89+func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) {
90+ theme := s.snapshot()
91+ return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown || theme.PaletteKnown != 0
79 }92 }
80 93
81 // milestones tracks lifecycle progress so a failure can report which stages94 // milestones tracks lifecycle progress so a failure can report which stages
@@ -646,6 +659,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
646 return welcomedResult(errLinkOffline)659 return welcomedResult(errLinkOffline)
647 }660 }
648 case <-colorQueryCh:661 case <-colorQueryCh:
662+ // A fresh query supersedes every prior OSC 4 response. Queue the
663+ // cleared snapshot before writing the query so the daemon never
664+ // applies stale palette entries while it awaits the new responses.
665+ clearedTheme := themeState.clearPalette()
666+ select {
667+ case sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(clearedTheme)}:
668+ case <-loopCtx.Done():
669+ return welcomedResult(nil)
670+ }
649 if err := term.QueryColors(); err != nil {671 if err := term.QueryColors(); err != nil {
650 log.Warn("querying terminal colors", "err", err)672 log.Warn("querying terminal colors", "err", err)
651 }673 }
@@ -876,11 +898,10 @@ func (p *stdinPump) run() {
876 for {898 for {
877 n, rerr := in.Read(buf)899 n, rerr := in.Read(buf)
878 if n > 0 {900 if n > 0 {
879- sendTheme := func(current ports.Theme) {901+ themeChanged := false
880- send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})902+ queryColors := false
881- }
882 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {903 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
883- current := themeState.update(func(current *ports.Theme) {904+ themeState.update(func(current *ports.Theme) {
884 switch kind {905 switch kind {
885 case 10:906 case 10:
886 current.HasForeground = true907 current.HasForeground = true
@@ -890,20 +911,30 @@ func (p *stdinPump) run() {
890 current.Background = rgb911 current.Background = rgb
891 }912 }
892 })913 })
893- sendTheme(current)914+ themeChanged = true
894- }, func(int, renderer.RGB) {915+ }, func(slot int, rgb renderer.RGB) {
895- // Palette inheritance is wired by the theme update path; retain a916+ themeState.update(func(current *ports.Theme) {
896- // no-op callback here until that path owns palette updates.917+ current.PaletteKnown |= uint16(1) << slot
918+ current.Palette[slot] = rgb
919+ })
920+ themeChanged = true
897 }, func(light bool) {921 }, func(light bool) {
898- current := themeState.update(func(current *ports.Theme) {922+ themeState.update(func(current *ports.Theme) {
899 current.SchemeKnown = true923 current.SchemeKnown = true
900 current.Light = light924 current.Light = light
901 })925 })
902- if requestColors != nil {926+ themeChanged = true
903- requestColors()927+ queryColors = true
904- }
905- sendTheme(current)
906 }, sink)928 }, sink)
929+ if themeChanged {
930+ send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(themeState.snapshot())})
931+ }
932+ // Defer the re-query until this read's complete snapshot is queued;
933+ // otherwise the main loop could invalidate palette state midway
934+ // through a chunk containing multiple terminal responses.
935+ if queryColors && requestColors != nil {
936+ requestColors()
937+ }
907 if !sendOK.Load() {938 if !sendOK.Load() {
908 return939 return
909 }940 }
M internal/usecase/client/client_test.go +72 -0
@@ -1047,3 +1047,75 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
10471047 require.Equal(t, int32(1), d.calls.Load())
10481048 require.Equal(t, int32(1), term.restoreCount.Load())
10491049 }
1050+
1051+func TestAttachSchemeRequeryClearsStalePalette(t *testing.T) {
1052+ var out bytes.Buffer
1053+ var restoreCount atomic.Int32
1054+ resizeCh := make(chan domain.Size)
1055+ input := newChunkedBlockingReader(
1056+ []byte("\x1b]11;#010203\a\x1b]4;1;#112233\a\x1b]4;14;#778899\a"),
1057+ []byte("\x1b[?997;2n"),
1058+ )
1059+ defer input.unblock()
1060+
1061+ tm := portsmocks.NewMockTerminal(t)
1062+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1063+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
1064+ tm.EXPECT().In().Return(input).Maybe()
1065+ tm.EXPECT().Out().Return(&out).Maybe()
1066+ tm.EXPECT().Flush().Return(nil).Maybe()
1067+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
1068+ tm.EXPECT().QueryColors().Return(nil).Once()
1069+
1070+ var themes []ports.Theme
1071+ var themesMu sync.Mutex
1072+ clearedPalette := make(chan struct{})
1073+ var clearOnce sync.Once
1074+ tr := portsmocks.NewMockTransport(t)
1075+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
1076+ tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
1077+ theme, err := ports.UnmarshalTheme(f.Payload)
1078+ require.NoError(t, err)
1079+ themesMu.Lock()
1080+ themes = append(themes, theme)
1081+ themesMu.Unlock()
1082+ if theme.SchemeKnown && theme.PaletteKnown == 0 {
1083+ clearOnce.Do(func() { close(clearedPalette) })
1084+ }
1085+ return nil
1086+ }).Times(3)
1087+
1088+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
1089+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
1090+ closed := make(chan struct{})
1091+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
1092+ select {
1093+ case <-closed:
1094+ return ports.Frame{}, io.EOF
1095+ default:
1096+ }
1097+ select {
1098+ case <-clearedPalette:
1099+ close(closed)
1100+ return detached, nil
1101+ default:
1102+ return welcome, nil
1103+ }
1104+ }).Maybe()
1105+ tr.EXPECT().Close().Return(nil).Once()
1106+
1107+ require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))
1108+ require.Equal(t, int32(1), restoreCount.Load())
1109+
1110+ themesMu.Lock()
1111+ defer themesMu.Unlock()
1112+ require.Len(t, themes, 3, "one complete snapshot per input chunk plus the re-query invalidation")
1113+ require.Equal(t, uint16(1<<1|1<<14), themes[0].PaletteKnown)
1114+ require.True(t, themes[0].HasBackground)
1115+ require.Equal(t, uint16(1<<1|1<<14), themes[1].PaletteKnown, "scheme update retains palette until re-query")
1116+ require.True(t, themes[1].SchemeKnown)
1117+ require.Zero(t, themes[2].PaletteKnown, "re-query must invalidate stale palette entries")
1118+ require.Equal(t, themes[0].Background, themes[2].Background)
1119+ require.True(t, themes[2].SchemeKnown)
1120+ require.True(t, themes[2].Light)
1121+}
@@ -1047,3 +1047,75 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
1047 require.Equal(t, int32(1), d.calls.Load())1047 require.Equal(t, int32(1), d.calls.Load())
1048 require.Equal(t, int32(1), term.restoreCount.Load())1048 require.Equal(t, int32(1), term.restoreCount.Load())
1049 }1049 }
1050+
1051+func TestAttachSchemeRequeryClearsStalePalette(t *testing.T) {
1052+ var out bytes.Buffer
1053+ var restoreCount atomic.Int32
1054+ resizeCh := make(chan domain.Size)
1055+ input := newChunkedBlockingReader(
1056+ []byte("\x1b]11;#010203\a\x1b]4;1;#112233\a\x1b]4;14;#778899\a"),
1057+ []byte("\x1b[?997;2n"),
1058+ )
1059+ defer input.unblock()
1060+
1061+ tm := portsmocks.NewMockTerminal(t)
1062+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1063+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
1064+ tm.EXPECT().In().Return(input).Maybe()
1065+ tm.EXPECT().Out().Return(&out).Maybe()
1066+ tm.EXPECT().Flush().Return(nil).Maybe()
1067+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
1068+ tm.EXPECT().QueryColors().Return(nil).Once()
1069+
1070+ var themes []ports.Theme
1071+ var themesMu sync.Mutex
1072+ clearedPalette := make(chan struct{})
1073+ var clearOnce sync.Once
1074+ tr := portsmocks.NewMockTransport(t)
1075+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
1076+ tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
1077+ theme, err := ports.UnmarshalTheme(f.Payload)
1078+ require.NoError(t, err)
1079+ themesMu.Lock()
1080+ themes = append(themes, theme)
1081+ themesMu.Unlock()
1082+ if theme.SchemeKnown && theme.PaletteKnown == 0 {
1083+ clearOnce.Do(func() { close(clearedPalette) })
1084+ }
1085+ return nil
1086+ }).Times(3)
1087+
1088+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
1089+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
1090+ closed := make(chan struct{})
1091+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
1092+ select {
1093+ case <-closed:
1094+ return ports.Frame{}, io.EOF
1095+ default:
1096+ }
1097+ select {
1098+ case <-clearedPalette:
1099+ close(closed)
1100+ return detached, nil
1101+ default:
1102+ return welcome, nil
1103+ }
1104+ }).Maybe()
1105+ tr.EXPECT().Close().Return(nil).Once()
1106+
1107+ require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))
1108+ require.Equal(t, int32(1), restoreCount.Load())
1109+
1110+ themesMu.Lock()
1111+ defer themesMu.Unlock()
1112+ require.Len(t, themes, 3, "one complete snapshot per input chunk plus the re-query invalidation")
1113+ require.Equal(t, uint16(1<<1|1<<14), themes[0].PaletteKnown)
1114+ require.True(t, themes[0].HasBackground)
1115+ require.Equal(t, uint16(1<<1|1<<14), themes[1].PaletteKnown, "scheme update retains palette until re-query")
1116+ require.True(t, themes[1].SchemeKnown)
1117+ require.Zero(t, themes[2].PaletteKnown, "re-query must invalidate stale palette entries")
1118+ require.Equal(t, themes[0].Background, themes[2].Background)
1119+ require.True(t, themes[2].SchemeKnown)
1120+ require.True(t, themes[2].Light)
1121+}
A internal/usecase/client/theme_state_test.go +77 -0
@@ -0,0 +1,77 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "log/slog"
8+ "testing"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/pkg/renderer"
14+)
15+
16+func TestTerminalThemeStateClearPalettePreservesReportedColors(t *testing.T) {
17+ state := &terminalThemeState{}
18+ state.update(func(theme *ports.Theme) {
19+ theme.HasForeground = true
20+ theme.Foreground = renderer.RGB{R: 1, G: 2, B: 3}
21+ theme.HasBackground = true
22+ theme.Background = renderer.RGB{R: 4, G: 5, B: 6}
23+ theme.SchemeKnown = true
24+ theme.Light = true
25+ theme.PaletteKnown = 1<<1 | 1<<14
26+ theme.Palette[1] = renderer.RGB{R: 7, G: 8, B: 9}
27+ theme.Palette[14] = renderer.RGB{R: 10, G: 11, B: 12}
28+ })
29+
30+ got := state.clearPalette()
31+ require.True(t, got.HasForeground)
32+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, got.Foreground)
33+ require.True(t, got.HasBackground)
34+ require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, got.Background)
35+ require.True(t, got.SchemeKnown)
36+ require.True(t, got.Light)
37+ require.Zero(t, got.PaletteKnown)
38+ require.Equal(t, [16]renderer.RGB{}, got.Palette)
39+
40+ _, reported := state.reportedTheme()
41+ require.True(t, reported, "foreground/background remain usable after palette invalidation")
42+}
43+
44+func TestStdinPumpCoalescesPaletteAndBackgroundThemePerRead(t *testing.T) {
45+ state := &terminalThemeState{}
46+ ctx, cancel := context.WithCancel(context.Background())
47+ defer cancel()
48+ out := make(chan ports.Frame, 3)
49+ pump := stdinPump{
50+ ctx: ctx,
51+ cancel: cancel,
52+ in: bytes.NewReader([]byte("\x1b]4;1;#112233\a\x1b]11;rgb:0404/0505/0606\a\x1b]4;14;#778899\a")),
53+ out: out,
54+ themeState: state,
55+ logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
56+ }
57+
58+ pump.run()
59+
60+ first := <-out
61+ require.Equal(t, ports.MsgTheme, first.Type)
62+ theme, err := ports.UnmarshalTheme(first.Payload)
63+ require.NoError(t, err)
64+ require.True(t, theme.HasBackground)
65+ require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, theme.Background)
66+ require.Equal(t, uint16(1<<1|1<<14), theme.PaletteKnown)
67+ require.Equal(t, renderer.RGB{R: 0x11, G: 0x22, B: 0x33}, theme.Palette[1])
68+ require.Equal(t, renderer.RGB{R: 0x77, G: 0x88, B: 0x99}, theme.Palette[14])
69+
70+ second := <-out
71+ require.Equal(t, ports.MsgDetach, second.Type)
72+ select {
73+ case extra := <-out:
74+ t.Fatalf("got extra frame after one input chunk: %v", extra.Type)
75+ default:
76+ }
77+}
@@ -0,0 +1,77 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "log/slog"
8+ "testing"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/pkg/renderer"
14+)
15+
16+func TestTerminalThemeStateClearPalettePreservesReportedColors(t *testing.T) {
17+ state := &terminalThemeState{}
18+ state.update(func(theme *ports.Theme) {
19+ theme.HasForeground = true
20+ theme.Foreground = renderer.RGB{R: 1, G: 2, B: 3}
21+ theme.HasBackground = true
22+ theme.Background = renderer.RGB{R: 4, G: 5, B: 6}
23+ theme.SchemeKnown = true
24+ theme.Light = true
25+ theme.PaletteKnown = 1<<1 | 1<<14
26+ theme.Palette[1] = renderer.RGB{R: 7, G: 8, B: 9}
27+ theme.Palette[14] = renderer.RGB{R: 10, G: 11, B: 12}
28+ })
29+
30+ got := state.clearPalette()
31+ require.True(t, got.HasForeground)
32+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, got.Foreground)
33+ require.True(t, got.HasBackground)
34+ require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, got.Background)
35+ require.True(t, got.SchemeKnown)
36+ require.True(t, got.Light)
37+ require.Zero(t, got.PaletteKnown)
38+ require.Equal(t, [16]renderer.RGB{}, got.Palette)
39+
40+ _, reported := state.reportedTheme()
41+ require.True(t, reported, "foreground/background remain usable after palette invalidation")
42+}
43+
44+func TestStdinPumpCoalescesPaletteAndBackgroundThemePerRead(t *testing.T) {
45+ state := &terminalThemeState{}
46+ ctx, cancel := context.WithCancel(context.Background())
47+ defer cancel()
48+ out := make(chan ports.Frame, 3)
49+ pump := stdinPump{
50+ ctx: ctx,
51+ cancel: cancel,
52+ in: bytes.NewReader([]byte("\x1b]4;1;#112233\a\x1b]11;rgb:0404/0505/0606\a\x1b]4;14;#778899\a")),
53+ out: out,
54+ themeState: state,
55+ logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
56+ }
57+
58+ pump.run()
59+
60+ first := <-out
61+ require.Equal(t, ports.MsgTheme, first.Type)
62+ theme, err := ports.UnmarshalTheme(first.Payload)
63+ require.NoError(t, err)
64+ require.True(t, theme.HasBackground)
65+ require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, theme.Background)
66+ require.Equal(t, uint16(1<<1|1<<14), theme.PaletteKnown)
67+ require.Equal(t, renderer.RGB{R: 0x11, G: 0x22, B: 0x33}, theme.Palette[1])
68+ require.Equal(t, renderer.RGB{R: 0x77, G: 0x88, B: 0x99}, theme.Palette[14])
69+
70+ second := <-out
71+ require.Equal(t, ports.MsgDetach, second.Type)
72+ select {
73+ case extra := <-out:
74+ t.Fatalf("got extra frame after one input chunk: %v", extra.Type)
75+ default:
76+ }
77+}

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