detached c3fc1ba

fix: bridge Wayland clipboard for CEF OSR (paste + copy)

CEF in OSR mode cannot access the Wayland/X11 system clipboard.

Paste (Ctrl+V): intercept in input bridge, read text from GDK clipboard
via ReadTextAsync, inject into CEF via ImeCommitText.

Copy (Ctrl+C/X): inject JS copy/cut event listeners on all pages that
send selected text to Go via fetch /api/clipboard-set, then write to
GDK clipboard via glib.IdleAddOnce on the GTK main thread.
commit
c3fc1baafc9164008dbdc627aa4c972956572e7a
author
brice · · 2026-03-25 06:07 UTC
committer
brice · · 2026-03-28 04:39 UTC
parent
37f0ef7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +135 -0
M internal/infrastructure/cef/content_injector.go +30 -0
@@ -379,6 +379,33 @@ const redditDirectVideoJS = `(function(){
379379 }, 1000);
380380 })();`
381381
382+// clipboardCopyBridgeJS intercepts copy/cut events and sends the selected text
383+// back to Go via the message bridge so it can be written to the GDK clipboard.
384+// Without this, Ctrl+C in CEF OSR mode writes to CEF's internal clipboard but
385+// not the Wayland/X11 system clipboard.
386+const clipboardCopyBridgeJS = `(function(){
387+ if (window.__dumberClipboardBridge) return;
388+ window.__dumberClipboardBridge = true;
389+ function sendToClipboard(text) {
390+ if (!text) return;
391+ var body = JSON.stringify({text: text});
392+ var encoded = typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';
393+ if (!encoded) return;
394+ fetch('dumb:///api/clipboard-set', {
395+ method: 'POST',
396+ headers: {'X-Dumber-Body': encoded}
397+ }).catch(function(){});
398+ }
399+ document.addEventListener('copy', function() {
400+ var sel = window.getSelection();
401+ if (sel) sendToClipboard(sel.toString());
402+ });
403+ document.addEventListener('cut', function() {
404+ var sel = window.getSelection();
405+ if (sel) sendToClipboard(sel.toString());
406+ });
407+})();`
408+
382409 // contentInjector implements port.ContentInjector for the CEF engine.
383410 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.
384411 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
@@ -503,6 +530,9 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
503530 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)
504531 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)
505532
533+ // All pages get clipboard copy bridge (CEF OSR can't write to Wayland clipboard).
534+ wv.RunJavaScript(context.Background(), clipboardCopyBridgeJS)
535+
506536 // Video playback diagnostic — logs video element state changes.
507537 // Gated behind DUMBER_VIDEO_DIAG=1 environment variable.
508538 if ci.videoDiagnosticsEnabled {
@@ -379,6 +379,33 @@ const redditDirectVideoJS = `(function(){
379 }, 1000);379 }, 1000);
380 })();`380 })();`
381 381
382+// clipboardCopyBridgeJS intercepts copy/cut events and sends the selected text
383+// back to Go via the message bridge so it can be written to the GDK clipboard.
384+// Without this, Ctrl+C in CEF OSR mode writes to CEF's internal clipboard but
385+// not the Wayland/X11 system clipboard.
386+const clipboardCopyBridgeJS = `(function(){
387+ if (window.__dumberClipboardBridge) return;
388+ window.__dumberClipboardBridge = true;
389+ function sendToClipboard(text) {
390+ if (!text) return;
391+ var body = JSON.stringify({text: text});
392+ var encoded = typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';
393+ if (!encoded) return;
394+ fetch('dumb:///api/clipboard-set', {
395+ method: 'POST',
396+ headers: {'X-Dumber-Body': encoded}
397+ }).catch(function(){});
398+ }
399+ document.addEventListener('copy', function() {
400+ var sel = window.getSelection();
401+ if (sel) sendToClipboard(sel.toString());
402+ });
403+ document.addEventListener('cut', function() {
404+ var sel = window.getSelection();
405+ if (sel) sendToClipboard(sel.toString());
406+ });
407+})();`
408+
382 // contentInjector implements port.ContentInjector for the CEF engine.409 // contentInjector implements port.ContentInjector for the CEF engine.
383 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.410 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.
384 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd411 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
@@ -503,6 +530,9 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
503 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)530 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)
504 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)531 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)
505 532
533+ // All pages get clipboard copy bridge (CEF OSR can't write to Wayland clipboard).
534+ wv.RunJavaScript(context.Background(), clipboardCopyBridgeJS)
535+
506 // Video playback diagnostic — logs video element state changes.536 // Video playback diagnostic — logs video element state changes.
507 // Gated behind DUMBER_VIDEO_DIAG=1 environment variable.537 // Gated behind DUMBER_VIDEO_DIAG=1 environment variable.
508 if ci.videoDiagnosticsEnabled {538 if ci.videoDiagnosticsEnabled {
M internal/infrastructure/cef/engine_init.go +15 -0
@@ -11,6 +11,7 @@ import (
1111
1212 purecef "github.com/bnema/purego-cef/cef"
1313 "github.com/bnema/puregotk/v4/gdk"
14+ "github.com/bnema/puregotk/v4/glib"
1415 "github.com/rs/zerolog"
1516
1617 "github.com/bnema/dumber/assets"
@@ -181,6 +182,20 @@ func wireEngine(
181182 schemeHandler := newDumbSchemeHandler(ctx, messageRouter, mediaTranscoder)
182183 schemeHandler.setAssets(assets.WebUIAssets)
183184
185+ // Bridge clipboard writes from CEF JS → GDK system clipboard.
186+ // The callback is invoked on the CEF IO thread, so we schedule the
187+ // GDK write on the GTK main loop via glib.IdleAddOnce.
188+ schemeHandler.onClipboardSet = func(text string) {
189+ fn := glib.SourceOnceFunc(func(_ uintptr) {
190+ if display := gdk.DisplayGetDefault(); display != nil {
191+ if cb := display.GetClipboard(); cb != nil {
192+ cb.SetText(text)
193+ }
194+ }
195+ })
196+ glib.IdleAddOnce(&fn, 0)
197+ }
198+
184199 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
185200
186201 result := purecef.RegisterSchemeHandlerFactory("dumb", "", schemeFactory)
@@ -11,6 +11,7 @@ import (
11 11
12 purecef "github.com/bnema/purego-cef/cef"12 purecef "github.com/bnema/purego-cef/cef"
13 "github.com/bnema/puregotk/v4/gdk"13 "github.com/bnema/puregotk/v4/gdk"
14+ "github.com/bnema/puregotk/v4/glib"
14 "github.com/rs/zerolog"15 "github.com/rs/zerolog"
15 16
16 "github.com/bnema/dumber/assets"17 "github.com/bnema/dumber/assets"
@@ -181,6 +182,20 @@ func wireEngine(
181 schemeHandler := newDumbSchemeHandler(ctx, messageRouter, mediaTranscoder)182 schemeHandler := newDumbSchemeHandler(ctx, messageRouter, mediaTranscoder)
182 schemeHandler.setAssets(assets.WebUIAssets)183 schemeHandler.setAssets(assets.WebUIAssets)
183 184
185+ // Bridge clipboard writes from CEF JS → GDK system clipboard.
186+ // The callback is invoked on the CEF IO thread, so we schedule the
187+ // GDK write on the GTK main loop via glib.IdleAddOnce.
188+ schemeHandler.onClipboardSet = func(text string) {
189+ fn := glib.SourceOnceFunc(func(_ uintptr) {
190+ if display := gdk.DisplayGetDefault(); display != nil {
191+ if cb := display.GetClipboard(); cb != nil {
192+ cb.SetText(text)
193+ }
194+ }
195+ })
196+ glib.IdleAddOnce(&fn, 0)
197+ }
198+
184 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)199 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
185 200
186 result := purecef.RegisterSchemeHandlerFactory("dumb", "", schemeFactory)201 result := purecef.RegisterSchemeHandlerFactory("dumb", "", schemeFactory)
M internal/infrastructure/cef/input_bridge.go +61 -0
@@ -5,6 +5,7 @@ import (
55
66 purecef "github.com/bnema/purego-cef/cef"
77 "github.com/bnema/puregotk/v4/gdk"
8+ "github.com/bnema/puregotk/v4/gio"
89 "github.com/bnema/puregotk/v4/gtk"
910 )
1011
@@ -45,6 +46,11 @@ type inputBridge struct {
4546 // carry their own coordinates from GDK.
4647 lastX, lastY float64
4748
49+ // GDK clipboard for paste support. CEF in OSR mode cannot access the
50+ // Wayland clipboard directly, so we read from GDK and inject via
51+ // ImeCommitText. Set once during attachTo, read-only afterwards.
52+ clipboard *gdk.Clipboard
53+
4854 // IMContext for dead key / compose sequence support. Held as a field
4955 // to prevent garbage collection while the controller is alive.
5056 imContext *gtk.IMContextSimple
@@ -70,6 +76,12 @@ func (ib *inputBridge) setHost(host purecef.BrowserHost) {
7076
7177 // attachTo creates GDK event controllers and connects them to the given GLArea.
7278 func (ib *inputBridge) attachTo(glArea *gtk.GLArea) {
79+ // Acquire GDK clipboard for paste support. CEF OSR can't access the
80+ // Wayland clipboard, so we bridge it via GDK → ImeCommitText.
81+ if display := gdk.DisplayGetDefault(); display != nil {
82+ ib.clipboard = display.GetClipboard()
83+ }
84+
7385 // Motion (mouse move + enter/leave)
7486 motion := gtk.NewEventControllerMotion()
7587 motionCb := func(g gtk.EventControllerMotion, x, y float64) {
@@ -162,6 +174,16 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) {
162174
163175 keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
164176 mods := uint(state)
177+
178+ // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the
179+ // Wayland/X11 clipboard, so we read from GDK and inject via
180+ // ImeCommitText instead of letting CEF handle the paste.
181+ if mods&uint(gdk.ControlMaskValue) != 0 &&
182+ (keyval == gdkKeyLowercaseV || keyval == gdkKeyUppercaseV) {
183+ ib.pasteFromClipboard()
184+ return true
185+ }
186+
165187 ib.onKeyPress(keyval, keycode, mods)
166188
167189 // Let modifier combos and function keys propagate to the window's
@@ -354,6 +376,41 @@ func (ib *inputBridge) onKeyRelease(keyval, keycode, mods uint) {
354376 host.SendKeyEvent(&evt)
355377 }
356378
379+// pasteFromClipboard reads text from the GDK clipboard asynchronously and
380+// injects it into the focused CEF input via ImeCommitText. This bridges the
381+// gap where CEF OSR mode cannot read the Wayland/X11 system clipboard.
382+func (ib *inputBridge) pasteFromClipboard() {
383+ cb := ib.clipboard
384+ if cb == nil {
385+ return
386+ }
387+ ib.mu.Lock()
388+ host := ib.host
389+ ib.mu.Unlock()
390+ if host == nil {
391+ return
392+ }
393+
394+ asyncCb := gio.AsyncReadyCallback(func(_, resultPtr, _ uintptr) {
395+ result := &gio.AsyncResultBase{}
396+ result.SetGoPointer(resultPtr)
397+ text, err := cb.ReadTextFinish(result)
398+ if err != nil || text == "" {
399+ return
400+ }
401+ // The GLib async callback runs on the main loop (UI thread),
402+ // which is the correct thread for CEF ImeCommitText.
403+ ib.mu.Lock()
404+ h := ib.host
405+ ib.mu.Unlock()
406+ if h != nil {
407+ h.ImeCommitText(text, nil, 0)
408+ }
409+ })
410+
411+ cb.ReadTextAsync(nil, &asyncCb, 0)
412+}
413+
357414 // ---------------------------------------------------------------------------
358415 // Translation helpers
359416 // ---------------------------------------------------------------------------
@@ -436,6 +493,10 @@ const (
436493 gdkKeyEnd = 0xff57
437494 gdkKeyPageUp = 0xff55
438495 gdkKeyPageDown = 0xff56
496+
497+ // Clipboard shortcut keyvals.
498+ gdkKeyLowercaseV = 0x076
499+ gdkKeyUppercaseV = 0x056
439500 )
440501
441502 // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events.
@@ -5,6 +5,7 @@ import (
5 5
6 purecef "github.com/bnema/purego-cef/cef"6 purecef "github.com/bnema/purego-cef/cef"
7 "github.com/bnema/puregotk/v4/gdk"7 "github.com/bnema/puregotk/v4/gdk"
8+ "github.com/bnema/puregotk/v4/gio"
8 "github.com/bnema/puregotk/v4/gtk"9 "github.com/bnema/puregotk/v4/gtk"
9 )10 )
10 11
@@ -45,6 +46,11 @@ type inputBridge struct {
45 // carry their own coordinates from GDK.46 // carry their own coordinates from GDK.
46 lastX, lastY float6447 lastX, lastY float64
47 48
49+ // GDK clipboard for paste support. CEF in OSR mode cannot access the
50+ // Wayland clipboard directly, so we read from GDK and inject via
51+ // ImeCommitText. Set once during attachTo, read-only afterwards.
52+ clipboard *gdk.Clipboard
53+
48 // IMContext for dead key / compose sequence support. Held as a field54 // IMContext for dead key / compose sequence support. Held as a field
49 // to prevent garbage collection while the controller is alive.55 // to prevent garbage collection while the controller is alive.
50 imContext *gtk.IMContextSimple56 imContext *gtk.IMContextSimple
@@ -70,6 +76,12 @@ func (ib *inputBridge) setHost(host purecef.BrowserHost) {
70 76
71 // attachTo creates GDK event controllers and connects them to the given GLArea.77 // attachTo creates GDK event controllers and connects them to the given GLArea.
72 func (ib *inputBridge) attachTo(glArea *gtk.GLArea) {78 func (ib *inputBridge) attachTo(glArea *gtk.GLArea) {
79+ // Acquire GDK clipboard for paste support. CEF OSR can't access the
80+ // Wayland clipboard, so we bridge it via GDK → ImeCommitText.
81+ if display := gdk.DisplayGetDefault(); display != nil {
82+ ib.clipboard = display.GetClipboard()
83+ }
84+
73 // Motion (mouse move + enter/leave)85 // Motion (mouse move + enter/leave)
74 motion := gtk.NewEventControllerMotion()86 motion := gtk.NewEventControllerMotion()
75 motionCb := func(g gtk.EventControllerMotion, x, y float64) {87 motionCb := func(g gtk.EventControllerMotion, x, y float64) {
@@ -162,6 +174,16 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) {
162 174
163 keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {175 keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
164 mods := uint(state)176 mods := uint(state)
177+
178+ // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the
179+ // Wayland/X11 clipboard, so we read from GDK and inject via
180+ // ImeCommitText instead of letting CEF handle the paste.
181+ if mods&uint(gdk.ControlMaskValue) != 0 &&
182+ (keyval == gdkKeyLowercaseV || keyval == gdkKeyUppercaseV) {
183+ ib.pasteFromClipboard()
184+ return true
185+ }
186+
165 ib.onKeyPress(keyval, keycode, mods)187 ib.onKeyPress(keyval, keycode, mods)
166 188
167 // Let modifier combos and function keys propagate to the window's189 // Let modifier combos and function keys propagate to the window's
@@ -354,6 +376,41 @@ func (ib *inputBridge) onKeyRelease(keyval, keycode, mods uint) {
354 host.SendKeyEvent(&evt)376 host.SendKeyEvent(&evt)
355 }377 }
356 378
379+// pasteFromClipboard reads text from the GDK clipboard asynchronously and
380+// injects it into the focused CEF input via ImeCommitText. This bridges the
381+// gap where CEF OSR mode cannot read the Wayland/X11 system clipboard.
382+func (ib *inputBridge) pasteFromClipboard() {
383+ cb := ib.clipboard
384+ if cb == nil {
385+ return
386+ }
387+ ib.mu.Lock()
388+ host := ib.host
389+ ib.mu.Unlock()
390+ if host == nil {
391+ return
392+ }
393+
394+ asyncCb := gio.AsyncReadyCallback(func(_, resultPtr, _ uintptr) {
395+ result := &gio.AsyncResultBase{}
396+ result.SetGoPointer(resultPtr)
397+ text, err := cb.ReadTextFinish(result)
398+ if err != nil || text == "" {
399+ return
400+ }
401+ // The GLib async callback runs on the main loop (UI thread),
402+ // which is the correct thread for CEF ImeCommitText.
403+ ib.mu.Lock()
404+ h := ib.host
405+ ib.mu.Unlock()
406+ if h != nil {
407+ h.ImeCommitText(text, nil, 0)
408+ }
409+ })
410+
411+ cb.ReadTextAsync(nil, &asyncCb, 0)
412+}
413+
357 // ---------------------------------------------------------------------------414 // ---------------------------------------------------------------------------
358 // Translation helpers415 // Translation helpers
359 // ---------------------------------------------------------------------------416 // ---------------------------------------------------------------------------
@@ -436,6 +493,10 @@ const (
436 gdkKeyEnd = 0xff57493 gdkKeyEnd = 0xff57
437 gdkKeyPageUp = 0xff55494 gdkKeyPageUp = 0xff55
438 gdkKeyPageDown = 0xff56495 gdkKeyPageDown = 0xff56
496+
497+ // Clipboard shortcut keyvals.
498+ gdkKeyLowercaseV = 0x076
499+ gdkKeyUppercaseV = 0x056
439 )500 )
440 501
441 // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events.502 // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events.
M internal/infrastructure/cef/scheme_handler.go +29 -0
@@ -47,6 +47,10 @@ type dumbSchemeHandler struct {
4747 logger zerolog.Logger
4848 hwSurveyor *env.HardwareSurveyor
4949 mu sync.RWMutex
50+
51+ // onClipboardSet is called when JS sends copied text via /api/clipboard-set.
52+ // Set by the engine to write to the GDK system clipboard.
53+ onClipboardSet func(text string)
5054 }
5155
5256 // newDumbSchemeHandler creates a handler for internal CEF pages.
@@ -136,6 +140,9 @@ func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Reque
136140 case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")):
137141 return h.handleTranscodeAPI(request)
138142
143+ case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"):
144+ return h.handleClipboardSet(request)
145+
139146 default:
140147 return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
141148 }
@@ -207,6 +214,28 @@ func (h *dumbSchemeHandler) handleConfigAPI(cfg *config.Config) purecef.Resource
207214 return h.newRawResourceHandler(http.StatusOK, "application/json", data)
208215 }
209216
217+// handleClipboardSet receives copied text from JS copy/cut events and writes
218+// it to the system clipboard via the engine callback.
219+func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.ResourceHandler {
220+ body := readBodyFromHeader(request)
221+ if body == nil {
222+ return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
223+ }
224+
225+ var payload struct {
226+ Text string `json:"text"`
227+ }
228+ if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" {
229+ return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"})
230+ }
231+
232+ if h.onClipboardSet != nil {
233+ h.onClipboardSet(payload.Text)
234+ }
235+
236+ return h.newJSONResourceHandler(http.StatusOK, map[string]string{"ok": "true"})
237+}
238+
210239 func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler {
211240 return purecef.NewResourceHandler(&staticResourceHandler{
212241 contentType: "text/plain; charset=utf-8",
@@ -47,6 +47,10 @@ type dumbSchemeHandler struct {
47 logger zerolog.Logger47 logger zerolog.Logger
48 hwSurveyor *env.HardwareSurveyor48 hwSurveyor *env.HardwareSurveyor
49 mu sync.RWMutex49 mu sync.RWMutex
50+
51+ // onClipboardSet is called when JS sends copied text via /api/clipboard-set.
52+ // Set by the engine to write to the GDK system clipboard.
53+ onClipboardSet func(text string)
50 }54 }
51 55
52 // newDumbSchemeHandler creates a handler for internal CEF pages.56 // newDumbSchemeHandler creates a handler for internal CEF pages.
@@ -136,6 +140,9 @@ func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Reque
136 case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")):140 case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")):
137 return h.handleTranscodeAPI(request)141 return h.handleTranscodeAPI(request)
138 142
143+ case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"):
144+ return h.handleClipboardSet(request)
145+
139 default:146 default:
140 return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})147 return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
141 }148 }
@@ -207,6 +214,28 @@ func (h *dumbSchemeHandler) handleConfigAPI(cfg *config.Config) purecef.Resource
207 return h.newRawResourceHandler(http.StatusOK, "application/json", data)214 return h.newRawResourceHandler(http.StatusOK, "application/json", data)
208 }215 }
209 216
217+// handleClipboardSet receives copied text from JS copy/cut events and writes
218+// it to the system clipboard via the engine callback.
219+func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.ResourceHandler {
220+ body := readBodyFromHeader(request)
221+ if body == nil {
222+ return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
223+ }
224+
225+ var payload struct {
226+ Text string `json:"text"`
227+ }
228+ if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" {
229+ return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"})
230+ }
231+
232+ if h.onClipboardSet != nil {
233+ h.onClipboardSet(payload.Text)
234+ }
235+
236+ return h.newJSONResourceHandler(http.StatusOK, map[string]string{"ok": "true"})
237+}
238+
210 func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler {239 func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler {
211 return purecef.NewResourceHandler(&staticResourceHandler{240 return purecef.NewResourceHandler(&staticResourceHandler{
212 contentType: "text/plain; charset=utf-8",241 contentType: "text/plain; charset=utf-8",

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