detached 82d9dff

fix: address CodeRabbit findings + vendor purego fork with FFI optimizations

CodeRabbit fixes (28 findings):
- Guard nil pointer deref in render_pipeline.go
- Add os.Exit(1) after MaybeExitSubprocess
- Add cef-helper to build target
- Use html.EscapeString in scheme_handler
- Fix schema range for windowless_frame_rate
- Use ResolveEngineType() in validation
- Remove unnecessary goroutine in noop favicon
- Context propagation in favicon cache/service/adapter
- Deadlock timeout in textureFromBytesOnGTK
- Log LoadURI errors in popup
- Document handler_deps exception
- Rune-safe truncation in app.go
- Accent handler debug logging
- Built-in min/max in render_pipeline
- gl_loader Close() method
- LoadHTML size warning
- Zoom delay documentation
- Table-driven zoom tests
- Test context initialization

Vendor updates:
- purego fork: []string marshalling, RWMutex callbacks, sync.Pool args,
  fast path, SyscallSelf, scalar return optimization, HostLayout
- purego-cef: eliminate OnPaint buffer copy
commit
82d9dffd8f0da675e25006a8872ccdcb8c006620
author
brice · · 2026-03-23 09:46 UTC
committer
brice · · 2026-03-28 04:38 UTC
parent
5ec3eb1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
27 files · +141 -89
M Makefile +1 -0
@@ -42,6 +42,7 @@ build: build-frontend ## Build the application (pure Go, no CGO)
4242 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."
4343 @mkdir -p $(DIST_DIR)
4444 CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
45+ CGO_ENABLED=0 go build -p $(NPROCS) -ldflags "-s -w" -o $(DIST_DIR)/cef-helper ./cmd/cef-helper
4546 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
4647
4748 build-frontend: ## Build homepage and error pages
@@ -42,6 +42,7 @@ build: build-frontend ## Build the application (pure Go, no CGO)
42 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."42 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."
43 @mkdir -p $(DIST_DIR)43 @mkdir -p $(DIST_DIR)
44 CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)44 CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
45+ CGO_ENABLED=0 go build -p $(NPROCS) -ldflags "-s -w" -o $(DIST_DIR)/cef-helper ./cmd/cef-helper
45 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"46 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
46 47
47 build-frontend: ## Build homepage and error pages48 build-frontend: ## Build homepage and error pages
M cmd/dumber/main.go +1 -0
@@ -64,6 +64,7 @@ func main() {
6464 // engine can also be selected via config.
6565 if isCEFSubprocess() {
6666 cef.MaybeExitSubprocess()
67+ os.Exit(1)
6768 }
6869
6970 // Run GUI mode for browse command
@@ -64,6 +64,7 @@ func main() {
64 // engine can also be selected via config.64 // engine can also be selected via config.
65 if isCEFSubprocess() {65 if isCEFSubprocess() {
66 cef.MaybeExitSubprocess()66 cef.MaybeExitSubprocess()
67+ os.Exit(1)
67 }68 }
68 69
69 // Run GUI mode for browse command70 // Run GUI mode for browse command
M go.mod +2 -0
@@ -29,6 +29,8 @@ replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-202603230
2929
3030 replace github.com/bnema/purego-cef => ../purego-cef
3131
32+replace github.com/ebitengine/purego => ../purego
33+
3234 require (
3335 github.com/atotto/clipboard v0.1.4 // indirect
3436 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -29,6 +29,8 @@ replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-202603230
29 29
30 replace github.com/bnema/purego-cef => ../purego-cef30 replace github.com/bnema/purego-cef => ../purego-cef
31 31
32+replace github.com/ebitengine/purego => ../purego
33+
32 require (34 require (
33 github.com/atotto/clipboard v0.1.4 // indirect35 github.com/atotto/clipboard v0.1.4 // indirect
34 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect36 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
M go.sum +0 -2
@@ -34,8 +34,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
3434 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3535 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
3636 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
37-github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
38-github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
3937 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
4038 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
4139 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -34,8 +34,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
34 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=34 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
35 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=35 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
36 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=36 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
37-github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
38-github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
39 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=37 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
40 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=38 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
41 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=39 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
M internal/application/port/favicon_service.go +1 -1
@@ -4,7 +4,7 @@ import "context"
44
55 // FaviconService provides favicon retrieval and caching.
66 type FaviconService interface {
7- GetCached(domain string) ([]byte, bool)
7+ GetCached(ctx context.Context, domain string) ([]byte, bool)
88 Get(ctx context.Context, domain string) ([]byte, error)
99 DiskPathPNG(domain string) string
1010 HasPNGOnDisk(domain string) bool
@@ -4,7 +4,7 @@ import "context"
4 4
5 // FaviconService provides favicon retrieval and caching.5 // FaviconService provides favicon retrieval and caching.
6 type FaviconService interface {6 type FaviconService interface {
7- GetCached(domain string) ([]byte, bool)7+ GetCached(ctx context.Context, domain string) ([]byte, bool)
8 Get(ctx context.Context, domain string) ([]byte, error)8 Get(ctx context.Context, domain string) ([]byte, error)
9 DiskPathPNG(domain string) string9 DiskPathPNG(domain string) string
10 HasPNGOnDisk(domain string) bool10 HasPNGOnDisk(domain string) bool
M internal/application/port/handler_deps.go +4 -0
@@ -34,6 +34,10 @@ type AutoCopyConfig interface {
3434 // HandlerDeps holds handler dependencies built at bootstrap time.
3535 // Lives in the port layer so both bootstrap and ui can depend on it
3636 // without creating a reverse dependency.
37+//
38+// This is an intentional exception to the interfaces-only port layer guideline.
39+// A struct is used here to avoid reverse dependencies between the bootstrap and
40+// UI layers, which both need to reference this shared dependency container.
3741 type HandlerDeps struct {
3842 SaveConfig func(context.Context, WebUIConfig) error
3943 KeybindingsGetter KeybindingsGetter
@@ -34,6 +34,10 @@ type AutoCopyConfig interface {
34 // HandlerDeps holds handler dependencies built at bootstrap time.34 // HandlerDeps holds handler dependencies built at bootstrap time.
35 // Lives in the port layer so both bootstrap and ui can depend on it35 // Lives in the port layer so both bootstrap and ui can depend on it
36 // without creating a reverse dependency.36 // without creating a reverse dependency.
37+//
38+// This is an intentional exception to the interfaces-only port layer guideline.
39+// A struct is used here to avoid reverse dependencies between the bootstrap and
40+// UI layers, which both need to reference this shared dependency container.
37 type HandlerDeps struct {41 type HandlerDeps struct {
38 SaveConfig func(context.Context, WebUIConfig) error42 SaveConfig func(context.Context, WebUIConfig) error
39 KeybindingsGetter KeybindingsGetter43 KeybindingsGetter KeybindingsGetter
M internal/application/port/mocks/mocks.go +20 -14
@@ -4425,8 +4425,8 @@ func (_c *MockFaviconService_Get_Call) RunAndReturn(run func(ctx context.Context
44254425 }
44264426
44274427 // GetCached provides a mock function for the type MockFaviconService
4428-func (_mock *MockFaviconService) GetCached(domain string) ([]byte, bool) {
4429- ret := _mock.Called(domain)
4428+func (_mock *MockFaviconService) GetCached(ctx context.Context, domain string) ([]byte, bool) {
4429+ ret := _mock.Called(ctx, domain)
44304430
44314431 if len(ret) == 0 {
44324432 panic("no return value specified for GetCached")
@@ -4434,18 +4434,18 @@ func (_mock *MockFaviconService) GetCached(domain string) ([]byte, bool) {
44344434
44354435 var r0 []byte
44364436 var r1 bool
4437- if returnFunc, ok := ret.Get(0).(func(string) ([]byte, bool)); ok {
4438- return returnFunc(domain)
4437+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]byte, bool)); ok {
4438+ return returnFunc(ctx, domain)
44394439 }
4440- if returnFunc, ok := ret.Get(0).(func(string) []byte); ok {
4441- r0 = returnFunc(domain)
4440+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []byte); ok {
4441+ r0 = returnFunc(ctx, domain)
44424442 } else {
44434443 if ret.Get(0) != nil {
44444444 r0 = ret.Get(0).([]byte)
44454445 }
44464446 }
4447- if returnFunc, ok := ret.Get(1).(func(string) bool); ok {
4448- r1 = returnFunc(domain)
4447+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok {
4448+ r1 = returnFunc(ctx, domain)
44494449 } else {
44504450 r1 = ret.Get(1).(bool)
44514451 }
@@ -4458,19 +4458,25 @@ type MockFaviconService_GetCached_Call struct {
44584458 }
44594459
44604460 // GetCached is a helper method to define mock.On call
4461+// - ctx context.Context
44614462 // - domain string
4462-func (_e *MockFaviconService_Expecter) GetCached(domain interface{}) *MockFaviconService_GetCached_Call {
4463- return &MockFaviconService_GetCached_Call{Call: _e.mock.On("GetCached", domain)}
4463+func (_e *MockFaviconService_Expecter) GetCached(ctx interface{}, domain interface{}) *MockFaviconService_GetCached_Call {
4464+ return &MockFaviconService_GetCached_Call{Call: _e.mock.On("GetCached", ctx, domain)}
44644465 }
44654466
4466-func (_c *MockFaviconService_GetCached_Call) Run(run func(domain string)) *MockFaviconService_GetCached_Call {
4467+func (_c *MockFaviconService_GetCached_Call) Run(run func(ctx context.Context, domain string)) *MockFaviconService_GetCached_Call {
44674468 _c.Call.Run(func(args mock.Arguments) {
4468- var arg0 string
4469+ var arg0 context.Context
44694470 if args[0] != nil {
4470- arg0 = args[0].(string)
4471+ arg0 = args[0].(context.Context)
4472+ }
4473+ var arg1 string
4474+ if args[1] != nil {
4475+ arg1 = args[1].(string)
44714476 }
44724477 run(
44734478 arg0,
4479+ arg1,
44744480 )
44754481 })
44764482 return _c
@@ -4481,7 +4487,7 @@ func (_c *MockFaviconService_GetCached_Call) Return(bytes []byte, b bool) *MockF
44814487 return _c
44824488 }
44834489
4484-func (_c *MockFaviconService_GetCached_Call) RunAndReturn(run func(domain string) ([]byte, bool)) *MockFaviconService_GetCached_Call {
4490+func (_c *MockFaviconService_GetCached_Call) RunAndReturn(run func(ctx context.Context, domain string) ([]byte, bool)) *MockFaviconService_GetCached_Call {
44854491 _c.Call.Return(run)
44864492 return _c
44874493 }
@@ -4425,8 +4425,8 @@ func (_c *MockFaviconService_Get_Call) RunAndReturn(run func(ctx context.Context
4425 }4425 }
4426 4426
4427 // GetCached provides a mock function for the type MockFaviconService4427 // GetCached provides a mock function for the type MockFaviconService
4428-func (_mock *MockFaviconService) GetCached(domain string) ([]byte, bool) {4428+func (_mock *MockFaviconService) GetCached(ctx context.Context, domain string) ([]byte, bool) {
4429- ret := _mock.Called(domain)4429+ ret := _mock.Called(ctx, domain)
4430 4430
4431 if len(ret) == 0 {4431 if len(ret) == 0 {
4432 panic("no return value specified for GetCached")4432 panic("no return value specified for GetCached")
@@ -4434,18 +4434,18 @@ func (_mock *MockFaviconService) GetCached(domain string) ([]byte, bool) {
4434 4434
4435 var r0 []byte4435 var r0 []byte
4436 var r1 bool4436 var r1 bool
4437- if returnFunc, ok := ret.Get(0).(func(string) ([]byte, bool)); ok {4437+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]byte, bool)); ok {
4438- return returnFunc(domain)4438+ return returnFunc(ctx, domain)
4439 }4439 }
4440- if returnFunc, ok := ret.Get(0).(func(string) []byte); ok {4440+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []byte); ok {
4441- r0 = returnFunc(domain)4441+ r0 = returnFunc(ctx, domain)
4442 } else {4442 } else {
4443 if ret.Get(0) != nil {4443 if ret.Get(0) != nil {
4444 r0 = ret.Get(0).([]byte)4444 r0 = ret.Get(0).([]byte)
4445 }4445 }
4446 }4446 }
4447- if returnFunc, ok := ret.Get(1).(func(string) bool); ok {4447+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok {
4448- r1 = returnFunc(domain)4448+ r1 = returnFunc(ctx, domain)
4449 } else {4449 } else {
4450 r1 = ret.Get(1).(bool)4450 r1 = ret.Get(1).(bool)
4451 }4451 }
@@ -4458,19 +4458,25 @@ type MockFaviconService_GetCached_Call struct {
4458 }4458 }
4459 4459
4460 // GetCached is a helper method to define mock.On call4460 // GetCached is a helper method to define mock.On call
4461+// - ctx context.Context
4461 // - domain string4462 // - domain string
4462-func (_e *MockFaviconService_Expecter) GetCached(domain interface{}) *MockFaviconService_GetCached_Call {4463+func (_e *MockFaviconService_Expecter) GetCached(ctx interface{}, domain interface{}) *MockFaviconService_GetCached_Call {
4463- return &MockFaviconService_GetCached_Call{Call: _e.mock.On("GetCached", domain)}4464+ return &MockFaviconService_GetCached_Call{Call: _e.mock.On("GetCached", ctx, domain)}
4464 }4465 }
4465 4466
4466-func (_c *MockFaviconService_GetCached_Call) Run(run func(domain string)) *MockFaviconService_GetCached_Call {4467+func (_c *MockFaviconService_GetCached_Call) Run(run func(ctx context.Context, domain string)) *MockFaviconService_GetCached_Call {
4467 _c.Call.Run(func(args mock.Arguments) {4468 _c.Call.Run(func(args mock.Arguments) {
4468- var arg0 string4469+ var arg0 context.Context
4469 if args[0] != nil {4470 if args[0] != nil {
4470- arg0 = args[0].(string)4471+ arg0 = args[0].(context.Context)
4472+ }
4473+ var arg1 string
4474+ if args[1] != nil {
4475+ arg1 = args[1].(string)
4471 }4476 }
4472 run(4477 run(
4473 arg0,4478 arg0,
4479+ arg1,
4474 )4480 )
4475 })4481 })
4476 return _c4482 return _c
@@ -4481,7 +4487,7 @@ func (_c *MockFaviconService_GetCached_Call) Return(bytes []byte, b bool) *MockF
4481 return _c4487 return _c
4482 }4488 }
4483 4489
4484-func (_c *MockFaviconService_GetCached_Call) RunAndReturn(run func(domain string) ([]byte, bool)) *MockFaviconService_GetCached_Call {4490+func (_c *MockFaviconService_GetCached_Call) RunAndReturn(run func(ctx context.Context, domain string) ([]byte, bool)) *MockFaviconService_GetCached_Call {
4485 _c.Call.Return(run)4491 _c.Call.Return(run)
4486 return _c4492 return _c
4487 }4493 }
M internal/application/usecase/navigate_test.go +6 -2
@@ -140,8 +140,12 @@ func TestHistoryWorker_ReenqueueDuringFlushIsPersistedOnShutdown(t *testing.T) {
140140 uc.RecordHistory(ctx, "pane-1", "https://example.com/article")
141141
142142 // The history worker flushes on a timer; there is no exported sync
143- // mechanism, so sleep 3× the flush interval before shutting down.
144- time.Sleep(historyWorkerFlushInterval * 3)
143+ // mechanism, so we sleep before shutting down. Using 5× the flush
144+ // interval for headroom — this is a known flaky pattern, but
145+ // restructuring the worker for deterministic sync is out of scope.
146+ // Close() performs a final flush, so items re-enqueued during the
147+ // timed flush are still captured on shutdown.
148+ time.Sleep(historyWorkerFlushInterval * 5)
145149 uc.Close()
146150
147151 mu.Lock()
@@ -140,8 +140,12 @@ func TestHistoryWorker_ReenqueueDuringFlushIsPersistedOnShutdown(t *testing.T) {
140 uc.RecordHistory(ctx, "pane-1", "https://example.com/article")140 uc.RecordHistory(ctx, "pane-1", "https://example.com/article")
141 141
142 // The history worker flushes on a timer; there is no exported sync142 // The history worker flushes on a timer; there is no exported sync
143- // mechanism, so sleep 3× the flush interval before shutting down.143+ // mechanism, so we sleep before shutting down. Using 5× the flush
144- time.Sleep(historyWorkerFlushInterval * 3)144+ // interval for headroom — this is a known flaky pattern, but
145+ // restructuring the worker for deterministic sync is out of scope.
146+ // Close() performs a final flush, so items re-enqueued during the
147+ // timed flush are still captured on shutdown.
148+ time.Sleep(historyWorkerFlushInterval * 5)
145 uc.Close()149 uc.Close()
146 150
147 mu.Lock()151 mu.Lock()
M internal/bootstrap/handler_deps.go +2 -1
@@ -11,7 +11,8 @@ import (
1111
1212 // BuildHandlerDeps constructs handler dependencies from the config manager.
1313 // It combines keybinding use cases and config save into a single struct.
14-func BuildHandlerDeps(_ context.Context) (*port.HandlerDeps, error) {
14+// The context is accepted for future use (e.g., passing to constructors that may need cancellation).
15+func BuildHandlerDeps(ctx context.Context) (*port.HandlerDeps, error) {
1516 mgr := config.GetManager()
1617 if mgr == nil {
1718 return nil, fmt.Errorf("config manager not initialized")
@@ -11,7 +11,8 @@ import (
11 11
12 // BuildHandlerDeps constructs handler dependencies from the config manager.12 // BuildHandlerDeps constructs handler dependencies from the config manager.
13 // It combines keybinding use cases and config save into a single struct.13 // It combines keybinding use cases and config save into a single struct.
14-func BuildHandlerDeps(_ context.Context) (*port.HandlerDeps, error) {14+// The context is accepted for future use (e.g., passing to constructors that may need cancellation).
15+func BuildHandlerDeps(ctx context.Context) (*port.HandlerDeps, error) {
15 mgr := config.GetManager()16 mgr := config.GetManager()
16 if mgr == nil {17 if mgr == nil {
17 return nil, fmt.Errorf("config manager not initialized")18 return nil, fmt.Errorf("config manager not initialized")
M internal/domain/service/favicon.go +2 -2
@@ -12,10 +12,10 @@ type FaviconService interface {
1212
1313 // GetCached returns favicon bytes only if already cached (no external fetch).
1414 // Returns the bytes and true if found, nil and false otherwise.
15- GetCached(domain string) ([]byte, bool)
15+ GetCached(ctx context.Context, domain string) ([]byte, bool)
1616
1717 // Store saves favicon bytes for a domain to cache.
18- Store(domain string, data []byte) error
18+ Store(ctx context.Context, domain string, data []byte) error
1919
2020 // DiskPath returns the filesystem path where a domain's favicon is cached (ICO).
2121 DiskPath(domain string) string
@@ -12,10 +12,10 @@ type FaviconService interface {
12 12
13 // GetCached returns favicon bytes only if already cached (no external fetch).13 // GetCached returns favicon bytes only if already cached (no external fetch).
14 // Returns the bytes and true if found, nil and false otherwise.14 // Returns the bytes and true if found, nil and false otherwise.
15- GetCached(domain string) ([]byte, bool)15+ GetCached(ctx context.Context, domain string) ([]byte, bool)
16 16
17 // Store saves favicon bytes for a domain to cache.17 // Store saves favicon bytes for a domain to cache.
18- Store(domain string, data []byte) error18+ Store(ctx context.Context, domain string, data []byte) error
19 19
20 // DiskPath returns the filesystem path where a domain's favicon is cached (ICO).20 // DiskPath returns the filesystem path where a domain's favicon is cached (ICO).
21 DiskPath(domain string) string21 DiskPath(domain string) string
M internal/infrastructure/cef/app.go +4 -1
@@ -32,7 +32,10 @@ func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLin
3232 if commandLine != nil {
3333 cmdline = commandLine.GetCommandLineString()
3434 if len(cmdline) > maxCmdLineLogLen {
35- cmdline = cmdline[:maxCmdLineLogLen] + "…"
35+ runes := []rune(cmdline)
36+ if len(runes) > maxCmdLineLogLen {
37+ cmdline = string(runes[:maxCmdLineLogLen]) + "…"
38+ }
3639 }
3740 }
3841 log.Debug().
@@ -32,7 +32,10 @@ func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLin
32 if commandLine != nil {32 if commandLine != nil {
33 cmdline = commandLine.GetCommandLineString()33 cmdline = commandLine.GetCommandLineString()
34 if len(cmdline) > maxCmdLineLogLen {34 if len(cmdline) > maxCmdLineLogLen {
35- cmdline = cmdline[:maxCmdLineLogLen] + "…"35+ runes := []rune(cmdline)
36+ if len(runes) > maxCmdLineLogLen {
37+ cmdline = string(runes[:maxCmdLineLogLen]) + "…"
38+ }
36 }39 }
37 }40 }
38 log.Debug().41 log.Debug().
M internal/infrastructure/cef/engine.go +4 -0
@@ -231,6 +231,8 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent
231231 }
232232 if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 {
233233 handler.OnKeyPressed(ctx, r, p.Shift)
234+ } else {
235+ log.Debug().Str("char", p.Char).Msg("cef: ignoring invalid accent_key_press char")
234236 }
235237 return nil, nil
236238 },
@@ -248,6 +250,8 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent
248250 }
249251 if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 {
250252 handler.OnKeyReleased(ctx, r)
253+ } else {
254+ log.Debug().Str("char", p.Char).Msg("cef: ignoring invalid accent_key_release char")
251255 }
252256 return nil, nil
253257 },
@@ -231,6 +231,8 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent
231 }231 }
232 if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 {232 if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 {
233 handler.OnKeyPressed(ctx, r, p.Shift)233 handler.OnKeyPressed(ctx, r, p.Shift)
234+ } else {
235+ log.Debug().Str("char", p.Char).Msg("cef: ignoring invalid accent_key_press char")
234 }236 }
235 return nil, nil237 return nil, nil
236 },238 },
@@ -248,6 +250,8 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent
248 }250 }
249 if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 {251 if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 {
250 handler.OnKeyReleased(ctx, r)252 handler.OnKeyReleased(ctx, r)
253+ } else {
254+ log.Debug().Str("char", p.Char).Msg("cef: ignoring invalid accent_key_release char")
251 }255 }
252 return nil, nil256 return nil, nil
253 },257 },
M internal/infrastructure/cef/engine_adapters.go +1 -1
@@ -97,5 +97,5 @@ func (n *noopSettingsApplier) ApplyToAll(_ context.Context, _ []port.WebView) {}
9797 type noopFaviconDatabase struct{}
9898
9999 func (n *noopFaviconDatabase) GetFaviconAsync(_ string, callback func(port.Texture)) {
100- go callback(nil)
100+ callback(nil)
101101 }
@@ -97,5 +97,5 @@ func (n *noopSettingsApplier) ApplyToAll(_ context.Context, _ []port.WebView) {}
97 type noopFaviconDatabase struct{}97 type noopFaviconDatabase struct{}
98 98
99 func (n *noopFaviconDatabase) GetFaviconAsync(_ string, callback func(port.Texture)) {99 func (n *noopFaviconDatabase) GetFaviconAsync(_ string, callback func(port.Texture)) {
100- go callback(nil)100+ callback(nil)
101 }101 }
M internal/infrastructure/cef/gl_loader.go +8 -0
@@ -96,6 +96,14 @@ type glLoader struct {
9696 disable func(cap uint32)
9797 }
9898
99+// Close releases the OpenGL library handle.
100+func (gl *glLoader) Close() error {
101+ if gl.handle != 0 {
102+ return purego.Dlclose(gl.handle)
103+ }
104+ return nil
105+}
106+
99107 // newGLLoader opens libGL.so.1 and loads all required OpenGL function pointers.
100108 func newGLLoader() (*glLoader, error) {
101109 handle, err := purego.Dlopen("libGL.so.1", purego.RTLD_LAZY)
@@ -96,6 +96,14 @@ type glLoader struct {
96 disable func(cap uint32)96 disable func(cap uint32)
97 }97 }
98 98
99+// Close releases the OpenGL library handle.
100+func (gl *glLoader) Close() error {
101+ if gl.handle != 0 {
102+ return purego.Dlclose(gl.handle)
103+ }
104+ return nil
105+}
106+
99 // newGLLoader opens libGL.so.1 and loads all required OpenGL function pointers.107 // newGLLoader opens libGL.so.1 and loads all required OpenGL function pointers.
100 func newGLLoader() (*glLoader, error) {108 func newGLLoader() (*glLoader, error) {
101 handle, err := purego.Dlopen("libGL.so.1", purego.RTLD_LAZY)109 handle, err := purego.Dlopen("libGL.so.1", purego.RTLD_LAZY)
M internal/infrastructure/cef/input_bridge.go +2 -1
@@ -227,7 +227,8 @@ func (ib *inputBridge) onMousePress(x, y float64, button, mods uint, clickCount
227227 }
228228
229229 // Middle-click on a link opens in a new tab instead of sending to CEF.
230- // The URI is resolved by the callback closure (factory wiring reads wv.lastHoverURI).
230+ // The empty string parameter is unused — the callback closure resolves
231+ // the actual URI via wv.lastHoverURI at invocation time.
231232 if button == 2 && middleCB != nil {
232233 go middleCB("")
233234 return
@@ -227,7 +227,8 @@ func (ib *inputBridge) onMousePress(x, y float64, button, mods uint, clickCount
227 }227 }
228 228
229 // Middle-click on a link opens in a new tab instead of sending to CEF.229 // Middle-click on a link opens in a new tab instead of sending to CEF.
230- // The URI is resolved by the callback closure (factory wiring reads wv.lastHoverURI).230+ // The empty string parameter is unused — the callback closure resolves
231+ // the actual URI via wv.lastHoverURI at invocation time.
231 if button == 2 && middleCB != nil {232 if button == 2 && middleCB != nil {
232 go middleCB("")233 go middleCB("")
233 return234 return
M internal/infrastructure/cef/render_pipeline.go +11 -23
@@ -392,11 +392,13 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render
392392 rp.mu.Unlock()
393393
394394 if !gl.unmapBuffer(glPixelUnpackBuffer) {
395- logging.FromContext(rp.ctx).Error().
396- Int32("width", w).
397- Int32("height", h).
398- Bool("full_upload", fullUpload).
399- Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame")
395+ if rp.ctx != nil {
396+ logging.FromContext(rp.ctx).Error().
397+ Int32("width", w).
398+ Int32("height", h).
399+ Bool("full_upload", fullUpload).
400+ Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame")
401+ }
400402 gl.bindBuffer(glPixelUnpackBuffer, 0)
401403 return 0
402404 }
@@ -693,10 +695,10 @@ func clampDirtyRect(r rect, width, height int32) (rect, bool) {
693695 return rect{}, false
694696 }
695697
696- x0 := maxInt32(r.X, 0)
697- y0 := maxInt32(r.Y, 0)
698- x1 := minInt32(r.X+r.Width, width)
699- y1 := minInt32(r.Y+r.Height, height)
698+ x0 := max(r.X, 0)
699+ y0 := max(r.Y, 0)
700+ x1 := min(r.X+r.Width, width)
701+ y1 := min(r.Y+r.Height, height)
700702
701703 if x1 <= x0 || y1 <= y0 {
702704 return rect{}, false
@@ -710,20 +712,6 @@ func clampDirtyRect(r rect, width, height int32) (rect, bool) {
710712 }, true
711713 }
712714
713-func minInt32(a, b int32) int32 {
714- if a < b {
715- return a
716- }
717- return b
718-}
719-
720-func maxInt32(a, b int32) int32 {
721- if a > b {
722- return a
723- }
724- return b
725-}
726-
727715 // destroy releases all GL resources.
728716 func (rp *renderPipeline) destroy() {
729717 if !rp.glReady {
@@ -392,11 +392,13 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render
392 rp.mu.Unlock()392 rp.mu.Unlock()
393 393
394 if !gl.unmapBuffer(glPixelUnpackBuffer) {394 if !gl.unmapBuffer(glPixelUnpackBuffer) {
395- logging.FromContext(rp.ctx).Error().395+ if rp.ctx != nil {
396- Int32("width", w).396+ logging.FromContext(rp.ctx).Error().
397- Int32("height", h).397+ Int32("width", w).
398- Bool("full_upload", fullUpload).398+ Int32("height", h).
399- Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame")399+ Bool("full_upload", fullUpload).
400+ Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame")
401+ }
400 gl.bindBuffer(glPixelUnpackBuffer, 0)402 gl.bindBuffer(glPixelUnpackBuffer, 0)
401 return 0403 return 0
402 }404 }
@@ -693,10 +695,10 @@ func clampDirtyRect(r rect, width, height int32) (rect, bool) {
693 return rect{}, false695 return rect{}, false
694 }696 }
695 697
696- x0 := maxInt32(r.X, 0)698+ x0 := max(r.X, 0)
697- y0 := maxInt32(r.Y, 0)699+ y0 := max(r.Y, 0)
698- x1 := minInt32(r.X+r.Width, width)700+ x1 := min(r.X+r.Width, width)
699- y1 := minInt32(r.Y+r.Height, height)701+ y1 := min(r.Y+r.Height, height)
700 702
701 if x1 <= x0 || y1 <= y0 {703 if x1 <= x0 || y1 <= y0 {
702 return rect{}, false704 return rect{}, false
@@ -710,20 +712,6 @@ func clampDirtyRect(r rect, width, height int32) (rect, bool) {
710 }, true712 }, true
711 }713 }
712 714
713-func minInt32(a, b int32) int32 {
714- if a < b {
715- return a
716- }
717- return b
718-}
719-
720-func maxInt32(a, b int32) int32 {
721- if a > b {
722- return a
723- }
724- return b
725-}
726-
727 // destroy releases all GL resources.715 // destroy releases all GL resources.
728 func (rp *renderPipeline) destroy() {716 func (rp *renderPipeline) destroy() {
729 if !rp.glReady {717 if !rp.glReady {
M internal/infrastructure/cef/scheme_handler.go +2 -1
@@ -6,6 +6,7 @@ import (
66 "encoding/base64"
77 "encoding/json"
88 "fmt"
9+ "html"
910 "io/fs"
1011 "mime"
1112 "net/http"
@@ -267,7 +268,7 @@ func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string
267268 }
268269
269270 func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {
270- escaped := strings.ReplaceAll(strings.ReplaceAll(msg, "&", "&amp;"), "<", "&lt;")
271+ escaped := html.EscapeString(msg)
271272 body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped)
272273 return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body))
273274 }
@@ -6,6 +6,7 @@ import (
6 "encoding/base64"6 "encoding/base64"
7 "encoding/json"7 "encoding/json"
8 "fmt"8 "fmt"
9+ "html"
9 "io/fs"10 "io/fs"
10 "mime"11 "mime"
11 "net/http"12 "net/http"
@@ -267,7 +268,7 @@ func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string
267 }268 }
268 269
269 func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {270 func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {
270- escaped := strings.ReplaceAll(strings.ReplaceAll(msg, "&", "&amp;"), "<", "&lt;")271+ escaped := html.EscapeString(msg)
271 body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped)272 body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped)
272 return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body))273 return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body))
273 }274 }
M internal/infrastructure/cef/webview.go +21 -1
@@ -132,10 +132,16 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error {
132132 }
133133
134134 // LoadHTML loads HTML content with an optional base URI (ignored in Phase 1).
135-func (wv *WebView) LoadHTML(_ context.Context, content, _ string) error {
135+func (wv *WebView) LoadHTML(ctx context.Context, content, _ string) error {
136136 if wv.destroyed.Load() {
137137 return errDestroyed
138138 }
139+ const maxDataURLSize = 1 << 20 // 1MB
140+ if len(content) > maxDataURLSize {
141+ logging.FromContext(ctx).Warn().
142+ Int("content_len", len(content)).
143+ Msg("cef: LoadHTML content exceeds 1MB, data URL may fail")
144+ }
139145 wv.mu.RLock()
140146 browser := wv.browser
141147 wv.mu.RUnlock()
@@ -557,6 +563,13 @@ func (wv *WebView) updateHoverURI(uri string) {
557563 wv.mu.Unlock()
558564 }
559565
566+// scheduleZoomRefresh posts two invalidation requests to the CEF UI thread:
567+// - 16ms delay: one frame at 60fps, gives the renderer time to process
568+// the zoom IPC before we request the first repaint.
569+// - 48ms delay: three frames at 60fps, a second invalidation to catch
570+// any compositor frame that was still in-flight during the first.
571+//
572+// These values are empirically tuned for CEF's async zoom application.
560573 func (wv *WebView) scheduleZoomRefresh() {
561574 for _, delayMs := range [...]int64{16, 48} {
562575 purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() {
@@ -574,6 +587,13 @@ func (wv *WebView) scheduleZoomRefresh() {
574587 }
575588 }
576589
590+// scheduleZoomReadback posts two diagnostic readbacks to verify zoom applied:
591+// - 0ms delay: immediate check on the CEF UI thread to log the zoom level
592+// right after SetZoomLevel returns (usually still the old value).
593+// - 64ms delay: four frames at 60fps, enough time for the renderer to
594+// process the zoom IPC and reflect the new level back to the browser.
595+//
596+// These values are empirically tuned for CEF's async zoom application.
577597 func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) {
578598 for _, delayMs := range [...]int64{0, 64} {
579599 purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() {
@@ -132,10 +132,16 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error {
132 }132 }
133 133
134 // LoadHTML loads HTML content with an optional base URI (ignored in Phase 1).134 // LoadHTML loads HTML content with an optional base URI (ignored in Phase 1).
135-func (wv *WebView) LoadHTML(_ context.Context, content, _ string) error {135+func (wv *WebView) LoadHTML(ctx context.Context, content, _ string) error {
136 if wv.destroyed.Load() {136 if wv.destroyed.Load() {
137 return errDestroyed137 return errDestroyed
138 }138 }
139+ const maxDataURLSize = 1 << 20 // 1MB
140+ if len(content) > maxDataURLSize {
141+ logging.FromContext(ctx).Warn().
142+ Int("content_len", len(content)).
143+ Msg("cef: LoadHTML content exceeds 1MB, data URL may fail")
144+ }
139 wv.mu.RLock()145 wv.mu.RLock()
140 browser := wv.browser146 browser := wv.browser
141 wv.mu.RUnlock()147 wv.mu.RUnlock()
@@ -557,6 +563,13 @@ func (wv *WebView) updateHoverURI(uri string) {
557 wv.mu.Unlock()563 wv.mu.Unlock()
558 }564 }
559 565
566+// scheduleZoomRefresh posts two invalidation requests to the CEF UI thread:
567+// - 16ms delay: one frame at 60fps, gives the renderer time to process
568+// the zoom IPC before we request the first repaint.
569+// - 48ms delay: three frames at 60fps, a second invalidation to catch
570+// any compositor frame that was still in-flight during the first.
571+//
572+// These values are empirically tuned for CEF's async zoom application.
560 func (wv *WebView) scheduleZoomRefresh() {573 func (wv *WebView) scheduleZoomRefresh() {
561 for _, delayMs := range [...]int64{16, 48} {574 for _, delayMs := range [...]int64{16, 48} {
562 purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() {575 purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() {
@@ -574,6 +587,13 @@ func (wv *WebView) scheduleZoomRefresh() {
574 }587 }
575 }588 }
576 589
590+// scheduleZoomReadback posts two diagnostic readbacks to verify zoom applied:
591+// - 0ms delay: immediate check on the CEF UI thread to log the zoom level
592+// right after SetZoomLevel returns (usually still the old value).
593+// - 64ms delay: four frames at 60fps, enough time for the renderer to
594+// process the zoom IPC and reflect the new level back to the browser.
595+//
596+// These values are empirically tuned for CEF's async zoom application.
577 func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) {597 func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) {
578 for _, delayMs := range [...]int64{0, 64} {598 for _, delayMs := range [...]int64{0, 64} {
579 purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() {599 purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() {
M internal/infrastructure/cef/webview_handlers_test.go +4 -1
@@ -1,6 +1,7 @@
11 package cef
22
33 import (
4+ "context"
45 "testing"
56
67 purecef "github.com/bnema/purego-cef/cef"
@@ -44,7 +45,7 @@ func (f stubFrame) CreateUrlrequest(purecef.Request, purecef.UrlrequestClient) p
4445 }
4546
4647 func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
47- wv := &WebView{}
48+ wv := &WebView{ctx: context.Background()}
4849 var gotEvents []port.LoadEvent
4950 wv.SetCallbacks(&port.WebViewCallbacks{
5051 OnLoadChanged: func(event port.LoadEvent) {
@@ -64,6 +65,7 @@ func TestGetViewRectUsesDIPCoordinates(t *testing.T) {
6465 rect := &purecef.Rect{}
6566 h := &handlerSet{
6667 wv: &WebView{
68+ ctx: context.Background(),
6769 pipeline: &renderPipeline{
6870 width: 800,
6971 height: 600,
@@ -84,6 +86,7 @@ func TestGetScreenInfoUsesDIPRectAndScale(t *testing.T) {
8486 info := &purecef.ScreenInfo{}
8587 h := &handlerSet{
8688 wv: &WebView{
89+ ctx: context.Background(),
8790 pipeline: &renderPipeline{
8891 width: 1500,
8992 height: 900,
@@ -1,6 +1,7 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
5 6
6 purecef "github.com/bnema/purego-cef/cef"7 purecef "github.com/bnema/purego-cef/cef"
@@ -44,7 +45,7 @@ func (f stubFrame) CreateUrlrequest(purecef.Request, purecef.UrlrequestClient) p
44 }45 }
45 46
46 func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {47 func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
47- wv := &WebView{}48+ wv := &WebView{ctx: context.Background()}
48 var gotEvents []port.LoadEvent49 var gotEvents []port.LoadEvent
49 wv.SetCallbacks(&port.WebViewCallbacks{50 wv.SetCallbacks(&port.WebViewCallbacks{
50 OnLoadChanged: func(event port.LoadEvent) {51 OnLoadChanged: func(event port.LoadEvent) {
@@ -64,6 +65,7 @@ func TestGetViewRectUsesDIPCoordinates(t *testing.T) {
64 rect := &purecef.Rect{}65 rect := &purecef.Rect{}
65 h := &handlerSet{66 h := &handlerSet{
66 wv: &WebView{67 wv: &WebView{
68+ ctx: context.Background(),
67 pipeline: &renderPipeline{69 pipeline: &renderPipeline{
68 width: 800,70 width: 800,
69 height: 600,71 height: 600,
@@ -84,6 +86,7 @@ func TestGetScreenInfoUsesDIPRectAndScale(t *testing.T) {
84 info := &purecef.ScreenInfo{}86 info := &purecef.ScreenInfo{}
85 h := &handlerSet{87 h := &handlerSet{
86 wv: &WebView{88 wv: &WebView{
89+ ctx: context.Background(),
87 pipeline: &renderPipeline{90 pipeline: &renderPipeline{
88 width: 1500,91 width: 1500,
89 height: 900,92 height: 900,
M internal/infrastructure/cef/webview_zoom_test.go +8 -6
@@ -1,18 +1,20 @@
11 package cef
22
33 import (
4+ "fmt"
45 "math"
56 "testing"
67 )
78
89 func TestZoomConversionsRoundTrip(t *testing.T) {
910 factors := []float64{0.5, 0.8, 1.0, 1.1, 1.2, 1.4, 2.0}
10-
1111 for _, factor := range factors {
12- level := cefZoomFromFactor(factor)
13- got := factorFromCEFZoom(level)
14- if math.Abs(got-factor) > 1e-9 {
15- t.Fatalf("round-trip mismatch for factor %.6f: level %.6f -> factor %.12f", factor, level, got)
16- }
12+ t.Run(fmt.Sprintf("factor_%.1f", factor), func(t *testing.T) {
13+ level := cefZoomFromFactor(factor)
14+ got := factorFromCEFZoom(level)
15+ if math.Abs(got-factor) > 1e-9 {
16+ t.Errorf("round-trip mismatch for factor %.6f: level %.6f -> factor %.12f", factor, level, got)
17+ }
18+ })
1719 }
1820 }
@@ -1,18 +1,20 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "fmt"
4 "math"5 "math"
5 "testing"6 "testing"
6 )7 )
7 8
8 func TestZoomConversionsRoundTrip(t *testing.T) {9 func TestZoomConversionsRoundTrip(t *testing.T) {
9 factors := []float64{0.5, 0.8, 1.0, 1.1, 1.2, 1.4, 2.0}10 factors := []float64{0.5, 0.8, 1.0, 1.1, 1.2, 1.4, 2.0}
10-
11 for _, factor := range factors {11 for _, factor := range factors {
12- level := cefZoomFromFactor(factor)12+ t.Run(fmt.Sprintf("factor_%.1f", factor), func(t *testing.T) {
13- got := factorFromCEFZoom(level)13+ level := cefZoomFromFactor(factor)
14- if math.Abs(got-factor) > 1e-9 {14+ got := factorFromCEFZoom(level)
15- t.Fatalf("round-trip mismatch for factor %.6f: level %.6f -> factor %.12f", factor, level, got)15+ if math.Abs(got-factor) > 1e-9 {
16- }16+ t.Errorf("round-trip mismatch for factor %.6f: level %.6f -> factor %.12f", factor, level, got)
17+ }
18+ })
17 }19 }
18 }20 }
M internal/infrastructure/config/schema_provider.go +1 -1
@@ -916,7 +916,7 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
916916 Type: "int32",
917917 Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRate()),
918918 Description: "Maximum off-screen frame rate for the CEF engine",
919- Range: ">=1",
919+ Range: ">=0",
920920 Section: SectionPerformance,
921921 },
922922 {
@@ -916,7 +916,7 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
916 Type: "int32",916 Type: "int32",
917 Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRate()),917 Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRate()),
918 Description: "Maximum off-screen frame rate for the CEF engine",918 Description: "Maximum off-screen frame rate for the CEF engine",
919- Range: ">=1",919+ Range: ">=0",
920 Section: SectionPerformance,920 Section: SectionPerformance,
921 },921 },
922 {922 {
M internal/infrastructure/config/validation.go +2 -7
@@ -3,7 +3,6 @@ package config
33 import (
44 "fmt"
55 "net/url"
6- "os"
76 "sort"
87 "strings"
98
@@ -467,12 +466,8 @@ func validatePerformanceProfile(config *Config) []string {
467466 var validationErrors []string
468467
469468 // Performance profiles and webkit-specific tuning only apply to the webkit engine.
470- // Check both config and DUMBER_ENGINE env var override.
471- engineType := config.Engine.Type
472- if envEngine := os.Getenv("DUMBER_ENGINE"); envEngine != "" {
473- engineType = envEngine
474- }
475- if engineType != "" && engineType != "webkit" {
469+ engineType := config.Engine.ResolveEngineType()
470+ if engineType != "webkit" {
476471 return nil
477472 }
478473
@@ -3,7 +3,6 @@ package config
3 import (3 import (
4 "fmt"4 "fmt"
5 "net/url"5 "net/url"
6- "os"
7 "sort"6 "sort"
8 "strings"7 "strings"
9 8
@@ -467,12 +466,8 @@ func validatePerformanceProfile(config *Config) []string {
467 var validationErrors []string466 var validationErrors []string
468 467
469 // Performance profiles and webkit-specific tuning only apply to the webkit engine.468 // Performance profiles and webkit-specific tuning only apply to the webkit engine.
470- // Check both config and DUMBER_ENGINE env var override.469+ engineType := config.Engine.ResolveEngineType()
471- engineType := config.Engine.Type470+ if engineType != "webkit" {
472- if envEngine := os.Getenv("DUMBER_ENGINE"); envEngine != "" {
473- engineType = envEngine
474- }
475- if engineType != "" && engineType != "webkit" {
476 return nil471 return nil
477 }472 }
478 473
M internal/infrastructure/favicon/cache.go +4 -4
@@ -53,11 +53,11 @@ func NewCache(diskDir string) *Cache {
5353 // Get retrieves favicon bytes for a domain.
5454 // Checks memory cache first, then disk cache.
5555 // Returns the bytes and true if found, nil and false otherwise.
56-func (c *Cache) Get(domain string) ([]byte, bool) {
56+func (c *Cache) Get(ctx context.Context, domain string) ([]byte, bool) {
5757 if domain == "" {
5858 return nil, false
5959 }
60- log := logging.FromContext(context.Background())
60+ log := logging.FromContext(ctx)
6161
6262 // Check memory cache first
6363 c.mu.RLock()
@@ -91,11 +91,11 @@ func (c *Cache) Get(domain string) ([]byte, bool) {
9191
9292 // Set stores favicon bytes for a domain.
9393 // Writes to memory cache immediately and queues async disk write.
94-func (c *Cache) Set(domain string, data []byte) {
94+func (c *Cache) Set(ctx context.Context, domain string, data []byte) {
9595 if domain == "" || len(data) == 0 {
9696 return
9797 }
98- logging.FromContext(context.Background()).Debug().
98+ logging.FromContext(ctx).Debug().
9999 Str("domain", domain).
100100 Int("bytes", len(data)).
101101 Msg("favicon: Cache.Set")
@@ -53,11 +53,11 @@ func NewCache(diskDir string) *Cache {
53 // Get retrieves favicon bytes for a domain.53 // Get retrieves favicon bytes for a domain.
54 // Checks memory cache first, then disk cache.54 // Checks memory cache first, then disk cache.
55 // Returns the bytes and true if found, nil and false otherwise.55 // Returns the bytes and true if found, nil and false otherwise.
56-func (c *Cache) Get(domain string) ([]byte, bool) {56+func (c *Cache) Get(ctx context.Context, domain string) ([]byte, bool) {
57 if domain == "" {57 if domain == "" {
58 return nil, false58 return nil, false
59 }59 }
60- log := logging.FromContext(context.Background())60+ log := logging.FromContext(ctx)
61 61
62 // Check memory cache first62 // Check memory cache first
63 c.mu.RLock()63 c.mu.RLock()
@@ -91,11 +91,11 @@ func (c *Cache) Get(domain string) ([]byte, bool) {
91 91
92 // Set stores favicon bytes for a domain.92 // Set stores favicon bytes for a domain.
93 // Writes to memory cache immediately and queues async disk write.93 // Writes to memory cache immediately and queues async disk write.
94-func (c *Cache) Set(domain string, data []byte) {94+func (c *Cache) Set(ctx context.Context, domain string, data []byte) {
95 if domain == "" || len(data) == 0 {95 if domain == "" || len(data) == 0 {
96 return96 return
97 }97 }
98- logging.FromContext(context.Background()).Debug().98+ logging.FromContext(ctx).Debug().
99 Str("domain", domain).99 Str("domain", domain).
100 Int("bytes", len(data)).100 Int("bytes", len(data)).
101 Msg("favicon: Cache.Set")101 Msg("favicon: Cache.Set")
M internal/infrastructure/favicon/service.go +8 -8
@@ -47,7 +47,7 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
4747 }
4848
4949 // Check cache first (memory + disk)
50- if data, ok := s.cache.Get(domain); ok {
50+ if data, ok := s.cache.Get(ctx, domain); ok {
5151 log.Debug().
5252 Str("domain", domain).
5353 Int("bytes", len(data)).
@@ -64,7 +64,7 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
6464
6565 // Store in cache if we got data
6666 if len(data) > 0 {
67- s.cache.Set(domain, data)
67+ s.cache.Set(ctx, domain, data)
6868 }
6969 log.Debug().
7070 Str("domain", domain).
@@ -86,9 +86,9 @@ func GetLogoBytes() []byte {
8686 }
8787
8888 // GetCached returns favicon bytes only if already cached (no external fetch).
89-func (s *Service) GetCached(domain string) ([]byte, bool) {
90- data, ok := s.cache.Get(domain)
91- logging.FromContext(context.Background()).Debug().
89+func (s *Service) GetCached(ctx context.Context, domain string) ([]byte, bool) {
90+ data, ok := s.cache.Get(ctx, domain)
91+ logging.FromContext(ctx).Debug().
9292 Str("domain", domain).
9393 Bool("hit", ok).
9494 Int("bytes", len(data)).
@@ -97,8 +97,8 @@ func (s *Service) GetCached(domain string) ([]byte, bool) {
9797 }
9898
9999 // Store saves favicon bytes for a domain to cache.
100-func (s *Service) Store(domain string, data []byte) error {
101- s.cache.Set(domain, data)
100+func (s *Service) Store(ctx context.Context, domain string, data []byte) error {
101+ s.cache.Set(ctx, domain, data)
102102 return nil
103103 }
104104
@@ -150,7 +150,7 @@ func (s *Service) EnsureDiskCache(ctx context.Context, domain string) {
150150 }
151151
152152 if len(data) > 0 {
153- s.cache.Set(domain, data)
153+ s.cache.Set(ctx, domain, data)
154154 }
155155 log.Debug().
156156 Str("domain", domain).
@@ -47,7 +47,7 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
47 }47 }
48 48
49 // Check cache first (memory + disk)49 // Check cache first (memory + disk)
50- if data, ok := s.cache.Get(domain); ok {50+ if data, ok := s.cache.Get(ctx, domain); ok {
51 log.Debug().51 log.Debug().
52 Str("domain", domain).52 Str("domain", domain).
53 Int("bytes", len(data)).53 Int("bytes", len(data)).
@@ -64,7 +64,7 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
64 64
65 // Store in cache if we got data65 // Store in cache if we got data
66 if len(data) > 0 {66 if len(data) > 0 {
67- s.cache.Set(domain, data)67+ s.cache.Set(ctx, domain, data)
68 }68 }
69 log.Debug().69 log.Debug().
70 Str("domain", domain).70 Str("domain", domain).
@@ -86,9 +86,9 @@ func GetLogoBytes() []byte {
86 }86 }
87 87
88 // GetCached returns favicon bytes only if already cached (no external fetch).88 // GetCached returns favicon bytes only if already cached (no external fetch).
89-func (s *Service) GetCached(domain string) ([]byte, bool) {89+func (s *Service) GetCached(ctx context.Context, domain string) ([]byte, bool) {
90- data, ok := s.cache.Get(domain)90+ data, ok := s.cache.Get(ctx, domain)
91- logging.FromContext(context.Background()).Debug().91+ logging.FromContext(ctx).Debug().
92 Str("domain", domain).92 Str("domain", domain).
93 Bool("hit", ok).93 Bool("hit", ok).
94 Int("bytes", len(data)).94 Int("bytes", len(data)).
@@ -97,8 +97,8 @@ func (s *Service) GetCached(domain string) ([]byte, bool) {
97 }97 }
98 98
99 // Store saves favicon bytes for a domain to cache.99 // Store saves favicon bytes for a domain to cache.
100-func (s *Service) Store(domain string, data []byte) error {100+func (s *Service) Store(ctx context.Context, domain string, data []byte) error {
101- s.cache.Set(domain, data)101+ s.cache.Set(ctx, domain, data)
102 return nil102 return nil
103 }103 }
104 104
@@ -150,7 +150,7 @@ func (s *Service) EnsureDiskCache(ctx context.Context, domain string) {
150 }150 }
151 151
152 if len(data) > 0 {152 if len(data) > 0 {
153- s.cache.Set(domain, data)153+ s.cache.Set(ctx, domain, data)
154 }154 }
155 log.Debug().155 log.Debug().
156 Str("domain", domain).156 Str("domain", domain).
M internal/ui/adapter/favicon.go +16 -10
@@ -5,6 +5,7 @@ import (
55 "context"
66 "os"
77 "sync"
8+ "time"
89
910 "github.com/bnema/dumber/internal/application/port"
1011 domainurl "github.com/bnema/dumber/internal/domain/url"
@@ -136,7 +137,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
136137 }
137138
138139 // Check service cache (memory + disk) and convert to texture
139- if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {
140+ if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {
140141 log.Debug().
141142 Str("domain", domain).
142143 Int("bytes", len(data)).
@@ -333,9 +334,9 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t
333334
334335 // PreloadFromCache attempts to load a favicon from cache without fetching.
335336 // Returns the texture if found in memory or disk cache, nil otherwise.
336-func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture {
337+func (a *FaviconAdapter) PreloadFromCache(ctx context.Context, pageURL string) *gdk.Texture {
337338 if a.disabled {
338- logging.FromContext(context.Background()).Debug().
339+ logging.FromContext(ctx).Debug().
339340 Str("page_url", pageURL).
340341 Str("env", disableFaviconsEnv).
341342 Msg("favicon: PreloadFromCache skipped because favicon pipeline is disabled")
@@ -345,7 +346,7 @@ func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture {
345346 if domain == "" {
346347 return nil
347348 }
348- log := logging.FromContext(context.Background())
349+ log := logging.FromContext(ctx)
349350 log.Debug().
350351 Str("page_url", pageURL).
351352 Str("domain", domain).
@@ -358,7 +359,7 @@ func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture {
358359 }
359360
360361 // Check service cache (memory + disk)
361- if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {
362+ if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {
362363 log.Debug().
363364 Str("domain", domain).
364365 Int("bytes", len(data)).
@@ -510,8 +511,8 @@ func (a *FaviconAdapter) textureFromBytesOnGTK(data []byte) *gdk.Texture {
510511 Int("bytes", len(data)).
511512 Msg("favicon: textureFromBytesOnGTK begin")
512513
513- ctx := glib.MainContextDefault()
514- if ctx != nil && ctx.IsOwner() {
514+ glibCtx := glib.MainContextDefault()
515+ if glibCtx != nil && glibCtx.IsOwner() {
515516 texture := bytesToTexture(data)
516517 log.Debug().Bool("texture_nil", texture == nil).Msg("favicon: textureFromBytesOnGTK inline")
517518 return texture
@@ -523,9 +524,14 @@ func (a *FaviconAdapter) textureFromBytesOnGTK(data []byte) *gdk.Texture {
523524 resultCh <- textureResult{texture: bytesToTexture(data)}
524525 })
525526 glib.IdleAddOnce(&cb, 0)
526- result := <-resultCh
527- log.Debug().Bool("texture_nil", result.texture == nil).Msg("favicon: textureFromBytesOnGTK end")
528- return result.texture
527+ select {
528+ case result := <-resultCh:
529+ log.Debug().Bool("texture_nil", result.texture == nil).Msg("favicon: textureFromBytesOnGTK end")
530+ return result.texture
531+ case <-time.After(5 * time.Second):
532+ log.Error().Msg("textureFromBytesOnGTK timeout - possible deadlock")
533+ return nil
534+ }
529535 }
530536
531537 // bytesToTexture converts raw favicon bytes to a GDK texture.
@@ -5,6 +5,7 @@ import (
5 "context"5 "context"
6 "os"6 "os"
7 "sync"7 "sync"
8+ "time"
8 9
9 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
10 domainurl "github.com/bnema/dumber/internal/domain/url"11 domainurl "github.com/bnema/dumber/internal/domain/url"
@@ -136,7 +137,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
136 }137 }
137 138
138 // Check service cache (memory + disk) and convert to texture139 // Check service cache (memory + disk) and convert to texture
139- if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {140+ if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {
140 log.Debug().141 log.Debug().
141 Str("domain", domain).142 Str("domain", domain).
142 Int("bytes", len(data)).143 Int("bytes", len(data)).
@@ -333,9 +334,9 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t
333 334
334 // PreloadFromCache attempts to load a favicon from cache without fetching.335 // PreloadFromCache attempts to load a favicon from cache without fetching.
335 // Returns the texture if found in memory or disk cache, nil otherwise.336 // Returns the texture if found in memory or disk cache, nil otherwise.
336-func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture {337+func (a *FaviconAdapter) PreloadFromCache(ctx context.Context, pageURL string) *gdk.Texture {
337 if a.disabled {338 if a.disabled {
338- logging.FromContext(context.Background()).Debug().339+ logging.FromContext(ctx).Debug().
339 Str("page_url", pageURL).340 Str("page_url", pageURL).
340 Str("env", disableFaviconsEnv).341 Str("env", disableFaviconsEnv).
341 Msg("favicon: PreloadFromCache skipped because favicon pipeline is disabled")342 Msg("favicon: PreloadFromCache skipped because favicon pipeline is disabled")
@@ -345,7 +346,7 @@ func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture {
345 if domain == "" {346 if domain == "" {
346 return nil347 return nil
347 }348 }
348- log := logging.FromContext(context.Background())349+ log := logging.FromContext(ctx)
349 log.Debug().350 log.Debug().
350 Str("page_url", pageURL).351 Str("page_url", pageURL).
351 Str("domain", domain).352 Str("domain", domain).
@@ -358,7 +359,7 @@ func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture {
358 }359 }
359 360
360 // Check service cache (memory + disk)361 // Check service cache (memory + disk)
361- if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {362+ if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {
362 log.Debug().363 log.Debug().
363 Str("domain", domain).364 Str("domain", domain).
364 Int("bytes", len(data)).365 Int("bytes", len(data)).
@@ -510,8 +511,8 @@ func (a *FaviconAdapter) textureFromBytesOnGTK(data []byte) *gdk.Texture {
510 Int("bytes", len(data)).511 Int("bytes", len(data)).
511 Msg("favicon: textureFromBytesOnGTK begin")512 Msg("favicon: textureFromBytesOnGTK begin")
512 513
513- ctx := glib.MainContextDefault()514+ glibCtx := glib.MainContextDefault()
514- if ctx != nil && ctx.IsOwner() {515+ if glibCtx != nil && glibCtx.IsOwner() {
515 texture := bytesToTexture(data)516 texture := bytesToTexture(data)
516 log.Debug().Bool("texture_nil", texture == nil).Msg("favicon: textureFromBytesOnGTK inline")517 log.Debug().Bool("texture_nil", texture == nil).Msg("favicon: textureFromBytesOnGTK inline")
517 return texture518 return texture
@@ -523,9 +524,14 @@ func (a *FaviconAdapter) textureFromBytesOnGTK(data []byte) *gdk.Texture {
523 resultCh <- textureResult{texture: bytesToTexture(data)}524 resultCh <- textureResult{texture: bytesToTexture(data)}
524 })525 })
525 glib.IdleAddOnce(&cb, 0)526 glib.IdleAddOnce(&cb, 0)
526- result := <-resultCh527+ select {
527- log.Debug().Bool("texture_nil", result.texture == nil).Msg("favicon: textureFromBytesOnGTK end")528+ case result := <-resultCh:
528- return result.texture529+ log.Debug().Bool("texture_nil", result.texture == nil).Msg("favicon: textureFromBytesOnGTK end")
530+ return result.texture
531+ case <-time.After(5 * time.Second):
532+ log.Error().Msg("textureFromBytesOnGTK timeout - possible deadlock")
533+ return nil
534+ }
529 }535 }
530 536
531 // bytesToTexture converts raw favicon bytes to a GDK texture.537 // bytesToTexture converts raw favicon bytes to a GDK texture.
M internal/ui/coordinator/content/popup.go +5 -1
@@ -370,7 +370,11 @@ func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.W
370370 // For engines that create independent browsers (not real popups),
371371 // the WebView has no pending navigation — load the target URI.
372372 if pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" {
373- _ = pending.WebView.LoadURI(ctx, pending.TargetURI)
373+ if err := pending.WebView.LoadURI(ctx, pending.TargetURI); err != nil {
374+ log.Warn().Err(err).
375+ Str("uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
376+ Msg("failed to load target URI in popup")
377+ }
374378 }
375379 }
376380
@@ -370,7 +370,11 @@ func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.W
370 // For engines that create independent browsers (not real popups),370 // For engines that create independent browsers (not real popups),
371 // the WebView has no pending navigation — load the target URI.371 // the WebView has no pending navigation — load the target URI.
372 if pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" {372 if pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" {
373- _ = pending.WebView.LoadURI(ctx, pending.TargetURI)373+ if err := pending.WebView.LoadURI(ctx, pending.TargetURI); err != nil {
374+ log.Warn().Err(err).
375+ Str("uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
376+ Msg("failed to load target URI in popup")
377+ }
374 }378 }
375 }379 }
376 380
M internal/ui/coordinator/content/title.go +1 -1
@@ -286,7 +286,7 @@ func (c *Coordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.Pa
286286 }
287287
288288 // Check memory and disk cache (no external fetch)
289- texture := c.faviconAdapter.PreloadFromCache(uri)
289+ texture := c.faviconAdapter.PreloadFromCache(ctx, uri)
290290 select {
291291 case <-ctx.Done():
292292 return
@@ -286,7 +286,7 @@ func (c *Coordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.Pa
286 }286 }
287 287
288 // Check memory and disk cache (no external fetch)288 // Check memory and disk cache (no external fetch)
289- texture := c.faviconAdapter.PreloadFromCache(uri)289+ texture := c.faviconAdapter.PreloadFromCache(ctx, uri)
290 select {290 select {
291 case <-ctx.Done():291 case <-ctx.Done():
292 return292 return

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