detached 105c961

refactor(webkit): align WebViewID to port interface and simplify JS execution API

commit
105c9619435bc3c68db0db5aa390144aa6f01f24
author
brice · · 2025-12-14 19:28 UTC
committer
brice · · 2025-12-14 22:12 UTC
parent
3385450
signature
unsigned
4 files · +55 -83
M internal/infrastructure/webkit/context.go +1 -1
@@ -74,7 +74,7 @@ func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitCon
7474 // initNetworkSession creates and configures the persistent network session.
7575 func (c *WebKitContext) initNetworkSession() error {
7676 // Create persistent network session
77- session := webkit.NewNetworkSession(c.dataDir, c.cacheDir)
77+ session := webkit.NewNetworkSession(&c.dataDir, &c.cacheDir)
7878 if session == nil {
7979 return fmt.Errorf("failed to create network session")
8080 }
@@ -74,7 +74,7 @@ func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitCon
74 // initNetworkSession creates and configures the persistent network session.74 // initNetworkSession creates and configures the persistent network session.
75 func (c *WebKitContext) initNetworkSession() error {75 func (c *WebKitContext) initNetworkSession() error {
76 // Create persistent network session76 // Create persistent network session
77- session := webkit.NewNetworkSession(c.dataDir, c.cacheDir)77+ session := webkit.NewNetworkSession(&c.dataDir, &c.cacheDir)
78 if session == nil {78 if session == nil {
79 return fmt.Errorf("failed to create network session")79 return fmt.Errorf("failed to create network session")
80 }80 }
M internal/infrastructure/webkit/injector.go +15 -10
@@ -174,7 +174,8 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
174174 }
175175 }
176176
177-// InjectStyles adds the component CSS to the isolated world.
177+// InjectStyles injects component CSS as a JS variable into the isolated world.
178+// The shadow DOM code reads window.__dumber_component_styles and injects it into the shadow root.
178179 func (ci *ContentInjector) InjectStyles(ctx context.Context, ucm *webkit.UserContentManager) {
179180 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
180181
@@ -188,19 +189,23 @@ func (ci *ContentInjector) InjectStyles(ctx context.Context, ucm *webkit.UserCon
188189 return
189190 }
190191
191- style := webkit.NewUserStyleSheetForWorld(
192- assets.ComponentStyles,
192+ // Inject CSS as a JS string variable in isolated world at document-start
193+ // The shadow DOM code (shadowHost.ts) reads this and injects into shadow root
194+ // Using %q properly escapes the CSS for JavaScript
195+ componentStylesScript := fmt.Sprintf("window.__dumber_component_styles=%q;", assets.ComponentStyles)
196+ script := webkit.NewUserScriptForWorld(
197+ componentStylesScript,
193198 webkit.UserContentInjectTopFrameValue,
194- webkit.UserStyleLevelUserValue,
199+ webkit.UserScriptInjectAtDocumentStartValue,
195200 ScriptWorldName,
196- ci.allowList,
197- ci.blockList,
201+ nil,
202+ nil,
198203 )
199- if style == nil {
200- log.Warn().Msg("failed to create user style sheet")
204+ if script == nil {
205+ log.Warn().Msg("failed to create component styles script")
201206 return
202207 }
203208
204- ucm.AddStyleSheet(style)
205- log.Debug().Msg("injected component styles")
209+ ucm.AddScript(script)
210+ log.Debug().Int("css_bytes", len(assets.ComponentStyles)).Msg("injected component styles")
206211 }
@@ -174,7 +174,8 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
174 }174 }
175 }175 }
176 176
177-// InjectStyles adds the component CSS to the isolated world.177+// InjectStyles injects component CSS as a JS variable into the isolated world.
178+// The shadow DOM code reads window.__dumber_component_styles and injects it into the shadow root.
178 func (ci *ContentInjector) InjectStyles(ctx context.Context, ucm *webkit.UserContentManager) {179 func (ci *ContentInjector) InjectStyles(ctx context.Context, ucm *webkit.UserContentManager) {
179 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()180 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
180 181
@@ -188,19 +189,23 @@ func (ci *ContentInjector) InjectStyles(ctx context.Context, ucm *webkit.UserCon
188 return189 return
189 }190 }
190 191
191- style := webkit.NewUserStyleSheetForWorld(192+ // Inject CSS as a JS string variable in isolated world at document-start
192- assets.ComponentStyles,193+ // The shadow DOM code (shadowHost.ts) reads this and injects into shadow root
194+ // Using %q properly escapes the CSS for JavaScript
195+ componentStylesScript := fmt.Sprintf("window.__dumber_component_styles=%q;", assets.ComponentStyles)
196+ script := webkit.NewUserScriptForWorld(
197+ componentStylesScript,
193 webkit.UserContentInjectTopFrameValue,198 webkit.UserContentInjectTopFrameValue,
194- webkit.UserStyleLevelUserValue,199+ webkit.UserScriptInjectAtDocumentStartValue,
195 ScriptWorldName,200 ScriptWorldName,
196- ci.allowList,201+ nil,
197- ci.blockList,202+ nil,
198 )203 )
199- if style == nil {204+ if script == nil {
200- log.Warn().Msg("failed to create user style sheet")205+ log.Warn().Msg("failed to create component styles script")
201 return206 return
202 }207 }
203 208
204- ucm.AddStyleSheet(style)209+ ucm.AddScript(script)
205- log.Debug().Msg("injected component styles")210+ log.Debug().Int("css_bytes", len(assets.ComponentStyles)).Msg("injected component styles")
206 }211 }
M internal/infrastructure/webkit/scheme_handler.go +1 -1
@@ -172,7 +172,7 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response
172172 contentType = "text/html"
173173 }
174174
175- req.Finish(&stream.InputStream, int64(len(response.Data)), contentType)
175+ req.Finish(&stream.InputStream, int64(len(response.Data)), &contentType)
176176 }
177177
178178 // RegisterWithContext registers the dumb:// scheme with a WebKitContext.
@@ -172,7 +172,7 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response
172 contentType = "text/html"172 contentType = "text/html"
173 }173 }
174 174
175- req.Finish(&stream.InputStream, int64(len(response.Data)), contentType)175+ req.Finish(&stream.InputStream, int64(len(response.Data)), &contentType)
176 }176 }
177 177
178 // RegisterWithContext registers the dumb:// scheme with a WebKitContext.178 // RegisterWithContext registers the dumb:// scheme with a WebKitContext.
M internal/infrastructure/webkit/webview.go +38 -71
@@ -2,11 +2,11 @@ package webkit
22
33 import (
44 "context"
5- "errors"
65 "fmt"
76 "sync"
87 "sync/atomic"
98
9+ "github.com/bnema/dumber/internal/application/port"
1010 "github.com/bnema/dumber/internal/logging"
1111 "github.com/bnema/puregotk-webkit/webkit"
1212 "github.com/jwijenbergh/puregotk/v4/gio"
@@ -14,8 +14,9 @@ import (
1414 "github.com/rs/zerolog"
1515 )
1616
17-// WebViewID uniquely identifies a WebView instance.
18-type WebViewID uint64
17+// WebViewID is an alias to port.WebViewID for clean architecture compliance.
18+// Infrastructure layer uses the type defined in the application port.
19+type WebViewID = port.WebViewID
1920
2021 // LoadEvent represents WebKit load events.
2122 type LoadEvent int
@@ -295,7 +296,11 @@ func (wv *WebView) LoadHTML(ctx context.Context, content, baseURI string) error
295296 if wv.destroyed.Load() {
296297 return fmt.Errorf("webview %d is destroyed", wv.id)
297298 }
298- wv.inner.LoadHtml(content, baseURI)
299+ var baseURIPtr *string
300+ if baseURI != "" {
301+ baseURIPtr = &baseURI
302+ }
303+ wv.inner.LoadHtml(content, baseURIPtr)
299304 return nil
300305 }
301306
@@ -445,64 +450,9 @@ func (wv *WebView) Destroy() {
445450 }
446451
447452 // RunJavaScript executes script in the specified world (empty for main world).
448-// It waits for completion and reports JS exceptions through the returned error.
449-// WARNING: Do not call from GTK signal handlers - use RunJavaScriptAsync instead.
450-func (wv *WebView) RunJavaScript(ctx context.Context, script, worldName string) error {
451- if wv.destroyed.Load() {
452- return fmt.Errorf("webview %d is destroyed", wv.id)
453- }
454- if ctx == nil {
455- ctx = context.Background()
456- }
457-
458- log := logging.FromContext(ctx)
459-
460- done := make(chan struct{})
461- var execErr error
462-
463- cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) {
464- defer close(done)
465-
466- if resPtr == 0 {
467- execErr = errors.New("nil async result")
468- return
469- }
470-
471- res := &gio.AsyncResultBase{Ptr: resPtr}
472- value, err := wv.inner.EvaluateJavascriptFinish(res)
473- if err != nil {
474- execErr = err
475- return
476- }
477-
478- if value != nil {
479- if jscCtx := value.GetContext(); jscCtx != nil {
480- if exc := jscCtx.GetException(); exc != nil {
481- execErr = fmt.Errorf("javascript exception: %s", exc.GetMessage())
482- }
483- }
484- }
485- })
486-
487- wv.inner.EvaluateJavascript(script, -1, worldName, "", nil, &cb, 0)
488-
489- select {
490- case <-done:
491- if execErr != nil {
492- log.Warn().
493- Err(execErr).
494- Uint64("webview_id", uint64(wv.id)).
495- Msg("javascript execution failed")
496- }
497- return execErr
498- case <-ctx.Done():
499- return ctx.Err()
500- }
501-}
502-
503-// RunJavaScriptAsync executes script without waiting for completion.
504-// Safe to call from GTK signal handlers. Errors are logged but not returned.
505-func (wv *WebView) RunJavaScriptAsync(ctx context.Context, script, worldName string) {
453+// This is fire-and-forget: it does not block and errors are logged asynchronously.
454+// Safe to call from any context including GTK signal handlers.
455+func (wv *WebView) RunJavaScript(ctx context.Context, script, worldName string) {
506456 if wv.destroyed.Load() {
507457 return
508458 }
@@ -514,14 +464,14 @@ func (wv *WebView) RunJavaScriptAsync(ctx context.Context, script, worldName str
514464
515465 cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) {
516466 if resPtr == 0 {
517- log.Warn().Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScriptAsync: nil async result")
467+ log.Warn().Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScript: nil async result")
518468 return
519469 }
520470
521471 res := &gio.AsyncResultBase{Ptr: resPtr}
522472 value, err := wv.inner.EvaluateJavascriptFinish(res)
523473 if err != nil {
524- log.Warn().Err(err).Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScriptAsync: failed")
474+ log.Warn().Err(err).Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScript: failed")
525475 return
526476 }
527477
@@ -531,7 +481,7 @@ func (wv *WebView) RunJavaScriptAsync(ctx context.Context, script, worldName str
531481 log.Warn().
532482 Str("exception", exc.GetMessage()).
533483 Uint64("webview_id", uint64(wv.id)).
534- Msg("RunJavaScriptAsync: JS exception")
484+ Msg("RunJavaScript: JS exception")
535485 }
536486 }
537487 }
@@ -542,7 +492,13 @@ func (wv *WebView) RunJavaScriptAsync(ctx context.Context, script, worldName str
542492 wv.asyncCallbacks = append(wv.asyncCallbacks, cb)
543493 wv.mu.Unlock()
544494
545- wv.inner.EvaluateJavascript(script, -1, worldName, "", nil, &cb, 0)
495+ // worldName: nil for main world, &worldName for specific world
496+ // sourceUri: nil (not used)
497+ var worldNamePtr *string
498+ if worldName != "" {
499+ worldNamePtr = &worldName
500+ }
501+ wv.inner.EvaluateJavascript(script, -1, worldNamePtr, nil, nil, &cb, 0)
546502 }
547503
548504 // AttachFrontend injects scripts/styles and wires the message router once per WebView.
@@ -556,14 +512,22 @@ func (wv *WebView) AttachFrontend(ctx context.Context, injector *ContentInjector
556512 Uint64("webview_id", uint64(wv.id)).
557513 Logger()
558514
515+ log.Debug().
516+ Bool("injector_nil", injector == nil).
517+ Bool("router_nil", router == nil).
518+ Msg("AttachFrontend called")
519+
559520 if wv.destroyed.Load() {
521+ log.Debug().Msg("AttachFrontend: webview is destroyed")
560522 return fmt.Errorf("webview %d is destroyed", wv.id)
561523 }
562524 if injector == nil && router == nil {
525+ log.Debug().Msg("AttachFrontend: both injector and router are nil, skipping")
563526 return nil
564527 }
565528
566529 if !wv.frontendAttached.CompareAndSwap(false, true) {
530+ log.Debug().Msg("AttachFrontend: already attached, skipping")
567531 return nil // already attached
568532 }
569533
@@ -576,17 +540,20 @@ func (wv *WebView) AttachFrontend(ctx context.Context, injector *ContentInjector
576540 }
577541 }()
578542
579- if injector != nil {
580- injector.InjectScripts(ctx, wv.ucm, wv.id)
581- injector.InjectStyles(ctx, wv.ucm)
582- }
583-
584543 if router != nil {
544+ log.Debug().Msg("AttachFrontend: setting up message router")
585545 if _, err := router.SetupMessageHandler(wv.ucm, ScriptWorldName); err != nil {
586546 attachErr = fmt.Errorf("setup message router: %w", err)
587547 log.Warn().Err(err).Msg("failed to attach message router")
588548 return attachErr
589549 }
550+ log.Debug().Msg("AttachFrontend: message router setup complete")
551+ }
552+
553+ if injector != nil {
554+ log.Debug().Msg("AttachFrontend: injecting scripts and styles")
555+ injector.InjectScripts(ctx, wv.ucm, wv.id)
556+ injector.InjectStyles(ctx, wv.ucm)
590557 }
591558
592559 log.Debug().Msg("frontend assets attached to webview")
@@ -2,11 +2,11 @@ package webkit
2 2
3 import (3 import (
4 "context"4 "context"
5- "errors"
6 "fmt"5 "fmt"
7 "sync"6 "sync"
8 "sync/atomic"7 "sync/atomic"
9 8
9+ "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
11 "github.com/bnema/puregotk-webkit/webkit"11 "github.com/bnema/puregotk-webkit/webkit"
12 "github.com/jwijenbergh/puregotk/v4/gio"12 "github.com/jwijenbergh/puregotk/v4/gio"
@@ -14,8 +14,9 @@ import (
14 "github.com/rs/zerolog"14 "github.com/rs/zerolog"
15 )15 )
16 16
17-// WebViewID uniquely identifies a WebView instance.17+// WebViewID is an alias to port.WebViewID for clean architecture compliance.
18-type WebViewID uint6418+// Infrastructure layer uses the type defined in the application port.
19+type WebViewID = port.WebViewID
19 20
20 // LoadEvent represents WebKit load events.21 // LoadEvent represents WebKit load events.
21 type LoadEvent int22 type LoadEvent int
@@ -295,7 +296,11 @@ func (wv *WebView) LoadHTML(ctx context.Context, content, baseURI string) error
295 if wv.destroyed.Load() {296 if wv.destroyed.Load() {
296 return fmt.Errorf("webview %d is destroyed", wv.id)297 return fmt.Errorf("webview %d is destroyed", wv.id)
297 }298 }
298- wv.inner.LoadHtml(content, baseURI)299+ var baseURIPtr *string
300+ if baseURI != "" {
301+ baseURIPtr = &baseURI
302+ }
303+ wv.inner.LoadHtml(content, baseURIPtr)
299 return nil304 return nil
300 }305 }
301 306
@@ -445,64 +450,9 @@ func (wv *WebView) Destroy() {
445 }450 }
446 451
447 // RunJavaScript executes script in the specified world (empty for main world).452 // RunJavaScript executes script in the specified world (empty for main world).
448-// It waits for completion and reports JS exceptions through the returned error.453+// This is fire-and-forget: it does not block and errors are logged asynchronously.
449-// WARNING: Do not call from GTK signal handlers - use RunJavaScriptAsync instead.454+// Safe to call from any context including GTK signal handlers.
450-func (wv *WebView) RunJavaScript(ctx context.Context, script, worldName string) error {455+func (wv *WebView) RunJavaScript(ctx context.Context, script, worldName string) {
451- if wv.destroyed.Load() {
452- return fmt.Errorf("webview %d is destroyed", wv.id)
453- }
454- if ctx == nil {
455- ctx = context.Background()
456- }
457-
458- log := logging.FromContext(ctx)
459-
460- done := make(chan struct{})
461- var execErr error
462-
463- cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) {
464- defer close(done)
465-
466- if resPtr == 0 {
467- execErr = errors.New("nil async result")
468- return
469- }
470-
471- res := &gio.AsyncResultBase{Ptr: resPtr}
472- value, err := wv.inner.EvaluateJavascriptFinish(res)
473- if err != nil {
474- execErr = err
475- return
476- }
477-
478- if value != nil {
479- if jscCtx := value.GetContext(); jscCtx != nil {
480- if exc := jscCtx.GetException(); exc != nil {
481- execErr = fmt.Errorf("javascript exception: %s", exc.GetMessage())
482- }
483- }
484- }
485- })
486-
487- wv.inner.EvaluateJavascript(script, -1, worldName, "", nil, &cb, 0)
488-
489- select {
490- case <-done:
491- if execErr != nil {
492- log.Warn().
493- Err(execErr).
494- Uint64("webview_id", uint64(wv.id)).
495- Msg("javascript execution failed")
496- }
497- return execErr
498- case <-ctx.Done():
499- return ctx.Err()
500- }
501-}
502-
503-// RunJavaScriptAsync executes script without waiting for completion.
504-// Safe to call from GTK signal handlers. Errors are logged but not returned.
505-func (wv *WebView) RunJavaScriptAsync(ctx context.Context, script, worldName string) {
506 if wv.destroyed.Load() {456 if wv.destroyed.Load() {
507 return457 return
508 }458 }
@@ -514,14 +464,14 @@ func (wv *WebView) RunJavaScriptAsync(ctx context.Context, script, worldName str
514 464
515 cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) {465 cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) {
516 if resPtr == 0 {466 if resPtr == 0 {
517- log.Warn().Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScriptAsync: nil async result")467+ log.Warn().Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScript: nil async result")
518 return468 return
519 }469 }
520 470
521 res := &gio.AsyncResultBase{Ptr: resPtr}471 res := &gio.AsyncResultBase{Ptr: resPtr}
522 value, err := wv.inner.EvaluateJavascriptFinish(res)472 value, err := wv.inner.EvaluateJavascriptFinish(res)
523 if err != nil {473 if err != nil {
524- log.Warn().Err(err).Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScriptAsync: failed")474+ log.Warn().Err(err).Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScript: failed")
525 return475 return
526 }476 }
527 477
@@ -531,7 +481,7 @@ func (wv *WebView) RunJavaScriptAsync(ctx context.Context, script, worldName str
531 log.Warn().481 log.Warn().
532 Str("exception", exc.GetMessage()).482 Str("exception", exc.GetMessage()).
533 Uint64("webview_id", uint64(wv.id)).483 Uint64("webview_id", uint64(wv.id)).
534- Msg("RunJavaScriptAsync: JS exception")484+ Msg("RunJavaScript: JS exception")
535 }485 }
536 }486 }
537 }487 }
@@ -542,7 +492,13 @@ func (wv *WebView) RunJavaScriptAsync(ctx context.Context, script, worldName str
542 wv.asyncCallbacks = append(wv.asyncCallbacks, cb)492 wv.asyncCallbacks = append(wv.asyncCallbacks, cb)
543 wv.mu.Unlock()493 wv.mu.Unlock()
544 494
545- wv.inner.EvaluateJavascript(script, -1, worldName, "", nil, &cb, 0)495+ // worldName: nil for main world, &worldName for specific world
496+ // sourceUri: nil (not used)
497+ var worldNamePtr *string
498+ if worldName != "" {
499+ worldNamePtr = &worldName
500+ }
501+ wv.inner.EvaluateJavascript(script, -1, worldNamePtr, nil, nil, &cb, 0)
546 }502 }
547 503
548 // AttachFrontend injects scripts/styles and wires the message router once per WebView.504 // AttachFrontend injects scripts/styles and wires the message router once per WebView.
@@ -556,14 +512,22 @@ func (wv *WebView) AttachFrontend(ctx context.Context, injector *ContentInjector
556 Uint64("webview_id", uint64(wv.id)).512 Uint64("webview_id", uint64(wv.id)).
557 Logger()513 Logger()
558 514
515+ log.Debug().
516+ Bool("injector_nil", injector == nil).
517+ Bool("router_nil", router == nil).
518+ Msg("AttachFrontend called")
519+
559 if wv.destroyed.Load() {520 if wv.destroyed.Load() {
521+ log.Debug().Msg("AttachFrontend: webview is destroyed")
560 return fmt.Errorf("webview %d is destroyed", wv.id)522 return fmt.Errorf("webview %d is destroyed", wv.id)
561 }523 }
562 if injector == nil && router == nil {524 if injector == nil && router == nil {
525+ log.Debug().Msg("AttachFrontend: both injector and router are nil, skipping")
563 return nil526 return nil
564 }527 }
565 528
566 if !wv.frontendAttached.CompareAndSwap(false, true) {529 if !wv.frontendAttached.CompareAndSwap(false, true) {
530+ log.Debug().Msg("AttachFrontend: already attached, skipping")
567 return nil // already attached531 return nil // already attached
568 }532 }
569 533
@@ -576,17 +540,20 @@ func (wv *WebView) AttachFrontend(ctx context.Context, injector *ContentInjector
576 }540 }
577 }()541 }()
578 542
579- if injector != nil {
580- injector.InjectScripts(ctx, wv.ucm, wv.id)
581- injector.InjectStyles(ctx, wv.ucm)
582- }
583-
584 if router != nil {543 if router != nil {
544+ log.Debug().Msg("AttachFrontend: setting up message router")
585 if _, err := router.SetupMessageHandler(wv.ucm, ScriptWorldName); err != nil {545 if _, err := router.SetupMessageHandler(wv.ucm, ScriptWorldName); err != nil {
586 attachErr = fmt.Errorf("setup message router: %w", err)546 attachErr = fmt.Errorf("setup message router: %w", err)
587 log.Warn().Err(err).Msg("failed to attach message router")547 log.Warn().Err(err).Msg("failed to attach message router")
588 return attachErr548 return attachErr
589 }549 }
550+ log.Debug().Msg("AttachFrontend: message router setup complete")
551+ }
552+
553+ if injector != nil {
554+ log.Debug().Msg("AttachFrontend: injecting scripts and styles")
555+ injector.InjectScripts(ctx, wv.ucm, wv.id)
556+ injector.InjectStyles(ctx, wv.ucm)
590 }557 }
591 558
592 log.Debug().Msg("frontend assets attached to webview")559 log.Debug().Msg("frontend assets attached to webview")

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