detached 6e9ad5e

refactor: deepen architecture modules (floating sessions, vim mode, omnibox, system-view policy, engine seams, WebUI contract, config policy) (#455)

* style(bootstrap): use %#q for backquoted crash report fields

* refactor(usecase): fold media diagnostics pass-through into CheckMediaUseCase

* refactor(port): split PageNavigator from WebView for navigation consumers

* refactor(config): move custom performance clamping into SaveWebUIConfigUseCase

* refactor(webui): declare WebUI message names and callbacks in one contract

Handlers and the systemviews bridge now read message types and callback names from port.WebUIMessageCallbacks. A contract test asserts every declared message is registered with its declared callbacks.

* refactor(webutil): share system-view request policy between CEF and WebKit

Asset path safety, WASM decoding, requester trust and favicon request parsing now live once in webutil. Each engine keeps only native request/response translation and declares its trusted URLs.

WebKit now treats a corrupt compressed WASM artifact as an error instead of silently serving the raw file, matching CEF.

* refactor(ui): stop branching on engine kind for popup reveal and runtime activity

Native popups carry AlreadyReadyToShow from the deferred feature-resolution path instead of the UI checking for WebKit. Runtime activity is a declared optional engine capability (port.RuntimeActivityProvider) instead of an anonymous type assertion.

* refactor(ui): move floating pane sessions out of app.go behind a registry

floatingSessionRegistry owns tab/pane/visibility lookups over sessions that were repeated as ad-hoc loops. Floating session lifecycle moves to floating_pane.go unchanged.

* refactor(ui): move Vim Mode ownership out of app.go behind vimModeState

vimModeState owns editable-focus tracking and the pulse debounce clock with a directly testable interface. Vim Mode policy/ownership methods move to vim_mode.go unchanged.

* refactor(omnibox): gather suggestion logic behind ghostCompletion

Pure ghost-completion, selection-target and search-query helpers move from the 3.2k-line GTK widget into omnibox_suggestions.go. ghostCompletion is the single entry point the widget calls, and the new table test exercises it end to end.

* perf(ui): iterate floating sessions without allocating on layout callbacks

floatingSessionRegistry.forTab and all now yield sessions in place; forTabSnapshot is kept for the release path that mutates the registry. The runtime-config UI-scale loop goes through the registry too. Adds an explicit-selection case to the ghostCompletion test.
commit
6e9ad5e249c7099990dd37e0efa9cdbfd6059330
author
bnema · · 2026-09-29 16:10 UTC
committer
GitHub · · 2026-09-29 16:10 UTC
parent
4b8d64c
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
43 files · +2944 -1886
M .mockery.yaml +1 -0
@@ -58,6 +58,7 @@ packages:
5858 SessionProcessProbe: {}
5959 SessionSpawner: {}
6060 WindowStateProvider: {}
61+ PageNavigator: {}
6162 WebView: {}
6263 PageScrollable: {}
6364 WebViewFactory: {}
M internal/application/port/mocks/webview.go +541 -13
@@ -18,10 +18,19 @@ func NewMockTexture(t interface {
1818 mock.TestingT
1919 Cleanup(func())
2020 }) *MockTexture {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
2125 mock := &MockTexture{}
2226 mock.Mock.Test(t)
2327
24- t.Cleanup(func() { mock.AssertExpectations(t) })
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
2534
2635 return mock
2736 }
@@ -89,10 +98,19 @@ func NewMockFindController(t interface {
8998 mock.TestingT
9099 Cleanup(func())
91100 }) *MockFindController {
101+ if helper, ok := t.(interface{ Helper() }); ok {
102+ helper.Helper()
103+ }
104+
92105 mock := &MockFindController{}
93106 mock.Mock.Test(t)
94107
95- t.Cleanup(func() { mock.AssertExpectations(t) })
108+ t.Cleanup(func() {
109+ if helper, ok := t.(interface{ Helper() }); ok {
110+ helper.Helper()
111+ }
112+ mock.AssertExpectations(t)
113+ })
96114
97115 return mock
98116 }
@@ -550,16 +568,436 @@ func (_c *MockFindController_SearchPrevious_Call) RunAndReturn(run func()) *Mock
550568 return _c
551569 }
552570
571+// NewMockPageNavigator creates a new instance of MockPageNavigator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
572+// The first argument is typically a *testing.T value.
573+func NewMockPageNavigator(t interface {
574+ mock.TestingT
575+ Cleanup(func())
576+}) *MockPageNavigator {
577+ if helper, ok := t.(interface{ Helper() }); ok {
578+ helper.Helper()
579+ }
580+
581+ mock := &MockPageNavigator{}
582+ mock.Mock.Test(t)
583+
584+ t.Cleanup(func() {
585+ if helper, ok := t.(interface{ Helper() }); ok {
586+ helper.Helper()
587+ }
588+ mock.AssertExpectations(t)
589+ })
590+
591+ return mock
592+}
593+
594+// MockPageNavigator is an autogenerated mock type for the PageNavigator type
595+type MockPageNavigator struct {
596+ mock.Mock
597+}
598+
599+type MockPageNavigator_Expecter struct {
600+ mock *mock.Mock
601+}
602+
603+func (_m *MockPageNavigator) EXPECT() *MockPageNavigator_Expecter {
604+ return &MockPageNavigator_Expecter{mock: &_m.Mock}
605+}
606+
607+// GoBack provides a mock function for the type MockPageNavigator
608+func (_mock *MockPageNavigator) GoBack(ctx context.Context) error {
609+ ret := _mock.Called(ctx)
610+
611+ if len(ret) == 0 {
612+ panic("no return value specified for GoBack")
613+ }
614+
615+ var r0 error
616+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
617+ r0 = returnFunc(ctx)
618+ } else {
619+ r0 = ret.Error(0)
620+ }
621+ return r0
622+}
623+
624+// MockPageNavigator_GoBack_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GoBack'
625+type MockPageNavigator_GoBack_Call struct {
626+ *mock.Call
627+}
628+
629+// GoBack is a helper method to define mock.On call
630+// - ctx context.Context
631+func (_e *MockPageNavigator_Expecter) GoBack(ctx any) *MockPageNavigator_GoBack_Call {
632+ return &MockPageNavigator_GoBack_Call{Call: _e.mock.On("GoBack", ctx)}
633+}
634+
635+func (_c *MockPageNavigator_GoBack_Call) Run(run func(ctx context.Context)) *MockPageNavigator_GoBack_Call {
636+ _c.Call.Run(func(args mock.Arguments) {
637+ var arg0 context.Context
638+ if args[0] != nil {
639+ arg0 = args[0].(context.Context)
640+ }
641+ run(
642+ arg0,
643+ )
644+ })
645+ return _c
646+}
647+
648+func (_c *MockPageNavigator_GoBack_Call) Return(err error) *MockPageNavigator_GoBack_Call {
649+ _c.Call.Return(err)
650+ return _c
651+}
652+
653+func (_c *MockPageNavigator_GoBack_Call) RunAndReturn(run func(ctx context.Context) error) *MockPageNavigator_GoBack_Call {
654+ _c.Call.Return(run)
655+ return _c
656+}
657+
658+// GoForward provides a mock function for the type MockPageNavigator
659+func (_mock *MockPageNavigator) GoForward(ctx context.Context) error {
660+ ret := _mock.Called(ctx)
661+
662+ if len(ret) == 0 {
663+ panic("no return value specified for GoForward")
664+ }
665+
666+ var r0 error
667+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
668+ r0 = returnFunc(ctx)
669+ } else {
670+ r0 = ret.Error(0)
671+ }
672+ return r0
673+}
674+
675+// MockPageNavigator_GoForward_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GoForward'
676+type MockPageNavigator_GoForward_Call struct {
677+ *mock.Call
678+}
679+
680+// GoForward is a helper method to define mock.On call
681+// - ctx context.Context
682+func (_e *MockPageNavigator_Expecter) GoForward(ctx any) *MockPageNavigator_GoForward_Call {
683+ return &MockPageNavigator_GoForward_Call{Call: _e.mock.On("GoForward", ctx)}
684+}
685+
686+func (_c *MockPageNavigator_GoForward_Call) Run(run func(ctx context.Context)) *MockPageNavigator_GoForward_Call {
687+ _c.Call.Run(func(args mock.Arguments) {
688+ var arg0 context.Context
689+ if args[0] != nil {
690+ arg0 = args[0].(context.Context)
691+ }
692+ run(
693+ arg0,
694+ )
695+ })
696+ return _c
697+}
698+
699+func (_c *MockPageNavigator_GoForward_Call) Return(err error) *MockPageNavigator_GoForward_Call {
700+ _c.Call.Return(err)
701+ return _c
702+}
703+
704+func (_c *MockPageNavigator_GoForward_Call) RunAndReturn(run func(ctx context.Context) error) *MockPageNavigator_GoForward_Call {
705+ _c.Call.Return(run)
706+ return _c
707+}
708+
709+// LoadHTML provides a mock function for the type MockPageNavigator
710+func (_mock *MockPageNavigator) LoadHTML(ctx context.Context, content string, baseURI string) error {
711+ ret := _mock.Called(ctx, content, baseURI)
712+
713+ if len(ret) == 0 {
714+ panic("no return value specified for LoadHTML")
715+ }
716+
717+ var r0 error
718+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
719+ r0 = returnFunc(ctx, content, baseURI)
720+ } else {
721+ r0 = ret.Error(0)
722+ }
723+ return r0
724+}
725+
726+// MockPageNavigator_LoadHTML_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadHTML'
727+type MockPageNavigator_LoadHTML_Call struct {
728+ *mock.Call
729+}
730+
731+// LoadHTML is a helper method to define mock.On call
732+// - ctx context.Context
733+// - content string
734+// - baseURI string
735+func (_e *MockPageNavigator_Expecter) LoadHTML(ctx any, content any, baseURI any) *MockPageNavigator_LoadHTML_Call {
736+ return &MockPageNavigator_LoadHTML_Call{Call: _e.mock.On("LoadHTML", ctx, content, baseURI)}
737+}
738+
739+func (_c *MockPageNavigator_LoadHTML_Call) Run(run func(ctx context.Context, content string, baseURI string)) *MockPageNavigator_LoadHTML_Call {
740+ _c.Call.Run(func(args mock.Arguments) {
741+ var arg0 context.Context
742+ if args[0] != nil {
743+ arg0 = args[0].(context.Context)
744+ }
745+ var arg1 string
746+ if args[1] != nil {
747+ arg1 = args[1].(string)
748+ }
749+ var arg2 string
750+ if args[2] != nil {
751+ arg2 = args[2].(string)
752+ }
753+ run(
754+ arg0,
755+ arg1,
756+ arg2,
757+ )
758+ })
759+ return _c
760+}
761+
762+func (_c *MockPageNavigator_LoadHTML_Call) Return(err error) *MockPageNavigator_LoadHTML_Call {
763+ _c.Call.Return(err)
764+ return _c
765+}
766+
767+func (_c *MockPageNavigator_LoadHTML_Call) RunAndReturn(run func(ctx context.Context, content string, baseURI string) error) *MockPageNavigator_LoadHTML_Call {
768+ _c.Call.Return(run)
769+ return _c
770+}
771+
772+// LoadURI provides a mock function for the type MockPageNavigator
773+func (_mock *MockPageNavigator) LoadURI(ctx context.Context, uri string) error {
774+ ret := _mock.Called(ctx, uri)
775+
776+ if len(ret) == 0 {
777+ panic("no return value specified for LoadURI")
778+ }
779+
780+ var r0 error
781+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
782+ r0 = returnFunc(ctx, uri)
783+ } else {
784+ r0 = ret.Error(0)
785+ }
786+ return r0
787+}
788+
789+// MockPageNavigator_LoadURI_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadURI'
790+type MockPageNavigator_LoadURI_Call struct {
791+ *mock.Call
792+}
793+
794+// LoadURI is a helper method to define mock.On call
795+// - ctx context.Context
796+// - uri string
797+func (_e *MockPageNavigator_Expecter) LoadURI(ctx any, uri any) *MockPageNavigator_LoadURI_Call {
798+ return &MockPageNavigator_LoadURI_Call{Call: _e.mock.On("LoadURI", ctx, uri)}
799+}
800+
801+func (_c *MockPageNavigator_LoadURI_Call) Run(run func(ctx context.Context, uri string)) *MockPageNavigator_LoadURI_Call {
802+ _c.Call.Run(func(args mock.Arguments) {
803+ var arg0 context.Context
804+ if args[0] != nil {
805+ arg0 = args[0].(context.Context)
806+ }
807+ var arg1 string
808+ if args[1] != nil {
809+ arg1 = args[1].(string)
810+ }
811+ run(
812+ arg0,
813+ arg1,
814+ )
815+ })
816+ return _c
817+}
818+
819+func (_c *MockPageNavigator_LoadURI_Call) Return(err error) *MockPageNavigator_LoadURI_Call {
820+ _c.Call.Return(err)
821+ return _c
822+}
823+
824+func (_c *MockPageNavigator_LoadURI_Call) RunAndReturn(run func(ctx context.Context, uri string) error) *MockPageNavigator_LoadURI_Call {
825+ _c.Call.Return(run)
826+ return _c
827+}
828+
829+// Reload provides a mock function for the type MockPageNavigator
830+func (_mock *MockPageNavigator) Reload(ctx context.Context) error {
831+ ret := _mock.Called(ctx)
832+
833+ if len(ret) == 0 {
834+ panic("no return value specified for Reload")
835+ }
836+
837+ var r0 error
838+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
839+ r0 = returnFunc(ctx)
840+ } else {
841+ r0 = ret.Error(0)
842+ }
843+ return r0
844+}
845+
846+// MockPageNavigator_Reload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reload'
847+type MockPageNavigator_Reload_Call struct {
848+ *mock.Call
849+}
850+
851+// Reload is a helper method to define mock.On call
852+// - ctx context.Context
853+func (_e *MockPageNavigator_Expecter) Reload(ctx any) *MockPageNavigator_Reload_Call {
854+ return &MockPageNavigator_Reload_Call{Call: _e.mock.On("Reload", ctx)}
855+}
856+
857+func (_c *MockPageNavigator_Reload_Call) Run(run func(ctx context.Context)) *MockPageNavigator_Reload_Call {
858+ _c.Call.Run(func(args mock.Arguments) {
859+ var arg0 context.Context
860+ if args[0] != nil {
861+ arg0 = args[0].(context.Context)
862+ }
863+ run(
864+ arg0,
865+ )
866+ })
867+ return _c
868+}
869+
870+func (_c *MockPageNavigator_Reload_Call) Return(err error) *MockPageNavigator_Reload_Call {
871+ _c.Call.Return(err)
872+ return _c
873+}
874+
875+func (_c *MockPageNavigator_Reload_Call) RunAndReturn(run func(ctx context.Context) error) *MockPageNavigator_Reload_Call {
876+ _c.Call.Return(run)
877+ return _c
878+}
879+
880+// ReloadBypassCache provides a mock function for the type MockPageNavigator
881+func (_mock *MockPageNavigator) ReloadBypassCache(ctx context.Context) error {
882+ ret := _mock.Called(ctx)
883+
884+ if len(ret) == 0 {
885+ panic("no return value specified for ReloadBypassCache")
886+ }
887+
888+ var r0 error
889+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
890+ r0 = returnFunc(ctx)
891+ } else {
892+ r0 = ret.Error(0)
893+ }
894+ return r0
895+}
896+
897+// MockPageNavigator_ReloadBypassCache_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReloadBypassCache'
898+type MockPageNavigator_ReloadBypassCache_Call struct {
899+ *mock.Call
900+}
901+
902+// ReloadBypassCache is a helper method to define mock.On call
903+// - ctx context.Context
904+func (_e *MockPageNavigator_Expecter) ReloadBypassCache(ctx any) *MockPageNavigator_ReloadBypassCache_Call {
905+ return &MockPageNavigator_ReloadBypassCache_Call{Call: _e.mock.On("ReloadBypassCache", ctx)}
906+}
907+
908+func (_c *MockPageNavigator_ReloadBypassCache_Call) Run(run func(ctx context.Context)) *MockPageNavigator_ReloadBypassCache_Call {
909+ _c.Call.Run(func(args mock.Arguments) {
910+ var arg0 context.Context
911+ if args[0] != nil {
912+ arg0 = args[0].(context.Context)
913+ }
914+ run(
915+ arg0,
916+ )
917+ })
918+ return _c
919+}
920+
921+func (_c *MockPageNavigator_ReloadBypassCache_Call) Return(err error) *MockPageNavigator_ReloadBypassCache_Call {
922+ _c.Call.Return(err)
923+ return _c
924+}
925+
926+func (_c *MockPageNavigator_ReloadBypassCache_Call) RunAndReturn(run func(ctx context.Context) error) *MockPageNavigator_ReloadBypassCache_Call {
927+ _c.Call.Return(run)
928+ return _c
929+}
930+
931+// Stop provides a mock function for the type MockPageNavigator
932+func (_mock *MockPageNavigator) Stop(ctx context.Context) error {
933+ ret := _mock.Called(ctx)
934+
935+ if len(ret) == 0 {
936+ panic("no return value specified for Stop")
937+ }
938+
939+ var r0 error
940+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
941+ r0 = returnFunc(ctx)
942+ } else {
943+ r0 = ret.Error(0)
944+ }
945+ return r0
946+}
947+
948+// MockPageNavigator_Stop_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stop'
949+type MockPageNavigator_Stop_Call struct {
950+ *mock.Call
951+}
952+
953+// Stop is a helper method to define mock.On call
954+// - ctx context.Context
955+func (_e *MockPageNavigator_Expecter) Stop(ctx any) *MockPageNavigator_Stop_Call {
956+ return &MockPageNavigator_Stop_Call{Call: _e.mock.On("Stop", ctx)}
957+}
958+
959+func (_c *MockPageNavigator_Stop_Call) Run(run func(ctx context.Context)) *MockPageNavigator_Stop_Call {
960+ _c.Call.Run(func(args mock.Arguments) {
961+ var arg0 context.Context
962+ if args[0] != nil {
963+ arg0 = args[0].(context.Context)
964+ }
965+ run(
966+ arg0,
967+ )
968+ })
969+ return _c
970+}
971+
972+func (_c *MockPageNavigator_Stop_Call) Return(err error) *MockPageNavigator_Stop_Call {
973+ _c.Call.Return(err)
974+ return _c
975+}
976+
977+func (_c *MockPageNavigator_Stop_Call) RunAndReturn(run func(ctx context.Context) error) *MockPageNavigator_Stop_Call {
978+ _c.Call.Return(run)
979+ return _c
980+}
981+
553982 // NewMockWebView creates a new instance of MockWebView. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
554983 // The first argument is typically a *testing.T value.
555984 func NewMockWebView(t interface {
556985 mock.TestingT
557986 Cleanup(func())
558987 }) *MockWebView {
988+ if helper, ok := t.(interface{ Helper() }); ok {
989+ helper.Helper()
990+ }
991+
559992 mock := &MockWebView{}
560993 mock.Mock.Test(t)
561994
562- t.Cleanup(func() { mock.AssertExpectations(t) })
995+ t.Cleanup(func() {
996+ if helper, ok := t.(interface{ Helper() }); ok {
997+ helper.Helper()
998+ }
999+ mock.AssertExpectations(t)
1000+ })
5631001
5641002 return mock
5651003 }
@@ -1889,10 +2327,19 @@ func NewMockWebViewPool(t interface {
18892327 mock.TestingT
18902328 Cleanup(func())
18912329 }) *MockWebViewPool {
2330+ if helper, ok := t.(interface{ Helper() }); ok {
2331+ helper.Helper()
2332+ }
2333+
18922334 mock := &MockWebViewPool{}
18932335 mock.Mock.Test(t)
18942336
1895- t.Cleanup(func() { mock.AssertExpectations(t) })
2337+ t.Cleanup(func() {
2338+ if helper, ok := t.(interface{ Helper() }); ok {
2339+ helper.Helper()
2340+ }
2341+ mock.AssertExpectations(t)
2342+ })
18962343
18972344 return mock
18982345 }
@@ -2181,10 +2628,19 @@ func NewMockWebViewFactory(t interface {
21812628 mock.TestingT
21822629 Cleanup(func())
21832630 }) *MockWebViewFactory {
2631+ if helper, ok := t.(interface{ Helper() }); ok {
2632+ helper.Helper()
2633+ }
2634+
21842635 mock := &MockWebViewFactory{}
21852636 mock.Mock.Test(t)
21862637
2187- t.Cleanup(func() { mock.AssertExpectations(t) })
2638+ t.Cleanup(func() {
2639+ if helper, ok := t.(interface{ Helper() }); ok {
2640+ helper.Helper()
2641+ }
2642+ mock.AssertExpectations(t)
2643+ })
21882644
21892645 return mock
21902646 }
@@ -2338,10 +2794,19 @@ func NewMockDevToolsOpener(t interface {
23382794 mock.TestingT
23392795 Cleanup(func())
23402796 }) *MockDevToolsOpener {
2797+ if helper, ok := t.(interface{ Helper() }); ok {
2798+ helper.Helper()
2799+ }
2800+
23412801 mock := &MockDevToolsOpener{}
23422802 mock.Mock.Test(t)
23432803
2344- t.Cleanup(func() { mock.AssertExpectations(t) })
2804+ t.Cleanup(func() {
2805+ if helper, ok := t.(interface{ Helper() }); ok {
2806+ helper.Helper()
2807+ }
2808+ mock.AssertExpectations(t)
2809+ })
23452810
23462811 return mock
23472812 }
@@ -2398,10 +2863,19 @@ func NewMockPrinter(t interface {
23982863 mock.TestingT
23992864 Cleanup(func())
24002865 }) *MockPrinter {
2866+ if helper, ok := t.(interface{ Helper() }); ok {
2867+ helper.Helper()
2868+ }
2869+
24012870 mock := &MockPrinter{}
24022871 mock.Mock.Test(t)
24032872
2404- t.Cleanup(func() { mock.AssertExpectations(t) })
2873+ t.Cleanup(func() {
2874+ if helper, ok := t.(interface{ Helper() }); ok {
2875+ helper.Helper()
2876+ }
2877+ mock.AssertExpectations(t)
2878+ })
24052879
24062880 return mock
24072881 }
@@ -2458,10 +2932,19 @@ func NewMockPopupLifecycleCapable(t interface {
24582932 mock.TestingT
24592933 Cleanup(func())
24602934 }) *MockPopupLifecycleCapable {
2935+ if helper, ok := t.(interface{ Helper() }); ok {
2936+ helper.Helper()
2937+ }
2938+
24612939 mock := &MockPopupLifecycleCapable{}
24622940 mock.Mock.Test(t)
24632941
2464- t.Cleanup(func() { mock.AssertExpectations(t) })
2942+ t.Cleanup(func() {
2943+ if helper, ok := t.(interface{ Helper() }); ok {
2944+ helper.Helper()
2945+ }
2946+ mock.AssertExpectations(t)
2947+ })
24652948
24662949 return mock
24672950 }
@@ -2638,10 +3121,19 @@ func NewMockPopupFeatureResolver(t interface {
26383121 mock.TestingT
26393122 Cleanup(func())
26403123 }) *MockPopupFeatureResolver {
3124+ if helper, ok := t.(interface{ Helper() }); ok {
3125+ helper.Helper()
3126+ }
3127+
26413128 mock := &MockPopupFeatureResolver{}
26423129 mock.Mock.Test(t)
26433130
2644- t.Cleanup(func() { mock.AssertExpectations(t) })
3131+ t.Cleanup(func() {
3132+ if helper, ok := t.(interface{ Helper() }); ok {
3133+ helper.Helper()
3134+ }
3135+ mock.AssertExpectations(t)
3136+ })
26453137
26463138 return mock
26473139 }
@@ -2709,10 +3201,19 @@ func NewMockPopupOpenerCapable(t interface {
27093201 mock.TestingT
27103202 Cleanup(func())
27113203 }) *MockPopupOpenerCapable {
3204+ if helper, ok := t.(interface{ Helper() }); ok {
3205+ helper.Helper()
3206+ }
3207+
27123208 mock := &MockPopupOpenerCapable{}
27133209 mock.Mock.Test(t)
27143210
2715- t.Cleanup(func() { mock.AssertExpectations(t) })
3211+ t.Cleanup(func() {
3212+ if helper, ok := t.(interface{ Helper() }); ok {
3213+ helper.Helper()
3214+ }
3215+ mock.AssertExpectations(t)
3216+ })
27163217
27173218 return mock
27183219 }
@@ -2906,10 +3407,19 @@ func NewMockOAuthCallbackCapable(t interface {
29063407 mock.TestingT
29073408 Cleanup(func())
29083409 }) *MockOAuthCallbackCapable {
3410+ if helper, ok := t.(interface{ Helper() }); ok {
3411+ helper.Helper()
3412+ }
3413+
29093414 mock := &MockOAuthCallbackCapable{}
29103415 mock.Mock.Test(t)
29113416
2912- t.Cleanup(func() { mock.AssertExpectations(t) })
3417+ t.Cleanup(func() {
3418+ if helper, ok := t.(interface{ Helper() }); ok {
3419+ helper.Helper()
3420+ }
3421+ mock.AssertExpectations(t)
3422+ })
29133423
29143424 return mock
29153425 }
@@ -3046,10 +3556,19 @@ func NewMockTextInputTargetProvider(t interface {
30463556 mock.TestingT
30473557 Cleanup(func())
30483558 }) *MockTextInputTargetProvider {
3559+ if helper, ok := t.(interface{ Helper() }); ok {
3560+ helper.Helper()
3561+ }
3562+
30493563 mock := &MockTextInputTargetProvider{}
30503564 mock.Mock.Test(t)
30513565
3052- t.Cleanup(func() { mock.AssertExpectations(t) })
3566+ t.Cleanup(func() {
3567+ if helper, ok := t.(interface{ Helper() }); ok {
3568+ helper.Helper()
3569+ }
3570+ mock.AssertExpectations(t)
3571+ })
30533572
30543573 return mock
30553574 }
@@ -3119,10 +3638,19 @@ func NewMockPageScrollable(t interface {
31193638 mock.TestingT
31203639 Cleanup(func())
31213640 }) *MockPageScrollable {
3641+ if helper, ok := t.(interface{ Helper() }); ok {
3642+ helper.Helper()
3643+ }
3644+
31223645 mock := &MockPageScrollable{}
31233646 mock.Mock.Test(t)
31243647
3125- t.Cleanup(func() { mock.AssertExpectations(t) })
3648+ t.Cleanup(func() {
3649+ if helper, ok := t.(interface{ Helper() }); ok {
3650+ helper.Helper()
3651+ }
3652+ mock.AssertExpectations(t)
3653+ })
31263654
31273655 return mock
31283656 }
M internal/application/port/runtime_activity.go +17 -0
@@ -33,6 +33,23 @@ type RuntimeActivity interface {
3333 Subscribe(func(RuntimeActivitySnapshot)) (unsubscribe func())
3434 }
3535
36+// RuntimeActivityProvider is an optional Engine capability. Engines whose
37+// native runtime has work that must settle before idle exit (CEF) implement
38+// it; engines without such work (WebKit) do not, and callers treat that as
39+// always quiescent.
40+type RuntimeActivityProvider interface {
41+ RuntimeActivity() RuntimeActivity
42+}
43+
44+// EngineRuntimeActivity returns the engine's runtime activity, or nil when
45+// the engine does not report any.
46+func EngineRuntimeActivity(engine Engine) RuntimeActivity {
47+ if provider, ok := engine.(RuntimeActivityProvider); ok && provider != nil {
48+ return provider.RuntimeActivity()
49+ }
50+ return nil
51+}
52+
3653 // PersistenceDrain is the narrow activity/drain boundary for persistence
3754 // owners whose saves cross the GTK/database boundary. Active covers pending
3855 // debounce, in-flight capture/execution, and unsettled terminal failure.
A internal/application/port/webui_messages.go +101 -0
@@ -0,0 +1,101 @@
1+package port
2+
3+// WebUI message types exchanged between internal pages and Go handlers.
4+// Both the handler registries and the systemviews bridge use these names,
5+// so a rename or a new message only has to be declared here.
6+const (
7+ MsgHistoryTimeline = "history_timeline"
8+ MsgHistoryTimelineByDomain = "history_timeline_by_domain"
9+ MsgHistoryTimelineWindow = "history_timeline_window"
10+ MsgHistorySearchFTS = "history_search_fts"
11+ MsgHistoryDeleteEntry = "history_delete_entry"
12+ MsgHistoryDeleteRange = "history_delete_range"
13+ MsgHistoryClearAll = "history_clear_all"
14+ MsgHistoryStats = "history_stats"
15+ MsgHistoryAnalytics = "history_analytics"
16+ MsgHistoryDomainStats = "history_domain_stats"
17+ MsgHistoryDeleteDomain = "history_delete_domain"
18+
19+ MsgFavoriteList = "favorite_list"
20+ MsgFavoriteCreate = "favorite_create"
21+ MsgFavoriteUpdate = "favorite_update"
22+ MsgFavoriteDelete = "favorite_delete"
23+ MsgFavoriteSetShortcut = "favorite_set_shortcut"
24+ MsgFavoriteGetByShortcut = "favorite_get_by_shortcut"
25+
26+ MsgTagList = "tag_list"
27+ MsgTagCreate = "tag_create"
28+ MsgTagDelete = "tag_delete"
29+ MsgTagUpdate = "tag_update"
30+ MsgTagAssign = "tag_assign"
31+ MsgTagRemove = "tag_remove"
32+
33+ MsgSaveConfig = "save_config"
34+ MsgGetKeybindings = "get_keybindings"
35+ MsgSetKeybinding = "set_keybinding"
36+ MsgResetKeybinding = "reset_keybinding"
37+ MsgResetAllKeybindings = "reset_all_keybindings"
38+)
39+
40+// WebUICallbacks names the JS functions that receive a message's result.
41+type WebUICallbacks struct {
42+ Success string
43+ Failure string
44+}
45+
46+// Homepage callbacks shared by every history, favorite and tag message.
47+var homepageCallbacks = WebUICallbacks{Success: "__dumber_homepage_response", Failure: "__dumber_error"}
48+
49+// WebUIMessageCallbacks is the single source of truth for which JS callbacks
50+// answer each WebUI message. Handlers register with these callbacks and the
51+// systemviews bridge waits on them.
52+var WebUIMessageCallbacks = map[string]WebUICallbacks{
53+ MsgHistoryTimeline: homepageCallbacks,
54+ MsgHistoryTimelineByDomain: homepageCallbacks,
55+ MsgHistoryTimelineWindow: homepageCallbacks,
56+ MsgHistorySearchFTS: homepageCallbacks,
57+ MsgHistoryDeleteEntry: homepageCallbacks,
58+ MsgHistoryDeleteRange: homepageCallbacks,
59+ MsgHistoryClearAll: homepageCallbacks,
60+ MsgHistoryStats: homepageCallbacks,
61+ MsgHistoryAnalytics: homepageCallbacks,
62+ MsgHistoryDomainStats: homepageCallbacks,
63+ MsgHistoryDeleteDomain: homepageCallbacks,
64+
65+ MsgFavoriteList: homepageCallbacks,
66+ MsgFavoriteCreate: homepageCallbacks,
67+ MsgFavoriteUpdate: homepageCallbacks,
68+ MsgFavoriteDelete: homepageCallbacks,
69+ MsgFavoriteSetShortcut: homepageCallbacks,
70+ MsgFavoriteGetByShortcut: homepageCallbacks,
71+
72+ MsgTagList: homepageCallbacks,
73+ MsgTagCreate: homepageCallbacks,
74+ MsgTagDelete: homepageCallbacks,
75+ MsgTagUpdate: homepageCallbacks,
76+ MsgTagAssign: homepageCallbacks,
77+ MsgTagRemove: homepageCallbacks,
78+
79+ MsgSaveConfig: {Success: "__dumber_config_saved", Failure: "__dumber_config_error"},
80+ MsgGetKeybindings: {Success: "__dumber_keybindings_loaded", Failure: "__dumber_keybindings_error"},
81+ MsgSetKeybinding: {Success: "__dumber_keybinding_set", Failure: "__dumber_keybinding_set_error"},
82+ MsgResetKeybinding: {Success: "__dumber_keybinding_reset", Failure: "__dumber_keybinding_reset_error"},
83+ MsgResetAllKeybindings: {Success: "__dumber_keybindings_reset_all", Failure: "__dumber_keybindings_reset_all_error"},
84+}
85+
86+// RegisterWebUIMessage registers handler for msgType using the callbacks
87+// declared in WebUIMessageCallbacks, in the main world.
88+func RegisterWebUIMessage(router WebUIHandlerRouter, msgType string, handler WebUIMessageHandler) error {
89+ cb, ok := WebUIMessageCallbacks[msgType]
90+ if !ok {
91+ return &UnknownWebUIMessageError{Type: msgType}
92+ }
93+ return router.RegisterHandlerWithCallbacks(msgType, cb.Success, cb.Failure, "", handler)
94+}
95+
96+// UnknownWebUIMessageError reports a message type missing from the contract.
97+type UnknownWebUIMessageError struct{ Type string }
98+
99+func (e *UnknownWebUIMessageError) Error() string {
100+ return "unknown webui message type: " + e.Type
101+}
M internal/application/port/webview.go +14 -10
@@ -172,16 +172,9 @@ type FindController interface {
172172 DisconnectSignal(id uint)
173173 }
174174
175-// WebView defines the port interface for browser view operations.
176-// This interface abstracts the underlying browser engine (WebKit, etc.)
177-// and exposes only the navigation and state capabilities needed by
178-// the application layer.
179-type WebView interface {
180- // ID returns the unique identifier for this WebView.
181- ID() WebViewID
182-
183- // --- Navigation ---
184-
175+// PageNavigator is the navigation subset of WebView. Consumers that only
176+// load, reload, or stop pages depend on this instead of the full WebView.
177+type PageNavigator interface {
185178 // LoadURI navigates to the specified URI.
186179 LoadURI(ctx context.Context, uri string) error
187180
@@ -204,6 +197,17 @@ type WebView interface {
204197 // GoForward navigates forward in history.
205198 // Uses WebKit native navigation if available, falls back to JavaScript history.forward() for SPA compatibility.
206199 GoForward(ctx context.Context) error
200+}
201+
202+// WebView defines the port interface for browser view operations.
203+// This interface abstracts the underlying browser engine (WebKit, etc.)
204+// and exposes only the navigation and state capabilities needed by
205+// the application layer.
206+type WebView interface {
207+ PageNavigator
208+
209+ // ID returns the unique identifier for this WebView.
210+ ID() WebViewID
207211
208212 // --- State Queries ---
209213
M internal/application/usecase/check_media.go +11 -2
@@ -9,7 +9,8 @@ import (
99 "github.com/bnema/dumber/internal/logging"
1010 )
1111
12-// CheckMediaUseCase validates media playback requirements at startup.
12+// CheckMediaUseCase validates media playback requirements and exposes the
13+// detailed diagnostics used by the doctor command.
1314 type CheckMediaUseCase struct {
1415 diagnostics port.MediaDiagnostics
1516 }
@@ -34,13 +35,21 @@ type CheckMediaOutput struct {
3435 Warnings []string
3536 }
3637
38+// Diagnose returns the full media diagnostics report. It never returns nil.
39+func (uc *CheckMediaUseCase) Diagnose(ctx context.Context) *port.MediaDiagnosticsResult {
40+ if result := uc.diagnostics.RunDiagnostics(ctx); result != nil {
41+ return result
42+ }
43+ return &port.MediaDiagnosticsResult{}
44+}
45+
3746 // Execute checks media playback requirements.
3847 // Returns error if GStreamer is not installed (fatal).
3948 // Returns warnings for missing hardware acceleration (non-fatal).
4049 func (uc *CheckMediaUseCase) Execute(ctx context.Context, input CheckMediaInput) (*CheckMediaOutput, error) {
4150 log := logging.FromContext(ctx)
4251
43- result := uc.diagnostics.RunDiagnostics(ctx)
52+ result := uc.Diagnose(ctx)
4453
4554 // GStreamer is required - fail early if not installed
4655 if !result.GStreamerAvailable {
A internal/application/usecase/check_media_test.go +47 -0
@@ -0,0 +1,47 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
13+)
14+
15+func TestCheckMediaDiagnoseNeverReturnsNil(t *testing.T) {
16+ diag := portmocks.NewMockMediaDiagnostics(t)
17+ diag.EXPECT().RunDiagnostics(mock.Anything).Return(nil)
18+
19+ got := NewCheckMediaUseCase(diag).Diagnose(context.Background())
20+
21+ require.NotNil(t, got)
22+ assert.False(t, got.GStreamerAvailable)
23+}
24+
25+func TestCheckMediaExecuteFailsWithoutGStreamer(t *testing.T) {
26+ diag := portmocks.NewMockMediaDiagnostics(t)
27+ diag.EXPECT().RunDiagnostics(mock.Anything).Return(nil)
28+
29+ _, err := NewCheckMediaUseCase(diag).Execute(context.Background(), CheckMediaInput{})
30+
31+ require.Error(t, err)
32+}
33+
34+func TestCheckMediaExecuteReturnsSummary(t *testing.T) {
35+ diag := portmocks.NewMockMediaDiagnostics(t)
36+ diag.EXPECT().RunDiagnostics(mock.Anything).Return(&port.MediaDiagnosticsResult{
37+ GStreamerAvailable: true,
38+ HWAccelAvailable: true,
39+ Warnings: []string{"no av1"},
40+ })
41+
42+ out, err := NewCheckMediaUseCase(diag).Execute(context.Background(), CheckMediaInput{})
43+
44+ require.NoError(t, err)
45+ assert.True(t, out.HWAccelAvailable)
46+ assert.Equal(t, []string{"no av1"}, out.Warnings)
47+}
M internal/application/usecase/navigate.go +3 -3
@@ -29,7 +29,7 @@ func NewNavigateUseCase(defaultZoom float64) *NavigateUseCase {
2929 type NavigateInput struct {
3030 URL string
3131 PaneID string
32- WebView port.WebView
32+ WebView port.PageNavigator
3333 }
3434
3535 // NavigateOutput contains the result of navigation.
@@ -62,7 +62,7 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
6262 }
6363
6464 // Reload reloads the current page.
65-func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, bypassCache bool) error {
65+func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.PageNavigator, bypassCache bool) error {
6666 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
6767 log.Debug().Bool("bypass_cache", bypassCache).Msg("reloading page")
6868
@@ -73,7 +73,7 @@ func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, byp
7373 }
7474
7575 // Stop stops the current page load.
76-func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error {
76+func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.PageNavigator) error {
7777 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
7878 log.Debug().Msg("stopping page load")
7979 return webview.Stop(ctx)
M internal/application/usecase/navigate_test.go +23 -45
@@ -6,61 +6,27 @@ import (
66 "math"
77 "testing"
88
9- "github.com/bnema/dumber/internal/application/port"
9+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
1010 "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/stretchr/testify/mock"
1112 "github.com/stretchr/testify/require"
1213 )
1314
14-type fakeWebView struct {
15- loaded string
16- loadErr error
15+func newLoadingNavigator(t *testing.T, url string, err error) *portmocks.MockPageNavigator {
16+ t.Helper()
17+ nav := portmocks.NewMockPageNavigator(t)
18+ nav.EXPECT().LoadURI(mock.Anything, url).Return(err).Once()
19+ return nav
1720 }
1821
19-func (f *fakeWebView) LoadURI(_ context.Context, uri string) error {
20- if f.loadErr != nil {
21- return f.loadErr
22- }
23- f.loaded = uri
24- return nil
25-}
26-func (*fakeWebView) LoadHTML(context.Context, string, string) error { return nil }
27-func (*fakeWebView) Reload(context.Context) error { return nil }
28-func (*fakeWebView) ReloadBypassCache(context.Context) error { return nil }
29-func (*fakeWebView) Stop(context.Context) error { return nil }
30-func (*fakeWebView) GoBack(context.Context) error { return nil }
31-func (*fakeWebView) GoForward(context.Context) error { return nil }
32-func (*fakeWebView) ID() port.WebViewID { return 1 }
33-func (*fakeWebView) State() port.WebViewState { return port.WebViewState{} }
34-func (f *fakeWebView) URI() string { return f.loaded }
35-func (*fakeWebView) Title() string { return "" }
36-func (*fakeWebView) IsLoading() bool { return false }
37-func (*fakeWebView) EstimatedProgress() float64 { return 1 }
38-func (*fakeWebView) CanGoBack() bool { return false }
39-func (*fakeWebView) CanGoForward() bool { return false }
40-func (*fakeWebView) SetZoomLevel(context.Context, float64) error { return nil }
41-func (*fakeWebView) GetZoomLevel() float64 { return 1 }
42-func (*fakeWebView) GetFindController() port.FindController { return nil }
43-func (*fakeWebView) SetCallbacks(*port.WebViewCallbacks) {}
44-func (*fakeWebView) RunJavaScript(context.Context, string) {}
45-func (*fakeWebView) SetBackgroundColor(float64, float64, float64, float64) {
46-}
47-func (*fakeWebView) ResetBackgroundToDefault() {}
48-func (*fakeWebView) Favicon() port.Texture { return nil }
49-func (*fakeWebView) Generation() uint64 { return 0 }
50-func (*fakeWebView) IsFullscreen() bool { return false }
51-func (*fakeWebView) IsPlayingAudio() bool { return false }
52-func (*fakeWebView) IsDestroyed() bool { return false }
53-func (*fakeWebView) Destroy() {}
54-
5522 func TestNavigateUseCase_ExecuteLoadsURLAndReturnsDefaultZoom(t *testing.T) {
5623 ctx := context.Background()
57- wv := &fakeWebView{}
24+ wv := newLoadingNavigator(t, "https://example.com", nil)
5825 uc := NewNavigateUseCase(entity.ZoomDefault)
5926
6027 out, err := uc.Execute(ctx, NavigateInput{URL: "https://example.com", PaneID: "pane-1", WebView: wv})
6128
6229 require.NoError(t, err)
63- require.Equal(t, "https://example.com", wv.loaded)
6430 require.InDelta(t, entity.ZoomDefault, out.AppliedZoom, 0.0001)
6531 }
6632
@@ -80,13 +46,12 @@ func TestNavigateUseCase_ExecuteUsesConfiguredOrFallbackZoom(t *testing.T) {
8046 for _, tt := range tests {
8147 t.Run(tt.name, func(t *testing.T) {
8248 ctx := context.Background()
83- wv := &fakeWebView{}
49+ wv := newLoadingNavigator(t, "https://example.com", nil)
8450 uc := NewNavigateUseCase(tt.configuredZoom)
8551
8652 out, err := uc.Execute(ctx, NavigateInput{URL: "https://example.com", PaneID: "pane-1", WebView: wv})
8753
8854 require.NoError(t, err)
89- require.Equal(t, "https://example.com", wv.loaded)
9055 require.InDelta(t, tt.wantZoom, out.AppliedZoom, 0.0001)
9156 })
9257 }
@@ -95,7 +60,7 @@ func TestNavigateUseCase_ExecuteUsesConfiguredOrFallbackZoom(t *testing.T) {
9560 func TestNavigateUseCase_ExecuteReturnsLoadError(t *testing.T) {
9661 ctx := context.Background()
9762 loadErr := errors.New("load failed")
98- wv := &fakeWebView{loadErr: loadErr}
63+ wv := newLoadingNavigator(t, "https://example.com", loadErr)
9964 uc := NewNavigateUseCase(entity.ZoomDefault)
10065
10166 out, err := uc.Execute(ctx, NavigateInput{URL: "https://example.com", PaneID: "pane-1", WebView: wv})
@@ -104,3 +69,16 @@ func TestNavigateUseCase_ExecuteReturnsLoadError(t *testing.T) {
10469 require.ErrorIs(t, err, loadErr)
10570 require.Contains(t, err.Error(), "failed to load URL")
10671 }
72+
73+func TestNavigateUseCase_ReloadHonoursBypassCache(t *testing.T) {
74+ ctx := context.Background()
75+ uc := NewNavigateUseCase(entity.ZoomDefault)
76+
77+ nav := portmocks.NewMockPageNavigator(t)
78+ nav.EXPECT().ReloadBypassCache(mock.Anything).Return(nil).Once()
79+ require.NoError(t, uc.Reload(ctx, nav, true))
80+
81+ nav = portmocks.NewMockPageNavigator(t)
82+ nav.EXPECT().Reload(mock.Anything).Return(nil).Once()
83+ require.NoError(t, uc.Reload(ctx, nav, false))
84+}
D internal/application/usecase/run_media_diagnostics.go +0 -66
@@ -1,66 +0,0 @@
1-// Package usecase contains application business logic.
2-package usecase
3-
4-import (
5- "context"
6-
7- "github.com/bnema/dumber/internal/application/port"
8-)
9-
10-// RunMediaDiagnosticsUseCase retrieves detailed media playback diagnostics.
11-type RunMediaDiagnosticsUseCase struct {
12- diagnostics port.MediaDiagnostics
13-}
14-
15-func NewRunMediaDiagnosticsUseCase(diagnostics port.MediaDiagnostics) *RunMediaDiagnosticsUseCase {
16- return &RunMediaDiagnosticsUseCase{diagnostics: diagnostics}
17-}
18-
19-type RunMediaDiagnosticsInput struct{}
20-
21-type RunMediaDiagnosticsOutput struct {
22- GStreamerAvailable bool
23- HasVAPlugin bool
24- HasVAAPIPlugin bool
25- HasNVCodecPlugin bool
26-
27- AV1Decoders []string
28- H264Decoders []string
29- H265Decoders []string
30- VP9Decoders []string
31-
32- VAAPIAvailable bool
33- VAAPIDriver string
34- VAAPIVersion string
35-
36- HWAccelAvailable bool
37- AV1HWAvailable bool
38- Warnings []string
39-}
40-
41-func (uc *RunMediaDiagnosticsUseCase) Execute(ctx context.Context, _ RunMediaDiagnosticsInput) (*RunMediaDiagnosticsOutput, error) {
42- result := uc.diagnostics.RunDiagnostics(ctx)
43- if result == nil {
44- return &RunMediaDiagnosticsOutput{}, nil
45- }
46-
47- return &RunMediaDiagnosticsOutput{
48- GStreamerAvailable: result.GStreamerAvailable,
49- HasVAPlugin: result.HasVAPlugin,
50- HasVAAPIPlugin: result.HasVAAPIPlugin,
51- HasNVCodecPlugin: result.HasNVCodecPlugin,
52-
53- AV1Decoders: result.AV1Decoders,
54- H264Decoders: result.H264Decoders,
55- H265Decoders: result.H265Decoders,
56- VP9Decoders: result.VP9Decoders,
57-
58- VAAPIAvailable: result.VAAPIAvailable,
59- VAAPIDriver: result.VAAPIDriver,
60- VAAPIVersion: result.VAAPIVersion,
61-
62- HWAccelAvailable: result.HWAccelAvailable,
63- AV1HWAvailable: result.AV1HWAvailable,
64- Warnings: result.Warnings,
65- }, nil
66-}
M internal/application/usecase/save_webui_config.go +26 -0
@@ -58,6 +58,10 @@ func normalizeWebUIConfig(cfg dto.WebUIConfig) dto.WebUIConfig {
5858 cfg.Appearance.GtkFont = strings.TrimSpace(cfg.Appearance.GtkFont)
5959 cfg.Appearance.ColorScheme = strings.TrimSpace(cfg.Appearance.ColorScheme)
6060 cfg.DefaultSearchEngine = strings.TrimSpace(cfg.DefaultSearchEngine)
61+ cfg.Performance.Profile = strings.TrimSpace(cfg.Performance.Profile)
62+ if cfg.Performance.Profile == performanceProfileCustom {
63+ cfg.Performance.Custom = clampCustomPerformance(cfg.Performance.Custom)
64+ }
6165 if len(cfg.SearchShortcuts) > 0 {
6266 normalized := make(map[string]dto.SearchShortcut, len(cfg.SearchShortcuts))
6367 for key, shortcut := range cfg.SearchShortcuts {
@@ -74,6 +78,28 @@ func normalizeWebUIConfig(cfg dto.WebUIConfig) dto.WebUIConfig {
7478 return cfg
7579 }
7680
81+const performanceProfileCustom = "custom"
82+
83+// Bounds for user-editable custom performance fields.
84+const (
85+ maxSkiaCPUThreads = 8
86+ maxSkiaGPUThreads = 8
87+ maxWebProcessMemoryMB = 16384
88+ maxNetworkProcessMemoryMB = 4096
89+ maxWebViewPoolPrewarm = 20
90+)
91+
92+// clampCustomPerformance keeps custom profile values inside supported bounds.
93+// A GPU thread count of -1 means "engine default".
94+func clampCustomPerformance(c dto.WebUICustomPerformanceConfig) dto.WebUICustomPerformanceConfig {
95+ c.SkiaCPUThreads = min(max(c.SkiaCPUThreads, 0), maxSkiaCPUThreads)
96+ c.SkiaGPUThreads = min(max(c.SkiaGPUThreads, -1), maxSkiaGPUThreads)
97+ c.WebProcessMemoryMB = min(max(c.WebProcessMemoryMB, 0), maxWebProcessMemoryMB)
98+ c.NetworkProcessMemoryMB = min(max(c.NetworkProcessMemoryMB, 0), maxNetworkProcessMemoryMB)
99+ c.WebViewPoolPrewarm = min(max(c.WebViewPoolPrewarm, 0), maxWebViewPoolPrewarm)
100+ return c
101+}
102+
77103 func validateWebUIConfig(cfg dto.WebUIConfig) error {
78104 var errs []string
79105
M internal/application/usecase/save_webui_config_test.go +45 -0
@@ -97,6 +97,51 @@ func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) {
9797 require.Equal(t, "DuckDuckGo", saved.SearchShortcuts["ddg"].Description)
9898 }
9999
100+func TestSaveWebUIConfigUseCase_ClampsCustomPerformanceOnlyForCustomProfile(t *testing.T) {
101+ out := dto.WebUICustomPerformanceConfig{
102+ SkiaCPUThreads: 99,
103+ SkiaGPUThreads: -5,
104+ WebProcessMemoryMB: -1,
105+ NetworkProcessMemoryMB: 99999,
106+ WebViewPoolPrewarm: 500,
107+ }
108+
109+ tests := []struct {
110+ name string
111+ profile string
112+ want dto.WebUICustomPerformanceConfig
113+ }{
114+ {
115+ name: "custom is clamped",
116+ profile: " custom ",
117+ want: dto.WebUICustomPerformanceConfig{
118+ SkiaCPUThreads: 8,
119+ SkiaGPUThreads: -1,
120+ WebProcessMemoryMB: 0,
121+ NetworkProcessMemoryMB: 4096,
122+ WebViewPoolPrewarm: 20,
123+ },
124+ },
125+ {name: "other profiles are untouched", profile: "balanced", want: out},
126+ }
127+
128+ for _, tt := range tests {
129+ t.Run(tt.name, func(t *testing.T) {
130+ saver := portmocks.NewMockWebUIConfigSaver(t)
131+ var saved dto.WebUIConfig
132+ saver.EXPECT().SaveWebUIConfig(mock.Anything, mock.AnythingOfType("dto.WebUIConfig")).Run(func(_ context.Context, cfg dto.WebUIConfig) {
133+ saved = cfg
134+ }).Return(nil).Once()
135+ cfg := validWebUIConfig()
136+ cfg.Performance.Profile = tt.profile
137+ cfg.Performance.Custom = out
138+
139+ require.NoError(t, usecase.NewSaveWebUIConfigUseCase(saver).Execute(context.Background(), cfg))
140+ require.Equal(t, tt.want, saved.Performance.Custom)
141+ })
142+ }
143+}
144+
100145 func validWebUIConfig() dto.WebUIConfig {
101146 palette := dto.ColorPalette{
102147 Background: "#ffffff",
M internal/bootstrap/crash_report.go +6 -6
@@ -128,18 +128,18 @@ func buildCrashMarkdown(report crashReport) string {
128128 lines := []string{
129129 "# Unexpected Close Report",
130130 "",
131- fmt.Sprintf("Generated: `%s`", report.GeneratedAt),
132- fmt.Sprintf("Session: `%s`", report.SessionID),
131+ fmt.Sprintf("Generated: %#q", report.GeneratedAt),
132+ fmt.Sprintf("Session: %#q", report.SessionID),
133133 fmt.Sprintf("Crashed PID: `%d`", report.CrashedPID),
134134 "",
135135 "## Reporter",
136136 fmt.Sprintf("- pid: `%d`", report.ReporterProcess.PID),
137137 fmt.Sprintf("- ppid: `%d`", report.ReporterProcess.PPID),
138- fmt.Sprintf("- go: `%s`", report.ReporterProcess.GoVersion),
138+ fmt.Sprintf("- go: %#q", report.ReporterProcess.GoVersion),
139139 "",
140140 "## Core Dump Diagnostics",
141- fmt.Sprintf("- RLIMIT_CORE soft: `%s`", report.CoreDumpDiagnostics.RLimitCoreSoft),
142- fmt.Sprintf("- RLIMIT_CORE hard: `%s`", report.CoreDumpDiagnostics.RLimitCoreHard),
141+ fmt.Sprintf("- RLIMIT_CORE soft: %#q", report.CoreDumpDiagnostics.RLimitCoreSoft),
142+ fmt.Sprintf("- RLIMIT_CORE hard: %#q", report.CoreDumpDiagnostics.RLimitCoreHard),
143143 fmt.Sprintf("- hint: %s", report.CoreDumpDiagnostics.Hint),
144144 }
145145
@@ -163,7 +163,7 @@ func buildCrashMarkdown(report crashReport) string {
163163 "Describe what you were doing just before Dumber closed unexpectedly.",
164164 "",
165165 "### Crash report",
166- fmt.Sprintf("- session id: `%s`", report.SessionID),
166+ fmt.Sprintf("- session id: %#q", report.SessionID),
167167 fmt.Sprintf("- crashed pid: `%d`", report.CrashedPID),
168168 "",
169169 "### Additional context",
M internal/cli/cmd/doctor.go +1 -5
@@ -88,11 +88,7 @@ func runDoctor(_ *cobra.Command, _ []string) error {
8888 mediaOK := true
8989 if runMedia {
9090 adapter := media.New()
91- mediaUC := usecase.NewRunMediaDiagnosticsUseCase(adapter)
92- mediaOut, err := mediaUC.Execute(app.Ctx(), usecase.RunMediaDiagnosticsInput{})
93- if err != nil {
94- return err
95- }
91+ mediaOut := usecase.NewCheckMediaUseCase(adapter).Diagnose(app.Ctx())
9692
9793 mediaOK = mediaOut.GStreamerAvailable
9894 report.Media = &styles.DoctorMediaReport{
M internal/infrastructure/cef/engine.go +1 -0
@@ -21,6 +21,7 @@ import (
2121
2222 // Compile-time interface check.
2323 var _ port.Engine = (*Engine)(nil)
24+var _ port.RuntimeActivityProvider = (*Engine)(nil)
2425 var _ port.AlreadyRunningAppRelaunchHandlerSetter = (*Engine)(nil)
2526
2627 // Engine implements port.Engine for the CEF browser backend.
M internal/infrastructure/cef/scheme_handler.go +23 -83
@@ -9,18 +9,14 @@ import (
99 "encoding/json"
1010 "fmt"
1111 "html"
12- "io"
1312 "io/fs"
1413 "maps"
1514 "net/http"
1615 "net/url"
17- "path"
18- "strconv"
1916 "strings"
2017 "sync"
2118 "unsafe"
2219
23- "github.com/andybalholm/brotli"
2420 purecef "github.com/bnema/purego-cef/cef"
2521
2622 "github.com/bnema/dumber/internal/application/port"
@@ -39,8 +35,18 @@ const (
3935 errorPath = "error"
4036 indexHTML = "index.html"
4137 maxSchemeTruncatedURLLength = 240
42- maxSystemviewsWASMBytes = 64 * 1024 * 1024
43- systemviewsAssetDir = "systemviews"
38+ maxSystemviewsWASMBytes = webutil.MaxSystemviewsWASMBytes
39+ systemviewsAssetDir = webutil.SystemviewsAssetDir
40+)
41+
42+// systemviewTrust accepts requesters served from the internal HTTPS origin or
43+// dumb:// pages. CEF system views have a real origin, so an opaque "null"
44+// Origin is rejected rather than falling back to the referrer.
45+var systemviewTrust = webutil.SystemviewTrust{IsTrustedURL: isTrustedSystemviewURL}
46+
47+var (
48+ safeSystemviewsAssetPath = webutil.SafeSystemviewsAssetPath
49+ readAssetWithEncoding = webutil.ReadSystemviewAsset
4450 )
4551
4652 // pageRootFiles maps internal page hosts/paths to their HTML entry points.
@@ -297,25 +303,20 @@ func (h *dumbSchemeHandler) rejectUntrustedSystemviewRequester(request purecef.R
297303 if request == nil {
298304 return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
299305 }
300- origin := strings.TrimSpace(request.GetHeaderByName("Origin"))
301- if origin != "" {
302- if isTrustedSystemviewURL(origin) {
303- return nil
306+ origin := request.GetHeaderByName("Origin")
307+ referrer := ""
308+ if strings.TrimSpace(origin) == "" {
309+ referrer = strings.TrimSpace(request.GetReferrerURL())
310+ if referrer == "" {
311+ referrer = request.GetHeaderByName("Referer")
304312 }
305- return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
306313 }
307- referrer := strings.TrimSpace(request.GetReferrerURL())
308- if referrer == "" {
309- referrer = strings.TrimSpace(request.GetHeaderByName("Referer"))
310- }
311- if !isTrustedSystemviewURL(referrer) {
314+ if !systemviewTrust.Allows(origin, referrer) {
312315 return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
313316 }
314317 return nil
315318 }
316319
317-const systemviewFaviconSize = 32
318-
319320 func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.ResourceHandler {
320321 if denied := h.rejectUntrustedFaviconRequester(request); denied != nil {
321322 return denied
@@ -328,21 +329,9 @@ func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.Re
328329 return h.newPrivateAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "favicon unavailable"})
329330 }
330331
331- parsed, err := url.Parse(request.GetURL())
332+ domain, size, err := webutil.ParseSystemviewFaviconRequest(request.GetURL())
332333 if err != nil {
333- return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"})
334- }
335- domain := strings.TrimSpace(parsed.Query().Get("domain"))
336- if domain == "" {
337- return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing domain"})
338- }
339- size := systemviewFaviconSize
340- if rawSize := strings.TrimSpace(parsed.Query().Get("size")); rawSize != "" {
341- parsedSize, parseErr := strconv.Atoi(rawSize)
342- if parseErr != nil || parsedSize != systemviewFaviconSize {
343- return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "unsupported favicon size"})
344- }
345- size = parsedSize
334+ return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
346335 }
347336
348337 return newFaviconResourceHandler(h.ctx, resolver, domain, size)
@@ -360,17 +349,8 @@ func isTrustedSystemviewURL(raw string) bool {
360349 if strings.EqualFold(parsed.Scheme, actualInternalScheme) {
361350 return strings.EqualFold(parsed.Host, actualInternalHost)
362351 }
363- if !strings.EqualFold(parsed.Scheme, "dumb") {
364- return false
365- }
366- host := parsed.Host
367- if host == "" {
368- host = parsed.Opaque
369- }
370- if idx := strings.IndexAny(host, "/?#"); idx >= 0 {
371- host = host[:idx]
372- }
373- return isInternalPageHost(host)
352+ host, ok := webutil.DumbURLHost(raw)
353+ return ok && isInternalPageHost(host)
374354 }
375355
376356 func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) {
@@ -815,46 +795,6 @@ func sha256Hex(data []byte) string {
815795 return hex.EncodeToString(sum[:])
816796 }
817797
818-func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) {
819- assetDir = strings.Trim(assetDir, "/")
820- if assetDir != systemviewsAssetDir {
821- return "", "", false
822- }
823-
824- relPath = strings.TrimLeft(relPath, "/")
825- if relPath == "" || strings.ContainsRune(relPath, '\x00') {
826- return "", "", false
827- }
828-
829- cleanRelPath = path.Clean(relPath)
830- if cleanRelPath == "." || cleanRelPath == ".." || strings.HasPrefix(cleanRelPath, "../") || path.IsAbs(cleanRelPath) {
831- return "", "", false
832- }
833-
834- fullPath = path.Join(assetDir, cleanRelPath)
835- if fullPath != assetDir && !strings.HasPrefix(fullPath, assetDir+"/") {
836- return "", "", false
837- }
838- return fullPath, cleanRelPath, true
839-}
840-
841-func readAssetWithEncoding(assetsFS fs.FS, fullPath, relPath string) ([]byte, error) {
842- if strings.HasSuffix(relPath, ".wasm") {
843- if compressed, err := fs.ReadFile(assetsFS, fullPath+".br"); err == nil {
844- data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))
845- if err != nil {
846- return nil, err
847- }
848- if len(data) > maxSystemviewsWASMBytes {
849- return nil, fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)
850- }
851- return data, nil
852- }
853- }
854- data, err := fs.ReadFile(assetsFS, fullPath)
855- return data, err
856-}
857-
858798 // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL
859799 // to a relative asset path inside assets/systemviews.
860800 func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
M internal/infrastructure/config/profile.go +0 -4
@@ -11,10 +11,6 @@ const (
1111 // supported by WebKitGTK (0-8 range).
1212 maxSkiaCPUThreads = 8
1313
14- // maxSkiaGPUThreads is the maximum number of Skia GPU painting threads
15- // supported by WebKitGTK (-1 to 8 range, where -1 means "let WebKit decide").
16- maxSkiaGPUThreads = 8
17-
1814 // Memory tier thresholds (in bytes)
1915 // Explicitly typed as uint64 to prevent overflow on 32-bit systems.
2016 ramTierLow uint64 = 8 * 1024 * 1024 * 1024 // 8 GB
M internal/infrastructure/config/webui_gateway.go +8 -23
@@ -15,24 +15,8 @@ func NewWebUIConfigGateway(mgr *Manager) *WebUIConfigGateway {
1515 return &WebUIConfigGateway{mgr: mgr}
1616 }
1717
18-// Validation limits for custom performance fields.
19-const (
20- maxWebProcessMemoryMB = 16384
21- maxNetworkProcessMemoryMB = 4096
22- maxWebViewPoolPrewarm = 20
23-)
24-
25-// clampInt returns v clamped to [lo, hi].
26-func clampInt(v, lo, hi int) int {
27- if v < lo {
28- return lo
29- }
30- if v > hi {
31- return hi
32- }
33- return v
34-}
35-
18+// SaveWebUIConfig maps an already validated and normalized WebUI config onto
19+// the stored config and saves it. Policy lives in SaveWebUIConfigUseCase.
3620 func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg dto.WebUIConfig) error {
3721 _ = ctx
3822 if g == nil || g.mgr == nil {
@@ -87,11 +71,12 @@ func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg dto.WebUIC
8771
8872 // Custom performance fields (only used when profile is "custom")
8973 if cfg.Performance.Profile == string(ProfileCustom) {
90- current.Engine.WebKit.SkiaCPUPaintingThreads = clampInt(cfg.Performance.Custom.SkiaCPUThreads, 0, maxSkiaCPUThreads)
91- current.Engine.WebKit.SkiaGPUPaintingThreads = clampInt(cfg.Performance.Custom.SkiaGPUThreads, -1, maxSkiaGPUThreads)
92- current.Engine.WebKit.WebProcessMemoryLimitMB = clampInt(cfg.Performance.Custom.WebProcessMemoryMB, 0, maxWebProcessMemoryMB)
93- current.Engine.WebKit.NetworkProcessMemoryLimitMB = clampInt(cfg.Performance.Custom.NetworkProcessMemoryMB, 0, maxNetworkProcessMemoryMB)
94- current.Engine.PoolPrewarmCount = clampInt(cfg.Performance.Custom.WebViewPoolPrewarm, 0, maxWebViewPoolPrewarm)
74+ custom := cfg.Performance.Custom
75+ current.Engine.WebKit.SkiaCPUPaintingThreads = custom.SkiaCPUThreads
76+ current.Engine.WebKit.SkiaGPUPaintingThreads = custom.SkiaGPUThreads
77+ current.Engine.WebKit.WebProcessMemoryLimitMB = custom.WebProcessMemoryMB
78+ current.Engine.WebKit.NetworkProcessMemoryLimitMB = custom.NetworkProcessMemoryMB
79+ current.Engine.PoolPrewarmCount = custom.WebViewPoolPrewarm
9580 }
9681
9782 return g.mgr.Save(current)
M internal/infrastructure/handlers/config.go +1 -7
@@ -63,13 +63,7 @@ func RegisterConfigHandlers(
6363
6464 log := logging.FromContext(ctx).With().Str("component", "handlers").Logger()
6565
66- if err := router.RegisterHandlerWithCallbacks(
67- "save_config",
68- "__dumber_config_saved",
69- "__dumber_config_error",
70- "",
71- handler,
72- ); err != nil {
66+ if err := port.RegisterWebUIMessage(router, port.MsgSaveConfig, handler); err != nil {
7367 return err
7468 }
7569
M internal/infrastructure/handlers/homepage/registry.go +37 -40
@@ -19,47 +19,9 @@ func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg C
1919 log := logging.FromContext(ctx)
2020 log.Debug().Msg("registering homepage message handlers")
2121
22- // Common callback for all homepage handlers
23- const callback = "__dumber_homepage_response"
24- const errorCallback = "__dumber_error"
25- const worldName = "" // main world
26-
27- handlers := make(map[string]port.WebUIMessageHandler)
28-
29- // History handlers
30- historyHandlers := NewHistoryHandlers(cfg.HistoryUC)
31- handlers["history_timeline"] = historyHandlers.HandleTimeline()
32- handlers["history_timeline_by_domain"] = historyHandlers.HandleTimelineByDomain()
33- handlers["history_timeline_window"] = historyHandlers.HandleTimelineWindow()
34- handlers["history_search_fts"] = historyHandlers.HandleSearchFTS()
35- handlers["history_delete_entry"] = historyHandlers.HandleDeleteEntry()
36- handlers["history_delete_range"] = historyHandlers.HandleDeleteRange()
37- handlers["history_clear_all"] = historyHandlers.HandleClearAll()
38- handlers["history_stats"] = historyHandlers.HandleStats()
39- handlers["history_analytics"] = historyHandlers.HandleAnalytics()
40- handlers["history_domain_stats"] = historyHandlers.HandleDomainStats()
41- handlers["history_delete_domain"] = historyHandlers.HandleDeleteDomain()
42-
43- // Favorites handlers
44- favoritesHandlers := NewFavoritesHandlers(cfg.FavoritesUC)
45- handlers["favorite_list"] = favoritesHandlers.HandleList()
46- handlers["favorite_create"] = favoritesHandlers.HandleCreate()
47- handlers["favorite_update"] = favoritesHandlers.HandleUpdate()
48- handlers["favorite_delete"] = favoritesHandlers.HandleDelete()
49- handlers["favorite_set_shortcut"] = favoritesHandlers.HandleSetShortcut()
50- handlers["favorite_get_by_shortcut"] = favoritesHandlers.HandleGetByShortcut()
51- // Tag handlers
52- tagHandlers := NewTagHandlers(cfg.FavoritesUC)
53- handlers["tag_list"] = tagHandlers.HandleList()
54- handlers["tag_create"] = tagHandlers.HandleCreate()
55- handlers["tag_delete"] = tagHandlers.HandleDelete()
56- handlers["tag_update"] = tagHandlers.HandleUpdate() // Requires UpdateTag()
57- handlers["tag_assign"] = tagHandlers.HandleAssign()
58- handlers["tag_remove"] = tagHandlers.HandleRemove()
59-
60- // Register all handlers
22+ handlers := Handlers(cfg)
6123 for msgType, handler := range handlers {
62- if err := router.RegisterHandlerWithCallbacks(msgType, callback, errorCallback, worldName, handler); err != nil {
24+ if err := port.RegisterWebUIMessage(router, msgType, handler); err != nil {
6325 return fmt.Errorf("failed to register handler %s: %w", msgType, err)
6426 }
6527 log.Debug().Str("type", msgType).Msg("registered homepage handler")
@@ -68,3 +30,38 @@ func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg C
6830 log.Info().Int("count", len(handlers)).Msg("homepage handlers registered")
6931 return nil
7032 }
33+
34+// Handlers returns every homepage handler keyed by its WebUI message type.
35+func Handlers(cfg Config) map[string]port.WebUIMessageHandler {
36+ history := NewHistoryHandlers(cfg.HistoryUC)
37+ favorites := NewFavoritesHandlers(cfg.FavoritesUC)
38+ tags := NewTagHandlers(cfg.FavoritesUC)
39+
40+ return map[string]port.WebUIMessageHandler{
41+ port.MsgHistoryTimeline: history.HandleTimeline(),
42+ port.MsgHistoryTimelineByDomain: history.HandleTimelineByDomain(),
43+ port.MsgHistoryTimelineWindow: history.HandleTimelineWindow(),
44+ port.MsgHistorySearchFTS: history.HandleSearchFTS(),
45+ port.MsgHistoryDeleteEntry: history.HandleDeleteEntry(),
46+ port.MsgHistoryDeleteRange: history.HandleDeleteRange(),
47+ port.MsgHistoryClearAll: history.HandleClearAll(),
48+ port.MsgHistoryStats: history.HandleStats(),
49+ port.MsgHistoryAnalytics: history.HandleAnalytics(),
50+ port.MsgHistoryDomainStats: history.HandleDomainStats(),
51+ port.MsgHistoryDeleteDomain: history.HandleDeleteDomain(),
52+
53+ port.MsgFavoriteList: favorites.HandleList(),
54+ port.MsgFavoriteCreate: favorites.HandleCreate(),
55+ port.MsgFavoriteUpdate: favorites.HandleUpdate(),
56+ port.MsgFavoriteDelete: favorites.HandleDelete(),
57+ port.MsgFavoriteSetShortcut: favorites.HandleSetShortcut(),
58+ port.MsgFavoriteGetByShortcut: favorites.HandleGetByShortcut(),
59+
60+ port.MsgTagList: tags.HandleList(),
61+ port.MsgTagCreate: tags.HandleCreate(),
62+ port.MsgTagDelete: tags.HandleDelete(),
63+ port.MsgTagUpdate: tags.HandleUpdate(),
64+ port.MsgTagAssign: tags.HandleAssign(),
65+ port.MsgTagRemove: tags.HandleRemove(),
66+ }
67+}
M internal/infrastructure/handlers/keybindings.go +9 -42
@@ -93,48 +93,15 @@ func (h *KeybindingsHandler) HandleResetAllKeybindings(ctx context.Context, _ po
9393 func RegisterKeybindingsHandlers(ctx context.Context, router port.WebUIHandlerRouter, handler *KeybindingsHandler) error {
9494 log := logging.FromContext(ctx).With().Str("component", "handlers").Logger()
9595
96- // Get all keybindings
97- if err := router.RegisterHandlerWithCallbacks(
98- "get_keybindings",
99- "__dumber_keybindings_loaded",
100- "__dumber_keybindings_error",
101- "",
102- port.WebUIMessageHandlerFunc(handler.HandleGetKeybindings),
103- ); err != nil {
104- return err
105- }
106-
107- // Set a single keybinding
108- if err := router.RegisterHandlerWithCallbacks(
109- "set_keybinding",
110- "__dumber_keybinding_set",
111- "__dumber_keybinding_set_error",
112- "",
113- port.WebUIMessageHandlerFunc(handler.HandleSetKeybinding),
114- ); err != nil {
115- return err
116- }
117-
118- // Reset a single keybinding
119- if err := router.RegisterHandlerWithCallbacks(
120- "reset_keybinding",
121- "__dumber_keybinding_reset",
122- "__dumber_keybinding_reset_error",
123- "",
124- port.WebUIMessageHandlerFunc(handler.HandleResetKeybinding),
125- ); err != nil {
126- return err
127- }
128-
129- // Reset all keybindings
130- if err := router.RegisterHandlerWithCallbacks(
131- "reset_all_keybindings",
132- "__dumber_keybindings_reset_all",
133- "__dumber_keybindings_reset_all_error",
134- "",
135- port.WebUIMessageHandlerFunc(handler.HandleResetAllKeybindings),
136- ); err != nil {
137- return err
96+ for msgType, fn := range map[string]port.WebUIMessageHandlerFunc{
97+ port.MsgGetKeybindings: handler.HandleGetKeybindings,
98+ port.MsgSetKeybinding: handler.HandleSetKeybinding,
99+ port.MsgResetKeybinding: handler.HandleResetKeybinding,
100+ port.MsgResetAllKeybindings: handler.HandleResetAllKeybindings,
101+ } {
102+ if err := port.RegisterWebUIMessage(router, msgType, fn); err != nil {
103+ return err
104+ }
138105 }
139106
140107 log.Info().Msg("registered keybindings handlers")
A internal/infrastructure/handlers/webui_contract_test.go +49 -0
@@ -0,0 +1,49 @@
1+package handlers
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/application/dto"
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/application/port/mocks"
14+ "github.com/bnema/dumber/internal/infrastructure/handlers/homepage"
15+)
16+
17+// TestWebUIContract_EveryMessageHasAHandlerAndMatchingCallbacks checks both
18+// directions of the WebUI message contract: every declared message has a Go
19+// handler, and every handler registers with the declared callbacks.
20+func TestWebUIContract_EveryMessageHasAHandlerAndMatchingCallbacks(t *testing.T) {
21+ ctx := context.Background()
22+ registered := map[string]port.WebUICallbacks{}
23+
24+ router := mocks.NewMockWebUIHandlerRouter(t)
25+ router.EXPECT().
26+ RegisterHandlerWithCallbacks(mock.Anything, mock.Anything, mock.Anything, "", mock.Anything).
27+ Run(func(msgType, success, failure, _ string, _ port.WebUIMessageHandler) {
28+ registered[msgType] = port.WebUICallbacks{Success: success, Failure: failure}
29+ }).
30+ Return(nil)
31+
32+ require.NoError(t, homepage.RegisterHandlers(ctx, router, homepage.Config{
33+ HistoryUC: mocks.NewMockHomepageHistory(t),
34+ FavoritesUC: mocks.NewMockHomepageFavorites(t),
35+ }))
36+ require.NoError(t, RegisterConfigHandlers(ctx, router, func(context.Context, dto.WebUIConfig) error { return nil }))
37+ require.NoError(t, RegisterKeybindingsHandlers(ctx, router, &KeybindingsHandler{}))
38+
39+ assert.Equal(t, port.WebUIMessageCallbacks, registered)
40+}
41+
42+func TestRegisterWebUIMessage_RejectsUnknownType(t *testing.T) {
43+ router := mocks.NewMockWebUIHandlerRouter(t)
44+
45+ err := port.RegisterWebUIMessage(router, "not_a_message", port.WebUIMessageHandlerFunc(nil))
46+
47+ var unknown *port.UnknownWebUIMessageError
48+ require.ErrorAs(t, err, &unknown)
49+}
M internal/infrastructure/systemviewsbridge/browser_transport_js.go +5 -17
@@ -11,6 +11,8 @@ import (
1111 "strings"
1212 "sync"
1313 "syscall/js"
14+
15+ "github.com/bnema/dumber/internal/application/port"
1416 )
1517
1618 type callbackPlan struct {
@@ -23,25 +25,11 @@ func NewBrowserClient() *Client {
2325 }
2426
2527 func callbackPlanForMessage(msgType string) (callbackPlan, bool) {
26- switch msgType {
27- case "history_timeline", "history_timeline_by_domain", "history_timeline_window", "history_search_fts", "history_delete_entry", "history_delete_range", "history_stats", "history_analytics",
28- "history_domain_stats", "history_delete_domain", "favorite_list", "favorite_create", "favorite_update", "favorite_delete", "tag_list",
29- "favorite_set_shortcut",
30- "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove":
31- return callbackPlan{success: "__dumber_homepage_response", failure: "__dumber_error"}, true
32- case "save_config":
33- return callbackPlan{success: "__dumber_config_saved", failure: "__dumber_config_error"}, true
34- case "get_keybindings":
35- return callbackPlan{success: "__dumber_keybindings_loaded", failure: "__dumber_keybindings_error"}, true
36- case "set_keybinding":
37- return callbackPlan{success: "__dumber_keybinding_set", failure: "__dumber_keybinding_set_error"}, true
38- case "reset_keybinding":
39- return callbackPlan{success: "__dumber_keybinding_reset", failure: "__dumber_keybinding_reset_error"}, true
40- case "reset_all_keybindings":
41- return callbackPlan{success: "__dumber_keybindings_reset_all", failure: "__dumber_keybindings_reset_all_error"}, true
42- default:
28+ cb, ok := port.WebUIMessageCallbacks[msgType]
29+ if !ok {
4330 return callbackPlan{}, false
4431 }
32+ return callbackPlan{success: cb.Success, failure: cb.Failure}, true
4533 }
4634
4735 type browserTransport struct{}
M internal/infrastructure/systemviewsbridge/client.go +26 -26
@@ -82,7 +82,7 @@ func (c *Client) Timeline(ctx context.Context, limit, offset int) ([]*entity.His
8282 if offset < 0 {
8383 return nil, fmt.Errorf("timeline offset must be non-negative, got %d", offset)
8484 }
85- return request[[]*entity.HistoryEntry](c, ctx, "history_timeline", struct {
85+ return request[[]*entity.HistoryEntry](c, ctx, port.MsgHistoryTimeline, struct {
8686 RequestID string `json:"requestId"`
8787 Limit int `json:"limit"`
8888 Offset int `json:"offset"`
@@ -100,7 +100,7 @@ func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, off
100100 if offset < 0 {
101101 return nil, fmt.Errorf("timeline-by-domain offset must be non-negative, got %d", offset)
102102 }
103- return request[[]*entity.HistoryEntry](c, ctx, "history_timeline_by_domain", struct {
103+ return request[[]*entity.HistoryEntry](c, ctx, port.MsgHistoryTimelineByDomain, struct {
104104 RequestID string `json:"requestId"`
105105 Domain string `json:"domain"`
106106 Limit int `json:"limit"`
@@ -119,7 +119,7 @@ func (c *Client) TimelineWindow(ctx context.Context, before time.Time, beforeID
119119 if !before.IsZero() {
120120 beforeCursor = before.Format(time.RFC3339Nano)
121121 }
122- return request[*entity.HistoryWindow](c, ctx, "history_timeline_window", struct {
122+ return request[*entity.HistoryWindow](c, ctx, port.MsgHistoryTimelineWindow, struct {
123123 RequestID string `json:"requestId"`
124124 Before string `json:"before,omitempty"`
125125 BeforeID int64 `json:"beforeId,omitempty"`
@@ -131,7 +131,7 @@ func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity
131131 if limit < 0 {
132132 return nil, fmt.Errorf("search limit must be non-negative, got %d", limit)
133133 }
134- return request[[]*entity.HistoryEntry](c, ctx, "history_search_fts", struct {
134+ return request[[]*entity.HistoryEntry](c, ctx, port.MsgHistorySearchFTS, struct {
135135 RequestID string `json:"requestId"`
136136 Query string `json:"query"`
137137 Limit int `json:"limit"`
@@ -142,7 +142,7 @@ func (c *Client) DeleteEntry(ctx context.Context, id int64) error {
142142 if id <= 0 {
143143 return fmt.Errorf("delete entry id must be positive, got %d", id)
144144 }
145- _, err := request[struct{}](c, ctx, "history_delete_entry", struct {
145+ _, err := request[struct{}](c, ctx, port.MsgHistoryDeleteEntry, struct {
146146 RequestID string `json:"requestId"`
147147 ID int64 `json:"id"`
148148 }{RequestID: nextRequestID(), ID: id})
@@ -154,7 +154,7 @@ func (c *Client) DeleteRange(ctx context.Context, rangeID string) error {
154154 if rangeID == "" {
155155 return errors.New("history range ID cannot be empty")
156156 }
157- _, err := request[struct{}](c, ctx, "history_delete_range", struct {
157+ _, err := request[struct{}](c, ctx, port.MsgHistoryDeleteRange, struct {
158158 RequestID string `json:"requestId"`
159159 Range string `json:"range"`
160160 }{RequestID: nextRequestID(), Range: rangeID})
@@ -178,40 +178,40 @@ func (c *Client) Default(ctx context.Context) (dto.SystemviewConfigPayload, erro
178178 }
179179
180180 func (c *Client) Save(ctx context.Context, cfg dto.WebUIConfig) error {
181- _, err := request[struct{}](c, ctx, "save_config", cfg)
181+ _, err := request[struct{}](c, ctx, port.MsgSaveConfig, cfg)
182182 return err
183183 }
184184
185185 func (c *Client) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) {
186- return request[port.KeybindingsConfig](c, ctx, "get_keybindings", struct {
186+ return request[port.KeybindingsConfig](c, ctx, port.MsgGetKeybindings, struct {
187187 RequestID string `json:"requestId"`
188188 }{RequestID: nextRequestID()})
189189 }
190190
191191 func (c *Client) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
192- return request[port.SetKeybindingResponse](c, ctx, "set_keybinding", req)
192+ return request[port.SetKeybindingResponse](c, ctx, port.MsgSetKeybinding, req)
193193 }
194194
195195 func (c *Client) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error {
196- _, err := request[struct{}](c, ctx, "reset_keybinding", req)
196+ _, err := request[struct{}](c, ctx, port.MsgResetKeybinding, req)
197197 return err
198198 }
199199
200200 func (c *Client) ResetAllKeybindings(ctx context.Context) error {
201- _, err := request[struct{}](c, ctx, "reset_all_keybindings", struct {
201+ _, err := request[struct{}](c, ctx, port.MsgResetAllKeybindings, struct {
202202 RequestID string `json:"requestId"`
203203 }{RequestID: nextRequestID()})
204204 return err
205205 }
206206
207207 func (c *Client) Stats(ctx context.Context) (*entity.HistoryStats, error) {
208- return request[*entity.HistoryStats](c, ctx, "history_stats", struct {
208+ return request[*entity.HistoryStats](c, ctx, port.MsgHistoryStats, struct {
209209 RequestID string `json:"requestId"`
210210 }{RequestID: nextRequestID()})
211211 }
212212
213213 func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) {
214- return request[*entity.HistoryAnalytics](c, ctx, "history_analytics", struct {
214+ return request[*entity.HistoryAnalytics](c, ctx, port.MsgHistoryAnalytics, struct {
215215 RequestID string `json:"requestId"`
216216 }{RequestID: nextRequestID()})
217217 }
@@ -220,7 +220,7 @@ func (c *Client) DomainStats(ctx context.Context, limit int) ([]*entity.DomainSt
220220 if limit < 0 {
221221 return nil, fmt.Errorf("domain stats limit must be non-negative, got %d", limit)
222222 }
223- return request[[]*entity.DomainStat](c, ctx, "history_domain_stats", struct {
223+ return request[[]*entity.DomainStat](c, ctx, port.MsgHistoryDomainStats, struct {
224224 RequestID string `json:"requestId"`
225225 Limit int `json:"limit"`
226226 }{RequestID: nextRequestID(), Limit: limit})
@@ -231,7 +231,7 @@ func (c *Client) DeleteDomain(ctx context.Context, domain string) error {
231231 if domain == "" {
232232 return errors.New("history domain cannot be empty")
233233 }
234- _, err := request[struct{}](c, ctx, "history_delete_domain", struct {
234+ _, err := request[struct{}](c, ctx, port.MsgHistoryDeleteDomain, struct {
235235 RequestID string `json:"requestId"`
236236 Domain string `json:"domain"`
237237 }{RequestID: nextRequestID(), Domain: domain})
@@ -239,27 +239,27 @@ func (c *Client) DeleteDomain(ctx context.Context, domain string) error {
239239 }
240240
241241 func (c *Client) List(ctx context.Context) ([]*entity.Favorite, error) {
242- return request[[]*entity.Favorite](c, ctx, "favorite_list", struct {
242+ return request[[]*entity.Favorite](c, ctx, port.MsgFavoriteList, struct {
243243 RequestID string `json:"requestId"`
244244 }{RequestID: nextRequestID()})
245245 }
246246
247247 func (c *Client) CreateFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
248- return request[*entity.Favorite](c, ctx, "favorite_create", favoriteCreatePayload(input))
248+ return request[*entity.Favorite](c, ctx, port.MsgFavoriteCreate, favoriteCreatePayload(input))
249249 }
250250
251251 func (c *Client) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
252252 if input.ID <= 0 {
253253 return nil, fmt.Errorf("update favorite id must be positive, got %d", input.ID)
254254 }
255- return request[*entity.Favorite](c, ctx, "favorite_update", favoriteUpdatePayload(input))
255+ return request[*entity.Favorite](c, ctx, port.MsgFavoriteUpdate, favoriteUpdatePayload(input))
256256 }
257257
258258 func (c *Client) DeleteFavorite(ctx context.Context, id int64) error {
259259 if id <= 0 {
260260 return fmt.Errorf("delete favorite id must be positive, got %d", id)
261261 }
262- _, err := request[struct{}](c, ctx, "favorite_delete", struct {
262+ _, err := request[struct{}](c, ctx, port.MsgFavoriteDelete, struct {
263263 RequestID string `json:"requestId"`
264264 ID int64 `json:"id"`
265265 }{RequestID: nextRequestID(), ID: id})
@@ -304,7 +304,7 @@ func tagIDPayloads(ids []entity.TagID) []int64 {
304304 }
305305
306306 func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) {
307- return request[[]*entity.Tag](c, ctx, "tag_list", struct {
307+ return request[[]*entity.Tag](c, ctx, port.MsgTagList, struct {
308308 RequestID string `json:"requestId"`
309309 }{RequestID: nextRequestID()})
310310 }
@@ -313,7 +313,7 @@ func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey
313313 if favoriteID <= 0 {
314314 return fmt.Errorf("set shortcut favorite id must be positive, got %d", favoriteID)
315315 }
316- _, err := request[struct{}](c, ctx, "favorite_set_shortcut", struct {
316+ _, err := request[struct{}](c, ctx, port.MsgFavoriteSetShortcut, struct {
317317 RequestID string `json:"requestId"`
318318 FavoriteID int64 `json:"favorite_id"`
319319 ShortcutKey *int `json:"shortcut_key"`
@@ -330,7 +330,7 @@ func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag
330330 if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
331331 colorPtr = &trimmedColor
332332 }
333- return request[*entity.Tag](c, ctx, "tag_create", struct {
333+ return request[*entity.Tag](c, ctx, port.MsgTagCreate, struct {
334334 RequestID string `json:"requestId"`
335335 Name string `json:"name"`
336336 Color *string `json:"color"`
@@ -350,7 +350,7 @@ func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) er
350350 if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
351351 colorPtr = &trimmedColor
352352 }
353- _, err := request[struct{}](c, ctx, "tag_update", struct {
353+ _, err := request[struct{}](c, ctx, port.MsgTagUpdate, struct {
354354 RequestID string `json:"requestId"`
355355 ID int64 `json:"id"`
356356 Name *string `json:"name"`
@@ -363,7 +363,7 @@ func (c *Client) DeleteTag(ctx context.Context, id int64) error {
363363 if id <= 0 {
364364 return fmt.Errorf("delete tag id must be positive, got %d", id)
365365 }
366- _, err := request[struct{}](c, ctx, "tag_delete", struct {
366+ _, err := request[struct{}](c, ctx, port.MsgTagDelete, struct {
367367 RequestID string `json:"requestId"`
368368 ID int64 `json:"id"`
369369 }{RequestID: nextRequestID(), ID: id})
@@ -377,7 +377,7 @@ func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error {
377377 if tagID <= 0 {
378378 return fmt.Errorf("assign tag id must be positive, got %d", tagID)
379379 }
380- _, err := request[struct{}](c, ctx, "tag_assign", struct {
380+ _, err := request[struct{}](c, ctx, port.MsgTagAssign, struct {
381381 RequestID string `json:"requestId"`
382382 FavoriteID int64 `json:"favorite_id"`
383383 TagID int64 `json:"tag_id"`
@@ -392,7 +392,7 @@ func (c *Client) RemoveTag(ctx context.Context, favoriteID, tagID int64) error {
392392 if tagID <= 0 {
393393 return fmt.Errorf("remove tag id must be positive, got %d", tagID)
394394 }
395- _, err := request[struct{}](c, ctx, "tag_remove", struct {
395+ _, err := request[struct{}](c, ctx, port.MsgTagRemove, struct {
396396 RequestID string `json:"requestId"`
397397 FavoriteID int64 `json:"favorite_id"`
398398 TagID int64 `json:"tag_id"`
M internal/infrastructure/webkit/scheme_handler.go +18 -90
@@ -1,20 +1,14 @@
11 package webkit
22
33 import (
4- "bytes"
54 "context"
65 "embed"
76 "fmt"
8- "io"
9- "io/fs"
107 "net/http"
118 "net/url"
12- "path"
13- "strconv"
149 "strings"
1510 "sync"
1611
17- "github.com/andybalholm/brotli"
1812 "github.com/bnema/dumber/internal/application/port"
1913 "github.com/bnema/dumber/internal/infrastructure/webutil"
2014 "github.com/bnema/dumber/internal/logging"
@@ -33,10 +27,17 @@ const (
3327 CrashPath = "crash"
3428 IndexHTML = "index.html"
3529 httpGET = "GET"
36- maxSystemviewsWASMBytes = 64 * 1024 * 1024
37- systemviewsAssetDir = "systemviews"
30+ maxSystemviewsWASMBytes = webutil.MaxSystemviewsWASMBytes
31+ systemviewsAssetDir = webutil.SystemviewsAssetDir
3832 )
3933
34+// systemviewTrust accepts dumb:// system-view requesters. WebKit gives
35+// custom-scheme pages an opaque "null" Origin, so the referrer decides then.
36+var systemviewTrust = webutil.SystemviewTrust{
37+ IsTrustedURL: isTrustedSystemviewURL,
38+ OpaqueOriginUsesReferrer: true,
39+}
40+
4041 // SchemeRequest represents a request to a custom URI scheme.
4142 type SchemeRequest struct {
4243 inner *webkit.URISchemeRequest
@@ -203,8 +204,6 @@ func buildCrashPageHTML(originalURI string) string {
203204 return webutil.BuildCrashPageHTML(originalURI)
204205 }
205206
206-const systemviewFaviconSize = 32
207-
208207 func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse {
209208 if !isTrustedSystemviewFaviconRequest(req) {
210209 return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
@@ -218,21 +217,9 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse
218217 if req == nil {
219218 return privateJSONErrorResponse(http.StatusBadRequest, "invalid request")
220219 }
221- parsed, err := url.Parse(req.URI)
220+ domain, size, err := webutil.ParseSystemviewFaviconRequest(req.URI)
222221 if err != nil {
223- return privateJSONErrorResponse(http.StatusBadRequest, "invalid request URL")
224- }
225- domain := strings.TrimSpace(parsed.Query().Get("domain"))
226- if domain == "" {
227- return privateJSONErrorResponse(http.StatusBadRequest, "missing domain")
228- }
229- size := systemviewFaviconSize
230- if rawSize := strings.TrimSpace(parsed.Query().Get("size")); rawSize != "" {
231- parsedSize, parseErr := strconv.Atoi(rawSize)
232- if parseErr != nil || parsedSize != systemviewFaviconSize {
233- return privateJSONErrorResponse(http.StatusBadRequest, "unsupported favicon size")
234- }
235- size = parsedSize
222+ return privateJSONErrorResponse(http.StatusBadRequest, err.Error())
236223 }
237224
238225 resolveCtx := h.ctx
@@ -261,31 +248,14 @@ func isTrustedSystemviewFaviconRequest(req *SchemeRequest) bool {
261248 }
262249
263250 func isTrustedSystemviewAPIRequest(req *SchemeRequest) bool {
264- if req == nil {
265- return false
266- }
267- origin := strings.TrimSpace(req.Origin)
268- if origin != "" && !strings.EqualFold(origin, "null") {
269- return isTrustedSystemviewURL(origin)
270- }
271- return isTrustedSystemviewURL(req.Referer)
251+ return req != nil && systemviewTrust.Allows(req.Origin, req.Referer)
272252 }
273253
274254 func isTrustedSystemviewURL(raw string) bool {
275- parsed, err := url.Parse(strings.TrimSpace(raw))
276- if err != nil || parsed.Scheme == "" {
277- return false
278- }
279- if !strings.EqualFold(parsed.Scheme, "dumb") {
255+ host, ok := webutil.DumbURLHost(raw)
256+ if !ok {
280257 return false
281258 }
282- host := parsed.Host
283- if host == "" {
284- host = parsed.Opaque
285- }
286- if idx := strings.IndexAny(host, "/?#"); idx >= 0 {
287- host = host[:idx]
288- }
289259 switch host {
290260 case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath:
291261 return true
@@ -476,52 +446,10 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
476446 }
477447 }
478448
479-func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) {
480- assetDir = strings.Trim(assetDir, "/")
481- if assetDir != systemviewsAssetDir {
482- return "", "", false
483- }
484-
485- relPath = strings.TrimLeft(relPath, "/")
486- if relPath == "" || strings.ContainsRune(relPath, '\x00') {
487- return "", "", false
488- }
489-
490- cleanRelPath = path.Clean(relPath)
491- if cleanRelPath == "." || cleanRelPath == ".." || strings.HasPrefix(cleanRelPath, "../") || path.IsAbs(cleanRelPath) {
492- return "", "", false
493- }
494-
495- fullPath = path.Join(assetDir, cleanRelPath)
496- if fullPath != assetDir && !strings.HasPrefix(fullPath, assetDir+"/") {
497- return "", "", false
498- }
499- return fullPath, cleanRelPath, true
500-}
501-
502-func readAssetWithEncoding(assets fs.FS, fullPath, relPath string) ([]byte, error) {
503- var compressedErr error
504- if strings.HasSuffix(relPath, ".wasm") {
505- if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
506- data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))
507- if err != nil {
508- compressedErr = err
509- } else if len(data) > maxSystemviewsWASMBytes {
510- compressedErr = fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)
511- } else {
512- return data, nil
513- }
514- }
515- }
516- data, err := fs.ReadFile(assets, fullPath)
517- if err == nil {
518- return data, nil
519- }
520- if compressedErr != nil {
521- return nil, compressedErr
522- }
523- return nil, err
524-}
449+var (
450+ safeSystemviewsAssetPath = webutil.SafeSystemviewsAssetPath
451+ readAssetWithEncoding = webutil.ReadSystemviewAsset
452+)
525453
526454 func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
527455 if u == nil {
A internal/infrastructure/webutil/systemview_policy.go +142 -0
@@ -0,0 +1,142 @@
1+package webutil
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "io/fs"
9+ "net/url"
10+ "path"
11+ "strconv"
12+ "strings"
13+
14+ "github.com/andybalholm/brotli"
15+)
16+
17+// Shared system-view (dumb://) request policy used by every browser engine.
18+// Engines keep only their native request/response translation; routing
19+// safety, trust, and asset decoding rules live here once.
20+
21+const (
22+ // SystemviewsAssetDir is the embedded directory holding system-view assets.
23+ SystemviewsAssetDir = "systemviews"
24+ // MaxSystemviewsWASMBytes bounds the decompressed system-view WASM size.
25+ MaxSystemviewsWASMBytes = 64 * 1024 * 1024
26+ // SystemviewFaviconSize is the only favicon size served to system views.
27+ SystemviewFaviconSize = 32
28+)
29+
30+// SafeSystemviewsAssetPath validates relPath inside assetDir and returns the
31+// cleaned full path. It rejects traversal, absolute paths, NUL bytes, and any
32+// directory other than SystemviewsAssetDir.
33+func SafeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) {
34+ assetDir = strings.Trim(assetDir, "/")
35+ if assetDir != SystemviewsAssetDir {
36+ return "", "", false
37+ }
38+
39+ relPath = strings.TrimLeft(relPath, "/")
40+ if relPath == "" || strings.ContainsRune(relPath, '\x00') {
41+ return "", "", false
42+ }
43+
44+ cleanRelPath = path.Clean(relPath)
45+ if cleanRelPath == "." || cleanRelPath == ".." || strings.HasPrefix(cleanRelPath, "../") || path.IsAbs(cleanRelPath) {
46+ return "", "", false
47+ }
48+
49+ fullPath = path.Join(assetDir, cleanRelPath)
50+ if fullPath != assetDir && !strings.HasPrefix(fullPath, assetDir+"/") {
51+ return "", "", false
52+ }
53+ return fullPath, cleanRelPath, true
54+}
55+
56+// ReadSystemviewAsset reads an embedded asset. For .wasm files a sibling .br
57+// artifact is authoritative when present: it is decompressed with a
58+// MaxSystemviewsWASMBytes bound, and a corrupt or oversized artifact is an
59+// error rather than a silent fallback to the raw file.
60+func ReadSystemviewAsset(assets fs.FS, fullPath, relPath string) ([]byte, error) {
61+ if strings.HasSuffix(relPath, ".wasm") {
62+ if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
63+ data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), MaxSystemviewsWASMBytes+1))
64+ if err != nil {
65+ return nil, err
66+ }
67+ if len(data) > MaxSystemviewsWASMBytes {
68+ return nil, fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, MaxSystemviewsWASMBytes)
69+ }
70+ return data, nil
71+ }
72+ }
73+ return fs.ReadFile(assets, fullPath)
74+}
75+
76+// SystemviewTrust describes how an engine identifies system-view requesters.
77+type SystemviewTrust struct {
78+ // IsTrustedURL reports whether an origin or referrer URL is a system view.
79+ IsTrustedURL func(string) bool
80+ // OpaqueOriginUsesReferrer treats an Origin of "null" like a missing
81+ // Origin. WebKit needs this because custom-scheme pages have opaque
82+ // origins; engines that serve system views from a real origin must not.
83+ OpaqueOriginUsesReferrer bool
84+}
85+
86+// Allows decides whether a request may call private system-view APIs. A
87+// present Origin is authoritative; otherwise the referrer is checked.
88+func (t SystemviewTrust) Allows(origin, referrer string) bool {
89+ if t.IsTrustedURL == nil {
90+ return false
91+ }
92+ origin = strings.TrimSpace(origin)
93+ opaque := strings.EqualFold(origin, "null")
94+ if origin != "" && (!opaque || !t.OpaqueOriginUsesReferrer) {
95+ return t.IsTrustedURL(origin)
96+ }
97+ return t.IsTrustedURL(strings.TrimSpace(referrer))
98+}
99+
100+// DumbURLHost returns the page host of a dumb:// URL (dumb://history or
101+// dumb:history) and whether raw is a dumb:// URL at all.
102+func DumbURLHost(raw string) (string, bool) {
103+ parsed, err := url.Parse(strings.TrimSpace(raw))
104+ if err != nil || !strings.EqualFold(parsed.Scheme, "dumb") {
105+ return "", false
106+ }
107+ host := parsed.Host
108+ if host == "" {
109+ host = parsed.Opaque
110+ }
111+ if idx := strings.IndexAny(host, "/?#"); idx >= 0 {
112+ host = host[:idx]
113+ }
114+ return host, true
115+}
116+
117+// Client-facing favicon API request errors.
118+var (
119+ ErrFaviconMissingDomain = errors.New("missing domain")
120+ ErrFaviconUnsupportedSize = errors.New("unsupported favicon size")
121+ ErrFaviconInvalidRequestURL = errors.New("invalid request URL")
122+)
123+
124+// ParseSystemviewFaviconRequest extracts the domain and size from a favicon
125+// API URL. Only SystemviewFaviconSize is accepted.
126+func ParseSystemviewFaviconRequest(rawURL string) (domain string, size int, err error) {
127+ parsed, parseErr := url.Parse(rawURL)
128+ if parseErr != nil {
129+ return "", 0, ErrFaviconInvalidRequestURL
130+ }
131+ domain = strings.TrimSpace(parsed.Query().Get("domain"))
132+ if domain == "" {
133+ return "", 0, ErrFaviconMissingDomain
134+ }
135+ if rawSize := strings.TrimSpace(parsed.Query().Get("size")); rawSize != "" {
136+ parsedSize, convErr := strconv.Atoi(rawSize)
137+ if convErr != nil || parsedSize != SystemviewFaviconSize {
138+ return "", 0, ErrFaviconUnsupportedSize
139+ }
140+ }
141+ return domain, SystemviewFaviconSize, nil
142+}
A internal/infrastructure/webutil/systemview_policy_test.go +85 -0
@@ -0,0 +1,85 @@
1+package webutil
2+
3+import (
4+ "testing"
5+ "testing/fstest"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestSystemviewTrustAllows(t *testing.T) {
12+ trusted := func(raw string) bool { return raw == "dumb://history" }
13+ webkit := SystemviewTrust{IsTrustedURL: trusted, OpaqueOriginUsesReferrer: true}
14+ cef := SystemviewTrust{IsTrustedURL: trusted}
15+
16+ tests := []struct {
17+ name string
18+ origin, referrer string
19+ webkit, cef bool
20+ }{
21+ {name: "trusted origin", origin: "dumb://history", webkit: true, cef: true},
22+ {name: "untrusted origin wins over trusted referrer", origin: "https://evil.example", referrer: "dumb://history"},
23+ {name: "missing origin uses referrer", referrer: "dumb://history", webkit: true, cef: true},
24+ {name: "opaque origin uses referrer only when allowed", origin: "null", referrer: "dumb://history", webkit: true},
25+ {name: "nothing is untrusted"},
26+ }
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ assert.Equal(t, tt.webkit, webkit.Allows(tt.origin, tt.referrer), "webkit")
30+ assert.Equal(t, tt.cef, cef.Allows(tt.origin, tt.referrer), "cef")
31+ })
32+ }
33+ assert.False(t, SystemviewTrust{}.Allows("dumb://history", ""), "nil matcher denies")
34+}
35+
36+func TestDumbURLHost(t *testing.T) {
37+ for raw, want := range map[string]string{
38+ "dumb://history": "history",
39+ "dumb:config": "config",
40+ "DUMB://favorites/x?y=1": "favorites",
41+ } {
42+ host, ok := DumbURLHost(raw)
43+ require.True(t, ok, raw)
44+ assert.Equal(t, want, host, raw)
45+ }
46+ _, ok := DumbURLHost("https://history")
47+ assert.False(t, ok)
48+}
49+
50+func TestParseSystemviewFaviconRequest(t *testing.T) {
51+ domain, size, err := ParseSystemviewFaviconRequest("dumb://history/api/favicon?domain=%20example.com%20&size=32")
52+ require.NoError(t, err)
53+ assert.Equal(t, "example.com", domain)
54+ assert.Equal(t, SystemviewFaviconSize, size)
55+
56+ _, _, err = ParseSystemviewFaviconRequest("dumb://history/api/favicon")
57+ require.ErrorIs(t, err, ErrFaviconMissingDomain)
58+
59+ _, _, err = ParseSystemviewFaviconRequest("dumb://history/api/favicon?domain=a&size=64")
60+ require.ErrorIs(t, err, ErrFaviconUnsupportedSize)
61+
62+ _, _, err = ParseSystemviewFaviconRequest("://bad")
63+ require.ErrorIs(t, err, ErrFaviconInvalidRequestURL)
64+}
65+
66+func TestReadSystemviewAssetCorruptCompressedIsAnError(t *testing.T) {
67+ fsys := fstest.MapFS{
68+ "systemviews/systemviews.wasm.br": {Data: []byte("not-brotli")},
69+ "systemviews/systemviews.wasm": {Data: []byte("\x00asm")},
70+ }
71+
72+ _, err := ReadSystemviewAsset(fsys, "systemviews/systemviews.wasm", "systemviews.wasm")
73+
74+ require.Error(t, err)
75+}
76+
77+func TestSafeSystemviewsAssetPathRejectsTraversal(t *testing.T) {
78+ _, _, ok := SafeSystemviewsAssetPath(SystemviewsAssetDir, "../secret")
79+ assert.False(t, ok)
80+
81+ full, rel, ok := SafeSystemviewsAssetPath(SystemviewsAssetDir, "a/../index.html")
82+ require.True(t, ok)
83+ assert.Equal(t, "systemviews/index.html", full)
84+ assert.Equal(t, "index.html", rel)
85+}
M internal/ui/app.go +26 -1192
@@ -11,7 +11,6 @@ import (
1111 "strings"
1212 "sync"
1313 "time"
14- "unsafe"
1514
1615 "github.com/bnema/dumber/internal/application/dto"
1716 "github.com/bnema/dumber/internal/application/port"
@@ -124,16 +123,16 @@ type App struct {
124123 // Focus tracking stays app-global.
125124 focusMgr *focus.Manager
126125
127- vimModePolicyUC *usecase.VimModePolicyUseCase
128- vimNavigationUC *usecase.VimNavigationUseCase
129- pageEditableFocusByPane map[entity.PaneID]bool
126+ vimModePolicyUC *usecase.VimModePolicyUseCase
127+ vimNavigationUC *usecase.VimNavigationUseCase
128+ vimMode vimModeState
130129
131130 // Omnibox configuration (omnibox is created per workspace view)
132131 omniboxCfg component.OmniboxConfig
133132 // Find bar configuration (find bar is created per workspace view)
134133 findBarCfg component.FindBarConfig
135134 // Floating pane sessions keyed by tab and profile session ID.
136- floatingSessions map[floatingSessionKey]*floatingWorkspaceSession
135+ floatingSessions floatingSessionRegistry
137136
138137 // Engine
139138 engine port.Engine
@@ -170,12 +169,6 @@ type App struct {
170169 // Accent picker for dead keys support
171170 accentFocusProvider port.FocusedInputProvider
172171
173- // Vim mode pulse debounce - tracks last pulse time to skip rapid
174- // repeats during held-key scroll actions. Reset on vim mode exit
175- // via handleModeChange.
176- vimModePulseLastTime time.Time
177- vimModePulseMu sync.Mutex
178-
179172 // Deferred initialization - runs after first load_started to avoid blocking initial navigation
180173 deferredInitOnce sync.Once
181174 deferredInitFn func()
@@ -186,30 +179,6 @@ type App struct {
186179 browserLaunchRelayCloser io.Closer
187180 }
188181
189-type floatingWorkspaceSession struct {
190- paneID entity.PaneID
191- pane *component.FloatingPane
192- paneView *component.PaneView
193- webView port.WebView
194- overlay layout.OverlayWidget
195- widget layout.Widget
196- focusWidget layout.Widget
197- omnibox *component.Omnibox
198- omniboxWidget layout.Widget
199- resizeWatcherActive bool
200- // resizeTickCallback keeps the purego callback live until its GTK source is
201- // removed. It is cleared exactly once with resizeTickID.
202- resizeTickCallback *gtk.TickCallback
203- resizeTickID uint
204- appliedWidth int
205- appliedHeight int
206-}
207-
208-type floatingSessionKey struct {
209- tabID entity.TabID
210- sessionID string
211-}
212-
213182 // New creates a new App with the given dependencies.
214183 func New(deps *Dependencies) (*App, error) {
215184 if err := deps.Validate(); err != nil {
@@ -218,19 +187,18 @@ func New(deps *Dependencies) (*App, error) {
218187 ctx, cancel := context.WithCancelCause(deps.Ctx)
219188
220189 app := &App{
221- deps: deps,
222- runtimeConfig: newRuntimeConfigState(deps.RuntimeConfig),
223- tabs: entity.NewTabList(),
224- tabsUC: deps.TabsUC,
225- panesUC: deps.PanesUC,
226- workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
227- windowForTab: make(map[entity.TabID]*browserWindow),
228- floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
229- browserWindows: make(map[string]*browserWindow),
230- instanceWindows: make(map[string]string),
231- vimModePolicyUC: usecase.NewVimModePolicyUseCase(),
232- vimNavigationUC: usecase.NewVimNavigationUseCase(),
233- pageEditableFocusByPane: make(map[entity.PaneID]bool),
190+ deps: deps,
191+ runtimeConfig: newRuntimeConfigState(deps.RuntimeConfig),
192+ tabs: entity.NewTabList(),
193+ tabsUC: deps.TabsUC,
194+ panesUC: deps.PanesUC,
195+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
196+ windowForTab: make(map[entity.TabID]*browserWindow),
197+ floatingSessions: make(floatingSessionRegistry),
198+ browserWindows: make(map[string]*browserWindow),
199+ instanceWindows: make(map[string]string),
200+ vimModePolicyUC: usecase.NewVimModePolicyUseCase(),
201+ vimNavigationUC: usecase.NewVimNavigationUseCase(),
234202 dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
235203 if fn != nil {
236204 fn()
@@ -623,10 +591,8 @@ func (a *App) browserWindowForAnyPane(paneID entity.PaneID) *browserWindow {
623591 if bw := a.browserWindowForPane(paneID); bw != nil {
624592 return bw
625593 }
626- for key, session := range a.floatingSessions {
627- if session != nil && session.paneID == paneID {
628- return a.browserWindowForTab(key.tabID)
629- }
594+ if key, _, ok := a.floatingSessions.byPaneID(paneID); ok {
595+ return a.browserWindowForTab(key.tabID)
630596 }
631597 return nil
632598 }
@@ -696,11 +662,9 @@ func (a *App) openPopupNativeWindow(ctx context.Context, popupInput content.Nati
696662 if err := a.openNativePopupWindow(ctx, popupInput); err != nil {
697663 return err
698664 }
699- // Deferred WebKit classification runs from ready-to-show. The native host
700- // is attached after that signal, so it must be revealed immediately rather
701- // than waiting for an already-delivered lifecycle callback.
702- if popupInput.Request.Engine == dto.BrowserEngineWebKit &&
703- popupInput.Request.PopupFeatures.State == dto.PopupFeaturesSpecified {
665+ // A popup classified after ready-to-show has already delivered that
666+ // lifecycle callback, so reveal it now instead of waiting for it.
667+ if popupInput.AlreadyReadyToShow {
704668 a.showNativePopupWindow(popupInput.PopupWebView.ID())
705669 }
706670 return nil
@@ -1839,8 +1803,8 @@ func (a *App) reattachFloatingSessions(tabID entity.TabID, wsView *component.Wor
18391803 return
18401804 }
18411805
1842- for key, session := range a.floatingSessions {
1843- if key.tabID != tabID || session == nil || session.pane == nil {
1806+ for _, session := range a.floatingSessions.forTab(tabID) {
1807+ if session.pane == nil {
18441808 continue
18451809 }
18461810
@@ -3169,15 +3133,7 @@ func (a *App) onShutdown(ctx context.Context) {
31693133 if a.faviconAdapter != nil {
31703134 a.faviconAdapter.Close()
31713135 }
3172- tabIDSet := make(map[entity.TabID]struct{}, len(a.floatingSessions))
3173- for key := range a.floatingSessions {
3174- tabIDSet[key.tabID] = struct{}{}
3175- }
3176- tabIDs := make([]entity.TabID, 0, len(tabIDSet))
3177- for tabID := range tabIDSet {
3178- tabIDs = append(tabIDs, tabID)
3179- }
3180- for _, tabID := range tabIDs {
3136+ for _, tabID := range a.floatingSessions.tabIDs() {
31813137 a.releaseFloatingSessionsForTab(ctx, tabID)
31823138 }
31833139 if a.contentCoord != nil {
@@ -3929,361 +3885,6 @@ func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
39293885 a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, ""))
39303886 }
39313887
3932-func (a *App) vimModePolicy() *usecase.VimModePolicyUseCase {
3933- if a == nil {
3934- return usecase.NewVimModePolicyUseCase()
3935- }
3936- if a.vimModePolicyUC == nil {
3937- a.vimModePolicyUC = usecase.NewVimModePolicyUseCase()
3938- }
3939- return a.vimModePolicyUC
3940-}
3941-
3942-func (a *App) ensurePageEditableFocusMap() {
3943- if a != nil && a.pageEditableFocusByPane == nil {
3944- a.pageEditableFocusByPane = make(map[entity.PaneID]bool)
3945- }
3946-}
3947-
3948-func (a *App) pageEditableFocused(paneID entity.PaneID) bool {
3949- if a == nil || paneID == "" || a.pageEditableFocusByPane == nil {
3950- return false
3951- }
3952- return a.pageEditableFocusByPane[paneID]
3953-}
3954-
3955-func (a *App) setPageEditableFocused(paneID entity.PaneID, editable bool) {
3956- if a == nil || paneID == "" {
3957- return
3958- }
3959- a.ensurePageEditableFocusMap()
3960- if editable {
3961- a.pageEditableFocusByPane[paneID] = true
3962- return
3963- }
3964- delete(a.pageEditableFocusByPane, paneID)
3965-}
3966-
3967-func (a *App) clearPageEditableFocusState(paneID entity.PaneID) {
3968- a.setPageEditableFocused(paneID, false)
3969-}
3970-
3971-func (a *App) vimModeActiveForBrowserWindow(bw *browserWindow) bool {
3972- return bw != nil && bw.keyboardHandler != nil && bw.keyboardHandler.Mode() == input.ModeVim
3973-}
3974-
3975-func (a *App) applyVimModePolicyTransition(_ context.Context, bw *browserWindow, transition usecase.VimModePolicyTransition) {
3976- if transition != usecase.VimModePolicyTransitionExit || bw == nil || bw.keyboardHandler == nil {
3977- return
3978- }
3979- if bw.keyboardHandler.Mode() != input.ModeVim {
3980- return
3981- }
3982- bw.keyboardHandler.ExitMode()
3983-}
3984-
3985-func (a *App) handleVimModeFocusTrigger(ctx context.Context, bw *browserWindow, trigger usecase.VimModePolicyTrigger) {
3986- if bw == nil {
3987- return
3988- }
3989- transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{
3990- Trigger: trigger,
3991- VimModeActive: a.vimModeActiveForBrowserWindow(bw),
3992- })
3993- a.applyVimModePolicyTransition(ctx, bw, transition)
3994-}
3995-
3996-func (a *App) handleVimModeTabSwitch(_ context.Context, bw *browserWindow) {
3997- if bw == nil || bw.tabs == nil || !a.vimModeActiveForBrowserWindow(bw) {
3998- return
3999- }
4000- transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{
4001- Trigger: usecase.VimModePolicyTriggerContextChanged,
4002- VimModeActive: true,
4003- PreserveOnContextChange: false,
4004- })
4005- if transition != usecase.VimModePolicyTransitionExit {
4006- return
4007- }
4008- if prevTabID := bw.tabs.PreviousActiveTabID; prevTabID != "" {
4009- if prevView := a.workspaceViews[prevTabID]; prevView != nil {
4010- if pv := prevView.GetPaneView(bw.vimModePaneID); pv != nil {
4011- pv.SetVimMode(false)
4012- }
4013- }
4014- }
4015- bw.vimModePaneID = ""
4016- bw.keyboardHandler.ExitMode()
4017-}
4018-
4019-func (a *App) handlePageEditableFocusChanged(ctx context.Context, paneID entity.PaneID, editable bool) {
4020- if paneID == "" {
4021- return
4022- }
4023- a.setPageEditableFocused(paneID, editable)
4024-
4025- bw := a.browserWindowForAnyPane(paneID)
4026- if bw == nil {
4027- return
4028- }
4029- ws := a.activeWorkspaceForBrowserWindow(bw)
4030- activeContext := ws != nil && ws.ActivePaneID == paneID && a.lastFocusedBrowserWindow() == bw
4031- transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{
4032- Trigger: usecase.VimModePolicyTriggerPageEditableFocusChanged,
4033- VimModeActive: a.vimModeActiveForBrowserWindow(bw),
4034- PageEditableFocused: editable,
4035- EventInActiveContext: activeContext,
4036- })
4037- a.applyVimModePolicyTransition(ctx, bw, transition)
4038-}
4039-
4040-func (*App) shouldBypassVimModeActivation(_ *browserWindow) bool {
4041- return false
4042-}
4043-
4044-// handleModeChange is called when the input mode changes for a specific browser window.
4045-func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to input.Mode) {
4046- log := logging.FromContext(ctx)
4047- log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
4048-
4049- if to == input.ModeVim && from != input.ModeVim {
4050- a.enableAccessibilityForVimMode(ctx, bw)
4051- }
4052- if from == input.ModeVim && to != input.ModeVim {
4053- a.clearVimNavigationHighlight(ctx, bw)
4054- }
4055-
4056- // Handle pane-local Vim Mode visual ownership.
4057- // Entering Vim Mode accents the active pane; leaving removes the accent.
4058- a.handleVimModeOwnership(ctx, bw, to, from)
4059-
4060- a.updateModeFrame(bw, to)
4061-
4062- // Show/hide this window's mode indicator toaster based on mode and config.
4063- a.updateModeIndicatorToaster(ctx, bw, to)
4064-}
4065-
4066-// transferVimModeOwnershipToPane transfers the pane-local Vim Mode accent
4067-// from the current owning pane to another pane in the same browser window.
4068-// The transfer only happens while that window is in Vim Mode.
4069-func (a *App) transferVimModeOwnershipToPane(ctx context.Context, bw *browserWindow, newPaneID entity.PaneID) {
4070- if bw == nil {
4071- return
4072- }
4073-
4074- // Only transfer if vim mode is currently active and the owner is changing.
4075- if bw.vimModePaneID == "" || bw.vimModePaneID == newPaneID {
4076- return
4077- }
4078-
4079- // Check if this window is actually in vim mode.
4080- if bw.keyboardHandler == nil || bw.keyboardHandler.Mode() != input.ModeVim {
4081- // Vim mode not active on this window; just clear stale ownership.
4082- bw.vimModePaneID = ""
4083- return
4084- }
4085-
4086- wsView := a.activeWorkspaceViewForBrowserWindow(bw)
4087- if wsView == nil {
4088- return
4089- }
4090-
4091- transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{
4092- Trigger: usecase.VimModePolicyTriggerContextChanged,
4093- VimModeActive: true,
4094- PreserveOnContextChange: !a.pageEditableFocused(newPaneID),
4095- })
4096- if transition == usecase.VimModePolicyTransitionExit {
4097- a.applyVimModePolicyTransition(ctx, bw, transition)
4098- return
4099- }
4100-
4101- oldPaneID := bw.vimModePaneID
4102-
4103- // Deactivate the old owning pane.
4104- if oldPV := wsView.GetPaneView(oldPaneID); oldPV != nil {
4105- oldPV.SetVimMode(false)
4106- }
4107-
4108- // Activate the new pane.
4109- if newPV := wsView.GetPaneView(newPaneID); newPV != nil {
4110- newPV.SetVimMode(true)
4111- }
4112- bw.vimModePaneID = newPaneID
4113-
4114- logging.FromContext(ctx).Debug().
4115- Str("window_id", bw.id).
4116- Str("old_pane_id", string(oldPaneID)).
4117- Str("new_pane_id", string(newPaneID)).
4118- Msg("vim mode ownership transferred")
4119-}
4120-
4121-// handleVimModeOwnership manages the pane-local Vim Mode accent and pulse
4122-// owner for a specific browser window when input modes change.
4123-func (a *App) handleVimModeOwnership(ctx context.Context, bw *browserWindow, to, from input.Mode) {
4124- if bw == nil {
4125- return
4126- }
4127-
4128- ws := a.activeWorkspaceForBrowserWindow(bw)
4129- wsView := a.activeWorkspaceViewForBrowserWindow(bw)
4130- if ws == nil || wsView == nil {
4131- // If there's no workspace yet (startup), just track the intent.
4132- if to != input.ModeVim && from == input.ModeVim {
4133- bw.vimModePaneID = ""
4134- }
4135- return
4136- }
4137-
4138- if to == input.ModeVim && from != input.ModeVim {
4139- // Entering vim mode: activate on the current active pane of this window.
4140- paneID := ws.ActivePaneID
4141- if pv := wsView.GetPaneView(paneID); pv != nil {
4142- pv.SetVimMode(true)
4143- bw.vimModePaneID = paneID
4144- logging.FromContext(ctx).Debug().
4145- Str("window_id", bw.id).
4146- Str("pane_id", string(paneID)).
4147- Msg("vim mode activated on pane")
4148- }
4149- } else if from == input.ModeVim && to != input.ModeVim {
4150- // Leaving Vim Mode: deactivate the pane that owns the visual accent.
4151- a.clearVimModeOwnership(ctx, bw)
4152- }
4153-}
4154-
4155-// triggerVimModePulse triggers a pane-local vim mode pulse on the
4156-// last-focused browser window's owning pane. This is called by the
4157-// keyboard dispatcher after a page scroll action. The bw used is the
4158-// window that was active when the action was dispatched (via
4159-// dispatchBrowserWindowAction which calls activateBrowserWindow first).
4160-// If no pane is currently in vim mode, the pulse is a no-op.
4161-//
4162-// Pulses are debounced at vimModePulseInterval to prevent excessive GTK CSS
4163-// class churn during held-key repeats. Smooth scroll cadence is owned by the
4164-// Vim Mode repeater and backend scroll path; pulse feedback is deliberately
4165-// slower so indicator/overlay animation work does not become part of the
4166-// scrolling critical path.
4167-func (a *App) triggerVimModePulse(_ context.Context, fast bool) {
4168- // Debounce: skip if called again too soon during a continuous hold.
4169- // We still serialize the timestamp update so repeated dispatcher calls do
4170- // not restart CSS animations at scroll cadence.
4171- a.vimModePulseMu.Lock()
4172- since := time.Since(a.vimModePulseLastTime)
4173- if since < vimModePulseInterval {
4174- a.vimModePulseMu.Unlock()
4175- return
4176- }
4177- a.vimModePulseLastTime = time.Now()
4178- a.vimModePulseMu.Unlock()
4179-
4180- bw := a.lastFocusedBrowserWindow()
4181- if bw == nil || bw.vimModePaneID == "" {
4182- return
4183- }
4184- if bw.modeFrame != nil {
4185- bw.modeFrame.pulse(fast)
4186- }
4187-}
4188-
4189-// clearVimModeOwnership deactivates vim mode on the owning pane for a
4190-// specific browser window and resets its tracked pane ID.
4191-// Also resets the pulse debounce timer so the first pulse on re-entry
4192-// is never skipped.
4193-func (a *App) clearVimModeOwnership(ctx context.Context, bw *browserWindow) {
4194- if bw == nil || bw.vimModePaneID == "" {
4195- return
4196- }
4197-
4198- wsView := a.activeWorkspaceViewForBrowserWindow(bw)
4199- if wsView != nil {
4200- if pv := wsView.GetPaneView(bw.vimModePaneID); pv != nil {
4201- pv.SetVimMode(false)
4202- logging.FromContext(ctx).Debug().
4203- Str("window_id", bw.id).
4204- Str("pane_id", string(bw.vimModePaneID)).
4205- Msg("vim mode deactivated on pane")
4206- }
4207- }
4208- bw.vimModePaneID = ""
4209-
4210- // Reset debounce timer so re-entry pulses are never mis-skipped.
4211- a.vimModePulseMu.Lock()
4212- a.vimModePulseLastTime = time.Time{}
4213- a.vimModePulseMu.Unlock()
4214-}
4215-
4216-// updateModeIndicatorToaster reconciles one window's mode toaster with its
4217-// current mode and the live mode-indicator preference.
4218-func (a *App) updateModeIndicatorToaster(ctx context.Context, bw *browserWindow, mode input.Mode) {
4219- if bw == nil || bw.modeToaster == nil {
4220- return
4221- }
4222-
4223- if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled || mode == input.ModeNormal {
4224- bw.modeToaster.Hide()
4225- return
4226- }
4227-
4228- bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
4229- component.WithDuration(0),
4230- component.WithPosition(component.ToastPositionBottomLeft),
4231- component.WithModeClass(getModeToastClass(mode)),
4232- )
4233-}
4234-
4235-// bindVimModeSequenceToaster wires pending Vim-sequence text onto this
4236-// window's modeToaster only (never appToaster / pane toasters).
4237-func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow) {
4238- if a == nil || bw == nil || bw.keyboardHandler == nil {
4239- return
4240- }
4241- bw.keyboardHandler.SetOnPendingSequenceChange(func(pending string) {
4242- a.showPendingSequence(ctx, bw, pending)
4243- })
4244- logging.FromContext(ctx).Debug().
4245- Str("window_id", bw.id).
4246- Msg("vim mode pending sequence toaster bound")
4247-}
4248-
4249-// showPendingSequence shows "VIM MODE · pending" on the per-window modeToaster
4250-// while a sequence is incomplete, and restores stable "VIM MODE" when pending
4251-// clears. Mode exit/hide is handled by handleModeChange/updateModeIndicatorToaster;
4252-// silent reset under ModalState avoids stale pending callbacks.
4253-func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pending string) {
4254- if a == nil || bw == nil {
4255- return
4256- }
4257- if bw.modeFrame != nil {
4258- cfg := a.runtimeConfigSnapshot().UI.Workspace
4259- bw.modeFrame.setPending(pending, cfg.VimMode.Actions)
4260- }
4261- if bw.modeToaster == nil {
4262- return
4263- }
4264- log := logging.FromContext(ctx)
4265- if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled {
4266- bw.modeToaster.Hide()
4267- log.Debug().Str("window_id", bw.id).Msg("vim mode pending toaster hidden (disabled)")
4268- return
4269- }
4270-
4271- text := input.ModeVim.DisplayName()
4272- if pending != "" {
4273- text = text + " · " + pending
4274- }
4275- bw.modeToaster.Show(ctx, text, component.ToastInfo,
4276- component.WithDuration(0),
4277- component.WithPosition(component.ToastPositionBottomLeft),
4278- component.WithModeClass(getModeToastClass(input.ModeVim)),
4279- )
4280- log.Debug().
4281- Str("window_id", bw.id).
4282- Str("pending", pending).
4283- Str("toast_text", text).
4284- Msg("vim mode pending toaster updated")
4285-}
4286-
42873888 // getModeToastClass returns the CSS class for the given mode's toast styling.
42883889 func getModeToastClass(mode input.Mode) string {
42893890 switch mode {
@@ -4546,773 +4147,6 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
45464147 log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
45474148 }
45484149
4549-func normalizeFloatingSessionID(sessionID string) string {
4550- sessionID = strings.TrimSpace(sessionID)
4551- if sessionID == "" {
4552- return floatingSessionIDDefault
4553- }
4554- return sessionID
4555-}
4556-
4557-func floatingPaneIDForSession(tabID entity.TabID, sessionID string) entity.PaneID {
4558- return entity.PaneID(floatingPaneIDPrefix + string(tabID) + ":" + normalizeFloatingSessionID(sessionID))
4559-}
4560-
4561-func floatingSessionMapKey(tabID entity.TabID, sessionID string) floatingSessionKey {
4562- return floatingSessionKey{tabID: tabID, sessionID: normalizeFloatingSessionID(sessionID)}
4563-}
4564-
4565-func decorateFloatingOverlay(overlay layout.OverlayWidget) {
4566- if overlay == nil {
4567- return
4568- }
4569-
4570- overlay.SetHexpand(false)
4571- overlay.SetVexpand(false)
4572- overlay.AddCssClass("floating-pane-container")
4573- overlay.AddCssClass("pane-border")
4574- overlay.AddCssClass("pane-active")
4575-}
4576-
4577-// showFloatingWidget makes a floating pane widget interactive and visible.
4578-// Visibility is controlled by CSS class to animate opacity while keeping the
4579-// widget mapped so WebKit can redraw without a hard pop.
4580-func showFloatingWidget(w layout.Widget) {
4581- if w == nil {
4582- return
4583- }
4584- w.AddCssClass(floatingPaneVisibleClass)
4585- w.SetCanTarget(true)
4586- w.SetCanFocus(true)
4587-}
4588-
4589-// hideFloatingWidget makes a floating pane widget invisible and non-interactive.
4590-// Removes the visible CSS class instead of SetVisible(false) so GTK keeps the
4591-// widget mapped and WebKit retains its rendered surface in GPU memory.
4592-func hideFloatingWidget(w layout.Widget) {
4593- if w == nil {
4594- return
4595- }
4596- w.RemoveCssClass(floatingPaneVisibleClass)
4597- w.SetCanTarget(false)
4598- w.SetCanFocus(false)
4599-}
4600-
4601-// setFloatingWidgetShown applies show or hide based on the visible flag.
4602-func setFloatingWidgetShown(w layout.Widget, visible bool) {
4603- if visible {
4604- showFloatingWidget(w)
4605- } else {
4606- hideFloatingWidget(w)
4607- }
4608-}
4609-
4610-func configureFloatingOverlayMeasurement(workspaceOverlay layout.OverlayWidget, floatingOverlay layout.Widget) {
4611- if workspaceOverlay == nil || floatingOverlay == nil {
4612- return
4613- }
4614-
4615- workspaceOverlay.SetMeasureOverlay(floatingOverlay, false)
4616- workspaceOverlay.SetClipOverlay(floatingOverlay, false)
4617-}
4618-
4619-func floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHeight int) (x, y, width, height int, ok bool) {
4620- if overlayWidth <= 0 || overlayHeight <= 0 || desiredWidth <= 0 || desiredHeight <= 0 {
4621- return 0, 0, 0, 0, false
4622- }
4623-
4624- width = desiredWidth
4625- height = desiredHeight
4626- if width > overlayWidth {
4627- width = overlayWidth
4628- }
4629- if height > overlayHeight {
4630- height = overlayHeight
4631- }
4632-
4633- x = (overlayWidth - width) / 2
4634- y = (overlayHeight - height) / 2
4635- if x < 0 {
4636- x = 0
4637- }
4638- if y < 0 {
4639- y = 0
4640- }
4641-
4642- return x, y, width, height, true
4643-}
4644-
4645-type gtkAllocationLayout [4]int32
4646-
4647-const gtkAllocationLayoutSize = 16
4648-
4649-var (
4650- _ [gtkAllocationLayoutSize - int(unsafe.Sizeof(gtkAllocationLayout{}))]struct{}
4651- _ [int(unsafe.Sizeof(gtkAllocationLayout{})) - gtkAllocationLayoutSize]struct{}
4652-)
4653-
4654-// writeOverlayAllocation writes GTK-owned GtkAllocation fields in place.
4655-// Assumes GTK4 C layout for GdkRectangle/GtkAllocation: four contiguous
4656-// 32-bit signed integers {x, y, width, height} in native endianness.
4657-// If GTK struct layout changes, revisit offsets and writes below.
4658-//
4659-//nolint:gosec // Intentional unsafe pointer math against GTK-owned allocation memory.
4660-func writeOverlayAllocation(allocationPtr *uintptr, x, y, width, height int) bool {
4661- if allocationPtr == nil {
4662- return false
4663- }
4664-
4665- const (
4666- gtkAllocationOffsetX = uintptr(0)
4667- gtkAllocationOffsetY = uintptr(4)
4668- gtkAllocationOffsetWidth = uintptr(8)
4669- gtkAllocationOffsetHeight = uintptr(12)
4670- )
4671-
4672- // GtkAllocation/GdkRectangle is four 32-bit signed integers in C.
4673- base := unsafe.Pointer(allocationPtr)
4674- *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetX)) = int32(x)
4675- *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetY)) = int32(y)
4676- *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetWidth)) = int32(width)
4677- *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetHeight)) = int32(height)
4678- return true
4679-}
4680-
4681-func (a *App) floatingAllocationForWidget(
4682- tabID entity.TabID,
4683- widgetPtr uintptr,
4684- overlayWidth, overlayHeight int,
4685-) (x, y, width, height int, ok bool) {
4686- for key, session := range a.floatingSessions {
4687- if key.tabID != tabID || session == nil || session.widget == nil {
4688- continue
4689- }
4690-
4691- gtkWidget := session.widget.GtkWidget()
4692- if gtkWidget == nil || gtkWidget.GoPointer() != widgetPtr {
4693- continue
4694- }
4695-
4696- desiredWidth := session.appliedWidth
4697- desiredHeight := session.appliedHeight
4698- if (desiredWidth <= 0 || desiredHeight <= 0) && session.pane != nil {
4699- session.pane.Resize()
4700- desiredWidth, desiredHeight = session.pane.Dimensions()
4701- }
4702-
4703- return floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHeight)
4704- }
4705-
4706- return 0, 0, 0, 0, false
4707-}
4708-
4709-func (a *App) installFloatingOverlayPositioning(tabID entity.TabID, workspaceOverlay layout.OverlayWidget) {
4710- if workspaceOverlay == nil {
4711- return
4712- }
4713-
4714- workspaceWidget := workspaceOverlay.GtkWidget()
4715- if workspaceWidget == nil {
4716- return
4717- }
4718-
4719- gtkOverlay := gtk.OverlayNewFromInternalPtr(workspaceWidget.GoPointer())
4720- if gtkOverlay == nil {
4721- return
4722- }
4723-
4724- cb := func(_ gtk.Overlay, widgetPtr uintptr, allocationPtr *uintptr) bool {
4725- widget := gtk.WidgetNewFromInternalPtr(widgetPtr)
4726- if widget != nil {
4727- widgetPtr = widget.GoPointer()
4728- }
4729- overlayWidth := workspaceOverlay.GetAllocatedWidth()
4730- overlayHeight := workspaceOverlay.GetAllocatedHeight()
4731- x, y, width, height, ok := a.floatingAllocationForWidget(tabID, widgetPtr, overlayWidth, overlayHeight)
4732- if !ok {
4733- return false
4734- }
4735- return writeOverlayAllocation(allocationPtr, x, y, width, height)
4736- }
4737-
4738- gtkOverlay.ConnectGetChildPosition(&cb)
4739-}
4740-
4741-// currentFloatingWidthPct returns the configured floating pane width percentage.
4742-func (a *App) currentFloatingWidthPct() float64 {
4743- return a.runtimeConfigSnapshot().UI.Workspace.FloatingPane.WidthPct
4744-}
4745-
4746-// currentFloatingHeightPct returns the configured floating pane height percentage.
4747-func (a *App) currentFloatingHeightPct() float64 {
4748- return a.runtimeConfigSnapshot().UI.Workspace.FloatingPane.HeightPct
4749-}
4750-
4751-func (a *App) ensureFloatingSession(
4752- ctx context.Context,
4753- tabID entity.TabID,
4754- sessionID string,
4755- wsView *component.WorkspaceView,
4756-) (*floatingWorkspaceSession, error) {
4757- key := floatingSessionMapKey(tabID, sessionID)
4758- if session, ok := a.floatingSessions[key]; ok && session != nil {
4759- if wsView == nil {
4760- return session, nil
4761- }
4762- session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
4763- if session.widget != nil {
4764- wsView.AddWorkspaceOverlayWidget(session.widget)
4765- configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), session.widget)
4766- }
4767- if session.paneView != nil {
4768- wsView.RegisterPaneView(session.paneID, session.paneView)
4769- if session.focusWidget == nil {
4770- session.focusWidget = session.paneView.WebViewWidget()
4771- }
4772- }
4773- return session, nil
4774- }
4775-
4776- if wsView == nil {
4777- return nil, fmt.Errorf("workspace view not found for tab %s", tabID)
4778- }
4779- if a.contentCoord == nil {
4780- return nil, fmt.Errorf("content coordinator not initialized")
4781- }
4782- if a.widgetFactory == nil {
4783- return nil, fmt.Errorf("widget factory not initialized")
4784- }
4785-
4786- paneID := floatingPaneIDForSession(tabID, sessionID)
4787- wv, err := a.contentCoord.EnsureWebView(ctx, paneID)
4788- if err != nil {
4789- return nil, fmt.Errorf("ensure floating webview: %w", err)
4790- }
4791-
4792- webViewWidget := a.contentCoord.WrapWidget(ctx, wv)
4793- if webViewWidget == nil {
4794- return nil, fmt.Errorf("wrap floating webview widget")
4795- }
4796- webViewWidget.SetHexpand(true)
4797- webViewWidget.SetVexpand(true)
4798-
4799- // Wrap the WebView in a PaneView so it gets progress bar, loading
4800- // skeleton, and link status overlays — the same widget hierarchy as
4801- // regular workspace panes. This allows the content coordinator's
4802- // existing callbacks (onLoadStarted, onProgressChanged, etc.) to find
4803- // and update the floating pane automatically.
4804- pv := component.NewPaneView(ctx, a.widgetFactory, paneID, webViewWidget)
4805- pv.SetActive(true)
4806- // Hide loading skeleton — floating panes have their own themed container.
4807- pv.HideLoadingSkeleton()
4808- wsView.RegisterPaneView(paneID, pv)
4809-
4810- // Use the PaneView's overlay directly as the floating overlay.
4811- // This avoids nesting two overlays which causes GTK4 allocation
4812- // issues (inner overlay expanding to fill the parent's allocation).
4813- pvOverlay := pv.Overlay()
4814- decorateFloatingOverlay(pvOverlay)
4815- pvOverlay.SetHalign(gtk.AlignCenterValue)
4816- pvOverlay.SetValign(gtk.AlignCenterValue)
4817- hideFloatingWidget(pvOverlay)
4818- wsView.AddWorkspaceOverlayWidget(pvOverlay)
4819- configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), pvOverlay)
4820-
4821- floatingPane := component.NewFloatingPane(wsView.WorkspaceOverlayWidget(), component.FloatingPaneOptions{
4822- WidthPct: a.currentFloatingWidthPct(),
4823- HeightPct: a.currentFloatingHeightPct(),
4824- FallbackWidth: floatingPaneFallbackWidth,
4825- FallbackHeight: floatingPaneFallbackHeight,
4826- OnNavigate: func(navCtx context.Context, url string) error {
4827- return wv.LoadURI(navCtx, url)
4828- },
4829- })
4830-
4831- session := &floatingWorkspaceSession{
4832- paneID: paneID,
4833- pane: floatingPane,
4834- paneView: pv,
4835- webView: wv,
4836- overlay: pvOverlay,
4837- widget: pvOverlay,
4838- focusWidget: webViewWidget,
4839- }
4840- a.floatingSessions[key] = session
4841- return session, nil
4842-}
4843-
4844-func (a *App) floatingSessionsForTab(tabID entity.TabID) map[floatingSessionKey]*floatingWorkspaceSession {
4845- sessions := make(map[floatingSessionKey]*floatingWorkspaceSession)
4846- for key, session := range a.floatingSessions {
4847- if key.tabID != tabID || session == nil {
4848- continue
4849- }
4850- sessions[key] = session
4851- }
4852- return sessions
4853-}
4854-
4855-func (a *App) releaseFloatingSessionsForTab(ctx context.Context, tabID entity.TabID) {
4856- sessions := a.floatingSessionsForTab(tabID)
4857- if len(sessions) == 0 {
4858- return
4859- }
4860-
4861- for key, session := range sessions {
4862- a.releaseFloatingSession(ctx, key, session)
4863- }
4864- a.syncFloatingFocus()
4865-}
4866-
4867-func (a *App) activeFloatingSessionEntry() (floatingSessionKey, *floatingWorkspaceSession, bool) {
4868- activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
4869- if activeTab == nil {
4870- return floatingSessionKey{}, nil, false
4871- }
4872- for key, session := range a.floatingSessions {
4873- if key.tabID != activeTab.ID || session == nil || session.pane == nil {
4874- continue
4875- }
4876- if session.pane.IsVisible() {
4877- return key, session, true
4878- }
4879- }
4880- return floatingSessionKey{tabID: activeTab.ID}, nil, false
4881-}
4882-
4883-func (a *App) activeFloatingSession() (*floatingWorkspaceSession, entity.TabID) {
4884- key, session, ok := a.activeFloatingSessionEntry()
4885- if !ok {
4886- return nil, key.tabID
4887- }
4888- return session, key.tabID
4889-}
4890-
4891-func (a *App) releaseFloatingSession(ctx context.Context, key floatingSessionKey, session *floatingWorkspaceSession) {
4892- if session == nil {
4893- return
4894- }
4895-
4896- a.stopFloatingResizeWatcher(session)
4897- if session.pane != nil {
4898- session.pane.Hide(ctx)
4899- }
4900- a.hideFloatingOmnibox(ctx, session)
4901- if wsView := a.workspaceViews[key.tabID]; wsView != nil {
4902- if session.widget != nil {
4903- wsView.RemoveWorkspaceOverlayWidget(session.widget)
4904- }
4905- if session.paneID != "" {
4906- wsView.UnregisterPaneView(session.paneID)
4907- }
4908- }
4909- if a.contentCoord != nil && session.paneID != "" {
4910- a.contentCoord.ReleaseWebView(ctx, session.paneID)
4911- }
4912- delete(a.floatingSessions, key)
4913-
4914- session.appliedWidth = 0
4915- session.appliedHeight = 0
4916- session.focusWidget = nil
4917- session.paneView = nil
4918- session.widget = nil
4919- session.overlay = nil
4920- session.webView = nil
4921- session.pane = nil
4922- // Session is fully released/zeroed; caller must drop this reference and not reuse it.
4923-}
4924-
4925-func (a *App) updateFloatingSessionURI(paneID entity.PaneID, url string) {
4926- if paneID == "" {
4927- return
4928- }
4929-
4930- for _, session := range a.floatingSessions {
4931- if session == nil || session.pane == nil {
4932- continue
4933- }
4934- if session.paneID != paneID {
4935- continue
4936- }
4937- session.pane.RecordLoadedURL(url)
4938- return
4939- }
4940-}
4941-
4942-func (a *App) syncFloatingFocus() {
4943- for _, wsView := range a.workspaceViews {
4944- if wsView != nil {
4945- wsView.SetHoverFocusLocked(false)
4946- }
4947- }
4948-
4949- if a.contentCoord == nil {
4950- return
4951- }
4952-
4953- activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
4954- if activeTab == nil {
4955- a.contentCoord.ClearActivePaneOverride()
4956- return
4957- }
4958-
4959- if session, _ := a.activeFloatingSession(); session != nil {
4960- if wsView := a.workspaceViews[activeTab.ID]; wsView != nil {
4961- wsView.SetHoverFocusLocked(true)
4962- }
4963- a.startFloatingResizeWatcher(session)
4964- a.contentCoord.SetActivePaneOverride(session.paneID)
4965- return
4966- }
4967-
4968- a.contentCoord.ClearActivePaneOverride()
4969-}
4970-
4971-func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) {
4972- if session == nil || session.overlay == nil || a.widgetFactory == nil {
4973- return
4974- }
4975-
4976- if session.omnibox == nil {
4977- cfg := a.omniboxCfg
4978- cfg.OnNavigate = func(navCtx context.Context, url string) error {
4979- if session.pane == nil {
4980- return fmt.Errorf("floating pane is not available")
4981- }
4982- if navCtx == nil {
4983- navCtx = ctx
4984- }
4985- return session.pane.Navigate(navCtx, url)
4986- }
4987- cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) {
4988- a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level)
4989- }
4990-
4991- omnibox := component.NewOmnibox(ctx, cfg)
4992- if omnibox == nil {
4993- return
4994- }
4995-
4996- omnibox.SetParentOverlay(session.overlay)
4997- omniboxWidget := omnibox.WidgetAsLayout(a.widgetFactory)
4998- if omniboxWidget == nil {
4999- return
5000- }
5001-
5002- session.overlay.AddOverlay(omniboxWidget)
5003- session.overlay.SetClipOverlay(omniboxWidget, false)
5004- session.overlay.SetMeasureOverlay(omniboxWidget, false)
5005-
5006- session.omnibox = omnibox
5007- session.omniboxWidget = omniboxWidget
5008- }
5009-
5010- session.pane.SetOmniboxVisible(true)
5011- session.omnibox.Show(ctx, "")
5012-}
5013-
5014-func (a *App) hideFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) {
5015- if session == nil || session.pane == nil {
5016- return
5017- }
5018-
5019- session.pane.SetOmniboxVisible(false)
5020- if session.omnibox == nil {
5021- return
5022- }
5023-
5024- session.omnibox.Hide(ctx)
5025- if session.overlay != nil && session.omniboxWidget != nil {
5026- if parent := session.omniboxWidget.GetParent(); parent == session.overlay {
5027- session.overlay.RemoveOverlay(session.omniboxWidget)
5028- } else if parent != nil {
5029- session.omniboxWidget.Unparent()
5030- }
5031- }
5032- session.omnibox = nil
5033- session.omniboxWidget = nil
5034-}
5035-
5036-func floatingFocusWidget(session *floatingWorkspaceSession) layout.Widget {
5037- if session == nil {
5038- return nil
5039- }
5040- if session.focusWidget != nil {
5041- return session.focusWidget
5042- }
5043- if session.paneView != nil {
5044- return session.paneView.WebViewWidget()
5045- }
5046- return nil
5047-}
5048-
5049-func focusFloatingSessionContent(session *floatingWorkspaceSession) {
5050- if session == nil || session.pane == nil {
5051- return
5052- }
5053- if !session.pane.IsVisible() || session.pane.IsOmniboxVisible() {
5054- return
5055- }
5056- w := floatingFocusWidget(session)
5057- if w == nil {
5058- return
5059- }
5060- w.GrabFocus()
5061-}
5062-
5063-func (a *App) resizeFloatingWidget(session *floatingWorkspaceSession) {
5064- if session == nil || session.pane == nil {
5065- return
5066- }
5067- session.pane.Resize()
5068- if session.widget == nil {
5069- return
5070- }
5071- width, height := session.pane.Dimensions()
5072- if session.appliedWidth == width && session.appliedHeight == height {
5073- return
5074- }
5075- session.widget.SetSizeRequest(width, height)
5076- session.appliedWidth = width
5077- session.appliedHeight = height
5078- if a.contentCoord != nil {
5079- syncCtx := context.Background()
5080- if a.deps != nil && a.deps.Ctx != nil {
5081- syncCtx = a.deps.Ctx
5082- }
5083- a.contentCoord.SyncWebViewViewport(syncCtx, session.paneID, "floating-resize")
5084- }
5085-}
5086-
5087-func (a *App) handleFloatingViewportTick(session *floatingWorkspaceSession) bool {
5088- if session == nil || session.pane == nil {
5089- return false
5090- }
5091- if !session.pane.IsVisible() {
5092- session.resizeWatcherActive = false
5093- session.resizeTickID = 0
5094- return false
5095- }
5096-
5097- a.resizeFloatingWidget(session)
5098- return true
5099-}
5100-
5101-func (a *App) floatingSessionByPaneID(paneID entity.PaneID) *floatingWorkspaceSession {
5102- if paneID == "" {
5103- return nil
5104- }
5105- for _, session := range a.floatingSessions {
5106- if session == nil || session.paneID != paneID {
5107- continue
5108- }
5109- return session
5110- }
5111- return nil
5112-}
5113-
5114-// popupOwnerWindowIDForPane scopes named contexts to the top-level owner. A
5115-// floating pane is mapped through its session's tab, never by parsing PaneID.
5116-func (a *App) popupOwnerWindowIDForPane(paneID entity.PaneID) (string, bool) {
5117- for key, session := range a.floatingSessions {
5118- if session == nil || session.paneID != paneID {
5119- continue
5120- }
5121- if bw := a.browserWindowForTab(key.tabID); bw != nil {
5122- return bw.id, true
5123- }
5124- return "", false
5125- }
5126- if bw := a.browserWindowForAnyPane(paneID); bw != nil {
5127- return bw.id, true
5128- }
5129- return "", false
5130-}
5131-
5132-func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {
5133- if session == nil || session.overlay == nil || session.resizeWatcherActive {
5134- return
5135- }
5136-
5137- overlayWidget := session.overlay.GtkWidget()
5138- if overlayWidget == nil {
5139- return
5140- }
5141-
5142- session.resizeWatcherActive = true
5143- paneID := session.paneID
5144- callback := new(gtk.TickCallback)
5145- *callback = func(_ uintptr, _ uintptr, _ uintptr) bool {
5146- liveSession := a.floatingSessionByPaneID(paneID)
5147- if liveSession == nil {
5148- session.releaseResizeTickCallback()
5149- return false
5150- }
5151-
5152- keepRunning := a.handleFloatingViewportTick(liveSession)
5153- if !keepRunning {
5154- liveSession.releaseResizeTickCallback()
5155- }
5156- return keepRunning
5157- }
5158- session.resizeTickCallback = callback
5159- session.resizeTickID = overlayWidget.AddTickCallback(callback, 0, nil)
5160-}
5161-
5162-func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) {
5163- if session == nil {
5164- return
5165- }
5166-
5167- tickID := session.resizeTickID
5168- session.releaseResizeTickCallback()
5169- if tickID != 0 && session.overlay != nil {
5170- if overlayWidget := session.overlay.GtkWidget(); overlayWidget != nil {
5171- overlayWidget.RemoveTickCallback(tickID)
5172- }
5173- }
5174-}
5175-
5176-// releaseResizeTickCallback releases the raw purego callback slot. It is safe
5177-// to call after GTK has already removed a callback that returned false.
5178-func (session *floatingWorkspaceSession) releaseResizeTickCallback() {
5179- if session == nil {
5180- return
5181- }
5182- callback := session.resizeTickCallback
5183- session.resizeTickID = 0
5184- session.resizeTickCallback = nil
5185- session.resizeWatcherActive = false
5186- if callback != nil {
5187- // AddTickCallback creates a purego function-pointer slot. Clearing the Go
5188- // pointer alone cannot return that finite runtime slot to the ledger.
5189- _ = unrefTickCallback(callback)
5190- }
5191-}
5192-
5193-func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspaceSession) {
5194- if session == nil || session.pane == nil {
5195- return
5196- }
5197-
5198- a.stopFloatingResizeWatcher(session)
5199- session.pane.Hide(ctx)
5200- a.hideFloatingOmnibox(ctx, session)
5201- hideFloatingWidget(session.widget)
5202- // Force a fresh size request on next show so WebKit gets a new
5203- // allocation cycle and repaints content after rapid toggles.
5204- session.appliedWidth = 0
5205- session.appliedHeight = 0
5206-}
5207-
5208-func (a *App) hideVisibleFloatingSessions(ctx context.Context, tabID entity.TabID, except *floatingWorkspaceSession) {
5209- for key, session := range a.floatingSessions {
5210- if key.tabID != tabID || session == nil || session == except || session.pane == nil {
5211- continue
5212- }
5213- if !session.pane.IsVisible() {
5214- continue
5215- }
5216- a.hideFloatingSession(ctx, session)
5217- }
5218-}
5219-
5220-func (a *App) closeActiveFloatingPane(ctx context.Context) bool {
5221- session, _ := a.activeFloatingSession()
5222- if session == nil {
5223- return false
5224- }
5225- a.hideFloatingSession(ctx, session)
5226- a.syncFloatingFocus()
5227- return true
5228-}
5229-
5230-func (a *App) handleGlobalEscape(ctx context.Context) bool {
5231- return a.closeActiveFloatingPane(ctx)
5232-}
5233-
5234-func (a *App) closeAndReleaseActiveFloatingPane(ctx context.Context) bool {
5235- key, session, ok := a.activeFloatingSessionEntry()
5236- if !ok {
5237- return false
5238- }
5239- a.releaseFloatingSession(ctx, key, session)
5240- a.syncFloatingFocus()
5241- return true
5242-}
5243-
5244-// ToggleFloatingPane toggles the active workspace floating pane visibility.
5245-func (a *App) ToggleFloatingPane(ctx context.Context) error {
5246- activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
5247- if activeTab == nil {
5248- return nil
5249- }
5250- if a.closeActiveFloatingPane(ctx) {
5251- return nil
5252- }
5253- return a.openFloatingPaneSession(ctx, floatingSessionIDDefault)
5254-}
5255-
5256-// OpenFloatingPaneURL opens the active workspace floating pane directly to a URL.
5257-func (a *App) OpenFloatingPaneURL(ctx context.Context, url string) error {
5258- return a.openFloatingPaneSession(ctx, floatingSessionIDDefault, url)
5259-}
5260-
5261-// OpenFloatingPaneProfileURL opens a named floating pane profile session directly to a URL.
5262-func (a *App) OpenFloatingPaneProfileURL(ctx context.Context, sessionID, url string) error {
5263- return a.openFloatingPaneSession(ctx, sessionID, url)
5264-}
5265-
5266-func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url ...string) error {
5267- activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
5268- if activeTab == nil {
5269- return nil
5270- }
5271-
5272- wsView := a.workspaceViews[activeTab.ID]
5273- session, err := a.ensureFloatingSession(ctx, activeTab.ID, sessionID, wsView)
5274- if err != nil {
5275- return err
5276- }
5277- hasURL := len(url) > 0 && strings.TrimSpace(url[0]) != ""
5278- isProfileSession := normalizeFloatingSessionID(sessionID) != floatingSessionIDDefault
5279-
5280- if hasURL && isProfileSession && session.pane.IsVisible() {
5281- a.hideFloatingSession(ctx, session)
5282- a.syncFloatingFocus()
5283- return nil
5284- }
5285-
5286- a.hideVisibleFloatingSessions(ctx, activeTab.ID, session)
5287-
5288- if hasURL {
5289- if isProfileSession && session.pane.SessionStarted() {
5290- session.pane.Show()
5291- } else {
5292- if err := session.pane.ShowURL(ctx, url[0]); err != nil {
5293- return err
5294- }
5295- }
5296- } else {
5297- if err := session.pane.ShowToggle(ctx); err != nil {
5298- return err
5299- }
5300- }
5301-
5302- a.resizeFloatingWidget(session)
5303- setFloatingWidgetShown(session.widget, session.pane.IsVisible())
5304- if session.pane.IsOmniboxVisible() {
5305- a.showFloatingOmnibox(ctx, session)
5306- } else {
5307- a.hideFloatingOmnibox(ctx, session)
5308- focusFloatingSessionContent(session)
5309- }
5310- a.startFloatingResizeWatcher(session)
5311- a.syncFloatingFocus()
5312-
5313- return nil
5314-}
5315-
53164150 func (a *App) navigateFromOmnibox(ctx context.Context, url string) error {
53174151 session, _ := a.activeFloatingSession()
53184152 if session != nil && session.pane != nil && session.pane.IsVisible() && session.pane.IsOmniboxVisible() {
@@ -5479,8 +4313,8 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt
54794313 view.SetOmniboxUIScale(runtimeCfg.DefaultUIScale)
54804314 }
54814315 }
5482- for _, session := range a.floatingSessions {
5483- if session != nil && session.omnibox != nil {
4316+ for _, session := range a.floatingSessions.all() {
4317+ if session.omnibox != nil {
54844318 session.omnibox.SetUIScale(runtimeCfg.DefaultUIScale)
54854319 }
54864320 }
M internal/ui/app_vim_mode_test.go +8 -8
@@ -580,7 +580,7 @@ func TestVimMode_NativePageFocusNavigationUsesActiveEditableWebView(t *testing.T
580580 app, bw, paneID := newVimModeAccessibilityFixture(t)
581581 wv := newInputFocusingWebView(t, 7)
582582 app.contentCoord.RegisterPopupWebView(paneID, wv)
583- app.pageEditableFocusByPane = map[entity.PaneID]bool{paneID: true}
583+ app.vimMode.editableFocus = map[entity.PaneID]bool{paneID: true}
584584
585585 assert.True(t, app.navigatePageFocus(context.Background(), bw, true))
586586 assert.Equal(t, []bool{true}, wv.pageFocusNavigationCalls)
@@ -884,7 +884,7 @@ func TestVimMode_PaneSwitchToEditablePaneExitsVimMode(t *testing.T) {
884884 f := newSingleWindowVimModeFixture(t)
885885 enterVimMode(t, f.app, f.bw1)
886886 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
887- f.app.pageEditableFocusByPane = map[entity.PaneID]bool{
887+ f.app.vimMode.editableFocus = map[entity.PaneID]bool{
888888 "pane-b": true,
889889 }
890890
@@ -981,7 +981,7 @@ func TestVimMode_TabSwitchExitsAndClearsOldAccent(t *testing.T) {
981981
982982 func TestVimMode_ActivationRemainsAvailableWhenPageIsEditable(t *testing.T) {
983983 f := newSingleWindowVimModeFixture(t)
984- f.app.pageEditableFocusByPane = map[entity.PaneID]bool{
984+ f.app.vimMode.editableFocus = map[entity.PaneID]bool{
985985 "pane-a": true,
986986 }
987987
@@ -1000,7 +1000,7 @@ func TestVimMode_ActivationAvailabilityDoesNotDependOnEditableFocus(t *testing.T
10001000
10011001 func TestVimMode_ClearEditableFocusStateRemovesStoredBypass(t *testing.T) {
10021002 f := newSingleWindowVimModeFixture(t)
1003- f.app.pageEditableFocusByPane = map[entity.PaneID]bool{"pane-a": true}
1003+ f.app.vimMode.editableFocus = map[entity.PaneID]bool{"pane-a": true}
10041004
10051005 f.app.clearPageEditableFocusState(entity.PaneID("pane-a"))
10061006
@@ -1220,7 +1220,7 @@ func TestVimMode_Pulse_DebounceAllowsSpacedCalls(t *testing.T) {
12201220 f := newSingleWindowVimModeFixture(t)
12211221
12221222 // Fresh app: debounce timer is zero.
1223- assert.True(t, f.app.vimModePulseLastTime.IsZero(),
1223+ assert.True(t, f.app.vimMode.pulseIdle(),
12241224 "fresh app should have zero pulse timer")
12251225
12261226 enterVimMode(t, f.app, f.bw1)
@@ -1231,7 +1231,7 @@ func TestVimMode_Pulse_DebounceAllowsSpacedCalls(t *testing.T) {
12311231 f.app.triggerVimModePulse(context.Background(), false)
12321232
12331233 // Timer is now set, proving the first pulse registered.
1234- assert.False(t, f.app.vimModePulseLastTime.IsZero(),
1234+ assert.False(t, f.app.vimMode.pulseIdle(),
12351235 "pulse timer should be non-zero after first pulse")
12361236 }
12371237
@@ -1248,7 +1248,7 @@ func TestVimMode_Pulse_DebounceResetOnClearOwnership(t *testing.T) {
12481248 attachTestModeFrame(f.bw1, f.overlay1A)
12491249 setUpNormalPulse(f.overlay1A)
12501250 f.app.triggerVimModePulse(context.Background(), false)
1251- assert.False(t, f.app.vimModePulseLastTime.IsZero(),
1251+ assert.False(t, f.app.vimMode.pulseIdle(),
12521252 "pulse timer should be non-zero after first pulse")
12531253
12541254 // Clear ownership — this resets the debounce timer.
@@ -1257,7 +1257,7 @@ func TestVimMode_Pulse_DebounceResetOnClearOwnership(t *testing.T) {
12571257 assert.Empty(t, f.bw1.vimModePaneID)
12581258
12591259 // Debounce timer should now be back to zero.
1260- assert.True(t, f.app.vimModePulseLastTime.IsZero(),
1260+ assert.True(t, f.app.vimMode.pulseIdle(),
12611261 "pulse timer should be reset after clearVimModeOwnership")
12621262 }
12631263
M internal/ui/component/omnibox.go +11 -189
@@ -3,7 +3,6 @@ package component
33 import (
44 "context"
55 "fmt"
6- stdurl "net/url"
76 "strings"
87 "sync"
98 "time"
@@ -1192,15 +1191,6 @@ func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool
11921191 return false
11931192 }
11941193
1195-// isGhostEcho reports whether the entry text is the debounced echo of Dumber's
1196-// own ghost completion: realInput plus the suffix currently displayed. A
1197-// matching echo must leave the ghost state untouched, but it cannot be told
1198-// apart from a paste that replaced the selected suffix with the same text, so a
1199-// deferred Enter still wins and the ghost state is rebuilt from the paste.
1200-func isGhostEcho(entryText, realInput, ghostSuffix string) bool {
1201- return ghostSuffix != "" && entryText == realInput+ghostSuffix
1202-}
1203-
12041194 // onEntryChanged handles text input changes with debouncing.
12051195 // When this fires, GTK has already processed the keystroke — if ghost text was
12061196 // selected and the user typed, the selection was replaced naturally by GTK.
@@ -1453,19 +1443,6 @@ func (o *Omnibox) updateGhostFromSelection() {
14531443 o.updateGhostFromSelectionWithInput(query)
14541444 }
14551445
1456-// reconcileEntryState decides how to resync the entry buffer with the
1457-// realInput/ghostSuffix shadow state. The buffer is the source of truth:
1458-// search-changed is debounced, so realInput lags behind fresh keystrokes.
1459-// The buffer is only ever rewritten to strip a still-intact ghost suffix —
1460-// never to restore older text, which would delete characters the user just
1461-// typed.
1462-func reconcileEntryState(buffer, realInput, ghostSuffix string) (newRealInput string, rewrite bool) {
1463- if ghostSuffix != "" && buffer == realInput+ghostSuffix {
1464- return realInput, true
1465- }
1466- return buffer, false
1467-}
1468-
14691446 // restoreEntryToRealInput strips any intact ghost suffix from the entry buffer
14701447 // and resyncs realInput from the buffer. It never writes stale realInput over
14711448 // the buffer (see reconcileEntryState).
@@ -1552,152 +1529,27 @@ func (o *Omnibox) resolveGhostCompletion(
15521529 favorites []Favorite,
15531530 ) {
15541531 log := logging.FromContext(o.ctx)
1555- _, completionInput, ok := ghostCompletionInput(entryText)
1532+ fullText, suffix, ok := ghostCompletion(ghostCompletionRequest{
1533+ EntryText: entryText,
1534+ SelectedURL: selectedURL,
1535+ HasExplicitSelection: hasExplicitSelection,
1536+ Mode: mode,
1537+ MaxVisible: maxVisible,
1538+ Suggestions: suggestions,
1539+ Favorites: favorites,
1540+ })
15561541 if !ok {
1557- log.Debug().Str("entryText", entryText).Msg("ghost: resolveGhost — no completion input")
1558- return
1559- }
1560-
1561- fullText, suffix, found := visibleGhostSuggestion(
1562- completionInput,
1563- selectedURL,
1564- hasExplicitSelection,
1565- mode,
1566- maxVisible,
1567- suggestions,
1568- favorites,
1569- )
1570- if !found {
1571- log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — no match found")
1572- return
1573- }
1574- fullText, suffix = normalizeGhostSuggestion(completionInput, fullText, suffix)
1575- if fullText == "" || suffix == "" {
1576- log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — empty after normalize")
1542+ log.Debug().Str("entryText", entryText).Msg("ghost: resolveGhost — no completion")
15771543 return
15781544 }
15791545 log.Debug().
1580- Str("input", completionInput).
1546+ Str("entryText", entryText).
15811547 Str("fullText", fullText).
15821548 Str("suffix", suffix).
15831549 Msg("ghost: resolveGhost — applying")
15841550 o.setGhostText(entryText, suffix)
15851551 }
15861552
1587-func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) {
1588- trimmed := strings.TrimLeft(entryText, " \t")
1589- leadingWhitespace = entryText[:len(entryText)-len(trimmed)]
1590- if trimmed == "" {
1591- return leadingWhitespace, "", false
1592- }
1593- return leadingWhitespace, trimmed, true
1594-}
1595-
1596-// normalizeGhostSuggestion trims noisy URL completions to a domain completion when input
1597-// appears to be host-like (e.g. "google" -> "google.com" instead of full redirect URL).
1598-func normalizeGhostSuggestion(queryText, fullText, fallbackSuffix string) (normalizedFullText, suffix string) {
1599- if queryText == "" || fullText == "" {
1600- return "", ""
1601- }
1602-
1603- // If input already contains path/query-ish delimiters, keep original completion behavior.
1604- if strings.ContainsAny(queryText, "/?#=& ") {
1605- return fullText, fallbackSuffix
1606- }
1607-
1608- hostOnly := extractHostForCompletion(fullText)
1609- if hostOnly != "" {
1610- for _, candidate := range []string{hostOnly, strings.TrimPrefix(hostOnly, "www.")} {
1611- if completionSuffix, ok := autocomplete.ComputeCompletionSuffix(queryText, candidate); ok {
1612- return candidate, completionSuffix
1613- }
1614- }
1615- }
1616-
1617- return fullText, fallbackSuffix
1618-}
1619-
1620-func extractHostForCompletion(raw string) string {
1621- if raw == "" {
1622- return ""
1623- }
1624- if parsed, err := stdurl.Parse(raw); err == nil && parsed.Host != "" {
1625- return strings.ToLower(parsed.Hostname())
1626- }
1627- trimmed := autocomplete.StripProtocol(raw)
1628- trimmed = strings.ToLower(trimmed)
1629- for _, sep := range []string{"/", "?", "#"} {
1630- if idx := strings.Index(trimmed, sep); idx >= 0 {
1631- trimmed = trimmed[:idx]
1632- }
1633- }
1634- return strings.TrimSpace(trimmed)
1635-}
1636-
1637-func visibleGhostSuggestion(
1638- query, selectedURL string,
1639- hasExplicitSelection bool,
1640- mode ViewMode,
1641- maxVisible int,
1642- suggestions []Suggestion,
1643- favorites []Favorite,
1644-) (fullText, suffix string, ok bool) {
1645- if hasExplicitSelection {
1646- suffix, fullText, ok = autocomplete.ComputeURLCompletionSuffix(query, selectedURL)
1647- return fullText, suffix, ok
1648- }
1649-
1650- visibleURLs := visibleURLsForMode(mode, maxVisible, suggestions, favorites)
1651- suffix, fullText, ok = autocomplete.BestURLCompletion(query, visibleURLs)
1652- return fullText, suffix, ok
1653-}
1654-
1655-func visibleURLsForMode(mode ViewMode, maxVisible int, suggestions []Suggestion, favorites []Favorite) []string {
1656- if mode == ViewModeHistory {
1657- visibleCount := visibleResultCount(len(suggestions), maxVisible)
1658- urls := make([]string, 0, visibleCount)
1659- for _, s := range suggestions[:visibleCount] {
1660- if s.URL != "" {
1661- urls = append(urls, s.URL)
1662- }
1663- }
1664- return urls
1665- }
1666-
1667- visibleCount := visibleResultCount(len(favorites), maxVisible)
1668- urls := make([]string, 0, visibleCount)
1669- for _, f := range favorites[:visibleCount] {
1670- if f.URL != "" {
1671- urls = append(urls, f.URL)
1672- }
1673- }
1674- return urls
1675-}
1676-
1677-func selectedTargetURL(mode ViewMode, idx, maxVisible int, suggestions []Suggestion, favorites []Favorite) (string, bool) {
1678- if idx < 0 {
1679- return "", false
1680- }
1681- return resolveTargetURLForSelection(mode, idx, maxVisible, suggestions, favorites), true
1682-}
1683-
1684-func bangSuggestionTextAt(idx int, bangSuggestions []BangSuggestion) (string, bool) {
1685- if idx < 0 || idx >= len(bangSuggestions) {
1686- return "", false
1687- }
1688- return "!" + bangSuggestions[idx].Key + " ", true
1689-}
1690-
1691-func visibleResultCount(total, maxVisible int) int {
1692- if total <= 0 {
1693- return 0
1694- }
1695- if maxVisible <= 0 || total < maxVisible {
1696- return total
1697- }
1698- return maxVisible
1699-}
1700-
17011553 // performSearch executes the search based on current view mode and query.
17021554 func (o *Omnibox) performSearch() {
17031555 o.mu.RLock()
@@ -1751,13 +1603,6 @@ func (o *Omnibox) performSearch() {
17511603 o.searchHistory(query, o.effectiveMaxRows(), token)
17521604 }
17531605
1754-func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string {
1755- if hasGhost && realInput != "" {
1756- return realInput
1757- }
1758- return entryText
1759-}
1760-
17611606 // searchHistory runs a fuzzy history search in a background goroutine.
17621607 // query is the search text; limit caps the number of results.
17631608 func (o *Omnibox) searchHistory(query string, limit int, token uint64) {
@@ -2535,29 +2380,6 @@ func (o *Omnibox) navigateToSelected() {
25352380 o.submitNavigation(targetURL)
25362381 }
25372382
2538-func resolveTargetURLForSelection(mode ViewMode, idx, maxVisible int, suggestions []Suggestion, favorites []Favorite) string {
2539- if mode == ViewModeHistory {
2540- visibleCount := visibleResultCount(len(suggestions), maxVisible)
2541- if idx >= 0 && idx < visibleCount {
2542- return suggestions[idx].URL
2543- }
2544- return ""
2545- }
2546- visibleCount := visibleResultCount(len(favorites), maxVisible)
2547- if idx >= 0 && idx < visibleCount {
2548- return favorites[idx].URL
2549- }
2550- return ""
2551-}
2552-
2553-func shouldPreferTypedURLNavigation(entryText string) bool {
2554- entryText = strings.TrimSpace(entryText)
2555- if entryText == "" {
2556- return false
2557- }
2558- return url.LooksLikeURL(entryText)
2559-}
2560-
25612383 func resolveFavoriteToggleTarget(
25622384 mode ViewMode,
25632385 idx int,
A internal/ui/component/omnibox_suggestions.go +213 -0
@@ -0,0 +1,213 @@
1+package component
2+
3+import (
4+ stdurl "net/url"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/domain/autocomplete"
8+ "github.com/bnema/dumber/internal/domain/url"
9+)
10+
11+// Omnibox suggestion logic: pure functions over the entry text and the
12+// visible result list. The GTK widget in omnibox.go only feeds state in and
13+// renders the answer, so this file is the test surface for ghost completion,
14+// selection targets, and search-query resolution.
15+
16+// ghostCompletionRequest is everything ghost completion depends on.
17+type ghostCompletionRequest struct {
18+ EntryText string
19+ SelectedURL string
20+ HasExplicitSelection bool
21+ Mode ViewMode
22+ MaxVisible int
23+ Suggestions []Suggestion
24+ Favorites []Favorite
25+}
26+
27+// ghostCompletion returns the inline completion suffix to show after the
28+// entry text, and the full text it completes to. ok is false when nothing
29+// should be shown. Host-like input completes to a domain rather than a full
30+// redirect URL.
31+func ghostCompletion(req ghostCompletionRequest) (fullText, suffix string, ok bool) {
32+ _, input, ok := ghostCompletionInput(req.EntryText)
33+ if !ok {
34+ return "", "", false
35+ }
36+ fullText, suffix, ok = visibleGhostSuggestion(
37+ input, req.SelectedURL, req.HasExplicitSelection, req.Mode, req.MaxVisible, req.Suggestions, req.Favorites,
38+ )
39+ if !ok {
40+ return "", "", false
41+ }
42+ fullText, suffix = normalizeGhostSuggestion(input, fullText, suffix)
43+ if fullText == "" || suffix == "" {
44+ return "", "", false
45+ }
46+ return fullText, suffix, true
47+}
48+
49+// isGhostEcho reports whether the entry text is the debounced echo of Dumber's
50+// own ghost completion: realInput plus the suffix currently displayed. A
51+// matching echo must leave the ghost state untouched, but it cannot be told
52+// apart from a paste that replaced the selected suffix with the same text, so a
53+// deferred Enter still wins and the ghost state is rebuilt from the paste.
54+func isGhostEcho(entryText, realInput, ghostSuffix string) bool {
55+ return ghostSuffix != "" && entryText == realInput+ghostSuffix
56+}
57+
58+// reconcileEntryState decides how to resync the entry buffer with the
59+// realInput/ghostSuffix shadow state. The buffer is the source of truth:
60+// search-changed is debounced, so realInput lags behind fresh keystrokes.
61+// The buffer is only ever rewritten to strip a still-intact ghost suffix —
62+// never to restore older text, which would delete characters the user just
63+// typed.
64+func reconcileEntryState(buffer, realInput, ghostSuffix string) (newRealInput string, rewrite bool) {
65+ if ghostSuffix != "" && buffer == realInput+ghostSuffix {
66+ return realInput, true
67+ }
68+ return buffer, false
69+}
70+
71+func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) {
72+ trimmed := strings.TrimLeft(entryText, " \t")
73+ leadingWhitespace = entryText[:len(entryText)-len(trimmed)]
74+ if trimmed == "" {
75+ return leadingWhitespace, "", false
76+ }
77+ return leadingWhitespace, trimmed, true
78+}
79+
80+// normalizeGhostSuggestion trims noisy URL completions to a domain completion when input
81+// appears to be host-like (e.g. "google" -> "google.com" instead of full redirect URL).
82+func normalizeGhostSuggestion(queryText, fullText, fallbackSuffix string) (normalizedFullText, suffix string) {
83+ if queryText == "" || fullText == "" {
84+ return "", ""
85+ }
86+
87+ // If input already contains path/query-ish delimiters, keep original completion behavior.
88+ if strings.ContainsAny(queryText, "/?#=& ") {
89+ return fullText, fallbackSuffix
90+ }
91+
92+ hostOnly := extractHostForCompletion(fullText)
93+ if hostOnly != "" {
94+ for _, candidate := range []string{hostOnly, strings.TrimPrefix(hostOnly, "www.")} {
95+ if completionSuffix, ok := autocomplete.ComputeCompletionSuffix(queryText, candidate); ok {
96+ return candidate, completionSuffix
97+ }
98+ }
99+ }
100+
101+ return fullText, fallbackSuffix
102+}
103+
104+func extractHostForCompletion(raw string) string {
105+ if raw == "" {
106+ return ""
107+ }
108+ if parsed, err := stdurl.Parse(raw); err == nil && parsed.Host != "" {
109+ return strings.ToLower(parsed.Hostname())
110+ }
111+ trimmed := autocomplete.StripProtocol(raw)
112+ trimmed = strings.ToLower(trimmed)
113+ for _, sep := range []string{"/", "?", "#"} {
114+ if idx := strings.Index(trimmed, sep); idx >= 0 {
115+ trimmed = trimmed[:idx]
116+ }
117+ }
118+ return strings.TrimSpace(trimmed)
119+}
120+
121+func visibleGhostSuggestion(
122+ query, selectedURL string,
123+ hasExplicitSelection bool,
124+ mode ViewMode,
125+ maxVisible int,
126+ suggestions []Suggestion,
127+ favorites []Favorite,
128+) (fullText, suffix string, ok bool) {
129+ if hasExplicitSelection {
130+ suffix, fullText, ok = autocomplete.ComputeURLCompletionSuffix(query, selectedURL)
131+ return fullText, suffix, ok
132+ }
133+
134+ visibleURLs := visibleURLsForMode(mode, maxVisible, suggestions, favorites)
135+ suffix, fullText, ok = autocomplete.BestURLCompletion(query, visibleURLs)
136+ return fullText, suffix, ok
137+}
138+
139+func visibleURLsForMode(mode ViewMode, maxVisible int, suggestions []Suggestion, favorites []Favorite) []string {
140+ if mode == ViewModeHistory {
141+ visibleCount := visibleResultCount(len(suggestions), maxVisible)
142+ urls := make([]string, 0, visibleCount)
143+ for _, s := range suggestions[:visibleCount] {
144+ if s.URL != "" {
145+ urls = append(urls, s.URL)
146+ }
147+ }
148+ return urls
149+ }
150+
151+ visibleCount := visibleResultCount(len(favorites), maxVisible)
152+ urls := make([]string, 0, visibleCount)
153+ for _, f := range favorites[:visibleCount] {
154+ if f.URL != "" {
155+ urls = append(urls, f.URL)
156+ }
157+ }
158+ return urls
159+}
160+
161+func selectedTargetURL(mode ViewMode, idx, maxVisible int, suggestions []Suggestion, favorites []Favorite) (string, bool) {
162+ if idx < 0 {
163+ return "", false
164+ }
165+ return resolveTargetURLForSelection(mode, idx, maxVisible, suggestions, favorites), true
166+}
167+
168+func bangSuggestionTextAt(idx int, bangSuggestions []BangSuggestion) (string, bool) {
169+ if idx < 0 || idx >= len(bangSuggestions) {
170+ return "", false
171+ }
172+ return "!" + bangSuggestions[idx].Key + " ", true
173+}
174+
175+func visibleResultCount(total, maxVisible int) int {
176+ if total <= 0 {
177+ return 0
178+ }
179+ if maxVisible <= 0 || total < maxVisible {
180+ return total
181+ }
182+ return maxVisible
183+}
184+
185+func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string {
186+ if hasGhost && realInput != "" {
187+ return realInput
188+ }
189+ return entryText
190+}
191+
192+func resolveTargetURLForSelection(mode ViewMode, idx, maxVisible int, suggestions []Suggestion, favorites []Favorite) string {
193+ if mode == ViewModeHistory {
194+ visibleCount := visibleResultCount(len(suggestions), maxVisible)
195+ if idx >= 0 && idx < visibleCount {
196+ return suggestions[idx].URL
197+ }
198+ return ""
199+ }
200+ visibleCount := visibleResultCount(len(favorites), maxVisible)
201+ if idx >= 0 && idx < visibleCount {
202+ return favorites[idx].URL
203+ }
204+ return ""
205+}
206+
207+func shouldPreferTypedURLNavigation(entryText string) bool {
208+ entryText = strings.TrimSpace(entryText)
209+ if entryText == "" {
210+ return false
211+ }
212+ return url.LooksLikeURL(entryText)
213+}
A internal/ui/component/omnibox_suggestions_test.go +71 -0
@@ -0,0 +1,71 @@
1+package component
2+
3+import "testing"
4+
5+func TestGhostCompletion(t *testing.T) {
6+ history := []Suggestion{
7+ {URL: "https://www.google.com/url?q=https://x"},
8+ {URL: "https://github.com/bnema/dumber"},
9+ }
10+ favorites := []Favorite{{URL: "https://gitlab.com/"}}
11+
12+ tests := []struct {
13+ name string
14+ req ghostCompletionRequest
15+ wantFull string
16+ wantSuffix string
17+ wantOK bool
18+ }{
19+ {
20+ name: "host-like input completes to the domain, not the redirect URL",
21+ req: ghostCompletionRequest{EntryText: "goo", Mode: ViewModeHistory, MaxVisible: 5, Suggestions: history},
22+ wantFull: "google.com",
23+ wantSuffix: "gle.com",
24+ wantOK: true,
25+ },
26+ {
27+ name: "leading whitespace is ignored",
28+ req: ghostCompletionRequest{EntryText: " gith", Mode: ViewModeHistory, MaxVisible: 5, Suggestions: history},
29+ wantFull: "github.com",
30+ wantSuffix: "ub.com",
31+ wantOK: true,
32+ },
33+ {
34+ name: "hidden rows never complete",
35+ req: ghostCompletionRequest{EntryText: "gith", Mode: ViewModeHistory, MaxVisible: 1, Suggestions: history},
36+ wantOK: false,
37+ },
38+ {
39+ name: "favorites mode uses favorites",
40+ req: ghostCompletionRequest{EntryText: "gitl", Mode: ViewModeFavorites, MaxVisible: 5, Favorites: favorites},
41+ wantFull: "gitlab.com",
42+ wantSuffix: "ab.com",
43+ wantOK: true,
44+ },
45+ {
46+ name: "explicit selection completes from the selected URL, not the best match",
47+ req: ghostCompletionRequest{
48+ EntryText: "gi", SelectedURL: "https://gitlab.com/", HasExplicitSelection: true,
49+ Mode: ViewModeHistory, MaxVisible: 5, Suggestions: history,
50+ },
51+ wantFull: "gitlab.com",
52+ wantSuffix: "tlab.com",
53+ wantOK: true,
54+ },
55+ {
56+ name: "blank input shows nothing",
57+ req: ghostCompletionRequest{EntryText: " ", Mode: ViewModeHistory, MaxVisible: 5, Suggestions: history},
58+ wantOK: false,
59+ },
60+ }
61+
62+ for _, tt := range tests {
63+ t.Run(tt.name, func(t *testing.T) {
64+ full, suffix, ok := ghostCompletion(tt.req)
65+ if ok != tt.wantOK || full != tt.wantFull || suffix != tt.wantSuffix {
66+ t.Fatalf("ghostCompletion() = (%q, %q, %v), want (%q, %q, %v)",
67+ full, suffix, ok, tt.wantFull, tt.wantSuffix, tt.wantOK)
68+ }
69+ })
70+ }
71+}
M internal/ui/coordinator/content/popup.go +4 -0
@@ -142,6 +142,10 @@ type NativePopupInput struct {
142142 Request port.PopupRequest
143143 ObserveOAuthAutoClose bool
144144 AllowBrowserWindowFallback bool
145+ // AlreadyReadyToShow is true when the popup's ready-to-show lifecycle
146+ // event fired before the native host was attached (deferred feature
147+ // resolution). The host must reveal it immediately instead of waiting.
148+ AlreadyReadyToShow bool
145149 // OnNativeHostAbort returns true when fallback adopted the WebView. A false
146150 // result leaves destruction responsibility with the native host.
147151 OnNativeHostAbort func(context.Context, port.WebView) bool
M internal/ui/coordinator/content/popup_browsing_context_test.go +1 -0
@@ -477,6 +477,7 @@ func TestHandlePopupCreate_OpensNativePopupForAuthIntent(t *testing.T) {
477477 assert.Equal(t, popupWV, input.PopupWebView)
478478 assert.Equal(t, "https://accounts.google.com/o/oauth2/v2/auth", input.TargetURI)
479479 assert.True(t, input.ObserveOAuthAutoClose)
480+ assert.False(t, input.AlreadyReadyToShow, "immediate popups wait for their own ready-to-show")
480481 return nil
481482 })
482483
M internal/ui/coordinator/content/popup_hosting.go +6 -3
@@ -170,7 +170,7 @@ func (pm *popupManager) openNativePopup(
170170 pm.setBrowsingContextDecision(popupWV, decision)
171171 if !pm.adoptPopupInNativePopup(
172172 ctx, hooks, parentPaneID, parentID, parentURIAtOpen, popupWV, req, decision,
173- dto.BrowsingContextFailureNativeArm,
173+ dto.BrowsingContextFailureNativeArm, false,
174174 ) {
175175 popupWV.Destroy()
176176 return nil
@@ -179,7 +179,8 @@ func (pm *popupManager) openNativePopup(
179179 }
180180
181181 // openExistingPopupInNativePopup attempts to transfer an already-created
182-// WebView. On failure ownership remains with the caller.
182+// WebView whose ready-to-show event has already fired (deferred feature
183+// resolution). On failure ownership remains with the caller.
183184 func (pm *popupManager) openExistingPopupInNativePopup(
184185 ctx context.Context,
185186 hooks popupCoordinatorHooks,
@@ -192,7 +193,7 @@ func (pm *popupManager) openExistingPopupInNativePopup(
192193 ) bool {
193194 return pm.adoptPopupInNativePopup(
194195 ctx, hooks, parentPaneID, parentWebViewID, parentURIAtOpen, popupWV, req, decision,
195- dto.BrowsingContextFailureHostFailed,
196+ dto.BrowsingContextFailureHostFailed, true,
196197 )
197198 }
198199
@@ -206,6 +207,7 @@ func (pm *popupManager) adoptPopupInNativePopup(
206207 req port.PopupRequest,
207208 decision dto.HostDecision,
208209 failureCode dto.BrowsingContextFailureCode,
210+ alreadyReadyToShow bool,
209211 ) bool {
210212 log := logging.FromContext(ctx)
211213 normalized := buildPopupBrowsingContextRequest(req)
@@ -236,6 +238,7 @@ func (pm *popupManager) adoptPopupInNativePopup(
236238 PopupWebView: popupWV, TargetURI: req.TargetURI, Request: req,
237239 ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI),
238240 AllowBrowserWindowFallback: allowFallback, OnNativeHostAbort: onAbort,
241+ AlreadyReadyToShow: alreadyReadyToShow,
239242 }); err != nil {
240243 logBrowsingContextFailure(*log, normalized, decision, failureCode, err)
241244 return false
M internal/ui/coordinator/content/popup_lifecycle_test.go +1 -0
@@ -358,6 +358,7 @@ func TestPopupDeferredFeaturedDetachesBeforeNativeHost(t *testing.T) {
358358 assert.True(t, staging.detached)
359359 assert.Equal(t, dto.PopupFeaturesSpecified, input.Request.PopupFeatures.State)
360360 assert.Same(t, popup, input.PopupWebView)
361+ assert.True(t, input.AlreadyReadyToShow, "deferred popups have already fired ready-to-show")
361362 return nil
362363 })
363364
A internal/ui/floating_pane.go +765 -0
@@ -0,0 +1,765 @@
1+package ui
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "unsafe"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/ui/component"
12+ "github.com/bnema/dumber/internal/ui/layout"
13+ "github.com/bnema/puregotk/v4/gtk"
14+)
15+
16+type floatingWorkspaceSession struct {
17+ paneID entity.PaneID
18+ pane *component.FloatingPane
19+ paneView *component.PaneView
20+ webView port.WebView
21+ overlay layout.OverlayWidget
22+ widget layout.Widget
23+ focusWidget layout.Widget
24+ omnibox *component.Omnibox
25+ omniboxWidget layout.Widget
26+ resizeWatcherActive bool
27+ // resizeTickCallback keeps the purego callback live until its GTK source is
28+ // removed. It is cleared exactly once with resizeTickID.
29+ resizeTickCallback *gtk.TickCallback
30+ resizeTickID uint
31+ appliedWidth int
32+ appliedHeight int
33+}
34+
35+type floatingSessionKey struct {
36+ tabID entity.TabID
37+ sessionID string
38+}
39+
40+func normalizeFloatingSessionID(sessionID string) string {
41+ sessionID = strings.TrimSpace(sessionID)
42+ if sessionID == "" {
43+ return floatingSessionIDDefault
44+ }
45+ return sessionID
46+}
47+
48+func floatingPaneIDForSession(tabID entity.TabID, sessionID string) entity.PaneID {
49+ return entity.PaneID(floatingPaneIDPrefix + string(tabID) + ":" + normalizeFloatingSessionID(sessionID))
50+}
51+
52+func floatingSessionMapKey(tabID entity.TabID, sessionID string) floatingSessionKey {
53+ return floatingSessionKey{tabID: tabID, sessionID: normalizeFloatingSessionID(sessionID)}
54+}
55+
56+func decorateFloatingOverlay(overlay layout.OverlayWidget) {
57+ if overlay == nil {
58+ return
59+ }
60+
61+ overlay.SetHexpand(false)
62+ overlay.SetVexpand(false)
63+ overlay.AddCssClass("floating-pane-container")
64+ overlay.AddCssClass("pane-border")
65+ overlay.AddCssClass("pane-active")
66+}
67+
68+// showFloatingWidget makes a floating pane widget interactive and visible.
69+// Visibility is controlled by CSS class to animate opacity while keeping the
70+// widget mapped so WebKit can redraw without a hard pop.
71+func showFloatingWidget(w layout.Widget) {
72+ if w == nil {
73+ return
74+ }
75+ w.AddCssClass(floatingPaneVisibleClass)
76+ w.SetCanTarget(true)
77+ w.SetCanFocus(true)
78+}
79+
80+// hideFloatingWidget makes a floating pane widget invisible and non-interactive.
81+// Removes the visible CSS class instead of SetVisible(false) so GTK keeps the
82+// widget mapped and WebKit retains its rendered surface in GPU memory.
83+func hideFloatingWidget(w layout.Widget) {
84+ if w == nil {
85+ return
86+ }
87+ w.RemoveCssClass(floatingPaneVisibleClass)
88+ w.SetCanTarget(false)
89+ w.SetCanFocus(false)
90+}
91+
92+// setFloatingWidgetShown applies show or hide based on the visible flag.
93+func setFloatingWidgetShown(w layout.Widget, visible bool) {
94+ if visible {
95+ showFloatingWidget(w)
96+ } else {
97+ hideFloatingWidget(w)
98+ }
99+}
100+
101+func configureFloatingOverlayMeasurement(workspaceOverlay layout.OverlayWidget, floatingOverlay layout.Widget) {
102+ if workspaceOverlay == nil || floatingOverlay == nil {
103+ return
104+ }
105+
106+ workspaceOverlay.SetMeasureOverlay(floatingOverlay, false)
107+ workspaceOverlay.SetClipOverlay(floatingOverlay, false)
108+}
109+
110+func floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHeight int) (x, y, width, height int, ok bool) {
111+ if overlayWidth <= 0 || overlayHeight <= 0 || desiredWidth <= 0 || desiredHeight <= 0 {
112+ return 0, 0, 0, 0, false
113+ }
114+
115+ width = desiredWidth
116+ height = desiredHeight
117+ if width > overlayWidth {
118+ width = overlayWidth
119+ }
120+ if height > overlayHeight {
121+ height = overlayHeight
122+ }
123+
124+ x = (overlayWidth - width) / 2
125+ y = (overlayHeight - height) / 2
126+ if x < 0 {
127+ x = 0
128+ }
129+ if y < 0 {
130+ y = 0
131+ }
132+
133+ return x, y, width, height, true
134+}
135+
136+type gtkAllocationLayout [4]int32
137+
138+const gtkAllocationLayoutSize = 16
139+
140+var (
141+ _ [gtkAllocationLayoutSize - int(unsafe.Sizeof(gtkAllocationLayout{}))]struct{}
142+ _ [int(unsafe.Sizeof(gtkAllocationLayout{})) - gtkAllocationLayoutSize]struct{}
143+)
144+
145+// writeOverlayAllocation writes GTK-owned GtkAllocation fields in place.
146+// Assumes GTK4 C layout for GdkRectangle/GtkAllocation: four contiguous
147+// 32-bit signed integers {x, y, width, height} in native endianness.
148+// If GTK struct layout changes, revisit offsets and writes below.
149+//
150+//nolint:gosec // Intentional unsafe pointer math against GTK-owned allocation memory.
151+func writeOverlayAllocation(allocationPtr *uintptr, x, y, width, height int) bool {
152+ if allocationPtr == nil {
153+ return false
154+ }
155+
156+ const (
157+ gtkAllocationOffsetX = uintptr(0)
158+ gtkAllocationOffsetY = uintptr(4)
159+ gtkAllocationOffsetWidth = uintptr(8)
160+ gtkAllocationOffsetHeight = uintptr(12)
161+ )
162+
163+ // GtkAllocation/GdkRectangle is four 32-bit signed integers in C.
164+ base := unsafe.Pointer(allocationPtr)
165+ *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetX)) = int32(x)
166+ *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetY)) = int32(y)
167+ *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetWidth)) = int32(width)
168+ *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetHeight)) = int32(height)
169+ return true
170+}
171+
172+func (a *App) floatingAllocationForWidget(
173+ tabID entity.TabID,
174+ widgetPtr uintptr,
175+ overlayWidth, overlayHeight int,
176+) (x, y, width, height int, ok bool) {
177+ for _, session := range a.floatingSessions.forTab(tabID) {
178+ if session.widget == nil {
179+ continue
180+ }
181+
182+ gtkWidget := session.widget.GtkWidget()
183+ if gtkWidget == nil || gtkWidget.GoPointer() != widgetPtr {
184+ continue
185+ }
186+
187+ desiredWidth := session.appliedWidth
188+ desiredHeight := session.appliedHeight
189+ if (desiredWidth <= 0 || desiredHeight <= 0) && session.pane != nil {
190+ session.pane.Resize()
191+ desiredWidth, desiredHeight = session.pane.Dimensions()
192+ }
193+
194+ return floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHeight)
195+ }
196+
197+ return 0, 0, 0, 0, false
198+}
199+
200+func (a *App) installFloatingOverlayPositioning(tabID entity.TabID, workspaceOverlay layout.OverlayWidget) {
201+ if workspaceOverlay == nil {
202+ return
203+ }
204+
205+ workspaceWidget := workspaceOverlay.GtkWidget()
206+ if workspaceWidget == nil {
207+ return
208+ }
209+
210+ gtkOverlay := gtk.OverlayNewFromInternalPtr(workspaceWidget.GoPointer())
211+ if gtkOverlay == nil {
212+ return
213+ }
214+
215+ cb := func(_ gtk.Overlay, widgetPtr uintptr, allocationPtr *uintptr) bool {
216+ widget := gtk.WidgetNewFromInternalPtr(widgetPtr)
217+ if widget != nil {
218+ widgetPtr = widget.GoPointer()
219+ }
220+ overlayWidth := workspaceOverlay.GetAllocatedWidth()
221+ overlayHeight := workspaceOverlay.GetAllocatedHeight()
222+ x, y, width, height, ok := a.floatingAllocationForWidget(tabID, widgetPtr, overlayWidth, overlayHeight)
223+ if !ok {
224+ return false
225+ }
226+ return writeOverlayAllocation(allocationPtr, x, y, width, height)
227+ }
228+
229+ gtkOverlay.ConnectGetChildPosition(&cb)
230+}
231+
232+// currentFloatingWidthPct returns the configured floating pane width percentage.
233+func (a *App) currentFloatingWidthPct() float64 {
234+ return a.runtimeConfigSnapshot().UI.Workspace.FloatingPane.WidthPct
235+}
236+
237+// currentFloatingHeightPct returns the configured floating pane height percentage.
238+func (a *App) currentFloatingHeightPct() float64 {
239+ return a.runtimeConfigSnapshot().UI.Workspace.FloatingPane.HeightPct
240+}
241+
242+func (a *App) ensureFloatingSession(
243+ ctx context.Context,
244+ tabID entity.TabID,
245+ sessionID string,
246+ wsView *component.WorkspaceView,
247+) (*floatingWorkspaceSession, error) {
248+ key := floatingSessionMapKey(tabID, sessionID)
249+ if session, ok := a.floatingSessions[key]; ok && session != nil {
250+ if wsView == nil {
251+ return session, nil
252+ }
253+ session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
254+ if session.widget != nil {
255+ wsView.AddWorkspaceOverlayWidget(session.widget)
256+ configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), session.widget)
257+ }
258+ if session.paneView != nil {
259+ wsView.RegisterPaneView(session.paneID, session.paneView)
260+ if session.focusWidget == nil {
261+ session.focusWidget = session.paneView.WebViewWidget()
262+ }
263+ }
264+ return session, nil
265+ }
266+
267+ if wsView == nil {
268+ return nil, fmt.Errorf("workspace view not found for tab %s", tabID)
269+ }
270+ if a.contentCoord == nil {
271+ return nil, fmt.Errorf("content coordinator not initialized")
272+ }
273+ if a.widgetFactory == nil {
274+ return nil, fmt.Errorf("widget factory not initialized")
275+ }
276+
277+ paneID := floatingPaneIDForSession(tabID, sessionID)
278+ wv, err := a.contentCoord.EnsureWebView(ctx, paneID)
279+ if err != nil {
280+ return nil, fmt.Errorf("ensure floating webview: %w", err)
281+ }
282+
283+ webViewWidget := a.contentCoord.WrapWidget(ctx, wv)
284+ if webViewWidget == nil {
285+ return nil, fmt.Errorf("wrap floating webview widget")
286+ }
287+ webViewWidget.SetHexpand(true)
288+ webViewWidget.SetVexpand(true)
289+
290+ // Wrap the WebView in a PaneView so it gets progress bar, loading
291+ // skeleton, and link status overlays — the same widget hierarchy as
292+ // regular workspace panes. This allows the content coordinator's
293+ // existing callbacks (onLoadStarted, onProgressChanged, etc.) to find
294+ // and update the floating pane automatically.
295+ pv := component.NewPaneView(ctx, a.widgetFactory, paneID, webViewWidget)
296+ pv.SetActive(true)
297+ // Hide loading skeleton — floating panes have their own themed container.
298+ pv.HideLoadingSkeleton()
299+ wsView.RegisterPaneView(paneID, pv)
300+
301+ // Use the PaneView's overlay directly as the floating overlay.
302+ // This avoids nesting two overlays which causes GTK4 allocation
303+ // issues (inner overlay expanding to fill the parent's allocation).
304+ pvOverlay := pv.Overlay()
305+ decorateFloatingOverlay(pvOverlay)
306+ pvOverlay.SetHalign(gtk.AlignCenterValue)
307+ pvOverlay.SetValign(gtk.AlignCenterValue)
308+ hideFloatingWidget(pvOverlay)
309+ wsView.AddWorkspaceOverlayWidget(pvOverlay)
310+ configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), pvOverlay)
311+
312+ floatingPane := component.NewFloatingPane(wsView.WorkspaceOverlayWidget(), component.FloatingPaneOptions{
313+ WidthPct: a.currentFloatingWidthPct(),
314+ HeightPct: a.currentFloatingHeightPct(),
315+ FallbackWidth: floatingPaneFallbackWidth,
316+ FallbackHeight: floatingPaneFallbackHeight,
317+ OnNavigate: func(navCtx context.Context, url string) error {
318+ return wv.LoadURI(navCtx, url)
319+ },
320+ })
321+
322+ session := &floatingWorkspaceSession{
323+ paneID: paneID,
324+ pane: floatingPane,
325+ paneView: pv,
326+ webView: wv,
327+ overlay: pvOverlay,
328+ widget: pvOverlay,
329+ focusWidget: webViewWidget,
330+ }
331+ a.floatingSessions[key] = session
332+ return session, nil
333+}
334+
335+func (a *App) releaseFloatingSessionsForTab(ctx context.Context, tabID entity.TabID) {
336+ // Snapshot: releaseFloatingSession deletes from the registry.
337+ sessions := a.floatingSessions.forTabSnapshot(tabID)
338+ if len(sessions) == 0 {
339+ return
340+ }
341+
342+ for key, session := range sessions {
343+ a.releaseFloatingSession(ctx, key, session)
344+ }
345+ a.syncFloatingFocus()
346+}
347+
348+func (a *App) activeFloatingSessionEntry() (floatingSessionKey, *floatingWorkspaceSession, bool) {
349+ activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
350+ if activeTab == nil {
351+ return floatingSessionKey{}, nil, false
352+ }
353+ return a.floatingSessions.visibleForTab(activeTab.ID)
354+}
355+
356+func (a *App) activeFloatingSession() (*floatingWorkspaceSession, entity.TabID) {
357+ key, session, ok := a.activeFloatingSessionEntry()
358+ if !ok {
359+ return nil, key.tabID
360+ }
361+ return session, key.tabID
362+}
363+
364+func (a *App) releaseFloatingSession(ctx context.Context, key floatingSessionKey, session *floatingWorkspaceSession) {
365+ if session == nil {
366+ return
367+ }
368+
369+ a.stopFloatingResizeWatcher(session)
370+ if session.pane != nil {
371+ session.pane.Hide(ctx)
372+ }
373+ a.hideFloatingOmnibox(ctx, session)
374+ if wsView := a.workspaceViews[key.tabID]; wsView != nil {
375+ if session.widget != nil {
376+ wsView.RemoveWorkspaceOverlayWidget(session.widget)
377+ }
378+ if session.paneID != "" {
379+ wsView.UnregisterPaneView(session.paneID)
380+ }
381+ }
382+ if a.contentCoord != nil && session.paneID != "" {
383+ a.contentCoord.ReleaseWebView(ctx, session.paneID)
384+ }
385+ delete(a.floatingSessions, key)
386+
387+ session.appliedWidth = 0
388+ session.appliedHeight = 0
389+ session.focusWidget = nil
390+ session.paneView = nil
391+ session.widget = nil
392+ session.overlay = nil
393+ session.webView = nil
394+ session.pane = nil
395+ // Session is fully released/zeroed; caller must drop this reference and not reuse it.
396+}
397+
398+func (a *App) updateFloatingSessionURI(paneID entity.PaneID, url string) {
399+ if session := a.floatingSessionByPaneID(paneID); session != nil && session.pane != nil {
400+ session.pane.RecordLoadedURL(url)
401+ }
402+}
403+
404+func (a *App) syncFloatingFocus() {
405+ for _, wsView := range a.workspaceViews {
406+ if wsView != nil {
407+ wsView.SetHoverFocusLocked(false)
408+ }
409+ }
410+
411+ if a.contentCoord == nil {
412+ return
413+ }
414+
415+ activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
416+ if activeTab == nil {
417+ a.contentCoord.ClearActivePaneOverride()
418+ return
419+ }
420+
421+ if session, _ := a.activeFloatingSession(); session != nil {
422+ if wsView := a.workspaceViews[activeTab.ID]; wsView != nil {
423+ wsView.SetHoverFocusLocked(true)
424+ }
425+ a.startFloatingResizeWatcher(session)
426+ a.contentCoord.SetActivePaneOverride(session.paneID)
427+ return
428+ }
429+
430+ a.contentCoord.ClearActivePaneOverride()
431+}
432+
433+func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) {
434+ if session == nil || session.overlay == nil || a.widgetFactory == nil {
435+ return
436+ }
437+
438+ if session.omnibox == nil {
439+ cfg := a.omniboxCfg
440+ cfg.OnNavigate = func(navCtx context.Context, url string) error {
441+ if session.pane == nil {
442+ return fmt.Errorf("floating pane is not available")
443+ }
444+ if navCtx == nil {
445+ navCtx = ctx
446+ }
447+ return session.pane.Navigate(navCtx, url)
448+ }
449+ cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) {
450+ a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level)
451+ }
452+
453+ omnibox := component.NewOmnibox(ctx, cfg)
454+ if omnibox == nil {
455+ return
456+ }
457+
458+ omnibox.SetParentOverlay(session.overlay)
459+ omniboxWidget := omnibox.WidgetAsLayout(a.widgetFactory)
460+ if omniboxWidget == nil {
461+ return
462+ }
463+
464+ session.overlay.AddOverlay(omniboxWidget)
465+ session.overlay.SetClipOverlay(omniboxWidget, false)
466+ session.overlay.SetMeasureOverlay(omniboxWidget, false)
467+
468+ session.omnibox = omnibox
469+ session.omniboxWidget = omniboxWidget
470+ }
471+
472+ session.pane.SetOmniboxVisible(true)
473+ session.omnibox.Show(ctx, "")
474+}
475+
476+func (a *App) hideFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) {
477+ if session == nil || session.pane == nil {
478+ return
479+ }
480+
481+ session.pane.SetOmniboxVisible(false)
482+ if session.omnibox == nil {
483+ return
484+ }
485+
486+ session.omnibox.Hide(ctx)
487+ if session.overlay != nil && session.omniboxWidget != nil {
488+ if parent := session.omniboxWidget.GetParent(); parent == session.overlay {
489+ session.overlay.RemoveOverlay(session.omniboxWidget)
490+ } else if parent != nil {
491+ session.omniboxWidget.Unparent()
492+ }
493+ }
494+ session.omnibox = nil
495+ session.omniboxWidget = nil
496+}
497+
498+func floatingFocusWidget(session *floatingWorkspaceSession) layout.Widget {
499+ if session == nil {
500+ return nil
501+ }
502+ if session.focusWidget != nil {
503+ return session.focusWidget
504+ }
505+ if session.paneView != nil {
506+ return session.paneView.WebViewWidget()
507+ }
508+ return nil
509+}
510+
511+func focusFloatingSessionContent(session *floatingWorkspaceSession) {
512+ if session == nil || session.pane == nil {
513+ return
514+ }
515+ if !session.pane.IsVisible() || session.pane.IsOmniboxVisible() {
516+ return
517+ }
518+ w := floatingFocusWidget(session)
519+ if w == nil {
520+ return
521+ }
522+ w.GrabFocus()
523+}
524+
525+func (a *App) resizeFloatingWidget(session *floatingWorkspaceSession) {
526+ if session == nil || session.pane == nil {
527+ return
528+ }
529+ session.pane.Resize()
530+ if session.widget == nil {
531+ return
532+ }
533+ width, height := session.pane.Dimensions()
534+ if session.appliedWidth == width && session.appliedHeight == height {
535+ return
536+ }
537+ session.widget.SetSizeRequest(width, height)
538+ session.appliedWidth = width
539+ session.appliedHeight = height
540+ if a.contentCoord != nil {
541+ syncCtx := context.Background()
542+ if a.deps != nil && a.deps.Ctx != nil {
543+ syncCtx = a.deps.Ctx
544+ }
545+ a.contentCoord.SyncWebViewViewport(syncCtx, session.paneID, "floating-resize")
546+ }
547+}
548+
549+func (a *App) handleFloatingViewportTick(session *floatingWorkspaceSession) bool {
550+ if session == nil || session.pane == nil {
551+ return false
552+ }
553+ if !session.pane.IsVisible() {
554+ session.resizeWatcherActive = false
555+ session.resizeTickID = 0
556+ return false
557+ }
558+
559+ a.resizeFloatingWidget(session)
560+ return true
561+}
562+
563+func (a *App) floatingSessionByPaneID(paneID entity.PaneID) *floatingWorkspaceSession {
564+ _, session, _ := a.floatingSessions.byPaneID(paneID)
565+ return session
566+}
567+
568+// popupOwnerWindowIDForPane scopes named contexts to the top-level owner. A
569+// floating pane is mapped through its session's tab, never by parsing PaneID.
570+func (a *App) popupOwnerWindowIDForPane(paneID entity.PaneID) (string, bool) {
571+ if key, _, ok := a.floatingSessions.byPaneID(paneID); ok {
572+ if bw := a.browserWindowForTab(key.tabID); bw != nil {
573+ return bw.id, true
574+ }
575+ return "", false
576+ }
577+ if bw := a.browserWindowForAnyPane(paneID); bw != nil {
578+ return bw.id, true
579+ }
580+ return "", false
581+}
582+
583+func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {
584+ if session == nil || session.overlay == nil || session.resizeWatcherActive {
585+ return
586+ }
587+
588+ overlayWidget := session.overlay.GtkWidget()
589+ if overlayWidget == nil {
590+ return
591+ }
592+
593+ session.resizeWatcherActive = true
594+ paneID := session.paneID
595+ callback := new(gtk.TickCallback)
596+ *callback = func(_ uintptr, _ uintptr, _ uintptr) bool {
597+ liveSession := a.floatingSessionByPaneID(paneID)
598+ if liveSession == nil {
599+ session.releaseResizeTickCallback()
600+ return false
601+ }
602+
603+ keepRunning := a.handleFloatingViewportTick(liveSession)
604+ if !keepRunning {
605+ liveSession.releaseResizeTickCallback()
606+ }
607+ return keepRunning
608+ }
609+ session.resizeTickCallback = callback
610+ session.resizeTickID = overlayWidget.AddTickCallback(callback, 0, nil)
611+}
612+
613+func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) {
614+ if session == nil {
615+ return
616+ }
617+
618+ tickID := session.resizeTickID
619+ session.releaseResizeTickCallback()
620+ if tickID != 0 && session.overlay != nil {
621+ if overlayWidget := session.overlay.GtkWidget(); overlayWidget != nil {
622+ overlayWidget.RemoveTickCallback(tickID)
623+ }
624+ }
625+}
626+
627+// releaseResizeTickCallback releases the raw purego callback slot. It is safe
628+// to call after GTK has already removed a callback that returned false.
629+func (session *floatingWorkspaceSession) releaseResizeTickCallback() {
630+ if session == nil {
631+ return
632+ }
633+ callback := session.resizeTickCallback
634+ session.resizeTickID = 0
635+ session.resizeTickCallback = nil
636+ session.resizeWatcherActive = false
637+ if callback != nil {
638+ // AddTickCallback creates a purego function-pointer slot. Clearing the Go
639+ // pointer alone cannot return that finite runtime slot to the ledger.
640+ _ = unrefTickCallback(callback)
641+ }
642+}
643+
644+func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspaceSession) {
645+ if session == nil || session.pane == nil {
646+ return
647+ }
648+
649+ a.stopFloatingResizeWatcher(session)
650+ session.pane.Hide(ctx)
651+ a.hideFloatingOmnibox(ctx, session)
652+ hideFloatingWidget(session.widget)
653+ // Force a fresh size request on next show so WebKit gets a new
654+ // allocation cycle and repaints content after rapid toggles.
655+ session.appliedWidth = 0
656+ session.appliedHeight = 0
657+}
658+
659+func (a *App) hideVisibleFloatingSessions(ctx context.Context, tabID entity.TabID, except *floatingWorkspaceSession) {
660+ for _, session := range a.floatingSessions.forTab(tabID) {
661+ if session == except || session.pane == nil {
662+ continue
663+ }
664+ if !session.pane.IsVisible() {
665+ continue
666+ }
667+ a.hideFloatingSession(ctx, session)
668+ }
669+}
670+
671+func (a *App) closeActiveFloatingPane(ctx context.Context) bool {
672+ session, _ := a.activeFloatingSession()
673+ if session == nil {
674+ return false
675+ }
676+ a.hideFloatingSession(ctx, session)
677+ a.syncFloatingFocus()
678+ return true
679+}
680+
681+func (a *App) handleGlobalEscape(ctx context.Context) bool {
682+ return a.closeActiveFloatingPane(ctx)
683+}
684+
685+func (a *App) closeAndReleaseActiveFloatingPane(ctx context.Context) bool {
686+ key, session, ok := a.activeFloatingSessionEntry()
687+ if !ok {
688+ return false
689+ }
690+ a.releaseFloatingSession(ctx, key, session)
691+ a.syncFloatingFocus()
692+ return true
693+}
694+
695+// ToggleFloatingPane toggles the active workspace floating pane visibility.
696+func (a *App) ToggleFloatingPane(ctx context.Context) error {
697+ activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
698+ if activeTab == nil {
699+ return nil
700+ }
701+ if a.closeActiveFloatingPane(ctx) {
702+ return nil
703+ }
704+ return a.openFloatingPaneSession(ctx, floatingSessionIDDefault)
705+}
706+
707+// OpenFloatingPaneURL opens the active workspace floating pane directly to a URL.
708+func (a *App) OpenFloatingPaneURL(ctx context.Context, url string) error {
709+ return a.openFloatingPaneSession(ctx, floatingSessionIDDefault, url)
710+}
711+
712+// OpenFloatingPaneProfileURL opens a named floating pane profile session directly to a URL.
713+func (a *App) OpenFloatingPaneProfileURL(ctx context.Context, sessionID, url string) error {
714+ return a.openFloatingPaneSession(ctx, sessionID, url)
715+}
716+
717+func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url ...string) error {
718+ activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
719+ if activeTab == nil {
720+ return nil
721+ }
722+
723+ wsView := a.workspaceViews[activeTab.ID]
724+ session, err := a.ensureFloatingSession(ctx, activeTab.ID, sessionID, wsView)
725+ if err != nil {
726+ return err
727+ }
728+ hasURL := len(url) > 0 && strings.TrimSpace(url[0]) != ""
729+ isProfileSession := normalizeFloatingSessionID(sessionID) != floatingSessionIDDefault
730+
731+ if hasURL && isProfileSession && session.pane.IsVisible() {
732+ a.hideFloatingSession(ctx, session)
733+ a.syncFloatingFocus()
734+ return nil
735+ }
736+
737+ a.hideVisibleFloatingSessions(ctx, activeTab.ID, session)
738+
739+ if hasURL {
740+ if isProfileSession && session.pane.SessionStarted() {
741+ session.pane.Show()
742+ } else {
743+ if err := session.pane.ShowURL(ctx, url[0]); err != nil {
744+ return err
745+ }
746+ }
747+ } else {
748+ if err := session.pane.ShowToggle(ctx); err != nil {
749+ return err
750+ }
751+ }
752+
753+ a.resizeFloatingWidget(session)
754+ setFloatingWidgetShown(session.widget, session.pane.IsVisible())
755+ if session.pane.IsOmniboxVisible() {
756+ a.showFloatingOmnibox(ctx, session)
757+ } else {
758+ a.hideFloatingOmnibox(ctx, session)
759+ focusFloatingSessionContent(session)
760+ }
761+ a.startFloatingResizeWatcher(session)
762+ a.syncFloatingFocus()
763+
764+ return nil
765+}
A internal/ui/floating_session_registry.go +82 -0
@@ -0,0 +1,82 @@
1+package ui
2+
3+import (
4+ "iter"
5+ "maps"
6+ "slices"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+// floatingSessionRegistry owns every floating pane session, keyed by tab and
12+// profile session. All lookups over sessions go through it so the ownership
13+// rules (one session per key, nil entries ignored, pane-to-tab mapping via
14+// the key, never by parsing PaneID) live in one place.
15+type floatingSessionRegistry map[floatingSessionKey]*floatingWorkspaceSession
16+
17+// all yields every non-nil session without allocating. Callers must not add
18+// or remove sessions while iterating; use forTabSnapshot for that.
19+func (r floatingSessionRegistry) all() iter.Seq2[floatingSessionKey, *floatingWorkspaceSession] {
20+ return func(yield func(floatingSessionKey, *floatingWorkspaceSession) bool) {
21+ for key, session := range r {
22+ if session != nil && !yield(key, session) {
23+ return
24+ }
25+ }
26+ }
27+}
28+
29+// forTab yields the non-nil sessions owned by tabID without allocating, so it
30+// is safe on hot GTK layout callbacks. Same mutation rule as all.
31+func (r floatingSessionRegistry) forTab(tabID entity.TabID) iter.Seq2[floatingSessionKey, *floatingWorkspaceSession] {
32+ return func(yield func(floatingSessionKey, *floatingWorkspaceSession) bool) {
33+ for key, session := range r.all() {
34+ if key.tabID == tabID && !yield(key, session) {
35+ return
36+ }
37+ }
38+ }
39+}
40+
41+// forTabSnapshot copies tabID's sessions so the caller may release them.
42+func (r floatingSessionRegistry) forTabSnapshot(tabID entity.TabID) map[floatingSessionKey]*floatingWorkspaceSession {
43+ return maps.Collect(r.forTab(tabID))
44+}
45+
46+// byPaneID returns the session hosting paneID and its key.
47+func (r floatingSessionRegistry) byPaneID(paneID entity.PaneID) (floatingSessionKey, *floatingWorkspaceSession, bool) {
48+ if paneID == "" {
49+ return floatingSessionKey{}, nil, false
50+ }
51+ for key, session := range r {
52+ if session != nil && session.paneID == paneID {
53+ return key, session, true
54+ }
55+ }
56+ return floatingSessionKey{}, nil, false
57+}
58+
59+// visibleForTab returns the visible session of tabID, if any.
60+func (r floatingSessionRegistry) visibleForTab(tabID entity.TabID) (floatingSessionKey, *floatingWorkspaceSession, bool) {
61+ for key, session := range r {
62+ if key.tabID != tabID || session == nil || session.pane == nil {
63+ continue
64+ }
65+ if session.pane.IsVisible() {
66+ return key, session, true
67+ }
68+ }
69+ return floatingSessionKey{tabID: tabID}, nil, false
70+}
71+
72+// tabIDs returns the distinct tabs owning at least one session, sorted.
73+func (r floatingSessionRegistry) tabIDs() []entity.TabID {
74+ ids := make([]entity.TabID, 0, len(r))
75+ for key := range r {
76+ if !slices.Contains(ids, key.tabID) {
77+ ids = append(ids, key.tabID)
78+ }
79+ }
80+ slices.Sort(ids)
81+ return ids
82+}
A internal/ui/floating_session_registry_test.go +64 -0
@@ -0,0 +1,64 @@
1+package ui
2+
3+import (
4+ "maps"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/dumber/internal/domain/entity"
11+)
12+
13+func TestFloatingSessionRegistry(t *testing.T) {
14+ tab1, tab2 := entity.TabID("tab-1"), entity.TabID("tab-2")
15+ gmail := newFloatingPaneSession(tab1, "profile:gmail")
16+ blank := newFloatingPaneSession(tab1, "profile:blank")
17+ other := newFloatingPaneSession(tab2, "profile:gmail")
18+ blank.pane.Show()
19+
20+ r := floatingSessionRegistry{
21+ floatingSessionMapKey(tab1, "profile:gmail"): gmail,
22+ floatingSessionMapKey(tab1, "profile:blank"): blank,
23+ floatingSessionMapKey(tab2, "profile:gmail"): other,
24+ floatingSessionMapKey(tab2, "profile:nil"): nil,
25+ }
26+
27+ t.Run("forTab skips other tabs and nil sessions", func(t *testing.T) {
28+ assert.Len(t, r.forTabSnapshot(tab1), 2)
29+ assert.Len(t, r.forTabSnapshot(tab2), 1)
30+ assert.Len(t, maps.Collect(r.all()), 3, "all skips nil sessions")
31+ })
32+
33+ t.Run("forTab does not allocate", func(t *testing.T) {
34+ allocs := testing.AllocsPerRun(100, func() {
35+ for range r.forTab(tab1) {
36+ }
37+ })
38+ assert.Zero(t, allocs)
39+ })
40+
41+ t.Run("byPaneID maps the pane to its owning key", func(t *testing.T) {
42+ key, session, ok := r.byPaneID(other.paneID)
43+ require.True(t, ok)
44+ assert.Same(t, other, session)
45+ assert.Equal(t, tab2, key.tabID)
46+
47+ _, _, ok = r.byPaneID("")
48+ assert.False(t, ok)
49+ })
50+
51+ t.Run("visibleForTab returns only the shown session", func(t *testing.T) {
52+ _, session, ok := r.visibleForTab(tab1)
53+ require.True(t, ok)
54+ assert.Same(t, blank, session)
55+
56+ key, _, ok := r.visibleForTab(tab2)
57+ assert.False(t, ok)
58+ assert.Equal(t, tab2, key.tabID, "miss still reports the tab")
59+ })
60+
61+ t.Run("tabIDs is distinct and sorted", func(t *testing.T) {
62+ assert.Equal(t, []entity.TabID{tab1, tab2}, r.tabIDs())
63+ })
64+}
M internal/ui/runtime_residency.go +8 -12
@@ -490,10 +490,8 @@ func (a *App) residencyQuiescent() bool {
490490 if a == nil {
491491 return true
492492 }
493- if eng, ok := a.engine.(interface{ RuntimeActivity() port.RuntimeActivity }); ok && eng != nil {
494- if act := eng.RuntimeActivity(); act != nil && !act.Snapshot().Quiescent() {
495- return false
496- }
493+ if act := port.EngineRuntimeActivity(a.engine); act != nil && !act.Snapshot().Quiescent() {
494+ return false
497495 }
498496 if drain, ok := a.snapshotService.(port.PersistenceDrain); ok && drain != nil && drain.Active() {
499497 return false
@@ -519,14 +517,12 @@ func (a *App) subscribeResidencyQuiescence() {
519517 return
520518 }
521519 dispatch := func(fn func()) { r.DispatchToGTK(fn) }
522- if eng, ok := a.engine.(interface{ RuntimeActivity() port.RuntimeActivity }); ok && eng != nil {
523- if act := eng.RuntimeActivity(); act != nil {
524- unsubscribe := act.Subscribe(func(snapshot port.RuntimeActivitySnapshot) {
525- busy := !snapshot.Quiescent()
526- dispatch(func() { r.SetNativeBusy("cef-activity", busy) })
527- })
528- a.residencyUnsubscribe = append(a.residencyUnsubscribe, unsubscribe)
529- }
520+ if act := port.EngineRuntimeActivity(a.engine); act != nil {
521+ unsubscribe := act.Subscribe(func(snapshot port.RuntimeActivitySnapshot) {
522+ busy := !snapshot.Quiescent()
523+ dispatch(func() { r.SetNativeBusy("engine-activity", busy) })
524+ })
525+ a.residencyUnsubscribe = append(a.residencyUnsubscribe, unsubscribe)
530526 }
531527 if drain, ok := a.snapshotService.(port.PersistenceDrain); ok && drain != nil {
532528 if notifier, ok := drain.(interface{ OnSettled(func()) }); ok {
A internal/ui/vim_mode.go +344 -0
@@ -0,0 +1,344 @@
1+package ui
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/internal/ui/component"
11+ "github.com/bnema/dumber/internal/ui/input"
12+)
13+
14+func (a *App) vimModePolicy() *usecase.VimModePolicyUseCase {
15+ if a == nil {
16+ return usecase.NewVimModePolicyUseCase()
17+ }
18+ if a.vimModePolicyUC == nil {
19+ a.vimModePolicyUC = usecase.NewVimModePolicyUseCase()
20+ }
21+ return a.vimModePolicyUC
22+}
23+
24+func (a *App) pageEditableFocused(paneID entity.PaneID) bool {
25+ return a != nil && a.vimMode.editableFocused(paneID)
26+}
27+
28+func (a *App) setPageEditableFocused(paneID entity.PaneID, editable bool) {
29+ if a != nil {
30+ a.vimMode.setEditableFocused(paneID, editable)
31+ }
32+}
33+
34+func (a *App) clearPageEditableFocusState(paneID entity.PaneID) {
35+ a.setPageEditableFocused(paneID, false)
36+}
37+
38+func (a *App) vimModeActiveForBrowserWindow(bw *browserWindow) bool {
39+ return bw != nil && bw.keyboardHandler != nil && bw.keyboardHandler.Mode() == input.ModeVim
40+}
41+
42+func (a *App) applyVimModePolicyTransition(_ context.Context, bw *browserWindow, transition usecase.VimModePolicyTransition) {
43+ if transition != usecase.VimModePolicyTransitionExit || bw == nil || bw.keyboardHandler == nil {
44+ return
45+ }
46+ if bw.keyboardHandler.Mode() != input.ModeVim {
47+ return
48+ }
49+ bw.keyboardHandler.ExitMode()
50+}
51+
52+func (a *App) handleVimModeFocusTrigger(ctx context.Context, bw *browserWindow, trigger usecase.VimModePolicyTrigger) {
53+ if bw == nil {
54+ return
55+ }
56+ transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{
57+ Trigger: trigger,
58+ VimModeActive: a.vimModeActiveForBrowserWindow(bw),
59+ })
60+ a.applyVimModePolicyTransition(ctx, bw, transition)
61+}
62+
63+func (a *App) handleVimModeTabSwitch(_ context.Context, bw *browserWindow) {
64+ if bw == nil || bw.tabs == nil || !a.vimModeActiveForBrowserWindow(bw) {
65+ return
66+ }
67+ transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{
68+ Trigger: usecase.VimModePolicyTriggerContextChanged,
69+ VimModeActive: true,
70+ PreserveOnContextChange: false,
71+ })
72+ if transition != usecase.VimModePolicyTransitionExit {
73+ return
74+ }
75+ if prevTabID := bw.tabs.PreviousActiveTabID; prevTabID != "" {
76+ if prevView := a.workspaceViews[prevTabID]; prevView != nil {
77+ if pv := prevView.GetPaneView(bw.vimModePaneID); pv != nil {
78+ pv.SetVimMode(false)
79+ }
80+ }
81+ }
82+ bw.vimModePaneID = ""
83+ bw.keyboardHandler.ExitMode()
84+}
85+
86+func (a *App) handlePageEditableFocusChanged(ctx context.Context, paneID entity.PaneID, editable bool) {
87+ if paneID == "" {
88+ return
89+ }
90+ a.setPageEditableFocused(paneID, editable)
91+
92+ bw := a.browserWindowForAnyPane(paneID)
93+ if bw == nil {
94+ return
95+ }
96+ ws := a.activeWorkspaceForBrowserWindow(bw)
97+ activeContext := ws != nil && ws.ActivePaneID == paneID && a.lastFocusedBrowserWindow() == bw
98+ transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{
99+ Trigger: usecase.VimModePolicyTriggerPageEditableFocusChanged,
100+ VimModeActive: a.vimModeActiveForBrowserWindow(bw),
101+ PageEditableFocused: editable,
102+ EventInActiveContext: activeContext,
103+ })
104+ a.applyVimModePolicyTransition(ctx, bw, transition)
105+}
106+
107+func (*App) shouldBypassVimModeActivation(_ *browserWindow) bool {
108+ return false
109+}
110+
111+// handleModeChange is called when the input mode changes for a specific browser window.
112+func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to input.Mode) {
113+ log := logging.FromContext(ctx)
114+ log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
115+
116+ if to == input.ModeVim && from != input.ModeVim {
117+ a.enableAccessibilityForVimMode(ctx, bw)
118+ }
119+ if from == input.ModeVim && to != input.ModeVim {
120+ a.clearVimNavigationHighlight(ctx, bw)
121+ }
122+
123+ // Handle pane-local Vim Mode visual ownership.
124+ // Entering Vim Mode accents the active pane; leaving removes the accent.
125+ a.handleVimModeOwnership(ctx, bw, to, from)
126+
127+ a.updateModeFrame(bw, to)
128+
129+ // Show/hide this window's mode indicator toaster based on mode and config.
130+ a.updateModeIndicatorToaster(ctx, bw, to)
131+}
132+
133+// transferVimModeOwnershipToPane transfers the pane-local Vim Mode accent
134+// from the current owning pane to another pane in the same browser window.
135+// The transfer only happens while that window is in Vim Mode.
136+func (a *App) transferVimModeOwnershipToPane(ctx context.Context, bw *browserWindow, newPaneID entity.PaneID) {
137+ if bw == nil {
138+ return
139+ }
140+
141+ // Only transfer if vim mode is currently active and the owner is changing.
142+ if bw.vimModePaneID == "" || bw.vimModePaneID == newPaneID {
143+ return
144+ }
145+
146+ // Check if this window is actually in vim mode.
147+ if bw.keyboardHandler == nil || bw.keyboardHandler.Mode() != input.ModeVim {
148+ // Vim mode not active on this window; just clear stale ownership.
149+ bw.vimModePaneID = ""
150+ return
151+ }
152+
153+ wsView := a.activeWorkspaceViewForBrowserWindow(bw)
154+ if wsView == nil {
155+ return
156+ }
157+
158+ transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{
159+ Trigger: usecase.VimModePolicyTriggerContextChanged,
160+ VimModeActive: true,
161+ PreserveOnContextChange: !a.pageEditableFocused(newPaneID),
162+ })
163+ if transition == usecase.VimModePolicyTransitionExit {
164+ a.applyVimModePolicyTransition(ctx, bw, transition)
165+ return
166+ }
167+
168+ oldPaneID := bw.vimModePaneID
169+
170+ // Deactivate the old owning pane.
171+ if oldPV := wsView.GetPaneView(oldPaneID); oldPV != nil {
172+ oldPV.SetVimMode(false)
173+ }
174+
175+ // Activate the new pane.
176+ if newPV := wsView.GetPaneView(newPaneID); newPV != nil {
177+ newPV.SetVimMode(true)
178+ }
179+ bw.vimModePaneID = newPaneID
180+
181+ logging.FromContext(ctx).Debug().
182+ Str("window_id", bw.id).
183+ Str("old_pane_id", string(oldPaneID)).
184+ Str("new_pane_id", string(newPaneID)).
185+ Msg("vim mode ownership transferred")
186+}
187+
188+// handleVimModeOwnership manages the pane-local Vim Mode accent and pulse
189+// owner for a specific browser window when input modes change.
190+func (a *App) handleVimModeOwnership(ctx context.Context, bw *browserWindow, to, from input.Mode) {
191+ if bw == nil {
192+ return
193+ }
194+
195+ ws := a.activeWorkspaceForBrowserWindow(bw)
196+ wsView := a.activeWorkspaceViewForBrowserWindow(bw)
197+ if ws == nil || wsView == nil {
198+ // If there's no workspace yet (startup), just track the intent.
199+ if to != input.ModeVim && from == input.ModeVim {
200+ bw.vimModePaneID = ""
201+ }
202+ return
203+ }
204+
205+ if to == input.ModeVim && from != input.ModeVim {
206+ // Entering vim mode: activate on the current active pane of this window.
207+ paneID := ws.ActivePaneID
208+ if pv := wsView.GetPaneView(paneID); pv != nil {
209+ pv.SetVimMode(true)
210+ bw.vimModePaneID = paneID
211+ logging.FromContext(ctx).Debug().
212+ Str("window_id", bw.id).
213+ Str("pane_id", string(paneID)).
214+ Msg("vim mode activated on pane")
215+ }
216+ } else if from == input.ModeVim && to != input.ModeVim {
217+ // Leaving Vim Mode: deactivate the pane that owns the visual accent.
218+ a.clearVimModeOwnership(ctx, bw)
219+ }
220+}
221+
222+// triggerVimModePulse triggers a pane-local vim mode pulse on the
223+// last-focused browser window's owning pane. This is called by the
224+// keyboard dispatcher after a page scroll action. The bw used is the
225+// window that was active when the action was dispatched (via
226+// dispatchBrowserWindowAction which calls activateBrowserWindow first).
227+// If no pane is currently in vim mode, the pulse is a no-op.
228+//
229+// Pulses are debounced at vimModePulseInterval to prevent excessive GTK CSS
230+// class churn during held-key repeats. Smooth scroll cadence is owned by the
231+// Vim Mode repeater and backend scroll path; pulse feedback is deliberately
232+// slower so indicator/overlay animation work does not become part of the
233+// scrolling critical path.
234+func (a *App) triggerVimModePulse(_ context.Context, fast bool) {
235+ // Debounce: skip if called again too soon during a continuous hold.
236+ // We still serialize the timestamp update so repeated dispatcher calls do
237+ // not restart CSS animations at scroll cadence.
238+ if !a.vimMode.allowPulse(time.Now()) {
239+ return
240+ }
241+
242+ bw := a.lastFocusedBrowserWindow()
243+ if bw == nil || bw.vimModePaneID == "" {
244+ return
245+ }
246+ if bw.modeFrame != nil {
247+ bw.modeFrame.pulse(fast)
248+ }
249+}
250+
251+// clearVimModeOwnership deactivates vim mode on the owning pane for a
252+// specific browser window and resets its tracked pane ID.
253+// Also resets the pulse debounce timer so the first pulse on re-entry
254+// is never skipped.
255+func (a *App) clearVimModeOwnership(ctx context.Context, bw *browserWindow) {
256+ if bw == nil || bw.vimModePaneID == "" {
257+ return
258+ }
259+
260+ wsView := a.activeWorkspaceViewForBrowserWindow(bw)
261+ if wsView != nil {
262+ if pv := wsView.GetPaneView(bw.vimModePaneID); pv != nil {
263+ pv.SetVimMode(false)
264+ logging.FromContext(ctx).Debug().
265+ Str("window_id", bw.id).
266+ Str("pane_id", string(bw.vimModePaneID)).
267+ Msg("vim mode deactivated on pane")
268+ }
269+ }
270+ bw.vimModePaneID = ""
271+
272+ a.vimMode.resetPulse()
273+}
274+
275+// updateModeIndicatorToaster reconciles one window's mode toaster with its
276+// current mode and the live mode-indicator preference.
277+func (a *App) updateModeIndicatorToaster(ctx context.Context, bw *browserWindow, mode input.Mode) {
278+ if bw == nil || bw.modeToaster == nil {
279+ return
280+ }
281+
282+ if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled || mode == input.ModeNormal {
283+ bw.modeToaster.Hide()
284+ return
285+ }
286+
287+ bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
288+ component.WithDuration(0),
289+ component.WithPosition(component.ToastPositionBottomLeft),
290+ component.WithModeClass(getModeToastClass(mode)),
291+ )
292+}
293+
294+// bindVimModeSequenceToaster wires pending Vim-sequence text onto this
295+// window's modeToaster only (never appToaster / pane toasters).
296+func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow) {
297+ if a == nil || bw == nil || bw.keyboardHandler == nil {
298+ return
299+ }
300+ bw.keyboardHandler.SetOnPendingSequenceChange(func(pending string) {
301+ a.showPendingSequence(ctx, bw, pending)
302+ })
303+ logging.FromContext(ctx).Debug().
304+ Str("window_id", bw.id).
305+ Msg("vim mode pending sequence toaster bound")
306+}
307+
308+// showPendingSequence shows "VIM MODE · pending" on the per-window modeToaster
309+// while a sequence is incomplete, and restores stable "VIM MODE" when pending
310+// clears. Mode exit/hide is handled by handleModeChange/updateModeIndicatorToaster;
311+// silent reset under ModalState avoids stale pending callbacks.
312+func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pending string) {
313+ if a == nil || bw == nil {
314+ return
315+ }
316+ if bw.modeFrame != nil {
317+ cfg := a.runtimeConfigSnapshot().UI.Workspace
318+ bw.modeFrame.setPending(pending, cfg.VimMode.Actions)
319+ }
320+ if bw.modeToaster == nil {
321+ return
322+ }
323+ log := logging.FromContext(ctx)
324+ if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled {
325+ bw.modeToaster.Hide()
326+ log.Debug().Str("window_id", bw.id).Msg("vim mode pending toaster hidden (disabled)")
327+ return
328+ }
329+
330+ text := input.ModeVim.DisplayName()
331+ if pending != "" {
332+ text = text + " · " + pending
333+ }
334+ bw.modeToaster.Show(ctx, text, component.ToastInfo,
335+ component.WithDuration(0),
336+ component.WithPosition(component.ToastPositionBottomLeft),
337+ component.WithModeClass(getModeToastClass(input.ModeVim)),
338+ )
339+ log.Debug().
340+ Str("window_id", bw.id).
341+ Str("pending", pending).
342+ Str("toast_text", text).
343+ Msg("vim mode pending toaster updated")
344+}
A internal/ui/vim_mode_state.go +64 -0
@@ -0,0 +1,64 @@
1+package ui
2+
3+import (
4+ "sync"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+// vimModeState holds the app-wide Vim Mode bookkeeping that is not tied to a
11+// single browser window: which panes report an editable page element focused,
12+// and the pulse debounce clock. Per-window ownership (the accented pane) stays
13+// on browserWindow.vimModePaneID.
14+type vimModeState struct {
15+ editableFocus map[entity.PaneID]bool
16+
17+ pulseMu sync.Mutex
18+ pulseLast time.Time
19+}
20+
21+func (s *vimModeState) editableFocused(paneID entity.PaneID) bool {
22+ return paneID != "" && s.editableFocus[paneID]
23+}
24+
25+func (s *vimModeState) setEditableFocused(paneID entity.PaneID, editable bool) {
26+ if paneID == "" {
27+ return
28+ }
29+ if !editable {
30+ delete(s.editableFocus, paneID)
31+ return
32+ }
33+ if s.editableFocus == nil {
34+ s.editableFocus = make(map[entity.PaneID]bool)
35+ }
36+ s.editableFocus[paneID] = true
37+}
38+
39+// allowPulse reports whether a pulse may run at now, and records it when so.
40+// Pulses closer than vimModePulseInterval are skipped so held-key repeats do
41+// not restart CSS animations at scroll cadence.
42+func (s *vimModeState) allowPulse(now time.Time) bool {
43+ s.pulseMu.Lock()
44+ defer s.pulseMu.Unlock()
45+ if now.Sub(s.pulseLast) < vimModePulseInterval {
46+ return false
47+ }
48+ s.pulseLast = now
49+ return true
50+}
51+
52+// resetPulse clears the debounce so the first pulse after re-entering Vim
53+// Mode is never skipped.
54+func (s *vimModeState) resetPulse() {
55+ s.pulseMu.Lock()
56+ s.pulseLast = time.Time{}
57+ s.pulseMu.Unlock()
58+}
59+
60+func (s *vimModeState) pulseIdle() bool {
61+ s.pulseMu.Lock()
62+ defer s.pulseMu.Unlock()
63+ return s.pulseLast.IsZero()
64+}
A internal/ui/vim_mode_state_test.go +36 -0
@@ -0,0 +1,36 @@
1+package ui
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestVimModeStateEditableFocus(t *testing.T) {
11+ var s vimModeState
12+
13+ assert.False(t, s.editableFocused("pane-a"), "zero value is safe")
14+ s.setEditableFocused("pane-a", true)
15+ s.setEditableFocused("", true)
16+ assert.True(t, s.editableFocused("pane-a"))
17+ assert.False(t, s.editableFocused(""))
18+
19+ s.setEditableFocused("pane-a", false)
20+ assert.False(t, s.editableFocused("pane-a"))
21+ assert.Empty(t, s.editableFocus, "cleared panes are removed, not stored as false")
22+}
23+
24+func TestVimModeStatePulseDebounce(t *testing.T) {
25+ var s vimModeState
26+ start := time.Unix(1000, 0)
27+
28+ assert.True(t, s.pulseIdle())
29+ assert.True(t, s.allowPulse(start))
30+ assert.False(t, s.allowPulse(start.Add(vimModePulseInterval/2)), "too soon")
31+ assert.True(t, s.allowPulse(start.Add(vimModePulseInterval)))
32+
33+ s.resetPulse()
34+ assert.True(t, s.pulseIdle())
35+ assert.True(t, s.allowPulse(start.Add(vimModePulseInterval+time.Millisecond)), "reset never skips re-entry")
36+}

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