detached b944d63

fix(review): address PR feedback

commit
b944d63bcd29af743977cc491d901e701fc8deba
author
brice · · 2026-04-18 11:47 UTC
parent
3192764
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
29 files · +443 -243
M cmd/cef-helper/main.go +3 -2
@@ -23,8 +23,9 @@ func main() {
2323
2424 // cef_execute_process will handle the subprocess lifecycle and call
2525 // os.Exit internally if this is a valid CEF subprocess invocation.
26- // We pass the lightweight subprocess app so renderer-process handlers
27- // (native JS bridge, custom schemes, etc.) are available in helpers.
26+ // We pass the lightweight subprocess app, which intentionally returns nil
27+ // from GetRenderProcessHandler(), so renderer-side bridges remain disabled
28+ // in helper subprocesses while the OSR startup regression is under repair.
2829 cef.MaybeExitSubprocessWithApp(infracef.NewSubprocessApp())
2930
3031 // If we get here, something went wrong.
@@ -23,8 +23,9 @@ func main() {
23 23
24 // cef_execute_process will handle the subprocess lifecycle and call24 // cef_execute_process will handle the subprocess lifecycle and call
25 // os.Exit internally if this is a valid CEF subprocess invocation.25 // os.Exit internally if this is a valid CEF subprocess invocation.
26- // We pass the lightweight subprocess app so renderer-process handlers26+ // We pass the lightweight subprocess app, which intentionally returns nil
27- // (native JS bridge, custom schemes, etc.) are available in helpers.27+ // from GetRenderProcessHandler(), so renderer-side bridges remain disabled
28+ // in helper subprocesses while the OSR startup regression is under repair.
28 cef.MaybeExitSubprocessWithApp(infracef.NewSubprocessApp())29 cef.MaybeExitSubprocessWithApp(infracef.NewSubprocessApp())
29 30
30 // If we get here, something went wrong.31 // If we get here, something went wrong.
M cmd/dumber/main.go +1 -1
@@ -774,7 +774,7 @@ func buildUIDependencies(
774774 Theme: themeManager,
775775 ColorResolver: colorResolver,
776776 AdwaitaDetector: adwaitaDetector,
777- XDG: xdg.New(),
777+ XDG: xdg.New(runtimeProfile),
778778 Engine: engine,
779779 FilterManager: filterManager,
780780 HistoryRepo: repos.history,
@@ -774,7 +774,7 @@ func buildUIDependencies(
774 Theme: themeManager,774 Theme: themeManager,
775 ColorResolver: colorResolver,775 ColorResolver: colorResolver,
776 AdwaitaDetector: adwaitaDetector,776 AdwaitaDetector: adwaitaDetector,
777- XDG: xdg.New(),777+ XDG: xdg.New(runtimeProfile),
778 Engine: engine,778 Engine: engine,
779 FilterManager: filterManager,779 FilterManager: filterManager,
780 HistoryRepo: repos.history,780 HistoryRepo: repos.history,
A internal/application/dto/clipboard.go +27 -0
@@ -0,0 +1,27 @@
1+package dto
2+
3+// ClipboardSource identifies the originating engine for clipboard events.
4+type ClipboardSource string
5+
6+const (
7+ // ClipboardSourceCEF marks events from the CEF engine.
8+ ClipboardSourceCEF ClipboardSource = "cef"
9+ // ClipboardSourceWebKit marks events from the WebKit engine.
10+ ClipboardSourceWebKit ClipboardSource = "webkit"
11+)
12+
13+// SelectionClipboardInput carries auto-copy selection updates.
14+type SelectionClipboardInput struct {
15+ Text string
16+ SourceEngine ClipboardSource
17+ ViewID uint64
18+}
19+
20+// ExplicitClipboardInput carries explicit copy requests.
21+type ExplicitClipboardInput struct {
22+ Text string
23+ SourceEngine ClipboardSource
24+ ViewID uint64
25+ Action string
26+ NativeHandled bool
27+}
@@ -0,0 +1,27 @@
1+package dto
2+
3+// ClipboardSource identifies the originating engine for clipboard events.
4+type ClipboardSource string
5+
6+const (
7+ // ClipboardSourceCEF marks events from the CEF engine.
8+ ClipboardSourceCEF ClipboardSource = "cef"
9+ // ClipboardSourceWebKit marks events from the WebKit engine.
10+ ClipboardSourceWebKit ClipboardSource = "webkit"
11+)
12+
13+// SelectionClipboardInput carries auto-copy selection updates.
14+type SelectionClipboardInput struct {
15+ Text string
16+ SourceEngine ClipboardSource
17+ ViewID uint64
18+}
19+
20+// ExplicitClipboardInput carries explicit copy requests.
21+type ExplicitClipboardInput struct {
22+ Text string
23+ SourceEngine ClipboardSource
24+ ViewID uint64
25+ Action string
26+ NativeHandled bool
27+}
M internal/application/port/clipboard_events.go +5 -27
@@ -1,35 +1,13 @@
11 package port
22
3-import "context"
3+import (
4+ "context"
45
5-// ClipboardSource identifies the originating engine for clipboard events.
6-type ClipboardSource string
7-
8-const (
9- // ClipboardSourceCEF marks events from the CEF engine.
10- ClipboardSourceCEF ClipboardSource = "cef"
11- // ClipboardSourceWebKit marks events from the WebKit engine.
12- ClipboardSourceWebKit ClipboardSource = "webkit"
6+ "github.com/bnema/dumber/internal/application/dto"
137 )
148
15-// SelectionClipboardInput carries auto-copy selection updates.
16-type SelectionClipboardInput struct {
17- Text string
18- SourceEngine ClipboardSource
19- ViewID WebViewID
20-}
21-
22-// ExplicitClipboardInput carries explicit copy requests.
23-type ExplicitClipboardInput struct {
24- Text string
25- SourceEngine ClipboardSource
26- ViewID WebViewID
27- Action string
28- NativeHandled bool
29-}
30-
319 // ClipboardTextOrchestrator coordinates shared clipboard behavior.
3210 type ClipboardTextOrchestrator interface {
33- HandleSelectionUpdate(ctx context.Context, input SelectionClipboardInput) error
34- HandleExplicitCopy(ctx context.Context, input ExplicitClipboardInput) error
11+ HandleSelectionUpdate(ctx context.Context, input dto.SelectionClipboardInput) error
12+ HandleExplicitCopy(ctx context.Context, input dto.ExplicitClipboardInput) error
3513 }
@@ -1,35 +1,13 @@
1 package port1 package port
2 2
3-import "context"3+import (
4+ "context"
4 5
5-// ClipboardSource identifies the originating engine for clipboard events.6+ "github.com/bnema/dumber/internal/application/dto"
6-type ClipboardSource string
7-
8-const (
9- // ClipboardSourceCEF marks events from the CEF engine.
10- ClipboardSourceCEF ClipboardSource = "cef"
11- // ClipboardSourceWebKit marks events from the WebKit engine.
12- ClipboardSourceWebKit ClipboardSource = "webkit"
13 )7 )
14 8
15-// SelectionClipboardInput carries auto-copy selection updates.
16-type SelectionClipboardInput struct {
17- Text string
18- SourceEngine ClipboardSource
19- ViewID WebViewID
20-}
21-
22-// ExplicitClipboardInput carries explicit copy requests.
23-type ExplicitClipboardInput struct {
24- Text string
25- SourceEngine ClipboardSource
26- ViewID WebViewID
27- Action string
28- NativeHandled bool
29-}
30-
31 // ClipboardTextOrchestrator coordinates shared clipboard behavior.9 // ClipboardTextOrchestrator coordinates shared clipboard behavior.
32 type ClipboardTextOrchestrator interface {10 type ClipboardTextOrchestrator interface {
33- HandleSelectionUpdate(ctx context.Context, input SelectionClipboardInput) error11+ HandleSelectionUpdate(ctx context.Context, input dto.SelectionClipboardInput) error
34- HandleExplicitCopy(ctx context.Context, input ExplicitClipboardInput) error12+ HandleExplicitCopy(ctx context.Context, input dto.ExplicitClipboardInput) error
35 }13 }
M internal/application/port/mocks/mocks.go +15 -14
@@ -11,6 +11,7 @@ import (
1111 "io"
1212 "time"
1313
14+ "github.com/bnema/dumber/internal/application/dto"
1415 "github.com/bnema/dumber/internal/application/port"
1516 "github.com/bnema/dumber/internal/domain/entity"
1617 "github.com/rs/zerolog"
@@ -758,7 +759,7 @@ func (_m *MockClipboardTextOrchestrator) EXPECT() *MockClipboardTextOrchestrator
758759 }
759760
760761 // HandleExplicitCopy provides a mock function for the type MockClipboardTextOrchestrator
761-func (_mock *MockClipboardTextOrchestrator) HandleExplicitCopy(ctx context.Context, input port.ExplicitClipboardInput) error {
762+func (_mock *MockClipboardTextOrchestrator) HandleExplicitCopy(ctx context.Context, input dto.ExplicitClipboardInput) error {
762763 ret := _mock.Called(ctx, input)
763764
764765 if len(ret) == 0 {
@@ -766,7 +767,7 @@ func (_mock *MockClipboardTextOrchestrator) HandleExplicitCopy(ctx context.Conte
766767 }
767768
768769 var r0 error
769- if returnFunc, ok := ret.Get(0).(func(context.Context, port.ExplicitClipboardInput) error); ok {
770+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.ExplicitClipboardInput) error); ok {
770771 r0 = returnFunc(ctx, input)
771772 } else {
772773 r0 = ret.Error(0)
@@ -781,20 +782,20 @@ type MockClipboardTextOrchestrator_HandleExplicitCopy_Call struct {
781782
782783 // HandleExplicitCopy is a helper method to define mock.On call
783784 // - ctx context.Context
784-// - input port.ExplicitClipboardInput
785+// - input dto.ExplicitClipboardInput
785786 func (_e *MockClipboardTextOrchestrator_Expecter) HandleExplicitCopy(ctx interface{}, input interface{}) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {
786787 return &MockClipboardTextOrchestrator_HandleExplicitCopy_Call{Call: _e.mock.On("HandleExplicitCopy", ctx, input)}
787788 }
788789
789-func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) Run(run func(ctx context.Context, input port.ExplicitClipboardInput)) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {
790+func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) Run(run func(ctx context.Context, input dto.ExplicitClipboardInput)) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {
790791 _c.Call.Run(func(args mock.Arguments) {
791792 var arg0 context.Context
792793 if args[0] != nil {
793794 arg0 = args[0].(context.Context)
794795 }
795- var arg1 port.ExplicitClipboardInput
796+ var arg1 dto.ExplicitClipboardInput
796797 if args[1] != nil {
797- arg1 = args[1].(port.ExplicitClipboardInput)
798+ arg1 = args[1].(dto.ExplicitClipboardInput)
798799 }
799800 run(
800801 arg0,
@@ -809,13 +810,13 @@ func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) Return(err erro
809810 return _c
810811 }
811812
812-func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) RunAndReturn(run func(ctx context.Context, input port.ExplicitClipboardInput) error) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {
813+func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) RunAndReturn(run func(ctx context.Context, input dto.ExplicitClipboardInput) error) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {
813814 _c.Call.Return(run)
814815 return _c
815816 }
816817
817818 // HandleSelectionUpdate provides a mock function for the type MockClipboardTextOrchestrator
818-func (_mock *MockClipboardTextOrchestrator) HandleSelectionUpdate(ctx context.Context, input port.SelectionClipboardInput) error {
819+func (_mock *MockClipboardTextOrchestrator) HandleSelectionUpdate(ctx context.Context, input dto.SelectionClipboardInput) error {
819820 ret := _mock.Called(ctx, input)
820821
821822 if len(ret) == 0 {
@@ -823,7 +824,7 @@ func (_mock *MockClipboardTextOrchestrator) HandleSelectionUpdate(ctx context.Co
823824 }
824825
825826 var r0 error
826- if returnFunc, ok := ret.Get(0).(func(context.Context, port.SelectionClipboardInput) error); ok {
827+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.SelectionClipboardInput) error); ok {
827828 r0 = returnFunc(ctx, input)
828829 } else {
829830 r0 = ret.Error(0)
@@ -838,20 +839,20 @@ type MockClipboardTextOrchestrator_HandleSelectionUpdate_Call struct {
838839
839840 // HandleSelectionUpdate is a helper method to define mock.On call
840841 // - ctx context.Context
841-// - input port.SelectionClipboardInput
842+// - input dto.SelectionClipboardInput
842843 func (_e *MockClipboardTextOrchestrator_Expecter) HandleSelectionUpdate(ctx interface{}, input interface{}) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {
843844 return &MockClipboardTextOrchestrator_HandleSelectionUpdate_Call{Call: _e.mock.On("HandleSelectionUpdate", ctx, input)}
844845 }
845846
846-func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) Run(run func(ctx context.Context, input port.SelectionClipboardInput)) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {
847+func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) Run(run func(ctx context.Context, input dto.SelectionClipboardInput)) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {
847848 _c.Call.Run(func(args mock.Arguments) {
848849 var arg0 context.Context
849850 if args[0] != nil {
850851 arg0 = args[0].(context.Context)
851852 }
852- var arg1 port.SelectionClipboardInput
853+ var arg1 dto.SelectionClipboardInput
853854 if args[1] != nil {
854- arg1 = args[1].(port.SelectionClipboardInput)
855+ arg1 = args[1].(dto.SelectionClipboardInput)
855856 }
856857 run(
857858 arg0,
@@ -866,7 +867,7 @@ func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) Return(err e
866867 return _c
867868 }
868869
869-func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) RunAndReturn(run func(ctx context.Context, input port.SelectionClipboardInput) error) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {
870+func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) RunAndReturn(run func(ctx context.Context, input dto.SelectionClipboardInput) error) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {
870871 _c.Call.Return(run)
871872 return _c
872873 }
@@ -11,6 +11,7 @@ import (
11 "io"11 "io"
12 "time"12 "time"
13 13
14+ "github.com/bnema/dumber/internal/application/dto"
14 "github.com/bnema/dumber/internal/application/port"15 "github.com/bnema/dumber/internal/application/port"
15 "github.com/bnema/dumber/internal/domain/entity"16 "github.com/bnema/dumber/internal/domain/entity"
16 "github.com/rs/zerolog"17 "github.com/rs/zerolog"
@@ -758,7 +759,7 @@ func (_m *MockClipboardTextOrchestrator) EXPECT() *MockClipboardTextOrchestrator
758 }759 }
759 760
760 // HandleExplicitCopy provides a mock function for the type MockClipboardTextOrchestrator761 // HandleExplicitCopy provides a mock function for the type MockClipboardTextOrchestrator
761-func (_mock *MockClipboardTextOrchestrator) HandleExplicitCopy(ctx context.Context, input port.ExplicitClipboardInput) error {762+func (_mock *MockClipboardTextOrchestrator) HandleExplicitCopy(ctx context.Context, input dto.ExplicitClipboardInput) error {
762 ret := _mock.Called(ctx, input)763 ret := _mock.Called(ctx, input)
763 764
764 if len(ret) == 0 {765 if len(ret) == 0 {
@@ -766,7 +767,7 @@ func (_mock *MockClipboardTextOrchestrator) HandleExplicitCopy(ctx context.Conte
766 }767 }
767 768
768 var r0 error769 var r0 error
769- if returnFunc, ok := ret.Get(0).(func(context.Context, port.ExplicitClipboardInput) error); ok {770+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.ExplicitClipboardInput) error); ok {
770 r0 = returnFunc(ctx, input)771 r0 = returnFunc(ctx, input)
771 } else {772 } else {
772 r0 = ret.Error(0)773 r0 = ret.Error(0)
@@ -781,20 +782,20 @@ type MockClipboardTextOrchestrator_HandleExplicitCopy_Call struct {
781 782
782 // HandleExplicitCopy is a helper method to define mock.On call783 // HandleExplicitCopy is a helper method to define mock.On call
783 // - ctx context.Context784 // - ctx context.Context
784-// - input port.ExplicitClipboardInput785+// - input dto.ExplicitClipboardInput
785 func (_e *MockClipboardTextOrchestrator_Expecter) HandleExplicitCopy(ctx interface{}, input interface{}) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {786 func (_e *MockClipboardTextOrchestrator_Expecter) HandleExplicitCopy(ctx interface{}, input interface{}) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {
786 return &MockClipboardTextOrchestrator_HandleExplicitCopy_Call{Call: _e.mock.On("HandleExplicitCopy", ctx, input)}787 return &MockClipboardTextOrchestrator_HandleExplicitCopy_Call{Call: _e.mock.On("HandleExplicitCopy", ctx, input)}
787 }788 }
788 789
789-func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) Run(run func(ctx context.Context, input port.ExplicitClipboardInput)) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {790+func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) Run(run func(ctx context.Context, input dto.ExplicitClipboardInput)) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {
790 _c.Call.Run(func(args mock.Arguments) {791 _c.Call.Run(func(args mock.Arguments) {
791 var arg0 context.Context792 var arg0 context.Context
792 if args[0] != nil {793 if args[0] != nil {
793 arg0 = args[0].(context.Context)794 arg0 = args[0].(context.Context)
794 }795 }
795- var arg1 port.ExplicitClipboardInput796+ var arg1 dto.ExplicitClipboardInput
796 if args[1] != nil {797 if args[1] != nil {
797- arg1 = args[1].(port.ExplicitClipboardInput)798+ arg1 = args[1].(dto.ExplicitClipboardInput)
798 }799 }
799 run(800 run(
800 arg0,801 arg0,
@@ -809,13 +810,13 @@ func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) Return(err erro
809 return _c810 return _c
810 }811 }
811 812
812-func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) RunAndReturn(run func(ctx context.Context, input port.ExplicitClipboardInput) error) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {813+func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) RunAndReturn(run func(ctx context.Context, input dto.ExplicitClipboardInput) error) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {
813 _c.Call.Return(run)814 _c.Call.Return(run)
814 return _c815 return _c
815 }816 }
816 817
817 // HandleSelectionUpdate provides a mock function for the type MockClipboardTextOrchestrator818 // HandleSelectionUpdate provides a mock function for the type MockClipboardTextOrchestrator
818-func (_mock *MockClipboardTextOrchestrator) HandleSelectionUpdate(ctx context.Context, input port.SelectionClipboardInput) error {819+func (_mock *MockClipboardTextOrchestrator) HandleSelectionUpdate(ctx context.Context, input dto.SelectionClipboardInput) error {
819 ret := _mock.Called(ctx, input)820 ret := _mock.Called(ctx, input)
820 821
821 if len(ret) == 0 {822 if len(ret) == 0 {
@@ -823,7 +824,7 @@ func (_mock *MockClipboardTextOrchestrator) HandleSelectionUpdate(ctx context.Co
823 }824 }
824 825
825 var r0 error826 var r0 error
826- if returnFunc, ok := ret.Get(0).(func(context.Context, port.SelectionClipboardInput) error); ok {827+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.SelectionClipboardInput) error); ok {
827 r0 = returnFunc(ctx, input)828 r0 = returnFunc(ctx, input)
828 } else {829 } else {
829 r0 = ret.Error(0)830 r0 = ret.Error(0)
@@ -838,20 +839,20 @@ type MockClipboardTextOrchestrator_HandleSelectionUpdate_Call struct {
838 839
839 // HandleSelectionUpdate is a helper method to define mock.On call840 // HandleSelectionUpdate is a helper method to define mock.On call
840 // - ctx context.Context841 // - ctx context.Context
841-// - input port.SelectionClipboardInput842+// - input dto.SelectionClipboardInput
842 func (_e *MockClipboardTextOrchestrator_Expecter) HandleSelectionUpdate(ctx interface{}, input interface{}) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {843 func (_e *MockClipboardTextOrchestrator_Expecter) HandleSelectionUpdate(ctx interface{}, input interface{}) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {
843 return &MockClipboardTextOrchestrator_HandleSelectionUpdate_Call{Call: _e.mock.On("HandleSelectionUpdate", ctx, input)}844 return &MockClipboardTextOrchestrator_HandleSelectionUpdate_Call{Call: _e.mock.On("HandleSelectionUpdate", ctx, input)}
844 }845 }
845 846
846-func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) Run(run func(ctx context.Context, input port.SelectionClipboardInput)) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {847+func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) Run(run func(ctx context.Context, input dto.SelectionClipboardInput)) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {
847 _c.Call.Run(func(args mock.Arguments) {848 _c.Call.Run(func(args mock.Arguments) {
848 var arg0 context.Context849 var arg0 context.Context
849 if args[0] != nil {850 if args[0] != nil {
850 arg0 = args[0].(context.Context)851 arg0 = args[0].(context.Context)
851 }852 }
852- var arg1 port.SelectionClipboardInput853+ var arg1 dto.SelectionClipboardInput
853 if args[1] != nil {854 if args[1] != nil {
854- arg1 = args[1].(port.SelectionClipboardInput)855+ arg1 = args[1].(dto.SelectionClipboardInput)
855 }856 }
856 run(857 run(
857 arg0,858 arg0,
@@ -866,7 +867,7 @@ func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) Return(err e
866 return _c867 return _c
867 }868 }
868 869
869-func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) RunAndReturn(run func(ctx context.Context, input port.SelectionClipboardInput) error) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {870+func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) RunAndReturn(run func(ctx context.Context, input dto.SelectionClipboardInput) error) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {
870 _c.Call.Return(run)871 _c.Call.Return(run)
871 return _c872 return _c
872 }873 }
M internal/application/usecase/clipboard_text_orchestrator.go +37 -22
@@ -8,6 +8,7 @@ import (
88 "time"
99 "unicode/utf8"
1010
11+ "github.com/bnema/dumber/internal/application/dto"
1112 "github.com/bnema/dumber/internal/application/port"
1213 "github.com/bnema/dumber/internal/logging"
1314 )
@@ -26,13 +27,14 @@ type ClipboardTextOrchestratorUseCase struct {
2627 }
2728
2829 type clipboardScope struct {
29- source port.ClipboardSource
30- viewID port.WebViewID
30+ source dto.ClipboardSource
31+ viewID uint64
3132 }
3233
3334 type explicitCopyState struct {
34- text string
35- at time.Time
35+ text string
36+ action string
37+ at time.Time
3638 }
3739
3840 var _ port.ClipboardTextOrchestrator = (*ClipboardTextOrchestratorUseCase)(nil)
@@ -56,7 +58,7 @@ func NewClipboardTextOrchestrator(
5658 // HandleSelectionUpdate applies auto-copy selection rules.
5759 func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(
5860 ctx context.Context,
59- input port.SelectionClipboardInput,
61+ input dto.SelectionClipboardInput,
6062 ) error {
6163 if uc == nil || uc.autoCopyConfig == nil || !uc.autoCopyConfig.IsAutoCopyEnabled() {
6264 if uc != nil {
@@ -94,19 +96,25 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(
9496 uc.mu.Unlock()
9597 return fmt.Errorf("clipboard not available")
9698 }
99+ selectionState := input.Text
100+ explicitInput := dto.ExplicitClipboardInput{SourceEngine: input.SourceEngine, ViewID: input.ViewID, Action: "copy"}
101+ explicitState := explicitCopyState{text: input.Text, action: explicitInput.Action, at: currentTime}
102+ uc.lastSelection[scope] = selectionState
103+ uc.lastExplicit[explicitScope(explicitInput)] = explicitState
104+ uc.mu.Unlock()
105+
97106 if err := uc.clipboard.WriteText(ctx, input.Text); err != nil {
107+ uc.mu.Lock()
108+ if uc.lastSelection[scope] == selectionState {
109+ delete(uc.lastSelection, scope)
110+ }
111+ if state, ok := uc.lastExplicit[explicitScope(explicitInput)]; ok && state == explicitState {
112+ delete(uc.lastExplicit, explicitScope(explicitInput))
113+ }
98114 uc.mu.Unlock()
99115 return fmt.Errorf("clipboard write failed: %w", err)
100116 }
101117
102- uc.lastSelection[scope] = input.Text
103- explicitInput := port.ExplicitClipboardInput{SourceEngine: input.SourceEngine, ViewID: input.ViewID}
104- uc.lastExplicit[explicitScope(explicitInput)] = explicitCopyState{
105- text: input.Text,
106- at: currentTime,
107- }
108- uc.mu.Unlock()
109-
110118 if uc.toast != nil {
111119 uc.toast(textLen)
112120 }
@@ -120,7 +128,7 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(
120128 // HandleExplicitCopy applies explicit copy rules.
121129 func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
122130 ctx context.Context,
123- input port.ExplicitClipboardInput,
131+ input dto.ExplicitClipboardInput,
124132 ) error {
125133 if uc == nil || input.Text == "" {
126134 return nil
@@ -134,10 +142,13 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
134142 textLen := utf8.RuneCountInString(input.Text)
135143 scope := explicitScope(input)
136144
145+ state := explicitCopyState{text: input.Text, action: input.Action, at: currentTime}
146+
137147 uc.mu.Lock()
138- if state, ok := uc.lastExplicit[scope]; ok &&
139- input.Text == state.text &&
140- currentTime.Sub(state.at) < explicitClipboardDedupWindow {
148+ if previous, ok := uc.lastExplicit[scope]; ok &&
149+ input.Text == previous.text &&
150+ input.Action == previous.action &&
151+ currentTime.Sub(previous.at) < explicitClipboardDedupWindow {
141152 uc.mu.Unlock()
142153 return nil
143154 }
@@ -145,16 +156,20 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
145156 uc.mu.Unlock()
146157 return fmt.Errorf("clipboard not available")
147158 }
159+ uc.lastExplicit[scope] = state
160+ uc.mu.Unlock()
161+
148162 if !input.NativeHandled {
149163 if err := uc.clipboard.WriteText(ctx, input.Text); err != nil {
164+ uc.mu.Lock()
165+ if current, ok := uc.lastExplicit[scope]; ok && current == state {
166+ delete(uc.lastExplicit, scope)
167+ }
150168 uc.mu.Unlock()
151169 return fmt.Errorf("clipboard write failed: %w", err)
152170 }
153171 }
154172
155- uc.lastExplicit[scope] = explicitCopyState{text: input.Text, at: currentTime}
156- uc.mu.Unlock()
157-
158173 if uc.toast != nil {
159174 uc.toast(textLen)
160175 }
@@ -167,10 +182,10 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
167182 return nil
168183 }
169184
170-func selectionScope(input port.SelectionClipboardInput) clipboardScope {
185+func selectionScope(input dto.SelectionClipboardInput) clipboardScope {
171186 return clipboardScope{source: input.SourceEngine, viewID: input.ViewID}
172187 }
173188
174-func explicitScope(input port.ExplicitClipboardInput) clipboardScope {
189+func explicitScope(input dto.ExplicitClipboardInput) clipboardScope {
175190 return clipboardScope{source: input.SourceEngine, viewID: input.ViewID}
176191 }
@@ -8,6 +8,7 @@ import (
8 "time"8 "time"
9 "unicode/utf8"9 "unicode/utf8"
10 10
11+ "github.com/bnema/dumber/internal/application/dto"
11 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
13 )14 )
@@ -26,13 +27,14 @@ type ClipboardTextOrchestratorUseCase struct {
26 }27 }
27 28
28 type clipboardScope struct {29 type clipboardScope struct {
29- source port.ClipboardSource30+ source dto.ClipboardSource
30- viewID port.WebViewID31+ viewID uint64
31 }32 }
32 33
33 type explicitCopyState struct {34 type explicitCopyState struct {
34- text string35+ text string
35- at time.Time36+ action string
37+ at time.Time
36 }38 }
37 39
38 var _ port.ClipboardTextOrchestrator = (*ClipboardTextOrchestratorUseCase)(nil)40 var _ port.ClipboardTextOrchestrator = (*ClipboardTextOrchestratorUseCase)(nil)
@@ -56,7 +58,7 @@ func NewClipboardTextOrchestrator(
56 // HandleSelectionUpdate applies auto-copy selection rules.58 // HandleSelectionUpdate applies auto-copy selection rules.
57 func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(59 func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(
58 ctx context.Context,60 ctx context.Context,
59- input port.SelectionClipboardInput,61+ input dto.SelectionClipboardInput,
60 ) error {62 ) error {
61 if uc == nil || uc.autoCopyConfig == nil || !uc.autoCopyConfig.IsAutoCopyEnabled() {63 if uc == nil || uc.autoCopyConfig == nil || !uc.autoCopyConfig.IsAutoCopyEnabled() {
62 if uc != nil {64 if uc != nil {
@@ -94,19 +96,25 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(
94 uc.mu.Unlock()96 uc.mu.Unlock()
95 return fmt.Errorf("clipboard not available")97 return fmt.Errorf("clipboard not available")
96 }98 }
99+ selectionState := input.Text
100+ explicitInput := dto.ExplicitClipboardInput{SourceEngine: input.SourceEngine, ViewID: input.ViewID, Action: "copy"}
101+ explicitState := explicitCopyState{text: input.Text, action: explicitInput.Action, at: currentTime}
102+ uc.lastSelection[scope] = selectionState
103+ uc.lastExplicit[explicitScope(explicitInput)] = explicitState
104+ uc.mu.Unlock()
105+
97 if err := uc.clipboard.WriteText(ctx, input.Text); err != nil {106 if err := uc.clipboard.WriteText(ctx, input.Text); err != nil {
107+ uc.mu.Lock()
108+ if uc.lastSelection[scope] == selectionState {
109+ delete(uc.lastSelection, scope)
110+ }
111+ if state, ok := uc.lastExplicit[explicitScope(explicitInput)]; ok && state == explicitState {
112+ delete(uc.lastExplicit, explicitScope(explicitInput))
113+ }
98 uc.mu.Unlock()114 uc.mu.Unlock()
99 return fmt.Errorf("clipboard write failed: %w", err)115 return fmt.Errorf("clipboard write failed: %w", err)
100 }116 }
101 117
102- uc.lastSelection[scope] = input.Text
103- explicitInput := port.ExplicitClipboardInput{SourceEngine: input.SourceEngine, ViewID: input.ViewID}
104- uc.lastExplicit[explicitScope(explicitInput)] = explicitCopyState{
105- text: input.Text,
106- at: currentTime,
107- }
108- uc.mu.Unlock()
109-
110 if uc.toast != nil {118 if uc.toast != nil {
111 uc.toast(textLen)119 uc.toast(textLen)
112 }120 }
@@ -120,7 +128,7 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(
120 // HandleExplicitCopy applies explicit copy rules.128 // HandleExplicitCopy applies explicit copy rules.
121 func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(129 func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
122 ctx context.Context,130 ctx context.Context,
123- input port.ExplicitClipboardInput,131+ input dto.ExplicitClipboardInput,
124 ) error {132 ) error {
125 if uc == nil || input.Text == "" {133 if uc == nil || input.Text == "" {
126 return nil134 return nil
@@ -134,10 +142,13 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
134 textLen := utf8.RuneCountInString(input.Text)142 textLen := utf8.RuneCountInString(input.Text)
135 scope := explicitScope(input)143 scope := explicitScope(input)
136 144
145+ state := explicitCopyState{text: input.Text, action: input.Action, at: currentTime}
146+
137 uc.mu.Lock()147 uc.mu.Lock()
138- if state, ok := uc.lastExplicit[scope]; ok &&148+ if previous, ok := uc.lastExplicit[scope]; ok &&
139- input.Text == state.text &&149+ input.Text == previous.text &&
140- currentTime.Sub(state.at) < explicitClipboardDedupWindow {150+ input.Action == previous.action &&
151+ currentTime.Sub(previous.at) < explicitClipboardDedupWindow {
141 uc.mu.Unlock()152 uc.mu.Unlock()
142 return nil153 return nil
143 }154 }
@@ -145,16 +156,20 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
145 uc.mu.Unlock()156 uc.mu.Unlock()
146 return fmt.Errorf("clipboard not available")157 return fmt.Errorf("clipboard not available")
147 }158 }
159+ uc.lastExplicit[scope] = state
160+ uc.mu.Unlock()
161+
148 if !input.NativeHandled {162 if !input.NativeHandled {
149 if err := uc.clipboard.WriteText(ctx, input.Text); err != nil {163 if err := uc.clipboard.WriteText(ctx, input.Text); err != nil {
164+ uc.mu.Lock()
165+ if current, ok := uc.lastExplicit[scope]; ok && current == state {
166+ delete(uc.lastExplicit, scope)
167+ }
150 uc.mu.Unlock()168 uc.mu.Unlock()
151 return fmt.Errorf("clipboard write failed: %w", err)169 return fmt.Errorf("clipboard write failed: %w", err)
152 }170 }
153 }171 }
154 172
155- uc.lastExplicit[scope] = explicitCopyState{text: input.Text, at: currentTime}
156- uc.mu.Unlock()
157-
158 if uc.toast != nil {173 if uc.toast != nil {
159 uc.toast(textLen)174 uc.toast(textLen)
160 }175 }
@@ -167,10 +182,10 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
167 return nil182 return nil
168 }183 }
169 184
170-func selectionScope(input port.SelectionClipboardInput) clipboardScope {185+func selectionScope(input dto.SelectionClipboardInput) clipboardScope {
171 return clipboardScope{source: input.SourceEngine, viewID: input.ViewID}186 return clipboardScope{source: input.SourceEngine, viewID: input.ViewID}
172 }187 }
173 188
174-func explicitScope(input port.ExplicitClipboardInput) clipboardScope {189+func explicitScope(input dto.ExplicitClipboardInput) clipboardScope {
175 return clipboardScope{source: input.SourceEngine, viewID: input.ViewID}190 return clipboardScope{source: input.SourceEngine, viewID: input.ViewID}
176 }191 }
M internal/application/usecase/clipboard_text_orchestrator_test.go +50 -30
@@ -7,7 +7,7 @@ import (
77 "testing"
88 "time"
99
10- "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/application/dto"
1111 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
1212 "github.com/bnema/dumber/internal/domain/entity"
1313 )
@@ -85,7 +85,7 @@ func TestClipboardTextOrchestrator_HandleSelectionUpdate_AutoCopyDisabledDoesNot
8585 toastCalls = append(toastCalls, textLen)
8686 })
8787
88- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "hello", SourceEngine: port.ClipboardSourceCEF}); err != nil {
88+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "hello", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
8989 t.Fatalf("HandleSelectionUpdate() error = %v, want nil", err)
9090 }
9191
@@ -106,19 +106,19 @@ func TestClipboardTextOrchestrator_HandleSelectionUpdate_WritesOncePerUniqueSele
106106 toastCalls = append(toastCalls, textLen)
107107 })
108108
109- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "é", SourceEngine: port.ClipboardSourceCEF}); err != nil {
109+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "é", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
110110 t.Fatalf("HandleSelectionUpdate(short) error = %v, want nil", err)
111111 }
112- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF}); err != nil {
112+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
113113 t.Fatalf("HandleSelectionUpdate(first) error = %v, want nil", err)
114114 }
115- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF}); err != nil {
115+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
116116 t.Fatalf("HandleSelectionUpdate(duplicate) error = %v, want nil", err)
117117 }
118- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "", SourceEngine: port.ClipboardSourceCEF}); err != nil {
118+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
119119 t.Fatalf("HandleSelectionUpdate(reset) error = %v, want nil", err)
120120 }
121- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF}); err != nil {
121+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
122122 t.Fatalf("HandleSelectionUpdate(after reset) error = %v, want nil", err)
123123 }
124124
@@ -136,10 +136,10 @@ func TestClipboardTextOrchestrator_HandleSelectionUpdate_DoesNotDedupAcrossEngin
136136
137137 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
138138
139- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceWebKit}); err != nil {
139+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceWebKit}); err != nil {
140140 t.Fatalf("HandleSelectionUpdate(webkit) error = %v, want nil", err)
141141 }
142- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF}); err != nil {
142+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
143143 t.Fatalf("HandleSelectionUpdate(cef) error = %v, want nil", err)
144144 }
145145 }
@@ -153,10 +153,10 @@ func TestClipboardTextOrchestrator_HandleSelectionUpdate_DoesNotDedupAcrossViews
153153
154154 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
155155
156- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 1}); err != nil {
156+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil {
157157 t.Fatalf("HandleSelectionUpdate(view 1) error = %v, want nil", err)
158158 }
159- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 2}); err != nil {
159+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 2}); err != nil {
160160 t.Fatalf("HandleSelectionUpdate(view 2) error = %v, want nil", err)
161161 }
162162 }
@@ -176,15 +176,15 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesIdenticalPaylo
176176 now := time.Unix(0, 0)
177177 uc.now = func() time.Time { return now }
178178
179- if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"}); err != nil {
179+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil {
180180 t.Fatalf("HandleExplicitCopy(first) error = %v, want nil", err)
181181 }
182182 now = now.Add(100 * time.Millisecond)
183- if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"}); err != nil {
183+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil {
184184 t.Fatalf("HandleExplicitCopy(duplicate) error = %v, want nil", err)
185185 }
186186 now = now.Add(151 * time.Millisecond)
187- if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"}); err != nil {
187+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil {
188188 t.Fatalf("HandleExplicitCopy(after window) error = %v, want nil", err)
189189 }
190190
@@ -203,7 +203,7 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_NativeHandledSkipsBackendW
203203 toastCalls = append(toastCalls, textLen)
204204 })
205205
206- if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceWebKit, Action: "copy", NativeHandled: true}); err != nil {
206+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy", NativeHandled: true}); err != nil {
207207 t.Fatalf("HandleExplicitCopy() error = %v, want nil", err)
208208 }
209209
@@ -218,6 +218,26 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_NativeHandledSkipsBackendW
218218 }
219219 }
220220
221+func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupDifferentActionsWithinWindow(t *testing.T) {
222+ ctx := context.Background()
223+ clipboard := portmocks.NewMockClipboard(t)
224+ autoCopyConfig := &staticAutoCopyConfig{enabled: true}
225+
226+ clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2)
227+
228+ uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
229+ now := time.Unix(0, 0)
230+ uc.now = func() time.Time { return now }
231+
232+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil {
233+ t.Fatalf("HandleExplicitCopy(copy) error = %v, want nil", err)
234+ }
235+ now = now.Add(100 * time.Millisecond)
236+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "cut"}); err != nil {
237+ t.Fatalf("HandleExplicitCopy(cut) error = %v, want nil", err)
238+ }
239+}
240+
221241 func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesRecentAutoCopySelection(t *testing.T) {
222242 ctx := context.Background()
223243 clipboard := portmocks.NewMockClipboard(t)
@@ -233,11 +253,11 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesRecentAutoCopy
233253 now := time.Unix(0, 0)
234254 uc.now = func() time.Time { return now }
235255
236- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 1}); err != nil {
256+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil {
237257 t.Fatalf("HandleSelectionUpdate() error = %v, want nil", err)
238258 }
239259 now = now.Add(100 * time.Millisecond)
240- if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 1, Action: "cut"}); err != nil {
260+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil {
241261 t.Fatalf("HandleExplicitCopy() error = %v, want nil", err)
242262 }
243263
@@ -259,10 +279,10 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupAcrossEngines(
259279 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
260280 uc.now = func() time.Time { return time.Unix(0, 0) }
261281
262- if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"}); err != nil {
282+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil {
263283 t.Fatalf("HandleExplicitCopy(webkit) error = %v, want nil", err)
264284 }
265- if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, Action: "copy"}); err != nil {
285+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, Action: "copy"}); err != nil {
266286 t.Fatalf("HandleExplicitCopy(cef) error = %v, want nil", err)
267287 }
268288 }
@@ -277,10 +297,10 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupAcrossViews(t
277297 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
278298 uc.now = func() time.Time { return time.Unix(0, 0) }
279299
280- if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil {
300+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil {
281301 t.Fatalf("HandleExplicitCopy(view 1) error = %v, want nil", err)
282302 }
283- if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 2, Action: "copy"}); err != nil {
303+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 2, Action: "copy"}); err != nil {
284304 t.Fatalf("HandleExplicitCopy(view 2) error = %v, want nil", err)
285305 }
286306 }
@@ -299,7 +319,7 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_KeepsBoundedStatePerScope(
299319 for i, text := range []string{"one", "two", "three"} {
300320 advancedTime := time.Unix(0, 0).Add(time.Duration(i) * time.Second)
301321 uc.now = func() time.Time { return advancedTime }
302- if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: text, SourceEngine: port.ClipboardSourceCEF, ViewID: 7, Action: "copy"}); err != nil {
322+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: text, SourceEngine: dto.ClipboardSourceCEF, ViewID: 7, Action: "copy"}); err != nil {
303323 t.Fatalf("HandleExplicitCopy(%q) error = %v, want nil", text, err)
304324 }
305325 }
@@ -318,15 +338,15 @@ func TestClipboardTextOrchestrator_HandleSelectionUpdate_ReenableClearsDedupStat
318338
319339 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
320340
321- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "toggle", SourceEngine: port.ClipboardSourceCEF, ViewID: 1}); err != nil {
341+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "toggle", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil {
322342 t.Fatalf("HandleSelectionUpdate(enabled) error = %v, want nil", err)
323343 }
324344 autoCopyConfig.enabled = false
325- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "toggle", SourceEngine: port.ClipboardSourceCEF, ViewID: 1}); err != nil {
345+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "toggle", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil {
326346 t.Fatalf("HandleSelectionUpdate(disabled) error = %v, want nil", err)
327347 }
328348 autoCopyConfig.enabled = true
329- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "toggle", SourceEngine: port.ClipboardSourceCEF, ViewID: 1}); err != nil {
349+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "toggle", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil {
330350 t.Fatalf("HandleSelectionUpdate(reenabled) error = %v, want nil", err)
331351 }
332352 }
@@ -342,7 +362,7 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_ClipboardFailureDoesNotToa
342362 toastCalls = append(toastCalls, textLen)
343363 })
344364
345- err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF, Action: "copy"})
365+ err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF, Action: "copy"})
346366 if err == nil {
347367 t.Fatal("HandleExplicitCopy() error = nil, want non-nil")
348368 }
@@ -362,13 +382,13 @@ func TestClipboardTextOrchestrator_HandleSelectionUpdate_DedupIsAtomic(t *testin
362382
363383 done1 := make(chan error, 1)
364384 go func() {
365- done1 <- uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF})
385+ done1 <- uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF})
366386 }()
367387 <-clipboard.started
368388
369389 done2 := make(chan error, 1)
370390 go func() {
371- done2 <- uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF})
391+ done2 <- uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF})
372392 }()
373393
374394 select {
@@ -406,13 +426,13 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_DedupIsAtomic(t *testing.T
406426
407427 done1 := make(chan error, 1)
408428 go func() {
409- done1 <- uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"})
429+ done1 <- uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"})
410430 }()
411431 <-clipboard.started
412432
413433 done2 := make(chan error, 1)
414434 go func() {
415- done2 <- uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"})
435+ done2 <- uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"})
416436 }()
417437
418438 select {
@@ -7,7 +7,7 @@ import (
7 "testing"7 "testing"
8 "time"8 "time"
9 9
10- "github.com/bnema/dumber/internal/application/port"10+ "github.com/bnema/dumber/internal/application/dto"
11 portmocks "github.com/bnema/dumber/internal/application/port/mocks"11 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
12 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
13 )13 )
@@ -85,7 +85,7 @@ func TestClipboardTextOrchestrator_HandleSelectionUpdate_AutoCopyDisabledDoesNot
85 toastCalls = append(toastCalls, textLen)85 toastCalls = append(toastCalls, textLen)
86 })86 })
87 87
88- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "hello", SourceEngine: port.ClipboardSourceCEF}); err != nil {88+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "hello", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
89 t.Fatalf("HandleSelectionUpdate() error = %v, want nil", err)89 t.Fatalf("HandleSelectionUpdate() error = %v, want nil", err)
90 }90 }
91 91
@@ -106,19 +106,19 @@ func TestClipboardTextOrchestrator_HandleSelectionUpdate_WritesOncePerUniqueSele
106 toastCalls = append(toastCalls, textLen)106 toastCalls = append(toastCalls, textLen)
107 })107 })
108 108
109- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "é", SourceEngine: port.ClipboardSourceCEF}); err != nil {109+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "é", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
110 t.Fatalf("HandleSelectionUpdate(short) error = %v, want nil", err)110 t.Fatalf("HandleSelectionUpdate(short) error = %v, want nil", err)
111 }111 }
112- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF}); err != nil {112+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
113 t.Fatalf("HandleSelectionUpdate(first) error = %v, want nil", err)113 t.Fatalf("HandleSelectionUpdate(first) error = %v, want nil", err)
114 }114 }
115- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF}); err != nil {115+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
116 t.Fatalf("HandleSelectionUpdate(duplicate) error = %v, want nil", err)116 t.Fatalf("HandleSelectionUpdate(duplicate) error = %v, want nil", err)
117 }117 }
118- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "", SourceEngine: port.ClipboardSourceCEF}); err != nil {118+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
119 t.Fatalf("HandleSelectionUpdate(reset) error = %v, want nil", err)119 t.Fatalf("HandleSelectionUpdate(reset) error = %v, want nil", err)
120 }120 }
121- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF}); err != nil {121+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
122 t.Fatalf("HandleSelectionUpdate(after reset) error = %v, want nil", err)122 t.Fatalf("HandleSelectionUpdate(after reset) error = %v, want nil", err)
123 }123 }
124 124
@@ -136,10 +136,10 @@ func TestClipboardTextOrchestrator_HandleSelectionUpdate_DoesNotDedupAcrossEngin
136 136
137 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)137 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
138 138
139- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceWebKit}); err != nil {139+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceWebKit}); err != nil {
140 t.Fatalf("HandleSelectionUpdate(webkit) error = %v, want nil", err)140 t.Fatalf("HandleSelectionUpdate(webkit) error = %v, want nil", err)
141 }141 }
142- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF}); err != nil {142+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
143 t.Fatalf("HandleSelectionUpdate(cef) error = %v, want nil", err)143 t.Fatalf("HandleSelectionUpdate(cef) error = %v, want nil", err)
144 }144 }
145 }145 }
@@ -153,10 +153,10 @@ func TestClipboardTextOrchestrator_HandleSelectionUpdate_DoesNotDedupAcrossViews
153 153
154 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)154 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
155 155
156- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 1}); err != nil {156+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil {
157 t.Fatalf("HandleSelectionUpdate(view 1) error = %v, want nil", err)157 t.Fatalf("HandleSelectionUpdate(view 1) error = %v, want nil", err)
158 }158 }
159- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 2}); err != nil {159+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 2}); err != nil {
160 t.Fatalf("HandleSelectionUpdate(view 2) error = %v, want nil", err)160 t.Fatalf("HandleSelectionUpdate(view 2) error = %v, want nil", err)
161 }161 }
162 }162 }
@@ -176,15 +176,15 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesIdenticalPaylo
176 now := time.Unix(0, 0)176 now := time.Unix(0, 0)
177 uc.now = func() time.Time { return now }177 uc.now = func() time.Time { return now }
178 178
179- if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"}); err != nil {179+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil {
180 t.Fatalf("HandleExplicitCopy(first) error = %v, want nil", err)180 t.Fatalf("HandleExplicitCopy(first) error = %v, want nil", err)
181 }181 }
182 now = now.Add(100 * time.Millisecond)182 now = now.Add(100 * time.Millisecond)
183- if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"}); err != nil {183+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil {
184 t.Fatalf("HandleExplicitCopy(duplicate) error = %v, want nil", err)184 t.Fatalf("HandleExplicitCopy(duplicate) error = %v, want nil", err)
185 }185 }
186 now = now.Add(151 * time.Millisecond)186 now = now.Add(151 * time.Millisecond)
187- if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"}); err != nil {187+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil {
188 t.Fatalf("HandleExplicitCopy(after window) error = %v, want nil", err)188 t.Fatalf("HandleExplicitCopy(after window) error = %v, want nil", err)
189 }189 }
190 190
@@ -203,7 +203,7 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_NativeHandledSkipsBackendW
203 toastCalls = append(toastCalls, textLen)203 toastCalls = append(toastCalls, textLen)
204 })204 })
205 205
206- if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceWebKit, Action: "copy", NativeHandled: true}); err != nil {206+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy", NativeHandled: true}); err != nil {
207 t.Fatalf("HandleExplicitCopy() error = %v, want nil", err)207 t.Fatalf("HandleExplicitCopy() error = %v, want nil", err)
208 }208 }
209 209
@@ -218,6 +218,26 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_NativeHandledSkipsBackendW
218 }218 }
219 }219 }
220 220
221+func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupDifferentActionsWithinWindow(t *testing.T) {
222+ ctx := context.Background()
223+ clipboard := portmocks.NewMockClipboard(t)
224+ autoCopyConfig := &staticAutoCopyConfig{enabled: true}
225+
226+ clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2)
227+
228+ uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
229+ now := time.Unix(0, 0)
230+ uc.now = func() time.Time { return now }
231+
232+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil {
233+ t.Fatalf("HandleExplicitCopy(copy) error = %v, want nil", err)
234+ }
235+ now = now.Add(100 * time.Millisecond)
236+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "cut"}); err != nil {
237+ t.Fatalf("HandleExplicitCopy(cut) error = %v, want nil", err)
238+ }
239+}
240+
221 func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesRecentAutoCopySelection(t *testing.T) {241 func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesRecentAutoCopySelection(t *testing.T) {
222 ctx := context.Background()242 ctx := context.Background()
223 clipboard := portmocks.NewMockClipboard(t)243 clipboard := portmocks.NewMockClipboard(t)
@@ -233,11 +253,11 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesRecentAutoCopy
233 now := time.Unix(0, 0)253 now := time.Unix(0, 0)
234 uc.now = func() time.Time { return now }254 uc.now = func() time.Time { return now }
235 255
236- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 1}); err != nil {256+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil {
237 t.Fatalf("HandleSelectionUpdate() error = %v, want nil", err)257 t.Fatalf("HandleSelectionUpdate() error = %v, want nil", err)
238 }258 }
239 now = now.Add(100 * time.Millisecond)259 now = now.Add(100 * time.Millisecond)
240- if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 1, Action: "cut"}); err != nil {260+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil {
241 t.Fatalf("HandleExplicitCopy() error = %v, want nil", err)261 t.Fatalf("HandleExplicitCopy() error = %v, want nil", err)
242 }262 }
243 263
@@ -259,10 +279,10 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupAcrossEngines(
259 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)279 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
260 uc.now = func() time.Time { return time.Unix(0, 0) }280 uc.now = func() time.Time { return time.Unix(0, 0) }
261 281
262- if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"}); err != nil {282+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil {
263 t.Fatalf("HandleExplicitCopy(webkit) error = %v, want nil", err)283 t.Fatalf("HandleExplicitCopy(webkit) error = %v, want nil", err)
264 }284 }
265- if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, Action: "copy"}); err != nil {285+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, Action: "copy"}); err != nil {
266 t.Fatalf("HandleExplicitCopy(cef) error = %v, want nil", err)286 t.Fatalf("HandleExplicitCopy(cef) error = %v, want nil", err)
267 }287 }
268 }288 }
@@ -277,10 +297,10 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupAcrossViews(t
277 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)297 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
278 uc.now = func() time.Time { return time.Unix(0, 0) }298 uc.now = func() time.Time { return time.Unix(0, 0) }
279 299
280- if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil {300+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil {
281 t.Fatalf("HandleExplicitCopy(view 1) error = %v, want nil", err)301 t.Fatalf("HandleExplicitCopy(view 1) error = %v, want nil", err)
282 }302 }
283- if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 2, Action: "copy"}); err != nil {303+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 2, Action: "copy"}); err != nil {
284 t.Fatalf("HandleExplicitCopy(view 2) error = %v, want nil", err)304 t.Fatalf("HandleExplicitCopy(view 2) error = %v, want nil", err)
285 }305 }
286 }306 }
@@ -299,7 +319,7 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_KeepsBoundedStatePerScope(
299 for i, text := range []string{"one", "two", "three"} {319 for i, text := range []string{"one", "two", "three"} {
300 advancedTime := time.Unix(0, 0).Add(time.Duration(i) * time.Second)320 advancedTime := time.Unix(0, 0).Add(time.Duration(i) * time.Second)
301 uc.now = func() time.Time { return advancedTime }321 uc.now = func() time.Time { return advancedTime }
302- if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: text, SourceEngine: port.ClipboardSourceCEF, ViewID: 7, Action: "copy"}); err != nil {322+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: text, SourceEngine: dto.ClipboardSourceCEF, ViewID: 7, Action: "copy"}); err != nil {
303 t.Fatalf("HandleExplicitCopy(%q) error = %v, want nil", text, err)323 t.Fatalf("HandleExplicitCopy(%q) error = %v, want nil", text, err)
304 }324 }
305 }325 }
@@ -318,15 +338,15 @@ func TestClipboardTextOrchestrator_HandleSelectionUpdate_ReenableClearsDedupStat
318 338
319 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)339 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
320 340
321- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "toggle", SourceEngine: port.ClipboardSourceCEF, ViewID: 1}); err != nil {341+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "toggle", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil {
322 t.Fatalf("HandleSelectionUpdate(enabled) error = %v, want nil", err)342 t.Fatalf("HandleSelectionUpdate(enabled) error = %v, want nil", err)
323 }343 }
324 autoCopyConfig.enabled = false344 autoCopyConfig.enabled = false
325- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "toggle", SourceEngine: port.ClipboardSourceCEF, ViewID: 1}); err != nil {345+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "toggle", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil {
326 t.Fatalf("HandleSelectionUpdate(disabled) error = %v, want nil", err)346 t.Fatalf("HandleSelectionUpdate(disabled) error = %v, want nil", err)
327 }347 }
328 autoCopyConfig.enabled = true348 autoCopyConfig.enabled = true
329- if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "toggle", SourceEngine: port.ClipboardSourceCEF, ViewID: 1}); err != nil {349+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "toggle", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil {
330 t.Fatalf("HandleSelectionUpdate(reenabled) error = %v, want nil", err)350 t.Fatalf("HandleSelectionUpdate(reenabled) error = %v, want nil", err)
331 }351 }
332 }352 }
@@ -342,7 +362,7 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_ClipboardFailureDoesNotToa
342 toastCalls = append(toastCalls, textLen)362 toastCalls = append(toastCalls, textLen)
343 })363 })
344 364
345- err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF, Action: "copy"})365+ err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF, Action: "copy"})
346 if err == nil {366 if err == nil {
347 t.Fatal("HandleExplicitCopy() error = nil, want non-nil")367 t.Fatal("HandleExplicitCopy() error = nil, want non-nil")
348 }368 }
@@ -362,13 +382,13 @@ func TestClipboardTextOrchestrator_HandleSelectionUpdate_DedupIsAtomic(t *testin
362 382
363 done1 := make(chan error, 1)383 done1 := make(chan error, 1)
364 go func() {384 go func() {
365- done1 <- uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF})385+ done1 <- uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF})
366 }()386 }()
367 <-clipboard.started387 <-clipboard.started
368 388
369 done2 := make(chan error, 1)389 done2 := make(chan error, 1)
370 go func() {390 go func() {
371- done2 <- uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF})391+ done2 <- uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF})
372 }()392 }()
373 393
374 select {394 select {
@@ -406,13 +426,13 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_DedupIsAtomic(t *testing.T
406 426
407 done1 := make(chan error, 1)427 done1 := make(chan error, 1)
408 go func() {428 go func() {
409- done1 <- uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"})429+ done1 <- uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"})
410 }()430 }()
411 <-clipboard.started431 <-clipboard.started
412 432
413 done2 := make(chan error, 1)433 done2 := make(chan error, 1)
414 go func() {434 go func() {
415- done2 <- uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"})435+ done2 <- uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"})
416 }()436 }()
417 437
418 select {438 select {
M internal/application/usecase/execute_context_menu_action.go +1 -4
@@ -106,10 +106,7 @@ func (uc *ExecuteContextMenuActionUseCase) executeMenuAction(
106106 }
107107 return nil
108108 case port.MenuActionCopySelection:
109- if menuContext.SelectionText != "" {
110- if uc.clipboard == nil {
111- return fmt.Errorf("copy selection: clipboard not available")
112- }
109+ if menuContext.SelectionText != "" && uc.clipboard != nil {
113110 if err := uc.clipboard.WriteText(ctx, menuContext.SelectionText); err != nil {
114111 return fmt.Errorf("copy selection: %w", err)
115112 }
@@ -106,10 +106,7 @@ func (uc *ExecuteContextMenuActionUseCase) executeMenuAction(
106 }106 }
107 return nil107 return nil
108 case port.MenuActionCopySelection:108 case port.MenuActionCopySelection:
109- if menuContext.SelectionText != "" {109+ if menuContext.SelectionText != "" && uc.clipboard != nil {
110- if uc.clipboard == nil {
111- return fmt.Errorf("copy selection: clipboard not available")
112- }
113 if err := uc.clipboard.WriteText(ctx, menuContext.SelectionText); err != nil {110 if err := uc.clipboard.WriteText(ctx, menuContext.SelectionText); err != nil {
114 return fmt.Errorf("copy selection: %w", err)111 return fmt.Errorf("copy selection: %w", err)
115 }112 }
M internal/application/usecase/execute_context_menu_action_test.go +19 -0
@@ -238,6 +238,25 @@ func TestExecuteContextMenuActionUseCase_CopySelectionWritesSelectedText(t *test
238238 require.Zero(t, delegator.delegateCalls)
239239 }
240240
241+func TestExecuteContextMenuActionUseCase_CopySelectionFallsBackToDelegatorWhenClipboardMissing(t *testing.T) {
242+ resolver := &fakeImageResolver{}
243+ saver := &fakeResolvedImageSaver{}
244+ delegator := &fakeMenuActionDelegator{}
245+ uc := NewExecuteContextMenuActionUseCase(nil, resolver, saver, delegator)
246+
247+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
248+ Action: port.MenuActionCopySelection,
249+ Context: port.MenuContext{
250+ HasSelection: true,
251+ SelectionText: "selected text",
252+ },
253+ })
254+
255+ require.NoError(t, err)
256+ require.Equal(t, 1, delegator.delegateCalls)
257+ require.Equal(t, port.MenuActionCopySelection, delegator.action)
258+}
259+
241260 func TestExecuteContextMenuActionUseCase_CopyLinkWritesText(t *testing.T) {
242261 clipboard := &fakeClipboard{}
243262 resolver := &fakeImageResolver{}
@@ -238,6 +238,25 @@ func TestExecuteContextMenuActionUseCase_CopySelectionWritesSelectedText(t *test
238 require.Zero(t, delegator.delegateCalls)238 require.Zero(t, delegator.delegateCalls)
239 }239 }
240 240
241+func TestExecuteContextMenuActionUseCase_CopySelectionFallsBackToDelegatorWhenClipboardMissing(t *testing.T) {
242+ resolver := &fakeImageResolver{}
243+ saver := &fakeResolvedImageSaver{}
244+ delegator := &fakeMenuActionDelegator{}
245+ uc := NewExecuteContextMenuActionUseCase(nil, resolver, saver, delegator)
246+
247+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
248+ Action: port.MenuActionCopySelection,
249+ Context: port.MenuContext{
250+ HasSelection: true,
251+ SelectionText: "selected text",
252+ },
253+ })
254+
255+ require.NoError(t, err)
256+ require.Equal(t, 1, delegator.delegateCalls)
257+ require.Equal(t, port.MenuActionCopySelection, delegator.action)
258+}
259+
241 func TestExecuteContextMenuActionUseCase_CopyLinkWritesText(t *testing.T) {260 func TestExecuteContextMenuActionUseCase_CopyLinkWritesText(t *testing.T) {
242 clipboard := &fakeClipboard{}261 clipboard := &fakeClipboard{}
243 resolver := &fakeImageResolver{}262 resolver := &fakeImageResolver{}
M internal/bootstrap/session_spawner.go +1 -1
@@ -15,7 +15,7 @@ import (
1515 func NewSessionSpawner(ctx context.Context, profile runtimeprofile.Profile) port.SessionSpawner {
1616 var spawnEnv port.SessionSpawnEnvironment
1717 if profile.Engine == config.EngineTypeCEF {
18- spawnEnv = cef.NewSessionSpawnEnvironment(profile)
18+ spawnEnv = cef.NewSessionSpawnEnvironment(profile.CEFUserDataDir())
1919 }
2020 return desktop.NewSessionSpawner(ctx, spawnEnv)
2121 }
@@ -15,7 +15,7 @@ import (
15 func NewSessionSpawner(ctx context.Context, profile runtimeprofile.Profile) port.SessionSpawner {15 func NewSessionSpawner(ctx context.Context, profile runtimeprofile.Profile) port.SessionSpawner {
16 var spawnEnv port.SessionSpawnEnvironment16 var spawnEnv port.SessionSpawnEnvironment
17 if profile.Engine == config.EngineTypeCEF {17 if profile.Engine == config.EngineTypeCEF {
18- spawnEnv = cef.NewSessionSpawnEnvironment(profile)18+ spawnEnv = cef.NewSessionSpawnEnvironment(profile.CEFUserDataDir())
19 }19 }
20 return desktop.NewSessionSpawner(ctx, spawnEnv)20 return desktop.NewSessionSpawner(ctx, spawnEnv)
21 }21 }
M internal/cli/cmd/gen_docs.go +2 -1
@@ -9,6 +9,7 @@ import (
99 "github.com/spf13/cobra"
1010 "github.com/spf13/cobra/doc"
1111
12+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
1213 xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg"
1314 )
1415
@@ -57,7 +58,7 @@ func runGenDocs(_ *cobra.Command, _ []string) error {
5758 if outputDir == "" {
5859 switch genDocsFormat {
5960 case "man":
60- xdg := xdgadapter.New()
61+ xdg := xdgadapter.New(runtimeprofile.Profile{})
6162 manDir, err := xdg.ManDir()
6263 if err != nil {
6364 return fmt.Errorf("resolve man directory: %w", err)
@@ -9,6 +9,7 @@ import (
9 "github.com/spf13/cobra"9 "github.com/spf13/cobra"
10 "github.com/spf13/cobra/doc"10 "github.com/spf13/cobra/doc"
11 11
12+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
12 xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg"13 xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg"
13 )14 )
14 15
@@ -57,7 +58,7 @@ func runGenDocs(_ *cobra.Command, _ []string) error {
57 if outputDir == "" {58 if outputDir == "" {
58 switch genDocsFormat {59 switch genDocsFormat {
59 case "man":60 case "man":
60- xdg := xdgadapter.New()61+ xdg := xdgadapter.New(runtimeprofile.Profile{})
61 manDir, err := xdg.ManDir()62 manDir, err := xdg.ManDir()
62 if err != nil {63 if err != nil {
63 return fmt.Errorf("resolve man directory: %w", err)64 return fmt.Errorf("resolve man directory: %w", err)
M internal/cli/cmd/purge.go +7 -2
@@ -7,6 +7,7 @@ import (
77 "github.com/spf13/cobra"
88
99 "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/bootstrap"
1011 "github.com/bnema/dumber/internal/cli"
1112 "github.com/bnema/dumber/internal/cli/model"
1213 "github.com/bnema/dumber/internal/cli/styles"
@@ -47,7 +48,11 @@ func runPurge(_ *cobra.Command, _ []string) error {
4748 }
4849
4950 fsAdapter := filesystem.New()
50- xdgAdapter := xdgadapter.New()
51+ profile, err := bootstrap.ResolveRuntimeProfile(app.Config)
52+ if err != nil {
53+ return fmt.Errorf("resolve runtime profile: %w", err)
54+ }
55+ xdgAdapter := xdgadapter.New(profile)
5156 desktopAdapter := desktop.New()
5257 purgeUC := usecase.NewPurgeDataUseCase(fsAdapter, xdgAdapter, desktopAdapter)
5358
@@ -63,7 +68,7 @@ func runPurge(_ *cobra.Command, _ []string) error {
6368
6469 m := model.NewPurgeModel(app.Ctx(), app.Theme, purgeUC, cfg)
6570 p := tea.NewProgram(m, tea.WithAltScreen())
66- _, err := p.Run()
71+ _, err = p.Run()
6772 return err
6873 }
6974
@@ -7,6 +7,7 @@ import (
7 "github.com/spf13/cobra"7 "github.com/spf13/cobra"
8 8
9 "github.com/bnema/dumber/internal/application/usecase"9 "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/bootstrap"
10 "github.com/bnema/dumber/internal/cli"11 "github.com/bnema/dumber/internal/cli"
11 "github.com/bnema/dumber/internal/cli/model"12 "github.com/bnema/dumber/internal/cli/model"
12 "github.com/bnema/dumber/internal/cli/styles"13 "github.com/bnema/dumber/internal/cli/styles"
@@ -47,7 +48,11 @@ func runPurge(_ *cobra.Command, _ []string) error {
47 }48 }
48 49
49 fsAdapter := filesystem.New()50 fsAdapter := filesystem.New()
50- xdgAdapter := xdgadapter.New()51+ profile, err := bootstrap.ResolveRuntimeProfile(app.Config)
52+ if err != nil {
53+ return fmt.Errorf("resolve runtime profile: %w", err)
54+ }
55+ xdgAdapter := xdgadapter.New(profile)
51 desktopAdapter := desktop.New()56 desktopAdapter := desktop.New()
52 purgeUC := usecase.NewPurgeDataUseCase(fsAdapter, xdgAdapter, desktopAdapter)57 purgeUC := usecase.NewPurgeDataUseCase(fsAdapter, xdgAdapter, desktopAdapter)
53 58
@@ -63,7 +68,7 @@ func runPurge(_ *cobra.Command, _ []string) error {
63 68
64 m := model.NewPurgeModel(app.Ctx(), app.Theme, purgeUC, cfg)69 m := model.NewPurgeModel(app.Ctx(), app.Theme, purgeUC, cfg)
65 p := tea.NewProgram(m, tea.WithAltScreen())70 p := tea.NewProgram(m, tea.WithAltScreen())
66- _, err := p.Run()71+ _, err = p.Run()
67 return err72 return err
68 }73 }
69 74
M internal/infrastructure/cef/content_injector_test.go +2 -1
@@ -18,5 +18,6 @@ func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testin
1818 require.Contains(t, clipboardSelectionFetchBridgeJS, "X-Dumber-Bridge-Action")
1919 require.Contains(t, clipboardSelectionFetchBridgeJS, "focus-sync")
2020 require.Contains(t, clipboardSelectionFetchBridgeJS, "focusin")
21- require.Contains(t, clipboardSelectionFetchBridgeJS, "if (isEditable(document.activeElement)) sendFocusSync();")
21+ require.Contains(t, clipboardSelectionFetchBridgeJS, "isEditable(document.activeElement)")
22+ require.Contains(t, clipboardSelectionFetchBridgeJS, "sendFocusSync()")
2223 }
@@ -18,5 +18,6 @@ func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testin
18 require.Contains(t, clipboardSelectionFetchBridgeJS, "X-Dumber-Bridge-Action")18 require.Contains(t, clipboardSelectionFetchBridgeJS, "X-Dumber-Bridge-Action")
19 require.Contains(t, clipboardSelectionFetchBridgeJS, "focus-sync")19 require.Contains(t, clipboardSelectionFetchBridgeJS, "focus-sync")
20 require.Contains(t, clipboardSelectionFetchBridgeJS, "focusin")20 require.Contains(t, clipboardSelectionFetchBridgeJS, "focusin")
21- require.Contains(t, clipboardSelectionFetchBridgeJS, "if (isEditable(document.activeElement)) sendFocusSync();")21+ require.Contains(t, clipboardSelectionFetchBridgeJS, "isEditable(document.activeElement)")
22+ require.Contains(t, clipboardSelectionFetchBridgeJS, "sendFocusSync()")
22 }23 }
M internal/infrastructure/cef/engine.go +7 -6
@@ -9,6 +9,7 @@ import (
99
1010 purecef "github.com/bnema/purego-cef/cef"
1111
12+ "github.com/bnema/dumber/internal/application/dto"
1213 "github.com/bnema/dumber/internal/application/port"
1314 "github.com/bnema/dumber/internal/logging"
1415 )
@@ -296,11 +297,11 @@ func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action
296297 Msg("cef: clipboard orchestration skipped — orchestrator nil")
297298 return
298299 }
299- if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), port.ExplicitClipboardInput{
300+ if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), dto.ExplicitClipboardInput{
300301 Text: text,
301302 Action: action,
302- SourceEngine: port.ClipboardSourceCEF,
303- ViewID: viewID,
303+ SourceEngine: dto.ClipboardSourceCEF,
304+ ViewID: uint64(viewID),
304305 }); err != nil {
305306 logging.FromContext(e.currentContext()).Debug().
306307 Err(err).
@@ -314,10 +315,10 @@ func (e *Engine) handleClipboardSelectionUpdate(viewID port.WebViewID, text stri
314315 if e == nil || e.clipboardTextOrchestrator == nil {
315316 return
316317 }
317- if err := e.clipboardTextOrchestrator.HandleSelectionUpdate(e.currentContext(), port.SelectionClipboardInput{
318+ if err := e.clipboardTextOrchestrator.HandleSelectionUpdate(e.currentContext(), dto.SelectionClipboardInput{
318319 Text: text,
319- SourceEngine: port.ClipboardSourceCEF,
320- ViewID: viewID,
320+ SourceEngine: dto.ClipboardSourceCEF,
321+ ViewID: uint64(viewID),
321322 }); err != nil {
322323 logging.FromContext(e.currentContext()).Debug().
323324 Err(err).
@@ -9,6 +9,7 @@ import (
9 9
10 purecef "github.com/bnema/purego-cef/cef"10 purecef "github.com/bnema/purego-cef/cef"
11 11
12+ "github.com/bnema/dumber/internal/application/dto"
12 "github.com/bnema/dumber/internal/application/port"13 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/logging"14 "github.com/bnema/dumber/internal/logging"
14 )15 )
@@ -296,11 +297,11 @@ func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action
296 Msg("cef: clipboard orchestration skipped — orchestrator nil")297 Msg("cef: clipboard orchestration skipped — orchestrator nil")
297 return298 return
298 }299 }
299- if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), port.ExplicitClipboardInput{300+ if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), dto.ExplicitClipboardInput{
300 Text: text,301 Text: text,
301 Action: action,302 Action: action,
302- SourceEngine: port.ClipboardSourceCEF,303+ SourceEngine: dto.ClipboardSourceCEF,
303- ViewID: viewID,304+ ViewID: uint64(viewID),
304 }); err != nil {305 }); err != nil {
305 logging.FromContext(e.currentContext()).Debug().306 logging.FromContext(e.currentContext()).Debug().
306 Err(err).307 Err(err).
@@ -314,10 +315,10 @@ func (e *Engine) handleClipboardSelectionUpdate(viewID port.WebViewID, text stri
314 if e == nil || e.clipboardTextOrchestrator == nil {315 if e == nil || e.clipboardTextOrchestrator == nil {
315 return316 return
316 }317 }
317- if err := e.clipboardTextOrchestrator.HandleSelectionUpdate(e.currentContext(), port.SelectionClipboardInput{318+ if err := e.clipboardTextOrchestrator.HandleSelectionUpdate(e.currentContext(), dto.SelectionClipboardInput{
318 Text: text,319 Text: text,
319- SourceEngine: port.ClipboardSourceCEF,320+ SourceEngine: dto.ClipboardSourceCEF,
320- ViewID: viewID,321+ ViewID: uint64(viewID),
321 }); err != nil {322 }); err != nil {
322 logging.FromContext(e.currentContext()).Debug().323 logging.FromContext(e.currentContext()).Debug().
323 Err(err).324 Err(err).
M internal/infrastructure/cef/engine_init.go +3 -0
@@ -95,6 +95,9 @@ func resolvedStateRoot(profile runtimeprofile.Profile, opts port.EngineOptions)
9595 if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {
9696 return root
9797 }
98+ if opts.CacheDir != "" {
99+ return opts.CacheDir
100+ }
98101 if opts.DataDir != "" {
99102 return opts.DataDir
100103 }
@@ -95,6 +95,9 @@ func resolvedStateRoot(profile runtimeprofile.Profile, opts port.EngineOptions)
95 if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {95 if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {
96 return root96 return root
97 }97 }
98+ if opts.CacheDir != "" {
99+ return opts.CacheDir
100+ }
98 if opts.DataDir != "" {101 if opts.DataDir != "" {
99 return opts.DataDir102 return opts.DataDir
100 }103 }
M internal/infrastructure/cef/engine_init_test.go +2 -2
@@ -12,10 +12,10 @@ import (
1212 "github.com/stretchr/testify/require"
1313 )
1414
15-func TestResolvedStateRoot_PrefersDataDir(t *testing.T) {
15+func TestResolvedStateRoot_PrefersCacheDir(t *testing.T) {
1616 profile := testCEFDevProfile(t)
1717 root := resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})
18- require.Equal(t, "/tmp/data", root)
18+ require.Equal(t, "/tmp/cache", root)
1919 }
2020
2121 func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) {
@@ -12,10 +12,10 @@ import (
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 )13 )
14 14
15-func TestResolvedStateRoot_PrefersDataDir(t *testing.T) {15+func TestResolvedStateRoot_PrefersCacheDir(t *testing.T) {
16 profile := testCEFDevProfile(t)16 profile := testCEFDevProfile(t)
17 root := resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})17 root := resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})
18- require.Equal(t, "/tmp/data", root)18+ require.Equal(t, "/tmp/cache", root)
19 }19 }
20 20
21 func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) {21 func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) {
M internal/infrastructure/cef/engine_runtime_test.go +9 -8
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "testing"
77
8+ "github.com/bnema/dumber/internal/application/dto"
89 "github.com/bnema/dumber/internal/application/port"
910 "github.com/stretchr/testify/require"
1011 )
@@ -76,7 +77,7 @@ func TestEngineClipboardSelectionUpdate_ForwardsViewID(t *testing.T) {
7677
7778 eng.handleClipboardSelectionUpdate(99, "selected")
7879
79- require.Equal(t, port.SelectionClipboardInput{Text: "selected", SourceEngine: port.ClipboardSourceCEF, ViewID: 99}, orchestrator.selection)
80+ require.Equal(t, dto.SelectionClipboardInput{Text: "selected", SourceEngine: dto.ClipboardSourceCEF, ViewID: 99}, orchestrator.selection)
8081 }
8182
8283 func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) {
@@ -85,30 +86,30 @@ func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) {
8586
8687 eng.handleExplicitClipboardBridgeText(77, "copy", "copied")
8788
88- require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: false}, orchestrator.explicit)
89+ require.Equal(t, dto.ExplicitClipboardInput{Text: "copied", SourceEngine: dto.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: false}, orchestrator.explicit)
8990 }
9091
9192 type stubClipboardTextOrchestrator struct{}
9293
9394 type recordingClipboardTextOrchestrator struct {
94- selection port.SelectionClipboardInput
95- explicit port.ExplicitClipboardInput
95+ selection dto.SelectionClipboardInput
96+ explicit dto.ExplicitClipboardInput
9697 }
9798
98-func (stubClipboardTextOrchestrator) HandleSelectionUpdate(context.Context, port.SelectionClipboardInput) error {
99+func (stubClipboardTextOrchestrator) HandleSelectionUpdate(context.Context, dto.SelectionClipboardInput) error {
99100 return nil
100101 }
101102
102-func (stubClipboardTextOrchestrator) HandleExplicitCopy(context.Context, port.ExplicitClipboardInput) error {
103+func (stubClipboardTextOrchestrator) HandleExplicitCopy(context.Context, dto.ExplicitClipboardInput) error {
103104 return nil
104105 }
105106
106-func (r *recordingClipboardTextOrchestrator) HandleSelectionUpdate(_ context.Context, input port.SelectionClipboardInput) error {
107+func (r *recordingClipboardTextOrchestrator) HandleSelectionUpdate(_ context.Context, input dto.SelectionClipboardInput) error {
107108 r.selection = input
108109 return nil
109110 }
110111
111-func (r *recordingClipboardTextOrchestrator) HandleExplicitCopy(_ context.Context, input port.ExplicitClipboardInput) error {
112+func (r *recordingClipboardTextOrchestrator) HandleExplicitCopy(_ context.Context, input dto.ExplicitClipboardInput) error {
112113 r.explicit = input
113114 return nil
114115 }
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "testing"6 "testing"
7 7
8+ "github.com/bnema/dumber/internal/application/dto"
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
10 )11 )
@@ -76,7 +77,7 @@ func TestEngineClipboardSelectionUpdate_ForwardsViewID(t *testing.T) {
76 77
77 eng.handleClipboardSelectionUpdate(99, "selected")78 eng.handleClipboardSelectionUpdate(99, "selected")
78 79
79- require.Equal(t, port.SelectionClipboardInput{Text: "selected", SourceEngine: port.ClipboardSourceCEF, ViewID: 99}, orchestrator.selection)80+ require.Equal(t, dto.SelectionClipboardInput{Text: "selected", SourceEngine: dto.ClipboardSourceCEF, ViewID: 99}, orchestrator.selection)
80 }81 }
81 82
82 func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) {83 func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) {
@@ -85,30 +86,30 @@ func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) {
85 86
86 eng.handleExplicitClipboardBridgeText(77, "copy", "copied")87 eng.handleExplicitClipboardBridgeText(77, "copy", "copied")
87 88
88- require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: false}, orchestrator.explicit)89+ require.Equal(t, dto.ExplicitClipboardInput{Text: "copied", SourceEngine: dto.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: false}, orchestrator.explicit)
89 }90 }
90 91
91 type stubClipboardTextOrchestrator struct{}92 type stubClipboardTextOrchestrator struct{}
92 93
93 type recordingClipboardTextOrchestrator struct {94 type recordingClipboardTextOrchestrator struct {
94- selection port.SelectionClipboardInput95+ selection dto.SelectionClipboardInput
95- explicit port.ExplicitClipboardInput96+ explicit dto.ExplicitClipboardInput
96 }97 }
97 98
98-func (stubClipboardTextOrchestrator) HandleSelectionUpdate(context.Context, port.SelectionClipboardInput) error {99+func (stubClipboardTextOrchestrator) HandleSelectionUpdate(context.Context, dto.SelectionClipboardInput) error {
99 return nil100 return nil
100 }101 }
101 102
102-func (stubClipboardTextOrchestrator) HandleExplicitCopy(context.Context, port.ExplicitClipboardInput) error {103+func (stubClipboardTextOrchestrator) HandleExplicitCopy(context.Context, dto.ExplicitClipboardInput) error {
103 return nil104 return nil
104 }105 }
105 106
106-func (r *recordingClipboardTextOrchestrator) HandleSelectionUpdate(_ context.Context, input port.SelectionClipboardInput) error {107+func (r *recordingClipboardTextOrchestrator) HandleSelectionUpdate(_ context.Context, input dto.SelectionClipboardInput) error {
107 r.selection = input108 r.selection = input
108 return nil109 return nil
109 }110 }
110 111
111-func (r *recordingClipboardTextOrchestrator) HandleExplicitCopy(_ context.Context, input port.ExplicitClipboardInput) error {112+func (r *recordingClipboardTextOrchestrator) HandleExplicitCopy(_ context.Context, input dto.ExplicitClipboardInput) error {
112 r.explicit = input113 r.explicit = input
113 return nil114 return nil
114 }115 }
M internal/infrastructure/cef/renderer_bridge.go +1 -1
@@ -80,7 +80,7 @@ const rendererBridgeExtensionJS = `
8080 }
8181 setTimeout(function() {
8282 var text = capturedText;
83- if (!text) text = getSelectedText();
83+ if (!text && !e.defaultPrevented) text = getSelectedText();
8484 if (text) notifyExplicitTextCopy(action, text);
8585 }, 0);
8686 }
@@ -80,7 +80,7 @@ const rendererBridgeExtensionJS = `
80 }80 }
81 setTimeout(function() {81 setTimeout(function() {
82 var text = capturedText;82 var text = capturedText;
83- if (!text) text = getSelectedText();83+ if (!text && !e.defaultPrevented) text = getSelectedText();
84 if (text) notifyExplicitTextCopy(action, text);84 if (text) notifyExplicitTextCopy(action, text);
85 }, 0);85 }, 0);
86 }86 }
M internal/infrastructure/cef/renderer_bridge_test.go +1 -0
@@ -100,6 +100,7 @@ func TestRendererBridgeExtensionJS_UsesNativeDispatchInExtensionScope(t *testing
100100 func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T) {
101101 require.Contains(t, rendererBridgeExtensionJS, "if (!e.isTrusted) return;")
102102 require.Contains(t, rendererBridgeExtensionJS, "setTimeout(function() {")
103+ require.Contains(t, rendererBridgeExtensionJS, "if (!text && !e.defaultPrevented) text = getSelectedText();")
103104 require.NotContains(t, rendererBridgeExtensionJS, "document.execCommand = function")
104105 require.Contains(t, rendererBridgeExtensionJS, "return result;")
105106 }
@@ -100,6 +100,7 @@ func TestRendererBridgeExtensionJS_UsesNativeDispatchInExtensionScope(t *testing
100 func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T) {100 func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T) {
101 require.Contains(t, rendererBridgeExtensionJS, "if (!e.isTrusted) return;")101 require.Contains(t, rendererBridgeExtensionJS, "if (!e.isTrusted) return;")
102 require.Contains(t, rendererBridgeExtensionJS, "setTimeout(function() {")102 require.Contains(t, rendererBridgeExtensionJS, "setTimeout(function() {")
103+ require.Contains(t, rendererBridgeExtensionJS, "if (!text && !e.defaultPrevented) text = getSelectedText();")
103 require.NotContains(t, rendererBridgeExtensionJS, "document.execCommand = function")104 require.NotContains(t, rendererBridgeExtensionJS, "document.execCommand = function")
104 require.Contains(t, rendererBridgeExtensionJS, "return result;")105 require.Contains(t, rendererBridgeExtensionJS, "return result;")
105 }106 }
M internal/infrastructure/cef/session_spawn_environment.go +4 -5
@@ -5,17 +5,16 @@ import (
55
66 "github.com/bnema/dumber/internal/application/port"
77 "github.com/bnema/dumber/internal/domain/entity"
8- "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
98 )
109
1110 // SessionSpawnEnvironment exposes the engine-specific launch environment needed
1211 // when restoring a session into a freshly spawned browser process.
1312 type SessionSpawnEnvironment struct {
14- profile runtimeprofile.Profile
13+ cefUserDataDir string
1514 }
1615
17-func NewSessionSpawnEnvironment(profile runtimeprofile.Profile) SessionSpawnEnvironment {
18- return SessionSpawnEnvironment{profile: profile}
16+func NewSessionSpawnEnvironment(cefUserDataDir string) SessionSpawnEnvironment {
17+ return SessionSpawnEnvironment{cefUserDataDir: cefUserDataDir}
1918 }
2019
2120 func (SessionSpawnEnvironment) RootCacheEnvVar() string {
@@ -23,7 +22,7 @@ func (SessionSpawnEnvironment) RootCacheEnvVar() string {
2322 }
2423
2524 func (e SessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string {
26- return filepath.Join(e.profile.CEFUserDataDir(), "sessions", string(sessionID))
25+ return filepath.Join(e.cefUserDataDir, "sessions", string(sessionID))
2726 }
2827
2928 var _ port.SessionSpawnEnvironment = SessionSpawnEnvironment{}
@@ -5,17 +5,16 @@ import (
5 5
6 "github.com/bnema/dumber/internal/application/port"6 "github.com/bnema/dumber/internal/application/port"
7 "github.com/bnema/dumber/internal/domain/entity"7 "github.com/bnema/dumber/internal/domain/entity"
8- "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
9 )8 )
10 9
11 // SessionSpawnEnvironment exposes the engine-specific launch environment needed10 // SessionSpawnEnvironment exposes the engine-specific launch environment needed
12 // when restoring a session into a freshly spawned browser process.11 // when restoring a session into a freshly spawned browser process.
13 type SessionSpawnEnvironment struct {12 type SessionSpawnEnvironment struct {
14- profile runtimeprofile.Profile13+ cefUserDataDir string
15 }14 }
16 15
17-func NewSessionSpawnEnvironment(profile runtimeprofile.Profile) SessionSpawnEnvironment {16+func NewSessionSpawnEnvironment(cefUserDataDir string) SessionSpawnEnvironment {
18- return SessionSpawnEnvironment{profile: profile}17+ return SessionSpawnEnvironment{cefUserDataDir: cefUserDataDir}
19 }18 }
20 19
21 func (SessionSpawnEnvironment) RootCacheEnvVar() string {20 func (SessionSpawnEnvironment) RootCacheEnvVar() string {
@@ -23,7 +22,7 @@ func (SessionSpawnEnvironment) RootCacheEnvVar() string {
23 }22 }
24 23
25 func (e SessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string {24 func (e SessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string {
26- return filepath.Join(e.profile.CEFUserDataDir(), "sessions", string(sessionID))25+ return filepath.Join(e.cefUserDataDir, "sessions", string(sessionID))
27 }26 }
28 27
29 var _ port.SessionSpawnEnvironment = SessionSpawnEnvironment{}28 var _ port.SessionSpawnEnvironment = SessionSpawnEnvironment{}
M internal/infrastructure/cef/webview.go +53 -22
@@ -34,10 +34,14 @@ var errNoBrowser = errors.New("cef: browser not yet created")
3434
3535 const (
3636 clipboardSelectionDebounceDelay = 300 * time.Millisecond
37- pendingNavigationRetryDelay = 25 * time.Millisecond
38- pendingNavigationMaxRetries = 20
37+ pendingNavigationRetryDelay = 50 * time.Millisecond
38+ pendingNavigationMaxRetries = 80
3939 )
4040
41+type stoppableTimer interface {
42+ Stop() bool
43+}
44+
4145 var cefLoadWatchdogDelays = []time.Duration{
4246 250 * time.Millisecond,
4347 1 * time.Second,
@@ -89,16 +93,18 @@ type WebView struct {
8993 callbacks *port.WebViewCallbacks
9094
9195 // State cache (mutex-protected).
92- uri string
93- title string
94- progress float64
95- canGoBack bool
96- canGoFwd bool
97- isLoading bool
98- selectedText string
99- focusedEditable bool
100- selectionDebounceTimer *time.Timer
101- selectionDebounceSeq uint64
96+ uri string
97+ title string
98+ progress float64
99+ canGoBack bool
100+ canGoFwd bool
101+ isLoading bool
102+ selectedText string
103+ focusedEditable bool
104+ selectionDebounceTimer stoppableTimer
105+ selectionDebounceSeq uint64
106+ selectionDebounceDelay *time.Duration
107+ selectionDebounceSchedule func(time.Duration, func()) stoppableTimer
102108
103109 // Last known hover URI for middle-click → new tab.
104110 lastHoverURI string
@@ -169,7 +175,7 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error {
169175 if browser == nil {
170176 return nil
171177 }
172- wv.schedulePendingNavigationReplay(browser, 0)
178+ wv.schedulePendingNavigationReplay(0)
173179 return nil
174180 }
175181
@@ -677,6 +683,22 @@ func (wv *WebView) cancelSelectionDebounce() {
677683 }
678684 }
679685
686+func (wv *WebView) selectionDebounceInterval() time.Duration {
687+ if wv == nil || wv.selectionDebounceDelay == nil {
688+ return clipboardSelectionDebounceDelay
689+ }
690+ return *wv.selectionDebounceDelay
691+}
692+
693+func (wv *WebView) selectionDebounceScheduler() func(time.Duration, func()) stoppableTimer {
694+ if wv != nil && wv.selectionDebounceSchedule != nil {
695+ return wv.selectionDebounceSchedule
696+ }
697+ return func(delay time.Duration, fn func()) stoppableTimer {
698+ return time.AfterFunc(delay, fn)
699+ }
700+}
701+
680702 func (wv *WebView) scheduleSelectionUpdate(text string) {
681703 if wv == nil || wv.destroyed.Load() {
682704 return
@@ -700,7 +722,15 @@ func (wv *WebView) scheduleSelectionUpdate(text string) {
700722 if timer := wv.selectionDebounceTimer; timer != nil {
701723 timer.Stop()
702724 }
703- wv.selectionDebounceTimer = time.AfterFunc(clipboardSelectionDebounceDelay, func() {
725+ delay := wv.selectionDebounceInterval()
726+ if delay <= 0 {
727+ wv.selectionDebounceTimer = nil
728+ wv.mu.Unlock()
729+ wv.flushSelectionUpdate(seq, text)
730+ return
731+ }
732+ scheduler := wv.selectionDebounceScheduler()
733+ wv.selectionDebounceTimer = scheduler(delay, func() {
704734 wv.flushSelectionUpdate(seq, text)
705735 })
706736 wv.mu.Unlock()
@@ -812,12 +842,12 @@ func (wv *WebView) pendingNavigationURI() string {
812842 return wv.pendingURI
813843 }
814844
815-func (wv *WebView) schedulePendingNavigationReplay(browser purecef.Browser, attempt int) {
816- if wv == nil || browser == nil || wv.destroyed.Load() {
845+func (wv *WebView) schedulePendingNavigationReplay(attempt int) {
846+ if wv == nil || wv.destroyed.Load() {
817847 return
818848 }
819849 task := cefNewTask(cefTaskFunc(func() {
820- wv.replayPendingNavigation(browser, attempt)
850+ wv.replayPendingNavigation(attempt)
821851 }))
822852 if task == nil {
823853 return
@@ -845,18 +875,19 @@ func (wv *WebView) schedulePendingNavigationReplay(browser purecef.Browser, atte
845875 return
846876 }
847877 cefScheduleAfter(pendingNavigationRetryDelay, func() {
848- wv.schedulePendingNavigationReplay(browser, attempt+1)
878+ wv.schedulePendingNavigationReplay(attempt + 1)
849879 })
850880 }
851881
852-func (wv *WebView) replayPendingNavigation(browser purecef.Browser, attempt int) {
853- if wv == nil || browser == nil || wv.destroyed.Load() {
882+func (wv *WebView) replayPendingNavigation(attempt int) {
883+ if wv == nil || wv.destroyed.Load() {
854884 return
855885 }
856886 wv.mu.RLock()
857887 uri := wv.pendingURI
888+ browser := wv.browser
858889 wv.mu.RUnlock()
859- if uri == "" {
890+ if uri == "" || browser == nil {
860891 return
861892 }
862893 frame := browser.GetMainFrame()
@@ -876,7 +907,7 @@ func (wv *WebView) replayPendingNavigation(browser purecef.Browser, attempt int)
876907 Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
877908 Msg("cef: pending navigation replay waiting for main frame")
878909 }
879- wv.schedulePendingNavigationReplay(browser, attempt+1)
910+ wv.schedulePendingNavigationReplay(attempt + 1)
880911 return
881912 }
882913 currentURL := frame.GetURL()
@@ -34,10 +34,14 @@ var errNoBrowser = errors.New("cef: browser not yet created")
34 34
35 const (35 const (
36 clipboardSelectionDebounceDelay = 300 * time.Millisecond36 clipboardSelectionDebounceDelay = 300 * time.Millisecond
37- pendingNavigationRetryDelay = 25 * time.Millisecond37+ pendingNavigationRetryDelay = 50 * time.Millisecond
38- pendingNavigationMaxRetries = 2038+ pendingNavigationMaxRetries = 80
39 )39 )
40 40
41+type stoppableTimer interface {
42+ Stop() bool
43+}
44+
41 var cefLoadWatchdogDelays = []time.Duration{45 var cefLoadWatchdogDelays = []time.Duration{
42 250 * time.Millisecond,46 250 * time.Millisecond,
43 1 * time.Second,47 1 * time.Second,
@@ -89,16 +93,18 @@ type WebView struct {
89 callbacks *port.WebViewCallbacks93 callbacks *port.WebViewCallbacks
90 94
91 // State cache (mutex-protected).95 // State cache (mutex-protected).
92- uri string96+ uri string
93- title string97+ title string
94- progress float6498+ progress float64
95- canGoBack bool99+ canGoBack bool
96- canGoFwd bool100+ canGoFwd bool
97- isLoading bool101+ isLoading bool
98- selectedText string102+ selectedText string
99- focusedEditable bool103+ focusedEditable bool
100- selectionDebounceTimer *time.Timer104+ selectionDebounceTimer stoppableTimer
101- selectionDebounceSeq uint64105+ selectionDebounceSeq uint64
106+ selectionDebounceDelay *time.Duration
107+ selectionDebounceSchedule func(time.Duration, func()) stoppableTimer
102 108
103 // Last known hover URI for middle-click → new tab.109 // Last known hover URI for middle-click → new tab.
104 lastHoverURI string110 lastHoverURI string
@@ -169,7 +175,7 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error {
169 if browser == nil {175 if browser == nil {
170 return nil176 return nil
171 }177 }
172- wv.schedulePendingNavigationReplay(browser, 0)178+ wv.schedulePendingNavigationReplay(0)
173 return nil179 return nil
174 }180 }
175 181
@@ -677,6 +683,22 @@ func (wv *WebView) cancelSelectionDebounce() {
677 }683 }
678 }684 }
679 685
686+func (wv *WebView) selectionDebounceInterval() time.Duration {
687+ if wv == nil || wv.selectionDebounceDelay == nil {
688+ return clipboardSelectionDebounceDelay
689+ }
690+ return *wv.selectionDebounceDelay
691+}
692+
693+func (wv *WebView) selectionDebounceScheduler() func(time.Duration, func()) stoppableTimer {
694+ if wv != nil && wv.selectionDebounceSchedule != nil {
695+ return wv.selectionDebounceSchedule
696+ }
697+ return func(delay time.Duration, fn func()) stoppableTimer {
698+ return time.AfterFunc(delay, fn)
699+ }
700+}
701+
680 func (wv *WebView) scheduleSelectionUpdate(text string) {702 func (wv *WebView) scheduleSelectionUpdate(text string) {
681 if wv == nil || wv.destroyed.Load() {703 if wv == nil || wv.destroyed.Load() {
682 return704 return
@@ -700,7 +722,15 @@ func (wv *WebView) scheduleSelectionUpdate(text string) {
700 if timer := wv.selectionDebounceTimer; timer != nil {722 if timer := wv.selectionDebounceTimer; timer != nil {
701 timer.Stop()723 timer.Stop()
702 }724 }
703- wv.selectionDebounceTimer = time.AfterFunc(clipboardSelectionDebounceDelay, func() {725+ delay := wv.selectionDebounceInterval()
726+ if delay <= 0 {
727+ wv.selectionDebounceTimer = nil
728+ wv.mu.Unlock()
729+ wv.flushSelectionUpdate(seq, text)
730+ return
731+ }
732+ scheduler := wv.selectionDebounceScheduler()
733+ wv.selectionDebounceTimer = scheduler(delay, func() {
704 wv.flushSelectionUpdate(seq, text)734 wv.flushSelectionUpdate(seq, text)
705 })735 })
706 wv.mu.Unlock()736 wv.mu.Unlock()
@@ -812,12 +842,12 @@ func (wv *WebView) pendingNavigationURI() string {
812 return wv.pendingURI842 return wv.pendingURI
813 }843 }
814 844
815-func (wv *WebView) schedulePendingNavigationReplay(browser purecef.Browser, attempt int) {845+func (wv *WebView) schedulePendingNavigationReplay(attempt int) {
816- if wv == nil || browser == nil || wv.destroyed.Load() {846+ if wv == nil || wv.destroyed.Load() {
817 return847 return
818 }848 }
819 task := cefNewTask(cefTaskFunc(func() {849 task := cefNewTask(cefTaskFunc(func() {
820- wv.replayPendingNavigation(browser, attempt)850+ wv.replayPendingNavigation(attempt)
821 }))851 }))
822 if task == nil {852 if task == nil {
823 return853 return
@@ -845,18 +875,19 @@ func (wv *WebView) schedulePendingNavigationReplay(browser purecef.Browser, atte
845 return875 return
846 }876 }
847 cefScheduleAfter(pendingNavigationRetryDelay, func() {877 cefScheduleAfter(pendingNavigationRetryDelay, func() {
848- wv.schedulePendingNavigationReplay(browser, attempt+1)878+ wv.schedulePendingNavigationReplay(attempt + 1)
849 })879 })
850 }880 }
851 881
852-func (wv *WebView) replayPendingNavigation(browser purecef.Browser, attempt int) {882+func (wv *WebView) replayPendingNavigation(attempt int) {
853- if wv == nil || browser == nil || wv.destroyed.Load() {883+ if wv == nil || wv.destroyed.Load() {
854 return884 return
855 }885 }
856 wv.mu.RLock()886 wv.mu.RLock()
857 uri := wv.pendingURI887 uri := wv.pendingURI
888+ browser := wv.browser
858 wv.mu.RUnlock()889 wv.mu.RUnlock()
859- if uri == "" {890+ if uri == "" || browser == nil {
860 return891 return
861 }892 }
862 frame := browser.GetMainFrame()893 frame := browser.GetMainFrame()
@@ -876,7 +907,7 @@ func (wv *WebView) replayPendingNavigation(browser purecef.Browser, attempt int)
876 Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).907 Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
877 Msg("cef: pending navigation replay waiting for main frame")908 Msg("cef: pending navigation replay waiting for main frame")
878 }909 }
879- wv.schedulePendingNavigationReplay(browser, attempt+1)910+ wv.schedulePendingNavigationReplay(attempt + 1)
880 return911 return
881 }912 }
882 currentURL := frame.GetURL()913 currentURL := frame.GetURL()
M internal/infrastructure/cef/webview_handlers.go +8 -6
@@ -376,16 +376,16 @@ func (h *handlerSet) OnTextSelectionChanged(_ purecef.Browser, selectedText stri
376376 return
377377 }
378378 previous, changed := h.wv.setSelectedText(selectedText)
379- if !changed || h.wv.ctx == nil {
379+ if !changed {
380380 return
381381 }
382- if selectedText == "" {
382+ if h.wv.ctx != nil && selectedText == "" {
383383 if previous != "" {
384384 logging.FromContext(h.wv.ctx).Debug().
385385 Int("prev_text_len", len(previous)).
386386 Msg("cef: text selection cleared")
387387 }
388- } else {
388+ } else if h.wv.ctx != nil {
389389 logging.FromContext(h.wv.ctx).Debug().
390390 Int("text_len", len(selectedText)).
391391 Msg("cef: text selection changed")
@@ -547,7 +547,9 @@ func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangobac
547547 h.wv.mu.RLock()
548548 host := h.wv.host
549549 h.wv.mu.RUnlock()
550- syncWindowlessBrowserFocus(host)
550+ if host != nil {
551+ syncWindowlessBrowserFocus(host)
552+ }
551553 }
552554
553555 h.wv.mu.RLock()
@@ -611,7 +613,7 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
611613 log.Debug().
612614 Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
613615 Msg("cef: replaying pending navigation after about:blank load end")
614- h.wv.schedulePendingNavigationReplay(h.wv.browser, 0)
616+ h.wv.schedulePendingNavigationReplay(0)
615617 }
616618 }
617619
@@ -738,7 +740,7 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
738740 host.Invalidate(purecef.PaintElementTypePetView)
739741 }
740742 if hasPendingNavigation {
741- h.wv.schedulePendingNavigationReplay(browser, 0)
743+ h.wv.schedulePendingNavigationReplay(0)
742744 }
743745
744746 h.wv.scheduleStartBeginFrameLoop()
@@ -376,16 +376,16 @@ func (h *handlerSet) OnTextSelectionChanged(_ purecef.Browser, selectedText stri
376 return376 return
377 }377 }
378 previous, changed := h.wv.setSelectedText(selectedText)378 previous, changed := h.wv.setSelectedText(selectedText)
379- if !changed || h.wv.ctx == nil {379+ if !changed {
380 return380 return
381 }381 }
382- if selectedText == "" {382+ if h.wv.ctx != nil && selectedText == "" {
383 if previous != "" {383 if previous != "" {
384 logging.FromContext(h.wv.ctx).Debug().384 logging.FromContext(h.wv.ctx).Debug().
385 Int("prev_text_len", len(previous)).385 Int("prev_text_len", len(previous)).
386 Msg("cef: text selection cleared")386 Msg("cef: text selection cleared")
387 }387 }
388- } else {388+ } else if h.wv.ctx != nil {
389 logging.FromContext(h.wv.ctx).Debug().389 logging.FromContext(h.wv.ctx).Debug().
390 Int("text_len", len(selectedText)).390 Int("text_len", len(selectedText)).
391 Msg("cef: text selection changed")391 Msg("cef: text selection changed")
@@ -547,7 +547,9 @@ func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangobac
547 h.wv.mu.RLock()547 h.wv.mu.RLock()
548 host := h.wv.host548 host := h.wv.host
549 h.wv.mu.RUnlock()549 h.wv.mu.RUnlock()
550- syncWindowlessBrowserFocus(host)550+ if host != nil {
551+ syncWindowlessBrowserFocus(host)
552+ }
551 }553 }
552 554
553 h.wv.mu.RLock()555 h.wv.mu.RLock()
@@ -611,7 +613,7 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
611 log.Debug().613 log.Debug().
612 Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).614 Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
613 Msg("cef: replaying pending navigation after about:blank load end")615 Msg("cef: replaying pending navigation after about:blank load end")
614- h.wv.schedulePendingNavigationReplay(h.wv.browser, 0)616+ h.wv.schedulePendingNavigationReplay(0)
615 }617 }
616 }618 }
617 619
@@ -738,7 +740,7 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
738 host.Invalidate(purecef.PaintElementTypePetView)740 host.Invalidate(purecef.PaintElementTypePetView)
739 }741 }
740 if hasPendingNavigation {742 if hasPendingNavigation {
741- h.wv.schedulePendingNavigationReplay(browser, 0)743+ h.wv.schedulePendingNavigationReplay(0)
742 }744 }
743 745
744 h.wv.scheduleStartBeginFrameLoop()746 h.wv.scheduleStartBeginFrameLoop()
M internal/infrastructure/cef/webview_handlers_test.go +80 -40
@@ -12,16 +12,48 @@ import (
1212 "github.com/stretchr/testify/mock"
1313 "github.com/stretchr/testify/require"
1414
15+ "github.com/bnema/dumber/internal/application/dto"
1516 "github.com/bnema/dumber/internal/application/port"
1617 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
1718 )
1819
1920 type clipboardOrchestratorRecorder struct {
2021 mu sync.Mutex
21- selection port.SelectionClipboardInput
22+ selection dto.SelectionClipboardInput
2223 selectionCalls int
2324 }
2425
26+type controlledSelectionTimer struct {
27+ stopped bool
28+}
29+
30+func (t *controlledSelectionTimer) Stop() bool {
31+ alreadyStopped := t.stopped
32+ t.stopped = true
33+ return !alreadyStopped
34+}
35+
36+type controlledSelectionScheduler struct {
37+ timers []*controlledSelectionTimer
38+ callbacks []func()
39+}
40+
41+func (s *controlledSelectionScheduler) schedule(_ time.Duration, fn func()) stoppableTimer {
42+ timer := &controlledSelectionTimer{}
43+ s.timers = append(s.timers, timer)
44+ s.callbacks = append(s.callbacks, fn)
45+ return timer
46+}
47+
48+func (s *controlledSelectionScheduler) fire(index int) {
49+ if index < 0 || index >= len(s.callbacks) {
50+ return
51+ }
52+ if !s.timers[index].stopped {
53+ s.callbacks[index]()
54+ }
55+}
56+
2557 func newTestPipeline(w, h, s int32) *renderPipeline {
2658 rp := &renderPipeline{scale: s}
2759 rp.widthAtomic.Store(w)
@@ -103,15 +135,17 @@ func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) {
103135 func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) {
104136 recorder := &clipboardOrchestratorRecorder{}
105137 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
106- orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input port.SelectionClipboardInput) {
138+ orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) {
107139 recorder.mu.Lock()
108140 defer recorder.mu.Unlock()
109141 recorder.selection = input
110142 recorder.selectionCalls++
111143 }).Return(nil).Once()
144+ zeroDelay := time.Duration(0)
112145 wv := &WebView{
113- ctx: context.Background(),
114- id: 42,
146+ ctx: context.Background(),
147+ id: 42,
148+ selectionDebounceDelay: &zeroDelay,
115149 engine: &Engine{
116150 clipboardTextOrchestrator: orchestrator,
117151 },
@@ -120,28 +154,28 @@ func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *test
120154
121155 h.OnTextSelectionChanged(nil, "selected text", nil)
122156
123- require.Eventually(t, func() bool {
124- recorder.mu.Lock()
125- defer recorder.mu.Unlock()
126- return recorder.selectionCalls == 1 &&
127- recorder.selection.Text == "selected text" &&
128- recorder.selection.SourceEngine == port.ClipboardSourceCEF &&
129- recorder.selection.ViewID == port.WebViewID(42)
130- }, 2*time.Second, 10*time.Millisecond)
157+ recorder.mu.Lock()
158+ defer recorder.mu.Unlock()
159+ require.Equal(t, 1, recorder.selectionCalls)
160+ require.Equal(t, "selected text", recorder.selection.Text)
161+ require.Equal(t, dto.ClipboardSourceCEF, recorder.selection.SourceEngine)
162+ require.Equal(t, uint64(42), recorder.selection.ViewID)
131163 }
132164
133165 func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T) {
134166 recorder := &clipboardOrchestratorRecorder{}
167+ scheduler := &controlledSelectionScheduler{}
135168 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
136- orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input port.SelectionClipboardInput) {
169+ orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) {
137170 recorder.mu.Lock()
138171 defer recorder.mu.Unlock()
139172 recorder.selection = input
140173 recorder.selectionCalls++
141174 }).Return(nil).Once()
142175 wv := &WebView{
143- ctx: context.Background(),
144- id: 42,
176+ ctx: context.Background(),
177+ id: 42,
178+ selectionDebounceSchedule: scheduler.schedule,
145179 engine: &Engine{
146180 clipboardTextOrchestrator: orchestrator,
147181 },
@@ -155,27 +189,36 @@ func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T)
155189 gotCalls := recorder.selectionCalls
156190 recorder.mu.Unlock()
157191 require.Zero(t, gotCalls)
192+ require.Len(t, scheduler.callbacks, 2)
158193
159- require.Eventually(t, func() bool {
160- recorder.mu.Lock()
161- defer recorder.mu.Unlock()
162- return recorder.selectionCalls == 1 && recorder.selection.Text == "second selection"
163- }, 2*time.Second, 10*time.Millisecond)
194+ scheduler.fire(0)
195+ recorder.mu.Lock()
196+ gotCalls = recorder.selectionCalls
197+ recorder.mu.Unlock()
198+ require.Zero(t, gotCalls)
199+
200+ scheduler.fire(1)
201+ recorder.mu.Lock()
202+ defer recorder.mu.Unlock()
203+ require.Equal(t, 1, recorder.selectionCalls)
204+ require.Equal(t, "second selection", recorder.selection.Text)
164205 require.Equal(t, "second selection", wv.selectedTextSnapshot())
165206 }
166207
167208 func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResumesWhenCleared(t *testing.T) {
168209 recorder := &clipboardOrchestratorRecorder{}
210+ scheduler := &controlledSelectionScheduler{}
169211 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
170- orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input port.SelectionClipboardInput) {
212+ orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) {
171213 recorder.mu.Lock()
172214 defer recorder.mu.Unlock()
173215 recorder.selection = input
174216 recorder.selectionCalls++
175217 }).Return(nil).Once()
176218 wv := &WebView{
177- ctx: context.Background(),
178- id: 42,
219+ ctx: context.Background(),
220+ id: 42,
221+ selectionDebounceSchedule: scheduler.schedule,
179222 engine: &Engine{
180223 clipboardTextOrchestrator: orchestrator,
181224 },
@@ -204,12 +247,7 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu
204247 gotCalls := recorder.selectionCalls
205248 recorder.mu.Unlock()
206249 require.Zero(t, gotCalls)
207- start := time.Now()
208- require.Eventually(t, func() bool {
209- recorder.mu.Lock()
210- defer recorder.mu.Unlock()
211- return recorder.selectionCalls == 0 && time.Since(start) >= clipboardSelectionDebounceDelay+50*time.Millisecond
212- }, 2*time.Second, 10*time.Millisecond)
250+ require.Empty(t, scheduler.callbacks)
213251
214252 newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage {
215253 return newTestBridgeProcessMessage(name, false)
@@ -229,18 +267,22 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu
229267 gotCalls = recorder.selectionCalls
230268 recorder.mu.Unlock()
231269 require.Zero(t, gotCalls)
232- require.Eventually(t, func() bool {
233- recorder.mu.Lock()
234- defer recorder.mu.Unlock()
235- return recorder.selectionCalls == 1 && recorder.selection.Text == "free selection"
236- }, 2*time.Second, 10*time.Millisecond)
270+ require.Len(t, scheduler.callbacks, 1)
271+
272+ scheduler.fire(0)
273+ recorder.mu.Lock()
274+ defer recorder.mu.Unlock()
275+ require.Equal(t, 1, recorder.selectionCalls)
276+ require.Equal(t, "free selection", recorder.selection.Text)
237277 }
238278
239279 func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *testing.T) {
280+ scheduler := &controlledSelectionScheduler{}
240281 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
241282 wv := &WebView{
242- ctx: context.Background(),
243- id: 42,
283+ ctx: context.Background(),
284+ id: 42,
285+ selectionDebounceSchedule: scheduler.schedule,
244286 engine: &Engine{
245287 clipboardTextOrchestrator: orchestrator,
246288 },
@@ -248,12 +290,10 @@ func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *te
248290 h := &handlerSet{wv: wv}
249291
250292 h.OnTextSelectionChanged(nil, "selected text", nil)
293+ require.Len(t, scheduler.callbacks, 1)
251294 wv.Destroy()
295+ scheduler.fire(0)
252296
253- start := time.Now()
254- require.Eventually(t, func() bool {
255- return time.Since(start) >= clipboardSelectionDebounceDelay+50*time.Millisecond
256- }, 2*time.Second, 10*time.Millisecond)
257297 orchestrator.AssertNotCalled(t, "HandleSelectionUpdate", mock.Anything, mock.Anything)
258298 require.Equal(t, "selected text", wv.selectedTextSnapshot())
259299 }
@@ -12,16 +12,48 @@ import (
12 "github.com/stretchr/testify/mock"12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14 14
15+ "github.com/bnema/dumber/internal/application/dto"
15 "github.com/bnema/dumber/internal/application/port"16 "github.com/bnema/dumber/internal/application/port"
16 portmocks "github.com/bnema/dumber/internal/application/port/mocks"17 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
17 )18 )
18 19
19 type clipboardOrchestratorRecorder struct {20 type clipboardOrchestratorRecorder struct {
20 mu sync.Mutex21 mu sync.Mutex
21- selection port.SelectionClipboardInput22+ selection dto.SelectionClipboardInput
22 selectionCalls int23 selectionCalls int
23 }24 }
24 25
26+type controlledSelectionTimer struct {
27+ stopped bool
28+}
29+
30+func (t *controlledSelectionTimer) Stop() bool {
31+ alreadyStopped := t.stopped
32+ t.stopped = true
33+ return !alreadyStopped
34+}
35+
36+type controlledSelectionScheduler struct {
37+ timers []*controlledSelectionTimer
38+ callbacks []func()
39+}
40+
41+func (s *controlledSelectionScheduler) schedule(_ time.Duration, fn func()) stoppableTimer {
42+ timer := &controlledSelectionTimer{}
43+ s.timers = append(s.timers, timer)
44+ s.callbacks = append(s.callbacks, fn)
45+ return timer
46+}
47+
48+func (s *controlledSelectionScheduler) fire(index int) {
49+ if index < 0 || index >= len(s.callbacks) {
50+ return
51+ }
52+ if !s.timers[index].stopped {
53+ s.callbacks[index]()
54+ }
55+}
56+
25 func newTestPipeline(w, h, s int32) *renderPipeline {57 func newTestPipeline(w, h, s int32) *renderPipeline {
26 rp := &renderPipeline{scale: s}58 rp := &renderPipeline{scale: s}
27 rp.widthAtomic.Store(w)59 rp.widthAtomic.Store(w)
@@ -103,15 +135,17 @@ func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) {
103 func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) {135 func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) {
104 recorder := &clipboardOrchestratorRecorder{}136 recorder := &clipboardOrchestratorRecorder{}
105 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)137 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
106- orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input port.SelectionClipboardInput) {138+ orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) {
107 recorder.mu.Lock()139 recorder.mu.Lock()
108 defer recorder.mu.Unlock()140 defer recorder.mu.Unlock()
109 recorder.selection = input141 recorder.selection = input
110 recorder.selectionCalls++142 recorder.selectionCalls++
111 }).Return(nil).Once()143 }).Return(nil).Once()
144+ zeroDelay := time.Duration(0)
112 wv := &WebView{145 wv := &WebView{
113- ctx: context.Background(),146+ ctx: context.Background(),
114- id: 42,147+ id: 42,
148+ selectionDebounceDelay: &zeroDelay,
115 engine: &Engine{149 engine: &Engine{
116 clipboardTextOrchestrator: orchestrator,150 clipboardTextOrchestrator: orchestrator,
117 },151 },
@@ -120,28 +154,28 @@ func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *test
120 154
121 h.OnTextSelectionChanged(nil, "selected text", nil)155 h.OnTextSelectionChanged(nil, "selected text", nil)
122 156
123- require.Eventually(t, func() bool {157+ recorder.mu.Lock()
124- recorder.mu.Lock()158+ defer recorder.mu.Unlock()
125- defer recorder.mu.Unlock()159+ require.Equal(t, 1, recorder.selectionCalls)
126- return recorder.selectionCalls == 1 &&160+ require.Equal(t, "selected text", recorder.selection.Text)
127- recorder.selection.Text == "selected text" &&161+ require.Equal(t, dto.ClipboardSourceCEF, recorder.selection.SourceEngine)
128- recorder.selection.SourceEngine == port.ClipboardSourceCEF &&162+ require.Equal(t, uint64(42), recorder.selection.ViewID)
129- recorder.selection.ViewID == port.WebViewID(42)
130- }, 2*time.Second, 10*time.Millisecond)
131 }163 }
132 164
133 func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T) {165 func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T) {
134 recorder := &clipboardOrchestratorRecorder{}166 recorder := &clipboardOrchestratorRecorder{}
167+ scheduler := &controlledSelectionScheduler{}
135 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)168 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
136- orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input port.SelectionClipboardInput) {169+ orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) {
137 recorder.mu.Lock()170 recorder.mu.Lock()
138 defer recorder.mu.Unlock()171 defer recorder.mu.Unlock()
139 recorder.selection = input172 recorder.selection = input
140 recorder.selectionCalls++173 recorder.selectionCalls++
141 }).Return(nil).Once()174 }).Return(nil).Once()
142 wv := &WebView{175 wv := &WebView{
143- ctx: context.Background(),176+ ctx: context.Background(),
144- id: 42,177+ id: 42,
178+ selectionDebounceSchedule: scheduler.schedule,
145 engine: &Engine{179 engine: &Engine{
146 clipboardTextOrchestrator: orchestrator,180 clipboardTextOrchestrator: orchestrator,
147 },181 },
@@ -155,27 +189,36 @@ func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T)
155 gotCalls := recorder.selectionCalls189 gotCalls := recorder.selectionCalls
156 recorder.mu.Unlock()190 recorder.mu.Unlock()
157 require.Zero(t, gotCalls)191 require.Zero(t, gotCalls)
192+ require.Len(t, scheduler.callbacks, 2)
158 193
159- require.Eventually(t, func() bool {194+ scheduler.fire(0)
160- recorder.mu.Lock()195+ recorder.mu.Lock()
161- defer recorder.mu.Unlock()196+ gotCalls = recorder.selectionCalls
162- return recorder.selectionCalls == 1 && recorder.selection.Text == "second selection"197+ recorder.mu.Unlock()
163- }, 2*time.Second, 10*time.Millisecond)198+ require.Zero(t, gotCalls)
199+
200+ scheduler.fire(1)
201+ recorder.mu.Lock()
202+ defer recorder.mu.Unlock()
203+ require.Equal(t, 1, recorder.selectionCalls)
204+ require.Equal(t, "second selection", recorder.selection.Text)
164 require.Equal(t, "second selection", wv.selectedTextSnapshot())205 require.Equal(t, "second selection", wv.selectedTextSnapshot())
165 }206 }
166 207
167 func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResumesWhenCleared(t *testing.T) {208 func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResumesWhenCleared(t *testing.T) {
168 recorder := &clipboardOrchestratorRecorder{}209 recorder := &clipboardOrchestratorRecorder{}
210+ scheduler := &controlledSelectionScheduler{}
169 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)211 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
170- orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input port.SelectionClipboardInput) {212+ orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) {
171 recorder.mu.Lock()213 recorder.mu.Lock()
172 defer recorder.mu.Unlock()214 defer recorder.mu.Unlock()
173 recorder.selection = input215 recorder.selection = input
174 recorder.selectionCalls++216 recorder.selectionCalls++
175 }).Return(nil).Once()217 }).Return(nil).Once()
176 wv := &WebView{218 wv := &WebView{
177- ctx: context.Background(),219+ ctx: context.Background(),
178- id: 42,220+ id: 42,
221+ selectionDebounceSchedule: scheduler.schedule,
179 engine: &Engine{222 engine: &Engine{
180 clipboardTextOrchestrator: orchestrator,223 clipboardTextOrchestrator: orchestrator,
181 },224 },
@@ -204,12 +247,7 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu
204 gotCalls := recorder.selectionCalls247 gotCalls := recorder.selectionCalls
205 recorder.mu.Unlock()248 recorder.mu.Unlock()
206 require.Zero(t, gotCalls)249 require.Zero(t, gotCalls)
207- start := time.Now()250+ require.Empty(t, scheduler.callbacks)
208- require.Eventually(t, func() bool {
209- recorder.mu.Lock()
210- defer recorder.mu.Unlock()
211- return recorder.selectionCalls == 0 && time.Since(start) >= clipboardSelectionDebounceDelay+50*time.Millisecond
212- }, 2*time.Second, 10*time.Millisecond)
213 251
214 newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage {252 newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage {
215 return newTestBridgeProcessMessage(name, false)253 return newTestBridgeProcessMessage(name, false)
@@ -229,18 +267,22 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu
229 gotCalls = recorder.selectionCalls267 gotCalls = recorder.selectionCalls
230 recorder.mu.Unlock()268 recorder.mu.Unlock()
231 require.Zero(t, gotCalls)269 require.Zero(t, gotCalls)
232- require.Eventually(t, func() bool {270+ require.Len(t, scheduler.callbacks, 1)
233- recorder.mu.Lock()271+
234- defer recorder.mu.Unlock()272+ scheduler.fire(0)
235- return recorder.selectionCalls == 1 && recorder.selection.Text == "free selection"273+ recorder.mu.Lock()
236- }, 2*time.Second, 10*time.Millisecond)274+ defer recorder.mu.Unlock()
275+ require.Equal(t, 1, recorder.selectionCalls)
276+ require.Equal(t, "free selection", recorder.selection.Text)
237 }277 }
238 278
239 func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *testing.T) {279 func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *testing.T) {
280+ scheduler := &controlledSelectionScheduler{}
240 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)281 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
241 wv := &WebView{282 wv := &WebView{
242- ctx: context.Background(),283+ ctx: context.Background(),
243- id: 42,284+ id: 42,
285+ selectionDebounceSchedule: scheduler.schedule,
244 engine: &Engine{286 engine: &Engine{
245 clipboardTextOrchestrator: orchestrator,287 clipboardTextOrchestrator: orchestrator,
246 },288 },
@@ -248,12 +290,10 @@ func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *te
248 h := &handlerSet{wv: wv}290 h := &handlerSet{wv: wv}
249 291
250 h.OnTextSelectionChanged(nil, "selected text", nil)292 h.OnTextSelectionChanged(nil, "selected text", nil)
293+ require.Len(t, scheduler.callbacks, 1)
251 wv.Destroy()294 wv.Destroy()
295+ scheduler.fire(0)
252 296
253- start := time.Now()
254- require.Eventually(t, func() bool {
255- return time.Since(start) >= clipboardSelectionDebounceDelay+50*time.Millisecond
256- }, 2*time.Second, 10*time.Millisecond)
257 orchestrator.AssertNotCalled(t, "HandleSelectionUpdate", mock.Anything, mock.Anything)297 orchestrator.AssertNotCalled(t, "HandleSelectionUpdate", mock.Anything, mock.Anything)
258 require.Equal(t, "selected text", wv.selectedTextSnapshot())298 require.Equal(t, "selected text", wv.selectedTextSnapshot())
259 }299 }
M internal/infrastructure/cef/webview_navigation_test.go +38 -6
@@ -17,8 +17,8 @@ func TestWebViewReplayPendingNavigation_LoadsQueuedURIWhenMainFrameAvailable(t *
1717 frame.EXPECT().LoadURL("https://github.com/bnema").Once()
1818 browser.EXPECT().GetMainFrame().Return(frame).Once()
1919
20- wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"}
21- wv.replayPendingNavigation(browser, 0)
20+ wv := &WebView{ctx: context.Background(), browser: browser, pendingURI: "https://github.com/bnema"}
21+ wv.replayPendingNavigation(0)
2222
2323 wv.mu.RLock()
2424 defer wv.mu.RUnlock()
@@ -46,8 +46,8 @@ func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testi
4646 return 1
4747 }
4848
49- wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"}
50- wv.replayPendingNavigation(browser, 0)
49+ wv := &WebView{ctx: context.Background(), browser: browser, pendingURI: "https://github.com/bnema"}
50+ wv.replayPendingNavigation(0)
5151
5252 require.True(t, scheduled)
5353 wv.mu.RLock()
@@ -88,12 +88,44 @@ func TestWebViewSchedulePendingNavigationReplay_RetriesWhenTaskPostFails(t *test
8888 return 1
8989 }
9090
91- wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"}
92- wv.schedulePendingNavigationReplay(browser, 0)
91+ wv := &WebView{ctx: context.Background(), browser: browser, pendingURI: "https://github.com/bnema"}
92+ wv.schedulePendingNavigationReplay(0)
9393
9494 require.True(t, retried)
9595 }
9696
97+func TestWebViewReplayPendingNavigation_UsesCurrentBrowserAtExecutionTime(t *testing.T) {
98+ staleBrowser := cefmocks.NewMockBrowser(t)
99+ activeBrowser := cefmocks.NewMockBrowser(t)
100+ frame := cefmocks.NewMockFrame(t)
101+ frame.EXPECT().GetURL().Return("").Once()
102+ frame.EXPECT().LoadURL("https://github.com/bnema").Once()
103+ activeBrowser.EXPECT().GetMainFrame().Return(frame).Once()
104+
105+ oldTask := cefNewTask
106+ oldPost := cefPostTask
107+ defer func() {
108+ cefNewTask = oldTask
109+ cefPostTask = oldPost
110+ }()
111+ var scheduled purecef.Task
112+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
113+ cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 {
114+ require.Equal(t, purecef.ThreadIDTidUi, threadID)
115+ scheduled = task
116+ return 1
117+ }
118+
119+ wv := &WebView{ctx: context.Background(), browser: staleBrowser, pendingURI: "https://github.com/bnema"}
120+ wv.schedulePendingNavigationReplay(0)
121+ wv.mu.Lock()
122+ wv.browser = activeBrowser
123+ wv.mu.Unlock()
124+
125+ require.NotNil(t, scheduled)
126+ scheduled.Execute()
127+}
128+
97129 func TestWebViewLoadURI_QueuesPendingNavigationReplayForExistingBrowser(t *testing.T) {
98130 browser := cefmocks.NewMockBrowser(t)
99131 frame := cefmocks.NewMockFrame(t)
@@ -17,8 +17,8 @@ func TestWebViewReplayPendingNavigation_LoadsQueuedURIWhenMainFrameAvailable(t *
17 frame.EXPECT().LoadURL("https://github.com/bnema").Once()17 frame.EXPECT().LoadURL("https://github.com/bnema").Once()
18 browser.EXPECT().GetMainFrame().Return(frame).Once()18 browser.EXPECT().GetMainFrame().Return(frame).Once()
19 19
20- wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"}20+ wv := &WebView{ctx: context.Background(), browser: browser, pendingURI: "https://github.com/bnema"}
21- wv.replayPendingNavigation(browser, 0)21+ wv.replayPendingNavigation(0)
22 22
23 wv.mu.RLock()23 wv.mu.RLock()
24 defer wv.mu.RUnlock()24 defer wv.mu.RUnlock()
@@ -46,8 +46,8 @@ func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testi
46 return 146 return 1
47 }47 }
48 48
49- wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"}49+ wv := &WebView{ctx: context.Background(), browser: browser, pendingURI: "https://github.com/bnema"}
50- wv.replayPendingNavigation(browser, 0)50+ wv.replayPendingNavigation(0)
51 51
52 require.True(t, scheduled)52 require.True(t, scheduled)
53 wv.mu.RLock()53 wv.mu.RLock()
@@ -88,12 +88,44 @@ func TestWebViewSchedulePendingNavigationReplay_RetriesWhenTaskPostFails(t *test
88 return 188 return 1
89 }89 }
90 90
91- wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"}91+ wv := &WebView{ctx: context.Background(), browser: browser, pendingURI: "https://github.com/bnema"}
92- wv.schedulePendingNavigationReplay(browser, 0)92+ wv.schedulePendingNavigationReplay(0)
93 93
94 require.True(t, retried)94 require.True(t, retried)
95 }95 }
96 96
97+func TestWebViewReplayPendingNavigation_UsesCurrentBrowserAtExecutionTime(t *testing.T) {
98+ staleBrowser := cefmocks.NewMockBrowser(t)
99+ activeBrowser := cefmocks.NewMockBrowser(t)
100+ frame := cefmocks.NewMockFrame(t)
101+ frame.EXPECT().GetURL().Return("").Once()
102+ frame.EXPECT().LoadURL("https://github.com/bnema").Once()
103+ activeBrowser.EXPECT().GetMainFrame().Return(frame).Once()
104+
105+ oldTask := cefNewTask
106+ oldPost := cefPostTask
107+ defer func() {
108+ cefNewTask = oldTask
109+ cefPostTask = oldPost
110+ }()
111+ var scheduled purecef.Task
112+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
113+ cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 {
114+ require.Equal(t, purecef.ThreadIDTidUi, threadID)
115+ scheduled = task
116+ return 1
117+ }
118+
119+ wv := &WebView{ctx: context.Background(), browser: staleBrowser, pendingURI: "https://github.com/bnema"}
120+ wv.schedulePendingNavigationReplay(0)
121+ wv.mu.Lock()
122+ wv.browser = activeBrowser
123+ wv.mu.Unlock()
124+
125+ require.NotNil(t, scheduled)
126+ scheduled.Execute()
127+}
128+
97 func TestWebViewLoadURI_QueuesPendingNavigationReplayForExistingBrowser(t *testing.T) {129 func TestWebViewLoadURI_QueuesPendingNavigationReplayForExistingBrowser(t *testing.T) {
98 browser := cefmocks.NewMockBrowser(t)130 browser := cefmocks.NewMockBrowser(t)
99 frame := cefmocks.NewMockFrame(t)131 frame := cefmocks.NewMockFrame(t)
M internal/infrastructure/handlers/clipboard.go +7 -6
@@ -4,6 +4,7 @@ import (
44 "context"
55 "encoding/json"
66
7+ "github.com/bnema/dumber/internal/application/dto"
78 "github.com/bnema/dumber/internal/application/port"
89 "github.com/bnema/dumber/internal/logging"
910 )
@@ -44,10 +45,10 @@ func (h *ClipboardHandler) HandleAutoCopySelection() port.WebUIMessageHandler {
4445 if h.orchestrator == nil {
4546 return nil, nil
4647 }
47- if err := h.orchestrator.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{
48+ if err := h.orchestrator.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{
4849 Text: req.Text,
49- SourceEngine: port.ClipboardSourceWebKit,
50- ViewID: webviewID,
50+ SourceEngine: dto.ClipboardSourceWebKit,
51+ ViewID: uint64(webviewID),
5152 }); err != nil {
5253 log.Debug().Err(err).Msg("clipboard selection handling failed")
5354 }
@@ -69,11 +70,11 @@ func (h *ClipboardHandler) HandleExplicitCopy() port.WebUIMessageHandler {
6970 if h.orchestrator == nil {
7071 return nil, nil
7172 }
72- if err := h.orchestrator.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{
73+ if err := h.orchestrator.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{
7374 Text: req.Text,
7475 Action: req.Action,
75- SourceEngine: port.ClipboardSourceWebKit,
76- ViewID: webviewID,
76+ SourceEngine: dto.ClipboardSourceWebKit,
77+ ViewID: uint64(webviewID),
7778 NativeHandled: true,
7879 }); err != nil {
7980 log.Debug().Err(err).Msg("clipboard explicit handling failed")
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6 6
7+ "github.com/bnema/dumber/internal/application/dto"
7 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
9 )10 )
@@ -44,10 +45,10 @@ func (h *ClipboardHandler) HandleAutoCopySelection() port.WebUIMessageHandler {
44 if h.orchestrator == nil {45 if h.orchestrator == nil {
45 return nil, nil46 return nil, nil
46 }47 }
47- if err := h.orchestrator.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{48+ if err := h.orchestrator.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{
48 Text: req.Text,49 Text: req.Text,
49- SourceEngine: port.ClipboardSourceWebKit,50+ SourceEngine: dto.ClipboardSourceWebKit,
50- ViewID: webviewID,51+ ViewID: uint64(webviewID),
51 }); err != nil {52 }); err != nil {
52 log.Debug().Err(err).Msg("clipboard selection handling failed")53 log.Debug().Err(err).Msg("clipboard selection handling failed")
53 }54 }
@@ -69,11 +70,11 @@ func (h *ClipboardHandler) HandleExplicitCopy() port.WebUIMessageHandler {
69 if h.orchestrator == nil {70 if h.orchestrator == nil {
70 return nil, nil71 return nil, nil
71 }72 }
72- if err := h.orchestrator.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{73+ if err := h.orchestrator.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{
73 Text: req.Text,74 Text: req.Text,
74 Action: req.Action,75 Action: req.Action,
75- SourceEngine: port.ClipboardSourceWebKit,76+ SourceEngine: dto.ClipboardSourceWebKit,
76- ViewID: webviewID,77+ ViewID: uint64(webviewID),
77 NativeHandled: true,78 NativeHandled: true,
78 }); err != nil {79 }); err != nil {
79 log.Debug().Err(err).Msg("clipboard explicit handling failed")80 log.Debug().Err(err).Msg("clipboard explicit handling failed")
M internal/infrastructure/handlers/clipboard_test.go +9 -9
@@ -6,24 +6,24 @@ import (
66 "sync"
77 "testing"
88
9- "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/dto"
1010 "github.com/stretchr/testify/require"
1111 )
1212
1313 type recordingClipboardOrchestrator struct {
1414 mu sync.Mutex
15- selection port.SelectionClipboardInput
16- explicit port.ExplicitClipboardInput
15+ selection dto.SelectionClipboardInput
16+ explicit dto.ExplicitClipboardInput
1717 }
1818
19-func (r *recordingClipboardOrchestrator) HandleSelectionUpdate(_ context.Context, input port.SelectionClipboardInput) error {
19+func (r *recordingClipboardOrchestrator) HandleSelectionUpdate(_ context.Context, input dto.SelectionClipboardInput) error {
2020 r.mu.Lock()
2121 defer r.mu.Unlock()
2222 r.selection = input
2323 return nil
2424 }
2525
26-func (r *recordingClipboardOrchestrator) HandleExplicitCopy(_ context.Context, input port.ExplicitClipboardInput) error {
26+func (r *recordingClipboardOrchestrator) HandleExplicitCopy(_ context.Context, input dto.ExplicitClipboardInput) error {
2727 r.mu.Lock()
2828 defer r.mu.Unlock()
2929 r.explicit = input
@@ -41,8 +41,8 @@ func TestClipboardHandler_HandleAutoCopySelection_DelegatesToOrchestrator(t *tes
4141 orchestrator.mu.Lock()
4242 defer orchestrator.mu.Unlock()
4343 require.Equal(t, "selected text", orchestrator.selection.Text)
44- require.Equal(t, port.ClipboardSourceWebKit, orchestrator.selection.SourceEngine)
45- require.Equal(t, port.WebViewID(42), orchestrator.selection.ViewID)
44+ require.Equal(t, dto.ClipboardSourceWebKit, orchestrator.selection.SourceEngine)
45+ require.Equal(t, uint64(42), orchestrator.selection.ViewID)
4646 }
4747
4848 func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing.T) {
@@ -57,7 +57,7 @@ func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing.
5757 defer orchestrator.mu.Unlock()
5858 require.Equal(t, "copied text", orchestrator.explicit.Text)
5959 require.Equal(t, "copy", orchestrator.explicit.Action)
60- require.Equal(t, port.ClipboardSourceWebKit, orchestrator.explicit.SourceEngine)
61- require.Equal(t, port.WebViewID(42), orchestrator.explicit.ViewID)
60+ require.Equal(t, dto.ClipboardSourceWebKit, orchestrator.explicit.SourceEngine)
61+ require.Equal(t, uint64(42), orchestrator.explicit.ViewID)
6262 require.True(t, orchestrator.explicit.NativeHandled)
6363 }
@@ -6,24 +6,24 @@ import (
6 "sync"6 "sync"
7 "testing"7 "testing"
8 8
9- "github.com/bnema/dumber/internal/application/port"9+ "github.com/bnema/dumber/internal/application/dto"
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 )11 )
12 12
13 type recordingClipboardOrchestrator struct {13 type recordingClipboardOrchestrator struct {
14 mu sync.Mutex14 mu sync.Mutex
15- selection port.SelectionClipboardInput15+ selection dto.SelectionClipboardInput
16- explicit port.ExplicitClipboardInput16+ explicit dto.ExplicitClipboardInput
17 }17 }
18 18
19-func (r *recordingClipboardOrchestrator) HandleSelectionUpdate(_ context.Context, input port.SelectionClipboardInput) error {19+func (r *recordingClipboardOrchestrator) HandleSelectionUpdate(_ context.Context, input dto.SelectionClipboardInput) error {
20 r.mu.Lock()20 r.mu.Lock()
21 defer r.mu.Unlock()21 defer r.mu.Unlock()
22 r.selection = input22 r.selection = input
23 return nil23 return nil
24 }24 }
25 25
26-func (r *recordingClipboardOrchestrator) HandleExplicitCopy(_ context.Context, input port.ExplicitClipboardInput) error {26+func (r *recordingClipboardOrchestrator) HandleExplicitCopy(_ context.Context, input dto.ExplicitClipboardInput) error {
27 r.mu.Lock()27 r.mu.Lock()
28 defer r.mu.Unlock()28 defer r.mu.Unlock()
29 r.explicit = input29 r.explicit = input
@@ -41,8 +41,8 @@ func TestClipboardHandler_HandleAutoCopySelection_DelegatesToOrchestrator(t *tes
41 orchestrator.mu.Lock()41 orchestrator.mu.Lock()
42 defer orchestrator.mu.Unlock()42 defer orchestrator.mu.Unlock()
43 require.Equal(t, "selected text", orchestrator.selection.Text)43 require.Equal(t, "selected text", orchestrator.selection.Text)
44- require.Equal(t, port.ClipboardSourceWebKit, orchestrator.selection.SourceEngine)44+ require.Equal(t, dto.ClipboardSourceWebKit, orchestrator.selection.SourceEngine)
45- require.Equal(t, port.WebViewID(42), orchestrator.selection.ViewID)45+ require.Equal(t, uint64(42), orchestrator.selection.ViewID)
46 }46 }
47 47
48 func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing.T) {48 func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing.T) {
@@ -57,7 +57,7 @@ func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing.
57 defer orchestrator.mu.Unlock()57 defer orchestrator.mu.Unlock()
58 require.Equal(t, "copied text", orchestrator.explicit.Text)58 require.Equal(t, "copied text", orchestrator.explicit.Text)
59 require.Equal(t, "copy", orchestrator.explicit.Action)59 require.Equal(t, "copy", orchestrator.explicit.Action)
60- require.Equal(t, port.ClipboardSourceWebKit, orchestrator.explicit.SourceEngine)60+ require.Equal(t, dto.ClipboardSourceWebKit, orchestrator.explicit.SourceEngine)
61- require.Equal(t, port.WebViewID(42), orchestrator.explicit.ViewID)61+ require.Equal(t, uint64(42), orchestrator.explicit.ViewID)
62 require.True(t, orchestrator.explicit.NativeHandled)62 require.True(t, orchestrator.explicit.NativeHandled)
63 }63 }
M internal/infrastructure/xdg/adapter.go +22 -7
@@ -6,14 +6,29 @@ import (
66
77 "github.com/bnema/dumber/internal/application/port"
88 "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
910 )
1011
1112 // Adapter implements port.XDGPaths using config.GetXDGDirs().
12-type Adapter struct{}
13+type Adapter struct {
14+ mode runtimeprofile.Mode
15+ runtimeDir string
16+}
1317
1418 // New creates a new XDG paths adapter.
15-func New() *Adapter {
16- return &Adapter{}
19+func New(profile runtimeprofile.Profile) *Adapter {
20+ runtimeDir := ""
21+ switch profile.Mode {
22+ case runtimeprofile.ModeDev:
23+ if profile.Shared.RootDir != "" {
24+ runtimeDir = filepath.Join(profile.Shared.RootDir, "runtime")
25+ }
26+ case runtimeprofile.ModeProd:
27+ if profile.Shared.StateDir != "" {
28+ runtimeDir = filepath.Join(profile.Shared.StateDir, "runtime")
29+ }
30+ }
31+ return &Adapter{mode: profile.Mode, runtimeDir: runtimeDir}
1732 }
1833
1934 func (a *Adapter) ConfigDir() (string, error) {
@@ -29,19 +44,19 @@ func (a *Adapter) StateDir() (string, error) {
2944 }
3045
3146 func (a *Adapter) RuntimeDir() (string, error) {
32- if os.Getenv("ENV") != "dev" {
47+ if a == nil || a.mode != runtimeprofile.ModeDev {
3348 if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
3449 return dir, nil
3550 }
3651 }
52+ if a != nil && a.runtimeDir != "" {
53+ return a.runtimeDir, nil
54+ }
3755
3856 stateDir, err := a.StateDir()
3957 if err != nil {
4058 return "", err
4159 }
42- if os.Getenv("ENV") == "dev" {
43- return filepath.Join(filepath.Dir(stateDir), "runtime"), nil
44- }
4560 return filepath.Join(stateDir, "runtime"), nil
4661 }
4762
@@ -6,14 +6,29 @@ import (
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/infrastructure/config"8 "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
9 )10 )
10 11
11 // Adapter implements port.XDGPaths using config.GetXDGDirs().12 // Adapter implements port.XDGPaths using config.GetXDGDirs().
12-type Adapter struct{}13+type Adapter struct {
14+ mode runtimeprofile.Mode
15+ runtimeDir string
16+}
13 17
14 // New creates a new XDG paths adapter.18 // New creates a new XDG paths adapter.
15-func New() *Adapter {19+func New(profile runtimeprofile.Profile) *Adapter {
16- return &Adapter{}20+ runtimeDir := ""
21+ switch profile.Mode {
22+ case runtimeprofile.ModeDev:
23+ if profile.Shared.RootDir != "" {
24+ runtimeDir = filepath.Join(profile.Shared.RootDir, "runtime")
25+ }
26+ case runtimeprofile.ModeProd:
27+ if profile.Shared.StateDir != "" {
28+ runtimeDir = filepath.Join(profile.Shared.StateDir, "runtime")
29+ }
30+ }
31+ return &Adapter{mode: profile.Mode, runtimeDir: runtimeDir}
17 }32 }
18 33
19 func (a *Adapter) ConfigDir() (string, error) {34 func (a *Adapter) ConfigDir() (string, error) {
@@ -29,19 +44,19 @@ func (a *Adapter) StateDir() (string, error) {
29 }44 }
30 45
31 func (a *Adapter) RuntimeDir() (string, error) {46 func (a *Adapter) RuntimeDir() (string, error) {
32- if os.Getenv("ENV") != "dev" {47+ if a == nil || a.mode != runtimeprofile.ModeDev {
33 if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {48 if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
34 return dir, nil49 return dir, nil
35 }50 }
36 }51 }
52+ if a != nil && a.runtimeDir != "" {
53+ return a.runtimeDir, nil
54+ }
37 55
38 stateDir, err := a.StateDir()56 stateDir, err := a.StateDir()
39 if err != nil {57 if err != nil {
40 return "", err58 return "", err
41 }59 }
42- if os.Getenv("ENV") == "dev" {
43- return filepath.Join(filepath.Dir(stateDir), "runtime"), nil
44- }
45 return filepath.Join(stateDir, "runtime"), nil60 return filepath.Join(stateDir, "runtime"), nil
46 }61 }
47 62
M internal/infrastructure/xdg/adapter_test.go +18 -17
@@ -5,12 +5,13 @@ import (
55 "path/filepath"
66 "testing"
77
8+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
89 "github.com/stretchr/testify/assert"
910 "github.com/stretchr/testify/require"
1011 )
1112
1213 func TestAdapter_DownloadDir(t *testing.T) {
13- adapter := New()
14+ adapter := New(runtimeprofile.Profile{})
1415
1516 t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) {
1617 t.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads")
@@ -37,10 +38,8 @@ func TestAdapter_DownloadDir(t *testing.T) {
3738 }
3839
3940 func TestAdapter_RuntimeDir(t *testing.T) {
40- adapter := New()
41-
4241 t.Run("returns XDG_RUNTIME_DIR when set outside dev", func(t *testing.T) {
43- t.Setenv("ENV", "")
42+ adapter := New(runtimeprofile.Profile{Mode: runtimeprofile.ModeProd})
4443 t.Setenv("XDG_RUNTIME_DIR", "/custom/runtime")
4544
4645 dir, err := adapter.RuntimeDir()
@@ -49,10 +48,14 @@ func TestAdapter_RuntimeDir(t *testing.T) {
4948 assert.Equal(t, "/custom/runtime", dir)
5049 })
5150
52- t.Run("falls back to state runtime dir when XDG_RUNTIME_DIR not set", func(t *testing.T) {
53- t.Setenv("ENV", "")
51+ t.Run("falls back to injected prod runtime dir when XDG_RUNTIME_DIR not set", func(t *testing.T) {
52+ adapter := New(runtimeprofile.Profile{
53+ Mode: runtimeprofile.ModeProd,
54+ Shared: runtimeprofile.SharedPaths{
55+ StateDir: "/tmp/dumber-state/dumber",
56+ },
57+ })
5458 t.Setenv("XDG_RUNTIME_DIR", "")
55- t.Setenv("XDG_STATE_HOME", "/tmp/dumber-state")
5659
5760 dir, err := adapter.RuntimeDir()
5861
@@ -60,16 +63,14 @@ func TestAdapter_RuntimeDir(t *testing.T) {
6063 assert.Equal(t, "/tmp/dumber-state/dumber/runtime", dir)
6164 })
6265
63- t.Run("uses sandbox runtime dir in dev even when XDG_RUNTIME_DIR is set", func(t *testing.T) {
66+ t.Run("uses injected sandbox runtime dir in dev even when XDG_RUNTIME_DIR is set", func(t *testing.T) {
6467 wd := t.TempDir()
65- oldWD, err := os.Getwd()
66- require.NoError(t, err)
67- require.NoError(t, os.Chdir(wd))
68- defer func() {
69- require.NoError(t, os.Chdir(oldWD))
70- }()
71-
72- t.Setenv("ENV", "dev")
68+ adapter := New(runtimeprofile.Profile{
69+ Mode: runtimeprofile.ModeDev,
70+ Shared: runtimeprofile.SharedPaths{
71+ RootDir: filepath.Join(wd, ".dev", "dumber"),
72+ },
73+ })
7374 t.Setenv("XDG_RUNTIME_DIR", "/shared/runtime")
7475
7576 dir, err := adapter.RuntimeDir()
@@ -80,7 +81,7 @@ func TestAdapter_RuntimeDir(t *testing.T) {
8081 }
8182
8283 func TestNew(t *testing.T) {
83- adapter := New()
84+ adapter := New(runtimeprofile.Profile{})
8485
8586 assert.NotNil(t, adapter)
8687 }
@@ -5,12 +5,13 @@ import (
5 "path/filepath"5 "path/filepath"
6 "testing"6 "testing"
7 7
8+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
8 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
10 )11 )
11 12
12 func TestAdapter_DownloadDir(t *testing.T) {13 func TestAdapter_DownloadDir(t *testing.T) {
13- adapter := New()14+ adapter := New(runtimeprofile.Profile{})
14 15
15 t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) {16 t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) {
16 t.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads")17 t.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads")
@@ -37,10 +38,8 @@ func TestAdapter_DownloadDir(t *testing.T) {
37 }38 }
38 39
39 func TestAdapter_RuntimeDir(t *testing.T) {40 func TestAdapter_RuntimeDir(t *testing.T) {
40- adapter := New()
41-
42 t.Run("returns XDG_RUNTIME_DIR when set outside dev", func(t *testing.T) {41 t.Run("returns XDG_RUNTIME_DIR when set outside dev", func(t *testing.T) {
43- t.Setenv("ENV", "")42+ adapter := New(runtimeprofile.Profile{Mode: runtimeprofile.ModeProd})
44 t.Setenv("XDG_RUNTIME_DIR", "/custom/runtime")43 t.Setenv("XDG_RUNTIME_DIR", "/custom/runtime")
45 44
46 dir, err := adapter.RuntimeDir()45 dir, err := adapter.RuntimeDir()
@@ -49,10 +48,14 @@ func TestAdapter_RuntimeDir(t *testing.T) {
49 assert.Equal(t, "/custom/runtime", dir)48 assert.Equal(t, "/custom/runtime", dir)
50 })49 })
51 50
52- t.Run("falls back to state runtime dir when XDG_RUNTIME_DIR not set", func(t *testing.T) {51+ t.Run("falls back to injected prod runtime dir when XDG_RUNTIME_DIR not set", func(t *testing.T) {
53- t.Setenv("ENV", "")52+ adapter := New(runtimeprofile.Profile{
53+ Mode: runtimeprofile.ModeProd,
54+ Shared: runtimeprofile.SharedPaths{
55+ StateDir: "/tmp/dumber-state/dumber",
56+ },
57+ })
54 t.Setenv("XDG_RUNTIME_DIR", "")58 t.Setenv("XDG_RUNTIME_DIR", "")
55- t.Setenv("XDG_STATE_HOME", "/tmp/dumber-state")
56 59
57 dir, err := adapter.RuntimeDir()60 dir, err := adapter.RuntimeDir()
58 61
@@ -60,16 +63,14 @@ func TestAdapter_RuntimeDir(t *testing.T) {
60 assert.Equal(t, "/tmp/dumber-state/dumber/runtime", dir)63 assert.Equal(t, "/tmp/dumber-state/dumber/runtime", dir)
61 })64 })
62 65
63- t.Run("uses sandbox runtime dir in dev even when XDG_RUNTIME_DIR is set", func(t *testing.T) {66+ t.Run("uses injected sandbox runtime dir in dev even when XDG_RUNTIME_DIR is set", func(t *testing.T) {
64 wd := t.TempDir()67 wd := t.TempDir()
65- oldWD, err := os.Getwd()68+ adapter := New(runtimeprofile.Profile{
66- require.NoError(t, err)69+ Mode: runtimeprofile.ModeDev,
67- require.NoError(t, os.Chdir(wd))70+ Shared: runtimeprofile.SharedPaths{
68- defer func() {71+ RootDir: filepath.Join(wd, ".dev", "dumber"),
69- require.NoError(t, os.Chdir(oldWD))72+ },
70- }()73+ })
71-
72- t.Setenv("ENV", "dev")
73 t.Setenv("XDG_RUNTIME_DIR", "/shared/runtime")74 t.Setenv("XDG_RUNTIME_DIR", "/shared/runtime")
74 75
75 dir, err := adapter.RuntimeDir()76 dir, err := adapter.RuntimeDir()
@@ -80,7 +81,7 @@ func TestAdapter_RuntimeDir(t *testing.T) {
80 }81 }
81 82
82 func TestNew(t *testing.T) {83 func TestNew(t *testing.T) {
83- adapter := New()84+ adapter := New(runtimeprofile.Profile{})
84 85
85 assert.NotNil(t, adapter)86 assert.NotNil(t, adapter)
86 }87 }
M internal/ui/component/progress_bar.go +11 -3
@@ -157,7 +157,11 @@ func (pb *ProgressBar) startAnimation() {
157157 })
158158
159159 pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0)
160- logging.FromContext(pb.ctx).
160+ ctx := pb.ctx
161+ if ctx == nil {
162+ ctx = context.Background()
163+ }
164+ logging.FromContext(ctx).
161165 Trace().
162166 Uint64("progress_event_seq", pb.progressEventSeq).
163167 Float64("current_value", pb.currentValue).
@@ -203,7 +207,7 @@ func (pb *ProgressBar) Show() {
203207 // Reset timeout timer on every Show call
204208 pb.resetTimeout()
205209
206- logging.FromContext(pb.ctx).
210+ logging.FromContext(ctx).
207211 Debug().
208212 Bool("visible", pb.visible).
209213 Float64("current_value", pb.currentValue).
@@ -226,7 +230,11 @@ func (pb *ProgressBar) resetTimeout() {
226230 defer pb.mu.Unlock()
227231
228232 now := time.Now()
229- logging.FromContext(pb.ctx).
233+ ctx := pb.ctx
234+ if ctx == nil {
235+ ctx = context.Background()
236+ }
237+ logging.FromContext(ctx).
230238 Debug().
231239 Float64("current_value", pb.currentValue).
232240 Float64("target_value", pb.targetValue).
@@ -157,7 +157,11 @@ func (pb *ProgressBar) startAnimation() {
157 })157 })
158 158
159 pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0)159 pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0)
160- logging.FromContext(pb.ctx).160+ ctx := pb.ctx
161+ if ctx == nil {
162+ ctx = context.Background()
163+ }
164+ logging.FromContext(ctx).
161 Trace().165 Trace().
162 Uint64("progress_event_seq", pb.progressEventSeq).166 Uint64("progress_event_seq", pb.progressEventSeq).
163 Float64("current_value", pb.currentValue).167 Float64("current_value", pb.currentValue).
@@ -203,7 +207,7 @@ func (pb *ProgressBar) Show() {
203 // Reset timeout timer on every Show call207 // Reset timeout timer on every Show call
204 pb.resetTimeout()208 pb.resetTimeout()
205 209
206- logging.FromContext(pb.ctx).210+ logging.FromContext(ctx).
207 Debug().211 Debug().
208 Bool("visible", pb.visible).212 Bool("visible", pb.visible).
209 Float64("current_value", pb.currentValue).213 Float64("current_value", pb.currentValue).
@@ -226,7 +230,11 @@ func (pb *ProgressBar) resetTimeout() {
226 defer pb.mu.Unlock()230 defer pb.mu.Unlock()
227 231
228 now := time.Now()232 now := time.Now()
229- logging.FromContext(pb.ctx).233+ ctx := pb.ctx
234+ if ctx == nil {
235+ ctx = context.Background()
236+ }
237+ logging.FromContext(ctx).
230 Debug().238 Debug().
231 Float64("current_value", pb.currentValue).239 Float64("current_value", pb.currentValue).
232 Float64("target_value", pb.targetValue).240 Float64("target_value", pb.targetValue).

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