detached 73a1699

feat(cef): scroll fluidity, video playback, and diagnostic improvements

- Detect monitor refresh rate via GdkMonitor and match CEF frame rate
  (120Hz monitor gets 120fps instead of hardcoded 60fps)
- Enable Chromium smooth scrolling for mouse wheel momentum/easing
- Enable kinetic scroll flag on GTK EventControllerScroll
- Always enable AudioHandler (unblocks video decode pipeline)
- Add autoplay-policy flag for muted video autoplay (Reddit feeds)
- Inject video diagnostic JS (pierces Shadow DOM, traces MSE codecs)
  TODO: remove once video playback is stable
commit
73a16999217db83617c01044b2875fedc2d3f33a
author
brice · · 2026-03-23 18:57 UTC
committer
brice · · 2026-03-28 04:38 UTC
parent
e564832
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +153 -11
M internal/infrastructure/cef/app.go +15 -6
@@ -28,20 +28,29 @@ const maxCmdLineLogLen = 200
2828
2929 func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLine purecef.CommandLine) {
3030 log := logging.FromContext(a.engine.ctx)
31- cmdline := ""
3231 if commandLine != nil {
33- cmdline = commandLine.GetCommandLineString()
32+ // Enable Chromium's built-in smooth scrolling animation — without this,
33+ // mouse wheel scroll jumps in discrete steps with no momentum/easing.
34+ commandLine.AppendSwitch("enable-smooth-scrolling")
35+
36+ // Allow video autoplay without requiring user gesture — sites like
37+ // Reddit autoplay muted videos in the feed; without this policy
38+ // Chromium blocks them, showing an infinite spinner.
39+ commandLine.AppendSwitchWithValue("autoplay-policy", "no-user-gesture-required")
40+
41+
42+ cmdline := commandLine.GetCommandLineString()
3443 if len(cmdline) > maxCmdLineLogLen {
3544 runes := []rune(cmdline)
3645 if len(runes) > maxCmdLineLogLen {
3746 cmdline = string(runes[:maxCmdLineLogLen]) + "…"
3847 }
3948 }
49+ log.Debug().
50+ Str("process_type", processType).
51+ Str("command_line", cmdline).
52+ Msg("cef: OnBeforeCommandLineProcessing")
4053 }
41- log.Debug().
42- Str("process_type", processType).
43- Str("command_line", cmdline).
44- Msg("cef: OnBeforeCommandLineProcessing")
4554 }
4655 func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) {
4756 options := int32(purecef.SchemeOptionsSchemeOptionStandard |
@@ -28,20 +28,29 @@ const maxCmdLineLogLen = 200
28 28
29 func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLine purecef.CommandLine) {29 func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLine purecef.CommandLine) {
30 log := logging.FromContext(a.engine.ctx)30 log := logging.FromContext(a.engine.ctx)
31- cmdline := ""
32 if commandLine != nil {31 if commandLine != nil {
33- cmdline = commandLine.GetCommandLineString()32+ // Enable Chromium's built-in smooth scrolling animation — without this,
33+ // mouse wheel scroll jumps in discrete steps with no momentum/easing.
34+ commandLine.AppendSwitch("enable-smooth-scrolling")
35+
36+ // Allow video autoplay without requiring user gesture — sites like
37+ // Reddit autoplay muted videos in the feed; without this policy
38+ // Chromium blocks them, showing an infinite spinner.
39+ commandLine.AppendSwitchWithValue("autoplay-policy", "no-user-gesture-required")
40+
41+
42+ cmdline := commandLine.GetCommandLineString()
34 if len(cmdline) > maxCmdLineLogLen {43 if len(cmdline) > maxCmdLineLogLen {
35 runes := []rune(cmdline)44 runes := []rune(cmdline)
36 if len(runes) > maxCmdLineLogLen {45 if len(runes) > maxCmdLineLogLen {
37 cmdline = string(runes[:maxCmdLineLogLen]) + "…"46 cmdline = string(runes[:maxCmdLineLogLen]) + "…"
38 }47 }
39 }48 }
49+ log.Debug().
50+ Str("process_type", processType).
51+ Str("command_line", cmdline).
52+ Msg("cef: OnBeforeCommandLineProcessing")
40 }53 }
41- log.Debug().
42- Str("process_type", processType).
43- Str("command_line", cmdline).
44- Msg("cef: OnBeforeCommandLineProcessing")
45 }54 }
46 func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) {55 func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) {
47 options := int32(purecef.SchemeOptionsSchemeOptionStandard |56 options := int32(purecef.SchemeOptionsSchemeOptionStandard |
M internal/infrastructure/cef/content_injector.go +98 -0
@@ -66,6 +66,100 @@ const scrollbarAutoHideJS = `(function(){
6666 });
6767 })();`
6868
69+// videoDiagnosticJS monitors all <video> elements on the page and logs their
70+// state changes to the console. This helps diagnose playback issues in CEF OSR.
71+// TODO: remove once video playback is stable.
72+const videoDiagnosticJS = `(function(){
73+ var tag = '[VIDEO-DIAG]';
74+ var events = ['loadstart','loadedmetadata','loadeddata','canplay','canplaythrough',
75+ 'play','playing','pause','waiting','stalled','error','abort','emptied','suspend'];
76+
77+ function monitor(v, label) {
78+ if (v._dumberDiag) return;
79+ v._dumberDiag = true;
80+ console.warn(tag, label, 'src:', (v.src||'').substring(0,80),
81+ 'currentSrc:', (v.currentSrc||'').substring(0,80),
82+ 'readyState:', v.readyState, 'networkState:', v.networkState,
83+ 'preload:', v.preload, 'autoplay:', v.autoplay);
84+ events.forEach(function(evt) {
85+ v.addEventListener(evt, function() {
86+ var err = v.error ? ('code='+v.error.code+' msg='+v.error.message) : 'none';
87+ console.warn(tag, evt, label, 'ready:', v.readyState, 'net:', v.networkState,
88+ 'paused:', v.paused, 'error:', err, 'src:', (v.currentSrc||v.src||'').substring(0,80));
89+ });
90+ });
91+ }
92+
93+ // Recursively scan a root (document or shadowRoot) for video elements.
94+ function scanRoot(root, label) {
95+ root.querySelectorAll('video').forEach(function(v, i) { monitor(v, label+'#'+i); });
96+ // Pierce shadow DOMs.
97+ root.querySelectorAll('*').forEach(function(el) {
98+ if (el.shadowRoot) {
99+ scanRoot(el.shadowRoot, label+'>'+el.tagName.toLowerCase());
100+ // Observe inside shadow root too.
101+ observeRoot(el.shadowRoot, label+'>'+el.tagName.toLowerCase());
102+ }
103+ });
104+ }
105+
106+ function observeRoot(root, label) {
107+ new MutationObserver(function(muts) {
108+ muts.forEach(function(m) {
109+ m.addedNodes.forEach(function(n) {
110+ if (n.nodeName === 'VIDEO') monitor(n, label+'-dyn');
111+ if (n.querySelectorAll) {
112+ n.querySelectorAll('video').forEach(function(v) { monitor(v, label+'-nested'); });
113+ }
114+ // New element might have shadow root.
115+ if (n.shadowRoot) {
116+ scanRoot(n.shadowRoot, label+'>'+n.tagName.toLowerCase());
117+ observeRoot(n.shadowRoot, label+'>'+n.tagName.toLowerCase());
118+ }
119+ });
120+ });
121+ }).observe(root, {childList:true, subtree:true});
122+ }
123+
124+ // Patch attachShadow to automatically observe new shadow roots.
125+ var origAttach = Element.prototype.attachShadow;
126+ Element.prototype.attachShadow = function(opts) {
127+ var sr = origAttach.call(this, opts);
128+ console.warn(tag, 'shadowRoot attached on', this.tagName.toLowerCase());
129+ observeRoot(sr, 'shadow>'+this.tagName.toLowerCase());
130+ return sr;
131+ };
132+
133+ // Also intercept MediaSource to trace usage.
134+ if (window.MediaSource) {
135+ var origAddSB = MediaSource.prototype.addSourceBuffer;
136+ MediaSource.prototype.addSourceBuffer = function(mime) {
137+ console.warn(tag, 'MediaSource.addSourceBuffer:', mime,
138+ 'supported:', MediaSource.isTypeSupported(mime));
139+ try { return origAddSB.call(this, mime); }
140+ catch(e) { console.error(tag, 'addSourceBuffer FAILED:', e.message, 'mime:', mime); throw e; }
141+ };
142+ var origURL = URL.createObjectURL;
143+ URL.createObjectURL = function(obj) {
144+ var url = origURL.call(this, obj);
145+ if (obj instanceof MediaSource) {
146+ console.warn(tag, 'URL.createObjectURL(MediaSource):', url.substring(0,80));
147+ }
148+ return url;
149+ };
150+ }
151+
152+ // Initial scan.
153+ scanRoot(document, 'doc');
154+
155+ // Observe document for new elements.
156+ observeRoot(document.documentElement, 'doc');
157+
158+ console.warn(tag, 'diagnostic active, MSE:', !!window.MediaSource,
159+ 'h264:', MediaSource ? MediaSource.isTypeSupported('video/mp4; codecs="avc1.4d401e"') : 'n/a',
160+ 'aac:', MediaSource ? MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"') : 'n/a');
161+})();`
162+
69163 // contentInjector implements port.ContentInjector for the CEF engine.
70164 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.
71165 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
@@ -181,6 +275,10 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
181275 // All pages get custom scrollbar styling with auto-hide.
182276 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)
183277 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)
278+
279+ // Video playback diagnostic — logs video element state changes.
280+ // TODO: remove once video playback is stable.
281+ wv.RunJavaScript(context.Background(), videoDiagnosticJS)
184282 }
185283
186284 // injectCSS injects a CSS string as a <style> element via JavaScript.
@@ -66,6 +66,100 @@ const scrollbarAutoHideJS = `(function(){
66 });66 });
67 })();`67 })();`
68 68
69+// videoDiagnosticJS monitors all <video> elements on the page and logs their
70+// state changes to the console. This helps diagnose playback issues in CEF OSR.
71+// TODO: remove once video playback is stable.
72+const videoDiagnosticJS = `(function(){
73+ var tag = '[VIDEO-DIAG]';
74+ var events = ['loadstart','loadedmetadata','loadeddata','canplay','canplaythrough',
75+ 'play','playing','pause','waiting','stalled','error','abort','emptied','suspend'];
76+
77+ function monitor(v, label) {
78+ if (v._dumberDiag) return;
79+ v._dumberDiag = true;
80+ console.warn(tag, label, 'src:', (v.src||'').substring(0,80),
81+ 'currentSrc:', (v.currentSrc||'').substring(0,80),
82+ 'readyState:', v.readyState, 'networkState:', v.networkState,
83+ 'preload:', v.preload, 'autoplay:', v.autoplay);
84+ events.forEach(function(evt) {
85+ v.addEventListener(evt, function() {
86+ var err = v.error ? ('code='+v.error.code+' msg='+v.error.message) : 'none';
87+ console.warn(tag, evt, label, 'ready:', v.readyState, 'net:', v.networkState,
88+ 'paused:', v.paused, 'error:', err, 'src:', (v.currentSrc||v.src||'').substring(0,80));
89+ });
90+ });
91+ }
92+
93+ // Recursively scan a root (document or shadowRoot) for video elements.
94+ function scanRoot(root, label) {
95+ root.querySelectorAll('video').forEach(function(v, i) { monitor(v, label+'#'+i); });
96+ // Pierce shadow DOMs.
97+ root.querySelectorAll('*').forEach(function(el) {
98+ if (el.shadowRoot) {
99+ scanRoot(el.shadowRoot, label+'>'+el.tagName.toLowerCase());
100+ // Observe inside shadow root too.
101+ observeRoot(el.shadowRoot, label+'>'+el.tagName.toLowerCase());
102+ }
103+ });
104+ }
105+
106+ function observeRoot(root, label) {
107+ new MutationObserver(function(muts) {
108+ muts.forEach(function(m) {
109+ m.addedNodes.forEach(function(n) {
110+ if (n.nodeName === 'VIDEO') monitor(n, label+'-dyn');
111+ if (n.querySelectorAll) {
112+ n.querySelectorAll('video').forEach(function(v) { monitor(v, label+'-nested'); });
113+ }
114+ // New element might have shadow root.
115+ if (n.shadowRoot) {
116+ scanRoot(n.shadowRoot, label+'>'+n.tagName.toLowerCase());
117+ observeRoot(n.shadowRoot, label+'>'+n.tagName.toLowerCase());
118+ }
119+ });
120+ });
121+ }).observe(root, {childList:true, subtree:true});
122+ }
123+
124+ // Patch attachShadow to automatically observe new shadow roots.
125+ var origAttach = Element.prototype.attachShadow;
126+ Element.prototype.attachShadow = function(opts) {
127+ var sr = origAttach.call(this, opts);
128+ console.warn(tag, 'shadowRoot attached on', this.tagName.toLowerCase());
129+ observeRoot(sr, 'shadow>'+this.tagName.toLowerCase());
130+ return sr;
131+ };
132+
133+ // Also intercept MediaSource to trace usage.
134+ if (window.MediaSource) {
135+ var origAddSB = MediaSource.prototype.addSourceBuffer;
136+ MediaSource.prototype.addSourceBuffer = function(mime) {
137+ console.warn(tag, 'MediaSource.addSourceBuffer:', mime,
138+ 'supported:', MediaSource.isTypeSupported(mime));
139+ try { return origAddSB.call(this, mime); }
140+ catch(e) { console.error(tag, 'addSourceBuffer FAILED:', e.message, 'mime:', mime); throw e; }
141+ };
142+ var origURL = URL.createObjectURL;
143+ URL.createObjectURL = function(obj) {
144+ var url = origURL.call(this, obj);
145+ if (obj instanceof MediaSource) {
146+ console.warn(tag, 'URL.createObjectURL(MediaSource):', url.substring(0,80));
147+ }
148+ return url;
149+ };
150+ }
151+
152+ // Initial scan.
153+ scanRoot(document, 'doc');
154+
155+ // Observe document for new elements.
156+ observeRoot(document.documentElement, 'doc');
157+
158+ console.warn(tag, 'diagnostic active, MSE:', !!window.MediaSource,
159+ 'h264:', MediaSource ? MediaSource.isTypeSupported('video/mp4; codecs="avc1.4d401e"') : 'n/a',
160+ 'aac:', MediaSource ? MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"') : 'n/a');
161+})();`
162+
69 // contentInjector implements port.ContentInjector for the CEF engine.163 // contentInjector implements port.ContentInjector for the CEF engine.
70 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.164 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.
71 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd165 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
@@ -181,6 +275,10 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
181 // All pages get custom scrollbar styling with auto-hide.275 // All pages get custom scrollbar styling with auto-hide.
182 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)276 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)
183 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)277 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)
278+
279+ // Video playback diagnostic — logs video element state changes.
280+ // TODO: remove once video playback is stable.
281+ wv.RunJavaScript(context.Background(), videoDiagnosticJS)
184 }282 }
185 283
186 // injectCSS injects a CSS string as a <style> element via JavaScript.284 // injectCSS injects a CSS string as a <style> element via JavaScript.
M internal/infrastructure/cef/engine_init.go +34 -0
@@ -127,6 +127,14 @@ func wireEngine(
127127
128128 scale := detectHiDPIScale(logger)
129129
130+ // If frame rate is the static default (60), try to match the monitor's
131+ // actual refresh rate for smoother scrolling on high-refresh displays.
132+ if windowlessFrameRate == 60 {
133+ if hz := detectMonitorRefreshRate(logger); hz > 0 {
134+ windowlessFrameRate = hz
135+ }
136+ }
137+
130138 factory := newWebViewFactory(eng, gl, webViewFactoryOptions{
131139 scale: scale,
132140 windowlessFrameRate: windowlessFrameRate,
@@ -184,6 +192,32 @@ func detectHiDPIScale(logger *zerolog.Logger) int32 {
184192 return 1
185193 }
186194
195+// detectMonitorRefreshRate queries the primary GDK monitor for its refresh rate.
196+// Returns the rate in Hz (e.g., 120 for a 120Hz display), or 0 if detection fails.
197+func detectMonitorRefreshRate(logger *zerolog.Logger) int32 {
198+ display := gdk.DisplayGetDefault()
199+ if display == nil {
200+ return 0
201+ }
202+ monitors := display.GetMonitors()
203+ if monitors == nil {
204+ return 0
205+ }
206+ obj := monitors.GetObject(0)
207+ if obj == nil {
208+ return 0
209+ }
210+ mon := &gdk.Monitor{}
211+ mon.SetGoPointer(obj.GoPointer())
212+ milliHz := mon.GetRefreshRate()
213+ if milliHz <= 0 {
214+ return 0
215+ }
216+ hz := int32(milliHz / 1000)
217+ logger.Info().Int32("refresh_rate_hz", hz).Msg("cef: detected monitor refresh rate")
218+ return hz
219+}
220+
187221 // appendIfMissing appends flag to args if it's not already present.
188222 func appendIfMissing(args []string, flag string) []string {
189223 for _, a := range args {
@@ -127,6 +127,14 @@ func wireEngine(
127 127
128 scale := detectHiDPIScale(logger)128 scale := detectHiDPIScale(logger)
129 129
130+ // If frame rate is the static default (60), try to match the monitor's
131+ // actual refresh rate for smoother scrolling on high-refresh displays.
132+ if windowlessFrameRate == 60 {
133+ if hz := detectMonitorRefreshRate(logger); hz > 0 {
134+ windowlessFrameRate = hz
135+ }
136+ }
137+
130 factory := newWebViewFactory(eng, gl, webViewFactoryOptions{138 factory := newWebViewFactory(eng, gl, webViewFactoryOptions{
131 scale: scale,139 scale: scale,
132 windowlessFrameRate: windowlessFrameRate,140 windowlessFrameRate: windowlessFrameRate,
@@ -184,6 +192,32 @@ func detectHiDPIScale(logger *zerolog.Logger) int32 {
184 return 1192 return 1
185 }193 }
186 194
195+// detectMonitorRefreshRate queries the primary GDK monitor for its refresh rate.
196+// Returns the rate in Hz (e.g., 120 for a 120Hz display), or 0 if detection fails.
197+func detectMonitorRefreshRate(logger *zerolog.Logger) int32 {
198+ display := gdk.DisplayGetDefault()
199+ if display == nil {
200+ return 0
201+ }
202+ monitors := display.GetMonitors()
203+ if monitors == nil {
204+ return 0
205+ }
206+ obj := monitors.GetObject(0)
207+ if obj == nil {
208+ return 0
209+ }
210+ mon := &gdk.Monitor{}
211+ mon.SetGoPointer(obj.GoPointer())
212+ milliHz := mon.GetRefreshRate()
213+ if milliHz <= 0 {
214+ return 0
215+ }
216+ hz := int32(milliHz / 1000)
217+ logger.Info().Int32("refresh_rate_hz", hz).Msg("cef: detected monitor refresh rate")
218+ return hz
219+}
220+
187 // appendIfMissing appends flag to args if it's not already present.221 // appendIfMissing appends flag to args if it's not already present.
188 func appendIfMissing(args []string, flag string) []string {222 func appendIfMissing(args []string, flag string) []string {
189 for _, a := range args {223 for _, a := range args {
M internal/infrastructure/cef/input_bridge.go +1 -1
@@ -107,7 +107,7 @@ func (ib *inputBridge) attachTo(glArea *gtk.GLArea) {
107107 glArea.AddController(&click.EventController)
108108
109109 // Scroll
110- scroll := gtk.NewEventControllerScroll(gtk.EventControllerScrollBothAxesValue)
110+ scroll := gtk.NewEventControllerScroll(gtk.EventControllerScrollBothAxesValue | gtk.EventControllerScrollKineticValue)
111111 scrollCb := func(_ gtk.EventControllerScroll, dx, dy float64) bool {
112112 ib.onScroll(dx, dy)
113113 return true
@@ -107,7 +107,7 @@ func (ib *inputBridge) attachTo(glArea *gtk.GLArea) {
107 glArea.AddController(&click.EventController)107 glArea.AddController(&click.EventController)
108 108
109 // Scroll109 // Scroll
110- scroll := gtk.NewEventControllerScroll(gtk.EventControllerScrollBothAxesValue)110+ scroll := gtk.NewEventControllerScroll(gtk.EventControllerScrollBothAxesValue | gtk.EventControllerScrollKineticValue)
111 scrollCb := func(_ gtk.EventControllerScroll, dx, dy float64) bool {111 scrollCb := func(_ gtk.EventControllerScroll, dx, dy float64) bool {
112 ib.onScroll(dx, dy)112 ib.onScroll(dx, dy)
113 return true113 return true
M internal/infrastructure/cef/webview_handlers.go +5 -4
@@ -34,10 +34,11 @@ var (
3434 // ===========================================================================
3535
3636 func (h *handlerSet) GetAudioHandler() purecef.AudioHandler {
37- if h.enableAudioHandler {
38- return h
39- }
40- return nil
37+ // Always return the audio handler — without it, CEF has no audio output
38+ // path and refuses to start media decoding, breaking all video playback.
39+ // The handler accepts audio but discards packets (no-op) until a real
40+ // audio backend (PipeWire/PulseAudio) is wired up.
41+ return h
4142 }
4243 func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil }
4344 func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler {
@@ -34,10 +34,11 @@ var (
34 // ===========================================================================34 // ===========================================================================
35 35
36 func (h *handlerSet) GetAudioHandler() purecef.AudioHandler {36 func (h *handlerSet) GetAudioHandler() purecef.AudioHandler {
37- if h.enableAudioHandler {37+ // Always return the audio handler — without it, CEF has no audio output
38- return h38+ // path and refuses to start media decoding, breaking all video playback.
39- }39+ // The handler accepts audio but discards packets (no-op) until a real
40- return nil40+ // audio backend (PipeWire/PulseAudio) is wired up.
41+ return h
41 }42 }
42 func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil }43 func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil }
43 func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler {44 func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler {

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