detached 4c6e36a

fix(download): harden engine download setup and terminal state

commit
4c6e36af016c971c7e0e7ad4c88646ae4778e4d9
author
brice · · 2026-04-19 19:37 UTC
parent
4169f15
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +104 -30
M internal/infrastructure/cef/download_handler.go +33 -22
@@ -3,8 +3,6 @@ package cef
33 import (
44 "context"
55 "fmt"
6- "net/http"
7- "strings"
86 "sync"
97
108 purecef "github.com/bnema/purego-cef/cef"
@@ -15,16 +13,22 @@ import (
1513 "github.com/bnema/dumber/internal/logging"
1614 )
1715
16+// maxFinishedEntries caps the finished-ID set. When the cap is reached the set
17+// is cleared, accepting a tiny risk of a duplicate terminal event for very old
18+// downloads. In practice download IDs are monotonically increasing uint32s and
19+// no user session will approach this limit.
20+const maxFinishedEntries = 1024
21+
1822 type downloadHandler struct {
19- runtime *downloadruntime.Runtime
20- mu sync.Mutex
21- active map[uint32]cefDownloadState
23+ runtime *downloadruntime.Runtime
24+ mu sync.Mutex
25+ active map[uint32]cefDownloadState
26+ finished map[uint32]struct{} // IDs that already emitted a terminal event
2227 }
2328
2429 type cefDownloadState struct {
2530 filename string
2631 destination string
27- finished bool
2832 }
2933
3034 type cefDownloadResponseAdapter struct {
@@ -37,13 +41,14 @@ func newDownloadHandler(
3741 preparer port.DownloadPreparer,
3842 ) *downloadHandler {
3943 return &downloadHandler{
40- runtime: downloadruntime.New(downloadPath, eventHandler, preparer),
41- active: make(map[uint32]cefDownloadState),
44+ runtime: downloadruntime.New(downloadPath, eventHandler, preparer),
45+ active: make(map[uint32]cefDownloadState),
46+ finished: make(map[uint32]struct{}),
4247 }
4348 }
4449
45-func (h *downloadHandler) canDownload(_ purecef.Browser, _, requestMethod string) bool {
46- return requestMethod == "" || strings.EqualFold(requestMethod, http.MethodGet)
50+func (h *downloadHandler) canDownload(_ purecef.Browser, _, _ string) bool {
51+ return true
4752 }
4853
4954 func (h *downloadHandler) onBeforeDownload(
@@ -89,16 +94,25 @@ func (h *downloadHandler) onDownloadUpdated(
8994 }
9095
9196 id := downloadItem.GetID()
97+
98+ // Fast path: skip already-finished downloads to avoid re-populating active.
99+ h.mu.Lock()
100+ _, done := h.finished[id]
101+ h.mu.Unlock()
102+ if done {
103+ return
104+ }
105+
92106 state := h.currentState(id, downloadItem)
93107
94108 switch {
95109 case downloadItem.IsComplete():
96- if !h.markFinished(id, state) {
110+ if !h.markFinished(id) {
97111 return
98112 }
99113 h.runtime.EmitFinished(ctx, state.filename, state.destination)
100114 case downloadItem.IsCanceled() || downloadItem.IsInterrupted():
101- if !h.markFinished(id, state) {
115+ if !h.markFinished(id) {
102116 return
103117 }
104118 var err error
@@ -129,21 +143,18 @@ func (h *downloadHandler) currentState(id uint32, item purecef.DownloadItem) cef
129143 return state
130144 }
131145
132-func (h *downloadHandler) markFinished(id uint32, state cefDownloadState) bool {
146+func (h *downloadHandler) markFinished(id uint32) bool {
133147 h.mu.Lock()
134148 defer h.mu.Unlock()
135149
136- current, ok := h.active[id]
137- if !ok {
138- state.finished = true
139- h.active[id] = state
140- return true
141- }
142- if current.finished {
150+ if _, seen := h.finished[id]; seen {
143151 return false
144152 }
145- state.finished = true
146- h.active[id] = state
153+ delete(h.active, id)
154+ if len(h.finished) >= maxFinishedEntries {
155+ clear(h.finished)
156+ }
157+ h.finished[id] = struct{}{}
147158 return true
148159 }
149160
@@ -3,8 +3,6 @@ package cef
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6- "net/http"
7- "strings"
8 "sync"6 "sync"
9 7
10 purecef "github.com/bnema/purego-cef/cef"8 purecef "github.com/bnema/purego-cef/cef"
@@ -15,16 +13,22 @@ import (
15 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
16 )14 )
17 15
16+// maxFinishedEntries caps the finished-ID set. When the cap is reached the set
17+// is cleared, accepting a tiny risk of a duplicate terminal event for very old
18+// downloads. In practice download IDs are monotonically increasing uint32s and
19+// no user session will approach this limit.
20+const maxFinishedEntries = 1024
21+
18 type downloadHandler struct {22 type downloadHandler struct {
19- runtime *downloadruntime.Runtime23+ runtime *downloadruntime.Runtime
20- mu sync.Mutex24+ mu sync.Mutex
21- active map[uint32]cefDownloadState25+ active map[uint32]cefDownloadState
26+ finished map[uint32]struct{} // IDs that already emitted a terminal event
22 }27 }
23 28
24 type cefDownloadState struct {29 type cefDownloadState struct {
25 filename string30 filename string
26 destination string31 destination string
27- finished bool
28 }32 }
29 33
30 type cefDownloadResponseAdapter struct {34 type cefDownloadResponseAdapter struct {
@@ -37,13 +41,14 @@ func newDownloadHandler(
37 preparer port.DownloadPreparer,41 preparer port.DownloadPreparer,
38 ) *downloadHandler {42 ) *downloadHandler {
39 return &downloadHandler{43 return &downloadHandler{
40- runtime: downloadruntime.New(downloadPath, eventHandler, preparer),44+ runtime: downloadruntime.New(downloadPath, eventHandler, preparer),
41- active: make(map[uint32]cefDownloadState),45+ active: make(map[uint32]cefDownloadState),
46+ finished: make(map[uint32]struct{}),
42 }47 }
43 }48 }
44 49
45-func (h *downloadHandler) canDownload(_ purecef.Browser, _, requestMethod string) bool {50+func (h *downloadHandler) canDownload(_ purecef.Browser, _, _ string) bool {
46- return requestMethod == "" || strings.EqualFold(requestMethod, http.MethodGet)51+ return true
47 }52 }
48 53
49 func (h *downloadHandler) onBeforeDownload(54 func (h *downloadHandler) onBeforeDownload(
@@ -89,16 +94,25 @@ func (h *downloadHandler) onDownloadUpdated(
89 }94 }
90 95
91 id := downloadItem.GetID()96 id := downloadItem.GetID()
97+
98+ // Fast path: skip already-finished downloads to avoid re-populating active.
99+ h.mu.Lock()
100+ _, done := h.finished[id]
101+ h.mu.Unlock()
102+ if done {
103+ return
104+ }
105+
92 state := h.currentState(id, downloadItem)106 state := h.currentState(id, downloadItem)
93 107
94 switch {108 switch {
95 case downloadItem.IsComplete():109 case downloadItem.IsComplete():
96- if !h.markFinished(id, state) {110+ if !h.markFinished(id) {
97 return111 return
98 }112 }
99 h.runtime.EmitFinished(ctx, state.filename, state.destination)113 h.runtime.EmitFinished(ctx, state.filename, state.destination)
100 case downloadItem.IsCanceled() || downloadItem.IsInterrupted():114 case downloadItem.IsCanceled() || downloadItem.IsInterrupted():
101- if !h.markFinished(id, state) {115+ if !h.markFinished(id) {
102 return116 return
103 }117 }
104 var err error118 var err error
@@ -129,21 +143,18 @@ func (h *downloadHandler) currentState(id uint32, item purecef.DownloadItem) cef
129 return state143 return state
130 }144 }
131 145
132-func (h *downloadHandler) markFinished(id uint32, state cefDownloadState) bool {146+func (h *downloadHandler) markFinished(id uint32) bool {
133 h.mu.Lock()147 h.mu.Lock()
134 defer h.mu.Unlock()148 defer h.mu.Unlock()
135 149
136- current, ok := h.active[id]150+ if _, seen := h.finished[id]; seen {
137- if !ok {
138- state.finished = true
139- h.active[id] = state
140- return true
141- }
142- if current.finished {
143 return false151 return false
144 }152 }
145- state.finished = true153+ delete(h.active, id)
146- h.active[id] = state154+ if len(h.finished) >= maxFinishedEntries {
155+ clear(h.finished)
156+ }
157+ h.finished[id] = struct{}{}
147 return true158 return true
148 }159 }
149 160
M internal/infrastructure/cef/download_handler_test.go +37 -0
@@ -92,4 +92,41 @@ func TestCEFDownloadHandlerLifecycle(t *testing.T) {
9292 require.Len(t, events.events, 2)
9393 require.Equal(t, port.DownloadEventFinished, events.events[1].Type)
9494 require.Equal(t, "image.iso", events.events[1].Filename)
95+
96+ // Active map should be cleaned up after terminal event.
97+ handler.mu.Lock()
98+ _, inActive := handler.active[7]
99+ _, inFinished := handler.finished[7]
100+ handler.mu.Unlock()
101+ require.False(t, inActive, "completed download should be removed from active map")
102+ require.True(t, inFinished, "completed download should be in finished set")
103+}
104+
105+func TestCanDownload_AllowsAllMethods(t *testing.T) {
106+ preparer := usecase.NewPrepareDownloadUseCase(nil)
107+ handler := newDownloadHandler("/tmp/downloads", nil, preparer)
108+
109+ require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "GET"))
110+ require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "POST"))
111+ require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "PUT"))
112+ require.True(t, handler.canDownload(nil, "https://example.com/file.zip", ""))
113+}
114+
115+func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) {
116+ preparer := usecase.NewPrepareDownloadUseCase(nil)
117+ handler := newDownloadHandler("/tmp/downloads", nil, preparer)
118+
119+ // Simulate a download that was tracked through onBeforeDownload.
120+ handler.mu.Lock()
121+ handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin"}
122+ handler.mu.Unlock()
123+
124+ require.True(t, handler.markFinished(42), "first terminal event should succeed")
125+ require.False(t, handler.markFinished(42), "second terminal event should be suppressed")
126+
127+ // Active map should not contain the entry.
128+ handler.mu.Lock()
129+ _, inActive := handler.active[42]
130+ handler.mu.Unlock()
131+ require.False(t, inActive)
95132 }
@@ -92,4 +92,41 @@ func TestCEFDownloadHandlerLifecycle(t *testing.T) {
92 require.Len(t, events.events, 2)92 require.Len(t, events.events, 2)
93 require.Equal(t, port.DownloadEventFinished, events.events[1].Type)93 require.Equal(t, port.DownloadEventFinished, events.events[1].Type)
94 require.Equal(t, "image.iso", events.events[1].Filename)94 require.Equal(t, "image.iso", events.events[1].Filename)
95+
96+ // Active map should be cleaned up after terminal event.
97+ handler.mu.Lock()
98+ _, inActive := handler.active[7]
99+ _, inFinished := handler.finished[7]
100+ handler.mu.Unlock()
101+ require.False(t, inActive, "completed download should be removed from active map")
102+ require.True(t, inFinished, "completed download should be in finished set")
103+}
104+
105+func TestCanDownload_AllowsAllMethods(t *testing.T) {
106+ preparer := usecase.NewPrepareDownloadUseCase(nil)
107+ handler := newDownloadHandler("/tmp/downloads", nil, preparer)
108+
109+ require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "GET"))
110+ require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "POST"))
111+ require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "PUT"))
112+ require.True(t, handler.canDownload(nil, "https://example.com/file.zip", ""))
113+}
114+
115+func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) {
116+ preparer := usecase.NewPrepareDownloadUseCase(nil)
117+ handler := newDownloadHandler("/tmp/downloads", nil, preparer)
118+
119+ // Simulate a download that was tracked through onBeforeDownload.
120+ handler.mu.Lock()
121+ handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin"}
122+ handler.mu.Unlock()
123+
124+ require.True(t, handler.markFinished(42), "first terminal event should succeed")
125+ require.False(t, handler.markFinished(42), "second terminal event should be suppressed")
126+
127+ // Active map should not contain the entry.
128+ handler.mu.Lock()
129+ _, inActive := handler.active[42]
130+ handler.mu.Unlock()
131+ require.False(t, inActive)
95 }132 }
M internal/infrastructure/cef/engine.go +4 -0
@@ -3,6 +3,7 @@ package cef
33 import (
44 "context"
55 "crypto/subtle"
6+ "fmt"
67 "sync"
78 "sync/atomic"
89 "time"
@@ -265,6 +266,9 @@ func (e *Engine) ConfigureDownloads(
265266 eventHandler port.DownloadEventHandler,
266267 preparer port.DownloadPreparer,
267268 ) error {
269+ if preparer == nil {
270+ return fmt.Errorf("cef: download preparer is required")
271+ }
268272 e.downloadMu.Lock()
269273 e.downloadHandler = newDownloadHandler(downloadPath, eventHandler, preparer)
270274 e.downloadMu.Unlock()
@@ -3,6 +3,7 @@ package cef
3 import (3 import (
4 "context"4 "context"
5 "crypto/subtle"5 "crypto/subtle"
6+ "fmt"
6 "sync"7 "sync"
7 "sync/atomic"8 "sync/atomic"
8 "time"9 "time"
@@ -265,6 +266,9 @@ func (e *Engine) ConfigureDownloads(
265 eventHandler port.DownloadEventHandler,266 eventHandler port.DownloadEventHandler,
266 preparer port.DownloadPreparer,267 preparer port.DownloadPreparer,
267 ) error {268 ) error {
269+ if preparer == nil {
270+ return fmt.Errorf("cef: download preparer is required")
271+ }
268 e.downloadMu.Lock()272 e.downloadMu.Lock()
269 e.downloadHandler = newDownloadHandler(downloadPath, eventHandler, preparer)273 e.downloadHandler = newDownloadHandler(downloadPath, eventHandler, preparer)
270 e.downloadMu.Unlock()274 e.downloadMu.Unlock()
M internal/infrastructure/cef/engine_contract_test.go +10 -0
@@ -19,6 +19,16 @@ func TestEngineConfigureDownloads_StoresHandler(t *testing.T) {
1919 require.NotNil(t, eng.currentDownloadHandler())
2020 }
2121
22+func TestEngineConfigureDownloads_NilPreparer(t *testing.T) {
23+ eng := &Engine{}
24+
25+ err := eng.ConfigureDownloads(context.Background(), "/tmp", nil, nil)
26+
27+ require.Error(t, err)
28+ require.Contains(t, err.Error(), "preparer is required")
29+ require.Nil(t, eng.currentDownloadHandler())
30+}
31+
2232 func TestWebViewFactoryCreateRelated_ReturnsUnsupported(t *testing.T) {
2333 factory := &WebViewFactory{}
2434 wv, err := factory.CreateRelated(context.Background(), port.WebViewID(42))
@@ -19,6 +19,16 @@ func TestEngineConfigureDownloads_StoresHandler(t *testing.T) {
19 require.NotNil(t, eng.currentDownloadHandler())19 require.NotNil(t, eng.currentDownloadHandler())
20 }20 }
21 21
22+func TestEngineConfigureDownloads_NilPreparer(t *testing.T) {
23+ eng := &Engine{}
24+
25+ err := eng.ConfigureDownloads(context.Background(), "/tmp", nil, nil)
26+
27+ require.Error(t, err)
28+ require.Contains(t, err.Error(), "preparer is required")
29+ require.Nil(t, eng.currentDownloadHandler())
30+}
31+
22 func TestWebViewFactoryCreateRelated_ReturnsUnsupported(t *testing.T) {32 func TestWebViewFactoryCreateRelated_ReturnsUnsupported(t *testing.T) {
23 factory := &WebViewFactory{}33 factory := &WebViewFactory{}
24 wv, err := factory.CreateRelated(context.Background(), port.WebViewID(42))34 wv, err := factory.CreateRelated(context.Background(), port.WebViewID(42))
M internal/infrastructure/downloadruntime/runtime.go +2 -4
@@ -20,11 +20,9 @@ type Runtime struct {
2020 preparer port.DownloadPreparer
2121 }
2222
23+// New creates a Runtime. The caller must ensure preparer is non-nil;
24+// ConfigureDownloads validates this before reaching here.
2325 func New(downloadPath string, eventHandler port.DownloadEventHandler, preparer port.DownloadPreparer) *Runtime {
24- if preparer == nil {
25- panic("preparer is required")
26- }
27-
2826 return &Runtime{
2927 downloadPath: downloadPath,
3028 eventHandler: eventHandler,
@@ -20,11 +20,9 @@ type Runtime struct {
20 preparer port.DownloadPreparer20 preparer port.DownloadPreparer
21 }21 }
22 22
23+// New creates a Runtime. The caller must ensure preparer is non-nil;
24+// ConfigureDownloads validates this before reaching here.
23 func New(downloadPath string, eventHandler port.DownloadEventHandler, preparer port.DownloadPreparer) *Runtime {25 func New(downloadPath string, eventHandler port.DownloadEventHandler, preparer port.DownloadPreparer) *Runtime {
24- if preparer == nil {
25- panic("preparer is required")
26- }
27-
28 return &Runtime{26 return &Runtime{
29 downloadPath: downloadPath,27 downloadPath: downloadPath,
30 eventHandler: eventHandler,28 eventHandler: eventHandler,
M internal/infrastructure/downloadruntime/runtime_test.go +6 -0
@@ -43,3 +43,9 @@ func TestRuntimeResolveDestinationAndEvents(t *testing.T) {
4343 require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
4444 require.Equal(t, port.DownloadEventFinished, events.events[1].Type)
4545 }
46+
47+func TestNewRuntime_NilPreparer_NoPanic(t *testing.T) {
48+ require.NotPanics(t, func() {
49+ _ = New("/tmp/downloads", nil, nil)
50+ })
51+}
@@ -43,3 +43,9 @@ func TestRuntimeResolveDestinationAndEvents(t *testing.T) {
43 require.Equal(t, port.DownloadEventStarted, events.events[0].Type)43 require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
44 require.Equal(t, port.DownloadEventFinished, events.events[1].Type)44 require.Equal(t, port.DownloadEventFinished, events.events[1].Type)
45 }45 }
46+
47+func TestNewRuntime_NilPreparer_NoPanic(t *testing.T) {
48+ require.NotPanics(t, func() {
49+ _ = New("/tmp/downloads", nil, nil)
50+ })
51+}
M internal/infrastructure/webkit/download_handler.go +1 -4
@@ -20,15 +20,12 @@ type DownloadHandler struct {
2020 }
2121
2222 // NewDownloadHandler creates a new download handler.
23-// Panics if preparer is nil (fail fast on misconfiguration).
23+// The caller (ConfigureDownloads) must validate that preparer is non-nil.
2424 func NewDownloadHandler(
2525 downloadPath string,
2626 handler port.DownloadEventHandler,
2727 preparer port.DownloadPreparer,
2828 ) *DownloadHandler {
29- if preparer == nil {
30- panic("preparer is required")
31- }
3229 return &DownloadHandler{
3330 runtime: downloadruntime.New(downloadPath, handler, preparer),
3431 }
@@ -20,15 +20,12 @@ type DownloadHandler struct {
20 }20 }
21 21
22 // NewDownloadHandler creates a new download handler.22 // NewDownloadHandler creates a new download handler.
23-// Panics if preparer is nil (fail fast on misconfiguration).23+// The caller (ConfigureDownloads) must validate that preparer is non-nil.
24 func NewDownloadHandler(24 func NewDownloadHandler(
25 downloadPath string,25 downloadPath string,
26 handler port.DownloadEventHandler,26 handler port.DownloadEventHandler,
27 preparer port.DownloadPreparer,27 preparer port.DownloadPreparer,
28 ) *DownloadHandler {28 ) *DownloadHandler {
29- if preparer == nil {
30- panic("preparer is required")
31- }
32 return &DownloadHandler{29 return &DownloadHandler{
33 runtime: downloadruntime.New(downloadPath, handler, preparer),30 runtime: downloadruntime.New(downloadPath, handler, preparer),
34 }31 }
M internal/infrastructure/webkit/engine.go +3 -0
@@ -120,6 +120,9 @@ func (e *Engine) ConfigureDownloads(
120120 if e.wkCtx == nil {
121121 return fmt.Errorf("webkit context not initialized")
122122 }
123+ if preparer == nil {
124+ return fmt.Errorf("download preparer is required")
125+ }
123126 handler := NewDownloadHandler(downloadPath, eventHandler, preparer)
124127 e.wkCtx.SetDownloadHandler(ctx, handler)
125128 e.downloadPath = downloadPath
@@ -120,6 +120,9 @@ func (e *Engine) ConfigureDownloads(
120 if e.wkCtx == nil {120 if e.wkCtx == nil {
121 return fmt.Errorf("webkit context not initialized")121 return fmt.Errorf("webkit context not initialized")
122 }122 }
123+ if preparer == nil {
124+ return fmt.Errorf("download preparer is required")
125+ }
123 handler := NewDownloadHandler(downloadPath, eventHandler, preparer)126 handler := NewDownloadHandler(downloadPath, eventHandler, preparer)
124 e.wkCtx.SetDownloadHandler(ctx, handler)127 e.wkCtx.SetDownloadHandler(ctx, handler)
125 e.downloadPath = downloadPath128 e.downloadPath = downloadPath
M internal/infrastructure/webkit/engine_runtime_test.go +8 -0
@@ -46,6 +46,14 @@ func TestEngine_ConfigureDownloads_NilContext(t *testing.T) {
4646 require.Contains(t, err.Error(), "webkit context not initialized")
4747 }
4848
49+func TestNewDownloadHandler_NilPreparer_NoPanic(t *testing.T) {
50+ // After removing the panic, NewDownloadHandler with nil preparer should
51+ // not panic. The caller (ConfigureDownloads) is responsible for validation.
52+ require.NotPanics(t, func() {
53+ _ = NewDownloadHandler("/tmp", nil, nil)
54+ })
55+}
56+
4957 func TestEngine_Close_NilPool(t *testing.T) {
5058 e := &Engine{}
5159 err := e.Close()
@@ -46,6 +46,14 @@ func TestEngine_ConfigureDownloads_NilContext(t *testing.T) {
46 require.Contains(t, err.Error(), "webkit context not initialized")46 require.Contains(t, err.Error(), "webkit context not initialized")
47 }47 }
48 48
49+func TestNewDownloadHandler_NilPreparer_NoPanic(t *testing.T) {
50+ // After removing the panic, NewDownloadHandler with nil preparer should
51+ // not panic. The caller (ConfigureDownloads) is responsible for validation.
52+ require.NotPanics(t, func() {
53+ _ = NewDownloadHandler("/tmp", nil, nil)
54+ })
55+}
56+
49 func TestEngine_Close_NilPool(t *testing.T) {57 func TestEngine_Close_NilPool(t *testing.T) {
50 e := &Engine{}58 e := &Engine{}
51 err := e.Close()59 err := e.Close()

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 8

Tags 42

↑↓ moveenter openesc close