detached a8274e6

refactor(cef): inject transcoding classification seams

commit
a8274e612f765edd559d89399fba6dd59aa1843a
author
brice · · 2026-04-11 13:14 UTC
parent
51da5d5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +66 -20
M internal/infrastructure/cef/engine_init.go +3 -1
@@ -71,7 +71,7 @@ func NewEngine(
7171 }
7272 os.Args = savedArgs
7373
74- return wireEngine(ctx, eng, cfg, transcodingCfg, windowlessFrameRate, audioFactory, logger, deps.CurrentConfigPayload, deps.DefaultConfigPayload)
74+ return wireEngine(ctx, eng, cfg, transcodingCfg, windowlessFrameRate, audioFactory, logger, deps.MediaClassifier.normalize(), deps.CurrentConfigPayload, deps.DefaultConfigPayload)
7575 }
7676
7777 func resolvedStateRoot(opts port.EngineOptions) string {
@@ -153,6 +153,7 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge
153153 func wireEngine(
154154 ctx context.Context, eng *Engine, cfg RuntimeConfig, transcodingCfg TranscodingRuntimeConfig,
155155 windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger,
156+ mediaClassifier MediaClassifier,
156157 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),
157158 ) (*Engine, error) {
158159 gl, err := newGLLoader()
@@ -196,6 +197,7 @@ func wireEngine(
196197 windowlessFrameRate: windowlessFrameRate,
197198 enableContextMenuHandler: cfg.EnableContextMenuHandler,
198199 transcoder: mediaTranscoder,
200+ mediaClassifier: mediaClassifier,
199201 audioOutputFactory: audioFactory,
200202 })
201203 pool := newWebViewPool(factory)
@@ -71,7 +71,7 @@ func NewEngine(
71 }71 }
72 os.Args = savedArgs72 os.Args = savedArgs
73 73
74- return wireEngine(ctx, eng, cfg, transcodingCfg, windowlessFrameRate, audioFactory, logger, deps.CurrentConfigPayload, deps.DefaultConfigPayload)74+ return wireEngine(ctx, eng, cfg, transcodingCfg, windowlessFrameRate, audioFactory, logger, deps.MediaClassifier.normalize(), deps.CurrentConfigPayload, deps.DefaultConfigPayload)
75 }75 }
76 76
77 func resolvedStateRoot(opts port.EngineOptions) string {77 func resolvedStateRoot(opts port.EngineOptions) string {
@@ -153,6 +153,7 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge
153 func wireEngine(153 func wireEngine(
154 ctx context.Context, eng *Engine, cfg RuntimeConfig, transcodingCfg TranscodingRuntimeConfig,154 ctx context.Context, eng *Engine, cfg RuntimeConfig, transcodingCfg TranscodingRuntimeConfig,
155 windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger,155 windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger,
156+ mediaClassifier MediaClassifier,
156 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),157 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),
157 ) (*Engine, error) {158 ) (*Engine, error) {
158 gl, err := newGLLoader()159 gl, err := newGLLoader()
@@ -196,6 +197,7 @@ func wireEngine(
196 windowlessFrameRate: windowlessFrameRate,197 windowlessFrameRate: windowlessFrameRate,
197 enableContextMenuHandler: cfg.EnableContextMenuHandler,198 enableContextMenuHandler: cfg.EnableContextMenuHandler,
198 transcoder: mediaTranscoder,199 transcoder: mediaTranscoder,
200+ mediaClassifier: mediaClassifier,
199 audioOutputFactory: audioFactory,201 audioOutputFactory: audioFactory,
200 })202 })
201 pool := newWebViewPool(factory)203 pool := newWebViewPool(factory)
M internal/infrastructure/cef/factory.go +4 -1
@@ -25,6 +25,7 @@ type WebViewFactory struct {
2525 enableContextMenuHandler bool
2626 bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
2727 transcoder port.MediaTranscoder
28+ mediaClassifier MediaClassifier
2829 audioOutputFactory port.AudioOutputFactory
2930 }
3031
@@ -33,6 +34,7 @@ type webViewFactoryOptions struct {
3334 windowlessFrameRate int32
3435 enableContextMenuHandler bool
3536 transcoder port.MediaTranscoder
37+ mediaClassifier MediaClassifier
3638 audioOutputFactory port.AudioOutputFactory
3739 }
3840
@@ -52,6 +54,7 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions)
5254 windowlessFrameRate: opts.windowlessFrameRate,
5355 enableContextMenuHandler: opts.enableContextMenuHandler,
5456 transcoder: opts.transcoder,
57+ mediaClassifier: opts.mediaClassifier,
5558 audioOutputFactory: opts.audioOutputFactory,
5659 }
5760 }
@@ -81,7 +84,7 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
8184
8285 var transcodingHandler purecef.ResourceRequestHandler
8386 if f.transcoder != nil {
84- transcodingHandler = newTranscodingRequestHandler(f.transcoder, func() context.Context {
87+ transcodingHandler = newTranscodingRequestHandler(f.transcoder, f.mediaClassifier, func() context.Context {
8588 if f.engine != nil {
8689 return f.engine.currentContext()
8790 }
@@ -25,6 +25,7 @@ type WebViewFactory struct {
25 enableContextMenuHandler bool25 enableContextMenuHandler bool
26 bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor26 bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
27 transcoder port.MediaTranscoder27 transcoder port.MediaTranscoder
28+ mediaClassifier MediaClassifier
28 audioOutputFactory port.AudioOutputFactory29 audioOutputFactory port.AudioOutputFactory
29 }30 }
30 31
@@ -33,6 +34,7 @@ type webViewFactoryOptions struct {
33 windowlessFrameRate int3234 windowlessFrameRate int32
34 enableContextMenuHandler bool35 enableContextMenuHandler bool
35 transcoder port.MediaTranscoder36 transcoder port.MediaTranscoder
37+ mediaClassifier MediaClassifier
36 audioOutputFactory port.AudioOutputFactory38 audioOutputFactory port.AudioOutputFactory
37 }39 }
38 40
@@ -52,6 +54,7 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions)
52 windowlessFrameRate: opts.windowlessFrameRate,54 windowlessFrameRate: opts.windowlessFrameRate,
53 enableContextMenuHandler: opts.enableContextMenuHandler,55 enableContextMenuHandler: opts.enableContextMenuHandler,
54 transcoder: opts.transcoder,56 transcoder: opts.transcoder,
57+ mediaClassifier: opts.mediaClassifier,
55 audioOutputFactory: opts.audioOutputFactory,58 audioOutputFactory: opts.audioOutputFactory,
56 }59 }
57 }60 }
@@ -81,7 +84,7 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
81 84
82 var transcodingHandler purecef.ResourceRequestHandler85 var transcodingHandler purecef.ResourceRequestHandler
83 if f.transcoder != nil {86 if f.transcoder != nil {
84- transcodingHandler = newTranscodingRequestHandler(f.transcoder, func() context.Context {87+ transcodingHandler = newTranscodingRequestHandler(f.transcoder, f.mediaClassifier, func() context.Context {
85 if f.engine != nil {88 if f.engine != nil {
86 return f.engine.currentContext()89 return f.engine.currentContext()
87 }90 }
M internal/infrastructure/cef/transcoding_handler.go +23 -18
@@ -4,7 +4,6 @@ import (
44 "context"
55 "errors"
66 "io"
7- "strings"
87 "sync"
98 "time"
109 "unsafe"
@@ -12,7 +11,6 @@ import (
1211 purecef "github.com/bnema/purego-cef/cef"
1312
1413 "github.com/bnema/dumber/internal/application/port"
15- "github.com/bnema/dumber/internal/infrastructure/transcoder"
1614 "github.com/bnema/dumber/internal/logging"
1715 "github.com/rs/zerolog"
1816 )
@@ -185,6 +183,7 @@ func (rh *transcodingResourceHandler) Skip(_ int64, _ unsafe.Pointer, _ purecef.
185183
186184 type transcodingRequestHandler struct {
187185 transcoder port.MediaTranscoder
186+ classifier MediaClassifier
188187 transcodableURLs sync.Map // url string -> requestInfo
189188 ctxf func() context.Context
190189 }
@@ -197,10 +196,11 @@ type requestInfo struct {
197196 // proprietary video containers and HLS/DASH manifest entrypoints, then
198197 // restarts those requests through a GPU transcoding ResourceHandler.
199198 func newTranscodingRequestHandler(
200- tc port.MediaTranscoder, ctxf func() context.Context,
199+ tc port.MediaTranscoder, classifier MediaClassifier, ctxf func() context.Context,
201200 ) purecef.ResourceRequestHandler {
202201 return purecef.NewResourceRequestHandler(&transcodingRequestHandler{
203202 transcoder: tc,
203+ classifier: classifier.normalize(),
204204 ctxf: ctxf,
205205 })
206206 }
@@ -219,10 +219,8 @@ func (h *transcodingRequestHandler) OnResourceResponse(
219219 url := request.GetURL()
220220
221221 log := h.logger()
222- isProprietary := transcoder.IsProprietaryVideoMIME(mimeType)
223- isAlreadyOpen := transcoder.IsOpenVideoMIME(mimeType)
224- isManifest := !isAlreadyOpen && (transcoder.IsStreamingManifestMIME(mimeType) || transcoder.IsStreamingManifestURL(url))
225- if isLikelyMediaResponse(url, mimeType) {
222+ isProprietary, isAlreadyOpen, isManifest := classifyMediaResponse(h.classifier, url, mimeType)
223+ if isProprietary || isAlreadyOpen || isManifest {
226224 log.Debug().
227225 Str("url", logging.TruncateURL(url, maxTranscodingURLLength)).
228226 Str("mime_type", mimeType).
@@ -275,7 +273,7 @@ func (h *transcodingRequestHandler) GetResourceHandler(
275273 return nil
276274 }
277275 info = cached
278- } else if syntheticSourceURL, referer, origin, synthetic := transcoder.ParseSyntheticTranscodeURL(url); synthetic {
276+ } else if syntheticSourceURL, referer, origin, synthetic := resolveTranscodeSource(h.classifier, url); synthetic {
279277 eager = true
280278 sourceURL = syntheticSourceURL
281279 info = buildRequestInfo(request)
@@ -285,9 +283,6 @@ func (h *transcodingRequestHandler) GetResourceHandler(
285283 if origin != "" && info.headers["Origin"] == "" {
286284 info.headers["Origin"] = origin
287285 }
288- } else if transcoder.IsEagerTranscodeURL(url) {
289- eager = true
290- info = buildRequestInfo(request)
291286 } else {
292287 return nil
293288 }
@@ -317,6 +312,23 @@ func (h *transcodingRequestHandler) GetResourceHandler(
317312 })
318313 }
319314
315+func classifyMediaResponse(classifier MediaClassifier, rawURL, mimeType string) (bool, bool, bool) {
316+ proprietary := classifier.IsProprietaryVideoMIME(mimeType)
317+ alreadyOpen := classifier.IsOpenVideoMIME(mimeType)
318+ manifest := !alreadyOpen && (classifier.IsStreamingManifestMIME(mimeType) || classifier.IsStreamingManifestURL(rawURL))
319+ return proprietary, alreadyOpen, manifest
320+}
321+
322+func resolveTranscodeSource(classifier MediaClassifier, rawURL string) (string, string, string, bool) {
323+ if sourceURL, referer, origin, ok := classifier.ParseSyntheticTranscodeURL(rawURL); ok {
324+ return sourceURL, referer, origin, true
325+ }
326+ if classifier.IsEagerTranscodeURL(rawURL) {
327+ return rawURL, "", "", true
328+ }
329+ return "", "", "", false
330+}
331+
320332 // --- No-op methods required by purecef.ResourceRequestHandler ---
321333
322334 func (h *transcodingRequestHandler) GetCookieAccessFilter(
@@ -351,13 +363,6 @@ func (h *transcodingRequestHandler) OnResourceLoadComplete(
351363 func (h *transcodingRequestHandler) OnProtocolExecution(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ unsafe.Pointer) {
352364 }
353365
354-func isLikelyMediaResponse(rawURL, mimeType string) bool {
355- return strings.HasPrefix(mimeType, "video/") ||
356- strings.HasPrefix(mimeType, "audio/") ||
357- transcoder.IsStreamingManifestMIME(mimeType) ||
358- transcoder.IsStreamingManifestURL(rawURL)
359-}
360-
361366 func buildRequestInfo(request purecef.Request) requestInfo {
362367 headers := make(map[string]string)
363368 if request == nil {
@@ -4,7 +4,6 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "io"6 "io"
7- "strings"
8 "sync"7 "sync"
9 "time"8 "time"
10 "unsafe"9 "unsafe"
@@ -12,7 +11,6 @@ import (
12 purecef "github.com/bnema/purego-cef/cef"11 purecef "github.com/bnema/purego-cef/cef"
13 12
14 "github.com/bnema/dumber/internal/application/port"13 "github.com/bnema/dumber/internal/application/port"
15- "github.com/bnema/dumber/internal/infrastructure/transcoder"
16 "github.com/bnema/dumber/internal/logging"14 "github.com/bnema/dumber/internal/logging"
17 "github.com/rs/zerolog"15 "github.com/rs/zerolog"
18 )16 )
@@ -185,6 +183,7 @@ func (rh *transcodingResourceHandler) Skip(_ int64, _ unsafe.Pointer, _ purecef.
185 183
186 type transcodingRequestHandler struct {184 type transcodingRequestHandler struct {
187 transcoder port.MediaTranscoder185 transcoder port.MediaTranscoder
186+ classifier MediaClassifier
188 transcodableURLs sync.Map // url string -> requestInfo187 transcodableURLs sync.Map // url string -> requestInfo
189 ctxf func() context.Context188 ctxf func() context.Context
190 }189 }
@@ -197,10 +196,11 @@ type requestInfo struct {
197 // proprietary video containers and HLS/DASH manifest entrypoints, then196 // proprietary video containers and HLS/DASH manifest entrypoints, then
198 // restarts those requests through a GPU transcoding ResourceHandler.197 // restarts those requests through a GPU transcoding ResourceHandler.
199 func newTranscodingRequestHandler(198 func newTranscodingRequestHandler(
200- tc port.MediaTranscoder, ctxf func() context.Context,199+ tc port.MediaTranscoder, classifier MediaClassifier, ctxf func() context.Context,
201 ) purecef.ResourceRequestHandler {200 ) purecef.ResourceRequestHandler {
202 return purecef.NewResourceRequestHandler(&transcodingRequestHandler{201 return purecef.NewResourceRequestHandler(&transcodingRequestHandler{
203 transcoder: tc,202 transcoder: tc,
203+ classifier: classifier.normalize(),
204 ctxf: ctxf,204 ctxf: ctxf,
205 })205 })
206 }206 }
@@ -219,10 +219,8 @@ func (h *transcodingRequestHandler) OnResourceResponse(
219 url := request.GetURL()219 url := request.GetURL()
220 220
221 log := h.logger()221 log := h.logger()
222- isProprietary := transcoder.IsProprietaryVideoMIME(mimeType)222+ isProprietary, isAlreadyOpen, isManifest := classifyMediaResponse(h.classifier, url, mimeType)
223- isAlreadyOpen := transcoder.IsOpenVideoMIME(mimeType)223+ if isProprietary || isAlreadyOpen || isManifest {
224- isManifest := !isAlreadyOpen && (transcoder.IsStreamingManifestMIME(mimeType) || transcoder.IsStreamingManifestURL(url))
225- if isLikelyMediaResponse(url, mimeType) {
226 log.Debug().224 log.Debug().
227 Str("url", logging.TruncateURL(url, maxTranscodingURLLength)).225 Str("url", logging.TruncateURL(url, maxTranscodingURLLength)).
228 Str("mime_type", mimeType).226 Str("mime_type", mimeType).
@@ -275,7 +273,7 @@ func (h *transcodingRequestHandler) GetResourceHandler(
275 return nil273 return nil
276 }274 }
277 info = cached275 info = cached
278- } else if syntheticSourceURL, referer, origin, synthetic := transcoder.ParseSyntheticTranscodeURL(url); synthetic {276+ } else if syntheticSourceURL, referer, origin, synthetic := resolveTranscodeSource(h.classifier, url); synthetic {
279 eager = true277 eager = true
280 sourceURL = syntheticSourceURL278 sourceURL = syntheticSourceURL
281 info = buildRequestInfo(request)279 info = buildRequestInfo(request)
@@ -285,9 +283,6 @@ func (h *transcodingRequestHandler) GetResourceHandler(
285 if origin != "" && info.headers["Origin"] == "" {283 if origin != "" && info.headers["Origin"] == "" {
286 info.headers["Origin"] = origin284 info.headers["Origin"] = origin
287 }285 }
288- } else if transcoder.IsEagerTranscodeURL(url) {
289- eager = true
290- info = buildRequestInfo(request)
291 } else {286 } else {
292 return nil287 return nil
293 }288 }
@@ -317,6 +312,23 @@ func (h *transcodingRequestHandler) GetResourceHandler(
317 })312 })
318 }313 }
319 314
315+func classifyMediaResponse(classifier MediaClassifier, rawURL, mimeType string) (bool, bool, bool) {
316+ proprietary := classifier.IsProprietaryVideoMIME(mimeType)
317+ alreadyOpen := classifier.IsOpenVideoMIME(mimeType)
318+ manifest := !alreadyOpen && (classifier.IsStreamingManifestMIME(mimeType) || classifier.IsStreamingManifestURL(rawURL))
319+ return proprietary, alreadyOpen, manifest
320+}
321+
322+func resolveTranscodeSource(classifier MediaClassifier, rawURL string) (string, string, string, bool) {
323+ if sourceURL, referer, origin, ok := classifier.ParseSyntheticTranscodeURL(rawURL); ok {
324+ return sourceURL, referer, origin, true
325+ }
326+ if classifier.IsEagerTranscodeURL(rawURL) {
327+ return rawURL, "", "", true
328+ }
329+ return "", "", "", false
330+}
331+
320 // --- No-op methods required by purecef.ResourceRequestHandler ---332 // --- No-op methods required by purecef.ResourceRequestHandler ---
321 333
322 func (h *transcodingRequestHandler) GetCookieAccessFilter(334 func (h *transcodingRequestHandler) GetCookieAccessFilter(
@@ -351,13 +363,6 @@ func (h *transcodingRequestHandler) OnResourceLoadComplete(
351 func (h *transcodingRequestHandler) OnProtocolExecution(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ unsafe.Pointer) {363 func (h *transcodingRequestHandler) OnProtocolExecution(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ unsafe.Pointer) {
352 }364 }
353 365
354-func isLikelyMediaResponse(rawURL, mimeType string) bool {
355- return strings.HasPrefix(mimeType, "video/") ||
356- strings.HasPrefix(mimeType, "audio/") ||
357- transcoder.IsStreamingManifestMIME(mimeType) ||
358- transcoder.IsStreamingManifestURL(rawURL)
359-}
360-
361 func buildRequestInfo(request purecef.Request) requestInfo {366 func buildRequestInfo(request purecef.Request) requestInfo {
362 headers := make(map[string]string)367 headers := make(map[string]string)
363 if request == nil {368 if request == nil {
A internal/infrastructure/cef/transcoding_handler_test.go +36 -0
@@ -0,0 +1,36 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestClassifyMediaResponse_UsesInjectedClassifier(t *testing.T) {
10+ classifier := MediaClassifier{
11+ IsProprietaryVideoMIME: func(string) bool { return true },
12+ IsOpenVideoMIME: func(string) bool { return false },
13+ IsStreamingManifestMIME: func(string) bool { return false },
14+ IsStreamingManifestURL: func(string) bool { return false },
15+ }.normalize()
16+
17+ proprietary, alreadyOpen, manifest := classifyMediaResponse(classifier, "https://cdn.example/video.mp4", "video/mp4")
18+ require.True(t, proprietary)
19+ require.False(t, alreadyOpen)
20+ require.False(t, manifest)
21+}
22+
23+func TestResolveTranscodeSource_UsesInjectedClassifier(t *testing.T) {
24+ classifier := MediaClassifier{
25+ ParseSyntheticTranscodeURL: func(string) (string, string, string, bool) {
26+ return "https://cdn.example/video.mp4", "https://example.com", "https://example.com", true
27+ },
28+ IsEagerTranscodeURL: func(string) bool { return false },
29+ }.normalize()
30+
31+ src, referer, origin, eager := resolveTranscodeSource(classifier, "https://dumber.invalid/__dumber__/transcode.webm?src=x")
32+ require.True(t, eager)
33+ require.Equal(t, "https://cdn.example/video.mp4", src)
34+ require.Equal(t, "https://example.com", referer)
35+ require.Equal(t, "https://example.com", origin)
36+}
@@ -0,0 +1,36 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestClassifyMediaResponse_UsesInjectedClassifier(t *testing.T) {
10+ classifier := MediaClassifier{
11+ IsProprietaryVideoMIME: func(string) bool { return true },
12+ IsOpenVideoMIME: func(string) bool { return false },
13+ IsStreamingManifestMIME: func(string) bool { return false },
14+ IsStreamingManifestURL: func(string) bool { return false },
15+ }.normalize()
16+
17+ proprietary, alreadyOpen, manifest := classifyMediaResponse(classifier, "https://cdn.example/video.mp4", "video/mp4")
18+ require.True(t, proprietary)
19+ require.False(t, alreadyOpen)
20+ require.False(t, manifest)
21+}
22+
23+func TestResolveTranscodeSource_UsesInjectedClassifier(t *testing.T) {
24+ classifier := MediaClassifier{
25+ ParseSyntheticTranscodeURL: func(string) (string, string, string, bool) {
26+ return "https://cdn.example/video.mp4", "https://example.com", "https://example.com", true
27+ },
28+ IsEagerTranscodeURL: func(string) bool { return false },
29+ }.normalize()
30+
31+ src, referer, origin, eager := resolveTranscodeSource(classifier, "https://dumber.invalid/__dumber__/transcode.webm?src=x")
32+ require.True(t, eager)
33+ require.Equal(t, "https://cdn.example/video.mp4", src)
34+ require.Equal(t, "https://example.com", referer)
35+ require.Equal(t, "https://example.com", origin)
36+}

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