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 Unverified
B5690EEEBB952194The key is unknown
43 files · +2944 -1886
M .mockery.yaml +1 -0
| @@ -58,6 +58,7 @@ packages: | |||||
| 58 | SessionProcessProbe: {} | 58 | SessionProcessProbe: {} | ||
| 59 | SessionSpawner: {} | 59 | SessionSpawner: {} | ||
| 60 | WindowStateProvider: {} | 60 | WindowStateProvider: {} | ||
| 61 | + | PageNavigator: {} | |||
| 61 | WebView: {} | 62 | WebView: {} | ||
| 62 | PageScrollable: {} | 63 | PageScrollable: {} | ||
| 63 | WebViewFactory: {} | 64 | WebViewFactory: {} | ||
M internal/application/port/mocks/webview.go +541 -13
| @@ -18,10 +18,19 @@ func NewMockTexture(t interface { | |||||
| 18 | mock.TestingT | 18 | mock.TestingT | ||
| 19 | Cleanup(func()) | 19 | Cleanup(func()) | ||
| 20 | }) *MockTexture { | 20 | }) *MockTexture { | ||
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 22 | + | helper.Helper() | |||
| 23 | + | } | |||
| 24 | + | ||||
| 21 | mock := &MockTexture{} | 25 | mock := &MockTexture{} | ||
| 22 | mock.Mock.Test(t) | 26 | mock.Mock.Test(t) | ||
| 23 | 27 | ||||
| 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 | + | }) | |||
| 25 | 34 | ||||
| 26 | return mock | 35 | return mock | ||
| 27 | } | 36 | } | ||
| @@ -89,10 +98,19 @@ func NewMockFindController(t interface { | |||||
| 89 | mock.TestingT | 98 | mock.TestingT | ||
| 90 | Cleanup(func()) | 99 | Cleanup(func()) | ||
| 91 | }) *MockFindController { | 100 | }) *MockFindController { | ||
| 101 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 102 | + | helper.Helper() | |||
| 103 | + | } | |||
| 104 | + | ||||
| 92 | mock := &MockFindController{} | 105 | mock := &MockFindController{} | ||
| 93 | mock.Mock.Test(t) | 106 | mock.Mock.Test(t) | ||
| 94 | 107 | ||||
| 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 | + | }) | |||
| 96 | 114 | ||||
| 97 | return mock | 115 | return mock | ||
| 98 | } | 116 | } | ||
| @@ -550,16 +568,436 @@ func (_c *MockFindController_SearchPrevious_Call) RunAndReturn(run func()) *Mock | |||||
| 550 | return _c | 568 | return _c | ||
| 551 | } | 569 | } | ||
| 552 | 570 | ||||
| 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 | + | ||||
| 553 | // 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. | 982 | // 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. | ||
| 554 | // The first argument is typically a *testing.T value. | 983 | // The first argument is typically a *testing.T value. | ||
| 555 | func NewMockWebView(t interface { | 984 | func NewMockWebView(t interface { | ||
| 556 | mock.TestingT | 985 | mock.TestingT | ||
| 557 | Cleanup(func()) | 986 | Cleanup(func()) | ||
| 558 | }) *MockWebView { | 987 | }) *MockWebView { | ||
| 988 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 989 | + | helper.Helper() | |||
| 990 | + | } | |||
| 991 | + | ||||
| 559 | mock := &MockWebView{} | 992 | mock := &MockWebView{} | ||
| 560 | mock.Mock.Test(t) | 993 | mock.Mock.Test(t) | ||
| 561 | 994 | ||||
| 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 | + | }) | |||
| 563 | 1001 | ||||
| 564 | return mock | 1002 | return mock | ||
| 565 | } | 1003 | } | ||
| @@ -1889,10 +2327,19 @@ func NewMockWebViewPool(t interface { | |||||
| 1889 | mock.TestingT | 2327 | mock.TestingT | ||
| 1890 | Cleanup(func()) | 2328 | Cleanup(func()) | ||
| 1891 | }) *MockWebViewPool { | 2329 | }) *MockWebViewPool { | ||
| 2330 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 2331 | + | helper.Helper() | |||
| 2332 | + | } | |||
| 2333 | + | ||||
| 1892 | mock := &MockWebViewPool{} | 2334 | mock := &MockWebViewPool{} | ||
| 1893 | mock.Mock.Test(t) | 2335 | mock.Mock.Test(t) | ||
| 1894 | 2336 | ||||
| 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 | + | }) | |||
| 1896 | 2343 | ||||
| 1897 | return mock | 2344 | return mock | ||
| 1898 | } | 2345 | } | ||
| @@ -2181,10 +2628,19 @@ func NewMockWebViewFactory(t interface { | |||||
| 2181 | mock.TestingT | 2628 | mock.TestingT | ||
| 2182 | Cleanup(func()) | 2629 | Cleanup(func()) | ||
| 2183 | }) *MockWebViewFactory { | 2630 | }) *MockWebViewFactory { | ||
| 2631 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 2632 | + | helper.Helper() | |||
| 2633 | + | } | |||
| 2634 | + | ||||
| 2184 | mock := &MockWebViewFactory{} | 2635 | mock := &MockWebViewFactory{} | ||
| 2185 | mock.Mock.Test(t) | 2636 | mock.Mock.Test(t) | ||
| 2186 | 2637 | ||||
| 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 | + | }) | |||
| 2188 | 2644 | ||||
| 2189 | return mock | 2645 | return mock | ||
| 2190 | } | 2646 | } | ||
| @@ -2338,10 +2794,19 @@ func NewMockDevToolsOpener(t interface { | |||||
| 2338 | mock.TestingT | 2794 | mock.TestingT | ||
| 2339 | Cleanup(func()) | 2795 | Cleanup(func()) | ||
| 2340 | }) *MockDevToolsOpener { | 2796 | }) *MockDevToolsOpener { | ||
| 2797 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 2798 | + | helper.Helper() | |||
| 2799 | + | } | |||
| 2800 | + | ||||
| 2341 | mock := &MockDevToolsOpener{} | 2801 | mock := &MockDevToolsOpener{} | ||
| 2342 | mock.Mock.Test(t) | 2802 | mock.Mock.Test(t) | ||
| 2343 | 2803 | ||||
| 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 | + | }) | |||
| 2345 | 2810 | ||||
| 2346 | return mock | 2811 | return mock | ||
| 2347 | } | 2812 | } | ||
| @@ -2398,10 +2863,19 @@ func NewMockPrinter(t interface { | |||||
| 2398 | mock.TestingT | 2863 | mock.TestingT | ||
| 2399 | Cleanup(func()) | 2864 | Cleanup(func()) | ||
| 2400 | }) *MockPrinter { | 2865 | }) *MockPrinter { | ||
| 2866 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 2867 | + | helper.Helper() | |||
| 2868 | + | } | |||
| 2869 | + | ||||
| 2401 | mock := &MockPrinter{} | 2870 | mock := &MockPrinter{} | ||
| 2402 | mock.Mock.Test(t) | 2871 | mock.Mock.Test(t) | ||
| 2403 | 2872 | ||||
| 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 | + | }) | |||
| 2405 | 2879 | ||||
| 2406 | return mock | 2880 | return mock | ||
| 2407 | } | 2881 | } | ||
| @@ -2458,10 +2932,19 @@ func NewMockPopupLifecycleCapable(t interface { | |||||
| 2458 | mock.TestingT | 2932 | mock.TestingT | ||
| 2459 | Cleanup(func()) | 2933 | Cleanup(func()) | ||
| 2460 | }) *MockPopupLifecycleCapable { | 2934 | }) *MockPopupLifecycleCapable { | ||
| 2935 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 2936 | + | helper.Helper() | |||
| 2937 | + | } | |||
| 2938 | + | ||||
| 2461 | mock := &MockPopupLifecycleCapable{} | 2939 | mock := &MockPopupLifecycleCapable{} | ||
| 2462 | mock.Mock.Test(t) | 2940 | mock.Mock.Test(t) | ||
| 2463 | 2941 | ||||
| 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 | + | }) | |||
| 2465 | 2948 | ||||
| 2466 | return mock | 2949 | return mock | ||
| 2467 | } | 2950 | } | ||
| @@ -2638,10 +3121,19 @@ func NewMockPopupFeatureResolver(t interface { | |||||
| 2638 | mock.TestingT | 3121 | mock.TestingT | ||
| 2639 | Cleanup(func()) | 3122 | Cleanup(func()) | ||
| 2640 | }) *MockPopupFeatureResolver { | 3123 | }) *MockPopupFeatureResolver { | ||
| 3124 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 3125 | + | helper.Helper() | |||
| 3126 | + | } | |||
| 3127 | + | ||||
| 2641 | mock := &MockPopupFeatureResolver{} | 3128 | mock := &MockPopupFeatureResolver{} | ||
| 2642 | mock.Mock.Test(t) | 3129 | mock.Mock.Test(t) | ||
| 2643 | 3130 | ||||
| 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 | + | }) | |||
| 2645 | 3137 | ||||
| 2646 | return mock | 3138 | return mock | ||
| 2647 | } | 3139 | } | ||
| @@ -2709,10 +3201,19 @@ func NewMockPopupOpenerCapable(t interface { | |||||
| 2709 | mock.TestingT | 3201 | mock.TestingT | ||
| 2710 | Cleanup(func()) | 3202 | Cleanup(func()) | ||
| 2711 | }) *MockPopupOpenerCapable { | 3203 | }) *MockPopupOpenerCapable { | ||
| 3204 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 3205 | + | helper.Helper() | |||
| 3206 | + | } | |||
| 3207 | + | ||||
| 2712 | mock := &MockPopupOpenerCapable{} | 3208 | mock := &MockPopupOpenerCapable{} | ||
| 2713 | mock.Mock.Test(t) | 3209 | mock.Mock.Test(t) | ||
| 2714 | 3210 | ||||
| 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 | + | }) | |||
| 2716 | 3217 | ||||
| 2717 | return mock | 3218 | return mock | ||
| 2718 | } | 3219 | } | ||
| @@ -2906,10 +3407,19 @@ func NewMockOAuthCallbackCapable(t interface { | |||||
| 2906 | mock.TestingT | 3407 | mock.TestingT | ||
| 2907 | Cleanup(func()) | 3408 | Cleanup(func()) | ||
| 2908 | }) *MockOAuthCallbackCapable { | 3409 | }) *MockOAuthCallbackCapable { | ||
| 3410 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 3411 | + | helper.Helper() | |||
| 3412 | + | } | |||
| 3413 | + | ||||
| 2909 | mock := &MockOAuthCallbackCapable{} | 3414 | mock := &MockOAuthCallbackCapable{} | ||
| 2910 | mock.Mock.Test(t) | 3415 | mock.Mock.Test(t) | ||
| 2911 | 3416 | ||||
| 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 | + | }) | |||
| 2913 | 3423 | ||||
| 2914 | return mock | 3424 | return mock | ||
| 2915 | } | 3425 | } | ||
| @@ -3046,10 +3556,19 @@ func NewMockTextInputTargetProvider(t interface { | |||||
| 3046 | mock.TestingT | 3556 | mock.TestingT | ||
| 3047 | Cleanup(func()) | 3557 | Cleanup(func()) | ||
| 3048 | }) *MockTextInputTargetProvider { | 3558 | }) *MockTextInputTargetProvider { | ||
| 3559 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 3560 | + | helper.Helper() | |||
| 3561 | + | } | |||
| 3562 | + | ||||
| 3049 | mock := &MockTextInputTargetProvider{} | 3563 | mock := &MockTextInputTargetProvider{} | ||
| 3050 | mock.Mock.Test(t) | 3564 | mock.Mock.Test(t) | ||
| 3051 | 3565 | ||||
| 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 | + | }) | |||
| 3053 | 3572 | ||||
| 3054 | return mock | 3573 | return mock | ||
| 3055 | } | 3574 | } | ||
| @@ -3119,10 +3638,19 @@ func NewMockPageScrollable(t interface { | |||||
| 3119 | mock.TestingT | 3638 | mock.TestingT | ||
| 3120 | Cleanup(func()) | 3639 | Cleanup(func()) | ||
| 3121 | }) *MockPageScrollable { | 3640 | }) *MockPageScrollable { | ||
| 3641 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 3642 | + | helper.Helper() | |||
| 3643 | + | } | |||
| 3644 | + | ||||
| 3122 | mock := &MockPageScrollable{} | 3645 | mock := &MockPageScrollable{} | ||
| 3123 | mock.Mock.Test(t) | 3646 | mock.Mock.Test(t) | ||
| 3124 | 3647 | ||||
| 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 | + | }) | |||
| 3126 | 3654 | ||||
| 3127 | return mock | 3655 | return mock | ||
| 3128 | } | 3656 | } | ||
M internal/application/port/runtime_activity.go +17 -0
| @@ -33,6 +33,23 @@ type RuntimeActivity interface { | |||||
| 33 | Subscribe(func(RuntimeActivitySnapshot)) (unsubscribe func()) | 33 | Subscribe(func(RuntimeActivitySnapshot)) (unsubscribe func()) | ||
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 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 | + | ||||
| 36 | // PersistenceDrain is the narrow activity/drain boundary for persistence | 53 | // PersistenceDrain is the narrow activity/drain boundary for persistence | ||
| 37 | // owners whose saves cross the GTK/database boundary. Active covers pending | 54 | // owners whose saves cross the GTK/database boundary. Active covers pending | ||
| 38 | // debounce, in-flight capture/execution, and unsettled terminal failure. | 55 | // 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 { | |||||
| 172 | DisconnectSignal(id uint) | 172 | DisconnectSignal(id uint) | ||
| 173 | } | 173 | } | ||
| 174 | 174 | ||||
| 175 | - | // WebView defines the port interface for browser view operations. | 175 | + | // PageNavigator is the navigation subset of WebView. Consumers that only |
| 176 | - | // This interface abstracts the underlying browser engine (WebKit, etc.) | 176 | + | // load, reload, or stop pages depend on this instead of the full WebView. |
| 177 | - | // and exposes only the navigation and state capabilities needed by | 177 | + | type PageNavigator interface { |
| 178 | - | // the application layer. | |||
| 179 | - | type WebView interface { | |||
| 180 | - | // ID returns the unique identifier for this WebView. | |||
| 181 | - | ID() WebViewID | |||
| 182 | - | ||||
| 183 | - | // --- Navigation --- | |||
| 184 | - | ||||
| 185 | // LoadURI navigates to the specified URI. | 178 | // LoadURI navigates to the specified URI. | ||
| 186 | LoadURI(ctx context.Context, uri string) error | 179 | LoadURI(ctx context.Context, uri string) error | ||
| 187 | 180 | ||||
| @@ -204,6 +197,17 @@ type WebView interface { | |||||
| 204 | // GoForward navigates forward in history. | 197 | // GoForward navigates forward in history. | ||
| 205 | // Uses WebKit native navigation if available, falls back to JavaScript history.forward() for SPA compatibility. | 198 | // Uses WebKit native navigation if available, falls back to JavaScript history.forward() for SPA compatibility. | ||
| 206 | GoForward(ctx context.Context) error | 199 | 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 | |||
| 207 | 211 | ||||
| 208 | // --- State Queries --- | 212 | // --- State Queries --- | ||
| 209 | 213 | ||||
M internal/application/usecase/check_media.go +11 -2
| @@ -9,7 +9,8 @@ import ( | |||||
| 9 | "github.com/bnema/dumber/internal/logging" | 9 | "github.com/bnema/dumber/internal/logging" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 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. | |||
| 13 | type CheckMediaUseCase struct { | 14 | type CheckMediaUseCase struct { | ||
| 14 | diagnostics port.MediaDiagnostics | 15 | diagnostics port.MediaDiagnostics | ||
| 15 | } | 16 | } | ||
| @@ -34,13 +35,21 @@ type CheckMediaOutput struct { | |||||
| 34 | Warnings []string | 35 | Warnings []string | ||
| 35 | } | 36 | } | ||
| 36 | 37 | ||||
| 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 | + | ||||
| 37 | // Execute checks media playback requirements. | 46 | // Execute checks media playback requirements. | ||
| 38 | // Returns error if GStreamer is not installed (fatal). | 47 | // Returns error if GStreamer is not installed (fatal). | ||
| 39 | // Returns warnings for missing hardware acceleration (non-fatal). | 48 | // Returns warnings for missing hardware acceleration (non-fatal). | ||
| 40 | func (uc *CheckMediaUseCase) Execute(ctx context.Context, input CheckMediaInput) (*CheckMediaOutput, error) { | 49 | func (uc *CheckMediaUseCase) Execute(ctx context.Context, input CheckMediaInput) (*CheckMediaOutput, error) { | ||
| 41 | log := logging.FromContext(ctx) | 50 | log := logging.FromContext(ctx) | ||
| 42 | 51 | ||||
| 43 | - | result := uc.diagnostics.RunDiagnostics(ctx) | 52 | + | result := uc.Diagnose(ctx) |
| 44 | 53 | ||||
| 45 | // GStreamer is required - fail early if not installed | 54 | // GStreamer is required - fail early if not installed | ||
| 46 | if !result.GStreamerAvailable { | 55 | 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 { | |||||
| 29 | type NavigateInput struct { | 29 | type NavigateInput struct { | ||
| 30 | URL string | 30 | URL string | ||
| 31 | PaneID string | 31 | PaneID string | ||
| 32 | - | WebView port.WebView | 32 | + | WebView port.PageNavigator |
| 33 | } | 33 | } | ||
| 34 | 34 | ||||
| 35 | // NavigateOutput contains the result of navigation. | 35 | // NavigateOutput contains the result of navigation. | ||
| @@ -62,7 +62,7 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N | |||||
| 62 | } | 62 | } | ||
| 63 | 63 | ||||
| 64 | // Reload reloads the current page. | 64 | // 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 { |
| 66 | log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger() | 66 | log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger() | ||
| 67 | log.Debug().Bool("bypass_cache", bypassCache).Msg("reloading page") | 67 | log.Debug().Bool("bypass_cache", bypassCache).Msg("reloading page") | ||
| 68 | 68 | ||||
| @@ -73,7 +73,7 @@ func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, byp | |||||
| 73 | } | 73 | } | ||
| 74 | 74 | ||||
| 75 | // Stop stops the current page load. | 75 | // 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 { |
| 77 | log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger() | 77 | log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger() | ||
| 78 | log.Debug().Msg("stopping page load") | 78 | log.Debug().Msg("stopping page load") | ||
| 79 | return webview.Stop(ctx) | 79 | return webview.Stop(ctx) | ||
M internal/application/usecase/navigate_test.go +23 -45
| @@ -6,61 +6,27 @@ import ( | |||||
| 6 | "math" | 6 | "math" | ||
| 7 | "testing" | 7 | "testing" | ||
| 8 | 8 | ||||
| 9 | - | "github.com/bnema/dumber/internal/application/port" | 9 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" |
| 10 | "github.com/bnema/dumber/internal/domain/entity" | 10 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 11 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| 14 | - | type fakeWebView struct { | 15 | + | func newLoadingNavigator(t *testing.T, url string, err error) *portmocks.MockPageNavigator { |
| 15 | - | loaded string | 16 | + | t.Helper() |
| 16 | - | loadErr error | 17 | + | nav := portmocks.NewMockPageNavigator(t) |
| 18 | + | nav.EXPECT().LoadURI(mock.Anything, url).Return(err).Once() | |||
| 19 | + | return nav | |||
| 17 | } | 20 | } | ||
| 18 | 21 | ||||
| 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 | - | ||||
| 55 | func TestNavigateUseCase_ExecuteLoadsURLAndReturnsDefaultZoom(t *testing.T) { | 22 | func TestNavigateUseCase_ExecuteLoadsURLAndReturnsDefaultZoom(t *testing.T) { | ||
| 56 | ctx := context.Background() | 23 | ctx := context.Background() | ||
| 57 | - | wv := &fakeWebView{} | 24 | + | wv := newLoadingNavigator(t, "https://example.com", nil) |
| 58 | uc := NewNavigateUseCase(entity.ZoomDefault) | 25 | uc := NewNavigateUseCase(entity.ZoomDefault) | ||
| 59 | 26 | ||||
| 60 | out, err := uc.Execute(ctx, NavigateInput{URL: "https://example.com", PaneID: "pane-1", WebView: wv}) | 27 | out, err := uc.Execute(ctx, NavigateInput{URL: "https://example.com", PaneID: "pane-1", WebView: wv}) | ||
| 61 | 28 | ||||
| 62 | require.NoError(t, err) | 29 | require.NoError(t, err) | ||
| 63 | - | require.Equal(t, "https://example.com", wv.loaded) | |||
| 64 | require.InDelta(t, entity.ZoomDefault, out.AppliedZoom, 0.0001) | 30 | require.InDelta(t, entity.ZoomDefault, out.AppliedZoom, 0.0001) | ||
| 65 | } | 31 | } | ||
| 66 | 32 | ||||
| @@ -80,13 +46,12 @@ func TestNavigateUseCase_ExecuteUsesConfiguredOrFallbackZoom(t *testing.T) { | |||||
| 80 | for _, tt := range tests { | 46 | for _, tt := range tests { | ||
| 81 | t.Run(tt.name, func(t *testing.T) { | 47 | t.Run(tt.name, func(t *testing.T) { | ||
| 82 | ctx := context.Background() | 48 | ctx := context.Background() | ||
| 83 | - | wv := &fakeWebView{} | 49 | + | wv := newLoadingNavigator(t, "https://example.com", nil) |
| 84 | uc := NewNavigateUseCase(tt.configuredZoom) | 50 | uc := NewNavigateUseCase(tt.configuredZoom) | ||
| 85 | 51 | ||||
| 86 | out, err := uc.Execute(ctx, NavigateInput{URL: "https://example.com", PaneID: "pane-1", WebView: wv}) | 52 | out, err := uc.Execute(ctx, NavigateInput{URL: "https://example.com", PaneID: "pane-1", WebView: wv}) | ||
| 87 | 53 | ||||
| 88 | require.NoError(t, err) | 54 | require.NoError(t, err) | ||
| 89 | - | require.Equal(t, "https://example.com", wv.loaded) | |||
| 90 | require.InDelta(t, tt.wantZoom, out.AppliedZoom, 0.0001) | 55 | require.InDelta(t, tt.wantZoom, out.AppliedZoom, 0.0001) | ||
| 91 | }) | 56 | }) | ||
| 92 | } | 57 | } | ||
| @@ -95,7 +60,7 @@ func TestNavigateUseCase_ExecuteUsesConfiguredOrFallbackZoom(t *testing.T) { | |||||
| 95 | func TestNavigateUseCase_ExecuteReturnsLoadError(t *testing.T) { | 60 | func TestNavigateUseCase_ExecuteReturnsLoadError(t *testing.T) { | ||
| 96 | ctx := context.Background() | 61 | ctx := context.Background() | ||
| 97 | loadErr := errors.New("load failed") | 62 | loadErr := errors.New("load failed") | ||
| 98 | - | wv := &fakeWebView{loadErr: loadErr} | 63 | + | wv := newLoadingNavigator(t, "https://example.com", loadErr) |
| 99 | uc := NewNavigateUseCase(entity.ZoomDefault) | 64 | uc := NewNavigateUseCase(entity.ZoomDefault) | ||
| 100 | 65 | ||||
| 101 | out, err := uc.Execute(ctx, NavigateInput{URL: "https://example.com", PaneID: "pane-1", WebView: wv}) | 66 | 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) { | |||||
| 104 | require.ErrorIs(t, err, loadErr) | 69 | require.ErrorIs(t, err, loadErr) | ||
| 105 | require.Contains(t, err.Error(), "failed to load URL") | 70 | require.Contains(t, err.Error(), "failed to load URL") | ||
| 106 | } | 71 | } | ||
| 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 { | |||||
| 58 | cfg.Appearance.GtkFont = strings.TrimSpace(cfg.Appearance.GtkFont) | 58 | cfg.Appearance.GtkFont = strings.TrimSpace(cfg.Appearance.GtkFont) | ||
| 59 | cfg.Appearance.ColorScheme = strings.TrimSpace(cfg.Appearance.ColorScheme) | 59 | cfg.Appearance.ColorScheme = strings.TrimSpace(cfg.Appearance.ColorScheme) | ||
| 60 | cfg.DefaultSearchEngine = strings.TrimSpace(cfg.DefaultSearchEngine) | 60 | 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 | + | } | |||
| 61 | if len(cfg.SearchShortcuts) > 0 { | 65 | if len(cfg.SearchShortcuts) > 0 { | ||
| 62 | normalized := make(map[string]dto.SearchShortcut, len(cfg.SearchShortcuts)) | 66 | normalized := make(map[string]dto.SearchShortcut, len(cfg.SearchShortcuts)) | ||
| 63 | for key, shortcut := range cfg.SearchShortcuts { | 67 | for key, shortcut := range cfg.SearchShortcuts { | ||
| @@ -74,6 +78,28 @@ func normalizeWebUIConfig(cfg dto.WebUIConfig) dto.WebUIConfig { | |||||
| 74 | return cfg | 78 | return cfg | ||
| 75 | } | 79 | } | ||
| 76 | 80 | ||||
| 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 | + | ||||
| 77 | func validateWebUIConfig(cfg dto.WebUIConfig) error { | 103 | func validateWebUIConfig(cfg dto.WebUIConfig) error { | ||
| 78 | var errs []string | 104 | var errs []string | ||
| 79 | 105 | ||||
M internal/application/usecase/save_webui_config_test.go +45 -0
| @@ -97,6 +97,51 @@ func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) { | |||||
| 97 | require.Equal(t, "DuckDuckGo", saved.SearchShortcuts["ddg"].Description) | 97 | require.Equal(t, "DuckDuckGo", saved.SearchShortcuts["ddg"].Description) | ||
| 98 | } | 98 | } | ||
| 99 | 99 | ||||
| 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 | + | ||||
| 100 | func validWebUIConfig() dto.WebUIConfig { | 145 | func validWebUIConfig() dto.WebUIConfig { | ||
| 101 | palette := dto.ColorPalette{ | 146 | palette := dto.ColorPalette{ | ||
| 102 | Background: "#ffffff", | 147 | Background: "#ffffff", | ||
M internal/bootstrap/crash_report.go +6 -6
| @@ -128,18 +128,18 @@ func buildCrashMarkdown(report crashReport) string { | |||||
| 128 | lines := []string{ | 128 | lines := []string{ | ||
| 129 | "# Unexpected Close Report", | 129 | "# Unexpected Close Report", | ||
| 130 | "", | 130 | "", | ||
| 131 | - | fmt.Sprintf("Generated: `%s`", report.GeneratedAt), | 131 | + | fmt.Sprintf("Generated: %#q", report.GeneratedAt), |
| 132 | - | fmt.Sprintf("Session: `%s`", report.SessionID), | 132 | + | fmt.Sprintf("Session: %#q", report.SessionID), |
| 133 | fmt.Sprintf("Crashed PID: `%d`", report.CrashedPID), | 133 | fmt.Sprintf("Crashed PID: `%d`", report.CrashedPID), | ||
| 134 | "", | 134 | "", | ||
| 135 | "## Reporter", | 135 | "## Reporter", | ||
| 136 | fmt.Sprintf("- pid: `%d`", report.ReporterProcess.PID), | 136 | fmt.Sprintf("- pid: `%d`", report.ReporterProcess.PID), | ||
| 137 | fmt.Sprintf("- ppid: `%d`", report.ReporterProcess.PPID), | 137 | fmt.Sprintf("- ppid: `%d`", report.ReporterProcess.PPID), | ||
| 138 | - | fmt.Sprintf("- go: `%s`", report.ReporterProcess.GoVersion), | 138 | + | fmt.Sprintf("- go: %#q", report.ReporterProcess.GoVersion), |
| 139 | "", | 139 | "", | ||
| 140 | "## Core Dump Diagnostics", | 140 | "## Core Dump Diagnostics", | ||
| 141 | - | fmt.Sprintf("- RLIMIT_CORE soft: `%s`", report.CoreDumpDiagnostics.RLimitCoreSoft), | 141 | + | fmt.Sprintf("- RLIMIT_CORE soft: %#q", report.CoreDumpDiagnostics.RLimitCoreSoft), |
| 142 | - | fmt.Sprintf("- RLIMIT_CORE hard: `%s`", report.CoreDumpDiagnostics.RLimitCoreHard), | 142 | + | fmt.Sprintf("- RLIMIT_CORE hard: %#q", report.CoreDumpDiagnostics.RLimitCoreHard), |
| 143 | fmt.Sprintf("- hint: %s", report.CoreDumpDiagnostics.Hint), | 143 | fmt.Sprintf("- hint: %s", report.CoreDumpDiagnostics.Hint), | ||
| 144 | } | 144 | } | ||
| 145 | 145 | ||||
| @@ -163,7 +163,7 @@ func buildCrashMarkdown(report crashReport) string { | |||||
| 163 | "Describe what you were doing just before Dumber closed unexpectedly.", | 163 | "Describe what you were doing just before Dumber closed unexpectedly.", | ||
| 164 | "", | 164 | "", | ||
| 165 | "### Crash report", | 165 | "### Crash report", | ||
| 166 | - | fmt.Sprintf("- session id: `%s`", report.SessionID), | 166 | + | fmt.Sprintf("- session id: %#q", report.SessionID), |
| 167 | fmt.Sprintf("- crashed pid: `%d`", report.CrashedPID), | 167 | fmt.Sprintf("- crashed pid: `%d`", report.CrashedPID), | ||
| 168 | "", | 168 | "", | ||
| 169 | "### Additional context", | 169 | "### Additional context", | ||
M internal/cli/cmd/doctor.go +1 -5
| @@ -88,11 +88,7 @@ func runDoctor(_ *cobra.Command, _ []string) error { | |||||
| 88 | mediaOK := true | 88 | mediaOK := true | ||
| 89 | if runMedia { | 89 | if runMedia { | ||
| 90 | adapter := media.New() | 90 | adapter := media.New() | ||
| 91 | - | mediaUC := usecase.NewRunMediaDiagnosticsUseCase(adapter) | 91 | + | mediaOut := usecase.NewCheckMediaUseCase(adapter).Diagnose(app.Ctx()) |
| 92 | - | mediaOut, err := mediaUC.Execute(app.Ctx(), usecase.RunMediaDiagnosticsInput{}) | |||
| 93 | - | if err != nil { | |||
| 94 | - | return err | |||
| 95 | - | } | |||
| 96 | 92 | ||||
| 97 | mediaOK = mediaOut.GStreamerAvailable | 93 | mediaOK = mediaOut.GStreamerAvailable | ||
| 98 | report.Media = &styles.DoctorMediaReport{ | 94 | report.Media = &styles.DoctorMediaReport{ | ||
M internal/infrastructure/cef/engine.go +1 -0
| @@ -21,6 +21,7 @@ import ( | |||||
| 21 | 21 | ||||
| 22 | // Compile-time interface check. | 22 | // Compile-time interface check. | ||
| 23 | var _ port.Engine = (*Engine)(nil) | 23 | var _ port.Engine = (*Engine)(nil) | ||
| 24 | + | var _ port.RuntimeActivityProvider = (*Engine)(nil) | |||
| 24 | var _ port.AlreadyRunningAppRelaunchHandlerSetter = (*Engine)(nil) | 25 | var _ port.AlreadyRunningAppRelaunchHandlerSetter = (*Engine)(nil) | ||
| 25 | 26 | ||||
| 26 | // Engine implements port.Engine for the CEF browser backend. | 27 | // Engine implements port.Engine for the CEF browser backend. | ||
M internal/infrastructure/cef/scheme_handler.go +23 -83
| @@ -9,18 +9,14 @@ import ( | |||||
| 9 | "encoding/json" | 9 | "encoding/json" | ||
| 10 | "fmt" | 10 | "fmt" | ||
| 11 | "html" | 11 | "html" | ||
| 12 | - | "io" | |||
| 13 | "io/fs" | 12 | "io/fs" | ||
| 14 | "maps" | 13 | "maps" | ||
| 15 | "net/http" | 14 | "net/http" | ||
| 16 | "net/url" | 15 | "net/url" | ||
| 17 | - | "path" | |||
| 18 | - | "strconv" | |||
| 19 | "strings" | 16 | "strings" | ||
| 20 | "sync" | 17 | "sync" | ||
| 21 | "unsafe" | 18 | "unsafe" | ||
| 22 | 19 | ||||
| 23 | - | "github.com/andybalholm/brotli" | |||
| 24 | purecef "github.com/bnema/purego-cef/cef" | 20 | purecef "github.com/bnema/purego-cef/cef" | ||
| 25 | 21 | ||||
| 26 | "github.com/bnema/dumber/internal/application/port" | 22 | "github.com/bnema/dumber/internal/application/port" | ||
| @@ -39,8 +35,18 @@ const ( | |||||
| 39 | errorPath = "error" | 35 | errorPath = "error" | ||
| 40 | indexHTML = "index.html" | 36 | indexHTML = "index.html" | ||
| 41 | maxSchemeTruncatedURLLength = 240 | 37 | maxSchemeTruncatedURLLength = 240 | ||
| 42 | - | maxSystemviewsWASMBytes = 64 * 1024 * 1024 | 38 | + | maxSystemviewsWASMBytes = webutil.MaxSystemviewsWASMBytes |
| 43 | - | systemviewsAssetDir = "systemviews" | 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 | |||
| 44 | ) | 50 | ) | ||
| 45 | 51 | ||||
| 46 | // pageRootFiles maps internal page hosts/paths to their HTML entry points. | 52 | // pageRootFiles maps internal page hosts/paths to their HTML entry points. | ||
| @@ -297,25 +303,20 @@ func (h *dumbSchemeHandler) rejectUntrustedSystemviewRequester(request purecef.R | |||||
| 297 | if request == nil { | 303 | if request == nil { | ||
| 298 | return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | 304 | return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | ||
| 299 | } | 305 | } | ||
| 300 | - | origin := strings.TrimSpace(request.GetHeaderByName("Origin")) | 306 | + | origin := request.GetHeaderByName("Origin") |
| 301 | - | if origin != "" { | 307 | + | referrer := "" |
| 302 | - | if isTrustedSystemviewURL(origin) { | 308 | + | if strings.TrimSpace(origin) == "" { |
| 303 | - | return nil | 309 | + | referrer = strings.TrimSpace(request.GetReferrerURL()) |
| 310 | + | if referrer == "" { | |||
| 311 | + | referrer = request.GetHeaderByName("Referer") | |||
| 304 | } | 312 | } | ||
| 305 | - | return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |||
| 306 | } | 313 | } | ||
| 307 | - | referrer := strings.TrimSpace(request.GetReferrerURL()) | 314 | + | if !systemviewTrust.Allows(origin, referrer) { |
| 308 | - | if referrer == "" { | |||
| 309 | - | referrer = strings.TrimSpace(request.GetHeaderByName("Referer")) | |||
| 310 | - | } | |||
| 311 | - | if !isTrustedSystemviewURL(referrer) { | |||
| 312 | return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | 315 | return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | ||
| 313 | } | 316 | } | ||
| 314 | return nil | 317 | return nil | ||
| 315 | } | 318 | } | ||
| 316 | 319 | ||||
| 317 | - | const systemviewFaviconSize = 32 | |||
| 318 | - | ||||
| 319 | func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.ResourceHandler { | 320 | func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.ResourceHandler { | ||
| 320 | if denied := h.rejectUntrustedFaviconRequester(request); denied != nil { | 321 | if denied := h.rejectUntrustedFaviconRequester(request); denied != nil { | ||
| 321 | return denied | 322 | return denied | ||
| @@ -328,21 +329,9 @@ func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.Re | |||||
| 328 | return h.newPrivateAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "favicon unavailable"}) | 329 | return h.newPrivateAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "favicon unavailable"}) | ||
| 329 | } | 330 | } | ||
| 330 | 331 | ||||
| 331 | - | parsed, err := url.Parse(request.GetURL()) | 332 | + | domain, size, err := webutil.ParseSystemviewFaviconRequest(request.GetURL()) |
| 332 | if err != nil { | 333 | if err != nil { | ||
| 333 | - | return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"}) | 334 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()}) |
| 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 | |||
| 346 | } | 335 | } | ||
| 347 | 336 | ||||
| 348 | return newFaviconResourceHandler(h.ctx, resolver, domain, size) | 337 | return newFaviconResourceHandler(h.ctx, resolver, domain, size) | ||
| @@ -360,17 +349,8 @@ func isTrustedSystemviewURL(raw string) bool { | |||||
| 360 | if strings.EqualFold(parsed.Scheme, actualInternalScheme) { | 349 | if strings.EqualFold(parsed.Scheme, actualInternalScheme) { | ||
| 361 | return strings.EqualFold(parsed.Host, actualInternalHost) | 350 | return strings.EqualFold(parsed.Host, actualInternalHost) | ||
| 362 | } | 351 | } | ||
| 363 | - | if !strings.EqualFold(parsed.Scheme, "dumb") { | 352 | + | host, ok := webutil.DumbURLHost(raw) |
| 364 | - | return false | 353 | + | return ok && isInternalPageHost(host) |
| 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) | |||
| 374 | } | 354 | } | ||
| 375 | 355 | ||||
| 376 | func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) { | 356 | func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) { | ||
| @@ -815,46 +795,6 @@ func sha256Hex(data []byte) string { | |||||
| 815 | return hex.EncodeToString(sum[:]) | 795 | return hex.EncodeToString(sum[:]) | ||
| 816 | } | 796 | } | ||
| 817 | 797 | ||||
| 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 | - | ||||
| 858 | // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL | 798 | // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL | ||
| 859 | // to a relative asset path inside assets/systemviews. | 799 | // to a relative asset path inside assets/systemviews. | ||
| 860 | func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | 800 | func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | ||
M internal/infrastructure/config/profile.go +0 -4
| @@ -11,10 +11,6 @@ const ( | |||||
| 11 | // supported by WebKitGTK (0-8 range). | 11 | // supported by WebKitGTK (0-8 range). | ||
| 12 | maxSkiaCPUThreads = 8 | 12 | maxSkiaCPUThreads = 8 | ||
| 13 | 13 | ||||
| 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 | - | ||||
| 18 | // Memory tier thresholds (in bytes) | 14 | // Memory tier thresholds (in bytes) | ||
| 19 | // Explicitly typed as uint64 to prevent overflow on 32-bit systems. | 15 | // Explicitly typed as uint64 to prevent overflow on 32-bit systems. | ||
| 20 | ramTierLow uint64 = 8 * 1024 * 1024 * 1024 // 8 GB | 16 | 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 { | |||||
| 15 | return &WebUIConfigGateway{mgr: mgr} | 15 | return &WebUIConfigGateway{mgr: mgr} | ||
| 16 | } | 16 | } | ||
| 17 | 17 | ||||
| 18 | - | // Validation limits for custom performance fields. | 18 | + | // SaveWebUIConfig maps an already validated and normalized WebUI config onto |
| 19 | - | const ( | 19 | + | // the stored config and saves it. Policy lives in SaveWebUIConfigUseCase. |
| 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 | - | ||||
| 36 | func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg dto.WebUIConfig) error { | 20 | func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg dto.WebUIConfig) error { | ||
| 37 | _ = ctx | 21 | _ = ctx | ||
| 38 | if g == nil || g.mgr == nil { | 22 | if g == nil || g.mgr == nil { | ||
| @@ -87,11 +71,12 @@ func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg dto.WebUIC | |||||
| 87 | 71 | ||||
| 88 | // Custom performance fields (only used when profile is "custom") | 72 | // Custom performance fields (only used when profile is "custom") | ||
| 89 | if cfg.Performance.Profile == string(ProfileCustom) { | 73 | if cfg.Performance.Profile == string(ProfileCustom) { | ||
| 90 | - | current.Engine.WebKit.SkiaCPUPaintingThreads = clampInt(cfg.Performance.Custom.SkiaCPUThreads, 0, maxSkiaCPUThreads) | 74 | + | custom := cfg.Performance.Custom |
| 91 | - | current.Engine.WebKit.SkiaGPUPaintingThreads = clampInt(cfg.Performance.Custom.SkiaGPUThreads, -1, maxSkiaGPUThreads) | 75 | + | current.Engine.WebKit.SkiaCPUPaintingThreads = custom.SkiaCPUThreads |
| 92 | - | current.Engine.WebKit.WebProcessMemoryLimitMB = clampInt(cfg.Performance.Custom.WebProcessMemoryMB, 0, maxWebProcessMemoryMB) | 76 | + | current.Engine.WebKit.SkiaGPUPaintingThreads = custom.SkiaGPUThreads |
| 93 | - | current.Engine.WebKit.NetworkProcessMemoryLimitMB = clampInt(cfg.Performance.Custom.NetworkProcessMemoryMB, 0, maxNetworkProcessMemoryMB) | 77 | + | current.Engine.WebKit.WebProcessMemoryLimitMB = custom.WebProcessMemoryMB |
| 94 | - | current.Engine.PoolPrewarmCount = clampInt(cfg.Performance.Custom.WebViewPoolPrewarm, 0, maxWebViewPoolPrewarm) | 78 | + | current.Engine.WebKit.NetworkProcessMemoryLimitMB = custom.NetworkProcessMemoryMB |
| 79 | + | current.Engine.PoolPrewarmCount = custom.WebViewPoolPrewarm | |||
| 95 | } | 80 | } | ||
| 96 | 81 | ||||
| 97 | return g.mgr.Save(current) | 82 | return g.mgr.Save(current) | ||
M internal/infrastructure/handlers/config.go +1 -7
| @@ -63,13 +63,7 @@ func RegisterConfigHandlers( | |||||
| 63 | 63 | ||||
| 64 | log := logging.FromContext(ctx).With().Str("component", "handlers").Logger() | 64 | log := logging.FromContext(ctx).With().Str("component", "handlers").Logger() | ||
| 65 | 65 | ||||
| 66 | - | if err := router.RegisterHandlerWithCallbacks( | 66 | + | if err := port.RegisterWebUIMessage(router, port.MsgSaveConfig, handler); err != nil { |
| 67 | - | "save_config", | |||
| 68 | - | "__dumber_config_saved", | |||
| 69 | - | "__dumber_config_error", | |||
| 70 | - | "", | |||
| 71 | - | handler, | |||
| 72 | - | ); err != nil { | |||
| 73 | return err | 67 | return err | ||
| 74 | } | 68 | } | ||
| 75 | 69 | ||||
M internal/infrastructure/handlers/homepage/registry.go +37 -40
| @@ -19,47 +19,9 @@ func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg C | |||||
| 19 | log := logging.FromContext(ctx) | 19 | log := logging.FromContext(ctx) | ||
| 20 | log.Debug().Msg("registering homepage message handlers") | 20 | log.Debug().Msg("registering homepage message handlers") | ||
| 21 | 21 | ||||
| 22 | - | // Common callback for all homepage handlers | 22 | + | handlers := Handlers(cfg) |
| 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 | |||
| 61 | for msgType, handler := range handlers { | 23 | 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 { |
| 63 | return fmt.Errorf("failed to register handler %s: %w", msgType, err) | 25 | return fmt.Errorf("failed to register handler %s: %w", msgType, err) | ||
| 64 | } | 26 | } | ||
| 65 | log.Debug().Str("type", msgType).Msg("registered homepage handler") | 27 | log.Debug().Str("type", msgType).Msg("registered homepage handler") | ||
| @@ -68,3 +30,38 @@ func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg C | |||||
| 68 | log.Info().Int("count", len(handlers)).Msg("homepage handlers registered") | 30 | log.Info().Int("count", len(handlers)).Msg("homepage handlers registered") | ||
| 69 | return nil | 31 | return nil | ||
| 70 | } | 32 | } | ||
| 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 | |||||
| 93 | func RegisterKeybindingsHandlers(ctx context.Context, router port.WebUIHandlerRouter, handler *KeybindingsHandler) error { | 93 | func RegisterKeybindingsHandlers(ctx context.Context, router port.WebUIHandlerRouter, handler *KeybindingsHandler) error { | ||
| 94 | log := logging.FromContext(ctx).With().Str("component", "handlers").Logger() | 94 | log := logging.FromContext(ctx).With().Str("component", "handlers").Logger() | ||
| 95 | 95 | ||||
| 96 | - | // Get all keybindings | 96 | + | for msgType, fn := range map[string]port.WebUIMessageHandlerFunc{ |
| 97 | - | if err := router.RegisterHandlerWithCallbacks( | 97 | + | port.MsgGetKeybindings: handler.HandleGetKeybindings, |
| 98 | - | "get_keybindings", | 98 | + | port.MsgSetKeybinding: handler.HandleSetKeybinding, |
| 99 | - | "__dumber_keybindings_loaded", | 99 | + | port.MsgResetKeybinding: handler.HandleResetKeybinding, |
| 100 | - | "__dumber_keybindings_error", | 100 | + | port.MsgResetAllKeybindings: handler.HandleResetAllKeybindings, |
| 101 | - | "", | 101 | + | } { |
| 102 | - | port.WebUIMessageHandlerFunc(handler.HandleGetKeybindings), | 102 | + | if err := port.RegisterWebUIMessage(router, msgType, fn); err != nil { |
| 103 | - | ); err != nil { | 103 | + | return err |
| 104 | - | return err | 104 | + | } |
| 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 | |||
| 138 | } | 105 | } | ||
| 139 | 106 | ||||
| 140 | log.Info().Msg("registered keybindings handlers") | 107 | 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 ( | |||||
| 11 | "strings" | 11 | "strings" | ||
| 12 | "sync" | 12 | "sync" | ||
| 13 | "syscall/js" | 13 | "syscall/js" | ||
| 14 | + | ||||
| 15 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 14 | ) | 16 | ) | ||
| 15 | 17 | ||||
| 16 | type callbackPlan struct { | 18 | type callbackPlan struct { | ||
| @@ -23,25 +25,11 @@ func NewBrowserClient() *Client { | |||||
| 23 | } | 25 | } | ||
| 24 | 26 | ||||
| 25 | func callbackPlanForMessage(msgType string) (callbackPlan, bool) { | 27 | func callbackPlanForMessage(msgType string) (callbackPlan, bool) { | ||
| 26 | - | switch msgType { | 28 | + | cb, ok := port.WebUIMessageCallbacks[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", | 29 | + | if !ok { |
| 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: | |||
| 43 | return callbackPlan{}, false | 30 | return callbackPlan{}, false | ||
| 44 | } | 31 | } | ||
| 32 | + | return callbackPlan{success: cb.Success, failure: cb.Failure}, true | |||
| 45 | } | 33 | } | ||
| 46 | 34 | ||||
| 47 | type browserTransport struct{} | 35 | 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 | |||||
| 82 | if offset < 0 { | 82 | if offset < 0 { | ||
| 83 | return nil, fmt.Errorf("timeline offset must be non-negative, got %d", offset) | 83 | return nil, fmt.Errorf("timeline offset must be non-negative, got %d", offset) | ||
| 84 | } | 84 | } | ||
| 85 | - | return request[[]*entity.HistoryEntry](c, ctx, "history_timeline", struct { | 85 | + | return request[[]*entity.HistoryEntry](c, ctx, port.MsgHistoryTimeline, struct { |
| 86 | RequestID string `json:"requestId"` | 86 | RequestID string `json:"requestId"` | ||
| 87 | Limit int `json:"limit"` | 87 | Limit int `json:"limit"` | ||
| 88 | Offset int `json:"offset"` | 88 | Offset int `json:"offset"` | ||
| @@ -100,7 +100,7 @@ func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, off | |||||
| 100 | if offset < 0 { | 100 | if offset < 0 { | ||
| 101 | return nil, fmt.Errorf("timeline-by-domain offset must be non-negative, got %d", offset) | 101 | return nil, fmt.Errorf("timeline-by-domain offset must be non-negative, got %d", offset) | ||
| 102 | } | 102 | } | ||
| 103 | - | return request[[]*entity.HistoryEntry](c, ctx, "history_timeline_by_domain", struct { | 103 | + | return request[[]*entity.HistoryEntry](c, ctx, port.MsgHistoryTimelineByDomain, struct { |
| 104 | RequestID string `json:"requestId"` | 104 | RequestID string `json:"requestId"` | ||
| 105 | Domain string `json:"domain"` | 105 | Domain string `json:"domain"` | ||
| 106 | Limit int `json:"limit"` | 106 | Limit int `json:"limit"` | ||
| @@ -119,7 +119,7 @@ func (c *Client) TimelineWindow(ctx context.Context, before time.Time, beforeID | |||||
| 119 | if !before.IsZero() { | 119 | if !before.IsZero() { | ||
| 120 | beforeCursor = before.Format(time.RFC3339Nano) | 120 | beforeCursor = before.Format(time.RFC3339Nano) | ||
| 121 | } | 121 | } | ||
| 122 | - | return request[*entity.HistoryWindow](c, ctx, "history_timeline_window", struct { | 122 | + | return request[*entity.HistoryWindow](c, ctx, port.MsgHistoryTimelineWindow, struct { |
| 123 | RequestID string `json:"requestId"` | 123 | RequestID string `json:"requestId"` | ||
| 124 | Before string `json:"before,omitempty"` | 124 | Before string `json:"before,omitempty"` | ||
| 125 | BeforeID int64 `json:"beforeId,omitempty"` | 125 | BeforeID int64 `json:"beforeId,omitempty"` | ||
| @@ -131,7 +131,7 @@ func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity | |||||
| 131 | if limit < 0 { | 131 | if limit < 0 { | ||
| 132 | return nil, fmt.Errorf("search limit must be non-negative, got %d", limit) | 132 | return nil, fmt.Errorf("search limit must be non-negative, got %d", limit) | ||
| 133 | } | 133 | } | ||
| 134 | - | return request[[]*entity.HistoryEntry](c, ctx, "history_search_fts", struct { | 134 | + | return request[[]*entity.HistoryEntry](c, ctx, port.MsgHistorySearchFTS, struct { |
| 135 | RequestID string `json:"requestId"` | 135 | RequestID string `json:"requestId"` | ||
| 136 | Query string `json:"query"` | 136 | Query string `json:"query"` | ||
| 137 | Limit int `json:"limit"` | 137 | Limit int `json:"limit"` | ||
| @@ -142,7 +142,7 @@ func (c *Client) DeleteEntry(ctx context.Context, id int64) error { | |||||
| 142 | if id <= 0 { | 142 | if id <= 0 { | ||
| 143 | return fmt.Errorf("delete entry id must be positive, got %d", id) | 143 | return fmt.Errorf("delete entry id must be positive, got %d", id) | ||
| 144 | } | 144 | } | ||
| 145 | - | _, err := request[struct{}](c, ctx, "history_delete_entry", struct { | 145 | + | _, err := request[struct{}](c, ctx, port.MsgHistoryDeleteEntry, struct { |
| 146 | RequestID string `json:"requestId"` | 146 | RequestID string `json:"requestId"` | ||
| 147 | ID int64 `json:"id"` | 147 | ID int64 `json:"id"` | ||
| 148 | }{RequestID: nextRequestID(), ID: id}) | 148 | }{RequestID: nextRequestID(), ID: id}) | ||
| @@ -154,7 +154,7 @@ func (c *Client) DeleteRange(ctx context.Context, rangeID string) error { | |||||
| 154 | if rangeID == "" { | 154 | if rangeID == "" { | ||
| 155 | return errors.New("history range ID cannot be empty") | 155 | return errors.New("history range ID cannot be empty") | ||
| 156 | } | 156 | } | ||
| 157 | - | _, err := request[struct{}](c, ctx, "history_delete_range", struct { | 157 | + | _, err := request[struct{}](c, ctx, port.MsgHistoryDeleteRange, struct { |
| 158 | RequestID string `json:"requestId"` | 158 | RequestID string `json:"requestId"` | ||
| 159 | Range string `json:"range"` | 159 | Range string `json:"range"` | ||
| 160 | }{RequestID: nextRequestID(), Range: rangeID}) | 160 | }{RequestID: nextRequestID(), Range: rangeID}) | ||
| @@ -178,40 +178,40 @@ func (c *Client) Default(ctx context.Context) (dto.SystemviewConfigPayload, erro | |||||
| 178 | } | 178 | } | ||
| 179 | 179 | ||||
| 180 | func (c *Client) Save(ctx context.Context, cfg dto.WebUIConfig) error { | 180 | 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) |
| 182 | return err | 182 | return err | ||
| 183 | } | 183 | } | ||
| 184 | 184 | ||||
| 185 | func (c *Client) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) { | 185 | 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 { |
| 187 | RequestID string `json:"requestId"` | 187 | RequestID string `json:"requestId"` | ||
| 188 | }{RequestID: nextRequestID()}) | 188 | }{RequestID: nextRequestID()}) | ||
| 189 | } | 189 | } | ||
| 190 | 190 | ||||
| 191 | func (c *Client) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) { | 191 | 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) |
| 193 | } | 193 | } | ||
| 194 | 194 | ||||
| 195 | func (c *Client) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error { | 195 | 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) |
| 197 | return err | 197 | return err | ||
| 198 | } | 198 | } | ||
| 199 | 199 | ||||
| 200 | func (c *Client) ResetAllKeybindings(ctx context.Context) error { | 200 | 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 { |
| 202 | RequestID string `json:"requestId"` | 202 | RequestID string `json:"requestId"` | ||
| 203 | }{RequestID: nextRequestID()}) | 203 | }{RequestID: nextRequestID()}) | ||
| 204 | return err | 204 | return err | ||
| 205 | } | 205 | } | ||
| 206 | 206 | ||||
| 207 | func (c *Client) Stats(ctx context.Context) (*entity.HistoryStats, error) { | 207 | 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 { |
| 209 | RequestID string `json:"requestId"` | 209 | RequestID string `json:"requestId"` | ||
| 210 | }{RequestID: nextRequestID()}) | 210 | }{RequestID: nextRequestID()}) | ||
| 211 | } | 211 | } | ||
| 212 | 212 | ||||
| 213 | func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) { | 213 | 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 { |
| 215 | RequestID string `json:"requestId"` | 215 | RequestID string `json:"requestId"` | ||
| 216 | }{RequestID: nextRequestID()}) | 216 | }{RequestID: nextRequestID()}) | ||
| 217 | } | 217 | } | ||
| @@ -220,7 +220,7 @@ func (c *Client) DomainStats(ctx context.Context, limit int) ([]*entity.DomainSt | |||||
| 220 | if limit < 0 { | 220 | if limit < 0 { | ||
| 221 | return nil, fmt.Errorf("domain stats limit must be non-negative, got %d", limit) | 221 | return nil, fmt.Errorf("domain stats limit must be non-negative, got %d", limit) | ||
| 222 | } | 222 | } | ||
| 223 | - | return request[[]*entity.DomainStat](c, ctx, "history_domain_stats", struct { | 223 | + | return request[[]*entity.DomainStat](c, ctx, port.MsgHistoryDomainStats, struct { |
| 224 | RequestID string `json:"requestId"` | 224 | RequestID string `json:"requestId"` | ||
| 225 | Limit int `json:"limit"` | 225 | Limit int `json:"limit"` | ||
| 226 | }{RequestID: nextRequestID(), Limit: limit}) | 226 | }{RequestID: nextRequestID(), Limit: limit}) | ||
| @@ -231,7 +231,7 @@ func (c *Client) DeleteDomain(ctx context.Context, domain string) error { | |||||
| 231 | if domain == "" { | 231 | if domain == "" { | ||
| 232 | return errors.New("history domain cannot be empty") | 232 | return errors.New("history domain cannot be empty") | ||
| 233 | } | 233 | } | ||
| 234 | - | _, err := request[struct{}](c, ctx, "history_delete_domain", struct { | 234 | + | _, err := request[struct{}](c, ctx, port.MsgHistoryDeleteDomain, struct { |
| 235 | RequestID string `json:"requestId"` | 235 | RequestID string `json:"requestId"` | ||
| 236 | Domain string `json:"domain"` | 236 | Domain string `json:"domain"` | ||
| 237 | }{RequestID: nextRequestID(), Domain: domain}) | 237 | }{RequestID: nextRequestID(), Domain: domain}) | ||
| @@ -239,27 +239,27 @@ func (c *Client) DeleteDomain(ctx context.Context, domain string) error { | |||||
| 239 | } | 239 | } | ||
| 240 | 240 | ||||
| 241 | func (c *Client) List(ctx context.Context) ([]*entity.Favorite, error) { | 241 | 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 { |
| 243 | RequestID string `json:"requestId"` | 243 | RequestID string `json:"requestId"` | ||
| 244 | }{RequestID: nextRequestID()}) | 244 | }{RequestID: nextRequestID()}) | ||
| 245 | } | 245 | } | ||
| 246 | 246 | ||||
| 247 | func (c *Client) CreateFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) { | 247 | 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)) |
| 249 | } | 249 | } | ||
| 250 | 250 | ||||
| 251 | func (c *Client) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) { | 251 | func (c *Client) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) { | ||
| 252 | if input.ID <= 0 { | 252 | if input.ID <= 0 { | ||
| 253 | return nil, fmt.Errorf("update favorite id must be positive, got %d", input.ID) | 253 | return nil, fmt.Errorf("update favorite id must be positive, got %d", input.ID) | ||
| 254 | } | 254 | } | ||
| 255 | - | return request[*entity.Favorite](c, ctx, "favorite_update", favoriteUpdatePayload(input)) | 255 | + | return request[*entity.Favorite](c, ctx, port.MsgFavoriteUpdate, favoriteUpdatePayload(input)) |
| 256 | } | 256 | } | ||
| 257 | 257 | ||||
| 258 | func (c *Client) DeleteFavorite(ctx context.Context, id int64) error { | 258 | func (c *Client) DeleteFavorite(ctx context.Context, id int64) error { | ||
| 259 | if id <= 0 { | 259 | if id <= 0 { | ||
| 260 | return fmt.Errorf("delete favorite id must be positive, got %d", id) | 260 | return fmt.Errorf("delete favorite id must be positive, got %d", id) | ||
| 261 | } | 261 | } | ||
| 262 | - | _, err := request[struct{}](c, ctx, "favorite_delete", struct { | 262 | + | _, err := request[struct{}](c, ctx, port.MsgFavoriteDelete, struct { |
| 263 | RequestID string `json:"requestId"` | 263 | RequestID string `json:"requestId"` | ||
| 264 | ID int64 `json:"id"` | 264 | ID int64 `json:"id"` | ||
| 265 | }{RequestID: nextRequestID(), ID: id}) | 265 | }{RequestID: nextRequestID(), ID: id}) | ||
| @@ -304,7 +304,7 @@ func tagIDPayloads(ids []entity.TagID) []int64 { | |||||
| 304 | } | 304 | } | ||
| 305 | 305 | ||||
| 306 | func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) { | 306 | 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 { |
| 308 | RequestID string `json:"requestId"` | 308 | RequestID string `json:"requestId"` | ||
| 309 | }{RequestID: nextRequestID()}) | 309 | }{RequestID: nextRequestID()}) | ||
| 310 | } | 310 | } | ||
| @@ -313,7 +313,7 @@ func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey | |||||
| 313 | if favoriteID <= 0 { | 313 | if favoriteID <= 0 { | ||
| 314 | return fmt.Errorf("set shortcut favorite id must be positive, got %d", favoriteID) | 314 | return fmt.Errorf("set shortcut favorite id must be positive, got %d", favoriteID) | ||
| 315 | } | 315 | } | ||
| 316 | - | _, err := request[struct{}](c, ctx, "favorite_set_shortcut", struct { | 316 | + | _, err := request[struct{}](c, ctx, port.MsgFavoriteSetShortcut, struct { |
| 317 | RequestID string `json:"requestId"` | 317 | RequestID string `json:"requestId"` | ||
| 318 | FavoriteID int64 `json:"favorite_id"` | 318 | FavoriteID int64 `json:"favorite_id"` | ||
| 319 | ShortcutKey *int `json:"shortcut_key"` | 319 | ShortcutKey *int `json:"shortcut_key"` | ||
| @@ -330,7 +330,7 @@ func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag | |||||
| 330 | if trimmedColor := strings.TrimSpace(color); trimmedColor != "" { | 330 | if trimmedColor := strings.TrimSpace(color); trimmedColor != "" { | ||
| 331 | colorPtr = &trimmedColor | 331 | colorPtr = &trimmedColor | ||
| 332 | } | 332 | } | ||
| 333 | - | return request[*entity.Tag](c, ctx, "tag_create", struct { | 333 | + | return request[*entity.Tag](c, ctx, port.MsgTagCreate, struct { |
| 334 | RequestID string `json:"requestId"` | 334 | RequestID string `json:"requestId"` | ||
| 335 | Name string `json:"name"` | 335 | Name string `json:"name"` | ||
| 336 | Color *string `json:"color"` | 336 | Color *string `json:"color"` | ||
| @@ -350,7 +350,7 @@ func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) er | |||||
| 350 | if trimmedColor := strings.TrimSpace(color); trimmedColor != "" { | 350 | if trimmedColor := strings.TrimSpace(color); trimmedColor != "" { | ||
| 351 | colorPtr = &trimmedColor | 351 | colorPtr = &trimmedColor | ||
| 352 | } | 352 | } | ||
| 353 | - | _, err := request[struct{}](c, ctx, "tag_update", struct { | 353 | + | _, err := request[struct{}](c, ctx, port.MsgTagUpdate, struct { |
| 354 | RequestID string `json:"requestId"` | 354 | RequestID string `json:"requestId"` | ||
| 355 | ID int64 `json:"id"` | 355 | ID int64 `json:"id"` | ||
| 356 | Name *string `json:"name"` | 356 | Name *string `json:"name"` | ||
| @@ -363,7 +363,7 @@ func (c *Client) DeleteTag(ctx context.Context, id int64) error { | |||||
| 363 | if id <= 0 { | 363 | if id <= 0 { | ||
| 364 | return fmt.Errorf("delete tag id must be positive, got %d", id) | 364 | return fmt.Errorf("delete tag id must be positive, got %d", id) | ||
| 365 | } | 365 | } | ||
| 366 | - | _, err := request[struct{}](c, ctx, "tag_delete", struct { | 366 | + | _, err := request[struct{}](c, ctx, port.MsgTagDelete, struct { |
| 367 | RequestID string `json:"requestId"` | 367 | RequestID string `json:"requestId"` | ||
| 368 | ID int64 `json:"id"` | 368 | ID int64 `json:"id"` | ||
| 369 | }{RequestID: nextRequestID(), ID: id}) | 369 | }{RequestID: nextRequestID(), ID: id}) | ||
| @@ -377,7 +377,7 @@ func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error { | |||||
| 377 | if tagID <= 0 { | 377 | if tagID <= 0 { | ||
| 378 | return fmt.Errorf("assign tag id must be positive, got %d", tagID) | 378 | return fmt.Errorf("assign tag id must be positive, got %d", tagID) | ||
| 379 | } | 379 | } | ||
| 380 | - | _, err := request[struct{}](c, ctx, "tag_assign", struct { | 380 | + | _, err := request[struct{}](c, ctx, port.MsgTagAssign, struct { |
| 381 | RequestID string `json:"requestId"` | 381 | RequestID string `json:"requestId"` | ||
| 382 | FavoriteID int64 `json:"favorite_id"` | 382 | FavoriteID int64 `json:"favorite_id"` | ||
| 383 | TagID int64 `json:"tag_id"` | 383 | TagID int64 `json:"tag_id"` | ||
| @@ -392,7 +392,7 @@ func (c *Client) RemoveTag(ctx context.Context, favoriteID, tagID int64) error { | |||||
| 392 | if tagID <= 0 { | 392 | if tagID <= 0 { | ||
| 393 | return fmt.Errorf("remove tag id must be positive, got %d", tagID) | 393 | return fmt.Errorf("remove tag id must be positive, got %d", tagID) | ||
| 394 | } | 394 | } | ||
| 395 | - | _, err := request[struct{}](c, ctx, "tag_remove", struct { | 395 | + | _, err := request[struct{}](c, ctx, port.MsgTagRemove, struct { |
| 396 | RequestID string `json:"requestId"` | 396 | RequestID string `json:"requestId"` | ||
| 397 | FavoriteID int64 `json:"favorite_id"` | 397 | FavoriteID int64 `json:"favorite_id"` | ||
| 398 | TagID int64 `json:"tag_id"` | 398 | TagID int64 `json:"tag_id"` | ||
M internal/infrastructure/webkit/scheme_handler.go +18 -90
| @@ -1,20 +1,14 @@ | |||||
| 1 | package webkit | 1 | package webkit | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "bytes" | |||
| 5 | "context" | 4 | "context" | ||
| 6 | "embed" | 5 | "embed" | ||
| 7 | "fmt" | 6 | "fmt" | ||
| 8 | - | "io" | |||
| 9 | - | "io/fs" | |||
| 10 | "net/http" | 7 | "net/http" | ||
| 11 | "net/url" | 8 | "net/url" | ||
| 12 | - | "path" | |||
| 13 | - | "strconv" | |||
| 14 | "strings" | 9 | "strings" | ||
| 15 | "sync" | 10 | "sync" | ||
| 16 | 11 | ||||
| 17 | - | "github.com/andybalholm/brotli" | |||
| 18 | "github.com/bnema/dumber/internal/application/port" | 12 | "github.com/bnema/dumber/internal/application/port" | ||
| 19 | "github.com/bnema/dumber/internal/infrastructure/webutil" | 13 | "github.com/bnema/dumber/internal/infrastructure/webutil" | ||
| 20 | "github.com/bnema/dumber/internal/logging" | 14 | "github.com/bnema/dumber/internal/logging" | ||
| @@ -33,10 +27,17 @@ const ( | |||||
| 33 | CrashPath = "crash" | 27 | CrashPath = "crash" | ||
| 34 | IndexHTML = "index.html" | 28 | IndexHTML = "index.html" | ||
| 35 | httpGET = "GET" | 29 | httpGET = "GET" | ||
| 36 | - | maxSystemviewsWASMBytes = 64 * 1024 * 1024 | 30 | + | maxSystemviewsWASMBytes = webutil.MaxSystemviewsWASMBytes |
| 37 | - | systemviewsAssetDir = "systemviews" | 31 | + | systemviewsAssetDir = webutil.SystemviewsAssetDir |
| 38 | ) | 32 | ) | ||
| 39 | 33 | ||||
| 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 | + | ||||
| 40 | // SchemeRequest represents a request to a custom URI scheme. | 41 | // SchemeRequest represents a request to a custom URI scheme. | ||
| 41 | type SchemeRequest struct { | 42 | type SchemeRequest struct { | ||
| 42 | inner *webkit.URISchemeRequest | 43 | inner *webkit.URISchemeRequest | ||
| @@ -203,8 +204,6 @@ func buildCrashPageHTML(originalURI string) string { | |||||
| 203 | return webutil.BuildCrashPageHTML(originalURI) | 204 | return webutil.BuildCrashPageHTML(originalURI) | ||
| 204 | } | 205 | } | ||
| 205 | 206 | ||||
| 206 | - | const systemviewFaviconSize = 32 | |||
| 207 | - | ||||
| 208 | func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse { | 207 | func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse { | ||
| 209 | if !isTrustedSystemviewFaviconRequest(req) { | 208 | if !isTrustedSystemviewFaviconRequest(req) { | ||
| 210 | return privateJSONErrorResponse(http.StatusForbidden, "forbidden") | 209 | return privateJSONErrorResponse(http.StatusForbidden, "forbidden") | ||
| @@ -218,21 +217,9 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse | |||||
| 218 | if req == nil { | 217 | if req == nil { | ||
| 219 | return privateJSONErrorResponse(http.StatusBadRequest, "invalid request") | 218 | return privateJSONErrorResponse(http.StatusBadRequest, "invalid request") | ||
| 220 | } | 219 | } | ||
| 221 | - | parsed, err := url.Parse(req.URI) | 220 | + | domain, size, err := webutil.ParseSystemviewFaviconRequest(req.URI) |
| 222 | if err != nil { | 221 | if err != nil { | ||
| 223 | - | return privateJSONErrorResponse(http.StatusBadRequest, "invalid request URL") | 222 | + | return privateJSONErrorResponse(http.StatusBadRequest, err.Error()) |
| 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 | |||
| 236 | } | 223 | } | ||
| 237 | 224 | ||||
| 238 | resolveCtx := h.ctx | 225 | resolveCtx := h.ctx | ||
| @@ -261,31 +248,14 @@ func isTrustedSystemviewFaviconRequest(req *SchemeRequest) bool { | |||||
| 261 | } | 248 | } | ||
| 262 | 249 | ||||
| 263 | func isTrustedSystemviewAPIRequest(req *SchemeRequest) bool { | 250 | func isTrustedSystemviewAPIRequest(req *SchemeRequest) bool { | ||
| 264 | - | if req == nil { | 251 | + | return req != nil && systemviewTrust.Allows(req.Origin, req.Referer) |
| 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) | |||
| 272 | } | 252 | } | ||
| 273 | 253 | ||||
| 274 | func isTrustedSystemviewURL(raw string) bool { | 254 | func isTrustedSystemviewURL(raw string) bool { | ||
| 275 | - | parsed, err := url.Parse(strings.TrimSpace(raw)) | 255 | + | host, ok := webutil.DumbURLHost(raw) |
| 276 | - | if err != nil || parsed.Scheme == "" { | 256 | + | if !ok { |
| 277 | - | return false | |||
| 278 | - | } | |||
| 279 | - | if !strings.EqualFold(parsed.Scheme, "dumb") { | |||
| 280 | return false | 257 | return false | ||
| 281 | } | 258 | } | ||
| 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 | - | } | |||
| 289 | switch host { | 259 | switch host { | ||
| 290 | case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath: | 260 | case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath: | ||
| 291 | return true | 261 | return true | ||
| @@ -476,52 +446,10 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||||
| 476 | } | 446 | } | ||
| 477 | } | 447 | } | ||
| 478 | 448 | ||||
| 479 | - | func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) { | 449 | + | var ( |
| 480 | - | assetDir = strings.Trim(assetDir, "/") | 450 | + | safeSystemviewsAssetPath = webutil.SafeSystemviewsAssetPath |
| 481 | - | if assetDir != systemviewsAssetDir { | 451 | + | readAssetWithEncoding = webutil.ReadSystemviewAsset |
| 482 | - | return "", "", false | 452 | + | ) |
| 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 | - | } | |||
| 525 | 453 | ||||
| 526 | func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | 454 | func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | ||
| 527 | if u == nil { | 455 | 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 ( | |||||
| 11 | "strings" | 11 | "strings" | ||
| 12 | "sync" | 12 | "sync" | ||
| 13 | "time" | 13 | "time" | ||
| 14 | - | "unsafe" | |||
| 15 | 14 | ||||
| 16 | "github.com/bnema/dumber/internal/application/dto" | 15 | "github.com/bnema/dumber/internal/application/dto" | ||
| 17 | "github.com/bnema/dumber/internal/application/port" | 16 | "github.com/bnema/dumber/internal/application/port" | ||
| @@ -124,16 +123,16 @@ type App struct { | |||||
| 124 | // Focus tracking stays app-global. | 123 | // Focus tracking stays app-global. | ||
| 125 | focusMgr *focus.Manager | 124 | focusMgr *focus.Manager | ||
| 126 | 125 | ||||
| 127 | - | vimModePolicyUC *usecase.VimModePolicyUseCase | 126 | + | vimModePolicyUC *usecase.VimModePolicyUseCase |
| 128 | - | vimNavigationUC *usecase.VimNavigationUseCase | 127 | + | vimNavigationUC *usecase.VimNavigationUseCase |
| 129 | - | pageEditableFocusByPane map[entity.PaneID]bool | 128 | + | vimMode vimModeState |
| 130 | 129 | ||||
| 131 | // Omnibox configuration (omnibox is created per workspace view) | 130 | // Omnibox configuration (omnibox is created per workspace view) | ||
| 132 | omniboxCfg component.OmniboxConfig | 131 | omniboxCfg component.OmniboxConfig | ||
| 133 | // Find bar configuration (find bar is created per workspace view) | 132 | // Find bar configuration (find bar is created per workspace view) | ||
| 134 | findBarCfg component.FindBarConfig | 133 | findBarCfg component.FindBarConfig | ||
| 135 | // Floating pane sessions keyed by tab and profile session ID. | 134 | // Floating pane sessions keyed by tab and profile session ID. | ||
| 136 | - | floatingSessions map[floatingSessionKey]*floatingWorkspaceSession | 135 | + | floatingSessions floatingSessionRegistry |
| 137 | 136 | ||||
| 138 | // Engine | 137 | // Engine | ||
| 139 | engine port.Engine | 138 | engine port.Engine | ||
| @@ -170,12 +169,6 @@ type App struct { | |||||
| 170 | // Accent picker for dead keys support | 169 | // Accent picker for dead keys support | ||
| 171 | accentFocusProvider port.FocusedInputProvider | 170 | accentFocusProvider port.FocusedInputProvider | ||
| 172 | 171 | ||||
| 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 | - | ||||
| 179 | // Deferred initialization - runs after first load_started to avoid blocking initial navigation | 172 | // Deferred initialization - runs after first load_started to avoid blocking initial navigation | ||
| 180 | deferredInitOnce sync.Once | 173 | deferredInitOnce sync.Once | ||
| 181 | deferredInitFn func() | 174 | deferredInitFn func() | ||
| @@ -186,30 +179,6 @@ type App struct { | |||||
| 186 | browserLaunchRelayCloser io.Closer | 179 | browserLaunchRelayCloser io.Closer | ||
| 187 | } | 180 | } | ||
| 188 | 181 | ||||
| 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 | - | ||||
| 213 | // New creates a new App with the given dependencies. | 182 | // New creates a new App with the given dependencies. | ||
| 214 | func New(deps *Dependencies) (*App, error) { | 183 | func New(deps *Dependencies) (*App, error) { | ||
| 215 | if err := deps.Validate(); err != nil { | 184 | if err := deps.Validate(); err != nil { | ||
| @@ -218,19 +187,18 @@ func New(deps *Dependencies) (*App, error) { | |||||
| 218 | ctx, cancel := context.WithCancelCause(deps.Ctx) | 187 | ctx, cancel := context.WithCancelCause(deps.Ctx) | ||
| 219 | 188 | ||||
| 220 | app := &App{ | 189 | app := &App{ | ||
| 221 | - | deps: deps, | 190 | + | deps: deps, |
| 222 | - | runtimeConfig: newRuntimeConfigState(deps.RuntimeConfig), | 191 | + | runtimeConfig: newRuntimeConfigState(deps.RuntimeConfig), |
| 223 | - | tabs: entity.NewTabList(), | 192 | + | tabs: entity.NewTabList(), |
| 224 | - | tabsUC: deps.TabsUC, | 193 | + | tabsUC: deps.TabsUC, |
| 225 | - | panesUC: deps.PanesUC, | 194 | + | panesUC: deps.PanesUC, |
| 226 | - | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), | 195 | + | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), |
| 227 | - | windowForTab: make(map[entity.TabID]*browserWindow), | 196 | + | windowForTab: make(map[entity.TabID]*browserWindow), |
| 228 | - | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), | 197 | + | floatingSessions: make(floatingSessionRegistry), |
| 229 | - | browserWindows: make(map[string]*browserWindow), | 198 | + | browserWindows: make(map[string]*browserWindow), |
| 230 | - | instanceWindows: make(map[string]string), | 199 | + | instanceWindows: make(map[string]string), |
| 231 | - | vimModePolicyUC: usecase.NewVimModePolicyUseCase(), | 200 | + | vimModePolicyUC: usecase.NewVimModePolicyUseCase(), |
| 232 | - | vimNavigationUC: usecase.NewVimNavigationUseCase(), | 201 | + | vimNavigationUC: usecase.NewVimNavigationUseCase(), |
| 233 | - | pageEditableFocusByPane: make(map[entity.PaneID]bool), | |||
| 234 | dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult { | 202 | dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult { | ||
| 235 | if fn != nil { | 203 | if fn != nil { | ||
| 236 | fn() | 204 | fn() | ||
| @@ -623,10 +591,8 @@ func (a *App) browserWindowForAnyPane(paneID entity.PaneID) *browserWindow { | |||||
| 623 | if bw := a.browserWindowForPane(paneID); bw != nil { | 591 | if bw := a.browserWindowForPane(paneID); bw != nil { | ||
| 624 | return bw | 592 | return bw | ||
| 625 | } | 593 | } | ||
| 626 | - | for key, session := range a.floatingSessions { | 594 | + | if key, _, ok := a.floatingSessions.byPaneID(paneID); ok { |
| 627 | - | if session != nil && session.paneID == paneID { | 595 | + | return a.browserWindowForTab(key.tabID) |
| 628 | - | return a.browserWindowForTab(key.tabID) | |||
| 629 | - | } | |||
| 630 | } | 596 | } | ||
| 631 | return nil | 597 | return nil | ||
| 632 | } | 598 | } | ||
| @@ -696,11 +662,9 @@ func (a *App) openPopupNativeWindow(ctx context.Context, popupInput content.Nati | |||||
| 696 | if err := a.openNativePopupWindow(ctx, popupInput); err != nil { | 662 | if err := a.openNativePopupWindow(ctx, popupInput); err != nil { | ||
| 697 | return err | 663 | return err | ||
| 698 | } | 664 | } | ||
| 699 | - | // Deferred WebKit classification runs from ready-to-show. The native host | 665 | + | // A popup classified after ready-to-show has already delivered that |
| 700 | - | // is attached after that signal, so it must be revealed immediately rather | 666 | + | // lifecycle callback, so reveal it now instead of waiting for it. |
| 701 | - | // than waiting for an already-delivered lifecycle callback. | 667 | + | if popupInput.AlreadyReadyToShow { |
| 702 | - | if popupInput.Request.Engine == dto.BrowserEngineWebKit && | |||
| 703 | - | popupInput.Request.PopupFeatures.State == dto.PopupFeaturesSpecified { | |||
| 704 | a.showNativePopupWindow(popupInput.PopupWebView.ID()) | 668 | a.showNativePopupWindow(popupInput.PopupWebView.ID()) | ||
| 705 | } | 669 | } | ||
| 706 | return nil | 670 | return nil | ||
| @@ -1839,8 +1803,8 @@ func (a *App) reattachFloatingSessions(tabID entity.TabID, wsView *component.Wor | |||||
| 1839 | return | 1803 | return | ||
| 1840 | } | 1804 | } | ||
| 1841 | 1805 | ||||
| 1842 | - | for key, session := range a.floatingSessions { | 1806 | + | for _, session := range a.floatingSessions.forTab(tabID) { |
| 1843 | - | if key.tabID != tabID || session == nil || session.pane == nil { | 1807 | + | if session.pane == nil { |
| 1844 | continue | 1808 | continue | ||
| 1845 | } | 1809 | } | ||
| 1846 | 1810 | ||||
| @@ -3169,15 +3133,7 @@ func (a *App) onShutdown(ctx context.Context) { | |||||
| 3169 | if a.faviconAdapter != nil { | 3133 | if a.faviconAdapter != nil { | ||
| 3170 | a.faviconAdapter.Close() | 3134 | a.faviconAdapter.Close() | ||
| 3171 | } | 3135 | } | ||
| 3172 | - | tabIDSet := make(map[entity.TabID]struct{}, len(a.floatingSessions)) | 3136 | + | for _, tabID := range a.floatingSessions.tabIDs() { |
| 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 { | |||
| 3181 | a.releaseFloatingSessionsForTab(ctx, tabID) | 3137 | a.releaseFloatingSessionsForTab(ctx, tabID) | ||
| 3182 | } | 3138 | } | ||
| 3183 | if a.contentCoord != nil { | 3139 | if a.contentCoord != nil { | ||
| @@ -3929,361 +3885,6 @@ func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) { | |||||
| 3929 | a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, "")) | 3885 | a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, "")) | ||
| 3930 | } | 3886 | } | ||
| 3931 | 3887 | ||||
| 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 | - | ||||
| 4287 | // getModeToastClass returns the CSS class for the given mode's toast styling. | 3888 | // getModeToastClass returns the CSS class for the given mode's toast styling. | ||
| 4288 | func getModeToastClass(mode input.Mode) string { | 3889 | func getModeToastClass(mode input.Mode) string { | ||
| 4289 | switch mode { | 3890 | switch mode { | ||
| @@ -4546,773 +4147,6 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) { | |||||
| 4546 | log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched") | 4147 | log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched") | ||
| 4547 | } | 4148 | } | ||
| 4548 | 4149 | ||||
| 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 | - | ||||
| 5316 | func (a *App) navigateFromOmnibox(ctx context.Context, url string) error { | 4150 | func (a *App) navigateFromOmnibox(ctx context.Context, url string) error { | ||
| 5317 | session, _ := a.activeFloatingSession() | 4151 | session, _ := a.activeFloatingSession() | ||
| 5318 | if session != nil && session.pane != nil && session.pane.IsVisible() && session.pane.IsOmniboxVisible() { | 4152 | 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 | |||||
| 5479 | view.SetOmniboxUIScale(runtimeCfg.DefaultUIScale) | 4313 | view.SetOmniboxUIScale(runtimeCfg.DefaultUIScale) | ||
| 5480 | } | 4314 | } | ||
| 5481 | } | 4315 | } | ||
| 5482 | - | for _, session := range a.floatingSessions { | 4316 | + | for _, session := range a.floatingSessions.all() { |
| 5483 | - | if session != nil && session.omnibox != nil { | 4317 | + | if session.omnibox != nil { |
| 5484 | session.omnibox.SetUIScale(runtimeCfg.DefaultUIScale) | 4318 | session.omnibox.SetUIScale(runtimeCfg.DefaultUIScale) | ||
| 5485 | } | 4319 | } | ||
| 5486 | } | 4320 | } | ||
M internal/ui/app_vim_mode_test.go +8 -8
| @@ -580,7 +580,7 @@ func TestVimMode_NativePageFocusNavigationUsesActiveEditableWebView(t *testing.T | |||||
| 580 | app, bw, paneID := newVimModeAccessibilityFixture(t) | 580 | app, bw, paneID := newVimModeAccessibilityFixture(t) | ||
| 581 | wv := newInputFocusingWebView(t, 7) | 581 | wv := newInputFocusingWebView(t, 7) | ||
| 582 | app.contentCoord.RegisterPopupWebView(paneID, wv) | 582 | app.contentCoord.RegisterPopupWebView(paneID, wv) | ||
| 583 | - | app.pageEditableFocusByPane = map[entity.PaneID]bool{paneID: true} | 583 | + | app.vimMode.editableFocus = map[entity.PaneID]bool{paneID: true} |
| 584 | 584 | ||||
| 585 | assert.True(t, app.navigatePageFocus(context.Background(), bw, true)) | 585 | assert.True(t, app.navigatePageFocus(context.Background(), bw, true)) | ||
| 586 | assert.Equal(t, []bool{true}, wv.pageFocusNavigationCalls) | 586 | assert.Equal(t, []bool{true}, wv.pageFocusNavigationCalls) | ||
| @@ -884,7 +884,7 @@ func TestVimMode_PaneSwitchToEditablePaneExitsVimMode(t *testing.T) { | |||||
| 884 | f := newSingleWindowVimModeFixture(t) | 884 | f := newSingleWindowVimModeFixture(t) | ||
| 885 | enterVimMode(t, f.app, f.bw1) | 885 | enterVimMode(t, f.app, f.bw1) | ||
| 886 | kh := bindVimModeKeyboardHandler(t, f.app, f.bw1) | 886 | kh := bindVimModeKeyboardHandler(t, f.app, f.bw1) | ||
| 887 | - | f.app.pageEditableFocusByPane = map[entity.PaneID]bool{ | 887 | + | f.app.vimMode.editableFocus = map[entity.PaneID]bool{ |
| 888 | "pane-b": true, | 888 | "pane-b": true, | ||
| 889 | } | 889 | } | ||
| 890 | 890 | ||||
| @@ -981,7 +981,7 @@ func TestVimMode_TabSwitchExitsAndClearsOldAccent(t *testing.T) { | |||||
| 981 | 981 | ||||
| 982 | func TestVimMode_ActivationRemainsAvailableWhenPageIsEditable(t *testing.T) { | 982 | func TestVimMode_ActivationRemainsAvailableWhenPageIsEditable(t *testing.T) { | ||
| 983 | f := newSingleWindowVimModeFixture(t) | 983 | f := newSingleWindowVimModeFixture(t) | ||
| 984 | - | f.app.pageEditableFocusByPane = map[entity.PaneID]bool{ | 984 | + | f.app.vimMode.editableFocus = map[entity.PaneID]bool{ |
| 985 | "pane-a": true, | 985 | "pane-a": true, | ||
| 986 | } | 986 | } | ||
| 987 | 987 | ||||
| @@ -1000,7 +1000,7 @@ func TestVimMode_ActivationAvailabilityDoesNotDependOnEditableFocus(t *testing.T | |||||
| 1000 | 1000 | ||||
| 1001 | func TestVimMode_ClearEditableFocusStateRemovesStoredBypass(t *testing.T) { | 1001 | func TestVimMode_ClearEditableFocusStateRemovesStoredBypass(t *testing.T) { | ||
| 1002 | f := newSingleWindowVimModeFixture(t) | 1002 | 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} |
| 1004 | 1004 | ||||
| 1005 | f.app.clearPageEditableFocusState(entity.PaneID("pane-a")) | 1005 | f.app.clearPageEditableFocusState(entity.PaneID("pane-a")) | ||
| 1006 | 1006 | ||||
| @@ -1220,7 +1220,7 @@ func TestVimMode_Pulse_DebounceAllowsSpacedCalls(t *testing.T) { | |||||
| 1220 | f := newSingleWindowVimModeFixture(t) | 1220 | f := newSingleWindowVimModeFixture(t) | ||
| 1221 | 1221 | ||||
| 1222 | // Fresh app: debounce timer is zero. | 1222 | // Fresh app: debounce timer is zero. | ||
| 1223 | - | assert.True(t, f.app.vimModePulseLastTime.IsZero(), | 1223 | + | assert.True(t, f.app.vimMode.pulseIdle(), |
| 1224 | "fresh app should have zero pulse timer") | 1224 | "fresh app should have zero pulse timer") | ||
| 1225 | 1225 | ||||
| 1226 | enterVimMode(t, f.app, f.bw1) | 1226 | enterVimMode(t, f.app, f.bw1) | ||
| @@ -1231,7 +1231,7 @@ func TestVimMode_Pulse_DebounceAllowsSpacedCalls(t *testing.T) { | |||||
| 1231 | f.app.triggerVimModePulse(context.Background(), false) | 1231 | f.app.triggerVimModePulse(context.Background(), false) | ||
| 1232 | 1232 | ||||
| 1233 | // Timer is now set, proving the first pulse registered. | 1233 | // 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(), |
| 1235 | "pulse timer should be non-zero after first pulse") | 1235 | "pulse timer should be non-zero after first pulse") | ||
| 1236 | } | 1236 | } | ||
| 1237 | 1237 | ||||
| @@ -1248,7 +1248,7 @@ func TestVimMode_Pulse_DebounceResetOnClearOwnership(t *testing.T) { | |||||
| 1248 | attachTestModeFrame(f.bw1, f.overlay1A) | 1248 | attachTestModeFrame(f.bw1, f.overlay1A) | ||
| 1249 | setUpNormalPulse(f.overlay1A) | 1249 | setUpNormalPulse(f.overlay1A) | ||
| 1250 | f.app.triggerVimModePulse(context.Background(), false) | 1250 | f.app.triggerVimModePulse(context.Background(), false) | ||
| 1251 | - | assert.False(t, f.app.vimModePulseLastTime.IsZero(), | 1251 | + | assert.False(t, f.app.vimMode.pulseIdle(), |
| 1252 | "pulse timer should be non-zero after first pulse") | 1252 | "pulse timer should be non-zero after first pulse") | ||
| 1253 | 1253 | ||||
| 1254 | // Clear ownership — this resets the debounce timer. | 1254 | // Clear ownership — this resets the debounce timer. | ||
| @@ -1257,7 +1257,7 @@ func TestVimMode_Pulse_DebounceResetOnClearOwnership(t *testing.T) { | |||||
| 1257 | assert.Empty(t, f.bw1.vimModePaneID) | 1257 | assert.Empty(t, f.bw1.vimModePaneID) | ||
| 1258 | 1258 | ||||
| 1259 | // Debounce timer should now be back to zero. | 1259 | // Debounce timer should now be back to zero. | ||
| 1260 | - | assert.True(t, f.app.vimModePulseLastTime.IsZero(), | 1260 | + | assert.True(t, f.app.vimMode.pulseIdle(), |
| 1261 | "pulse timer should be reset after clearVimModeOwnership") | 1261 | "pulse timer should be reset after clearVimModeOwnership") | ||
| 1262 | } | 1262 | } | ||
| 1263 | 1263 | ||||
M internal/ui/component/omnibox.go +11 -189
| @@ -3,7 +3,6 @@ package component | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | - | stdurl "net/url" | |||
| 7 | "strings" | 6 | "strings" | ||
| 8 | "sync" | 7 | "sync" | ||
| 9 | "time" | 8 | "time" | ||
| @@ -1192,15 +1191,6 @@ func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool | |||||
| 1192 | return false | 1191 | return false | ||
| 1193 | } | 1192 | } | ||
| 1194 | 1193 | ||||
| 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 | - | ||||
| 1204 | // onEntryChanged handles text input changes with debouncing. | 1194 | // onEntryChanged handles text input changes with debouncing. | ||
| 1205 | // When this fires, GTK has already processed the keystroke — if ghost text was | 1195 | // When this fires, GTK has already processed the keystroke — if ghost text was | ||
| 1206 | // selected and the user typed, the selection was replaced naturally by GTK. | 1196 | // selected and the user typed, the selection was replaced naturally by GTK. | ||
| @@ -1453,19 +1443,6 @@ func (o *Omnibox) updateGhostFromSelection() { | |||||
| 1453 | o.updateGhostFromSelectionWithInput(query) | 1443 | o.updateGhostFromSelectionWithInput(query) | ||
| 1454 | } | 1444 | } | ||
| 1455 | 1445 | ||||
| 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 | - | ||||
| 1469 | // restoreEntryToRealInput strips any intact ghost suffix from the entry buffer | 1446 | // restoreEntryToRealInput strips any intact ghost suffix from the entry buffer | ||
| 1470 | // and resyncs realInput from the buffer. It never writes stale realInput over | 1447 | // and resyncs realInput from the buffer. It never writes stale realInput over | ||
| 1471 | // the buffer (see reconcileEntryState). | 1448 | // the buffer (see reconcileEntryState). | ||
| @@ -1552,152 +1529,27 @@ func (o *Omnibox) resolveGhostCompletion( | |||||
| 1552 | favorites []Favorite, | 1529 | favorites []Favorite, | ||
| 1553 | ) { | 1530 | ) { | ||
| 1554 | log := logging.FromContext(o.ctx) | 1531 | 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 | + | }) | |||
| 1556 | if !ok { | 1541 | if !ok { | ||
| 1557 | - | log.Debug().Str("entryText", entryText).Msg("ghost: resolveGhost — no completion input") | 1542 | + | log.Debug().Str("entryText", entryText).Msg("ghost: resolveGhost — no completion") |
| 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") | |||
| 1577 | return | 1543 | return | ||
| 1578 | } | 1544 | } | ||
| 1579 | log.Debug(). | 1545 | log.Debug(). | ||
| 1580 | - | Str("input", completionInput). | 1546 | + | Str("entryText", entryText). |
| 1581 | Str("fullText", fullText). | 1547 | Str("fullText", fullText). | ||
| 1582 | Str("suffix", suffix). | 1548 | Str("suffix", suffix). | ||
| 1583 | Msg("ghost: resolveGhost — applying") | 1549 | Msg("ghost: resolveGhost — applying") | ||
| 1584 | o.setGhostText(entryText, suffix) | 1550 | o.setGhostText(entryText, suffix) | ||
| 1585 | } | 1551 | } | ||
| 1586 | 1552 | ||||
| 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 | - | ||||
| 1701 | // performSearch executes the search based on current view mode and query. | 1553 | // performSearch executes the search based on current view mode and query. | ||
| 1702 | func (o *Omnibox) performSearch() { | 1554 | func (o *Omnibox) performSearch() { | ||
| 1703 | o.mu.RLock() | 1555 | o.mu.RLock() | ||
| @@ -1751,13 +1603,6 @@ func (o *Omnibox) performSearch() { | |||||
| 1751 | o.searchHistory(query, o.effectiveMaxRows(), token) | 1603 | o.searchHistory(query, o.effectiveMaxRows(), token) | ||
| 1752 | } | 1604 | } | ||
| 1753 | 1605 | ||||
| 1754 | - | func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string { | |||
| 1755 | - | if hasGhost && realInput != "" { | |||
| 1756 | - | return realInput | |||
| 1757 | - | } | |||
| 1758 | - | return entryText | |||
| 1759 | - | } | |||
| 1760 | - | ||||
| 1761 | // searchHistory runs a fuzzy history search in a background goroutine. | 1606 | // searchHistory runs a fuzzy history search in a background goroutine. | ||
| 1762 | // query is the search text; limit caps the number of results. | 1607 | // query is the search text; limit caps the number of results. | ||
| 1763 | func (o *Omnibox) searchHistory(query string, limit int, token uint64) { | 1608 | func (o *Omnibox) searchHistory(query string, limit int, token uint64) { | ||
| @@ -2535,29 +2380,6 @@ func (o *Omnibox) navigateToSelected() { | |||||
| 2535 | o.submitNavigation(targetURL) | 2380 | o.submitNavigation(targetURL) | ||
| 2536 | } | 2381 | } | ||
| 2537 | 2382 | ||||
| 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 | - | ||||
| 2561 | func resolveFavoriteToggleTarget( | 2383 | func resolveFavoriteToggleTarget( | ||
| 2562 | mode ViewMode, | 2384 | mode ViewMode, | ||
| 2563 | idx int, | 2385 | 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 { | |||||
| 142 | Request port.PopupRequest | 142 | Request port.PopupRequest | ||
| 143 | ObserveOAuthAutoClose bool | 143 | ObserveOAuthAutoClose bool | ||
| 144 | AllowBrowserWindowFallback bool | 144 | 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 | |||
| 145 | // OnNativeHostAbort returns true when fallback adopted the WebView. A false | 149 | // OnNativeHostAbort returns true when fallback adopted the WebView. A false | ||
| 146 | // result leaves destruction responsibility with the native host. | 150 | // result leaves destruction responsibility with the native host. | ||
| 147 | OnNativeHostAbort func(context.Context, port.WebView) bool | 151 | 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) { | |||||
| 477 | assert.Equal(t, popupWV, input.PopupWebView) | 477 | assert.Equal(t, popupWV, input.PopupWebView) | ||
| 478 | assert.Equal(t, "https://accounts.google.com/o/oauth2/v2/auth", input.TargetURI) | 478 | assert.Equal(t, "https://accounts.google.com/o/oauth2/v2/auth", input.TargetURI) | ||
| 479 | assert.True(t, input.ObserveOAuthAutoClose) | 479 | assert.True(t, input.ObserveOAuthAutoClose) | ||
| 480 | + | assert.False(t, input.AlreadyReadyToShow, "immediate popups wait for their own ready-to-show") | |||
| 480 | return nil | 481 | return nil | ||
| 481 | }) | 482 | }) | ||
| 482 | 483 | ||||
M internal/ui/coordinator/content/popup_hosting.go +6 -3
| @@ -170,7 +170,7 @@ func (pm *popupManager) openNativePopup( | |||||
| 170 | pm.setBrowsingContextDecision(popupWV, decision) | 170 | pm.setBrowsingContextDecision(popupWV, decision) | ||
| 171 | if !pm.adoptPopupInNativePopup( | 171 | if !pm.adoptPopupInNativePopup( | ||
| 172 | ctx, hooks, parentPaneID, parentID, parentURIAtOpen, popupWV, req, decision, | 172 | ctx, hooks, parentPaneID, parentID, parentURIAtOpen, popupWV, req, decision, | ||
| 173 | - | dto.BrowsingContextFailureNativeArm, | 173 | + | dto.BrowsingContextFailureNativeArm, false, |
| 174 | ) { | 174 | ) { | ||
| 175 | popupWV.Destroy() | 175 | popupWV.Destroy() | ||
| 176 | return nil | 176 | return nil | ||
| @@ -179,7 +179,8 @@ func (pm *popupManager) openNativePopup( | |||||
| 179 | } | 179 | } | ||
| 180 | 180 | ||||
| 181 | // openExistingPopupInNativePopup attempts to transfer an already-created | 181 | // 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. | |||
| 183 | func (pm *popupManager) openExistingPopupInNativePopup( | 184 | func (pm *popupManager) openExistingPopupInNativePopup( | ||
| 184 | ctx context.Context, | 185 | ctx context.Context, | ||
| 185 | hooks popupCoordinatorHooks, | 186 | hooks popupCoordinatorHooks, | ||
| @@ -192,7 +193,7 @@ func (pm *popupManager) openExistingPopupInNativePopup( | |||||
| 192 | ) bool { | 193 | ) bool { | ||
| 193 | return pm.adoptPopupInNativePopup( | 194 | return pm.adoptPopupInNativePopup( | ||
| 194 | ctx, hooks, parentPaneID, parentWebViewID, parentURIAtOpen, popupWV, req, decision, | 195 | ctx, hooks, parentPaneID, parentWebViewID, parentURIAtOpen, popupWV, req, decision, | ||
| 195 | - | dto.BrowsingContextFailureHostFailed, | 196 | + | dto.BrowsingContextFailureHostFailed, true, |
| 196 | ) | 197 | ) | ||
| 197 | } | 198 | } | ||
| 198 | 199 | ||||
| @@ -206,6 +207,7 @@ func (pm *popupManager) adoptPopupInNativePopup( | |||||
| 206 | req port.PopupRequest, | 207 | req port.PopupRequest, | ||
| 207 | decision dto.HostDecision, | 208 | decision dto.HostDecision, | ||
| 208 | failureCode dto.BrowsingContextFailureCode, | 209 | failureCode dto.BrowsingContextFailureCode, | ||
| 210 | + | alreadyReadyToShow bool, | |||
| 209 | ) bool { | 211 | ) bool { | ||
| 210 | log := logging.FromContext(ctx) | 212 | log := logging.FromContext(ctx) | ||
| 211 | normalized := buildPopupBrowsingContextRequest(req) | 213 | normalized := buildPopupBrowsingContextRequest(req) | ||
| @@ -236,6 +238,7 @@ func (pm *popupManager) adoptPopupInNativePopup( | |||||
| 236 | PopupWebView: popupWV, TargetURI: req.TargetURI, Request: req, | 238 | PopupWebView: popupWV, TargetURI: req.TargetURI, Request: req, | ||
| 237 | ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI), | 239 | ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI), | ||
| 238 | AllowBrowserWindowFallback: allowFallback, OnNativeHostAbort: onAbort, | 240 | AllowBrowserWindowFallback: allowFallback, OnNativeHostAbort: onAbort, | ||
| 241 | + | AlreadyReadyToShow: alreadyReadyToShow, | |||
| 239 | }); err != nil { | 242 | }); err != nil { | ||
| 240 | logBrowsingContextFailure(*log, normalized, decision, failureCode, err) | 243 | logBrowsingContextFailure(*log, normalized, decision, failureCode, err) | ||
| 241 | return false | 244 | return false | ||
M internal/ui/coordinator/content/popup_lifecycle_test.go +1 -0
| @@ -358,6 +358,7 @@ func TestPopupDeferredFeaturedDetachesBeforeNativeHost(t *testing.T) { | |||||
| 358 | assert.True(t, staging.detached) | 358 | assert.True(t, staging.detached) | ||
| 359 | assert.Equal(t, dto.PopupFeaturesSpecified, input.Request.PopupFeatures.State) | 359 | assert.Equal(t, dto.PopupFeaturesSpecified, input.Request.PopupFeatures.State) | ||
| 360 | assert.Same(t, popup, input.PopupWebView) | 360 | assert.Same(t, popup, input.PopupWebView) | ||
| 361 | + | assert.True(t, input.AlreadyReadyToShow, "deferred popups have already fired ready-to-show") | |||
| 361 | return nil | 362 | return nil | ||
| 362 | }) | 363 | }) | ||
| 363 | 364 | ||||
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 { | |||||
| 490 | if a == nil { | 490 | if a == nil { | ||
| 491 | return true | 491 | return true | ||
| 492 | } | 492 | } | ||
| 493 | - | if eng, ok := a.engine.(interface{ RuntimeActivity() port.RuntimeActivity }); ok && eng != nil { | 493 | + | if act := port.EngineRuntimeActivity(a.engine); act != nil && !act.Snapshot().Quiescent() { |
| 494 | - | if act := eng.RuntimeActivity(); act != nil && !act.Snapshot().Quiescent() { | 494 | + | return false |
| 495 | - | return false | |||
| 496 | - | } | |||
| 497 | } | 495 | } | ||
| 498 | if drain, ok := a.snapshotService.(port.PersistenceDrain); ok && drain != nil && drain.Active() { | 496 | if drain, ok := a.snapshotService.(port.PersistenceDrain); ok && drain != nil && drain.Active() { | ||
| 499 | return false | 497 | return false | ||
| @@ -519,14 +517,12 @@ func (a *App) subscribeResidencyQuiescence() { | |||||
| 519 | return | 517 | return | ||
| 520 | } | 518 | } | ||
| 521 | dispatch := func(fn func()) { r.DispatchToGTK(fn) } | 519 | dispatch := func(fn func()) { r.DispatchToGTK(fn) } | ||
| 522 | - | if eng, ok := a.engine.(interface{ RuntimeActivity() port.RuntimeActivity }); ok && eng != nil { | 520 | + | if act := port.EngineRuntimeActivity(a.engine); act != nil { |
| 523 | - | if act := eng.RuntimeActivity(); act != nil { | 521 | + | unsubscribe := act.Subscribe(func(snapshot port.RuntimeActivitySnapshot) { |
| 524 | - | unsubscribe := act.Subscribe(func(snapshot port.RuntimeActivitySnapshot) { | 522 | + | busy := !snapshot.Quiescent() |
| 525 | - | busy := !snapshot.Quiescent() | 523 | + | dispatch(func() { r.SetNativeBusy("engine-activity", busy) }) |
| 526 | - | dispatch(func() { r.SetNativeBusy("cef-activity", busy) }) | 524 | + | }) |
| 527 | - | }) | 525 | + | a.residencyUnsubscribe = append(a.residencyUnsubscribe, unsubscribe) |
| 528 | - | a.residencyUnsubscribe = append(a.residencyUnsubscribe, unsubscribe) | |||
| 529 | - | } | |||
| 530 | } | 526 | } | ||
| 531 | if drain, ok := a.snapshotService.(port.PersistenceDrain); ok && drain != nil { | 527 | if drain, ok := a.snapshotService.(port.PersistenceDrain); ok && drain != nil { | ||
| 532 | if notifier, ok := drain.(interface{ OnSettled(func()) }); ok { | 528 | 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 | + | } | |||