detached e0d4a99

fix(context-menu): address review follow-ups

Harden image validation and clipboard execution, move image data out of the port layer, and add the missing context-menu regression coverage raised during PR review.
commit
e0d4a99348d843a7d7e67c28751e5b4a50d5a528
author
brice · · 2026-04-13 18:22 UTC
committer
brice · · 2026-04-14 12:06 UTC
parent
e1b799b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
22 files · +404 -150
M internal/application/port/clipboard.go +6 -2
@@ -1,6 +1,10 @@
11 package port
22
3-import "context"
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
48
59 // Clipboard defines the port interface for clipboard operations.
610 // This abstracts platform-specific clipboard implementations (GTK, etc.).
@@ -9,7 +13,7 @@ type Clipboard interface {
913 WriteText(ctx context.Context, text string) error
1014
1115 // WriteImage copies encoded image bytes to the clipboard.
12- WriteImage(ctx context.Context, image ImageData) error
16+ WriteImage(ctx context.Context, image entity.ImageData) error
1317
1418 // ReadText reads text from the clipboard.
1519 // Returns empty string if clipboard is empty or contains non-text data.
@@ -1,6 +1,10 @@
1 package port1 package port
2 2
3-import "context"3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
4 8
5 // Clipboard defines the port interface for clipboard operations.9 // Clipboard defines the port interface for clipboard operations.
6 // This abstracts platform-specific clipboard implementations (GTK, etc.).10 // This abstracts platform-specific clipboard implementations (GTK, etc.).
@@ -9,7 +13,7 @@ type Clipboard interface {
9 WriteText(ctx context.Context, text string) error13 WriteText(ctx context.Context, text string) error
10 14
11 // WriteImage copies encoded image bytes to the clipboard.15 // WriteImage copies encoded image bytes to the clipboard.
12- WriteImage(ctx context.Context, image ImageData) error16+ WriteImage(ctx context.Context, image entity.ImageData) error
13 17
14 // ReadText reads text from the clipboard.18 // ReadText reads text from the clipboard.
15 // Returns empty string if clipboard is empty or contains non-text data.19 // Returns empty string if clipboard is empty or contains non-text data.
M internal/application/port/image_data.go +6 -8
@@ -1,19 +1,17 @@
11 package port
22
3-import "context"
3+import (
4+ "context"
45
5-// ImageData contains resolved image bytes and MIME type metadata.
6-type ImageData struct {
7- Bytes []byte
8- MimeType string
9-}
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
108
119 // ImageDataResolver resolves image bytes from a URI.
1210 type ImageDataResolver interface {
13- ResolveImageData(ctx context.Context, uri string) (ImageData, error)
11+ ResolveImageData(ctx context.Context, uri string) (entity.ImageData, error)
1412 }
1513
1614 // ResolvedImageSaver persists already resolved image bytes.
1715 type ResolvedImageSaver interface {
18- SaveResolvedImage(ctx context.Context, image ImageData, menuContext MenuContext) error
16+ SaveResolvedImage(ctx context.Context, image entity.ImageData, menuContext MenuContext) error
1917 }
@@ -1,19 +1,17 @@
1 package port1 package port
2 2
3-import "context"3+import (
4+ "context"
4 5
5-// ImageData contains resolved image bytes and MIME type metadata.6+ "github.com/bnema/dumber/internal/domain/entity"
6-type ImageData struct {7+)
7- Bytes []byte
8- MimeType string
9-}
10 8
11 // ImageDataResolver resolves image bytes from a URI.9 // ImageDataResolver resolves image bytes from a URI.
12 type ImageDataResolver interface {10 type ImageDataResolver interface {
13- ResolveImageData(ctx context.Context, uri string) (ImageData, error)11+ ResolveImageData(ctx context.Context, uri string) (entity.ImageData, error)
14 }12 }
15 13
16 // ResolvedImageSaver persists already resolved image bytes.14 // ResolvedImageSaver persists already resolved image bytes.
17 type ResolvedImageSaver interface {15 type ResolvedImageSaver interface {
18- SaveResolvedImage(ctx context.Context, image ImageData, menuContext MenuContext) error16+ SaveResolvedImage(ctx context.Context, image entity.ImageData, menuContext MenuContext) error
19 }17 }
M internal/application/port/mocks/mocks.go +21 -21
@@ -371,7 +371,7 @@ func (_c *MockClipboard_ReadText_Call) RunAndReturn(run func(ctx context.Context
371371 }
372372
373373 // WriteImage provides a mock function for the type MockClipboard
374-func (_mock *MockClipboard) WriteImage(ctx context.Context, image port.ImageData) error {
374+func (_mock *MockClipboard) WriteImage(ctx context.Context, image entity.ImageData) error {
375375 ret := _mock.Called(ctx, image)
376376
377377 if len(ret) == 0 {
@@ -379,7 +379,7 @@ func (_mock *MockClipboard) WriteImage(ctx context.Context, image port.ImageData
379379 }
380380
381381 var r0 error
382- if returnFunc, ok := ret.Get(0).(func(context.Context, port.ImageData) error); ok {
382+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.ImageData) error); ok {
383383 r0 = returnFunc(ctx, image)
384384 } else {
385385 r0 = ret.Error(0)
@@ -394,20 +394,20 @@ type MockClipboard_WriteImage_Call struct {
394394
395395 // WriteImage is a helper method to define mock.On call
396396 // - ctx context.Context
397-// - image port.ImageData
397+// - image entity.ImageData
398398 func (_e *MockClipboard_Expecter) WriteImage(ctx interface{}, image interface{}) *MockClipboard_WriteImage_Call {
399399 return &MockClipboard_WriteImage_Call{Call: _e.mock.On("WriteImage", ctx, image)}
400400 }
401401
402-func (_c *MockClipboard_WriteImage_Call) Run(run func(ctx context.Context, image port.ImageData)) *MockClipboard_WriteImage_Call {
402+func (_c *MockClipboard_WriteImage_Call) Run(run func(ctx context.Context, image entity.ImageData)) *MockClipboard_WriteImage_Call {
403403 _c.Call.Run(func(args mock.Arguments) {
404404 var arg0 context.Context
405405 if args[0] != nil {
406406 arg0 = args[0].(context.Context)
407407 }
408- var arg1 port.ImageData
408+ var arg1 entity.ImageData
409409 if args[1] != nil {
410- arg1 = args[1].(port.ImageData)
410+ arg1 = args[1].(entity.ImageData)
411411 }
412412 run(
413413 arg0,
@@ -422,7 +422,7 @@ func (_c *MockClipboard_WriteImage_Call) Return(err error) *MockClipboard_WriteI
422422 return _c
423423 }
424424
425-func (_c *MockClipboard_WriteImage_Call) RunAndReturn(run func(ctx context.Context, image port.ImageData) error) *MockClipboard_WriteImage_Call {
425+func (_c *MockClipboard_WriteImage_Call) RunAndReturn(run func(ctx context.Context, image entity.ImageData) error) *MockClipboard_WriteImage_Call {
426426 _c.Call.Return(run)
427427 return _c
428428 }
@@ -7729,22 +7729,22 @@ func (_m *MockImageDataResolver) EXPECT() *MockImageDataResolver_Expecter {
77297729 }
77307730
77317731 // ResolveImageData provides a mock function for the type MockImageDataResolver
7732-func (_mock *MockImageDataResolver) ResolveImageData(ctx context.Context, uri string) (port.ImageData, error) {
7732+func (_mock *MockImageDataResolver) ResolveImageData(ctx context.Context, uri string) (entity.ImageData, error) {
77337733 ret := _mock.Called(ctx, uri)
77347734
77357735 if len(ret) == 0 {
77367736 panic("no return value specified for ResolveImageData")
77377737 }
77387738
7739- var r0 port.ImageData
7739+ var r0 entity.ImageData
77407740 var r1 error
7741- if returnFunc, ok := ret.Get(0).(func(context.Context, string) (port.ImageData, error)); ok {
7741+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (entity.ImageData, error)); ok {
77427742 return returnFunc(ctx, uri)
77437743 }
7744- if returnFunc, ok := ret.Get(0).(func(context.Context, string) port.ImageData); ok {
7744+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) entity.ImageData); ok {
77457745 r0 = returnFunc(ctx, uri)
77467746 } else {
7747- r0 = ret.Get(0).(port.ImageData)
7747+ r0 = ret.Get(0).(entity.ImageData)
77487748 }
77497749 if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
77507750 r1 = returnFunc(ctx, uri)
@@ -7784,12 +7784,12 @@ func (_c *MockImageDataResolver_ResolveImageData_Call) Run(run func(ctx context.
77847784 return _c
77857785 }
77867786
7787-func (_c *MockImageDataResolver_ResolveImageData_Call) Return(imageData port.ImageData, err error) *MockImageDataResolver_ResolveImageData_Call {
7787+func (_c *MockImageDataResolver_ResolveImageData_Call) Return(imageData entity.ImageData, err error) *MockImageDataResolver_ResolveImageData_Call {
77887788 _c.Call.Return(imageData, err)
77897789 return _c
77907790 }
77917791
7792-func (_c *MockImageDataResolver_ResolveImageData_Call) RunAndReturn(run func(ctx context.Context, uri string) (port.ImageData, error)) *MockImageDataResolver_ResolveImageData_Call {
7792+func (_c *MockImageDataResolver_ResolveImageData_Call) RunAndReturn(run func(ctx context.Context, uri string) (entity.ImageData, error)) *MockImageDataResolver_ResolveImageData_Call {
77937793 _c.Call.Return(run)
77947794 return _c
77957795 }
@@ -7822,7 +7822,7 @@ func (_m *MockResolvedImageSaver) EXPECT() *MockResolvedImageSaver_Expecter {
78227822 }
78237823
78247824 // SaveResolvedImage provides a mock function for the type MockResolvedImageSaver
7825-func (_mock *MockResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData, menuContext port.MenuContext) error {
7825+func (_mock *MockResolvedImageSaver) SaveResolvedImage(ctx context.Context, image entity.ImageData, menuContext port.MenuContext) error {
78267826 ret := _mock.Called(ctx, image, menuContext)
78277827
78287828 if len(ret) == 0 {
@@ -7830,7 +7830,7 @@ func (_mock *MockResolvedImageSaver) SaveResolvedImage(ctx context.Context, imag
78307830 }
78317831
78327832 var r0 error
7833- if returnFunc, ok := ret.Get(0).(func(context.Context, port.ImageData, port.MenuContext) error); ok {
7833+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.ImageData, port.MenuContext) error); ok {
78347834 r0 = returnFunc(ctx, image, menuContext)
78357835 } else {
78367836 r0 = ret.Error(0)
@@ -7845,21 +7845,21 @@ type MockResolvedImageSaver_SaveResolvedImage_Call struct {
78457845
78467846 // SaveResolvedImage is a helper method to define mock.On call
78477847 // - ctx context.Context
7848-// - image port.ImageData
7848+// - image entity.ImageData
78497849 // - menuContext port.MenuContext
78507850 func (_e *MockResolvedImageSaver_Expecter) SaveResolvedImage(ctx interface{}, image interface{}, menuContext interface{}) *MockResolvedImageSaver_SaveResolvedImage_Call {
78517851 return &MockResolvedImageSaver_SaveResolvedImage_Call{Call: _e.mock.On("SaveResolvedImage", ctx, image, menuContext)}
78527852 }
78537853
7854-func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx context.Context, image port.ImageData, menuContext port.MenuContext)) *MockResolvedImageSaver_SaveResolvedImage_Call {
7854+func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx context.Context, image entity.ImageData, menuContext port.MenuContext)) *MockResolvedImageSaver_SaveResolvedImage_Call {
78557855 _c.Call.Run(func(args mock.Arguments) {
78567856 var arg0 context.Context
78577857 if args[0] != nil {
78587858 arg0 = args[0].(context.Context)
78597859 }
7860- var arg1 port.ImageData
7860+ var arg1 entity.ImageData
78617861 if args[1] != nil {
7862- arg1 = args[1].(port.ImageData)
7862+ arg1 = args[1].(entity.ImageData)
78637863 }
78647864 var arg2 port.MenuContext
78657865 if args[2] != nil {
@@ -7879,7 +7879,7 @@ func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Return(err error) *Mock
78797879 return _c
78807880 }
78817881
7882-func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) RunAndReturn(run func(ctx context.Context, image port.ImageData, menuContext port.MenuContext) error) *MockResolvedImageSaver_SaveResolvedImage_Call {
7882+func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) RunAndReturn(run func(ctx context.Context, image entity.ImageData, menuContext port.MenuContext) error) *MockResolvedImageSaver_SaveResolvedImage_Call {
78837883 _c.Call.Return(run)
78847884 return _c
78857885 }
@@ -371,7 +371,7 @@ func (_c *MockClipboard_ReadText_Call) RunAndReturn(run func(ctx context.Context
371 }371 }
372 372
373 // WriteImage provides a mock function for the type MockClipboard373 // WriteImage provides a mock function for the type MockClipboard
374-func (_mock *MockClipboard) WriteImage(ctx context.Context, image port.ImageData) error {374+func (_mock *MockClipboard) WriteImage(ctx context.Context, image entity.ImageData) error {
375 ret := _mock.Called(ctx, image)375 ret := _mock.Called(ctx, image)
376 376
377 if len(ret) == 0 {377 if len(ret) == 0 {
@@ -379,7 +379,7 @@ func (_mock *MockClipboard) WriteImage(ctx context.Context, image port.ImageData
379 }379 }
380 380
381 var r0 error381 var r0 error
382- if returnFunc, ok := ret.Get(0).(func(context.Context, port.ImageData) error); ok {382+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.ImageData) error); ok {
383 r0 = returnFunc(ctx, image)383 r0 = returnFunc(ctx, image)
384 } else {384 } else {
385 r0 = ret.Error(0)385 r0 = ret.Error(0)
@@ -394,20 +394,20 @@ type MockClipboard_WriteImage_Call struct {
394 394
395 // WriteImage is a helper method to define mock.On call395 // WriteImage is a helper method to define mock.On call
396 // - ctx context.Context396 // - ctx context.Context
397-// - image port.ImageData397+// - image entity.ImageData
398 func (_e *MockClipboard_Expecter) WriteImage(ctx interface{}, image interface{}) *MockClipboard_WriteImage_Call {398 func (_e *MockClipboard_Expecter) WriteImage(ctx interface{}, image interface{}) *MockClipboard_WriteImage_Call {
399 return &MockClipboard_WriteImage_Call{Call: _e.mock.On("WriteImage", ctx, image)}399 return &MockClipboard_WriteImage_Call{Call: _e.mock.On("WriteImage", ctx, image)}
400 }400 }
401 401
402-func (_c *MockClipboard_WriteImage_Call) Run(run func(ctx context.Context, image port.ImageData)) *MockClipboard_WriteImage_Call {402+func (_c *MockClipboard_WriteImage_Call) Run(run func(ctx context.Context, image entity.ImageData)) *MockClipboard_WriteImage_Call {
403 _c.Call.Run(func(args mock.Arguments) {403 _c.Call.Run(func(args mock.Arguments) {
404 var arg0 context.Context404 var arg0 context.Context
405 if args[0] != nil {405 if args[0] != nil {
406 arg0 = args[0].(context.Context)406 arg0 = args[0].(context.Context)
407 }407 }
408- var arg1 port.ImageData408+ var arg1 entity.ImageData
409 if args[1] != nil {409 if args[1] != nil {
410- arg1 = args[1].(port.ImageData)410+ arg1 = args[1].(entity.ImageData)
411 }411 }
412 run(412 run(
413 arg0,413 arg0,
@@ -422,7 +422,7 @@ func (_c *MockClipboard_WriteImage_Call) Return(err error) *MockClipboard_WriteI
422 return _c422 return _c
423 }423 }
424 424
425-func (_c *MockClipboard_WriteImage_Call) RunAndReturn(run func(ctx context.Context, image port.ImageData) error) *MockClipboard_WriteImage_Call {425+func (_c *MockClipboard_WriteImage_Call) RunAndReturn(run func(ctx context.Context, image entity.ImageData) error) *MockClipboard_WriteImage_Call {
426 _c.Call.Return(run)426 _c.Call.Return(run)
427 return _c427 return _c
428 }428 }
@@ -7729,22 +7729,22 @@ func (_m *MockImageDataResolver) EXPECT() *MockImageDataResolver_Expecter {
7729 }7729 }
7730 7730
7731 // ResolveImageData provides a mock function for the type MockImageDataResolver7731 // ResolveImageData provides a mock function for the type MockImageDataResolver
7732-func (_mock *MockImageDataResolver) ResolveImageData(ctx context.Context, uri string) (port.ImageData, error) {7732+func (_mock *MockImageDataResolver) ResolveImageData(ctx context.Context, uri string) (entity.ImageData, error) {
7733 ret := _mock.Called(ctx, uri)7733 ret := _mock.Called(ctx, uri)
7734 7734
7735 if len(ret) == 0 {7735 if len(ret) == 0 {
7736 panic("no return value specified for ResolveImageData")7736 panic("no return value specified for ResolveImageData")
7737 }7737 }
7738 7738
7739- var r0 port.ImageData7739+ var r0 entity.ImageData
7740 var r1 error7740 var r1 error
7741- if returnFunc, ok := ret.Get(0).(func(context.Context, string) (port.ImageData, error)); ok {7741+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (entity.ImageData, error)); ok {
7742 return returnFunc(ctx, uri)7742 return returnFunc(ctx, uri)
7743 }7743 }
7744- if returnFunc, ok := ret.Get(0).(func(context.Context, string) port.ImageData); ok {7744+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) entity.ImageData); ok {
7745 r0 = returnFunc(ctx, uri)7745 r0 = returnFunc(ctx, uri)
7746 } else {7746 } else {
7747- r0 = ret.Get(0).(port.ImageData)7747+ r0 = ret.Get(0).(entity.ImageData)
7748 }7748 }
7749 if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {7749 if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
7750 r1 = returnFunc(ctx, uri)7750 r1 = returnFunc(ctx, uri)
@@ -7784,12 +7784,12 @@ func (_c *MockImageDataResolver_ResolveImageData_Call) Run(run func(ctx context.
7784 return _c7784 return _c
7785 }7785 }
7786 7786
7787-func (_c *MockImageDataResolver_ResolveImageData_Call) Return(imageData port.ImageData, err error) *MockImageDataResolver_ResolveImageData_Call {7787+func (_c *MockImageDataResolver_ResolveImageData_Call) Return(imageData entity.ImageData, err error) *MockImageDataResolver_ResolveImageData_Call {
7788 _c.Call.Return(imageData, err)7788 _c.Call.Return(imageData, err)
7789 return _c7789 return _c
7790 }7790 }
7791 7791
7792-func (_c *MockImageDataResolver_ResolveImageData_Call) RunAndReturn(run func(ctx context.Context, uri string) (port.ImageData, error)) *MockImageDataResolver_ResolveImageData_Call {7792+func (_c *MockImageDataResolver_ResolveImageData_Call) RunAndReturn(run func(ctx context.Context, uri string) (entity.ImageData, error)) *MockImageDataResolver_ResolveImageData_Call {
7793 _c.Call.Return(run)7793 _c.Call.Return(run)
7794 return _c7794 return _c
7795 }7795 }
@@ -7822,7 +7822,7 @@ func (_m *MockResolvedImageSaver) EXPECT() *MockResolvedImageSaver_Expecter {
7822 }7822 }
7823 7823
7824 // SaveResolvedImage provides a mock function for the type MockResolvedImageSaver7824 // SaveResolvedImage provides a mock function for the type MockResolvedImageSaver
7825-func (_mock *MockResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData, menuContext port.MenuContext) error {7825+func (_mock *MockResolvedImageSaver) SaveResolvedImage(ctx context.Context, image entity.ImageData, menuContext port.MenuContext) error {
7826 ret := _mock.Called(ctx, image, menuContext)7826 ret := _mock.Called(ctx, image, menuContext)
7827 7827
7828 if len(ret) == 0 {7828 if len(ret) == 0 {
@@ -7830,7 +7830,7 @@ func (_mock *MockResolvedImageSaver) SaveResolvedImage(ctx context.Context, imag
7830 }7830 }
7831 7831
7832 var r0 error7832 var r0 error
7833- if returnFunc, ok := ret.Get(0).(func(context.Context, port.ImageData, port.MenuContext) error); ok {7833+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.ImageData, port.MenuContext) error); ok {
7834 r0 = returnFunc(ctx, image, menuContext)7834 r0 = returnFunc(ctx, image, menuContext)
7835 } else {7835 } else {
7836 r0 = ret.Error(0)7836 r0 = ret.Error(0)
@@ -7845,21 +7845,21 @@ type MockResolvedImageSaver_SaveResolvedImage_Call struct {
7845 7845
7846 // SaveResolvedImage is a helper method to define mock.On call7846 // SaveResolvedImage is a helper method to define mock.On call
7847 // - ctx context.Context7847 // - ctx context.Context
7848-// - image port.ImageData7848+// - image entity.ImageData
7849 // - menuContext port.MenuContext7849 // - menuContext port.MenuContext
7850 func (_e *MockResolvedImageSaver_Expecter) SaveResolvedImage(ctx interface{}, image interface{}, menuContext interface{}) *MockResolvedImageSaver_SaveResolvedImage_Call {7850 func (_e *MockResolvedImageSaver_Expecter) SaveResolvedImage(ctx interface{}, image interface{}, menuContext interface{}) *MockResolvedImageSaver_SaveResolvedImage_Call {
7851 return &MockResolvedImageSaver_SaveResolvedImage_Call{Call: _e.mock.On("SaveResolvedImage", ctx, image, menuContext)}7851 return &MockResolvedImageSaver_SaveResolvedImage_Call{Call: _e.mock.On("SaveResolvedImage", ctx, image, menuContext)}
7852 }7852 }
7853 7853
7854-func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx context.Context, image port.ImageData, menuContext port.MenuContext)) *MockResolvedImageSaver_SaveResolvedImage_Call {7854+func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx context.Context, image entity.ImageData, menuContext port.MenuContext)) *MockResolvedImageSaver_SaveResolvedImage_Call {
7855 _c.Call.Run(func(args mock.Arguments) {7855 _c.Call.Run(func(args mock.Arguments) {
7856 var arg0 context.Context7856 var arg0 context.Context
7857 if args[0] != nil {7857 if args[0] != nil {
7858 arg0 = args[0].(context.Context)7858 arg0 = args[0].(context.Context)
7859 }7859 }
7860- var arg1 port.ImageData7860+ var arg1 entity.ImageData
7861 if args[1] != nil {7861 if args[1] != nil {
7862- arg1 = args[1].(port.ImageData)7862+ arg1 = args[1].(entity.ImageData)
7863 }7863 }
7864 var arg2 port.MenuContext7864 var arg2 port.MenuContext
7865 if args[2] != nil {7865 if args[2] != nil {
@@ -7879,7 +7879,7 @@ func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Return(err error) *Mock
7879 return _c7879 return _c
7880 }7880 }
7881 7881
7882-func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) RunAndReturn(run func(ctx context.Context, image port.ImageData, menuContext port.MenuContext) error) *MockResolvedImageSaver_SaveResolvedImage_Call {7882+func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) RunAndReturn(run func(ctx context.Context, image entity.ImageData, menuContext port.MenuContext) error) *MockResolvedImageSaver_SaveResolvedImage_Call {
7883 _c.Call.Return(run)7883 _c.Call.Return(run)
7884 return _c7884 return _c
7885 }7885 }
M internal/application/usecase/execute_context_menu_action.go +12 -11
@@ -5,6 +5,7 @@ import (
55 "fmt"
66
77 "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
89 )
910
1011 // ExecuteContextMenuActionInput identifies the action and its context.
@@ -81,25 +82,25 @@ func (uc *ExecuteContextMenuActionUseCase) executeMenuAction(
8182 }
8283 return nil
8384 case port.MenuActionCopyImage:
85+ if uc.clipboard == nil {
86+ return fmt.Errorf("copy image: clipboard not available")
87+ }
8488 image, err := uc.resolveImageData(ctx, menuContext.ImageURI)
8589 if err != nil {
8690 return fmt.Errorf("copy image: %w", err)
8791 }
88- if uc.clipboard == nil {
89- return fmt.Errorf("copy image: clipboard not available")
90- }
9192 if err := uc.clipboard.WriteImage(ctx, image); err != nil {
9293 return fmt.Errorf("copy image: %w", err)
9394 }
9495 return nil
9596 case port.MenuActionSaveImage:
97+ if uc.saver == nil {
98+ return fmt.Errorf("save image: image saver not available")
99+ }
96100 image, err := uc.resolveImageData(ctx, menuContext.ImageURI)
97101 if err != nil {
98102 return fmt.Errorf("save image: %w", err)
99103 }
100- if uc.saver == nil {
101- return fmt.Errorf("save image: image saver not available")
102- }
103104 if err := uc.saver.SaveResolvedImage(ctx, image, menuContext); err != nil {
104105 return fmt.Errorf("save image: %w", err)
105106 }
@@ -116,20 +117,20 @@ func (uc *ExecuteContextMenuActionUseCase) executeMenuAction(
116117 }
117118 }
118119
119-func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (port.ImageData, error) {
120+func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (entity.ImageData, error) {
120121 if uri == "" {
121- return port.ImageData{}, fmt.Errorf("image URI not available")
122+ return entity.ImageData{}, fmt.Errorf("image URI not available")
122123 }
123124 if uc.resolver == nil {
124- return port.ImageData{}, fmt.Errorf("image data resolver not available")
125+ return entity.ImageData{}, fmt.Errorf("image data resolver not available")
125126 }
126127
127128 image, err := uc.resolver.ResolveImageData(ctx, uri)
128129 if err != nil {
129- return port.ImageData{}, err
130+ return entity.ImageData{}, err
130131 }
131132 if len(image.Bytes) == 0 {
132- return port.ImageData{}, fmt.Errorf("image data not available")
133+ return entity.ImageData{}, fmt.Errorf("image data not available")
133134 }
134135
135136 return image, nil
@@ -5,6 +5,7 @@ import (
5 "fmt"5 "fmt"
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
8 )9 )
9 10
10 // ExecuteContextMenuActionInput identifies the action and its context.11 // ExecuteContextMenuActionInput identifies the action and its context.
@@ -81,25 +82,25 @@ func (uc *ExecuteContextMenuActionUseCase) executeMenuAction(
81 }82 }
82 return nil83 return nil
83 case port.MenuActionCopyImage:84 case port.MenuActionCopyImage:
85+ if uc.clipboard == nil {
86+ return fmt.Errorf("copy image: clipboard not available")
87+ }
84 image, err := uc.resolveImageData(ctx, menuContext.ImageURI)88 image, err := uc.resolveImageData(ctx, menuContext.ImageURI)
85 if err != nil {89 if err != nil {
86 return fmt.Errorf("copy image: %w", err)90 return fmt.Errorf("copy image: %w", err)
87 }91 }
88- if uc.clipboard == nil {
89- return fmt.Errorf("copy image: clipboard not available")
90- }
91 if err := uc.clipboard.WriteImage(ctx, image); err != nil {92 if err := uc.clipboard.WriteImage(ctx, image); err != nil {
92 return fmt.Errorf("copy image: %w", err)93 return fmt.Errorf("copy image: %w", err)
93 }94 }
94 return nil95 return nil
95 case port.MenuActionSaveImage:96 case port.MenuActionSaveImage:
97+ if uc.saver == nil {
98+ return fmt.Errorf("save image: image saver not available")
99+ }
96 image, err := uc.resolveImageData(ctx, menuContext.ImageURI)100 image, err := uc.resolveImageData(ctx, menuContext.ImageURI)
97 if err != nil {101 if err != nil {
98 return fmt.Errorf("save image: %w", err)102 return fmt.Errorf("save image: %w", err)
99 }103 }
100- if uc.saver == nil {
101- return fmt.Errorf("save image: image saver not available")
102- }
103 if err := uc.saver.SaveResolvedImage(ctx, image, menuContext); err != nil {104 if err := uc.saver.SaveResolvedImage(ctx, image, menuContext); err != nil {
104 return fmt.Errorf("save image: %w", err)105 return fmt.Errorf("save image: %w", err)
105 }106 }
@@ -116,20 +117,20 @@ func (uc *ExecuteContextMenuActionUseCase) executeMenuAction(
116 }117 }
117 }118 }
118 119
119-func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (port.ImageData, error) {120+func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (entity.ImageData, error) {
120 if uri == "" {121 if uri == "" {
121- return port.ImageData{}, fmt.Errorf("image URI not available")122+ return entity.ImageData{}, fmt.Errorf("image URI not available")
122 }123 }
123 if uc.resolver == nil {124 if uc.resolver == nil {
124- return port.ImageData{}, fmt.Errorf("image data resolver not available")125+ return entity.ImageData{}, fmt.Errorf("image data resolver not available")
125 }126 }
126 127
127 image, err := uc.resolver.ResolveImageData(ctx, uri)128 image, err := uc.resolver.ResolveImageData(ctx, uri)
128 if err != nil {129 if err != nil {
129- return port.ImageData{}, err130+ return entity.ImageData{}, err
130 }131 }
131 if len(image.Bytes) == 0 {132 if len(image.Bytes) == 0 {
132- return port.ImageData{}, fmt.Errorf("image data not available")133+ return entity.ImageData{}, fmt.Errorf("image data not available")
133 }134 }
134 135
135 return image, nil136 return image, nil
M internal/application/usecase/execute_context_menu_action_test.go +65 -8
@@ -6,6 +6,7 @@ import (
66 "testing"
77
88 "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/domain/entity"
910 "github.com/stretchr/testify/require"
1011 )
1112
@@ -13,7 +14,7 @@ type fakeClipboard struct {
1314 writeTextCalls int
1415 writeImageCalls int
1516 text string
16- image port.ImageData
17+ image entity.ImageData
1718 }
1819
1920 func (f *fakeClipboard) WriteText(_ context.Context, text string) error {
@@ -22,7 +23,7 @@ func (f *fakeClipboard) WriteText(_ context.Context, text string) error {
2223 return nil
2324 }
2425
25-func (f *fakeClipboard) WriteImage(_ context.Context, image port.ImageData) error {
26+func (f *fakeClipboard) WriteImage(_ context.Context, image entity.ImageData) error {
2627 f.writeImageCalls++
2728 f.image = image
2829 return nil
@@ -35,11 +36,11 @@ func (*fakeClipboard) HasText(context.Context) (bool, error) { return false,
3536 type fakeImageResolver struct {
3637 resolveCalls int
3738 uri string
38- image port.ImageData
39+ image entity.ImageData
3940 err error
4041 }
4142
42-func (f *fakeImageResolver) ResolveImageData(_ context.Context, uri string) (port.ImageData, error) {
43+func (f *fakeImageResolver) ResolveImageData(_ context.Context, uri string) (entity.ImageData, error) {
4344 f.resolveCalls++
4445 f.uri = uri
4546 return f.image, f.err
@@ -47,12 +48,12 @@ func (f *fakeImageResolver) ResolveImageData(_ context.Context, uri string) (por
4748
4849 type fakeResolvedImageSaver struct {
4950 saveCalls int
50- image port.ImageData
51+ image entity.ImageData
5152 menuContext port.MenuContext
5253 err error
5354 }
5455
55-func (f *fakeResolvedImageSaver) SaveResolvedImage(_ context.Context, image port.ImageData, menuContext port.MenuContext) error {
56+func (f *fakeResolvedImageSaver) SaveResolvedImage(_ context.Context, image entity.ImageData, menuContext port.MenuContext) error {
5657 f.saveCalls++
5758 f.image = image
5859 f.menuContext = menuContext
@@ -93,9 +94,48 @@ func TestExecuteContextMenuActionUseCase_CopyImageFailsWithoutResolvedData(t *te
9394 require.Zero(t, delegator.delegateCalls)
9495 }
9596
97+func TestExecuteContextMenuActionUseCase_CopyImageWritesResolvedImage(t *testing.T) {
98+ clipboard := &fakeClipboard{}
99+ resolver := &fakeImageResolver{image: entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}}
100+ saver := &fakeResolvedImageSaver{}
101+ delegator := &fakeMenuActionDelegator{}
102+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
103+
104+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
105+ Action: port.MenuActionCopyImage,
106+ Context: port.MenuContext{ImageURI: "https://example.com/image.png"},
107+ })
108+
109+ require.NoError(t, err)
110+ require.Equal(t, 1, resolver.resolveCalls)
111+ require.Equal(t, "https://example.com/image.png", resolver.uri)
112+ require.Equal(t, 1, clipboard.writeImageCalls)
113+ require.Equal(t, entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}, clipboard.image)
114+ require.Zero(t, saver.saveCalls)
115+ require.Zero(t, delegator.delegateCalls)
116+}
117+
118+func TestExecuteContextMenuActionUseCase_CopyImageFailsFastWithoutClipboard(t *testing.T) {
119+ resolver := &fakeImageResolver{}
120+ saver := &fakeResolvedImageSaver{}
121+ delegator := &fakeMenuActionDelegator{}
122+ uc := NewExecuteContextMenuActionUseCase(nil, resolver, saver, delegator)
123+
124+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
125+ Action: port.MenuActionCopyImage,
126+ Context: port.MenuContext{ImageURI: "https://example.com/image.png"},
127+ })
128+
129+ require.Error(t, err)
130+ require.Contains(t, err.Error(), "clipboard not available")
131+ require.Zero(t, resolver.resolveCalls)
132+ require.Zero(t, saver.saveCalls)
133+ require.Zero(t, delegator.delegateCalls)
134+}
135+
96136 func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *testing.T) {
97137 clipboard := &fakeClipboard{}
98- resolver := &fakeImageResolver{image: port.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}}
138+ resolver := &fakeImageResolver{image: entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}}
99139 saver := &fakeResolvedImageSaver{}
100140 delegator := &fakeMenuActionDelegator{}
101141 uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
@@ -109,13 +149,30 @@ func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *test
109149 require.Equal(t, 1, resolver.resolveCalls)
110150 require.Equal(t, "https://example.com/image.png", resolver.uri)
111151 require.Equal(t, 1, saver.saveCalls)
112- require.Equal(t, port.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}, saver.image)
152+ require.Equal(t, entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}, saver.image)
113153 require.Equal(t, port.MenuContext{ImageURI: "https://example.com/image.png"}, saver.menuContext)
114154 require.Zero(t, clipboard.writeTextCalls)
115155 require.Zero(t, clipboard.writeImageCalls)
116156 require.Zero(t, delegator.delegateCalls)
117157 }
118158
159+func TestExecuteContextMenuActionUseCase_SaveImageFailsFastWithoutSaver(t *testing.T) {
160+ clipboard := &fakeClipboard{}
161+ resolver := &fakeImageResolver{}
162+ delegator := &fakeMenuActionDelegator{}
163+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, nil, delegator)
164+
165+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
166+ Action: port.MenuActionSaveImage,
167+ Context: port.MenuContext{ImageURI: "https://example.com/image.png"},
168+ })
169+
170+ require.Error(t, err)
171+ require.Contains(t, err.Error(), "image saver not available")
172+ require.Zero(t, resolver.resolveCalls)
173+ require.Zero(t, delegator.delegateCalls)
174+}
175+
119176 func TestExecuteContextMenuActionUseCase_DelegatesInspect(t *testing.T) {
120177 clipboard := &fakeClipboard{}
121178 resolver := &fakeImageResolver{}
@@ -6,6 +6,7 @@ import (
6 "testing"6 "testing"
7 7
8 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
10 )11 )
11 12
@@ -13,7 +14,7 @@ type fakeClipboard struct {
13 writeTextCalls int14 writeTextCalls int
14 writeImageCalls int15 writeImageCalls int
15 text string16 text string
16- image port.ImageData17+ image entity.ImageData
17 }18 }
18 19
19 func (f *fakeClipboard) WriteText(_ context.Context, text string) error {20 func (f *fakeClipboard) WriteText(_ context.Context, text string) error {
@@ -22,7 +23,7 @@ func (f *fakeClipboard) WriteText(_ context.Context, text string) error {
22 return nil23 return nil
23 }24 }
24 25
25-func (f *fakeClipboard) WriteImage(_ context.Context, image port.ImageData) error {26+func (f *fakeClipboard) WriteImage(_ context.Context, image entity.ImageData) error {
26 f.writeImageCalls++27 f.writeImageCalls++
27 f.image = image28 f.image = image
28 return nil29 return nil
@@ -35,11 +36,11 @@ func (*fakeClipboard) HasText(context.Context) (bool, error) { return false,
35 type fakeImageResolver struct {36 type fakeImageResolver struct {
36 resolveCalls int37 resolveCalls int
37 uri string38 uri string
38- image port.ImageData39+ image entity.ImageData
39 err error40 err error
40 }41 }
41 42
42-func (f *fakeImageResolver) ResolveImageData(_ context.Context, uri string) (port.ImageData, error) {43+func (f *fakeImageResolver) ResolveImageData(_ context.Context, uri string) (entity.ImageData, error) {
43 f.resolveCalls++44 f.resolveCalls++
44 f.uri = uri45 f.uri = uri
45 return f.image, f.err46 return f.image, f.err
@@ -47,12 +48,12 @@ func (f *fakeImageResolver) ResolveImageData(_ context.Context, uri string) (por
47 48
48 type fakeResolvedImageSaver struct {49 type fakeResolvedImageSaver struct {
49 saveCalls int50 saveCalls int
50- image port.ImageData51+ image entity.ImageData
51 menuContext port.MenuContext52 menuContext port.MenuContext
52 err error53 err error
53 }54 }
54 55
55-func (f *fakeResolvedImageSaver) SaveResolvedImage(_ context.Context, image port.ImageData, menuContext port.MenuContext) error {56+func (f *fakeResolvedImageSaver) SaveResolvedImage(_ context.Context, image entity.ImageData, menuContext port.MenuContext) error {
56 f.saveCalls++57 f.saveCalls++
57 f.image = image58 f.image = image
58 f.menuContext = menuContext59 f.menuContext = menuContext
@@ -93,9 +94,48 @@ func TestExecuteContextMenuActionUseCase_CopyImageFailsWithoutResolvedData(t *te
93 require.Zero(t, delegator.delegateCalls)94 require.Zero(t, delegator.delegateCalls)
94 }95 }
95 96
97+func TestExecuteContextMenuActionUseCase_CopyImageWritesResolvedImage(t *testing.T) {
98+ clipboard := &fakeClipboard{}
99+ resolver := &fakeImageResolver{image: entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}}
100+ saver := &fakeResolvedImageSaver{}
101+ delegator := &fakeMenuActionDelegator{}
102+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
103+
104+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
105+ Action: port.MenuActionCopyImage,
106+ Context: port.MenuContext{ImageURI: "https://example.com/image.png"},
107+ })
108+
109+ require.NoError(t, err)
110+ require.Equal(t, 1, resolver.resolveCalls)
111+ require.Equal(t, "https://example.com/image.png", resolver.uri)
112+ require.Equal(t, 1, clipboard.writeImageCalls)
113+ require.Equal(t, entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}, clipboard.image)
114+ require.Zero(t, saver.saveCalls)
115+ require.Zero(t, delegator.delegateCalls)
116+}
117+
118+func TestExecuteContextMenuActionUseCase_CopyImageFailsFastWithoutClipboard(t *testing.T) {
119+ resolver := &fakeImageResolver{}
120+ saver := &fakeResolvedImageSaver{}
121+ delegator := &fakeMenuActionDelegator{}
122+ uc := NewExecuteContextMenuActionUseCase(nil, resolver, saver, delegator)
123+
124+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
125+ Action: port.MenuActionCopyImage,
126+ Context: port.MenuContext{ImageURI: "https://example.com/image.png"},
127+ })
128+
129+ require.Error(t, err)
130+ require.Contains(t, err.Error(), "clipboard not available")
131+ require.Zero(t, resolver.resolveCalls)
132+ require.Zero(t, saver.saveCalls)
133+ require.Zero(t, delegator.delegateCalls)
134+}
135+
96 func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *testing.T) {136 func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *testing.T) {
97 clipboard := &fakeClipboard{}137 clipboard := &fakeClipboard{}
98- resolver := &fakeImageResolver{image: port.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}}138+ resolver := &fakeImageResolver{image: entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}}
99 saver := &fakeResolvedImageSaver{}139 saver := &fakeResolvedImageSaver{}
100 delegator := &fakeMenuActionDelegator{}140 delegator := &fakeMenuActionDelegator{}
101 uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)141 uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
@@ -109,13 +149,30 @@ func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *test
109 require.Equal(t, 1, resolver.resolveCalls)149 require.Equal(t, 1, resolver.resolveCalls)
110 require.Equal(t, "https://example.com/image.png", resolver.uri)150 require.Equal(t, "https://example.com/image.png", resolver.uri)
111 require.Equal(t, 1, saver.saveCalls)151 require.Equal(t, 1, saver.saveCalls)
112- require.Equal(t, port.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}, saver.image)152+ require.Equal(t, entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}, saver.image)
113 require.Equal(t, port.MenuContext{ImageURI: "https://example.com/image.png"}, saver.menuContext)153 require.Equal(t, port.MenuContext{ImageURI: "https://example.com/image.png"}, saver.menuContext)
114 require.Zero(t, clipboard.writeTextCalls)154 require.Zero(t, clipboard.writeTextCalls)
115 require.Zero(t, clipboard.writeImageCalls)155 require.Zero(t, clipboard.writeImageCalls)
116 require.Zero(t, delegator.delegateCalls)156 require.Zero(t, delegator.delegateCalls)
117 }157 }
118 158
159+func TestExecuteContextMenuActionUseCase_SaveImageFailsFastWithoutSaver(t *testing.T) {
160+ clipboard := &fakeClipboard{}
161+ resolver := &fakeImageResolver{}
162+ delegator := &fakeMenuActionDelegator{}
163+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, nil, delegator)
164+
165+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
166+ Action: port.MenuActionSaveImage,
167+ Context: port.MenuContext{ImageURI: "https://example.com/image.png"},
168+ })
169+
170+ require.Error(t, err)
171+ require.Contains(t, err.Error(), "image saver not available")
172+ require.Zero(t, resolver.resolveCalls)
173+ require.Zero(t, delegator.delegateCalls)
174+}
175+
119 func TestExecuteContextMenuActionUseCase_DelegatesInspect(t *testing.T) {176 func TestExecuteContextMenuActionUseCase_DelegatesInspect(t *testing.T) {
120 clipboard := &fakeClipboard{}177 clipboard := &fakeClipboard{}
121 resolver := &fakeImageResolver{}178 resolver := &fakeImageResolver{}
A internal/domain/entity/image_data.go +7 -0
@@ -0,0 +1,7 @@
1+package entity
2+
3+// ImageData contains resolved image bytes and MIME type metadata.
4+type ImageData struct {
5+ Bytes []byte
6+ MimeType string
7+}
@@ -0,0 +1,7 @@
1+package entity
2+
3+// ImageData contains resolved image bytes and MIME type metadata.
4+type ImageData struct {
5+ Bytes []byte
6+ MimeType string
7+}
M internal/infrastructure/cef/context_menu.go +2 -0
@@ -25,9 +25,11 @@ func (h *handlerSet) RunContextMenu(
2525 if callback != nil {
2626 callback.Cancel()
2727 }
28+ // Returning 1 tells CEF we handled the menu, so cancel suppresses the default menu.
2829 return 1
2930 }
3031 if h.wv == nil || h.wv.engine == nil || h.wv.engine.ctxMenuBuilder == nil {
32+ // Returning 0 lets CEF show its native menu when no custom builder is wired.
3133 return 0
3234 }
3335
@@ -25,9 +25,11 @@ func (h *handlerSet) RunContextMenu(
25 if callback != nil {25 if callback != nil {
26 callback.Cancel()26 callback.Cancel()
27 }27 }
28+ // Returning 1 tells CEF we handled the menu, so cancel suppresses the default menu.
28 return 129 return 1
29 }30 }
30 if h.wv == nil || h.wv.engine == nil || h.wv.engine.ctxMenuBuilder == nil {31 if h.wv == nil || h.wv.engine == nil || h.wv.engine.ctxMenuBuilder == nil {
32+ // Returning 0 lets CEF show its native menu when no custom builder is wired.
31 return 033 return 0
32 }34 }
33 35
M internal/infrastructure/cef/context_menu_adapter_test.go +12 -0
@@ -96,3 +96,15 @@ func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) {
9696 require.Zero(t, callback.contCalls)
9797 require.Equal(t, 1, callback.cancelCalls)
9898 }
99+
100+func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) {
101+ callback := &stubRunContextMenuCallback{}
102+
103+ dispatchContextMenuSelection(callback, map[port.MenuAction]int32{
104+ port.MenuActionInspectElement: 204,
105+ }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"})
106+
107+ require.Equal(t, 1, callback.contCalls)
108+ require.Zero(t, callback.cancelCalls)
109+ require.Equal(t, int32(204), callback.commandID)
110+}
@@ -96,3 +96,15 @@ func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) {
96 require.Zero(t, callback.contCalls)96 require.Zero(t, callback.contCalls)
97 require.Equal(t, 1, callback.cancelCalls)97 require.Equal(t, 1, callback.cancelCalls)
98 }98 }
99+
100+func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) {
101+ callback := &stubRunContextMenuCallback{}
102+
103+ dispatchContextMenuSelection(callback, map[port.MenuAction]int32{
104+ port.MenuActionInspectElement: 204,
105+ }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"})
106+
107+ require.Equal(t, 1, callback.contCalls)
108+ require.Zero(t, callback.cancelCalls)
109+ require.Equal(t, int32(204), callback.commandID)
110+}
M internal/infrastructure/cef/renderer.go +15 -7
@@ -71,6 +71,12 @@ func showMenu(
7171 }
7272 return
7373 }
74+ if !hasRenderableItems(items) {
75+ if onClose != nil {
76+ onClose()
77+ }
78+ return
79+ }
7480
7581 box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
7682 box.AddCssClass("context-menu")
@@ -103,13 +109,6 @@ func showMenu(
103109 box.Append(&btn.Widget)
104110 }
105111
106- if len(items) == 0 {
107- if onClose != nil {
108- onClose()
109- }
110- return
111- }
112-
113112 popover = gtk.NewPopover()
114113 popover.SetChild(&box.Widget)
115114 popover.SetParent(anchor)
@@ -129,3 +128,12 @@ func showMenu(
129128
130129 popover.Popup()
131130 }
131+
132+func hasRenderableItems(items []renderItem) bool {
133+ for _, item := range items {
134+ if !item.separator {
135+ return true
136+ }
137+ }
138+ return false
139+}
@@ -71,6 +71,12 @@ func showMenu(
71 }71 }
72 return72 return
73 }73 }
74+ if !hasRenderableItems(items) {
75+ if onClose != nil {
76+ onClose()
77+ }
78+ return
79+ }
74 80
75 box := gtk.NewBox(gtk.OrientationVerticalValue, 0)81 box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
76 box.AddCssClass("context-menu")82 box.AddCssClass("context-menu")
@@ -103,13 +109,6 @@ func showMenu(
103 box.Append(&btn.Widget)109 box.Append(&btn.Widget)
104 }110 }
105 111
106- if len(items) == 0 {
107- if onClose != nil {
108- onClose()
109- }
110- return
111- }
112-
113 popover = gtk.NewPopover()112 popover = gtk.NewPopover()
114 popover.SetChild(&box.Widget)113 popover.SetChild(&box.Widget)
115 popover.SetParent(anchor)114 popover.SetParent(anchor)
@@ -129,3 +128,12 @@ func showMenu(
129 128
130 popover.Popup()129 popover.Popup()
131 }130 }
131+
132+func hasRenderableItems(items []renderItem) bool {
133+ for _, item := range items {
134+ if !item.separator {
135+ return true
136+ }
137+ }
138+ return false
139+}
M internal/infrastructure/cef/webview.go +3 -1
@@ -473,7 +473,9 @@ func (wv *WebView) Destroy() {
473473 if !wv.destroyed.CompareAndSwap(false, true) {
474474 return
475475 }
476- wv.resizeReconciler.stop()
476+ if wv.resizeReconciler != nil {
477+ wv.resizeReconciler.stop()
478+ }
477479 wv.closeAudioStream()
478480 wv.scheduleStopBeginFrameLoop()
479481 wv.mu.RLock()
@@ -473,7 +473,9 @@ func (wv *WebView) Destroy() {
473 if !wv.destroyed.CompareAndSwap(false, true) {473 if !wv.destroyed.CompareAndSwap(false, true) {
474 return474 return
475 }475 }
476- wv.resizeReconciler.stop()476+ if wv.resizeReconciler != nil {
477+ wv.resizeReconciler.stop()
478+ }
477 wv.closeAudioStream()479 wv.closeAudioStream()
478 wv.scheduleStopBeginFrameLoop()480 wv.scheduleStopBeginFrameLoop()
479 wv.mu.RLock()481 wv.mu.RLock()
M internal/infrastructure/cef/webview_handlers.go +9 -3
@@ -215,7 +215,8 @@ func (h *handlerSet) OnPaint(
215215 }
216216 log.Msg("cef: OnPaint begin")
217217 }
218- if elementType != purecef.PaintElementTypePetPopup && !h.shouldAcceptMainViewPaint(width, height, dirtyRects, paintSeq) {
218+ if elementType != purecef.PaintElementTypePetPopup &&
219+ !h.shouldAcceptMainViewPaint(width, height, dirtyRects, paintSeq, resizeSeq, resizeAgeMs) {
219220 return
220221 }
221222 rects := make([]rect, len(dirtyRects))
@@ -248,8 +249,13 @@ func (h *handlerSet) OnPaint(
248249 }
249250 }
250251
251-func (h *handlerSet) shouldAcceptMainViewPaint(width, height int32, dirtyRects []purecef.Rect, paintSeq uint64) bool {
252- resizeSeq, resizeAgeMs := h.wv.pipeline.latestResizeDiagnostics()
252+func (h *handlerSet) shouldAcceptMainViewPaint(
253+ width, height int32,
254+ dirtyRects []purecef.Rect,
255+ paintSeq uint64,
256+ resizeSeq uint64,
257+ resizeAgeMs int64,
258+) bool {
253259 expectedWidth, expectedHeight, _ := h.wv.pipeline.viewRectSize()
254260 sizeMatchesCurrentView := width == expectedWidth && height == expectedHeight
255261
@@ -215,7 +215,8 @@ func (h *handlerSet) OnPaint(
215 }215 }
216 log.Msg("cef: OnPaint begin")216 log.Msg("cef: OnPaint begin")
217 }217 }
218- if elementType != purecef.PaintElementTypePetPopup && !h.shouldAcceptMainViewPaint(width, height, dirtyRects, paintSeq) {218+ if elementType != purecef.PaintElementTypePetPopup &&
219+ !h.shouldAcceptMainViewPaint(width, height, dirtyRects, paintSeq, resizeSeq, resizeAgeMs) {
219 return220 return
220 }221 }
221 rects := make([]rect, len(dirtyRects))222 rects := make([]rect, len(dirtyRects))
@@ -248,8 +249,13 @@ func (h *handlerSet) OnPaint(
248 }249 }
249 }250 }
250 251
251-func (h *handlerSet) shouldAcceptMainViewPaint(width, height int32, dirtyRects []purecef.Rect, paintSeq uint64) bool {252+func (h *handlerSet) shouldAcceptMainViewPaint(
252- resizeSeq, resizeAgeMs := h.wv.pipeline.latestResizeDiagnostics()253+ width, height int32,
254+ dirtyRects []purecef.Rect,
255+ paintSeq uint64,
256+ resizeSeq uint64,
257+ resizeAgeMs int64,
258+) bool {
253 expectedWidth, expectedHeight, _ := h.wv.pipeline.viewRectSize()259 expectedWidth, expectedHeight, _ := h.wv.pipeline.viewRectSize()
254 sizeMatchesCurrentView := width == expectedWidth && height == expectedHeight260 sizeMatchesCurrentView := width == expectedWidth && height == expectedHeight
255 261
M internal/infrastructure/clipboard/clipboard.go +76 -27
@@ -1,4 +1,4 @@
1-// Package clipboard provides a clipboard adapter with toolkit preference and wl-clipboard/X11 fallback.
1+// Package clipboard provides a clipboard adapter with Wayland/X11 preference and toolkit fallback.
22 package clipboard
33
44 import (
@@ -10,6 +10,7 @@ import (
1010 "strings"
1111
1212 "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/domain/entity"
1314 "github.com/bnema/dumber/internal/logging"
1415 "github.com/bnema/puregotk/v4/gdk"
1516 "github.com/bnema/puregotk/v4/glib"
@@ -18,11 +19,11 @@ import (
1819
1920 type toolkitClipboard interface {
2021 WriteText(ctx context.Context, text string) error
21- WriteImage(ctx context.Context, image port.ImageData) error
22+ WriteImage(ctx context.Context, image entity.ImageData) error
2223 }
2324
24-// Adapter implements port.Clipboard using the toolkit clipboard when available.
25-// It falls back to wl-clipboard for Wayland, then xclip/xsel for X11.
25+// Adapter implements port.Clipboard using wl-clipboard on Wayland, then xclip/xsel on X11.
26+// If no system clipboard tool is available, it falls back to the toolkit clipboard.
2627 type Adapter struct {
2728 toolkit toolkitClipboard
2829 copyCmd string
@@ -61,29 +62,53 @@ type gdkToolkitClipboard struct {
6162 clipboard *gdk.Clipboard
6263 }
6364
64-func (c *gdkToolkitClipboard) WriteText(_ context.Context, text string) error {
65- if c == nil || c.clipboard == nil {
66- return fmt.Errorf("toolkit clipboard not available")
67- }
68- c.clipboard.SetText(text)
69- return nil
65+func (c *gdkToolkitClipboard) WriteText(ctx context.Context, text string) error {
66+ return withToolkitClipboard(ctx, func() error {
67+ if c == nil || c.clipboard == nil {
68+ return fmt.Errorf("toolkit clipboard not available")
69+ }
70+ c.clipboard.SetText(text)
71+ return nil
72+ })
73+}
74+
75+func (c *gdkToolkitClipboard) WriteImage(ctx context.Context, image entity.ImageData) error {
76+ return withToolkitClipboard(ctx, func() error {
77+ if c == nil || c.clipboard == nil {
78+ return fmt.Errorf("toolkit clipboard not available")
79+ }
80+
81+ texture, err := gdk.NewTextureFromBytes(glib.NewBytes(image.Bytes, uint(len(image.Bytes))))
82+ if err != nil {
83+ return err
84+ }
85+ if texture == nil {
86+ return fmt.Errorf("toolkit clipboard returned nil texture")
87+ }
88+
89+ c.clipboard.SetTexture(texture)
90+ return nil
91+ })
7092 }
7193
72-func (c *gdkToolkitClipboard) WriteImage(_ context.Context, image port.ImageData) error {
73- if c == nil || c.clipboard == nil {
74- return fmt.Errorf("toolkit clipboard not available")
94+func withToolkitClipboard(ctx context.Context, fn func() error) error {
95+ mainContext := glib.MainContextDefault()
96+ if mainContext == nil || mainContext.IsOwner() {
97+ return fn()
7598 }
7699
77- texture, err := gdk.NewTextureFromBytes(glib.NewBytes(image.Bytes, uint(len(image.Bytes))))
78- if err != nil {
100+ resultCh := make(chan error, 1)
101+ cb := glib.SourceFunc(func(_ uintptr) bool {
102+ resultCh <- fn()
103+ return false
104+ })
105+ glib.IdleAdd(&cb, 0)
106+ select {
107+ case err := <-resultCh:
79108 return err
109+ case <-ctx.Done():
110+ return ctx.Err()
80111 }
81- if texture == nil {
82- return fmt.Errorf("toolkit clipboard returned nil texture")
83- }
84-
85- c.clipboard.SetTexture(texture)
86- return nil
87112 }
88113
89114 func selectSystemClipboard() (copyCmd, pasteCmd string) {
@@ -114,6 +139,14 @@ func selectSystemClipboard() (copyCmd, pasteCmd string) {
114139 // WriteText copies text to the clipboard.
115140 func (a *Adapter) WriteText(ctx context.Context, text string) error {
116141 log := logging.FromContext(ctx)
142+ var commandErr error
143+ if a.copyCmd != "" {
144+ err := a.writeTextWithCommand(ctx, text, log)
145+ if err == nil {
146+ return nil
147+ }
148+ commandErr = err
149+ }
117150 if a.toolkit != nil {
118151 err := a.toolkit.WriteText(ctx, text)
119152 if err == nil {
@@ -122,21 +155,33 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error {
122155 }
123156 log.Debug().Err(err).Msg("toolkit clipboard write failed; falling back")
124157 }
125-
126- return a.writeTextWithCommand(ctx, text, log)
158+ if commandErr != nil {
159+ return commandErr
160+ }
161+ err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
162+ log.Error().Err(err).Msg("clipboard write failed")
163+ return err
127164 }
128165
129166 // WriteImage copies image bytes to the clipboard.
130167 // xsel is not supported because the adapter has no reliable binary image mode
131168 // for it in this codebase.
132-func (a *Adapter) WriteImage(ctx context.Context, image port.ImageData) error {
169+func (a *Adapter) WriteImage(ctx context.Context, image entity.ImageData) error {
133170 log := logging.FromContext(ctx)
171+ var commandErr error
134172
135173 if len(image.Bytes) == 0 {
136174 err := fmt.Errorf("empty image data")
137175 log.Error().Err(err).Msg("clipboard image write failed")
138176 return err
139177 }
178+ if a.copyCmd != "" {
179+ err := a.writeImageWithCommand(ctx, image, log)
180+ if err == nil {
181+ return nil
182+ }
183+ commandErr = err
184+ }
140185 if a.toolkit != nil {
141186 err := a.toolkit.WriteImage(ctx, image)
142187 if err == nil {
@@ -145,8 +190,12 @@ func (a *Adapter) WriteImage(ctx context.Context, image port.ImageData) error {
145190 }
146191 log.Debug().Err(err).Msg("toolkit clipboard image write failed; falling back")
147192 }
148-
149- return a.writeImageWithCommand(ctx, image, log)
193+ if commandErr != nil {
194+ return commandErr
195+ }
196+ err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
197+ log.Error().Err(err).Msg("clipboard image write failed")
198+ return err
150199 }
151200
152201 func (a *Adapter) writeTextWithCommand(ctx context.Context, text string, log *zerolog.Logger) error {
@@ -180,7 +229,7 @@ func (a *Adapter) writeTextWithCommand(ctx context.Context, text string, log *ze
180229 return nil
181230 }
182231
183-func (a *Adapter) writeImageWithCommand(ctx context.Context, image port.ImageData, log *zerolog.Logger) error {
232+func (a *Adapter) writeImageWithCommand(ctx context.Context, image entity.ImageData, log *zerolog.Logger) error {
184233 if a.copyCmd == "" {
185234 err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
186235 log.Error().Err(err).Msg("clipboard image write failed")
@@ -1,4 +1,4 @@
1-// Package clipboard provides a clipboard adapter with toolkit preference and wl-clipboard/X11 fallback.1+// Package clipboard provides a clipboard adapter with Wayland/X11 preference and toolkit fallback.
2 package clipboard2 package clipboard
3 3
4 import (4 import (
@@ -10,6 +10,7 @@ import (
10 "strings"10 "strings"
11 11
12 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/bnema/dumber/internal/logging"14 "github.com/bnema/dumber/internal/logging"
14 "github.com/bnema/puregotk/v4/gdk"15 "github.com/bnema/puregotk/v4/gdk"
15 "github.com/bnema/puregotk/v4/glib"16 "github.com/bnema/puregotk/v4/glib"
@@ -18,11 +19,11 @@ import (
18 19
19 type toolkitClipboard interface {20 type toolkitClipboard interface {
20 WriteText(ctx context.Context, text string) error21 WriteText(ctx context.Context, text string) error
21- WriteImage(ctx context.Context, image port.ImageData) error22+ WriteImage(ctx context.Context, image entity.ImageData) error
22 }23 }
23 24
24-// Adapter implements port.Clipboard using the toolkit clipboard when available.25+// Adapter implements port.Clipboard using wl-clipboard on Wayland, then xclip/xsel on X11.
25-// It falls back to wl-clipboard for Wayland, then xclip/xsel for X11.26+// If no system clipboard tool is available, it falls back to the toolkit clipboard.
26 type Adapter struct {27 type Adapter struct {
27 toolkit toolkitClipboard28 toolkit toolkitClipboard
28 copyCmd string29 copyCmd string
@@ -61,29 +62,53 @@ type gdkToolkitClipboard struct {
61 clipboard *gdk.Clipboard62 clipboard *gdk.Clipboard
62 }63 }
63 64
64-func (c *gdkToolkitClipboard) WriteText(_ context.Context, text string) error {65+func (c *gdkToolkitClipboard) WriteText(ctx context.Context, text string) error {
65- if c == nil || c.clipboard == nil {66+ return withToolkitClipboard(ctx, func() error {
66- return fmt.Errorf("toolkit clipboard not available")67+ if c == nil || c.clipboard == nil {
67- }68+ return fmt.Errorf("toolkit clipboard not available")
68- c.clipboard.SetText(text)69+ }
69- return nil70+ c.clipboard.SetText(text)
71+ return nil
72+ })
73+}
74+
75+func (c *gdkToolkitClipboard) WriteImage(ctx context.Context, image entity.ImageData) error {
76+ return withToolkitClipboard(ctx, func() error {
77+ if c == nil || c.clipboard == nil {
78+ return fmt.Errorf("toolkit clipboard not available")
79+ }
80+
81+ texture, err := gdk.NewTextureFromBytes(glib.NewBytes(image.Bytes, uint(len(image.Bytes))))
82+ if err != nil {
83+ return err
84+ }
85+ if texture == nil {
86+ return fmt.Errorf("toolkit clipboard returned nil texture")
87+ }
88+
89+ c.clipboard.SetTexture(texture)
90+ return nil
91+ })
70 }92 }
71 93
72-func (c *gdkToolkitClipboard) WriteImage(_ context.Context, image port.ImageData) error {94+func withToolkitClipboard(ctx context.Context, fn func() error) error {
73- if c == nil || c.clipboard == nil {95+ mainContext := glib.MainContextDefault()
74- return fmt.Errorf("toolkit clipboard not available")96+ if mainContext == nil || mainContext.IsOwner() {
97+ return fn()
75 }98 }
76 99
77- texture, err := gdk.NewTextureFromBytes(glib.NewBytes(image.Bytes, uint(len(image.Bytes))))100+ resultCh := make(chan error, 1)
78- if err != nil {101+ cb := glib.SourceFunc(func(_ uintptr) bool {
102+ resultCh <- fn()
103+ return false
104+ })
105+ glib.IdleAdd(&cb, 0)
106+ select {
107+ case err := <-resultCh:
79 return err108 return err
109+ case <-ctx.Done():
110+ return ctx.Err()
80 }111 }
81- if texture == nil {
82- return fmt.Errorf("toolkit clipboard returned nil texture")
83- }
84-
85- c.clipboard.SetTexture(texture)
86- return nil
87 }112 }
88 113
89 func selectSystemClipboard() (copyCmd, pasteCmd string) {114 func selectSystemClipboard() (copyCmd, pasteCmd string) {
@@ -114,6 +139,14 @@ func selectSystemClipboard() (copyCmd, pasteCmd string) {
114 // WriteText copies text to the clipboard.139 // WriteText copies text to the clipboard.
115 func (a *Adapter) WriteText(ctx context.Context, text string) error {140 func (a *Adapter) WriteText(ctx context.Context, text string) error {
116 log := logging.FromContext(ctx)141 log := logging.FromContext(ctx)
142+ var commandErr error
143+ if a.copyCmd != "" {
144+ err := a.writeTextWithCommand(ctx, text, log)
145+ if err == nil {
146+ return nil
147+ }
148+ commandErr = err
149+ }
117 if a.toolkit != nil {150 if a.toolkit != nil {
118 err := a.toolkit.WriteText(ctx, text)151 err := a.toolkit.WriteText(ctx, text)
119 if err == nil {152 if err == nil {
@@ -122,21 +155,33 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error {
122 }155 }
123 log.Debug().Err(err).Msg("toolkit clipboard write failed; falling back")156 log.Debug().Err(err).Msg("toolkit clipboard write failed; falling back")
124 }157 }
125-158+ if commandErr != nil {
126- return a.writeTextWithCommand(ctx, text, log)159+ return commandErr
160+ }
161+ err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
162+ log.Error().Err(err).Msg("clipboard write failed")
163+ return err
127 }164 }
128 165
129 // WriteImage copies image bytes to the clipboard.166 // WriteImage copies image bytes to the clipboard.
130 // xsel is not supported because the adapter has no reliable binary image mode167 // xsel is not supported because the adapter has no reliable binary image mode
131 // for it in this codebase.168 // for it in this codebase.
132-func (a *Adapter) WriteImage(ctx context.Context, image port.ImageData) error {169+func (a *Adapter) WriteImage(ctx context.Context, image entity.ImageData) error {
133 log := logging.FromContext(ctx)170 log := logging.FromContext(ctx)
171+ var commandErr error
134 172
135 if len(image.Bytes) == 0 {173 if len(image.Bytes) == 0 {
136 err := fmt.Errorf("empty image data")174 err := fmt.Errorf("empty image data")
137 log.Error().Err(err).Msg("clipboard image write failed")175 log.Error().Err(err).Msg("clipboard image write failed")
138 return err176 return err
139 }177 }
178+ if a.copyCmd != "" {
179+ err := a.writeImageWithCommand(ctx, image, log)
180+ if err == nil {
181+ return nil
182+ }
183+ commandErr = err
184+ }
140 if a.toolkit != nil {185 if a.toolkit != nil {
141 err := a.toolkit.WriteImage(ctx, image)186 err := a.toolkit.WriteImage(ctx, image)
142 if err == nil {187 if err == nil {
@@ -145,8 +190,12 @@ func (a *Adapter) WriteImage(ctx context.Context, image port.ImageData) error {
145 }190 }
146 log.Debug().Err(err).Msg("toolkit clipboard image write failed; falling back")191 log.Debug().Err(err).Msg("toolkit clipboard image write failed; falling back")
147 }192 }
148-193+ if commandErr != nil {
149- return a.writeImageWithCommand(ctx, image, log)194+ return commandErr
195+ }
196+ err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
197+ log.Error().Err(err).Msg("clipboard image write failed")
198+ return err
150 }199 }
151 200
152 func (a *Adapter) writeTextWithCommand(ctx context.Context, text string, log *zerolog.Logger) error {201 func (a *Adapter) writeTextWithCommand(ctx context.Context, text string, log *zerolog.Logger) error {
@@ -180,7 +229,7 @@ func (a *Adapter) writeTextWithCommand(ctx context.Context, text string, log *ze
180 return nil229 return nil
181 }230 }
182 231
183-func (a *Adapter) writeImageWithCommand(ctx context.Context, image port.ImageData, log *zerolog.Logger) error {232+func (a *Adapter) writeImageWithCommand(ctx context.Context, image entity.ImageData, log *zerolog.Logger) error {
184 if a.copyCmd == "" {233 if a.copyCmd == "" {
185 err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")234 err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
186 log.Error().Err(err).Msg("clipboard image write failed")235 log.Error().Err(err).Msg("clipboard image write failed")
M internal/infrastructure/clipboard/clipboard_test.go +30 -13
@@ -8,7 +8,7 @@ import (
88 "strings"
99 "testing"
1010
11- "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
1212 "github.com/stretchr/testify/require"
1313 )
1414
@@ -16,7 +16,7 @@ type fakeToolkitClipboard struct {
1616 writeTextCalls int
1717 writeImageCalls int
1818 text string
19- image port.ImageData
19+ image entity.ImageData
2020 }
2121
2222 func (f *fakeToolkitClipboard) WriteText(_ context.Context, text string) error {
@@ -25,13 +25,13 @@ func (f *fakeToolkitClipboard) WriteText(_ context.Context, text string) error {
2525 return nil
2626 }
2727
28-func (f *fakeToolkitClipboard) WriteImage(_ context.Context, image port.ImageData) error {
28+func (f *fakeToolkitClipboard) WriteImage(_ context.Context, image entity.ImageData) error {
2929 f.writeImageCalls++
3030 f.image = image
3131 return nil
3232 }
3333
34-func TestNew_PrefersToolkitClipboard(t *testing.T) {
34+func TestNew_FallsBackToToolkitClipboardWhenSystemToolsUnavailable(t *testing.T) {
3535 oldToolkitFactory := newToolkitClipboard
3636 oldLookPath := lookPath
3737 t.Cleanup(func() {
@@ -39,26 +39,43 @@ func TestNew_PrefersToolkitClipboard(t *testing.T) {
3939 lookPath = oldLookPath
4040 })
4141
42- toolkitChecked := false
4342 fake := &fakeToolkitClipboard{}
4443 newToolkitClipboard = func() toolkitClipboard {
45- toolkitChecked = true
4644 return fake
4745 }
4846 lookPath = func(_ string) (string, error) {
49- require.True(t, toolkitChecked, "toolkit clipboard should be checked before system tools")
50- return "", errors.New("unexpected call")
47+ return "", errors.New("missing")
5148 }
5249
5350 t.Setenv("WAYLAND_DISPLAY", "wayland-0")
5451 t.Setenv("DISPLAY", ":0")
5552
5653 adapter := New().(*Adapter)
57- require.NoError(t, adapter.WriteImage(context.Background(), port.ImageData{Bytes: []byte{1, 2, 3}}))
54+ require.NoError(t, adapter.WriteImage(context.Background(), entity.ImageData{Bytes: []byte{1, 2, 3}}))
5855 require.Equal(t, 1, fake.writeImageCalls)
5956 require.Equal(t, []byte{1, 2, 3}, fake.image.Bytes)
6057 }
6158
59+func TestAdapter_WriteImagePrefersSystemClipboardOverToolkit(t *testing.T) {
60+ dir := t.TempDir()
61+ scriptPath := filepath.Join(dir, "wl-copy")
62+ argsPath := filepath.Join(dir, "args.txt")
63+ stdinPath := filepath.Join(dir, "stdin.bin")
64+ script := "#!/bin/sh\nprintf '%s\n' \"$@\" > \"" + argsPath + "\"\ncat > \"" + stdinPath + "\"\n"
65+ require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755))
66+
67+ fake := &fakeToolkitClipboard{}
68+ adapter := &Adapter{toolkit: fake, copyCmd: scriptPath}
69+ image := entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}
70+
71+ require.NoError(t, adapter.WriteImage(context.Background(), image))
72+ require.Zero(t, fake.writeImageCalls)
73+
74+ stdin, err := os.ReadFile(stdinPath)
75+ require.NoError(t, err)
76+ require.Equal(t, image.Bytes, stdin)
77+}
78+
6279 func TestNew_FallsBackWaylandBeforeX11(t *testing.T) {
6380 oldToolkitFactory := newToolkitClipboard
6481 oldLookPath := lookPath
@@ -102,7 +119,7 @@ func TestAdapter_WriteImageRejectsEmptyBytes(t *testing.T) {
102119 newToolkitClipboard = func() toolkitClipboard { return fake }
103120 lookPath = func(_ string) (string, error) { return "/usr/bin/clipboard", nil }
104121
105- err := New().(*Adapter).WriteImage(context.Background(), port.ImageData{})
122+ err := New().(*Adapter).WriteImage(context.Background(), entity.ImageData{})
106123 require.Error(t, err)
107124 require.Contains(t, err.Error(), "empty image data")
108125 require.Zero(t, fake.writeImageCalls)
@@ -111,17 +128,17 @@ func TestAdapter_WriteImageRejectsEmptyBytes(t *testing.T) {
111128 func TestAdapter_WriteImageWithCommandUsesImageMimeType(t *testing.T) {
112129 testCases := []struct {
113130 name string
114- image port.ImageData
131+ image entity.ImageData
115132 expected string
116133 }{
117134 {
118135 name: "uses provided MIME type",
119- image: port.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/jpeg"},
136+ image: entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/jpeg"},
120137 expected: "image/jpeg",
121138 },
122139 {
123140 name: "falls back to PNG",
124- image: port.ImageData{Bytes: []byte{1, 2, 3}},
141+ image: entity.ImageData{Bytes: []byte{1, 2, 3}},
125142 expected: "image/png",
126143 },
127144 }
@@ -8,7 +8,7 @@ import (
8 "strings"8 "strings"
9 "testing"9 "testing"
10 10
11- "github.com/bnema/dumber/internal/application/port"11+ "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 )13 )
14 14
@@ -16,7 +16,7 @@ type fakeToolkitClipboard struct {
16 writeTextCalls int16 writeTextCalls int
17 writeImageCalls int17 writeImageCalls int
18 text string18 text string
19- image port.ImageData19+ image entity.ImageData
20 }20 }
21 21
22 func (f *fakeToolkitClipboard) WriteText(_ context.Context, text string) error {22 func (f *fakeToolkitClipboard) WriteText(_ context.Context, text string) error {
@@ -25,13 +25,13 @@ func (f *fakeToolkitClipboard) WriteText(_ context.Context, text string) error {
25 return nil25 return nil
26 }26 }
27 27
28-func (f *fakeToolkitClipboard) WriteImage(_ context.Context, image port.ImageData) error {28+func (f *fakeToolkitClipboard) WriteImage(_ context.Context, image entity.ImageData) error {
29 f.writeImageCalls++29 f.writeImageCalls++
30 f.image = image30 f.image = image
31 return nil31 return nil
32 }32 }
33 33
34-func TestNew_PrefersToolkitClipboard(t *testing.T) {34+func TestNew_FallsBackToToolkitClipboardWhenSystemToolsUnavailable(t *testing.T) {
35 oldToolkitFactory := newToolkitClipboard35 oldToolkitFactory := newToolkitClipboard
36 oldLookPath := lookPath36 oldLookPath := lookPath
37 t.Cleanup(func() {37 t.Cleanup(func() {
@@ -39,26 +39,43 @@ func TestNew_PrefersToolkitClipboard(t *testing.T) {
39 lookPath = oldLookPath39 lookPath = oldLookPath
40 })40 })
41 41
42- toolkitChecked := false
43 fake := &fakeToolkitClipboard{}42 fake := &fakeToolkitClipboard{}
44 newToolkitClipboard = func() toolkitClipboard {43 newToolkitClipboard = func() toolkitClipboard {
45- toolkitChecked = true
46 return fake44 return fake
47 }45 }
48 lookPath = func(_ string) (string, error) {46 lookPath = func(_ string) (string, error) {
49- require.True(t, toolkitChecked, "toolkit clipboard should be checked before system tools")47+ return "", errors.New("missing")
50- return "", errors.New("unexpected call")
51 }48 }
52 49
53 t.Setenv("WAYLAND_DISPLAY", "wayland-0")50 t.Setenv("WAYLAND_DISPLAY", "wayland-0")
54 t.Setenv("DISPLAY", ":0")51 t.Setenv("DISPLAY", ":0")
55 52
56 adapter := New().(*Adapter)53 adapter := New().(*Adapter)
57- require.NoError(t, adapter.WriteImage(context.Background(), port.ImageData{Bytes: []byte{1, 2, 3}}))54+ require.NoError(t, adapter.WriteImage(context.Background(), entity.ImageData{Bytes: []byte{1, 2, 3}}))
58 require.Equal(t, 1, fake.writeImageCalls)55 require.Equal(t, 1, fake.writeImageCalls)
59 require.Equal(t, []byte{1, 2, 3}, fake.image.Bytes)56 require.Equal(t, []byte{1, 2, 3}, fake.image.Bytes)
60 }57 }
61 58
59+func TestAdapter_WriteImagePrefersSystemClipboardOverToolkit(t *testing.T) {
60+ dir := t.TempDir()
61+ scriptPath := filepath.Join(dir, "wl-copy")
62+ argsPath := filepath.Join(dir, "args.txt")
63+ stdinPath := filepath.Join(dir, "stdin.bin")
64+ script := "#!/bin/sh\nprintf '%s\n' \"$@\" > \"" + argsPath + "\"\ncat > \"" + stdinPath + "\"\n"
65+ require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755))
66+
67+ fake := &fakeToolkitClipboard{}
68+ adapter := &Adapter{toolkit: fake, copyCmd: scriptPath}
69+ image := entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}
70+
71+ require.NoError(t, adapter.WriteImage(context.Background(), image))
72+ require.Zero(t, fake.writeImageCalls)
73+
74+ stdin, err := os.ReadFile(stdinPath)
75+ require.NoError(t, err)
76+ require.Equal(t, image.Bytes, stdin)
77+}
78+
62 func TestNew_FallsBackWaylandBeforeX11(t *testing.T) {79 func TestNew_FallsBackWaylandBeforeX11(t *testing.T) {
63 oldToolkitFactory := newToolkitClipboard80 oldToolkitFactory := newToolkitClipboard
64 oldLookPath := lookPath81 oldLookPath := lookPath
@@ -102,7 +119,7 @@ func TestAdapter_WriteImageRejectsEmptyBytes(t *testing.T) {
102 newToolkitClipboard = func() toolkitClipboard { return fake }119 newToolkitClipboard = func() toolkitClipboard { return fake }
103 lookPath = func(_ string) (string, error) { return "/usr/bin/clipboard", nil }120 lookPath = func(_ string) (string, error) { return "/usr/bin/clipboard", nil }
104 121
105- err := New().(*Adapter).WriteImage(context.Background(), port.ImageData{})122+ err := New().(*Adapter).WriteImage(context.Background(), entity.ImageData{})
106 require.Error(t, err)123 require.Error(t, err)
107 require.Contains(t, err.Error(), "empty image data")124 require.Contains(t, err.Error(), "empty image data")
108 require.Zero(t, fake.writeImageCalls)125 require.Zero(t, fake.writeImageCalls)
@@ -111,17 +128,17 @@ func TestAdapter_WriteImageRejectsEmptyBytes(t *testing.T) {
111 func TestAdapter_WriteImageWithCommandUsesImageMimeType(t *testing.T) {128 func TestAdapter_WriteImageWithCommandUsesImageMimeType(t *testing.T) {
112 testCases := []struct {129 testCases := []struct {
113 name string130 name string
114- image port.ImageData131+ image entity.ImageData
115 expected string132 expected string
116 }{133 }{
117 {134 {
118 name: "uses provided MIME type",135 name: "uses provided MIME type",
119- image: port.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/jpeg"},136+ image: entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/jpeg"},
120 expected: "image/jpeg",137 expected: "image/jpeg",
121 },138 },
122 {139 {
123 name: "falls back to PNG",140 name: "falls back to PNG",
124- image: port.ImageData{Bytes: []byte{1, 2, 3}},141+ image: entity.ImageData{Bytes: []byte{1, 2, 3}},
125 expected: "image/png",142 expected: "image/png",
126 },143 },
127 }144 }
M internal/infrastructure/config/transform_legacy_test.go +45 -0
@@ -188,6 +188,51 @@ func TestTransformLegacyEngineConfig_RemovesDeprecatedContextMenuHandler(t *test
188188 assert.False(t, exists)
189189 }
190190
191+func TestTransformLegacyEngineConfig_MissingCefSection(t *testing.T) {
192+ transformer := NewLegacyConfigTransformer()
193+ rawConfig := map[string]any{
194+ "engine": map[string]any{
195+ "webkit": map[string]any{"enabled": true},
196+ },
197+ }
198+
199+ transformer.TransformLegacyEngineConfig(rawConfig)
200+
201+ engine, ok := rawConfig["engine"].(map[string]any)
202+ require.True(t, ok)
203+ assert.NotNil(t, engine)
204+ assert.Equal(t, true, engine["webkit"].(map[string]any)["enabled"])
205+}
206+
207+func TestTransformLegacyEngineConfig_MissingContextMenuHandlerKey(t *testing.T) {
208+ transformer := NewLegacyConfigTransformer()
209+ rawConfig := map[string]any{
210+ "engine": map[string]any{
211+ "cef": map[string]any{
212+ "other_setting": true,
213+ },
214+ },
215+ }
216+
217+ transformer.TransformLegacyEngineConfig(rawConfig)
218+
219+ cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any)
220+ assert.True(t, cef["other_setting"].(bool))
221+ _, exists := cef["enable_context_menu_handler"]
222+ assert.False(t, exists)
223+}
224+
225+func TestTransformLegacyEngineConfig_NonMapEngineSection(t *testing.T) {
226+ transformer := NewLegacyConfigTransformer()
227+ rawConfig := map[string]any{
228+ "engine": "invalid",
229+ }
230+
231+ transformer.TransformLegacyEngineConfig(rawConfig)
232+
233+ assert.Equal(t, "invalid", rawConfig["engine"])
234+}
235+
191236 func TestTransformLegacyActions_UnknownAction(t *testing.T) {
192237 transformer := NewLegacyConfigTransformer()
193238
@@ -188,6 +188,51 @@ func TestTransformLegacyEngineConfig_RemovesDeprecatedContextMenuHandler(t *test
188 assert.False(t, exists)188 assert.False(t, exists)
189 }189 }
190 190
191+func TestTransformLegacyEngineConfig_MissingCefSection(t *testing.T) {
192+ transformer := NewLegacyConfigTransformer()
193+ rawConfig := map[string]any{
194+ "engine": map[string]any{
195+ "webkit": map[string]any{"enabled": true},
196+ },
197+ }
198+
199+ transformer.TransformLegacyEngineConfig(rawConfig)
200+
201+ engine, ok := rawConfig["engine"].(map[string]any)
202+ require.True(t, ok)
203+ assert.NotNil(t, engine)
204+ assert.Equal(t, true, engine["webkit"].(map[string]any)["enabled"])
205+}
206+
207+func TestTransformLegacyEngineConfig_MissingContextMenuHandlerKey(t *testing.T) {
208+ transformer := NewLegacyConfigTransformer()
209+ rawConfig := map[string]any{
210+ "engine": map[string]any{
211+ "cef": map[string]any{
212+ "other_setting": true,
213+ },
214+ },
215+ }
216+
217+ transformer.TransformLegacyEngineConfig(rawConfig)
218+
219+ cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any)
220+ assert.True(t, cef["other_setting"].(bool))
221+ _, exists := cef["enable_context_menu_handler"]
222+ assert.False(t, exists)
223+}
224+
225+func TestTransformLegacyEngineConfig_NonMapEngineSection(t *testing.T) {
226+ transformer := NewLegacyConfigTransformer()
227+ rawConfig := map[string]any{
228+ "engine": "invalid",
229+ }
230+
231+ transformer.TransformLegacyEngineConfig(rawConfig)
232+
233+ assert.Equal(t, "invalid", rawConfig["engine"])
234+}
235+
191 func TestTransformLegacyActions_UnknownAction(t *testing.T) {236 func TestTransformLegacyActions_UnknownAction(t *testing.T) {
192 transformer := NewLegacyConfigTransformer()237 transformer := NewLegacyConfigTransformer()
193 238
M internal/infrastructure/webkit/context_menu.go +28 -16
@@ -12,6 +12,7 @@ import (
1212 "time"
1313
1414 "github.com/bnema/dumber/internal/application/port"
15+ "github.com/bnema/dumber/internal/domain/entity"
1516 "github.com/bnema/dumber/internal/logging"
1617 "github.com/bnema/puregotk-webkit/webkit"
1718 )
@@ -31,61 +32,72 @@ var defaultImageFetchClient = &http.Client{Timeout: 15 * time.Second}
3132 const maxImageFetchBytes = 50 * 1024 * 1024
3233
3334 // ResolveImageData fetches raw image bytes from the given URI.
34-func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI string) (port.ImageData, error) {
35+func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI string) (entity.ImageData, error) {
3536 if imageURI == "" {
36- return port.ImageData{}, fmt.Errorf("empty image URI")
37+ return entity.ImageData{}, fmt.Errorf("empty image URI")
3738 }
3839
3940 parsed, parseErr := url.Parse(imageURI)
4041 if parseErr != nil {
41- return port.ImageData{}, fmt.Errorf("parse image URI: %w", parseErr)
42+ return entity.ImageData{}, fmt.Errorf("parse image URI: %w", parseErr)
4243 }
4344 switch strings.ToLower(parsed.Scheme) {
4445 case "http", "https":
4546 default:
46- return port.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", parsed.Scheme)
47+ return entity.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", parsed.Scheme)
4748 }
4849 if err := r.validateImageURL(ctx, parsed); err != nil {
49- return port.ImageData{}, err
50+ return entity.ImageData{}, err
5051 }
5152
5253 client := r.imageFetchClient(ctx)
5354
5455 req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), http.NoBody)
5556 if err != nil {
56- return port.ImageData{}, fmt.Errorf("build request: %w", err)
57+ return entity.ImageData{}, fmt.Errorf("build request: %w", err)
5758 }
5859
5960 resp, err := client.Do(req)
6061 if err != nil {
61- return port.ImageData{}, fmt.Errorf("fetch image: %w", err)
62+ return entity.ImageData{}, fmt.Errorf("fetch image: %w", err)
6263 }
6364 defer func() { _ = resp.Body.Close() }()
6465
6566 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
66- return port.ImageData{}, fmt.Errorf("fetch image: HTTP %d", resp.StatusCode)
67+ return entity.ImageData{}, fmt.Errorf("fetch image: HTTP %d", resp.StatusCode)
6768 }
6869
6970 data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageFetchBytes+1))
7071 if err != nil {
71- return port.ImageData{}, fmt.Errorf("read image body: %w", err)
72+ return entity.ImageData{}, fmt.Errorf("read image body: %w", err)
7273 }
7374 if len(data) == 0 {
74- return port.ImageData{}, fmt.Errorf("read image body: empty image data")
75+ return entity.ImageData{}, fmt.Errorf("read image body: empty image data")
7576 }
7677 if len(data) > maxImageFetchBytes {
77- return port.ImageData{}, fmt.Errorf("read image body: image too large (limit %d bytes)", maxImageFetchBytes)
78+ return entity.ImageData{}, fmt.Errorf("read image body: image too large (limit %d bytes)", maxImageFetchBytes)
7879 }
79- detectedType := validatedImageMimeType(http.DetectContentType(data))
80+ rawDetectedType := strings.ToLower(http.DetectContentType(data))
81+ detectedType := validatedImageMimeType(rawDetectedType)
8082 mimeType := detectedType
81- if mimeType == "" {
82- mimeType = validatedImageMimeType(resp.Header.Get("Content-Type"))
83+ headerType := resp.Header.Get("Content-Type")
84+ if mimeType == "" && rawDetectedType == "application/octet-stream" {
85+ mimeType = validatedImageMimeType(headerType)
8386 }
8487 if mimeType == "" {
85- return port.ImageData{}, fmt.Errorf("read image body: content is not an image (%s)", detectedType)
88+ responseURL := parsed.String()
89+ if resp.Request != nil && resp.Request.URL != nil {
90+ responseURL = resp.Request.URL.String()
91+ }
92+ return entity.ImageData{}, fmt.Errorf(
93+ "read image body: content is not an image (detected=%q, header=%q, url=%q)",
94+ rawDetectedType,
95+ headerType,
96+ responseURL,
97+ )
8698 }
8799
88- return port.ImageData{Bytes: data, MimeType: mimeType}, nil
100+ return entity.ImageData{Bytes: data, MimeType: mimeType}, nil
89101 }
90102
91103 func (r *contextMenuResolver) imageFetchClient(_ context.Context) *http.Client {
@@ -12,6 +12,7 @@ import (
12 "time"12 "time"
13 13
14 "github.com/bnema/dumber/internal/application/port"14 "github.com/bnema/dumber/internal/application/port"
15+ "github.com/bnema/dumber/internal/domain/entity"
15 "github.com/bnema/dumber/internal/logging"16 "github.com/bnema/dumber/internal/logging"
16 "github.com/bnema/puregotk-webkit/webkit"17 "github.com/bnema/puregotk-webkit/webkit"
17 )18 )
@@ -31,61 +32,72 @@ var defaultImageFetchClient = &http.Client{Timeout: 15 * time.Second}
31 const maxImageFetchBytes = 50 * 1024 * 102432 const maxImageFetchBytes = 50 * 1024 * 1024
32 33
33 // ResolveImageData fetches raw image bytes from the given URI.34 // ResolveImageData fetches raw image bytes from the given URI.
34-func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI string) (port.ImageData, error) {35+func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI string) (entity.ImageData, error) {
35 if imageURI == "" {36 if imageURI == "" {
36- return port.ImageData{}, fmt.Errorf("empty image URI")37+ return entity.ImageData{}, fmt.Errorf("empty image URI")
37 }38 }
38 39
39 parsed, parseErr := url.Parse(imageURI)40 parsed, parseErr := url.Parse(imageURI)
40 if parseErr != nil {41 if parseErr != nil {
41- return port.ImageData{}, fmt.Errorf("parse image URI: %w", parseErr)42+ return entity.ImageData{}, fmt.Errorf("parse image URI: %w", parseErr)
42 }43 }
43 switch strings.ToLower(parsed.Scheme) {44 switch strings.ToLower(parsed.Scheme) {
44 case "http", "https":45 case "http", "https":
45 default:46 default:
46- return port.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", parsed.Scheme)47+ return entity.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", parsed.Scheme)
47 }48 }
48 if err := r.validateImageURL(ctx, parsed); err != nil {49 if err := r.validateImageURL(ctx, parsed); err != nil {
49- return port.ImageData{}, err50+ return entity.ImageData{}, err
50 }51 }
51 52
52 client := r.imageFetchClient(ctx)53 client := r.imageFetchClient(ctx)
53 54
54 req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), http.NoBody)55 req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), http.NoBody)
55 if err != nil {56 if err != nil {
56- return port.ImageData{}, fmt.Errorf("build request: %w", err)57+ return entity.ImageData{}, fmt.Errorf("build request: %w", err)
57 }58 }
58 59
59 resp, err := client.Do(req)60 resp, err := client.Do(req)
60 if err != nil {61 if err != nil {
61- return port.ImageData{}, fmt.Errorf("fetch image: %w", err)62+ return entity.ImageData{}, fmt.Errorf("fetch image: %w", err)
62 }63 }
63 defer func() { _ = resp.Body.Close() }()64 defer func() { _ = resp.Body.Close() }()
64 65
65 if resp.StatusCode < 200 || resp.StatusCode >= 300 {66 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
66- return port.ImageData{}, fmt.Errorf("fetch image: HTTP %d", resp.StatusCode)67+ return entity.ImageData{}, fmt.Errorf("fetch image: HTTP %d", resp.StatusCode)
67 }68 }
68 69
69 data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageFetchBytes+1))70 data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageFetchBytes+1))
70 if err != nil {71 if err != nil {
71- return port.ImageData{}, fmt.Errorf("read image body: %w", err)72+ return entity.ImageData{}, fmt.Errorf("read image body: %w", err)
72 }73 }
73 if len(data) == 0 {74 if len(data) == 0 {
74- return port.ImageData{}, fmt.Errorf("read image body: empty image data")75+ return entity.ImageData{}, fmt.Errorf("read image body: empty image data")
75 }76 }
76 if len(data) > maxImageFetchBytes {77 if len(data) > maxImageFetchBytes {
77- return port.ImageData{}, fmt.Errorf("read image body: image too large (limit %d bytes)", maxImageFetchBytes)78+ return entity.ImageData{}, fmt.Errorf("read image body: image too large (limit %d bytes)", maxImageFetchBytes)
78 }79 }
79- detectedType := validatedImageMimeType(http.DetectContentType(data))80+ rawDetectedType := strings.ToLower(http.DetectContentType(data))
81+ detectedType := validatedImageMimeType(rawDetectedType)
80 mimeType := detectedType82 mimeType := detectedType
81- if mimeType == "" {83+ headerType := resp.Header.Get("Content-Type")
82- mimeType = validatedImageMimeType(resp.Header.Get("Content-Type"))84+ if mimeType == "" && rawDetectedType == "application/octet-stream" {
85+ mimeType = validatedImageMimeType(headerType)
83 }86 }
84 if mimeType == "" {87 if mimeType == "" {
85- return port.ImageData{}, fmt.Errorf("read image body: content is not an image (%s)", detectedType)88+ responseURL := parsed.String()
89+ if resp.Request != nil && resp.Request.URL != nil {
90+ responseURL = resp.Request.URL.String()
91+ }
92+ return entity.ImageData{}, fmt.Errorf(
93+ "read image body: content is not an image (detected=%q, header=%q, url=%q)",
94+ rawDetectedType,
95+ headerType,
96+ responseURL,
97+ )
86 }98 }
87 99
88- return port.ImageData{Bytes: data, MimeType: mimeType}, nil100+ return entity.ImageData{Bytes: data, MimeType: mimeType}, nil
89 }101 }
90 102
91 func (r *contextMenuResolver) imageFetchClient(_ context.Context) *http.Client {103 func (r *contextMenuResolver) imageFetchClient(_ context.Context) *http.Client {
M internal/infrastructure/webkit/context_menu_test.go +17 -0
@@ -219,6 +219,23 @@ func TestResolveImageData(t *testing.T) {
219219 assert.Contains(t, err.Error(), "not an image")
220220 })
221221
222+ t.Run("rejects spoofed image headers when sniffed bytes are not an image", func(t *testing.T) {
223+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
224+ w.Header().Set("Content-Type", "image/png")
225+ w.Write([]byte("<html><body>not really an image</body></html>"))
226+ _ = r
227+ }))
228+ defer srv.Close()
229+
230+ resolver := &contextMenuResolver{allowPrivateHosts: true}
231+ _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/spoofed.png")
232+ require.Error(t, err)
233+ assert.Contains(t, err.Error(), "not an image")
234+ assert.Contains(t, err.Error(), "text/html")
235+ assert.Contains(t, err.Error(), "image/png")
236+ assert.Contains(t, err.Error(), "/spoofed.png")
237+ })
238+
222239 t.Run("returns error on HTTP failure", func(t *testing.T) {
223240 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
224241 w.WriteHeader(http.StatusNotFound)
@@ -219,6 +219,23 @@ func TestResolveImageData(t *testing.T) {
219 assert.Contains(t, err.Error(), "not an image")219 assert.Contains(t, err.Error(), "not an image")
220 })220 })
221 221
222+ t.Run("rejects spoofed image headers when sniffed bytes are not an image", func(t *testing.T) {
223+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
224+ w.Header().Set("Content-Type", "image/png")
225+ w.Write([]byte("<html><body>not really an image</body></html>"))
226+ _ = r
227+ }))
228+ defer srv.Close()
229+
230+ resolver := &contextMenuResolver{allowPrivateHosts: true}
231+ _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/spoofed.png")
232+ require.Error(t, err)
233+ assert.Contains(t, err.Error(), "not an image")
234+ assert.Contains(t, err.Error(), "text/html")
235+ assert.Contains(t, err.Error(), "image/png")
236+ assert.Contains(t, err.Error(), "/spoofed.png")
237+ })
238+
222 t.Run("returns error on HTTP failure", func(t *testing.T) {239 t.Run("returns error on HTTP failure", func(t *testing.T) {
223 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {240 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
224 w.WriteHeader(http.StatusNotFound)241 w.WriteHeader(http.StatusNotFound)
M internal/infrastructure/webkit/factory.go +0 -2
@@ -131,7 +131,6 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
131131
132132 // Wire context menu if configured
133133 if f.ctxMenu != nil {
134- wv.contextMenu = f.ctxMenu
135134 wv.connectContextMenuSignal(f.ctxMenu)
136135 }
137136
@@ -170,7 +169,6 @@ func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) {
170169
171170 // Wire context menu if configured
172171 if f.ctxMenu != nil {
173- wv.contextMenu = f.ctxMenu
174172 wv.connectContextMenuSignal(f.ctxMenu)
175173 }
176174
@@ -131,7 +131,6 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
131 131
132 // Wire context menu if configured132 // Wire context menu if configured
133 if f.ctxMenu != nil {133 if f.ctxMenu != nil {
134- wv.contextMenu = f.ctxMenu
135 wv.connectContextMenuSignal(f.ctxMenu)134 wv.connectContextMenuSignal(f.ctxMenu)
136 }135 }
137 136
@@ -170,7 +169,6 @@ func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) {
170 169
171 // Wire context menu if configured170 // Wire context menu if configured
172 if f.ctxMenu != nil {171 if f.ctxMenu != nil {
173- wv.contextMenu = f.ctxMenu
174 wv.connectContextMenuSignal(f.ctxMenu)172 wv.connectContextMenuSignal(f.ctxMenu)
175 }173 }
176 174
M internal/infrastructure/webkit/pool.go +14 -5
@@ -65,6 +65,7 @@ type WebViewPool struct {
6565
6666 pool chan *WebView
6767 poolMu sync.Mutex
68+ ctxMenuMu sync.RWMutex
6869 injector *ContentInjector
6970 router *MessageRouter
7071 filterApplier FilterApplier // Optional content filter applier
@@ -148,7 +149,9 @@ func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {
148149
149150 // SetContextMenuPipeline sets the context menu pipeline for newly created WebViews.
150151 func (p *WebViewPool) SetContextMenuPipeline(pipeline *contextMenuPipeline) {
152+ p.ctxMenuMu.Lock()
151153 p.ctxMenu = pipeline
154+ p.ctxMenuMu.Unlock()
152155 }
153156
154157 // Acquire gets a WebView from the pool or creates a new one.
@@ -178,8 +181,11 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
178181 p.filterApplier.ApplyTo(ctx, wv.ucm)
179182 }
180183 // Ensure context menu pipeline is set (may not have been available during prewarm)
181- if p.ctxMenu != nil {
182- wv.contextMenu = p.ctxMenu
184+ p.ctxMenuMu.RLock()
185+ ctxMenu := p.ctxMenu
186+ p.ctxMenuMu.RUnlock()
187+ if ctxMenu != nil {
188+ wv.contextMenu = ctxMenu
183189 }
184190 wv.ReconnectSignals()
185191 p.acquireCount.Add(1)
@@ -248,9 +254,12 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
248254 }
249255
250256 // Wire context menu if configured
251- if p.ctxMenu != nil {
252- wv.contextMenu = p.ctxMenu
253- wv.connectContextMenuSignal(p.ctxMenu)
257+ p.ctxMenuMu.RLock()
258+ ctxMenu := p.ctxMenu
259+ p.ctxMenuMu.RUnlock()
260+ if ctxMenu != nil {
261+ wv.contextMenu = ctxMenu
262+ wv.connectContextMenuSignal(ctxMenu)
254263 }
255264
256265 return wv, nil
@@ -65,6 +65,7 @@ type WebViewPool struct {
65 65
66 pool chan *WebView66 pool chan *WebView
67 poolMu sync.Mutex67 poolMu sync.Mutex
68+ ctxMenuMu sync.RWMutex
68 injector *ContentInjector69 injector *ContentInjector
69 router *MessageRouter70 router *MessageRouter
70 filterApplier FilterApplier // Optional content filter applier71 filterApplier FilterApplier // Optional content filter applier
@@ -148,7 +149,9 @@ func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {
148 149
149 // SetContextMenuPipeline sets the context menu pipeline for newly created WebViews.150 // SetContextMenuPipeline sets the context menu pipeline for newly created WebViews.
150 func (p *WebViewPool) SetContextMenuPipeline(pipeline *contextMenuPipeline) {151 func (p *WebViewPool) SetContextMenuPipeline(pipeline *contextMenuPipeline) {
152+ p.ctxMenuMu.Lock()
151 p.ctxMenu = pipeline153 p.ctxMenu = pipeline
154+ p.ctxMenuMu.Unlock()
152 }155 }
153 156
154 // Acquire gets a WebView from the pool or creates a new one.157 // Acquire gets a WebView from the pool or creates a new one.
@@ -178,8 +181,11 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
178 p.filterApplier.ApplyTo(ctx, wv.ucm)181 p.filterApplier.ApplyTo(ctx, wv.ucm)
179 }182 }
180 // Ensure context menu pipeline is set (may not have been available during prewarm)183 // Ensure context menu pipeline is set (may not have been available during prewarm)
181- if p.ctxMenu != nil {184+ p.ctxMenuMu.RLock()
182- wv.contextMenu = p.ctxMenu185+ ctxMenu := p.ctxMenu
186+ p.ctxMenuMu.RUnlock()
187+ if ctxMenu != nil {
188+ wv.contextMenu = ctxMenu
183 }189 }
184 wv.ReconnectSignals()190 wv.ReconnectSignals()
185 p.acquireCount.Add(1)191 p.acquireCount.Add(1)
@@ -248,9 +254,12 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
248 }254 }
249 255
250 // Wire context menu if configured256 // Wire context menu if configured
251- if p.ctxMenu != nil {257+ p.ctxMenuMu.RLock()
252- wv.contextMenu = p.ctxMenu258+ ctxMenu := p.ctxMenu
253- wv.connectContextMenuSignal(p.ctxMenu)259+ p.ctxMenuMu.RUnlock()
260+ if ctxMenu != nil {
261+ wv.contextMenu = ctxMenu
262+ wv.connectContextMenuSignal(ctxMenu)
254 }263 }
255 264
256 return wv, nil265 return wv, nil
M internal/infrastructure/webkit/renderer.go +15 -7
@@ -71,6 +71,12 @@ func showMenu(
7171 }
7272 return
7373 }
74+ if !hasRenderableItems(items) {
75+ if onClose != nil {
76+ onClose()
77+ }
78+ return
79+ }
7480
7581 box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
7682 box.AddCssClass("context-menu")
@@ -103,13 +109,6 @@ func showMenu(
103109 box.Append(&btn.Widget)
104110 }
105111
106- if len(items) == 0 {
107- if onClose != nil {
108- onClose()
109- }
110- return
111- }
112-
113112 popover = gtk.NewPopover()
114113 popover.SetChild(&box.Widget)
115114 popover.SetParent(anchor)
@@ -129,3 +128,12 @@ func showMenu(
129128
130129 popover.Popup()
131130 }
131+
132+func hasRenderableItems(items []renderItem) bool {
133+ for _, item := range items {
134+ if !item.separator {
135+ return true
136+ }
137+ }
138+ return false
139+}
@@ -71,6 +71,12 @@ func showMenu(
71 }71 }
72 return72 return
73 }73 }
74+ if !hasRenderableItems(items) {
75+ if onClose != nil {
76+ onClose()
77+ }
78+ return
79+ }
74 80
75 box := gtk.NewBox(gtk.OrientationVerticalValue, 0)81 box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
76 box.AddCssClass("context-menu")82 box.AddCssClass("context-menu")
@@ -103,13 +109,6 @@ func showMenu(
103 box.Append(&btn.Widget)109 box.Append(&btn.Widget)
104 }110 }
105 111
106- if len(items) == 0 {
107- if onClose != nil {
108- onClose()
109- }
110- return
111- }
112-
113 popover = gtk.NewPopover()112 popover = gtk.NewPopover()
114 popover.SetChild(&box.Widget)113 popover.SetChild(&box.Widget)
115 popover.SetParent(anchor)114 popover.SetParent(anchor)
@@ -129,3 +128,12 @@ func showMenu(
129 128
130 popover.Popup()129 popover.Popup()
131 }130 }
131+
132+func hasRenderableItems(items []renderItem) bool {
133+ for _, item := range items {
134+ if !item.separator {
135+ return true
136+ }
137+ }
138+ return false
139+}
M internal/infrastructure/webkit/resolved_image_saver.go +5 -4
@@ -7,6 +7,7 @@ import (
77 "path/filepath"
88
99 "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
1011 )
1112
1213 const (
@@ -29,10 +30,13 @@ func NewResolvedImageSaver(preparer port.DownloadPreparer, downloadDir string) *
2930 }
3031
3132 // SaveResolvedImage resolves a destination path and writes the image bytes.
32-func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData, menuContext port.MenuContext) error {
33+func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image entity.ImageData, menuContext port.MenuContext) error {
3334 if s == nil || s.preparer == nil {
3435 return fmt.Errorf("resolved image saver: download preparer not available")
3536 }
37+ if len(image.Bytes) == 0 {
38+ return fmt.Errorf("resolved image saver: empty image data")
39+ }
3640
3741 output := s.preparer.Execute(ctx, port.DownloadPrepareInput{
3842 DownloadDir: s.downloadDir,
@@ -46,9 +50,6 @@ func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.I
4650 if output == nil || output.DestinationPath == "" {
4751 return fmt.Errorf("resolved image saver: destination path not prepared")
4852 }
49- if len(image.Bytes) == 0 {
50- return fmt.Errorf("resolved image saver: empty image data")
51- }
5253
5354 if err := os.MkdirAll(filepath.Dir(output.DestinationPath), resolvedImageDirPerm); err != nil {
5455 return err
@@ -7,6 +7,7 @@ import (
7 "path/filepath"7 "path/filepath"
8 8
9 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
10 )11 )
11 12
12 const (13 const (
@@ -29,10 +30,13 @@ func NewResolvedImageSaver(preparer port.DownloadPreparer, downloadDir string) *
29 }30 }
30 31
31 // SaveResolvedImage resolves a destination path and writes the image bytes.32 // SaveResolvedImage resolves a destination path and writes the image bytes.
32-func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData, menuContext port.MenuContext) error {33+func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image entity.ImageData, menuContext port.MenuContext) error {
33 if s == nil || s.preparer == nil {34 if s == nil || s.preparer == nil {
34 return fmt.Errorf("resolved image saver: download preparer not available")35 return fmt.Errorf("resolved image saver: download preparer not available")
35 }36 }
37+ if len(image.Bytes) == 0 {
38+ return fmt.Errorf("resolved image saver: empty image data")
39+ }
36 40
37 output := s.preparer.Execute(ctx, port.DownloadPrepareInput{41 output := s.preparer.Execute(ctx, port.DownloadPrepareInput{
38 DownloadDir: s.downloadDir,42 DownloadDir: s.downloadDir,
@@ -46,9 +50,6 @@ func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.I
46 if output == nil || output.DestinationPath == "" {50 if output == nil || output.DestinationPath == "" {
47 return fmt.Errorf("resolved image saver: destination path not prepared")51 return fmt.Errorf("resolved image saver: destination path not prepared")
48 }52 }
49- if len(image.Bytes) == 0 {
50- return fmt.Errorf("resolved image saver: empty image data")
51- }
52 53
53 if err := os.MkdirAll(filepath.Dir(output.DestinationPath), resolvedImageDirPerm); err != nil {54 if err := os.MkdirAll(filepath.Dir(output.DestinationPath), resolvedImageDirPerm); err != nil {
54 return err55 return err
M internal/infrastructure/webkit/resolved_image_saver_test.go +7 -3
@@ -7,15 +7,18 @@ import (
77 "testing"
88
99 "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
1011 "github.com/stretchr/testify/require"
1112 )
1213
1314 type fakeDownloadPreparer struct {
15+ calls int
1416 input port.DownloadPrepareInput
1517 output *port.DownloadPrepareOutput
1618 }
1719
1820 func (f *fakeDownloadPreparer) Execute(_ context.Context, input port.DownloadPrepareInput) *port.DownloadPrepareOutput {
21+ f.calls++
1922 f.input = input
2023 return f.output
2124 }
@@ -26,7 +29,7 @@ func TestResolvedImageSaver_SaveResolvedImage(t *testing.T) {
2629 preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: destPath}}
2730 saver := NewResolvedImageSaver(preparer, downloadDir)
2831
29- err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1, 2, 3, 4}, MimeType: "image/jpeg"}, port.MenuContext{ImageURI: "https://example.com/assets/image"})
32+ err := saver.SaveResolvedImage(context.Background(), entity.ImageData{Bytes: []byte{1, 2, 3, 4}, MimeType: "image/jpeg"}, port.MenuContext{ImageURI: "https://example.com/assets/image"})
3033 require.NoError(t, err)
3134 require.Equal(t, downloadDir, preparer.input.DownloadDir)
3235 require.Equal(t, "https://example.com/assets/image", preparer.input.Response.GetUri())
@@ -38,7 +41,7 @@ func TestResolvedImageSaver_SaveResolvedImageRequiresDestination(t *testing.T) {
3841 preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png"}}
3942 saver := NewResolvedImageSaver(preparer, t.TempDir())
4043
41- err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1}}, port.MenuContext{})
44+ err := saver.SaveResolvedImage(context.Background(), entity.ImageData{Bytes: []byte{1}}, port.MenuContext{})
4245 require.Error(t, err)
4346 require.Contains(t, err.Error(), "destination path")
4447 }
@@ -47,9 +50,10 @@ func TestResolvedImageSaver_SaveResolvedImageRejectsEmptyBytes(t *testing.T) {
4750 preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: filepath.Join(t.TempDir(), "image.png")}}
4851 saver := NewResolvedImageSaver(preparer, t.TempDir())
4952
50- err := saver.SaveResolvedImage(context.Background(), port.ImageData{}, port.MenuContext{})
53+ err := saver.SaveResolvedImage(context.Background(), entity.ImageData{}, port.MenuContext{})
5154 require.Error(t, err)
5255 require.Contains(t, err.Error(), "empty image data")
56+ require.Zero(t, preparer.calls)
5357 }
5458
5559 func readFile(t *testing.T, path string) []byte {
@@ -7,15 +7,18 @@ import (
7 "testing"7 "testing"
8 8
9 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
11 )12 )
12 13
13 type fakeDownloadPreparer struct {14 type fakeDownloadPreparer struct {
15+ calls int
14 input port.DownloadPrepareInput16 input port.DownloadPrepareInput
15 output *port.DownloadPrepareOutput17 output *port.DownloadPrepareOutput
16 }18 }
17 19
18 func (f *fakeDownloadPreparer) Execute(_ context.Context, input port.DownloadPrepareInput) *port.DownloadPrepareOutput {20 func (f *fakeDownloadPreparer) Execute(_ context.Context, input port.DownloadPrepareInput) *port.DownloadPrepareOutput {
21+ f.calls++
19 f.input = input22 f.input = input
20 return f.output23 return f.output
21 }24 }
@@ -26,7 +29,7 @@ func TestResolvedImageSaver_SaveResolvedImage(t *testing.T) {
26 preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: destPath}}29 preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: destPath}}
27 saver := NewResolvedImageSaver(preparer, downloadDir)30 saver := NewResolvedImageSaver(preparer, downloadDir)
28 31
29- err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1, 2, 3, 4}, MimeType: "image/jpeg"}, port.MenuContext{ImageURI: "https://example.com/assets/image"})32+ err := saver.SaveResolvedImage(context.Background(), entity.ImageData{Bytes: []byte{1, 2, 3, 4}, MimeType: "image/jpeg"}, port.MenuContext{ImageURI: "https://example.com/assets/image"})
30 require.NoError(t, err)33 require.NoError(t, err)
31 require.Equal(t, downloadDir, preparer.input.DownloadDir)34 require.Equal(t, downloadDir, preparer.input.DownloadDir)
32 require.Equal(t, "https://example.com/assets/image", preparer.input.Response.GetUri())35 require.Equal(t, "https://example.com/assets/image", preparer.input.Response.GetUri())
@@ -38,7 +41,7 @@ func TestResolvedImageSaver_SaveResolvedImageRequiresDestination(t *testing.T) {
38 preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png"}}41 preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png"}}
39 saver := NewResolvedImageSaver(preparer, t.TempDir())42 saver := NewResolvedImageSaver(preparer, t.TempDir())
40 43
41- err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1}}, port.MenuContext{})44+ err := saver.SaveResolvedImage(context.Background(), entity.ImageData{Bytes: []byte{1}}, port.MenuContext{})
42 require.Error(t, err)45 require.Error(t, err)
43 require.Contains(t, err.Error(), "destination path")46 require.Contains(t, err.Error(), "destination path")
44 }47 }
@@ -47,9 +50,10 @@ func TestResolvedImageSaver_SaveResolvedImageRejectsEmptyBytes(t *testing.T) {
47 preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: filepath.Join(t.TempDir(), "image.png")}}50 preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: filepath.Join(t.TempDir(), "image.png")}}
48 saver := NewResolvedImageSaver(preparer, t.TempDir())51 saver := NewResolvedImageSaver(preparer, t.TempDir())
49 52
50- err := saver.SaveResolvedImage(context.Background(), port.ImageData{}, port.MenuContext{})53+ err := saver.SaveResolvedImage(context.Background(), entity.ImageData{}, port.MenuContext{})
51 require.Error(t, err)54 require.Error(t, err)
52 require.Contains(t, err.Error(), "empty image data")55 require.Contains(t, err.Error(), "empty image data")
56+ require.Zero(t, preparer.calls)
53 }57 }
54 58
55 func readFile(t *testing.T, path string) []byte {59 func readFile(t *testing.T, path string) []byte {
M internal/ui/coordinator/workspace.go +9 -12
@@ -682,12 +682,7 @@ func (c *WorkspaceCoordinator) replaceRootSplit(
682682 Str("old_active_pane_id", string(oldActivePaneID)).
683683 Int("new_split_widget_alloc_width", newSplitWidth).
684684 Int("new_split_widget_alloc_height", newSplitHeight).
685- Str("orientation", func() string {
686- if orientation == layout.OrientationHorizontal {
687- return "horizontal"
688- }
689- return "vertical"
690- }()).
685+ Str("orientation", orientationString(orientation)).
691686 Msg("incremental split: replaced root with new split view")
692687
693688 return nil
@@ -760,17 +755,19 @@ func (c *WorkspaceCoordinator) replaceNonRootSplit(
760755 Bool("was_start_child", isStartChild).
761756 Int("new_split_widget_alloc_width", newSplitWidth).
762757 Int("new_split_widget_alloc_height", newSplitHeight).
763- Str("orientation", func() string {
764- if orientation == layout.OrientationHorizontal {
765- return "horizontal"
766- }
767- return "vertical"
768- }()).
758+ Str("orientation", orientationString(orientation)).
769759 Msg("incremental split: replaced pane in grandparent with new split view")
770760
771761 return nil
772762 }
773763
764+func orientationString(orientation layout.Orientation) string {
765+ if orientation == layout.OrientationHorizontal {
766+ return "horizontal"
767+ }
768+ return "vertical"
769+}
770+
774771 // ClosePane closes the active pane.
775772 func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {
776773 log := logging.FromContext(ctx)
@@ -682,12 +682,7 @@ func (c *WorkspaceCoordinator) replaceRootSplit(
682 Str("old_active_pane_id", string(oldActivePaneID)).682 Str("old_active_pane_id", string(oldActivePaneID)).
683 Int("new_split_widget_alloc_width", newSplitWidth).683 Int("new_split_widget_alloc_width", newSplitWidth).
684 Int("new_split_widget_alloc_height", newSplitHeight).684 Int("new_split_widget_alloc_height", newSplitHeight).
685- Str("orientation", func() string {685+ Str("orientation", orientationString(orientation)).
686- if orientation == layout.OrientationHorizontal {
687- return "horizontal"
688- }
689- return "vertical"
690- }()).
691 Msg("incremental split: replaced root with new split view")686 Msg("incremental split: replaced root with new split view")
692 687
693 return nil688 return nil
@@ -760,17 +755,19 @@ func (c *WorkspaceCoordinator) replaceNonRootSplit(
760 Bool("was_start_child", isStartChild).755 Bool("was_start_child", isStartChild).
761 Int("new_split_widget_alloc_width", newSplitWidth).756 Int("new_split_widget_alloc_width", newSplitWidth).
762 Int("new_split_widget_alloc_height", newSplitHeight).757 Int("new_split_widget_alloc_height", newSplitHeight).
763- Str("orientation", func() string {758+ Str("orientation", orientationString(orientation)).
764- if orientation == layout.OrientationHorizontal {
765- return "horizontal"
766- }
767- return "vertical"
768- }()).
769 Msg("incremental split: replaced pane in grandparent with new split view")759 Msg("incremental split: replaced pane in grandparent with new split view")
770 760
771 return nil761 return nil
772 }762 }
773 763
764+func orientationString(orientation layout.Orientation) string {
765+ if orientation == layout.OrientationHorizontal {
766+ return "horizontal"
767+ }
768+ return "vertical"
769+}
770+
774 // ClosePane closes the active pane.771 // ClosePane closes the active pane.
775 func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {772 func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {
776 log := logging.FromContext(ctx)773 log := logging.FromContext(ctx)

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