feat(vim): add link hints, visual selection, and text-object yanks
Vim Mode gains f/F/yf link hints, v visual selection with motions and y, and yap/yac/yat/yal text-object yanks alongside yah. A per-interaction token authorizes one runtime result through dumb:///api/vim-page, so the shared bridge nonce is never exposed to page-reachable calls.
- commit
- a94e48ad7a64716676a58ef92914bc18752ace1e
- author
- brice · · 2026-09-26 13:00 UTC
- committer
- brice · · 2026-10-10 08:27 UTC
- parent
- aefccc8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
31 files · +1798 -36
M .mockery.yaml +1 -0
| @@ -61,6 +61,7 @@ packages: | |||
| 61 | 61 | PageNavigator: {} | |
| 62 | 62 | WebView: {} | |
| 63 | 63 | PageScrollable: {} | |
| 64 | + | VimPageInteractor: {} | |
| 64 | 65 | WebViewFactory: {} | |
| 65 | 66 | WebViewPool: {} | |
| 66 | 67 | MemoryPressureApplier: {} | |
| @@ -61,6 +61,7 @@ packages: | |||||
| 61 | PageNavigator: {} | 61 | PageNavigator: {} | ||
| 62 | WebView: {} | 62 | WebView: {} | ||
| 63 | PageScrollable: {} | 63 | PageScrollable: {} | ||
| 64 | + | VimPageInteractor: {} | |||
| 64 | WebViewFactory: {} | 65 | WebViewFactory: {} | ||
| 65 | WebViewPool: {} | 66 | WebViewPool: {} | ||
| 66 | MemoryPressureApplier: {} | 67 | MemoryPressureApplier: {} | ||
M docs/config/index.md +8 -0
| @@ -463,6 +463,14 @@ desc = "Jump to next heading" | |||
| 463 | 463 | keys = ["[["] | |
| 464 | 464 | desc = "Jump to previous heading" | |
| 465 | 465 | ||
| 466 | + | [workspace.vim_mode.actions.hint-follow] | |
| 467 | + | keys = ["f"] | |
| 468 | + | desc = "Show hints and follow a link or control" | |
| 469 | + | ||
| 470 | + | [workspace.vim_mode.actions.visual] | |
| 471 | + | keys = ["v"] | |
| 472 | + | desc = "Start visual selection (motions extend, y yanks)" | |
| 473 | + | ||
| 466 | 474 | [workspace.vim_mode.actions.confirm] | |
| 467 | 475 | keys = ["enter"] | |
| 468 | 476 | desc = "Open selected heading link and exit mode" | |
| @@ -463,6 +463,14 @@ desc = "Jump to next heading" | |||||
| 463 | keys = ["[["] | 463 | keys = ["[["] | ||
| 464 | desc = "Jump to previous heading" | 464 | desc = "Jump to previous heading" | ||
| 465 | 465 | ||||
| 466 | + | [workspace.vim_mode.actions.hint-follow] | |||
| 467 | + | keys = ["f"] | |||
| 468 | + | desc = "Show hints and follow a link or control" | |||
| 469 | + | ||||
| 470 | + | [workspace.vim_mode.actions.visual] | |||
| 471 | + | keys = ["v"] | |||
| 472 | + | desc = "Start visual selection (motions extend, y yanks)" | |||
| 473 | + | ||||
| 466 | [workspace.vim_mode.actions.confirm] | 474 | [workspace.vim_mode.actions.confirm] | ||
| 467 | keys = ["enter"] | 475 | keys = ["enter"] | ||
| 468 | desc = "Open selected heading link and exit mode" | 476 | desc = "Open selected heading link and exit mode" | ||
M docs/reference/keybindings.md +17 -0
| @@ -64,6 +64,12 @@ CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with th | |||
| 64 | 64 | ||
| 65 | 65 | `gi` focuses the next visible, editable page input. With no eligible input focused it starts at the first one; repeated `gi` commands cycle through the inputs. `]]` and `[[` move through visible `h1`–`h6` headings and outline the selected heading with the current Dumber theme accent. `Enter` activates the first link inside the selected heading, or a link wrapping that heading, then leaves Vim Mode; without an associated link, it only leaves the mode. `Escape` leaves without activation. When a page input is focused, `Tab` and `Shift+Tab` keep focus traversal inside the page; traversal stops safely at the page boundaries instead of moving into the host window. These live input, heading, activation, and page-focus actions currently use the CEF engine path; the WebKit fallback supports Vim scrolling but not these semantic actions yet. | |
| 66 | 66 | ||
| 67 | + | Link hints label the visible targets in the viewport; type a label to pick one, `Backspace` to undo a letter, and `Escape` to close the hints and stay in Vim Mode. `f` follows a link or activates a control, `F` opens a link in a new pane, and `yf` copies a link URL. | |
| 68 | + | ||
| 69 | + | `v` starts a visual selection from the current page selection, or from the first visible text. Motions extend it: `h/j/k/l`, `w/b/e`, `0`/`^`/`$`, `(`/`)` for sentences, `{`/`}` for paragraphs, `gg`/`G` for the document edges, and a count prefix such as `3w`. `o` swaps the selection ends, `y` or `Enter` copies and ends the selection, and `v` or `Escape` cancels it. | |
| 70 | + | ||
| 71 | + | Text-object yanks copy one block without selecting it: `yah` copies the current section (the heading nearest the top of the viewport through the next heading of the same or a higher level), and `yap`, `yac`, `yat`, and `yal` copy the first visible paragraph, code block, table, or list. The copied block flashes with the theme accent. Hints, visual selection, and yanks use the CEF engine path. | |
| 72 | + | ||
| 67 | 73 | | Action | Keys | | |
| 68 | 74 | |--------|------| | |
| 69 | 75 | | Scroll left | `H` | | |
| @@ -76,6 +82,11 @@ CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with th | |||
| 76 | 82 | | Next heading | `]]` | | |
| 77 | 83 | | Previous heading | `[[` | | |
| 78 | 84 | | Page focus traversal | `Tab`, `Shift+Tab` | | |
| 85 | + | | Follow link or control (hints) | `f` | | |
| 86 | + | | Open link in new pane (hints) | `F` | | |
| 87 | + | | Yank link URL (hints) | `yf` | | |
| 88 | + | | Visual selection | `v` | | |
| 89 | + | | Yank section / paragraph / code / table / list | `yah`, `yap`, `yac`, `yat`, `yal` | | |
| 79 | 90 | | Activate selected heading link and exit | `Enter` | | |
| 80 | 91 | | Exit without activation | `Escape` | | |
| 81 | 92 | ||
| @@ -225,6 +236,12 @@ keys = ["]]"] | |||
| 225 | 236 | [workspace.vim_mode.actions.heading-prev] | |
| 226 | 237 | keys = ["[["] | |
| 227 | 238 | ||
| 239 | + | [workspace.vim_mode.actions.hint-follow] | |
| 240 | + | keys = ["f"] | |
| 241 | + | ||
| 242 | + | [workspace.vim_mode.actions.visual] | |
| 243 | + | keys = ["v"] | |
| 244 | + | ||
| 228 | 245 | [workspace.shortcuts.actions.close-pane] | |
| 229 | 246 | keys = ["ctrl+w"] | |
| 230 | 247 | ||
| @@ -64,6 +64,12 @@ CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with th | |||||
| 64 | 64 | ||||
| 65 | `gi` focuses the next visible, editable page input. With no eligible input focused it starts at the first one; repeated `gi` commands cycle through the inputs. `]]` and `[[` move through visible `h1`–`h6` headings and outline the selected heading with the current Dumber theme accent. `Enter` activates the first link inside the selected heading, or a link wrapping that heading, then leaves Vim Mode; without an associated link, it only leaves the mode. `Escape` leaves without activation. When a page input is focused, `Tab` and `Shift+Tab` keep focus traversal inside the page; traversal stops safely at the page boundaries instead of moving into the host window. These live input, heading, activation, and page-focus actions currently use the CEF engine path; the WebKit fallback supports Vim scrolling but not these semantic actions yet. | 65 | `gi` focuses the next visible, editable page input. With no eligible input focused it starts at the first one; repeated `gi` commands cycle through the inputs. `]]` and `[[` move through visible `h1`–`h6` headings and outline the selected heading with the current Dumber theme accent. `Enter` activates the first link inside the selected heading, or a link wrapping that heading, then leaves Vim Mode; without an associated link, it only leaves the mode. `Escape` leaves without activation. When a page input is focused, `Tab` and `Shift+Tab` keep focus traversal inside the page; traversal stops safely at the page boundaries instead of moving into the host window. These live input, heading, activation, and page-focus actions currently use the CEF engine path; the WebKit fallback supports Vim scrolling but not these semantic actions yet. | ||
| 66 | 66 | ||||
| 67 | + | Link hints label the visible targets in the viewport; type a label to pick one, `Backspace` to undo a letter, and `Escape` to close the hints and stay in Vim Mode. `f` follows a link or activates a control, `F` opens a link in a new pane, and `yf` copies a link URL. | |||
| 68 | + | ||||
| 69 | + | `v` starts a visual selection from the current page selection, or from the first visible text. Motions extend it: `h/j/k/l`, `w/b/e`, `0`/`^`/`$`, `(`/`)` for sentences, `{`/`}` for paragraphs, `gg`/`G` for the document edges, and a count prefix such as `3w`. `o` swaps the selection ends, `y` or `Enter` copies and ends the selection, and `v` or `Escape` cancels it. | |||
| 70 | + | ||||
| 71 | + | Text-object yanks copy one block without selecting it: `yah` copies the current section (the heading nearest the top of the viewport through the next heading of the same or a higher level), and `yap`, `yac`, `yat`, and `yal` copy the first visible paragraph, code block, table, or list. The copied block flashes with the theme accent. Hints, visual selection, and yanks use the CEF engine path. | |||
| 72 | + | ||||
| 67 | | Action | Keys | | 73 | | Action | Keys | | ||
| 68 | |--------|------| | 74 | |--------|------| | ||
| 69 | | Scroll left | `H` | | 75 | | Scroll left | `H` | | ||
| @@ -76,6 +82,11 @@ CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with th | |||||
| 76 | | Next heading | `]]` | | 82 | | Next heading | `]]` | | ||
| 77 | | Previous heading | `[[` | | 83 | | Previous heading | `[[` | | ||
| 78 | | Page focus traversal | `Tab`, `Shift+Tab` | | 84 | | Page focus traversal | `Tab`, `Shift+Tab` | | ||
| 85 | + | | Follow link or control (hints) | `f` | | |||
| 86 | + | | Open link in new pane (hints) | `F` | | |||
| 87 | + | | Yank link URL (hints) | `yf` | | |||
| 88 | + | | Visual selection | `v` | | |||
| 89 | + | | Yank section / paragraph / code / table / list | `yah`, `yap`, `yac`, `yat`, `yal` | | |||
| 79 | | Activate selected heading link and exit | `Enter` | | 90 | | Activate selected heading link and exit | `Enter` | | ||
| 80 | | Exit without activation | `Escape` | | 91 | | Exit without activation | `Escape` | | ||
| 81 | 92 | ||||
| @@ -225,6 +236,12 @@ keys = ["]]"] | |||||
| 225 | [workspace.vim_mode.actions.heading-prev] | 236 | [workspace.vim_mode.actions.heading-prev] | ||
| 226 | keys = ["[["] | 237 | keys = ["[["] | ||
| 227 | 238 | ||||
| 239 | + | [workspace.vim_mode.actions.hint-follow] | |||
| 240 | + | keys = ["f"] | |||
| 241 | + | ||||
| 242 | + | [workspace.vim_mode.actions.visual] | |||
| 243 | + | keys = ["v"] | |||
| 244 | + | ||||
| 228 | [workspace.shortcuts.actions.close-pane] | 245 | [workspace.shortcuts.actions.close-pane] | ||
| 229 | keys = ["ctrl+w"] | 246 | keys = ["ctrl+w"] | ||
| 230 | 247 | ||||
A internal/application/dto/vim_page_interaction.go +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | package dto | |
| 2 | + | ||
| 3 | + | // VimPageInteractionKind identifies an in-page Vim Mode interaction such as | |
| 4 | + | // link hints, visual selection, or a one-shot text-object yank. | |
| 5 | + | type VimPageInteractionKind int | |
| 6 | + | ||
| 7 | + | const ( | |
| 8 | + | // VimPageHintFollow labels visible clickable elements and activates the chosen one. | |
| 9 | + | VimPageHintFollow VimPageInteractionKind = iota + 1 | |
| 10 | + | // VimPageHintFollowNew labels visible links and opens the chosen one in a new pane. | |
| 11 | + | VimPageHintFollowNew | |
| 12 | + | // VimPageHintYankURL labels visible links and copies the chosen URL. | |
| 13 | + | VimPageHintYankURL | |
| 14 | + | // VimPageVisual starts a visual selection that motions extend and y copies. | |
| 15 | + | VimPageVisual | |
| 16 | + | // VimPageYankObject copies one structural text object immediately. | |
| 17 | + | VimPageYankObject | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // CapturesKeys reports whether the interaction consumes page keys until the | |
| 21 | + | // page reports its end. One-shot yanks complete without key capture. | |
| 22 | + | func (k VimPageInteractionKind) CapturesKeys() bool { | |
| 23 | + | switch k { | |
| 24 | + | case VimPageHintFollow, VimPageHintFollowNew, VimPageHintYankURL, VimPageVisual: | |
| 25 | + | return true | |
| 26 | + | default: | |
| 27 | + | return false | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // VimPageTextObject identifies the structural block copied by a text-object yank. | |
| 32 | + | type VimPageTextObject int | |
| 33 | + | ||
| 34 | + | const ( | |
| 35 | + | VimPageTextObjectSection VimPageTextObject = iota + 1 | |
| 36 | + | VimPageTextObjectParagraph | |
| 37 | + | VimPageTextObjectCode | |
| 38 | + | VimPageTextObjectTable | |
| 39 | + | VimPageTextObjectList | |
| 40 | + | ) | |
| 41 | + | ||
| 42 | + | // VimPageInteractionRequest describes one in-page Vim interaction. | |
| 43 | + | type VimPageInteractionRequest struct { | |
| 44 | + | Kind VimPageInteractionKind | |
| 45 | + | Object VimPageTextObject | |
| 46 | + | HighlightColor string | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // VimNavigationOutcome tells the UI how to continue after a Vim action. | |
| 50 | + | type VimNavigationOutcome struct { | |
| 51 | + | // CapturePageKeys asks the UI to forward keys to the page interaction until | |
| 52 | + | // the page reports that the interaction ended. | |
| 53 | + | CapturePageKeys bool | |
| 54 | + | } | |
| @@ -0,0 +1,54 @@ | |||||
| 1 | + | package dto | |||
| 2 | + | ||||
| 3 | + | // VimPageInteractionKind identifies an in-page Vim Mode interaction such as | |||
| 4 | + | // link hints, visual selection, or a one-shot text-object yank. | |||
| 5 | + | type VimPageInteractionKind int | |||
| 6 | + | ||||
| 7 | + | const ( | |||
| 8 | + | // VimPageHintFollow labels visible clickable elements and activates the chosen one. | |||
| 9 | + | VimPageHintFollow VimPageInteractionKind = iota + 1 | |||
| 10 | + | // VimPageHintFollowNew labels visible links and opens the chosen one in a new pane. | |||
| 11 | + | VimPageHintFollowNew | |||
| 12 | + | // VimPageHintYankURL labels visible links and copies the chosen URL. | |||
| 13 | + | VimPageHintYankURL | |||
| 14 | + | // VimPageVisual starts a visual selection that motions extend and y copies. | |||
| 15 | + | VimPageVisual | |||
| 16 | + | // VimPageYankObject copies one structural text object immediately. | |||
| 17 | + | VimPageYankObject | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // CapturesKeys reports whether the interaction consumes page keys until the | |||
| 21 | + | // page reports its end. One-shot yanks complete without key capture. | |||
| 22 | + | func (k VimPageInteractionKind) CapturesKeys() bool { | |||
| 23 | + | switch k { | |||
| 24 | + | case VimPageHintFollow, VimPageHintFollowNew, VimPageHintYankURL, VimPageVisual: | |||
| 25 | + | return true | |||
| 26 | + | default: | |||
| 27 | + | return false | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // VimPageTextObject identifies the structural block copied by a text-object yank. | |||
| 32 | + | type VimPageTextObject int | |||
| 33 | + | ||||
| 34 | + | const ( | |||
| 35 | + | VimPageTextObjectSection VimPageTextObject = iota + 1 | |||
| 36 | + | VimPageTextObjectParagraph | |||
| 37 | + | VimPageTextObjectCode | |||
| 38 | + | VimPageTextObjectTable | |||
| 39 | + | VimPageTextObjectList | |||
| 40 | + | ) | |||
| 41 | + | ||||
| 42 | + | // VimPageInteractionRequest describes one in-page Vim interaction. | |||
| 43 | + | type VimPageInteractionRequest struct { | |||
| 44 | + | Kind VimPageInteractionKind | |||
| 45 | + | Object VimPageTextObject | |||
| 46 | + | HighlightColor string | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // VimNavigationOutcome tells the UI how to continue after a Vim action. | |||
| 50 | + | type VimNavigationOutcome struct { | |||
| 51 | + | // CapturePageKeys asks the UI to forward keys to the page interaction until | |||
| 52 | + | // the page reports that the interaction ended. | |||
| 53 | + | CapturePageKeys bool | |||
| 54 | + | } | |||
M internal/application/port/mocks/webview.go +201 -0
| @@ -3724,3 +3724,204 @@ func (_c *MockPageScrollable_ScrollPage_Call) RunAndReturn(run func(ctx context. | |||
| 3724 | 3724 | _c.Call.Return(run) | |
| 3725 | 3725 | return _c | |
| 3726 | 3726 | } | |
| 3727 | + | ||
| 3728 | + | // NewMockVimPageInteractor creates a new instance of MockVimPageInteractor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 3729 | + | // The first argument is typically a *testing.T value. | |
| 3730 | + | func NewMockVimPageInteractor(t interface { | |
| 3731 | + | mock.TestingT | |
| 3732 | + | Cleanup(func()) | |
| 3733 | + | }) *MockVimPageInteractor { | |
| 3734 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 3735 | + | helper.Helper() | |
| 3736 | + | } | |
| 3737 | + | ||
| 3738 | + | mock := &MockVimPageInteractor{} | |
| 3739 | + | mock.Mock.Test(t) | |
| 3740 | + | ||
| 3741 | + | t.Cleanup(func() { | |
| 3742 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 3743 | + | helper.Helper() | |
| 3744 | + | } | |
| 3745 | + | mock.AssertExpectations(t) | |
| 3746 | + | }) | |
| 3747 | + | ||
| 3748 | + | return mock | |
| 3749 | + | } | |
| 3750 | + | ||
| 3751 | + | // MockVimPageInteractor is an autogenerated mock type for the VimPageInteractor type | |
| 3752 | + | type MockVimPageInteractor struct { | |
| 3753 | + | mock.Mock | |
| 3754 | + | } | |
| 3755 | + | ||
| 3756 | + | type MockVimPageInteractor_Expecter struct { | |
| 3757 | + | mock *mock.Mock | |
| 3758 | + | } | |
| 3759 | + | ||
| 3760 | + | func (_m *MockVimPageInteractor) EXPECT() *MockVimPageInteractor_Expecter { | |
| 3761 | + | return &MockVimPageInteractor_Expecter{mock: &_m.Mock} | |
| 3762 | + | } | |
| 3763 | + | ||
| 3764 | + | // CancelVimPageInteraction provides a mock function for the type MockVimPageInteractor | |
| 3765 | + | func (_mock *MockVimPageInteractor) CancelVimPageInteraction(ctx context.Context) error { | |
| 3766 | + | ret := _mock.Called(ctx) | |
| 3767 | + | ||
| 3768 | + | if len(ret) == 0 { | |
| 3769 | + | panic("no return value specified for CancelVimPageInteraction") | |
| 3770 | + | } | |
| 3771 | + | ||
| 3772 | + | var r0 error | |
| 3773 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { | |
| 3774 | + | r0 = returnFunc(ctx) | |
| 3775 | + | } else { | |
| 3776 | + | r0 = ret.Error(0) | |
| 3777 | + | } | |
| 3778 | + | return r0 | |
| 3779 | + | } | |
| 3780 | + | ||
| 3781 | + | // MockVimPageInteractor_CancelVimPageInteraction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CancelVimPageInteraction' | |
| 3782 | + | type MockVimPageInteractor_CancelVimPageInteraction_Call struct { | |
| 3783 | + | *mock.Call | |
| 3784 | + | } | |
| 3785 | + | ||
| 3786 | + | // CancelVimPageInteraction is a helper method to define mock.On call | |
| 3787 | + | // - ctx context.Context | |
| 3788 | + | func (_e *MockVimPageInteractor_Expecter) CancelVimPageInteraction(ctx any) *MockVimPageInteractor_CancelVimPageInteraction_Call { | |
| 3789 | + | return &MockVimPageInteractor_CancelVimPageInteraction_Call{Call: _e.mock.On("CancelVimPageInteraction", ctx)} | |
| 3790 | + | } | |
| 3791 | + | ||
| 3792 | + | func (_c *MockVimPageInteractor_CancelVimPageInteraction_Call) Run(run func(ctx context.Context)) *MockVimPageInteractor_CancelVimPageInteraction_Call { | |
| 3793 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 3794 | + | var arg0 context.Context | |
| 3795 | + | if args[0] != nil { | |
| 3796 | + | arg0 = args[0].(context.Context) | |
| 3797 | + | } | |
| 3798 | + | run( | |
| 3799 | + | arg0, | |
| 3800 | + | ) | |
| 3801 | + | }) | |
| 3802 | + | return _c | |
| 3803 | + | } | |
| 3804 | + | ||
| 3805 | + | func (_c *MockVimPageInteractor_CancelVimPageInteraction_Call) Return(err error) *MockVimPageInteractor_CancelVimPageInteraction_Call { | |
| 3806 | + | _c.Call.Return(err) | |
| 3807 | + | return _c | |
| 3808 | + | } | |
| 3809 | + | ||
| 3810 | + | func (_c *MockVimPageInteractor_CancelVimPageInteraction_Call) RunAndReturn(run func(ctx context.Context) error) *MockVimPageInteractor_CancelVimPageInteraction_Call { | |
| 3811 | + | _c.Call.Return(run) | |
| 3812 | + | return _c | |
| 3813 | + | } | |
| 3814 | + | ||
| 3815 | + | // SendVimPageKey provides a mock function for the type MockVimPageInteractor | |
| 3816 | + | func (_mock *MockVimPageInteractor) SendVimPageKey(ctx context.Context, key string) error { | |
| 3817 | + | ret := _mock.Called(ctx, key) | |
| 3818 | + | ||
| 3819 | + | if len(ret) == 0 { | |
| 3820 | + | panic("no return value specified for SendVimPageKey") | |
| 3821 | + | } | |
| 3822 | + | ||
| 3823 | + | var r0 error | |
| 3824 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 3825 | + | r0 = returnFunc(ctx, key) | |
| 3826 | + | } else { | |
| 3827 | + | r0 = ret.Error(0) | |
| 3828 | + | } | |
| 3829 | + | return r0 | |
| 3830 | + | } | |
| 3831 | + | ||
| 3832 | + | // MockVimPageInteractor_SendVimPageKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendVimPageKey' | |
| 3833 | + | type MockVimPageInteractor_SendVimPageKey_Call struct { | |
| 3834 | + | *mock.Call | |
| 3835 | + | } | |
| 3836 | + | ||
| 3837 | + | // SendVimPageKey is a helper method to define mock.On call | |
| 3838 | + | // - ctx context.Context | |
| 3839 | + | // - key string | |
| 3840 | + | func (_e *MockVimPageInteractor_Expecter) SendVimPageKey(ctx any, key any) *MockVimPageInteractor_SendVimPageKey_Call { | |
| 3841 | + | return &MockVimPageInteractor_SendVimPageKey_Call{Call: _e.mock.On("SendVimPageKey", ctx, key)} | |
| 3842 | + | } | |
| 3843 | + | ||
| 3844 | + | func (_c *MockVimPageInteractor_SendVimPageKey_Call) Run(run func(ctx context.Context, key string)) *MockVimPageInteractor_SendVimPageKey_Call { | |
| 3845 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 3846 | + | var arg0 context.Context | |
| 3847 | + | if args[0] != nil { | |
| 3848 | + | arg0 = args[0].(context.Context) | |
| 3849 | + | } | |
| 3850 | + | var arg1 string | |
| 3851 | + | if args[1] != nil { | |
| 3852 | + | arg1 = args[1].(string) | |
| 3853 | + | } | |
| 3854 | + | run( | |
| 3855 | + | arg0, | |
| 3856 | + | arg1, | |
| 3857 | + | ) | |
| 3858 | + | }) | |
| 3859 | + | return _c | |
| 3860 | + | } | |
| 3861 | + | ||
| 3862 | + | func (_c *MockVimPageInteractor_SendVimPageKey_Call) Return(err error) *MockVimPageInteractor_SendVimPageKey_Call { | |
| 3863 | + | _c.Call.Return(err) | |
| 3864 | + | return _c | |
| 3865 | + | } | |
| 3866 | + | ||
| 3867 | + | func (_c *MockVimPageInteractor_SendVimPageKey_Call) RunAndReturn(run func(ctx context.Context, key string) error) *MockVimPageInteractor_SendVimPageKey_Call { | |
| 3868 | + | _c.Call.Return(run) | |
| 3869 | + | return _c | |
| 3870 | + | } | |
| 3871 | + | ||
| 3872 | + | // StartVimPageInteraction provides a mock function for the type MockVimPageInteractor | |
| 3873 | + | func (_mock *MockVimPageInteractor) StartVimPageInteraction(ctx context.Context, request dto.VimPageInteractionRequest) error { | |
| 3874 | + | ret := _mock.Called(ctx, request) | |
| 3875 | + | ||
| 3876 | + | if len(ret) == 0 { | |
| 3877 | + | panic("no return value specified for StartVimPageInteraction") | |
| 3878 | + | } | |
| 3879 | + | ||
| 3880 | + | var r0 error | |
| 3881 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, dto.VimPageInteractionRequest) error); ok { | |
| 3882 | + | r0 = returnFunc(ctx, request) | |
| 3883 | + | } else { | |
| 3884 | + | r0 = ret.Error(0) | |
| 3885 | + | } | |
| 3886 | + | return r0 | |
| 3887 | + | } | |
| 3888 | + | ||
| 3889 | + | // MockVimPageInteractor_StartVimPageInteraction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartVimPageInteraction' | |
| 3890 | + | type MockVimPageInteractor_StartVimPageInteraction_Call struct { | |
| 3891 | + | *mock.Call | |
| 3892 | + | } | |
| 3893 | + | ||
| 3894 | + | // StartVimPageInteraction is a helper method to define mock.On call | |
| 3895 | + | // - ctx context.Context | |
| 3896 | + | // - request dto.VimPageInteractionRequest | |
| 3897 | + | func (_e *MockVimPageInteractor_Expecter) StartVimPageInteraction(ctx any, request any) *MockVimPageInteractor_StartVimPageInteraction_Call { | |
| 3898 | + | return &MockVimPageInteractor_StartVimPageInteraction_Call{Call: _e.mock.On("StartVimPageInteraction", ctx, request)} | |
| 3899 | + | } | |
| 3900 | + | ||
| 3901 | + | func (_c *MockVimPageInteractor_StartVimPageInteraction_Call) Run(run func(ctx context.Context, request dto.VimPageInteractionRequest)) *MockVimPageInteractor_StartVimPageInteraction_Call { | |
| 3902 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 3903 | + | var arg0 context.Context | |
| 3904 | + | if args[0] != nil { | |
| 3905 | + | arg0 = args[0].(context.Context) | |
| 3906 | + | } | |
| 3907 | + | var arg1 dto.VimPageInteractionRequest | |
| 3908 | + | if args[1] != nil { | |
| 3909 | + | arg1 = args[1].(dto.VimPageInteractionRequest) | |
| 3910 | + | } | |
| 3911 | + | run( | |
| 3912 | + | arg0, | |
| 3913 | + | arg1, | |
| 3914 | + | ) | |
| 3915 | + | }) | |
| 3916 | + | return _c | |
| 3917 | + | } | |
| 3918 | + | ||
| 3919 | + | func (_c *MockVimPageInteractor_StartVimPageInteraction_Call) Return(err error) *MockVimPageInteractor_StartVimPageInteraction_Call { | |
| 3920 | + | _c.Call.Return(err) | |
| 3921 | + | return _c | |
| 3922 | + | } | |
| 3923 | + | ||
| 3924 | + | func (_c *MockVimPageInteractor_StartVimPageInteraction_Call) RunAndReturn(run func(ctx context.Context, request dto.VimPageInteractionRequest) error) *MockVimPageInteractor_StartVimPageInteraction_Call { | |
| 3925 | + | _c.Call.Return(run) | |
| 3926 | + | return _c | |
| 3927 | + | } | |
| @@ -3724,3 +3724,204 @@ func (_c *MockPageScrollable_ScrollPage_Call) RunAndReturn(run func(ctx context. | |||||
| 3724 | _c.Call.Return(run) | 3724 | _c.Call.Return(run) | ||
| 3725 | return _c | 3725 | return _c | ||
| 3726 | } | 3726 | } | ||
| 3727 | + | ||||
| 3728 | + | // NewMockVimPageInteractor creates a new instance of MockVimPageInteractor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 3729 | + | // The first argument is typically a *testing.T value. | |||
| 3730 | + | func NewMockVimPageInteractor(t interface { | |||
| 3731 | + | mock.TestingT | |||
| 3732 | + | Cleanup(func()) | |||
| 3733 | + | }) *MockVimPageInteractor { | |||
| 3734 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 3735 | + | helper.Helper() | |||
| 3736 | + | } | |||
| 3737 | + | ||||
| 3738 | + | mock := &MockVimPageInteractor{} | |||
| 3739 | + | mock.Mock.Test(t) | |||
| 3740 | + | ||||
| 3741 | + | t.Cleanup(func() { | |||
| 3742 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 3743 | + | helper.Helper() | |||
| 3744 | + | } | |||
| 3745 | + | mock.AssertExpectations(t) | |||
| 3746 | + | }) | |||
| 3747 | + | ||||
| 3748 | + | return mock | |||
| 3749 | + | } | |||
| 3750 | + | ||||
| 3751 | + | // MockVimPageInteractor is an autogenerated mock type for the VimPageInteractor type | |||
| 3752 | + | type MockVimPageInteractor struct { | |||
| 3753 | + | mock.Mock | |||
| 3754 | + | } | |||
| 3755 | + | ||||
| 3756 | + | type MockVimPageInteractor_Expecter struct { | |||
| 3757 | + | mock *mock.Mock | |||
| 3758 | + | } | |||
| 3759 | + | ||||
| 3760 | + | func (_m *MockVimPageInteractor) EXPECT() *MockVimPageInteractor_Expecter { | |||
| 3761 | + | return &MockVimPageInteractor_Expecter{mock: &_m.Mock} | |||
| 3762 | + | } | |||
| 3763 | + | ||||
| 3764 | + | // CancelVimPageInteraction provides a mock function for the type MockVimPageInteractor | |||
| 3765 | + | func (_mock *MockVimPageInteractor) CancelVimPageInteraction(ctx context.Context) error { | |||
| 3766 | + | ret := _mock.Called(ctx) | |||
| 3767 | + | ||||
| 3768 | + | if len(ret) == 0 { | |||
| 3769 | + | panic("no return value specified for CancelVimPageInteraction") | |||
| 3770 | + | } | |||
| 3771 | + | ||||
| 3772 | + | var r0 error | |||
| 3773 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { | |||
| 3774 | + | r0 = returnFunc(ctx) | |||
| 3775 | + | } else { | |||
| 3776 | + | r0 = ret.Error(0) | |||
| 3777 | + | } | |||
| 3778 | + | return r0 | |||
| 3779 | + | } | |||
| 3780 | + | ||||
| 3781 | + | // MockVimPageInteractor_CancelVimPageInteraction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CancelVimPageInteraction' | |||
| 3782 | + | type MockVimPageInteractor_CancelVimPageInteraction_Call struct { | |||
| 3783 | + | *mock.Call | |||
| 3784 | + | } | |||
| 3785 | + | ||||
| 3786 | + | // CancelVimPageInteraction is a helper method to define mock.On call | |||
| 3787 | + | // - ctx context.Context | |||
| 3788 | + | func (_e *MockVimPageInteractor_Expecter) CancelVimPageInteraction(ctx any) *MockVimPageInteractor_CancelVimPageInteraction_Call { | |||
| 3789 | + | return &MockVimPageInteractor_CancelVimPageInteraction_Call{Call: _e.mock.On("CancelVimPageInteraction", ctx)} | |||
| 3790 | + | } | |||
| 3791 | + | ||||
| 3792 | + | func (_c *MockVimPageInteractor_CancelVimPageInteraction_Call) Run(run func(ctx context.Context)) *MockVimPageInteractor_CancelVimPageInteraction_Call { | |||
| 3793 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 3794 | + | var arg0 context.Context | |||
| 3795 | + | if args[0] != nil { | |||
| 3796 | + | arg0 = args[0].(context.Context) | |||
| 3797 | + | } | |||
| 3798 | + | run( | |||
| 3799 | + | arg0, | |||
| 3800 | + | ) | |||
| 3801 | + | }) | |||
| 3802 | + | return _c | |||
| 3803 | + | } | |||
| 3804 | + | ||||
| 3805 | + | func (_c *MockVimPageInteractor_CancelVimPageInteraction_Call) Return(err error) *MockVimPageInteractor_CancelVimPageInteraction_Call { | |||
| 3806 | + | _c.Call.Return(err) | |||
| 3807 | + | return _c | |||
| 3808 | + | } | |||
| 3809 | + | ||||
| 3810 | + | func (_c *MockVimPageInteractor_CancelVimPageInteraction_Call) RunAndReturn(run func(ctx context.Context) error) *MockVimPageInteractor_CancelVimPageInteraction_Call { | |||
| 3811 | + | _c.Call.Return(run) | |||
| 3812 | + | return _c | |||
| 3813 | + | } | |||
| 3814 | + | ||||
| 3815 | + | // SendVimPageKey provides a mock function for the type MockVimPageInteractor | |||
| 3816 | + | func (_mock *MockVimPageInteractor) SendVimPageKey(ctx context.Context, key string) error { | |||
| 3817 | + | ret := _mock.Called(ctx, key) | |||
| 3818 | + | ||||
| 3819 | + | if len(ret) == 0 { | |||
| 3820 | + | panic("no return value specified for SendVimPageKey") | |||
| 3821 | + | } | |||
| 3822 | + | ||||
| 3823 | + | var r0 error | |||
| 3824 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |||
| 3825 | + | r0 = returnFunc(ctx, key) | |||
| 3826 | + | } else { | |||
| 3827 | + | r0 = ret.Error(0) | |||
| 3828 | + | } | |||
| 3829 | + | return r0 | |||
| 3830 | + | } | |||
| 3831 | + | ||||
| 3832 | + | // MockVimPageInteractor_SendVimPageKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendVimPageKey' | |||
| 3833 | + | type MockVimPageInteractor_SendVimPageKey_Call struct { | |||
| 3834 | + | *mock.Call | |||
| 3835 | + | } | |||
| 3836 | + | ||||
| 3837 | + | // SendVimPageKey is a helper method to define mock.On call | |||
| 3838 | + | // - ctx context.Context | |||
| 3839 | + | // - key string | |||
| 3840 | + | func (_e *MockVimPageInteractor_Expecter) SendVimPageKey(ctx any, key any) *MockVimPageInteractor_SendVimPageKey_Call { | |||
| 3841 | + | return &MockVimPageInteractor_SendVimPageKey_Call{Call: _e.mock.On("SendVimPageKey", ctx, key)} | |||
| 3842 | + | } | |||
| 3843 | + | ||||
| 3844 | + | func (_c *MockVimPageInteractor_SendVimPageKey_Call) Run(run func(ctx context.Context, key string)) *MockVimPageInteractor_SendVimPageKey_Call { | |||
| 3845 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 3846 | + | var arg0 context.Context | |||
| 3847 | + | if args[0] != nil { | |||
| 3848 | + | arg0 = args[0].(context.Context) | |||
| 3849 | + | } | |||
| 3850 | + | var arg1 string | |||
| 3851 | + | if args[1] != nil { | |||
| 3852 | + | arg1 = args[1].(string) | |||
| 3853 | + | } | |||
| 3854 | + | run( | |||
| 3855 | + | arg0, | |||
| 3856 | + | arg1, | |||
| 3857 | + | ) | |||
| 3858 | + | }) | |||
| 3859 | + | return _c | |||
| 3860 | + | } | |||
| 3861 | + | ||||
| 3862 | + | func (_c *MockVimPageInteractor_SendVimPageKey_Call) Return(err error) *MockVimPageInteractor_SendVimPageKey_Call { | |||
| 3863 | + | _c.Call.Return(err) | |||
| 3864 | + | return _c | |||
| 3865 | + | } | |||
| 3866 | + | ||||
| 3867 | + | func (_c *MockVimPageInteractor_SendVimPageKey_Call) RunAndReturn(run func(ctx context.Context, key string) error) *MockVimPageInteractor_SendVimPageKey_Call { | |||
| 3868 | + | _c.Call.Return(run) | |||
| 3869 | + | return _c | |||
| 3870 | + | } | |||
| 3871 | + | ||||
| 3872 | + | // StartVimPageInteraction provides a mock function for the type MockVimPageInteractor | |||
| 3873 | + | func (_mock *MockVimPageInteractor) StartVimPageInteraction(ctx context.Context, request dto.VimPageInteractionRequest) error { | |||
| 3874 | + | ret := _mock.Called(ctx, request) | |||
| 3875 | + | ||||
| 3876 | + | if len(ret) == 0 { | |||
| 3877 | + | panic("no return value specified for StartVimPageInteraction") | |||
| 3878 | + | } | |||
| 3879 | + | ||||
| 3880 | + | var r0 error | |||
| 3881 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, dto.VimPageInteractionRequest) error); ok { | |||
| 3882 | + | r0 = returnFunc(ctx, request) | |||
| 3883 | + | } else { | |||
| 3884 | + | r0 = ret.Error(0) | |||
| 3885 | + | } | |||
| 3886 | + | return r0 | |||
| 3887 | + | } | |||
| 3888 | + | ||||
| 3889 | + | // MockVimPageInteractor_StartVimPageInteraction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartVimPageInteraction' | |||
| 3890 | + | type MockVimPageInteractor_StartVimPageInteraction_Call struct { | |||
| 3891 | + | *mock.Call | |||
| 3892 | + | } | |||
| 3893 | + | ||||
| 3894 | + | // StartVimPageInteraction is a helper method to define mock.On call | |||
| 3895 | + | // - ctx context.Context | |||
| 3896 | + | // - request dto.VimPageInteractionRequest | |||
| 3897 | + | func (_e *MockVimPageInteractor_Expecter) StartVimPageInteraction(ctx any, request any) *MockVimPageInteractor_StartVimPageInteraction_Call { | |||
| 3898 | + | return &MockVimPageInteractor_StartVimPageInteraction_Call{Call: _e.mock.On("StartVimPageInteraction", ctx, request)} | |||
| 3899 | + | } | |||
| 3900 | + | ||||
| 3901 | + | func (_c *MockVimPageInteractor_StartVimPageInteraction_Call) Run(run func(ctx context.Context, request dto.VimPageInteractionRequest)) *MockVimPageInteractor_StartVimPageInteraction_Call { | |||
| 3902 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 3903 | + | var arg0 context.Context | |||
| 3904 | + | if args[0] != nil { | |||
| 3905 | + | arg0 = args[0].(context.Context) | |||
| 3906 | + | } | |||
| 3907 | + | var arg1 dto.VimPageInteractionRequest | |||
| 3908 | + | if args[1] != nil { | |||
| 3909 | + | arg1 = args[1].(dto.VimPageInteractionRequest) | |||
| 3910 | + | } | |||
| 3911 | + | run( | |||
| 3912 | + | arg0, | |||
| 3913 | + | arg1, | |||
| 3914 | + | ) | |||
| 3915 | + | }) | |||
| 3916 | + | return _c | |||
| 3917 | + | } | |||
| 3918 | + | ||||
| 3919 | + | func (_c *MockVimPageInteractor_StartVimPageInteraction_Call) Return(err error) *MockVimPageInteractor_StartVimPageInteraction_Call { | |||
| 3920 | + | _c.Call.Return(err) | |||
| 3921 | + | return _c | |||
| 3922 | + | } | |||
| 3923 | + | ||||
| 3924 | + | func (_c *MockVimPageInteractor_StartVimPageInteraction_Call) RunAndReturn(run func(ctx context.Context, request dto.VimPageInteractionRequest) error) *MockVimPageInteractor_StartVimPageInteraction_Call { | |||
| 3925 | + | _c.Call.Return(run) | |||
| 3926 | + | return _c | |||
| 3927 | + | } | |||
M internal/application/port/webview.go +15 -0
| @@ -160,6 +160,10 @@ type WebViewCallbacks struct { | |||
| 160 | 160 | // navigation, WebView destruction). The UI must hide any dialog shown for | |
| 161 | 161 | // this WebView without invoking respond. | |
| 162 | 162 | OnJSDialogReset func() | |
| 163 | + | ||
| 164 | + | // OnVimPageInteractionEnded is called when an in-page Vim interaction | |
| 165 | + | // (link hints or visual selection) finishes or is canceled by the page. | |
| 166 | + | OnVimPageInteractionEnded func() | |
| 163 | 167 | } | |
| 164 | 168 | ||
| 165 | 169 | // FindOptions configures search behavior. | |
| @@ -502,6 +506,17 @@ type PageInputFocuser interface { | |||
| 502 | 506 | FocusNextInput() | |
| 503 | 507 | } | |
| 504 | 508 | ||
| 509 | + | // VimPageInteractor is an optional WebView capability for in-page Vim | |
| 510 | + | // interactions: link hints, visual selection, and text-object yanks. Keys are | |
| 511 | + | // canonical vimkeys strings such as "j", "J", or "<Esc>". Implementations | |
| 512 | + | // report the end of a key-capturing interaction through | |
| 513 | + | // WebViewCallbacks.OnVimPageInteractionEnded. | |
| 514 | + | type VimPageInteractor interface { | |
| 515 | + | StartVimPageInteraction(ctx context.Context, request dto.VimPageInteractionRequest) error | |
| 516 | + | SendVimPageKey(ctx context.Context, key string) error | |
| 517 | + | CancelVimPageInteraction(ctx context.Context) error | |
| 518 | + | } | |
| 519 | + | ||
| 505 | 520 | // PageFocusNavigator is an optional WebView capability for keeping native-like | |
| 506 | 521 | // focus traversal inside the active page when the host window would otherwise | |
| 507 | 522 | // move focus outside the WebView. | |
| @@ -160,6 +160,10 @@ type WebViewCallbacks struct { | |||||
| 160 | // navigation, WebView destruction). The UI must hide any dialog shown for | 160 | // navigation, WebView destruction). The UI must hide any dialog shown for | ||
| 161 | // this WebView without invoking respond. | 161 | // this WebView without invoking respond. | ||
| 162 | OnJSDialogReset func() | 162 | OnJSDialogReset func() | ||
| 163 | + | ||||
| 164 | + | // OnVimPageInteractionEnded is called when an in-page Vim interaction | |||
| 165 | + | // (link hints or visual selection) finishes or is canceled by the page. | |||
| 166 | + | OnVimPageInteractionEnded func() | |||
| 163 | } | 167 | } | ||
| 164 | 168 | ||||
| 165 | // FindOptions configures search behavior. | 169 | // FindOptions configures search behavior. | ||
| @@ -502,6 +506,17 @@ type PageInputFocuser interface { | |||||
| 502 | FocusNextInput() | 506 | FocusNextInput() | ||
| 503 | } | 507 | } | ||
| 504 | 508 | ||||
| 509 | + | // VimPageInteractor is an optional WebView capability for in-page Vim | |||
| 510 | + | // interactions: link hints, visual selection, and text-object yanks. Keys are | |||
| 511 | + | // canonical vimkeys strings such as "j", "J", or "<Esc>". Implementations | |||
| 512 | + | // report the end of a key-capturing interaction through | |||
| 513 | + | // WebViewCallbacks.OnVimPageInteractionEnded. | |||
| 514 | + | type VimPageInteractor interface { | |||
| 515 | + | StartVimPageInteraction(ctx context.Context, request dto.VimPageInteractionRequest) error | |||
| 516 | + | SendVimPageKey(ctx context.Context, key string) error | |||
| 517 | + | CancelVimPageInteraction(ctx context.Context) error | |||
| 518 | + | } | |||
| 519 | + | ||||
| 505 | // PageFocusNavigator is an optional WebView capability for keeping native-like | 520 | // PageFocusNavigator is an optional WebView capability for keeping native-like | ||
| 506 | // focus traversal inside the active page when the host window would otherwise | 521 | // focus traversal inside the active page when the host window would otherwise | ||
| 507 | // move focus outside the WebView. | 522 | // move focus outside the WebView. | ||
M internal/application/usecase/vim_navigation.go +74 -3
| @@ -23,12 +23,21 @@ func NewVimNavigationUseCase() *VimNavigationUseCase { | |||
| 23 | 23 | return &VimNavigationUseCase{} | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | - | // Execute dispatches a configured semantic Vim action to the active WebView. | |
| 27 | - | func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, action string, count int, highlightColor string) error { | |
| 26 | + | // Execute dispatches a configured Vim action to the active WebView. The | |
| 27 | + | // outcome tells the UI whether the page now owns key input. | |
| 28 | + | func (uc *VimNavigationUseCase) Execute( | |
| 29 | + | ctx context.Context, wv port.WebView, action string, count int, highlightColor string, | |
| 30 | + | ) (dto.VimNavigationOutcome, error) { | |
| 28 | 31 | if wv == nil { | |
| 29 | - | return errors.New("vim navigation: nil webview") | |
| 32 | + | return dto.VimNavigationOutcome{}, errors.New("vim navigation: nil webview") | |
| 30 | 33 | } | |
| 34 | + | if request, ok := vimPageInteractionRequest(action, highlightColor); ok { | |
| 35 | + | return uc.startPageInteraction(ctx, wv, request) | |
| 36 | + | } | |
| 37 | + | return dto.VimNavigationOutcome{}, executeVimNavigation(ctx, wv, action, count, highlightColor) | |
| 38 | + | } | |
| 31 | 39 | ||
| 40 | + | func executeVimNavigation(ctx context.Context, wv port.WebView, action string, count int, highlightColor string) error { | |
| 32 | 41 | switch action { | |
| 33 | 42 | case "confirm": | |
| 34 | 43 | activator, ok := wv.(port.SemanticNavigationActivator) | |
| @@ -72,6 +81,68 @@ func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, actio | |||
| 72 | 81 | return navigator.NavigateSemantic(ctx, request) | |
| 73 | 82 | } | |
| 74 | 83 | ||
| 84 | + | var vimYankObjects = map[string]dto.VimPageTextObject{ | |
| 85 | + | "yank-section": dto.VimPageTextObjectSection, | |
| 86 | + | "yank-paragraph": dto.VimPageTextObjectParagraph, | |
| 87 | + | "yank-code": dto.VimPageTextObjectCode, | |
| 88 | + | "yank-table": dto.VimPageTextObjectTable, | |
| 89 | + | "yank-list": dto.VimPageTextObjectList, | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func vimPageInteractionRequest(action, highlightColor string) (dto.VimPageInteractionRequest, bool) { | |
| 93 | + | request := dto.VimPageInteractionRequest{HighlightColor: highlightColor} | |
| 94 | + | switch action { | |
| 95 | + | case "hint-follow": | |
| 96 | + | request.Kind = dto.VimPageHintFollow | |
| 97 | + | case "hint-follow-new": | |
| 98 | + | request.Kind = dto.VimPageHintFollowNew | |
| 99 | + | case "hint-yank-url": | |
| 100 | + | request.Kind = dto.VimPageHintYankURL | |
| 101 | + | case "visual": | |
| 102 | + | request.Kind = dto.VimPageVisual | |
| 103 | + | default: | |
| 104 | + | object, ok := vimYankObjects[action] | |
| 105 | + | if !ok { | |
| 106 | + | return dto.VimPageInteractionRequest{}, false | |
| 107 | + | } | |
| 108 | + | request.Kind = dto.VimPageYankObject | |
| 109 | + | request.Object = object | |
| 110 | + | } | |
| 111 | + | return request, true | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (*VimNavigationUseCase) startPageInteraction( | |
| 115 | + | ctx context.Context, wv port.WebView, request dto.VimPageInteractionRequest, | |
| 116 | + | ) (dto.VimNavigationOutcome, error) { | |
| 117 | + | interactor, ok := wv.(port.VimPageInteractor) | |
| 118 | + | if !ok { | |
| 119 | + | return dto.VimNavigationOutcome{}, errUnsupportedVimNavigationEngine | |
| 120 | + | } | |
| 121 | + | if err := interactor.StartVimPageInteraction(ctx, request); err != nil { | |
| 122 | + | return dto.VimNavigationOutcome{}, err | |
| 123 | + | } | |
| 124 | + | return dto.VimNavigationOutcome{CapturePageKeys: request.Kind.CapturesKeys()}, nil | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // SendPageKey forwards one canonical key to the active in-page interaction. | |
| 128 | + | func (*VimNavigationUseCase) SendPageKey(ctx context.Context, wv port.WebView, key string) error { | |
| 129 | + | interactor, ok := wv.(port.VimPageInteractor) | |
| 130 | + | if !ok { | |
| 131 | + | return errUnsupportedVimNavigationEngine | |
| 132 | + | } | |
| 133 | + | return interactor.SendVimPageKey(ctx, key) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // CancelPageInteraction ends any in-page interaction, removing its overlay or | |
| 137 | + | // selection. WebViews without the capability have nothing to cancel. | |
| 138 | + | func (*VimNavigationUseCase) CancelPageInteraction(ctx context.Context, wv port.WebView) error { | |
| 139 | + | interactor, ok := wv.(port.VimPageInteractor) | |
| 140 | + | if !ok { | |
| 141 | + | return nil | |
| 142 | + | } | |
| 143 | + | return interactor.CancelVimPageInteraction(ctx) | |
| 144 | + | } | |
| 145 | + | ||
| 75 | 146 | // ClearSemanticNavigationHighlight removes the visual target left by semantic | |
| 76 | 147 | // navigation when the active Vim Mode session ends. | |
| 77 | 148 | func (*VimNavigationUseCase) ClearSemanticNavigationHighlight(ctx context.Context, wv port.WebView) error { | |
| @@ -23,12 +23,21 @@ func NewVimNavigationUseCase() *VimNavigationUseCase { | |||||
| 23 | return &VimNavigationUseCase{} | 23 | return &VimNavigationUseCase{} | ||
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | - | // Execute dispatches a configured semantic Vim action to the active WebView. | 26 | + | // Execute dispatches a configured Vim action to the active WebView. The |
| 27 | - | func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, action string, count int, highlightColor string) error { | 27 | + | // outcome tells the UI whether the page now owns key input. |
| 28 | + | func (uc *VimNavigationUseCase) Execute( | |||
| 29 | + | ctx context.Context, wv port.WebView, action string, count int, highlightColor string, | |||
| 30 | + | ) (dto.VimNavigationOutcome, error) { | |||
| 28 | if wv == nil { | 31 | if wv == nil { | ||
| 29 | - | return errors.New("vim navigation: nil webview") | 32 | + | return dto.VimNavigationOutcome{}, errors.New("vim navigation: nil webview") |
| 30 | } | 33 | } | ||
| 34 | + | if request, ok := vimPageInteractionRequest(action, highlightColor); ok { | |||
| 35 | + | return uc.startPageInteraction(ctx, wv, request) | |||
| 36 | + | } | |||
| 37 | + | return dto.VimNavigationOutcome{}, executeVimNavigation(ctx, wv, action, count, highlightColor) | |||
| 38 | + | } | |||
| 31 | 39 | ||||
| 40 | + | func executeVimNavigation(ctx context.Context, wv port.WebView, action string, count int, highlightColor string) error { | |||
| 32 | switch action { | 41 | switch action { | ||
| 33 | case "confirm": | 42 | case "confirm": | ||
| 34 | activator, ok := wv.(port.SemanticNavigationActivator) | 43 | activator, ok := wv.(port.SemanticNavigationActivator) | ||
| @@ -72,6 +81,68 @@ func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, actio | |||||
| 72 | return navigator.NavigateSemantic(ctx, request) | 81 | return navigator.NavigateSemantic(ctx, request) | ||
| 73 | } | 82 | } | ||
| 74 | 83 | ||||
| 84 | + | var vimYankObjects = map[string]dto.VimPageTextObject{ | |||
| 85 | + | "yank-section": dto.VimPageTextObjectSection, | |||
| 86 | + | "yank-paragraph": dto.VimPageTextObjectParagraph, | |||
| 87 | + | "yank-code": dto.VimPageTextObjectCode, | |||
| 88 | + | "yank-table": dto.VimPageTextObjectTable, | |||
| 89 | + | "yank-list": dto.VimPageTextObjectList, | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func vimPageInteractionRequest(action, highlightColor string) (dto.VimPageInteractionRequest, bool) { | |||
| 93 | + | request := dto.VimPageInteractionRequest{HighlightColor: highlightColor} | |||
| 94 | + | switch action { | |||
| 95 | + | case "hint-follow": | |||
| 96 | + | request.Kind = dto.VimPageHintFollow | |||
| 97 | + | case "hint-follow-new": | |||
| 98 | + | request.Kind = dto.VimPageHintFollowNew | |||
| 99 | + | case "hint-yank-url": | |||
| 100 | + | request.Kind = dto.VimPageHintYankURL | |||
| 101 | + | case "visual": | |||
| 102 | + | request.Kind = dto.VimPageVisual | |||
| 103 | + | default: | |||
| 104 | + | object, ok := vimYankObjects[action] | |||
| 105 | + | if !ok { | |||
| 106 | + | return dto.VimPageInteractionRequest{}, false | |||
| 107 | + | } | |||
| 108 | + | request.Kind = dto.VimPageYankObject | |||
| 109 | + | request.Object = object | |||
| 110 | + | } | |||
| 111 | + | return request, true | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func (*VimNavigationUseCase) startPageInteraction( | |||
| 115 | + | ctx context.Context, wv port.WebView, request dto.VimPageInteractionRequest, | |||
| 116 | + | ) (dto.VimNavigationOutcome, error) { | |||
| 117 | + | interactor, ok := wv.(port.VimPageInteractor) | |||
| 118 | + | if !ok { | |||
| 119 | + | return dto.VimNavigationOutcome{}, errUnsupportedVimNavigationEngine | |||
| 120 | + | } | |||
| 121 | + | if err := interactor.StartVimPageInteraction(ctx, request); err != nil { | |||
| 122 | + | return dto.VimNavigationOutcome{}, err | |||
| 123 | + | } | |||
| 124 | + | return dto.VimNavigationOutcome{CapturePageKeys: request.Kind.CapturesKeys()}, nil | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // SendPageKey forwards one canonical key to the active in-page interaction. | |||
| 128 | + | func (*VimNavigationUseCase) SendPageKey(ctx context.Context, wv port.WebView, key string) error { | |||
| 129 | + | interactor, ok := wv.(port.VimPageInteractor) | |||
| 130 | + | if !ok { | |||
| 131 | + | return errUnsupportedVimNavigationEngine | |||
| 132 | + | } | |||
| 133 | + | return interactor.SendVimPageKey(ctx, key) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // CancelPageInteraction ends any in-page interaction, removing its overlay or | |||
| 137 | + | // selection. WebViews without the capability have nothing to cancel. | |||
| 138 | + | func (*VimNavigationUseCase) CancelPageInteraction(ctx context.Context, wv port.WebView) error { | |||
| 139 | + | interactor, ok := wv.(port.VimPageInteractor) | |||
| 140 | + | if !ok { | |||
| 141 | + | return nil | |||
| 142 | + | } | |||
| 143 | + | return interactor.CancelVimPageInteraction(ctx) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 75 | // ClearSemanticNavigationHighlight removes the visual target left by semantic | 146 | // ClearSemanticNavigationHighlight removes the visual target left by semantic | ||
| 76 | // navigation when the active Vim Mode session ends. | 147 | // navigation when the active Vim Mode session ends. | ||
| 77 | func (*VimNavigationUseCase) ClearSemanticNavigationHighlight(ctx context.Context, wv port.WebView) error { | 148 | func (*VimNavigationUseCase) ClearSemanticNavigationHighlight(ctx context.Context, wv port.WebView) error { | ||
M internal/application/usecase/vim_navigation_test.go +8 -4
| @@ -46,9 +46,12 @@ func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) { | |||
| 46 | 46 | wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)} | |
| 47 | 47 | uc := NewVimNavigationUseCase() | |
| 48 | 48 | ||
| 49 | - | require.NoError(t, uc.Execute(context.Background(), wv, "focus-input", 1, "")) | |
| 50 | - | require.NoError(t, uc.Execute(context.Background(), wv, "heading-prev", 3, "#aabbcc")) | |
| 51 | - | require.NoError(t, uc.Execute(context.Background(), wv, "confirm", 1, "")) | |
| 49 | + | _, err := uc.Execute(context.Background(), wv, "focus-input", 1, "") | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | _, err = uc.Execute(context.Background(), wv, "heading-prev", 3, "#aabbcc") | |
| 52 | + | require.NoError(t, err) | |
| 53 | + | _, err = uc.Execute(context.Background(), wv, "confirm", 1, "") | |
| 54 | + | require.NoError(t, err) | |
| 52 | 55 | ||
| 53 | 56 | assert.Equal(t, 1, wv.focusNextInputCalls) | |
| 54 | 57 | assert.Equal(t, 1, wv.activateNavigationTargetCalls) | |
| @@ -80,6 +83,7 @@ func TestVimNavigationUseCaseNavigatePageFocus(t *testing.T) { | |||
| 80 | 83 | func TestVimNavigationUseCaseIgnoresUnsupportedCapability(t *testing.T) { | |
| 81 | 84 | wv := portmocks.NewMockWebView(t) | |
| 82 | 85 | ||
| 83 | - | require.ErrorIs(t, NewVimNavigationUseCase().Execute(context.Background(), wv, "focus-input", 1, ""), errUnsupportedVimNavigationEngine) | |
| 86 | + | _, err := NewVimNavigationUseCase().Execute(context.Background(), wv, "focus-input", 1, "") | |
| 87 | + | require.ErrorIs(t, err, errUnsupportedVimNavigationEngine) | |
| 84 | 88 | assert.False(t, NewVimNavigationUseCase().NavigatePageFocus(wv, true)) | |
| 85 | 89 | } | |
| @@ -46,9 +46,12 @@ func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) { | |||||
| 46 | wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)} | 46 | wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)} | ||
| 47 | uc := NewVimNavigationUseCase() | 47 | uc := NewVimNavigationUseCase() | ||
| 48 | 48 | ||||
| 49 | - | require.NoError(t, uc.Execute(context.Background(), wv, "focus-input", 1, "")) | 49 | + | _, err := uc.Execute(context.Background(), wv, "focus-input", 1, "") |
| 50 | - | require.NoError(t, uc.Execute(context.Background(), wv, "heading-prev", 3, "#aabbcc")) | 50 | + | require.NoError(t, err) |
| 51 | - | require.NoError(t, uc.Execute(context.Background(), wv, "confirm", 1, "")) | 51 | + | _, err = uc.Execute(context.Background(), wv, "heading-prev", 3, "#aabbcc") |
| 52 | + | require.NoError(t, err) | |||
| 53 | + | _, err = uc.Execute(context.Background(), wv, "confirm", 1, "") | |||
| 54 | + | require.NoError(t, err) | |||
| 52 | 55 | ||||
| 53 | assert.Equal(t, 1, wv.focusNextInputCalls) | 56 | assert.Equal(t, 1, wv.focusNextInputCalls) | ||
| 54 | assert.Equal(t, 1, wv.activateNavigationTargetCalls) | 57 | assert.Equal(t, 1, wv.activateNavigationTargetCalls) | ||
| @@ -80,6 +83,7 @@ func TestVimNavigationUseCaseNavigatePageFocus(t *testing.T) { | |||||
| 80 | func TestVimNavigationUseCaseIgnoresUnsupportedCapability(t *testing.T) { | 83 | func TestVimNavigationUseCaseIgnoresUnsupportedCapability(t *testing.T) { | ||
| 81 | wv := portmocks.NewMockWebView(t) | 84 | wv := portmocks.NewMockWebView(t) | ||
| 82 | 85 | ||||
| 83 | - | require.ErrorIs(t, NewVimNavigationUseCase().Execute(context.Background(), wv, "focus-input", 1, ""), errUnsupportedVimNavigationEngine) | 86 | + | _, err := NewVimNavigationUseCase().Execute(context.Background(), wv, "focus-input", 1, "") |
| 87 | + | require.ErrorIs(t, err, errUnsupportedVimNavigationEngine) | |||
| 84 | assert.False(t, NewVimNavigationUseCase().NavigatePageFocus(wv, true)) | 88 | assert.False(t, NewVimNavigationUseCase().NavigatePageFocus(wv, true)) | ||
| 85 | } | 89 | } | ||
A internal/application/usecase/vim_page_interaction_test.go +101 -0
| @@ -0,0 +1,101 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 12 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // interactiveWebView composes generated mocks so a WebView also exposes the | |
| 16 | + | // optional VimPageInteractor capability. | |
| 17 | + | type interactiveWebView struct { | |
| 18 | + | *portmocks.MockWebView | |
| 19 | + | *portmocks.MockVimPageInteractor | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func newInteractiveWebView(t *testing.T) (*interactiveWebView, *portmocks.MockVimPageInteractor) { | |
| 23 | + | t.Helper() | |
| 24 | + | interactor := portmocks.NewMockVimPageInteractor(t) | |
| 25 | + | return &interactiveWebView{MockWebView: portmocks.NewMockWebView(t), MockVimPageInteractor: interactor}, interactor | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestVimNavigationUseCaseStartsPageInteractions(t *testing.T) { | |
| 29 | + | tests := []struct { | |
| 30 | + | action string | |
| 31 | + | want dto.VimPageInteractionRequest | |
| 32 | + | wantCapture bool | |
| 33 | + | }{ | |
| 34 | + | {"hint-follow", dto.VimPageInteractionRequest{Kind: dto.VimPageHintFollow, HighlightColor: "#abc"}, true}, | |
| 35 | + | {"hint-follow-new", dto.VimPageInteractionRequest{Kind: dto.VimPageHintFollowNew, HighlightColor: "#abc"}, true}, | |
| 36 | + | {"hint-yank-url", dto.VimPageInteractionRequest{Kind: dto.VimPageHintYankURL, HighlightColor: "#abc"}, true}, | |
| 37 | + | {"visual", dto.VimPageInteractionRequest{Kind: dto.VimPageVisual, HighlightColor: "#abc"}, true}, | |
| 38 | + | {"yank-section", dto.VimPageInteractionRequest{ | |
| 39 | + | Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectSection, HighlightColor: "#abc", | |
| 40 | + | }, false}, | |
| 41 | + | {"yank-paragraph", dto.VimPageInteractionRequest{ | |
| 42 | + | Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectParagraph, HighlightColor: "#abc", | |
| 43 | + | }, false}, | |
| 44 | + | {"yank-code", dto.VimPageInteractionRequest{ | |
| 45 | + | Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectCode, HighlightColor: "#abc", | |
| 46 | + | }, false}, | |
| 47 | + | {"yank-table", dto.VimPageInteractionRequest{ | |
| 48 | + | Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectTable, HighlightColor: "#abc", | |
| 49 | + | }, false}, | |
| 50 | + | {"yank-list", dto.VimPageInteractionRequest{ | |
| 51 | + | Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectList, HighlightColor: "#abc", | |
| 52 | + | }, false}, | |
| 53 | + | } | |
| 54 | + | for _, tt := range tests { | |
| 55 | + | t.Run(tt.action, func(t *testing.T) { | |
| 56 | + | ctx := context.Background() | |
| 57 | + | wv, interactor := newInteractiveWebView(t) | |
| 58 | + | interactor.EXPECT().StartVimPageInteraction(ctx, tt.want).Return(nil).Once() | |
| 59 | + | ||
| 60 | + | outcome, err := NewVimNavigationUseCase().Execute(ctx, wv, tt.action, 1, "#abc") | |
| 61 | + | ||
| 62 | + | require.NoError(t, err) | |
| 63 | + | assert.Equal(t, tt.wantCapture, outcome.CapturePageKeys) | |
| 64 | + | }) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestVimNavigationUseCasePageInteractionErrorDoesNotCapture(t *testing.T) { | |
| 69 | + | ctx := context.Background() | |
| 70 | + | wv, interactor := newInteractiveWebView(t) | |
| 71 | + | boom := errors.New("boom") | |
| 72 | + | interactor.EXPECT().StartVimPageInteraction(ctx, dto.VimPageInteractionRequest{Kind: dto.VimPageVisual}).Return(boom).Once() | |
| 73 | + | ||
| 74 | + | outcome, err := NewVimNavigationUseCase().Execute(ctx, wv, "visual", 1, "") | |
| 75 | + | ||
| 76 | + | require.ErrorIs(t, err, boom) | |
| 77 | + | assert.False(t, outcome.CapturePageKeys) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestVimNavigationUseCasePageInteractionUnsupportedEngine(t *testing.T) { | |
| 81 | + | uc := NewVimNavigationUseCase() | |
| 82 | + | wv := portmocks.NewMockWebView(t) | |
| 83 | + | ||
| 84 | + | outcome, err := uc.Execute(context.Background(), wv, "hint-follow", 1, "") | |
| 85 | + | require.ErrorIs(t, err, errUnsupportedVimNavigationEngine) | |
| 86 | + | assert.False(t, outcome.CapturePageKeys) | |
| 87 | + | ||
| 88 | + | require.ErrorIs(t, uc.SendPageKey(context.Background(), wv, "a"), errUnsupportedVimNavigationEngine) | |
| 89 | + | require.NoError(t, uc.CancelPageInteraction(context.Background(), wv)) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestVimNavigationUseCaseForwardsPageKeysAndCancel(t *testing.T) { | |
| 93 | + | ctx := context.Background() | |
| 94 | + | wv, interactor := newInteractiveWebView(t) | |
| 95 | + | interactor.EXPECT().SendVimPageKey(ctx, "J").Return(nil).Once() | |
| 96 | + | interactor.EXPECT().CancelVimPageInteraction(ctx).Return(nil).Once() | |
| 97 | + | uc := NewVimNavigationUseCase() | |
| 98 | + | ||
| 99 | + | require.NoError(t, uc.SendPageKey(ctx, wv, "J")) | |
| 100 | + | require.NoError(t, uc.CancelPageInteraction(ctx, wv)) | |
| 101 | + | } | |
| @@ -0,0 +1,101 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 12 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // interactiveWebView composes generated mocks so a WebView also exposes the | |||
| 16 | + | // optional VimPageInteractor capability. | |||
| 17 | + | type interactiveWebView struct { | |||
| 18 | + | *portmocks.MockWebView | |||
| 19 | + | *portmocks.MockVimPageInteractor | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func newInteractiveWebView(t *testing.T) (*interactiveWebView, *portmocks.MockVimPageInteractor) { | |||
| 23 | + | t.Helper() | |||
| 24 | + | interactor := portmocks.NewMockVimPageInteractor(t) | |||
| 25 | + | return &interactiveWebView{MockWebView: portmocks.NewMockWebView(t), MockVimPageInteractor: interactor}, interactor | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestVimNavigationUseCaseStartsPageInteractions(t *testing.T) { | |||
| 29 | + | tests := []struct { | |||
| 30 | + | action string | |||
| 31 | + | want dto.VimPageInteractionRequest | |||
| 32 | + | wantCapture bool | |||
| 33 | + | }{ | |||
| 34 | + | {"hint-follow", dto.VimPageInteractionRequest{Kind: dto.VimPageHintFollow, HighlightColor: "#abc"}, true}, | |||
| 35 | + | {"hint-follow-new", dto.VimPageInteractionRequest{Kind: dto.VimPageHintFollowNew, HighlightColor: "#abc"}, true}, | |||
| 36 | + | {"hint-yank-url", dto.VimPageInteractionRequest{Kind: dto.VimPageHintYankURL, HighlightColor: "#abc"}, true}, | |||
| 37 | + | {"visual", dto.VimPageInteractionRequest{Kind: dto.VimPageVisual, HighlightColor: "#abc"}, true}, | |||
| 38 | + | {"yank-section", dto.VimPageInteractionRequest{ | |||
| 39 | + | Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectSection, HighlightColor: "#abc", | |||
| 40 | + | }, false}, | |||
| 41 | + | {"yank-paragraph", dto.VimPageInteractionRequest{ | |||
| 42 | + | Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectParagraph, HighlightColor: "#abc", | |||
| 43 | + | }, false}, | |||
| 44 | + | {"yank-code", dto.VimPageInteractionRequest{ | |||
| 45 | + | Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectCode, HighlightColor: "#abc", | |||
| 46 | + | }, false}, | |||
| 47 | + | {"yank-table", dto.VimPageInteractionRequest{ | |||
| 48 | + | Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectTable, HighlightColor: "#abc", | |||
| 49 | + | }, false}, | |||
| 50 | + | {"yank-list", dto.VimPageInteractionRequest{ | |||
| 51 | + | Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectList, HighlightColor: "#abc", | |||
| 52 | + | }, false}, | |||
| 53 | + | } | |||
| 54 | + | for _, tt := range tests { | |||
| 55 | + | t.Run(tt.action, func(t *testing.T) { | |||
| 56 | + | ctx := context.Background() | |||
| 57 | + | wv, interactor := newInteractiveWebView(t) | |||
| 58 | + | interactor.EXPECT().StartVimPageInteraction(ctx, tt.want).Return(nil).Once() | |||
| 59 | + | ||||
| 60 | + | outcome, err := NewVimNavigationUseCase().Execute(ctx, wv, tt.action, 1, "#abc") | |||
| 61 | + | ||||
| 62 | + | require.NoError(t, err) | |||
| 63 | + | assert.Equal(t, tt.wantCapture, outcome.CapturePageKeys) | |||
| 64 | + | }) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestVimNavigationUseCasePageInteractionErrorDoesNotCapture(t *testing.T) { | |||
| 69 | + | ctx := context.Background() | |||
| 70 | + | wv, interactor := newInteractiveWebView(t) | |||
| 71 | + | boom := errors.New("boom") | |||
| 72 | + | interactor.EXPECT().StartVimPageInteraction(ctx, dto.VimPageInteractionRequest{Kind: dto.VimPageVisual}).Return(boom).Once() | |||
| 73 | + | ||||
| 74 | + | outcome, err := NewVimNavigationUseCase().Execute(ctx, wv, "visual", 1, "") | |||
| 75 | + | ||||
| 76 | + | require.ErrorIs(t, err, boom) | |||
| 77 | + | assert.False(t, outcome.CapturePageKeys) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestVimNavigationUseCasePageInteractionUnsupportedEngine(t *testing.T) { | |||
| 81 | + | uc := NewVimNavigationUseCase() | |||
| 82 | + | wv := portmocks.NewMockWebView(t) | |||
| 83 | + | ||||
| 84 | + | outcome, err := uc.Execute(context.Background(), wv, "hint-follow", 1, "") | |||
| 85 | + | require.ErrorIs(t, err, errUnsupportedVimNavigationEngine) | |||
| 86 | + | assert.False(t, outcome.CapturePageKeys) | |||
| 87 | + | ||||
| 88 | + | require.ErrorIs(t, uc.SendPageKey(context.Background(), wv, "a"), errUnsupportedVimNavigationEngine) | |||
| 89 | + | require.NoError(t, uc.CancelPageInteraction(context.Background(), wv)) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func TestVimNavigationUseCaseForwardsPageKeysAndCancel(t *testing.T) { | |||
| 93 | + | ctx := context.Background() | |||
| 94 | + | wv, interactor := newInteractiveWebView(t) | |||
| 95 | + | interactor.EXPECT().SendVimPageKey(ctx, "J").Return(nil).Once() | |||
| 96 | + | interactor.EXPECT().CancelVimPageInteraction(ctx).Return(nil).Once() | |||
| 97 | + | uc := NewVimNavigationUseCase() | |||
| 98 | + | ||||
| 99 | + | require.NoError(t, uc.SendPageKey(ctx, wv, "J")) | |||
| 100 | + | require.NoError(t, uc.CancelPageInteraction(ctx, wv)) | |||
| 101 | + | } | |||
M internal/infrastructure/cef/engine_init.go +1 -0
| @@ -323,6 +323,7 @@ func newEngineSchemeHandler( | |||
| 323 | 323 | schemeHandler.onPopupClose = eng.handlePopupBridgeClose | |
| 324 | 324 | schemeHandler.onPopupOpenerNavigate = eng.handlePopupOpenerNavigate | |
| 325 | 325 | schemeHandler.onPopupOpenerPostMessage = eng.handlePopupOpenerPostMessage | |
| 326 | + | schemeHandler.onVimPage = eng.handleVimPageBridge | |
| 326 | 327 | schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest | |
| 327 | 328 | return messageRouter, schemeHandler, nil | |
| 328 | 329 | } | |
| @@ -323,6 +323,7 @@ func newEngineSchemeHandler( | |||||
| 323 | schemeHandler.onPopupClose = eng.handlePopupBridgeClose | 323 | schemeHandler.onPopupClose = eng.handlePopupBridgeClose | ||
| 324 | schemeHandler.onPopupOpenerNavigate = eng.handlePopupOpenerNavigate | 324 | schemeHandler.onPopupOpenerNavigate = eng.handlePopupOpenerNavigate | ||
| 325 | schemeHandler.onPopupOpenerPostMessage = eng.handlePopupOpenerPostMessage | 325 | schemeHandler.onPopupOpenerPostMessage = eng.handlePopupOpenerPostMessage | ||
| 326 | + | schemeHandler.onVimPage = eng.handleVimPageBridge | |||
| 326 | schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest | 327 | schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest | ||
| 327 | return messageRouter, schemeHandler, nil | 328 | return messageRouter, schemeHandler, nil | ||
| 328 | } | 329 | } | ||
M internal/infrastructure/cef/scheme_handler.go +28 -0
| @@ -96,6 +96,8 @@ type dumbSchemeHandler struct { | |||
| 96 | 96 | onPopupClose func(browser purecef.Browser, payload rendererBridgePopupClosePayload) | |
| 97 | 97 | onPopupOpenerNavigate func(browser purecef.Browser, payload popupOpenerNavigatePayload) | |
| 98 | 98 | onPopupOpenerPostMessage func(browser purecef.Browser, payload popupOpenerPostMessagePayload) | |
| 99 | + | // onVimPage receives Vim Mode page runtime results (copy, open, end). | |
| 100 | + | onVimPage func(browser purecef.Browser, payload vimPageBridgePayload) | |
| 99 | 101 | ||
| 100 | 102 | // bridgeNonceValidator checks whether a bridge nonce belongs to the active | |
| 101 | 103 | // browser/navigation context that issued the request. | |
| @@ -267,6 +269,8 @@ func (h *dumbSchemeHandler) handleAPIPost( | |||
| 267 | 269 | return h.handlePopupOpenerNavigate(request, browser), true | |
| 268 | 270 | case "/api/popup-opener-post-message": | |
| 269 | 271 | return h.handlePopupOpenerPostMessage(request, browser), true | |
| 272 | + | case "/api/vim-page": | |
| 273 | + | return h.handleVimPage(request, browser), true | |
| 270 | 274 | default: | |
| 271 | 275 | return nil, false | |
| 272 | 276 | } | |
| @@ -475,6 +479,30 @@ func handlePopupBridgeRequest[T any]( | |||
| 475 | 479 | return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true}) | |
| 476 | 480 | } | |
| 477 | 481 | ||
| 482 | + | // handleVimPage accepts Vim page runtime results. It does not use the shared | |
| 483 | + | // bridge nonce: the payload carries a per-interaction token that the source | |
| 484 | + | // WebView validates, so the bridge nonce is never exposed to page-reachable | |
| 485 | + | // Vim calls. | |
| 486 | + | func (h *dumbSchemeHandler) handleVimPage(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler { | |
| 487 | + | if browser == nil { | |
| 488 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"}) | |
| 489 | + | } | |
| 490 | + | body := readBodyFromHeader(request) | |
| 491 | + | if body == nil { | |
| 492 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | |
| 493 | + | } | |
| 494 | + | payload, err := decodeVimPageBridgePayload(body) | |
| 495 | + | if err != nil { | |
| 496 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()}) | |
| 497 | + | } | |
| 498 | + | if h.onVimPage == nil { | |
| 499 | + | h.logger.Warn().Msg("cef: vim-page — callback not wired") | |
| 500 | + | } else { | |
| 501 | + | h.onVimPage(browser, payload) | |
| 502 | + | } | |
| 503 | + | return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true}) | |
| 504 | + | } | |
| 505 | + | ||
| 478 | 506 | func (h *dumbSchemeHandler) handlePopupOpen(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler { | |
| 479 | 507 | return handlePopupBridgeRequest(h, request, browser, "popup-open", decodeRendererBridgePopupOpenPayload, h.onPopupOpen) | |
| 480 | 508 | } | |
| @@ -96,6 +96,8 @@ type dumbSchemeHandler struct { | |||||
| 96 | onPopupClose func(browser purecef.Browser, payload rendererBridgePopupClosePayload) | 96 | onPopupClose func(browser purecef.Browser, payload rendererBridgePopupClosePayload) | ||
| 97 | onPopupOpenerNavigate func(browser purecef.Browser, payload popupOpenerNavigatePayload) | 97 | onPopupOpenerNavigate func(browser purecef.Browser, payload popupOpenerNavigatePayload) | ||
| 98 | onPopupOpenerPostMessage func(browser purecef.Browser, payload popupOpenerPostMessagePayload) | 98 | onPopupOpenerPostMessage func(browser purecef.Browser, payload popupOpenerPostMessagePayload) | ||
| 99 | + | // onVimPage receives Vim Mode page runtime results (copy, open, end). | |||
| 100 | + | onVimPage func(browser purecef.Browser, payload vimPageBridgePayload) | |||
| 99 | 101 | ||||
| 100 | // bridgeNonceValidator checks whether a bridge nonce belongs to the active | 102 | // bridgeNonceValidator checks whether a bridge nonce belongs to the active | ||
| 101 | // browser/navigation context that issued the request. | 103 | // browser/navigation context that issued the request. | ||
| @@ -267,6 +269,8 @@ func (h *dumbSchemeHandler) handleAPIPost( | |||||
| 267 | return h.handlePopupOpenerNavigate(request, browser), true | 269 | return h.handlePopupOpenerNavigate(request, browser), true | ||
| 268 | case "/api/popup-opener-post-message": | 270 | case "/api/popup-opener-post-message": | ||
| 269 | return h.handlePopupOpenerPostMessage(request, browser), true | 271 | return h.handlePopupOpenerPostMessage(request, browser), true | ||
| 272 | + | case "/api/vim-page": | |||
| 273 | + | return h.handleVimPage(request, browser), true | |||
| 270 | default: | 274 | default: | ||
| 271 | return nil, false | 275 | return nil, false | ||
| 272 | } | 276 | } | ||
| @@ -475,6 +479,30 @@ func handlePopupBridgeRequest[T any]( | |||||
| 475 | return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true}) | 479 | return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true}) | ||
| 476 | } | 480 | } | ||
| 477 | 481 | ||||
| 482 | + | // handleVimPage accepts Vim page runtime results. It does not use the shared | |||
| 483 | + | // bridge nonce: the payload carries a per-interaction token that the source | |||
| 484 | + | // WebView validates, so the bridge nonce is never exposed to page-reachable | |||
| 485 | + | // Vim calls. | |||
| 486 | + | func (h *dumbSchemeHandler) handleVimPage(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler { | |||
| 487 | + | if browser == nil { | |||
| 488 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"}) | |||
| 489 | + | } | |||
| 490 | + | body := readBodyFromHeader(request) | |||
| 491 | + | if body == nil { | |||
| 492 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | |||
| 493 | + | } | |||
| 494 | + | payload, err := decodeVimPageBridgePayload(body) | |||
| 495 | + | if err != nil { | |||
| 496 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()}) | |||
| 497 | + | } | |||
| 498 | + | if h.onVimPage == nil { | |||
| 499 | + | h.logger.Warn().Msg("cef: vim-page — callback not wired") | |||
| 500 | + | } else { | |||
| 501 | + | h.onVimPage(browser, payload) | |||
| 502 | + | } | |||
| 503 | + | return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true}) | |||
| 504 | + | } | |||
| 505 | + | ||||
| 478 | func (h *dumbSchemeHandler) handlePopupOpen(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler { | 506 | func (h *dumbSchemeHandler) handlePopupOpen(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler { | ||
| 479 | return handlePopupBridgeRequest(h, request, browser, "popup-open", decodeRendererBridgePopupOpenPayload, h.onPopupOpen) | 507 | return handlePopupBridgeRequest(h, request, browser, "popup-open", decodeRendererBridgePopupOpenPayload, h.onPopupOpen) | ||
| 480 | } | 508 | } | ||
A internal/infrastructure/cef/vim_page.go +333 -0
| @@ -0,0 +1,333 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/subtle" | |
| 6 | + | _ "embed" | |
| 7 | + | "encoding/json" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "net/url" | |
| 11 | + | "strings" | |
| 12 | + | ||
| 13 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 16 | + | "github.com/bnema/dumber/internal/application/port" | |
| 17 | + | "github.com/bnema/dumber/internal/logging" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | var _ port.VimPageInteractor = (*WebView)(nil) | |
| 21 | + | ||
| 22 | + | //go:embed vim_page_runtime.js | |
| 23 | + | var vimPageRuntimeTemplateJS string | |
| 24 | + | ||
| 25 | + | var errVimPageTokenUnavailable = errors.New("vim page: interaction token unavailable") | |
| 26 | + | ||
| 27 | + | // vimPageBridgePayload is one result reported by the page runtime. Token is | |
| 28 | + | // the per-interaction secret issued by StartVimPageInteraction. | |
| 29 | + | type vimPageBridgePayload struct { | |
| 30 | + | Type string `json:"type"` | |
| 31 | + | Token string `json:"token"` | |
| 32 | + | Text string `json:"text,omitempty"` | |
| 33 | + | URL string `json:"url,omitempty"` | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | const ( | |
| 37 | + | vimPageMessageCopy = "copy" | |
| 38 | + | vimPageMessageOpenNew = "open-new" | |
| 39 | + | vimPageMessageEnd = "end" | |
| 40 | + | ) | |
| 41 | + | ||
| 42 | + | func decodeVimPageBridgePayload(body []byte) (vimPageBridgePayload, error) { | |
| 43 | + | var payload vimPageBridgePayload | |
| 44 | + | if len(body) > maxClipboardBytes { | |
| 45 | + | return payload, errors.New("payload too large") | |
| 46 | + | } | |
| 47 | + | if err := json.Unmarshal(body, &payload); err != nil { | |
| 48 | + | return payload, err | |
| 49 | + | } | |
| 50 | + | if payload.Token == "" { | |
| 51 | + | return payload, errors.New("missing token") | |
| 52 | + | } | |
| 53 | + | switch payload.Type { | |
| 54 | + | case vimPageMessageCopy: | |
| 55 | + | if payload.Text == "" { | |
| 56 | + | return payload, errors.New("missing text") | |
| 57 | + | } | |
| 58 | + | case vimPageMessageOpenNew: | |
| 59 | + | if payload.URL == "" { | |
| 60 | + | return payload, errors.New("missing url") | |
| 61 | + | } | |
| 62 | + | case vimPageMessageEnd: | |
| 63 | + | default: | |
| 64 | + | return payload, fmt.Errorf("unknown type %q", payload.Type) | |
| 65 | + | } | |
| 66 | + | return payload, nil | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // isNavigableVimHintURL keeps hint-opened panes to web documents with a host | |
| 70 | + | // so a page cannot smuggle javascript: or internal URLs through the bridge. | |
| 71 | + | // file: targets are allowed only from a page that is itself a file: document, | |
| 72 | + | // matching Chromium's own http(s)-to-file navigation block. | |
| 73 | + | func isNavigableVimHintURL(raw, sourceURI string) bool { | |
| 74 | + | parsed, err := url.Parse(raw) | |
| 75 | + | if err != nil { | |
| 76 | + | return false | |
| 77 | + | } | |
| 78 | + | switch strings.ToLower(parsed.Scheme) { | |
| 79 | + | case "http", "https": | |
| 80 | + | return parsed.Host != "" | |
| 81 | + | case "file": | |
| 82 | + | source, err := url.Parse(sourceURI) | |
| 83 | + | return err == nil && strings.EqualFold(source.Scheme, "file") | |
| 84 | + | default: | |
| 85 | + | return false | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func vimPageKindName(kind dto.VimPageInteractionKind) (string, bool) { | |
| 90 | + | switch kind { | |
| 91 | + | case dto.VimPageHintFollow: | |
| 92 | + | return "hint-follow", true | |
| 93 | + | case dto.VimPageHintFollowNew: | |
| 94 | + | return "hint-follow-new", true | |
| 95 | + | case dto.VimPageHintYankURL: | |
| 96 | + | return "hint-yank-url", true | |
| 97 | + | case dto.VimPageVisual: | |
| 98 | + | return "visual", true | |
| 99 | + | case dto.VimPageYankObject: | |
| 100 | + | return "yank", true | |
| 101 | + | default: | |
| 102 | + | return "", false | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func vimPageObjectName(object dto.VimPageTextObject) (string, bool) { | |
| 107 | + | switch object { | |
| 108 | + | case dto.VimPageTextObjectSection: | |
| 109 | + | return "section", true | |
| 110 | + | case dto.VimPageTextObjectParagraph: | |
| 111 | + | return "paragraph", true | |
| 112 | + | case dto.VimPageTextObjectCode: | |
| 113 | + | return "code", true | |
| 114 | + | case dto.VimPageTextObjectTable: | |
| 115 | + | return "table", true | |
| 116 | + | case dto.VimPageTextObjectList: | |
| 117 | + | return "list", true | |
| 118 | + | default: | |
| 119 | + | return "", false | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func vimPageRequestJSON(request dto.VimPageInteractionRequest) (string, error) { | |
| 124 | + | kind, ok := vimPageKindName(request.Kind) | |
| 125 | + | if !ok { | |
| 126 | + | return "", fmt.Errorf("unsupported vim page interaction: %d", request.Kind) | |
| 127 | + | } | |
| 128 | + | object := "" | |
| 129 | + | if request.Kind == dto.VimPageYankObject { | |
| 130 | + | if object, ok = vimPageObjectName(request.Object); !ok { | |
| 131 | + | return "", fmt.Errorf("unsupported vim text object: %d", request.Object) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | encoded, err := json.Marshal(struct { | |
| 135 | + | Kind string `json:"kind"` | |
| 136 | + | Object string `json:"object,omitempty"` | |
| 137 | + | Color string `json:"color,omitempty"` | |
| 138 | + | }{Kind: kind, Object: object, Color: request.HighlightColor}) | |
| 139 | + | if err != nil { | |
| 140 | + | return "", err | |
| 141 | + | } | |
| 142 | + | return string(encoded), nil | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // jsString renders s as a JavaScript string literal (JSON is valid JS). | |
| 146 | + | func jsString(s string) string { | |
| 147 | + | encoded, _ := json.Marshal(s) | |
| 148 | + | return string(encoded) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // vimPageScript installs the runtime when absent and then invokes one method | |
| 152 | + | // with the interaction token. The token is a per-interaction secret, not the | |
| 153 | + | // shared bridge nonce, so a page that intercepts the call only learns a token | |
| 154 | + | // valid for the interaction the user just started. | |
| 155 | + | func vimPageScript(method, token string, args ...string) string { | |
| 156 | + | call := append([]string{jsString(token)}, args...) | |
| 157 | + | return vimPageRuntimeTemplateJS + "\nwindow.__dumberVimPage." + method + "(" + strings.Join(call, ", ") + ");" | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // armVimPageInteraction issues a fresh token for a new interaction and | |
| 161 | + | // invalidates any previous one, so late results from it are rejected. | |
| 162 | + | func (wv *WebView) armVimPageInteraction(kind dto.VimPageInteractionKind) (string, error) { | |
| 163 | + | token := newBridgeNonce() | |
| 164 | + | if token == "" { | |
| 165 | + | return "", errVimPageTokenUnavailable | |
| 166 | + | } | |
| 167 | + | wv.mu.Lock() | |
| 168 | + | wv.vimPageToken = token | |
| 169 | + | wv.vimPageKind = kind | |
| 170 | + | wv.vimPageGeneration++ | |
| 171 | + | wv.mu.Unlock() | |
| 172 | + | return token, nil | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func (wv *WebView) currentVimPageToken() string { | |
| 176 | + | wv.mu.RLock() | |
| 177 | + | defer wv.mu.RUnlock() | |
| 178 | + | return wv.vimPageToken | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // disarmVimPageInteraction clears the active token. It reports whether a | |
| 182 | + | // key-capturing interaction was active and the generation it belonged to. | |
| 183 | + | func (wv *WebView) disarmVimPageInteraction() (bool, uint64) { | |
| 184 | + | wv.mu.Lock() | |
| 185 | + | defer wv.mu.Unlock() | |
| 186 | + | captured := wv.vimPageToken != "" && wv.vimPageKind.CapturesKeys() | |
| 187 | + | wv.vimPageToken = "" | |
| 188 | + | wv.vimPageKind = 0 | |
| 189 | + | return captured, wv.vimPageGeneration | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // notifyVimPageInteractionEnded runs the ended callback on GTK unless a newer | |
| 193 | + | // interaction was armed in the meantime (keys queued ahead of the idle). | |
| 194 | + | func (wv *WebView) notifyVimPageInteractionEnded(generation uint64) { | |
| 195 | + | wv.mu.RLock() | |
| 196 | + | cb := wv.callbacks | |
| 197 | + | wv.mu.RUnlock() | |
| 198 | + | if cb == nil || cb.OnVimPageInteractionEnded == nil { | |
| 199 | + | return | |
| 200 | + | } | |
| 201 | + | ended := cb.OnVimPageInteractionEnded | |
| 202 | + | wv.runOnGTK(func() { | |
| 203 | + | wv.mu.RLock() | |
| 204 | + | current := wv.vimPageGeneration | |
| 205 | + | wv.mu.RUnlock() | |
| 206 | + | if current == generation { | |
| 207 | + | ended() | |
| 208 | + | } | |
| 209 | + | }) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // vimPageResultAllowed ties each result type to the interaction the user | |
| 213 | + | // started, so a hijacked token only yields the kind of result requested. | |
| 214 | + | func vimPageResultAllowed(kind dto.VimPageInteractionKind, messageType string) bool { | |
| 215 | + | switch messageType { | |
| 216 | + | case vimPageMessageEnd: | |
| 217 | + | return true | |
| 218 | + | case vimPageMessageOpenNew: | |
| 219 | + | return kind == dto.VimPageHintFollow || kind == dto.VimPageHintFollowNew | |
| 220 | + | case vimPageMessageCopy: | |
| 221 | + | return kind == dto.VimPageYankObject || kind == dto.VimPageHintYankURL || kind == dto.VimPageVisual | |
| 222 | + | default: | |
| 223 | + | return false | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // StartVimPageInteraction starts link hints, visual selection, or a text-object yank. | |
| 228 | + | func (wv *WebView) StartVimPageInteraction(_ context.Context, request dto.VimPageInteractionRequest) error { | |
| 229 | + | if wv == nil || wv.destroyed.Load() { | |
| 230 | + | return errDestroyed | |
| 231 | + | } | |
| 232 | + | encoded, err := vimPageRequestJSON(request) | |
| 233 | + | if err != nil { | |
| 234 | + | return err | |
| 235 | + | } | |
| 236 | + | token, err := wv.armVimPageInteraction(request.Kind) | |
| 237 | + | if err != nil { | |
| 238 | + | return err | |
| 239 | + | } | |
| 240 | + | if err := wv.scheduleJavaScript(vimPageScript("start", token, encoded)); err != nil { | |
| 241 | + | wv.disarmVimPageInteraction() | |
| 242 | + | return err | |
| 243 | + | } | |
| 244 | + | return nil | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // SendVimPageKey forwards one canonical key to the active page interaction. | |
| 248 | + | func (wv *WebView) SendVimPageKey(_ context.Context, key string) error { | |
| 249 | + | if wv == nil || wv.destroyed.Load() { | |
| 250 | + | return errDestroyed | |
| 251 | + | } | |
| 252 | + | token := wv.currentVimPageToken() | |
| 253 | + | if key == "" || token == "" { | |
| 254 | + | return nil | |
| 255 | + | } | |
| 256 | + | return wv.scheduleJavaScript(vimPageScript("key", token, jsString(key))) | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // CancelVimPageInteraction removes any hint overlay or visual selection. | |
| 260 | + | func (wv *WebView) CancelVimPageInteraction(_ context.Context) error { | |
| 261 | + | if wv == nil || wv.destroyed.Load() { | |
| 262 | + | return errDestroyed | |
| 263 | + | } | |
| 264 | + | token := wv.currentVimPageToken() | |
| 265 | + | wv.disarmVimPageInteraction() | |
| 266 | + | if token == "" { | |
| 267 | + | return nil | |
| 268 | + | } | |
| 269 | + | return wv.scheduleJavaScript(vimPageScript("cancel", token)) | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // endVimPageInteractionOnNavigation drops an interaction whose document is | |
| 273 | + | // being replaced; its runtime can no longer report the end itself. | |
| 274 | + | func (wv *WebView) endVimPageInteractionOnNavigation() { | |
| 275 | + | if captured, generation := wv.disarmVimPageInteraction(); captured { | |
| 276 | + | wv.notifyVimPageInteractionEnded(generation) | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // handleVimPageBridge routes one runtime result from the trusted bridge. | |
| 281 | + | func (e *Engine) handleVimPageBridge(browser purecef.Browser, payload vimPageBridgePayload) { | |
| 282 | + | e.withBridgeSourceWebView(browser, "", "vim-page", func(wv *WebView) { | |
| 283 | + | wv.handleVimPageResult(payload) | |
| 284 | + | }) | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | // handleVimPageResult accepts a result only for the interaction the user | |
| 288 | + | // armed; results from stale or forged interactions are dropped. | |
| 289 | + | func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) { | |
| 290 | + | wv.mu.Lock() | |
| 291 | + | cb := wv.callbacks | |
| 292 | + | sourceURI := wv.uri | |
| 293 | + | armed := wv.vimPageToken | |
| 294 | + | kind := wv.vimPageKind | |
| 295 | + | generation := wv.vimPageGeneration | |
| 296 | + | valid := armed != "" && subtle.ConstantTimeCompare([]byte(armed), []byte(payload.Token)) == 1 | |
| 297 | + | if valid { | |
| 298 | + | // Every runtime message is terminal: a token grants exactly one result. | |
| 299 | + | wv.vimPageToken = "" | |
| 300 | + | wv.vimPageKind = 0 | |
| 301 | + | } | |
| 302 | + | wv.mu.Unlock() | |
| 303 | + | if !valid { | |
| 304 | + | logging.FromContext(wv.ctx).Debug().Str("type", payload.Type).Msg("cef: vim page result rejected — stale or unknown token") | |
| 305 | + | return | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | if kind.CapturesKeys() { | |
| 309 | + | wv.notifyVimPageInteractionEnded(generation) | |
| 310 | + | } | |
| 311 | + | if !vimPageResultAllowed(kind, payload.Type) { | |
| 312 | + | logging.FromContext(wv.ctx).Debug().Str("type", payload.Type).Msg("cef: vim page result rejected — not allowed for interaction") | |
| 313 | + | return | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | switch payload.Type { | |
| 317 | + | case vimPageMessageCopy: | |
| 318 | + | if wv.engine != nil { | |
| 319 | + | wv.engine.handleExplicitClipboardBridgeText(wv.id, "copy", payload.Text) | |
| 320 | + | } | |
| 321 | + | case vimPageMessageOpenNew: | |
| 322 | + | if !isNavigableVimHintURL(payload.URL, sourceURI) { | |
| 323 | + | logging.FromContext(wv.ctx).Debug().Msg("cef: vim hint open rejected — unsupported url") | |
| 324 | + | return | |
| 325 | + | } | |
| 326 | + | if cb == nil || cb.OnLinkMiddleClick == nil { | |
| 327 | + | logging.FromContext(wv.ctx).Debug().Msg("cef: vim hint open skipped — handler not available") | |
| 328 | + | return | |
| 329 | + | } | |
| 330 | + | target := payload.URL | |
| 331 | + | wv.runOnGTK(func() { cb.OnLinkMiddleClick(target) }) | |
| 332 | + | } | |
| 333 | + | } | |
| @@ -0,0 +1,333 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/subtle" | |||
| 6 | + | _ "embed" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 10 | + | "net/url" | |||
| 11 | + | "strings" | |||
| 12 | + | ||||
| 13 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 16 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 17 | + | "github.com/bnema/dumber/internal/logging" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | var _ port.VimPageInteractor = (*WebView)(nil) | |||
| 21 | + | ||||
| 22 | + | //go:embed vim_page_runtime.js | |||
| 23 | + | var vimPageRuntimeTemplateJS string | |||
| 24 | + | ||||
| 25 | + | var errVimPageTokenUnavailable = errors.New("vim page: interaction token unavailable") | |||
| 26 | + | ||||
| 27 | + | // vimPageBridgePayload is one result reported by the page runtime. Token is | |||
| 28 | + | // the per-interaction secret issued by StartVimPageInteraction. | |||
| 29 | + | type vimPageBridgePayload struct { | |||
| 30 | + | Type string `json:"type"` | |||
| 31 | + | Token string `json:"token"` | |||
| 32 | + | Text string `json:"text,omitempty"` | |||
| 33 | + | URL string `json:"url,omitempty"` | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | const ( | |||
| 37 | + | vimPageMessageCopy = "copy" | |||
| 38 | + | vimPageMessageOpenNew = "open-new" | |||
| 39 | + | vimPageMessageEnd = "end" | |||
| 40 | + | ) | |||
| 41 | + | ||||
| 42 | + | func decodeVimPageBridgePayload(body []byte) (vimPageBridgePayload, error) { | |||
| 43 | + | var payload vimPageBridgePayload | |||
| 44 | + | if len(body) > maxClipboardBytes { | |||
| 45 | + | return payload, errors.New("payload too large") | |||
| 46 | + | } | |||
| 47 | + | if err := json.Unmarshal(body, &payload); err != nil { | |||
| 48 | + | return payload, err | |||
| 49 | + | } | |||
| 50 | + | if payload.Token == "" { | |||
| 51 | + | return payload, errors.New("missing token") | |||
| 52 | + | } | |||
| 53 | + | switch payload.Type { | |||
| 54 | + | case vimPageMessageCopy: | |||
| 55 | + | if payload.Text == "" { | |||
| 56 | + | return payload, errors.New("missing text") | |||
| 57 | + | } | |||
| 58 | + | case vimPageMessageOpenNew: | |||
| 59 | + | if payload.URL == "" { | |||
| 60 | + | return payload, errors.New("missing url") | |||
| 61 | + | } | |||
| 62 | + | case vimPageMessageEnd: | |||
| 63 | + | default: | |||
| 64 | + | return payload, fmt.Errorf("unknown type %q", payload.Type) | |||
| 65 | + | } | |||
| 66 | + | return payload, nil | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // isNavigableVimHintURL keeps hint-opened panes to web documents with a host | |||
| 70 | + | // so a page cannot smuggle javascript: or internal URLs through the bridge. | |||
| 71 | + | // file: targets are allowed only from a page that is itself a file: document, | |||
| 72 | + | // matching Chromium's own http(s)-to-file navigation block. | |||
| 73 | + | func isNavigableVimHintURL(raw, sourceURI string) bool { | |||
| 74 | + | parsed, err := url.Parse(raw) | |||
| 75 | + | if err != nil { | |||
| 76 | + | return false | |||
| 77 | + | } | |||
| 78 | + | switch strings.ToLower(parsed.Scheme) { | |||
| 79 | + | case "http", "https": | |||
| 80 | + | return parsed.Host != "" | |||
| 81 | + | case "file": | |||
| 82 | + | source, err := url.Parse(sourceURI) | |||
| 83 | + | return err == nil && strings.EqualFold(source.Scheme, "file") | |||
| 84 | + | default: | |||
| 85 | + | return false | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func vimPageKindName(kind dto.VimPageInteractionKind) (string, bool) { | |||
| 90 | + | switch kind { | |||
| 91 | + | case dto.VimPageHintFollow: | |||
| 92 | + | return "hint-follow", true | |||
| 93 | + | case dto.VimPageHintFollowNew: | |||
| 94 | + | return "hint-follow-new", true | |||
| 95 | + | case dto.VimPageHintYankURL: | |||
| 96 | + | return "hint-yank-url", true | |||
| 97 | + | case dto.VimPageVisual: | |||
| 98 | + | return "visual", true | |||
| 99 | + | case dto.VimPageYankObject: | |||
| 100 | + | return "yank", true | |||
| 101 | + | default: | |||
| 102 | + | return "", false | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func vimPageObjectName(object dto.VimPageTextObject) (string, bool) { | |||
| 107 | + | switch object { | |||
| 108 | + | case dto.VimPageTextObjectSection: | |||
| 109 | + | return "section", true | |||
| 110 | + | case dto.VimPageTextObjectParagraph: | |||
| 111 | + | return "paragraph", true | |||
| 112 | + | case dto.VimPageTextObjectCode: | |||
| 113 | + | return "code", true | |||
| 114 | + | case dto.VimPageTextObjectTable: | |||
| 115 | + | return "table", true | |||
| 116 | + | case dto.VimPageTextObjectList: | |||
| 117 | + | return "list", true | |||
| 118 | + | default: | |||
| 119 | + | return "", false | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func vimPageRequestJSON(request dto.VimPageInteractionRequest) (string, error) { | |||
| 124 | + | kind, ok := vimPageKindName(request.Kind) | |||
| 125 | + | if !ok { | |||
| 126 | + | return "", fmt.Errorf("unsupported vim page interaction: %d", request.Kind) | |||
| 127 | + | } | |||
| 128 | + | object := "" | |||
| 129 | + | if request.Kind == dto.VimPageYankObject { | |||
| 130 | + | if object, ok = vimPageObjectName(request.Object); !ok { | |||
| 131 | + | return "", fmt.Errorf("unsupported vim text object: %d", request.Object) | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | encoded, err := json.Marshal(struct { | |||
| 135 | + | Kind string `json:"kind"` | |||
| 136 | + | Object string `json:"object,omitempty"` | |||
| 137 | + | Color string `json:"color,omitempty"` | |||
| 138 | + | }{Kind: kind, Object: object, Color: request.HighlightColor}) | |||
| 139 | + | if err != nil { | |||
| 140 | + | return "", err | |||
| 141 | + | } | |||
| 142 | + | return string(encoded), nil | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // jsString renders s as a JavaScript string literal (JSON is valid JS). | |||
| 146 | + | func jsString(s string) string { | |||
| 147 | + | encoded, _ := json.Marshal(s) | |||
| 148 | + | return string(encoded) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | // vimPageScript installs the runtime when absent and then invokes one method | |||
| 152 | + | // with the interaction token. The token is a per-interaction secret, not the | |||
| 153 | + | // shared bridge nonce, so a page that intercepts the call only learns a token | |||
| 154 | + | // valid for the interaction the user just started. | |||
| 155 | + | func vimPageScript(method, token string, args ...string) string { | |||
| 156 | + | call := append([]string{jsString(token)}, args...) | |||
| 157 | + | return vimPageRuntimeTemplateJS + "\nwindow.__dumberVimPage." + method + "(" + strings.Join(call, ", ") + ");" | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // armVimPageInteraction issues a fresh token for a new interaction and | |||
| 161 | + | // invalidates any previous one, so late results from it are rejected. | |||
| 162 | + | func (wv *WebView) armVimPageInteraction(kind dto.VimPageInteractionKind) (string, error) { | |||
| 163 | + | token := newBridgeNonce() | |||
| 164 | + | if token == "" { | |||
| 165 | + | return "", errVimPageTokenUnavailable | |||
| 166 | + | } | |||
| 167 | + | wv.mu.Lock() | |||
| 168 | + | wv.vimPageToken = token | |||
| 169 | + | wv.vimPageKind = kind | |||
| 170 | + | wv.vimPageGeneration++ | |||
| 171 | + | wv.mu.Unlock() | |||
| 172 | + | return token, nil | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func (wv *WebView) currentVimPageToken() string { | |||
| 176 | + | wv.mu.RLock() | |||
| 177 | + | defer wv.mu.RUnlock() | |||
| 178 | + | return wv.vimPageToken | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // disarmVimPageInteraction clears the active token. It reports whether a | |||
| 182 | + | // key-capturing interaction was active and the generation it belonged to. | |||
| 183 | + | func (wv *WebView) disarmVimPageInteraction() (bool, uint64) { | |||
| 184 | + | wv.mu.Lock() | |||
| 185 | + | defer wv.mu.Unlock() | |||
| 186 | + | captured := wv.vimPageToken != "" && wv.vimPageKind.CapturesKeys() | |||
| 187 | + | wv.vimPageToken = "" | |||
| 188 | + | wv.vimPageKind = 0 | |||
| 189 | + | return captured, wv.vimPageGeneration | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // notifyVimPageInteractionEnded runs the ended callback on GTK unless a newer | |||
| 193 | + | // interaction was armed in the meantime (keys queued ahead of the idle). | |||
| 194 | + | func (wv *WebView) notifyVimPageInteractionEnded(generation uint64) { | |||
| 195 | + | wv.mu.RLock() | |||
| 196 | + | cb := wv.callbacks | |||
| 197 | + | wv.mu.RUnlock() | |||
| 198 | + | if cb == nil || cb.OnVimPageInteractionEnded == nil { | |||
| 199 | + | return | |||
| 200 | + | } | |||
| 201 | + | ended := cb.OnVimPageInteractionEnded | |||
| 202 | + | wv.runOnGTK(func() { | |||
| 203 | + | wv.mu.RLock() | |||
| 204 | + | current := wv.vimPageGeneration | |||
| 205 | + | wv.mu.RUnlock() | |||
| 206 | + | if current == generation { | |||
| 207 | + | ended() | |||
| 208 | + | } | |||
| 209 | + | }) | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // vimPageResultAllowed ties each result type to the interaction the user | |||
| 213 | + | // started, so a hijacked token only yields the kind of result requested. | |||
| 214 | + | func vimPageResultAllowed(kind dto.VimPageInteractionKind, messageType string) bool { | |||
| 215 | + | switch messageType { | |||
| 216 | + | case vimPageMessageEnd: | |||
| 217 | + | return true | |||
| 218 | + | case vimPageMessageOpenNew: | |||
| 219 | + | return kind == dto.VimPageHintFollow || kind == dto.VimPageHintFollowNew | |||
| 220 | + | case vimPageMessageCopy: | |||
| 221 | + | return kind == dto.VimPageYankObject || kind == dto.VimPageHintYankURL || kind == dto.VimPageVisual | |||
| 222 | + | default: | |||
| 223 | + | return false | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | // StartVimPageInteraction starts link hints, visual selection, or a text-object yank. | |||
| 228 | + | func (wv *WebView) StartVimPageInteraction(_ context.Context, request dto.VimPageInteractionRequest) error { | |||
| 229 | + | if wv == nil || wv.destroyed.Load() { | |||
| 230 | + | return errDestroyed | |||
| 231 | + | } | |||
| 232 | + | encoded, err := vimPageRequestJSON(request) | |||
| 233 | + | if err != nil { | |||
| 234 | + | return err | |||
| 235 | + | } | |||
| 236 | + | token, err := wv.armVimPageInteraction(request.Kind) | |||
| 237 | + | if err != nil { | |||
| 238 | + | return err | |||
| 239 | + | } | |||
| 240 | + | if err := wv.scheduleJavaScript(vimPageScript("start", token, encoded)); err != nil { | |||
| 241 | + | wv.disarmVimPageInteraction() | |||
| 242 | + | return err | |||
| 243 | + | } | |||
| 244 | + | return nil | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // SendVimPageKey forwards one canonical key to the active page interaction. | |||
| 248 | + | func (wv *WebView) SendVimPageKey(_ context.Context, key string) error { | |||
| 249 | + | if wv == nil || wv.destroyed.Load() { | |||
| 250 | + | return errDestroyed | |||
| 251 | + | } | |||
| 252 | + | token := wv.currentVimPageToken() | |||
| 253 | + | if key == "" || token == "" { | |||
| 254 | + | return nil | |||
| 255 | + | } | |||
| 256 | + | return wv.scheduleJavaScript(vimPageScript("key", token, jsString(key))) | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // CancelVimPageInteraction removes any hint overlay or visual selection. | |||
| 260 | + | func (wv *WebView) CancelVimPageInteraction(_ context.Context) error { | |||
| 261 | + | if wv == nil || wv.destroyed.Load() { | |||
| 262 | + | return errDestroyed | |||
| 263 | + | } | |||
| 264 | + | token := wv.currentVimPageToken() | |||
| 265 | + | wv.disarmVimPageInteraction() | |||
| 266 | + | if token == "" { | |||
| 267 | + | return nil | |||
| 268 | + | } | |||
| 269 | + | return wv.scheduleJavaScript(vimPageScript("cancel", token)) | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | // endVimPageInteractionOnNavigation drops an interaction whose document is | |||
| 273 | + | // being replaced; its runtime can no longer report the end itself. | |||
| 274 | + | func (wv *WebView) endVimPageInteractionOnNavigation() { | |||
| 275 | + | if captured, generation := wv.disarmVimPageInteraction(); captured { | |||
| 276 | + | wv.notifyVimPageInteractionEnded(generation) | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | // handleVimPageBridge routes one runtime result from the trusted bridge. | |||
| 281 | + | func (e *Engine) handleVimPageBridge(browser purecef.Browser, payload vimPageBridgePayload) { | |||
| 282 | + | e.withBridgeSourceWebView(browser, "", "vim-page", func(wv *WebView) { | |||
| 283 | + | wv.handleVimPageResult(payload) | |||
| 284 | + | }) | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | // handleVimPageResult accepts a result only for the interaction the user | |||
| 288 | + | // armed; results from stale or forged interactions are dropped. | |||
| 289 | + | func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) { | |||
| 290 | + | wv.mu.Lock() | |||
| 291 | + | cb := wv.callbacks | |||
| 292 | + | sourceURI := wv.uri | |||
| 293 | + | armed := wv.vimPageToken | |||
| 294 | + | kind := wv.vimPageKind | |||
| 295 | + | generation := wv.vimPageGeneration | |||
| 296 | + | valid := armed != "" && subtle.ConstantTimeCompare([]byte(armed), []byte(payload.Token)) == 1 | |||
| 297 | + | if valid { | |||
| 298 | + | // Every runtime message is terminal: a token grants exactly one result. | |||
| 299 | + | wv.vimPageToken = "" | |||
| 300 | + | wv.vimPageKind = 0 | |||
| 301 | + | } | |||
| 302 | + | wv.mu.Unlock() | |||
| 303 | + | if !valid { | |||
| 304 | + | logging.FromContext(wv.ctx).Debug().Str("type", payload.Type).Msg("cef: vim page result rejected — stale or unknown token") | |||
| 305 | + | return | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | if kind.CapturesKeys() { | |||
| 309 | + | wv.notifyVimPageInteractionEnded(generation) | |||
| 310 | + | } | |||
| 311 | + | if !vimPageResultAllowed(kind, payload.Type) { | |||
| 312 | + | logging.FromContext(wv.ctx).Debug().Str("type", payload.Type).Msg("cef: vim page result rejected — not allowed for interaction") | |||
| 313 | + | return | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | switch payload.Type { | |||
| 317 | + | case vimPageMessageCopy: | |||
| 318 | + | if wv.engine != nil { | |||
| 319 | + | wv.engine.handleExplicitClipboardBridgeText(wv.id, "copy", payload.Text) | |||
| 320 | + | } | |||
| 321 | + | case vimPageMessageOpenNew: | |||
| 322 | + | if !isNavigableVimHintURL(payload.URL, sourceURI) { | |||
| 323 | + | logging.FromContext(wv.ctx).Debug().Msg("cef: vim hint open rejected — unsupported url") | |||
| 324 | + | return | |||
| 325 | + | } | |||
| 326 | + | if cb == nil || cb.OnLinkMiddleClick == nil { | |||
| 327 | + | logging.FromContext(wv.ctx).Debug().Msg("cef: vim hint open skipped — handler not available") | |||
| 328 | + | return | |||
| 329 | + | } | |||
| 330 | + | target := payload.URL | |||
| 331 | + | wv.runOnGTK(func() { cb.OnLinkMiddleClick(target) }) | |||
| 332 | + | } | |||
| 333 | + | } | |||
A internal/infrastructure/cef/vim_page_runtime.js +376 -0
| @@ -0,0 +1,376 @@ | |||
| 1 | + | // Dumber Vim Mode page runtime: link hints, visual selection, text-object | |
| 2 | + | // yanks. Installed once per document; Go calls window.__dumberVimPage.* with a | |
| 3 | + | // per-interaction token. Each interaction reports exactly one terminal message | |
| 4 | + | // (copy, open-new, or end) to dumb:///api/vim-page; Go accepts it only while | |
| 5 | + | // that token is armed, so the token grants at most one result. | |
| 6 | + | (() => { | |
| 7 | + | if (window.__dumberVimPage) return; | |
| 8 | + | ||
| 9 | + | const nativeFetch = window.fetch.bind(window); | |
| 10 | + | const HINT_CHARS = "sadfjklewcmpgh"; | |
| 11 | + | const ALL_TARGETS = [ | |
| 12 | + | "a[href]", "button", "input:not([type=\"hidden\"])", "select", "textarea", "summary", | |
| 13 | + | "[role=\"button\"]", "[role=\"link\"]", "[role=\"tab\"]", "[role=\"checkbox\"]", | |
| 14 | + | "[role=\"menuitem\"]", "[onclick]", "[contenteditable=\"\"]", "[contenteditable=\"true\"]", | |
| 15 | + | "[tabindex]:not([tabindex=\"-1\"])", | |
| 16 | + | ].join(","); | |
| 17 | + | const OBJECT_SELECTORS = { | |
| 18 | + | paragraph: ["p"], | |
| 19 | + | code: ["pre", "code"], | |
| 20 | + | table: ["table"], | |
| 21 | + | list: ["ul,ol,dl"], | |
| 22 | + | }; | |
| 23 | + | const VISUAL_MOTIONS = { | |
| 24 | + | h: ["backward", "character"], "<Left>": ["backward", "character"], | |
| 25 | + | l: ["forward", "character"], "<Right>": ["forward", "character"], | |
| 26 | + | j: ["forward", "line"], "<Down>": ["forward", "line"], | |
| 27 | + | k: ["backward", "line"], "<Up>": ["backward", "line"], | |
| 28 | + | w: ["forward", "word"], e: ["forward", "word"], b: ["backward", "word"], | |
| 29 | + | "0": ["backward", "lineboundary"], "^": ["backward", "lineboundary"], | |
| 30 | + | "$": ["forward", "lineboundary"], | |
| 31 | + | "(": ["backward", "sentence"], ")": ["forward", "sentence"], | |
| 32 | + | "{": ["backward", "paragraph"], "}": ["forward", "paragraph"], | |
| 33 | + | G: ["forward", "documentboundary"], | |
| 34 | + | }; | |
| 35 | + | ||
| 36 | + | let state = null; | |
| 37 | + | ||
| 38 | + | function post(token, message) { | |
| 39 | + | let encoded = ""; | |
| 40 | + | try { | |
| 41 | + | encoded = btoa(unescape(encodeURIComponent(JSON.stringify(Object.assign({ token }, message))))); | |
| 42 | + | } catch (_) { | |
| 43 | + | return; | |
| 44 | + | } | |
| 45 | + | nativeFetch("dumb:///api/vim-page", { | |
| 46 | + | method: "POST", | |
| 47 | + | headers: { "X-Dumber-Body": encoded }, | |
| 48 | + | }).catch(() => {}); | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | function visibleRect(element) { | |
| 52 | + | if (!(element instanceof Element) || !element.isConnected) return null; | |
| 53 | + | if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return null; | |
| 54 | + | const style = window.getComputedStyle(element); | |
| 55 | + | if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return null; | |
| 56 | + | for (const rect of element.getClientRects()) { | |
| 57 | + | if (rect.width < 1 || rect.height < 1) continue; | |
| 58 | + | if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= window.innerHeight || rect.left >= window.innerWidth) continue; | |
| 59 | + | return rect; | |
| 60 | + | } | |
| 61 | + | return null; | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | function isEditable(element) { | |
| 65 | + | if (element.isContentEditable) return true; | |
| 66 | + | if (element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) return true; | |
| 67 | + | if (!(element instanceof HTMLInputElement)) return false; | |
| 68 | + | return !["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(element.type); | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | function flash(element, color) { | |
| 72 | + | if (!(element instanceof HTMLElement)) return; | |
| 73 | + | const previousOutline = element.style.outline; | |
| 74 | + | const previousOffset = element.style.outlineOffset; | |
| 75 | + | element.style.outline = "3px solid " + color; | |
| 76 | + | element.style.outlineOffset = "3px"; | |
| 77 | + | setTimeout(() => { | |
| 78 | + | element.style.outline = previousOutline; | |
| 79 | + | element.style.outlineOffset = previousOffset; | |
| 80 | + | }, 400); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // --- Link hints ----------------------------------------------------------- | |
| 84 | + | ||
| 85 | + | function hintLabels(count) { | |
| 86 | + | let length = 1; | |
| 87 | + | while (Math.pow(HINT_CHARS.length, length) < count) length++; | |
| 88 | + | const labels = []; | |
| 89 | + | for (let i = 0; i < count; i++) { | |
| 90 | + | let n = i; | |
| 91 | + | let label = ""; | |
| 92 | + | for (let j = 0; j < length; j++) { | |
| 93 | + | label = HINT_CHARS[n % HINT_CHARS.length] + label; | |
| 94 | + | n = Math.floor(n / HINT_CHARS.length); | |
| 95 | + | } | |
| 96 | + | labels.push(label); | |
| 97 | + | } | |
| 98 | + | return labels; | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | function hintTargets(linksOnly) { | |
| 102 | + | const seen = new Set(); | |
| 103 | + | const targets = []; | |
| 104 | + | for (const element of document.querySelectorAll(linksOnly ? "a[href]" : ALL_TARGETS)) { | |
| 105 | + | if (seen.has(element) || element.disabled) continue; | |
| 106 | + | const rect = visibleRect(element); | |
| 107 | + | if (!rect) continue; | |
| 108 | + | const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1); | |
| 109 | + | const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1); | |
| 110 | + | const hit = document.elementFromPoint(x, y); | |
| 111 | + | if (hit && !element.contains(hit) && !hit.contains(element)) continue; | |
| 112 | + | seen.add(element); | |
| 113 | + | targets.push({ element, rect }); | |
| 114 | + | } | |
| 115 | + | return targets; | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | function startHints(kind, color) { | |
| 119 | + | const targets = hintTargets(kind !== "hint-follow"); | |
| 120 | + | if (targets.length === 0) return false; | |
| 121 | + | const labels = hintLabels(targets.length); | |
| 122 | + | const root = document.createElement("div"); | |
| 123 | + | root.setAttribute("data-dumber-vim-hints", ""); | |
| 124 | + | root.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:2147483647;"; | |
| 125 | + | const hints = targets.map((target, index) => { | |
| 126 | + | const node = document.createElement("span"); | |
| 127 | + | node.style.cssText = [ | |
| 128 | + | "position:fixed", "left:" + Math.max(target.rect.left, 0) + "px", | |
| 129 | + | "top:" + Math.max(target.rect.top, 0) + "px", "padding:1px 4px", "border-radius:3px", | |
| 130 | + | "font:bold 11px/1.3 monospace", "text-transform:uppercase", "color:#111", | |
| 131 | + | "background:" + color, "box-shadow:0 1px 3px rgba(0,0,0,.4)", | |
| 132 | + | ].join(";"); | |
| 133 | + | node.textContent = labels[index]; | |
| 134 | + | root.appendChild(node); | |
| 135 | + | return { label: labels[index], element: target.element, node }; | |
| 136 | + | }); | |
| 137 | + | document.documentElement.appendChild(root); | |
| 138 | + | state.root = root; | |
| 139 | + | state.hints = hints; | |
| 140 | + | state.typed = ""; | |
| 141 | + | return true; | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | function renderHintFilter() { | |
| 145 | + | for (const hint of state.hints) { | |
| 146 | + | const visible = hint.label.startsWith(state.typed); | |
| 147 | + | hint.node.style.display = visible ? "" : "none"; | |
| 148 | + | if (visible) { | |
| 149 | + | hint.node.textContent = ""; | |
| 150 | + | const typed = document.createElement("span"); | |
| 151 | + | typed.style.opacity = "0.45"; | |
| 152 | + | typed.textContent = state.typed; | |
| 153 | + | hint.node.append(typed, hint.label.slice(state.typed.length)); | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // activateHint returns the terminal message for the chosen hint. | |
| 159 | + | function activateHint(kind, element) { | |
| 160 | + | const url = element instanceof HTMLAnchorElement ? element.href : ""; | |
| 161 | + | if (kind === "hint-yank-url") return url ? { type: "copy", text: url } : { type: "end" }; | |
| 162 | + | if (kind === "hint-follow-new" || (url && element.target === "_blank")) { | |
| 163 | + | return url ? { type: "open-new", url } : { type: "end" }; | |
| 164 | + | } | |
| 165 | + | element.focus({ preventScroll: true }); | |
| 166 | + | if (!isEditable(element)) element.click(); | |
| 167 | + | return { type: "end" }; | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | function hintKey(key) { | |
| 171 | + | if (key === "<Escape>") return finish({ type: "end" }); | |
| 172 | + | if (key === "<BackSpace>") { | |
| 173 | + | state.typed = state.typed.slice(0, -1); | |
| 174 | + | return renderHintFilter(); | |
| 175 | + | } | |
| 176 | + | if (key.length !== 1) return; | |
| 177 | + | const typed = state.typed + key.toLowerCase(); | |
| 178 | + | const matches = state.hints.filter((hint) => hint.label.startsWith(typed)); | |
| 179 | + | if (matches.length === 0) return; | |
| 180 | + | state.typed = typed; | |
| 181 | + | if (matches.length === 1 && matches[0].label === typed) { | |
| 182 | + | const kind = state.kind; | |
| 183 | + | const element = matches[0].element; | |
| 184 | + | // Remove the overlay before activation so the click hits the page. | |
| 185 | + | if (state.root) state.root.remove(); | |
| 186 | + | finish(activateHint(kind, element)); | |
| 187 | + | return; | |
| 188 | + | } | |
| 189 | + | renderHintFilter(); | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // --- Visual selection ----------------------------------------------------- | |
| 193 | + | ||
| 194 | + | function firstVisibleTextPosition() { | |
| 195 | + | const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT, { | |
| 196 | + | acceptNode: (node) => (node.data.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT), | |
| 197 | + | }); | |
| 198 | + | for (let node = walker.nextNode(); node; node = walker.nextNode()) { | |
| 199 | + | if (!visibleRect(node.parentElement)) continue; | |
| 200 | + | const range = document.createRange(); | |
| 201 | + | range.selectNodeContents(node); | |
| 202 | + | const rect = range.getBoundingClientRect(); | |
| 203 | + | if (rect.bottom <= 0 || rect.top >= window.innerHeight) continue; | |
| 204 | + | return { node, offset: Math.max(node.data.search(/\S/), 0) }; | |
| 205 | + | } | |
| 206 | + | return null; | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | function startVisual() { | |
| 210 | + | const selection = window.getSelection(); | |
| 211 | + | if (!selection) return false; | |
| 212 | + | if (selection.rangeCount === 0 || selection.isCollapsed) { | |
| 213 | + | const start = firstVisibleTextPosition(); | |
| 214 | + | if (!start) return false; | |
| 215 | + | selection.setBaseAndExtent(start.node, start.offset, start.node, start.offset); | |
| 216 | + | selection.modify("extend", "forward", "character"); | |
| 217 | + | } | |
| 218 | + | state.count = ""; | |
| 219 | + | state.pendingG = false; | |
| 220 | + | return true; | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | function revealSelectionFocus(selection) { | |
| 224 | + | if (!selection.focusNode) return; | |
| 225 | + | const range = document.createRange(); | |
| 226 | + | range.setStart(selection.focusNode, selection.focusOffset); | |
| 227 | + | range.collapse(true); | |
| 228 | + | let rect = range.getBoundingClientRect(); | |
| 229 | + | if (rect.top === 0 && rect.bottom === 0) { | |
| 230 | + | const element = selection.focusNode.nodeType === Node.ELEMENT_NODE | |
| 231 | + | ? selection.focusNode : selection.focusNode.parentElement; | |
| 232 | + | if (!element) return; | |
| 233 | + | rect = element.getBoundingClientRect(); | |
| 234 | + | } | |
| 235 | + | const margin = 40; | |
| 236 | + | if (rect.top < margin) window.scrollBy(0, rect.top - margin); | |
| 237 | + | else if (rect.bottom > window.innerHeight - margin) window.scrollBy(0, rect.bottom - window.innerHeight + margin); | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | function visualKey(key) { | |
| 241 | + | const selection = window.getSelection(); | |
| 242 | + | if (!selection || selection.rangeCount === 0) return finish({ type: "end" }); | |
| 243 | + | if (key === "<Escape>" || key === "v") { | |
| 244 | + | selection.removeAllRanges(); | |
| 245 | + | return finish({ type: "end" }); | |
| 246 | + | } | |
| 247 | + | if (key === "y" || key === "<Return>") { | |
| 248 | + | const text = selection.toString(); | |
| 249 | + | selection.removeAllRanges(); | |
| 250 | + | return finish(text ? { type: "copy", text } : { type: "end" }); | |
| 251 | + | } | |
| 252 | + | if (key === "o") { | |
| 253 | + | selection.setBaseAndExtent(selection.focusNode, selection.focusOffset, selection.anchorNode, selection.anchorOffset); | |
| 254 | + | return revealSelectionFocus(selection); | |
| 255 | + | } | |
| 256 | + | if (/^[1-9]$/.test(key) || (key === "0" && state.count !== "")) { | |
| 257 | + | state.count += key; | |
| 258 | + | return; | |
| 259 | + | } | |
| 260 | + | let motion = VISUAL_MOTIONS[key]; | |
| 261 | + | if (key === "g") { | |
| 262 | + | if (!state.pendingG) { | |
| 263 | + | state.pendingG = true; | |
| 264 | + | return; | |
| 265 | + | } | |
| 266 | + | motion = ["backward", "documentboundary"]; | |
| 267 | + | } | |
| 268 | + | state.pendingG = false; | |
| 269 | + | const count = Math.min(parseInt(state.count || "1", 10), 500); | |
| 270 | + | state.count = ""; | |
| 271 | + | if (!motion) return; | |
| 272 | + | for (let i = 0; i < count; i++) selection.modify("extend", motion[0], motion[1]); | |
| 273 | + | revealSelectionFocus(selection); | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // --- Text objects --------------------------------------------------------- | |
| 277 | + | ||
| 278 | + | function selectionText(range) { | |
| 279 | + | const selection = window.getSelection(); | |
| 280 | + | if (!selection) return range.toString(); | |
| 281 | + | const saved = []; | |
| 282 | + | for (let i = 0; i < selection.rangeCount; i++) saved.push(selection.getRangeAt(i)); | |
| 283 | + | selection.removeAllRanges(); | |
| 284 | + | selection.addRange(range); | |
| 285 | + | const text = selection.toString(); | |
| 286 | + | selection.removeAllRanges(); | |
| 287 | + | saved.forEach((savedRange) => selection.addRange(savedRange)); | |
| 288 | + | return text; | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | function sectionText(color) { | |
| 292 | + | const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")) | |
| 293 | + | .filter((heading) => heading.getClientRects().length > 0); | |
| 294 | + | if (headings.length === 0) return ""; | |
| 295 | + | const threshold = window.innerHeight * 0.25; | |
| 296 | + | let index = -1; | |
| 297 | + | headings.forEach((heading, i) => { | |
| 298 | + | if (heading.getBoundingClientRect().top <= threshold) index = i; | |
| 299 | + | }); | |
| 300 | + | if (index < 0) index = 0; | |
| 301 | + | const heading = headings[index]; | |
| 302 | + | const level = Number(heading.tagName[1]); | |
| 303 | + | const boundary = headings.slice(index + 1).find((next) => Number(next.tagName[1]) <= level); | |
| 304 | + | const range = document.createRange(); | |
| 305 | + | range.setStartBefore(heading); | |
| 306 | + | if (boundary) range.setEndBefore(boundary); | |
| 307 | + | else range.setEndAfter(document.body ? document.body.lastChild || heading : heading); | |
| 308 | + | flash(heading, color); | |
| 309 | + | return selectionText(range); | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | function objectText(object, color) { | |
| 313 | + | if (object === "section") return sectionText(color); | |
| 314 | + | for (const selector of OBJECT_SELECTORS[object] || []) { | |
| 315 | + | const candidates = Array.from(document.querySelectorAll(selector)); | |
| 316 | + | const element = candidates.find((candidate) => | |
| 317 | + | visibleRect(candidate) && !candidates.some((other) => other !== candidate && other.contains(candidate))); | |
| 318 | + | if (element) { | |
| 319 | + | flash(element, color); | |
| 320 | + | return element.innerText; | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | return ""; | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | // --- Lifecycle ------------------------------------------------------------ | |
| 327 | + | ||
| 328 | + | function cleanup() { | |
| 329 | + | if (state && state.root) state.root.remove(); | |
| 330 | + | state = null; | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | // finish tears down the interaction and reports its single terminal message. | |
| 334 | + | function finish(message) { | |
| 335 | + | const token = state ? state.token : ""; | |
| 336 | + | cleanup(); | |
| 337 | + | if (token) post(token, message); | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | Object.defineProperty(window, "__dumberVimPage", { | |
| 341 | + | configurable: false, | |
| 342 | + | enumerable: false, | |
| 343 | + | value: Object.freeze({ | |
| 344 | + | start(token, request) { | |
| 345 | + | cleanup(); | |
| 346 | + | const color = request.color || "#fbbf24"; | |
| 347 | + | if (request.kind === "yank") { | |
| 348 | + | const text = objectText(request.object, color).trim(); | |
| 349 | + | post(token, text ? { type: "copy", text } : { type: "end" }); | |
| 350 | + | return; | |
| 351 | + | } | |
| 352 | + | state = { kind: request.kind, token }; | |
| 353 | + | const started = request.kind === "visual" ? startVisual() : startHints(request.kind, color); | |
| 354 | + | if (!started) finish({ type: "end" }); | |
| 355 | + | }, | |
| 356 | + | key(token, key) { | |
| 357 | + | if (!state || state.token !== token) { | |
| 358 | + | // Unknown interaction (for example the runtime was reinstalled): | |
| 359 | + | // release that token's key capture without touching live state. | |
| 360 | + | post(token, { type: "end" }); | |
| 361 | + | return; | |
| 362 | + | } | |
| 363 | + | if (state.kind === "visual") return visualKey(key); | |
| 364 | + | return hintKey(key); | |
| 365 | + | }, | |
| 366 | + | cancel(token) { | |
| 367 | + | if (!state || state.token !== token) return; | |
| 368 | + | if (state && state.kind === "visual") { | |
| 369 | + | const selection = window.getSelection(); | |
| 370 | + | if (selection) selection.removeAllRanges(); | |
| 371 | + | } | |
| 372 | + | cleanup(); | |
| 373 | + | }, | |
| 374 | + | }), | |
| 375 | + | }); | |
| 376 | + | })(); | |
| @@ -0,0 +1,376 @@ | |||||
| 1 | + | // Dumber Vim Mode page runtime: link hints, visual selection, text-object | |||
| 2 | + | // yanks. Installed once per document; Go calls window.__dumberVimPage.* with a | |||
| 3 | + | // per-interaction token. Each interaction reports exactly one terminal message | |||
| 4 | + | // (copy, open-new, or end) to dumb:///api/vim-page; Go accepts it only while | |||
| 5 | + | // that token is armed, so the token grants at most one result. | |||
| 6 | + | (() => { | |||
| 7 | + | if (window.__dumberVimPage) return; | |||
| 8 | + | ||||
| 9 | + | const nativeFetch = window.fetch.bind(window); | |||
| 10 | + | const HINT_CHARS = "sadfjklewcmpgh"; | |||
| 11 | + | const ALL_TARGETS = [ | |||
| 12 | + | "a[href]", "button", "input:not([type=\"hidden\"])", "select", "textarea", "summary", | |||
| 13 | + | "[role=\"button\"]", "[role=\"link\"]", "[role=\"tab\"]", "[role=\"checkbox\"]", | |||
| 14 | + | "[role=\"menuitem\"]", "[onclick]", "[contenteditable=\"\"]", "[contenteditable=\"true\"]", | |||
| 15 | + | "[tabindex]:not([tabindex=\"-1\"])", | |||
| 16 | + | ].join(","); | |||
| 17 | + | const OBJECT_SELECTORS = { | |||
| 18 | + | paragraph: ["p"], | |||
| 19 | + | code: ["pre", "code"], | |||
| 20 | + | table: ["table"], | |||
| 21 | + | list: ["ul,ol,dl"], | |||
| 22 | + | }; | |||
| 23 | + | const VISUAL_MOTIONS = { | |||
| 24 | + | h: ["backward", "character"], "<Left>": ["backward", "character"], | |||
| 25 | + | l: ["forward", "character"], "<Right>": ["forward", "character"], | |||
| 26 | + | j: ["forward", "line"], "<Down>": ["forward", "line"], | |||
| 27 | + | k: ["backward", "line"], "<Up>": ["backward", "line"], | |||
| 28 | + | w: ["forward", "word"], e: ["forward", "word"], b: ["backward", "word"], | |||
| 29 | + | "0": ["backward", "lineboundary"], "^": ["backward", "lineboundary"], | |||
| 30 | + | "$": ["forward", "lineboundary"], | |||
| 31 | + | "(": ["backward", "sentence"], ")": ["forward", "sentence"], | |||
| 32 | + | "{": ["backward", "paragraph"], "}": ["forward", "paragraph"], | |||
| 33 | + | G: ["forward", "documentboundary"], | |||
| 34 | + | }; | |||
| 35 | + | ||||
| 36 | + | let state = null; | |||
| 37 | + | ||||
| 38 | + | function post(token, message) { | |||
| 39 | + | let encoded = ""; | |||
| 40 | + | try { | |||
| 41 | + | encoded = btoa(unescape(encodeURIComponent(JSON.stringify(Object.assign({ token }, message))))); | |||
| 42 | + | } catch (_) { | |||
| 43 | + | return; | |||
| 44 | + | } | |||
| 45 | + | nativeFetch("dumb:///api/vim-page", { | |||
| 46 | + | method: "POST", | |||
| 47 | + | headers: { "X-Dumber-Body": encoded }, | |||
| 48 | + | }).catch(() => {}); | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | function visibleRect(element) { | |||
| 52 | + | if (!(element instanceof Element) || !element.isConnected) return null; | |||
| 53 | + | if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return null; | |||
| 54 | + | const style = window.getComputedStyle(element); | |||
| 55 | + | if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return null; | |||
| 56 | + | for (const rect of element.getClientRects()) { | |||
| 57 | + | if (rect.width < 1 || rect.height < 1) continue; | |||
| 58 | + | if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= window.innerHeight || rect.left >= window.innerWidth) continue; | |||
| 59 | + | return rect; | |||
| 60 | + | } | |||
| 61 | + | return null; | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | function isEditable(element) { | |||
| 65 | + | if (element.isContentEditable) return true; | |||
| 66 | + | if (element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) return true; | |||
| 67 | + | if (!(element instanceof HTMLInputElement)) return false; | |||
| 68 | + | return !["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(element.type); | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | function flash(element, color) { | |||
| 72 | + | if (!(element instanceof HTMLElement)) return; | |||
| 73 | + | const previousOutline = element.style.outline; | |||
| 74 | + | const previousOffset = element.style.outlineOffset; | |||
| 75 | + | element.style.outline = "3px solid " + color; | |||
| 76 | + | element.style.outlineOffset = "3px"; | |||
| 77 | + | setTimeout(() => { | |||
| 78 | + | element.style.outline = previousOutline; | |||
| 79 | + | element.style.outlineOffset = previousOffset; | |||
| 80 | + | }, 400); | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // --- Link hints ----------------------------------------------------------- | |||
| 84 | + | ||||
| 85 | + | function hintLabels(count) { | |||
| 86 | + | let length = 1; | |||
| 87 | + | while (Math.pow(HINT_CHARS.length, length) < count) length++; | |||
| 88 | + | const labels = []; | |||
| 89 | + | for (let i = 0; i < count; i++) { | |||
| 90 | + | let n = i; | |||
| 91 | + | let label = ""; | |||
| 92 | + | for (let j = 0; j < length; j++) { | |||
| 93 | + | label = HINT_CHARS[n % HINT_CHARS.length] + label; | |||
| 94 | + | n = Math.floor(n / HINT_CHARS.length); | |||
| 95 | + | } | |||
| 96 | + | labels.push(label); | |||
| 97 | + | } | |||
| 98 | + | return labels; | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | function hintTargets(linksOnly) { | |||
| 102 | + | const seen = new Set(); | |||
| 103 | + | const targets = []; | |||
| 104 | + | for (const element of document.querySelectorAll(linksOnly ? "a[href]" : ALL_TARGETS)) { | |||
| 105 | + | if (seen.has(element) || element.disabled) continue; | |||
| 106 | + | const rect = visibleRect(element); | |||
| 107 | + | if (!rect) continue; | |||
| 108 | + | const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1); | |||
| 109 | + | const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1); | |||
| 110 | + | const hit = document.elementFromPoint(x, y); | |||
| 111 | + | if (hit && !element.contains(hit) && !hit.contains(element)) continue; | |||
| 112 | + | seen.add(element); | |||
| 113 | + | targets.push({ element, rect }); | |||
| 114 | + | } | |||
| 115 | + | return targets; | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | function startHints(kind, color) { | |||
| 119 | + | const targets = hintTargets(kind !== "hint-follow"); | |||
| 120 | + | if (targets.length === 0) return false; | |||
| 121 | + | const labels = hintLabels(targets.length); | |||
| 122 | + | const root = document.createElement("div"); | |||
| 123 | + | root.setAttribute("data-dumber-vim-hints", ""); | |||
| 124 | + | root.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:2147483647;"; | |||
| 125 | + | const hints = targets.map((target, index) => { | |||
| 126 | + | const node = document.createElement("span"); | |||
| 127 | + | node.style.cssText = [ | |||
| 128 | + | "position:fixed", "left:" + Math.max(target.rect.left, 0) + "px", | |||
| 129 | + | "top:" + Math.max(target.rect.top, 0) + "px", "padding:1px 4px", "border-radius:3px", | |||
| 130 | + | "font:bold 11px/1.3 monospace", "text-transform:uppercase", "color:#111", | |||
| 131 | + | "background:" + color, "box-shadow:0 1px 3px rgba(0,0,0,.4)", | |||
| 132 | + | ].join(";"); | |||
| 133 | + | node.textContent = labels[index]; | |||
| 134 | + | root.appendChild(node); | |||
| 135 | + | return { label: labels[index], element: target.element, node }; | |||
| 136 | + | }); | |||
| 137 | + | document.documentElement.appendChild(root); | |||
| 138 | + | state.root = root; | |||
| 139 | + | state.hints = hints; | |||
| 140 | + | state.typed = ""; | |||
| 141 | + | return true; | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | function renderHintFilter() { | |||
| 145 | + | for (const hint of state.hints) { | |||
| 146 | + | const visible = hint.label.startsWith(state.typed); | |||
| 147 | + | hint.node.style.display = visible ? "" : "none"; | |||
| 148 | + | if (visible) { | |||
| 149 | + | hint.node.textContent = ""; | |||
| 150 | + | const typed = document.createElement("span"); | |||
| 151 | + | typed.style.opacity = "0.45"; | |||
| 152 | + | typed.textContent = state.typed; | |||
| 153 | + | hint.node.append(typed, hint.label.slice(state.typed.length)); | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // activateHint returns the terminal message for the chosen hint. | |||
| 159 | + | function activateHint(kind, element) { | |||
| 160 | + | const url = element instanceof HTMLAnchorElement ? element.href : ""; | |||
| 161 | + | if (kind === "hint-yank-url") return url ? { type: "copy", text: url } : { type: "end" }; | |||
| 162 | + | if (kind === "hint-follow-new" || (url && element.target === "_blank")) { | |||
| 163 | + | return url ? { type: "open-new", url } : { type: "end" }; | |||
| 164 | + | } | |||
| 165 | + | element.focus({ preventScroll: true }); | |||
| 166 | + | if (!isEditable(element)) element.click(); | |||
| 167 | + | return { type: "end" }; | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | function hintKey(key) { | |||
| 171 | + | if (key === "<Escape>") return finish({ type: "end" }); | |||
| 172 | + | if (key === "<BackSpace>") { | |||
| 173 | + | state.typed = state.typed.slice(0, -1); | |||
| 174 | + | return renderHintFilter(); | |||
| 175 | + | } | |||
| 176 | + | if (key.length !== 1) return; | |||
| 177 | + | const typed = state.typed + key.toLowerCase(); | |||
| 178 | + | const matches = state.hints.filter((hint) => hint.label.startsWith(typed)); | |||
| 179 | + | if (matches.length === 0) return; | |||
| 180 | + | state.typed = typed; | |||
| 181 | + | if (matches.length === 1 && matches[0].label === typed) { | |||
| 182 | + | const kind = state.kind; | |||
| 183 | + | const element = matches[0].element; | |||
| 184 | + | // Remove the overlay before activation so the click hits the page. | |||
| 185 | + | if (state.root) state.root.remove(); | |||
| 186 | + | finish(activateHint(kind, element)); | |||
| 187 | + | return; | |||
| 188 | + | } | |||
| 189 | + | renderHintFilter(); | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // --- Visual selection ----------------------------------------------------- | |||
| 193 | + | ||||
| 194 | + | function firstVisibleTextPosition() { | |||
| 195 | + | const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT, { | |||
| 196 | + | acceptNode: (node) => (node.data.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT), | |||
| 197 | + | }); | |||
| 198 | + | for (let node = walker.nextNode(); node; node = walker.nextNode()) { | |||
| 199 | + | if (!visibleRect(node.parentElement)) continue; | |||
| 200 | + | const range = document.createRange(); | |||
| 201 | + | range.selectNodeContents(node); | |||
| 202 | + | const rect = range.getBoundingClientRect(); | |||
| 203 | + | if (rect.bottom <= 0 || rect.top >= window.innerHeight) continue; | |||
| 204 | + | return { node, offset: Math.max(node.data.search(/\S/), 0) }; | |||
| 205 | + | } | |||
| 206 | + | return null; | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | function startVisual() { | |||
| 210 | + | const selection = window.getSelection(); | |||
| 211 | + | if (!selection) return false; | |||
| 212 | + | if (selection.rangeCount === 0 || selection.isCollapsed) { | |||
| 213 | + | const start = firstVisibleTextPosition(); | |||
| 214 | + | if (!start) return false; | |||
| 215 | + | selection.setBaseAndExtent(start.node, start.offset, start.node, start.offset); | |||
| 216 | + | selection.modify("extend", "forward", "character"); | |||
| 217 | + | } | |||
| 218 | + | state.count = ""; | |||
| 219 | + | state.pendingG = false; | |||
| 220 | + | return true; | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | function revealSelectionFocus(selection) { | |||
| 224 | + | if (!selection.focusNode) return; | |||
| 225 | + | const range = document.createRange(); | |||
| 226 | + | range.setStart(selection.focusNode, selection.focusOffset); | |||
| 227 | + | range.collapse(true); | |||
| 228 | + | let rect = range.getBoundingClientRect(); | |||
| 229 | + | if (rect.top === 0 && rect.bottom === 0) { | |||
| 230 | + | const element = selection.focusNode.nodeType === Node.ELEMENT_NODE | |||
| 231 | + | ? selection.focusNode : selection.focusNode.parentElement; | |||
| 232 | + | if (!element) return; | |||
| 233 | + | rect = element.getBoundingClientRect(); | |||
| 234 | + | } | |||
| 235 | + | const margin = 40; | |||
| 236 | + | if (rect.top < margin) window.scrollBy(0, rect.top - margin); | |||
| 237 | + | else if (rect.bottom > window.innerHeight - margin) window.scrollBy(0, rect.bottom - window.innerHeight + margin); | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | function visualKey(key) { | |||
| 241 | + | const selection = window.getSelection(); | |||
| 242 | + | if (!selection || selection.rangeCount === 0) return finish({ type: "end" }); | |||
| 243 | + | if (key === "<Escape>" || key === "v") { | |||
| 244 | + | selection.removeAllRanges(); | |||
| 245 | + | return finish({ type: "end" }); | |||
| 246 | + | } | |||
| 247 | + | if (key === "y" || key === "<Return>") { | |||
| 248 | + | const text = selection.toString(); | |||
| 249 | + | selection.removeAllRanges(); | |||
| 250 | + | return finish(text ? { type: "copy", text } : { type: "end" }); | |||
| 251 | + | } | |||
| 252 | + | if (key === "o") { | |||
| 253 | + | selection.setBaseAndExtent(selection.focusNode, selection.focusOffset, selection.anchorNode, selection.anchorOffset); | |||
| 254 | + | return revealSelectionFocus(selection); | |||
| 255 | + | } | |||
| 256 | + | if (/^[1-9]$/.test(key) || (key === "0" && state.count !== "")) { | |||
| 257 | + | state.count += key; | |||
| 258 | + | return; | |||
| 259 | + | } | |||
| 260 | + | let motion = VISUAL_MOTIONS[key]; | |||
| 261 | + | if (key === "g") { | |||
| 262 | + | if (!state.pendingG) { | |||
| 263 | + | state.pendingG = true; | |||
| 264 | + | return; | |||
| 265 | + | } | |||
| 266 | + | motion = ["backward", "documentboundary"]; | |||
| 267 | + | } | |||
| 268 | + | state.pendingG = false; | |||
| 269 | + | const count = Math.min(parseInt(state.count || "1", 10), 500); | |||
| 270 | + | state.count = ""; | |||
| 271 | + | if (!motion) return; | |||
| 272 | + | for (let i = 0; i < count; i++) selection.modify("extend", motion[0], motion[1]); | |||
| 273 | + | revealSelectionFocus(selection); | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | // --- Text objects --------------------------------------------------------- | |||
| 277 | + | ||||
| 278 | + | function selectionText(range) { | |||
| 279 | + | const selection = window.getSelection(); | |||
| 280 | + | if (!selection) return range.toString(); | |||
| 281 | + | const saved = []; | |||
| 282 | + | for (let i = 0; i < selection.rangeCount; i++) saved.push(selection.getRangeAt(i)); | |||
| 283 | + | selection.removeAllRanges(); | |||
| 284 | + | selection.addRange(range); | |||
| 285 | + | const text = selection.toString(); | |||
| 286 | + | selection.removeAllRanges(); | |||
| 287 | + | saved.forEach((savedRange) => selection.addRange(savedRange)); | |||
| 288 | + | return text; | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | function sectionText(color) { | |||
| 292 | + | const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")) | |||
| 293 | + | .filter((heading) => heading.getClientRects().length > 0); | |||
| 294 | + | if (headings.length === 0) return ""; | |||
| 295 | + | const threshold = window.innerHeight * 0.25; | |||
| 296 | + | let index = -1; | |||
| 297 | + | headings.forEach((heading, i) => { | |||
| 298 | + | if (heading.getBoundingClientRect().top <= threshold) index = i; | |||
| 299 | + | }); | |||
| 300 | + | if (index < 0) index = 0; | |||
| 301 | + | const heading = headings[index]; | |||
| 302 | + | const level = Number(heading.tagName[1]); | |||
| 303 | + | const boundary = headings.slice(index + 1).find((next) => Number(next.tagName[1]) <= level); | |||
| 304 | + | const range = document.createRange(); | |||
| 305 | + | range.setStartBefore(heading); | |||
| 306 | + | if (boundary) range.setEndBefore(boundary); | |||
| 307 | + | else range.setEndAfter(document.body ? document.body.lastChild || heading : heading); | |||
| 308 | + | flash(heading, color); | |||
| 309 | + | return selectionText(range); | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | function objectText(object, color) { | |||
| 313 | + | if (object === "section") return sectionText(color); | |||
| 314 | + | for (const selector of OBJECT_SELECTORS[object] || []) { | |||
| 315 | + | const candidates = Array.from(document.querySelectorAll(selector)); | |||
| 316 | + | const element = candidates.find((candidate) => | |||
| 317 | + | visibleRect(candidate) && !candidates.some((other) => other !== candidate && other.contains(candidate))); | |||
| 318 | + | if (element) { | |||
| 319 | + | flash(element, color); | |||
| 320 | + | return element.innerText; | |||
| 321 | + | } | |||
| 322 | + | } | |||
| 323 | + | return ""; | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | // --- Lifecycle ------------------------------------------------------------ | |||
| 327 | + | ||||
| 328 | + | function cleanup() { | |||
| 329 | + | if (state && state.root) state.root.remove(); | |||
| 330 | + | state = null; | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | // finish tears down the interaction and reports its single terminal message. | |||
| 334 | + | function finish(message) { | |||
| 335 | + | const token = state ? state.token : ""; | |||
| 336 | + | cleanup(); | |||
| 337 | + | if (token) post(token, message); | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | Object.defineProperty(window, "__dumberVimPage", { | |||
| 341 | + | configurable: false, | |||
| 342 | + | enumerable: false, | |||
| 343 | + | value: Object.freeze({ | |||
| 344 | + | start(token, request) { | |||
| 345 | + | cleanup(); | |||
| 346 | + | const color = request.color || "#fbbf24"; | |||
| 347 | + | if (request.kind === "yank") { | |||
| 348 | + | const text = objectText(request.object, color).trim(); | |||
| 349 | + | post(token, text ? { type: "copy", text } : { type: "end" }); | |||
| 350 | + | return; | |||
| 351 | + | } | |||
| 352 | + | state = { kind: request.kind, token }; | |||
| 353 | + | const started = request.kind === "visual" ? startVisual() : startHints(request.kind, color); | |||
| 354 | + | if (!started) finish({ type: "end" }); | |||
| 355 | + | }, | |||
| 356 | + | key(token, key) { | |||
| 357 | + | if (!state || state.token !== token) { | |||
| 358 | + | // Unknown interaction (for example the runtime was reinstalled): | |||
| 359 | + | // release that token's key capture without touching live state. | |||
| 360 | + | post(token, { type: "end" }); | |||
| 361 | + | return; | |||
| 362 | + | } | |||
| 363 | + | if (state.kind === "visual") return visualKey(key); | |||
| 364 | + | return hintKey(key); | |||
| 365 | + | }, | |||
| 366 | + | cancel(token) { | |||
| 367 | + | if (!state || state.token !== token) return; | |||
| 368 | + | if (state && state.kind === "visual") { | |||
| 369 | + | const selection = window.getSelection(); | |||
| 370 | + | if (selection) selection.removeAllRanges(); | |||
| 371 | + | } | |||
| 372 | + | cleanup(); | |||
| 373 | + | }, | |||
| 374 | + | }), | |||
| 375 | + | }); | |||
| 376 | + | })(); | |||
A internal/infrastructure/cef/vim_page_test.go +248 -0
| @@ -0,0 +1,248 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/base64" | |
| 6 | + | "net/http" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 16 | + | "github.com/bnema/dumber/internal/application/port" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func expectVimPageScript(t *testing.T, fragments ...string) *WebView { | |
| 20 | + | t.Helper() | |
| 21 | + | browser := cefmocks.NewMockBrowser(t) | |
| 22 | + | frame := cefmocks.NewMockFrame(t) | |
| 23 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |
| 24 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, fragments...)), "", int32(0)).Once() | |
| 25 | + | return &WebView{browser: browser, bridgeNonce: "shared-bridge-nonce"} | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestWebViewStartVimPageInteractionArmsFreshTokenWithoutBridgeNonce(t *testing.T) { | |
| 29 | + | wv := expectVimPageScript(t, | |
| 30 | + | "if (window.__dumberVimPage) return;", | |
| 31 | + | `dumb:///api/vim-page`, | |
| 32 | + | `{"kind":"yank","object":"code","color":"#123456"});`, | |
| 33 | + | ) | |
| 34 | + | ||
| 35 | + | err := wv.StartVimPageInteraction(context.Background(), dto.VimPageInteractionRequest{ | |
| 36 | + | Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectCode, HighlightColor: "#123456", | |
| 37 | + | }) | |
| 38 | + | require.NoError(t, err) | |
| 39 | + | token := wv.currentVimPageToken() | |
| 40 | + | require.NotEmpty(t, token) | |
| 41 | + | assert.NotEqual(t, "shared-bridge-nonce", token) | |
| 42 | + | assert.NotContains(t, vimPageScript("start", token, "{}"), "shared-bridge-nonce") | |
| 43 | + | assert.Contains(t, vimPageScript("start", token, "{}"), `window.__dumberVimPage.start("`+token+`", {});`) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestWebViewSendVimPageKeyEncodesKeyAsJSString(t *testing.T) { | |
| 47 | + | wv := expectVimPageScript(t, `window.__dumberVimPage.key("tok", "\"\u0007");`) | |
| 48 | + | wv.vimPageToken = "tok" | |
| 49 | + | ||
| 50 | + | require.NoError(t, wv.SendVimPageKey(context.Background(), "\"\a")) | |
| 51 | + | require.NoError(t, wv.SendVimPageKey(context.Background(), "")) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestWebViewSendVimPageKeyWithoutInteractionIsNoop(t *testing.T) { | |
| 55 | + | wv := &WebView{browser: cefmocks.NewMockBrowser(t)} | |
| 56 | + | ||
| 57 | + | require.NoError(t, wv.SendVimPageKey(context.Background(), "j")) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestWebViewCancelVimPageInteractionDisarmsToken(t *testing.T) { | |
| 61 | + | wv := expectVimPageScript(t, `window.__dumberVimPage.cancel("tok");`) | |
| 62 | + | wv.vimPageToken = "tok" | |
| 63 | + | ||
| 64 | + | require.NoError(t, wv.CancelVimPageInteraction(context.Background())) | |
| 65 | + | assert.Empty(t, wv.currentVimPageToken()) | |
| 66 | + | require.NoError(t, wv.CancelVimPageInteraction(context.Background())) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestVimPageRequestJSONRejectsUnknownRequests(t *testing.T) { | |
| 70 | + | _, err := vimPageRequestJSON(dto.VimPageInteractionRequest{}) | |
| 71 | + | require.Error(t, err) | |
| 72 | + | _, err = vimPageRequestJSON(dto.VimPageInteractionRequest{Kind: dto.VimPageYankObject}) | |
| 73 | + | require.Error(t, err) | |
| 74 | + | ||
| 75 | + | got, err := vimPageRequestJSON(dto.VimPageInteractionRequest{Kind: dto.VimPageHintFollowNew}) | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | assert.JSONEq(t, `{"kind":"hint-follow-new"}`, got) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestDecodeVimPageBridgePayload(t *testing.T) { | |
| 81 | + | tests := []struct { | |
| 82 | + | name string | |
| 83 | + | body string | |
| 84 | + | wantErr bool | |
| 85 | + | }{ | |
| 86 | + | {"copy", `{"type":"copy","token":"t","text":"hello"}`, false}, | |
| 87 | + | {"copy empty", `{"type":"copy","token":"t"}`, true}, | |
| 88 | + | {"missing token", `{"type":"end"}`, true}, | |
| 89 | + | {"open", `{"type":"open-new","token":"t","url":"https://example.com/a"}`, false}, | |
| 90 | + | {"open empty", `{"type":"open-new","token":"t"}`, true}, | |
| 91 | + | {"end", `{"type":"end","token":"t"}`, false}, | |
| 92 | + | {"unknown", `{"type":"eval","token":"t"}`, true}, | |
| 93 | + | {"malformed", `{`, true}, | |
| 94 | + | } | |
| 95 | + | for _, tt := range tests { | |
| 96 | + | t.Run(tt.name, func(t *testing.T) { | |
| 97 | + | _, err := decodeVimPageBridgePayload([]byte(tt.body)) | |
| 98 | + | if tt.wantErr { | |
| 99 | + | require.Error(t, err) | |
| 100 | + | return | |
| 101 | + | } | |
| 102 | + | require.NoError(t, err) | |
| 103 | + | }) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestIsNavigableVimHintURL(t *testing.T) { | |
| 108 | + | assert.True(t, isNavigableVimHintURL("https://example.com/a", "https://site.test/")) | |
| 109 | + | assert.False(t, isNavigableVimHintURL("https:///nohost", "https://site.test/")) | |
| 110 | + | assert.False(t, isNavigableVimHintURL("javascript:alert(1)", "https://site.test/")) | |
| 111 | + | assert.False(t, isNavigableVimHintURL("dumb://config", "https://site.test/")) | |
| 112 | + | assert.False(t, isNavigableVimHintURL("file:///etc/passwd", "https://site.test/")) | |
| 113 | + | assert.True(t, isNavigableVimHintURL("file:///tmp/b.html", "file:///tmp/a.html")) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func newVimResultWebView(opened *string, ended *int) *WebView { | |
| 117 | + | return &WebView{ctx: context.Background(), uri: "https://site.test/", callbacks: &port.WebViewCallbacks{ | |
| 118 | + | OnLinkMiddleClick: func(uri string) bool { *opened = uri; return true }, | |
| 119 | + | OnVimPageInteractionEnded: func() { *ended++ }, | |
| 120 | + | }} | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestWebViewHandleVimPageResultAcceptsArmedTokenOnce(t *testing.T) { | |
| 124 | + | var opened string | |
| 125 | + | ended := 0 | |
| 126 | + | wv := newVimResultWebView(&opened, &ended) | |
| 127 | + | wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageHintFollowNew | |
| 128 | + | ||
| 129 | + | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageOpenNew, Token: "tok", URL: "https://example.com"}) | |
| 130 | + | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageOpenNew, Token: "tok", URL: "https://evil.test"}) | |
| 131 | + | ||
| 132 | + | assert.Equal(t, "https://example.com", opened) | |
| 133 | + | assert.Equal(t, 1, ended) | |
| 134 | + | assert.Empty(t, wv.currentVimPageToken()) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestWebViewHandleVimPageResultRejectsStaleOrForgedTokens(t *testing.T) { | |
| 138 | + | var opened string | |
| 139 | + | ended := 0 | |
| 140 | + | wv := newVimResultWebView(&opened, &ended) | |
| 141 | + | wv.vimPageToken, wv.vimPageKind = "current", dto.VimPageHintFollowNew | |
| 142 | + | ||
| 143 | + | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageEnd, Token: "previous"}) | |
| 144 | + | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageOpenNew, Token: "forged", URL: "https://example.com"}) | |
| 145 | + | ||
| 146 | + | assert.Empty(t, opened) | |
| 147 | + | assert.Zero(t, ended) | |
| 148 | + | assert.Equal(t, "current", wv.currentVimPageToken()) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestWebViewHandleVimPageResultRejectsDisallowedURL(t *testing.T) { | |
| 152 | + | var opened string | |
| 153 | + | ended := 0 | |
| 154 | + | wv := newVimResultWebView(&opened, &ended) | |
| 155 | + | wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageHintFollowNew | |
| 156 | + | ||
| 157 | + | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageOpenNew, Token: "tok", URL: "file:///etc/passwd"}) | |
| 158 | + | ||
| 159 | + | assert.Empty(t, opened) | |
| 160 | + | assert.Equal(t, 1, ended, "the interaction still ends so key capture is released") | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func TestWebViewEndVimPageInteractionOnNavigation(t *testing.T) { | |
| 164 | + | var opened string | |
| 165 | + | ended := 0 | |
| 166 | + | wv := newVimResultWebView(&opened, &ended) | |
| 167 | + | wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageHintFollowNew | |
| 168 | + | ||
| 169 | + | wv.endVimPageInteractionOnNavigation() | |
| 170 | + | wv.endVimPageInteractionOnNavigation() | |
| 171 | + | ||
| 172 | + | assert.Equal(t, 1, ended) | |
| 173 | + | assert.Empty(t, wv.currentVimPageToken()) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestSchemeHandler_APIVimPageForwardsValidPayloads(t *testing.T) { | |
| 177 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 178 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 179 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 180 | + | ||
| 181 | + | tests := []struct { | |
| 182 | + | name string | |
| 183 | + | body string | |
| 184 | + | wantStatus int | |
| 185 | + | wantCalled bool | |
| 186 | + | }{ | |
| 187 | + | {"valid", `{"type":"copy","token":"tok","text":"yanked"}`, http.StatusOK, true}, | |
| 188 | + | {"missing token", `{"type":"copy","text":"yanked"}`, http.StatusBadRequest, false}, | |
| 189 | + | } | |
| 190 | + | for _, tt := range tests { | |
| 191 | + | t.Run(tt.name, func(t *testing.T) { | |
| 192 | + | h := newTestDumbSchemeHandler(t) | |
| 193 | + | browser := cefmocks.NewMockBrowser(t) | |
| 194 | + | var received vimPageBridgePayload | |
| 195 | + | called := false | |
| 196 | + | h.onVimPage = func(_ purecef.Browser, payload vimPageBridgePayload) { | |
| 197 | + | called = true | |
| 198 | + | received = payload | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | request := cefmocks.NewMockRequest(t) | |
| 202 | + | encoded := base64.StdEncoding.EncodeToString([]byte(tt.body)) | |
| 203 | + | request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once() | |
| 204 | + | response := cefmocks.NewMockResponse(t) | |
| 205 | + | response.EXPECT().SetStatus(int32(tt.wantStatus)).Once() | |
| 206 | + | response.EXPECT().SetStatusText(http.StatusText(tt.wantStatus)).Once() | |
| 207 | + | response.EXPECT().SetMimeType("application/json").Once() | |
| 208 | + | expectAPIResponseHeaders(response) | |
| 209 | + | ||
| 210 | + | handler := h.handleAPI(browser, http.MethodPost, "/api/vim-page", request) | |
| 211 | + | require.NotNil(t, handler) | |
| 212 | + | var length int64 | |
| 213 | + | handler.GetResponseHeaders(response, &length, 0) | |
| 214 | + | ||
| 215 | + | assert.Equal(t, tt.wantCalled, called) | |
| 216 | + | if tt.wantCalled { | |
| 217 | + | assert.Equal(t, vimPageBridgePayload{Type: "copy", Token: "tok", Text: "yanked"}, received) | |
| 218 | + | } | |
| 219 | + | }) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func TestWebViewHandleVimPageResultRejectsTypeNotMatchingInteraction(t *testing.T) { | |
| 224 | + | var opened string | |
| 225 | + | ended := 0 | |
| 226 | + | wv := newVimResultWebView(&opened, &ended) | |
| 227 | + | wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageYankObject | |
| 228 | + | ||
| 229 | + | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageOpenNew, Token: "tok", URL: "https://example.com"}) | |
| 230 | + | ||
| 231 | + | assert.Empty(t, opened) | |
| 232 | + | assert.Empty(t, wv.currentVimPageToken(), "the token is still spent") | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func TestWebViewVimPageEndedCallbackSkippedAfterRearm(t *testing.T) { | |
| 236 | + | var opened string | |
| 237 | + | ended := 0 | |
| 238 | + | wv := newVimResultWebView(&opened, &ended) | |
| 239 | + | _, err := wv.armVimPageInteraction(dto.VimPageHintFollow) | |
| 240 | + | require.NoError(t, err) | |
| 241 | + | _, generation := wv.disarmVimPageInteraction() | |
| 242 | + | _, err = wv.armVimPageInteraction(dto.VimPageHintFollow) | |
| 243 | + | require.NoError(t, err) | |
| 244 | + | ||
| 245 | + | wv.notifyVimPageInteractionEnded(generation) | |
| 246 | + | ||
| 247 | + | assert.Zero(t, ended) | |
| 248 | + | } | |
| @@ -0,0 +1,248 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/base64" | |||
| 6 | + | "net/http" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 10 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 16 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func expectVimPageScript(t *testing.T, fragments ...string) *WebView { | |||
| 20 | + | t.Helper() | |||
| 21 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 22 | + | frame := cefmocks.NewMockFrame(t) | |||
| 23 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |||
| 24 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, fragments...)), "", int32(0)).Once() | |||
| 25 | + | return &WebView{browser: browser, bridgeNonce: "shared-bridge-nonce"} | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestWebViewStartVimPageInteractionArmsFreshTokenWithoutBridgeNonce(t *testing.T) { | |||
| 29 | + | wv := expectVimPageScript(t, | |||
| 30 | + | "if (window.__dumberVimPage) return;", | |||
| 31 | + | `dumb:///api/vim-page`, | |||
| 32 | + | `{"kind":"yank","object":"code","color":"#123456"});`, | |||
| 33 | + | ) | |||
| 34 | + | ||||
| 35 | + | err := wv.StartVimPageInteraction(context.Background(), dto.VimPageInteractionRequest{ | |||
| 36 | + | Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectCode, HighlightColor: "#123456", | |||
| 37 | + | }) | |||
| 38 | + | require.NoError(t, err) | |||
| 39 | + | token := wv.currentVimPageToken() | |||
| 40 | + | require.NotEmpty(t, token) | |||
| 41 | + | assert.NotEqual(t, "shared-bridge-nonce", token) | |||
| 42 | + | assert.NotContains(t, vimPageScript("start", token, "{}"), "shared-bridge-nonce") | |||
| 43 | + | assert.Contains(t, vimPageScript("start", token, "{}"), `window.__dumberVimPage.start("`+token+`", {});`) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestWebViewSendVimPageKeyEncodesKeyAsJSString(t *testing.T) { | |||
| 47 | + | wv := expectVimPageScript(t, `window.__dumberVimPage.key("tok", "\"\u0007");`) | |||
| 48 | + | wv.vimPageToken = "tok" | |||
| 49 | + | ||||
| 50 | + | require.NoError(t, wv.SendVimPageKey(context.Background(), "\"\a")) | |||
| 51 | + | require.NoError(t, wv.SendVimPageKey(context.Background(), "")) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestWebViewSendVimPageKeyWithoutInteractionIsNoop(t *testing.T) { | |||
| 55 | + | wv := &WebView{browser: cefmocks.NewMockBrowser(t)} | |||
| 56 | + | ||||
| 57 | + | require.NoError(t, wv.SendVimPageKey(context.Background(), "j")) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestWebViewCancelVimPageInteractionDisarmsToken(t *testing.T) { | |||
| 61 | + | wv := expectVimPageScript(t, `window.__dumberVimPage.cancel("tok");`) | |||
| 62 | + | wv.vimPageToken = "tok" | |||
| 63 | + | ||||
| 64 | + | require.NoError(t, wv.CancelVimPageInteraction(context.Background())) | |||
| 65 | + | assert.Empty(t, wv.currentVimPageToken()) | |||
| 66 | + | require.NoError(t, wv.CancelVimPageInteraction(context.Background())) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestVimPageRequestJSONRejectsUnknownRequests(t *testing.T) { | |||
| 70 | + | _, err := vimPageRequestJSON(dto.VimPageInteractionRequest{}) | |||
| 71 | + | require.Error(t, err) | |||
| 72 | + | _, err = vimPageRequestJSON(dto.VimPageInteractionRequest{Kind: dto.VimPageYankObject}) | |||
| 73 | + | require.Error(t, err) | |||
| 74 | + | ||||
| 75 | + | got, err := vimPageRequestJSON(dto.VimPageInteractionRequest{Kind: dto.VimPageHintFollowNew}) | |||
| 76 | + | require.NoError(t, err) | |||
| 77 | + | assert.JSONEq(t, `{"kind":"hint-follow-new"}`, got) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestDecodeVimPageBridgePayload(t *testing.T) { | |||
| 81 | + | tests := []struct { | |||
| 82 | + | name string | |||
| 83 | + | body string | |||
| 84 | + | wantErr bool | |||
| 85 | + | }{ | |||
| 86 | + | {"copy", `{"type":"copy","token":"t","text":"hello"}`, false}, | |||
| 87 | + | {"copy empty", `{"type":"copy","token":"t"}`, true}, | |||
| 88 | + | {"missing token", `{"type":"end"}`, true}, | |||
| 89 | + | {"open", `{"type":"open-new","token":"t","url":"https://example.com/a"}`, false}, | |||
| 90 | + | {"open empty", `{"type":"open-new","token":"t"}`, true}, | |||
| 91 | + | {"end", `{"type":"end","token":"t"}`, false}, | |||
| 92 | + | {"unknown", `{"type":"eval","token":"t"}`, true}, | |||
| 93 | + | {"malformed", `{`, true}, | |||
| 94 | + | } | |||
| 95 | + | for _, tt := range tests { | |||
| 96 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 97 | + | _, err := decodeVimPageBridgePayload([]byte(tt.body)) | |||
| 98 | + | if tt.wantErr { | |||
| 99 | + | require.Error(t, err) | |||
| 100 | + | return | |||
| 101 | + | } | |||
| 102 | + | require.NoError(t, err) | |||
| 103 | + | }) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestIsNavigableVimHintURL(t *testing.T) { | |||
| 108 | + | assert.True(t, isNavigableVimHintURL("https://example.com/a", "https://site.test/")) | |||
| 109 | + | assert.False(t, isNavigableVimHintURL("https:///nohost", "https://site.test/")) | |||
| 110 | + | assert.False(t, isNavigableVimHintURL("javascript:alert(1)", "https://site.test/")) | |||
| 111 | + | assert.False(t, isNavigableVimHintURL("dumb://config", "https://site.test/")) | |||
| 112 | + | assert.False(t, isNavigableVimHintURL("file:///etc/passwd", "https://site.test/")) | |||
| 113 | + | assert.True(t, isNavigableVimHintURL("file:///tmp/b.html", "file:///tmp/a.html")) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func newVimResultWebView(opened *string, ended *int) *WebView { | |||
| 117 | + | return &WebView{ctx: context.Background(), uri: "https://site.test/", callbacks: &port.WebViewCallbacks{ | |||
| 118 | + | OnLinkMiddleClick: func(uri string) bool { *opened = uri; return true }, | |||
| 119 | + | OnVimPageInteractionEnded: func() { *ended++ }, | |||
| 120 | + | }} | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestWebViewHandleVimPageResultAcceptsArmedTokenOnce(t *testing.T) { | |||
| 124 | + | var opened string | |||
| 125 | + | ended := 0 | |||
| 126 | + | wv := newVimResultWebView(&opened, &ended) | |||
| 127 | + | wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageHintFollowNew | |||
| 128 | + | ||||
| 129 | + | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageOpenNew, Token: "tok", URL: "https://example.com"}) | |||
| 130 | + | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageOpenNew, Token: "tok", URL: "https://evil.test"}) | |||
| 131 | + | ||||
| 132 | + | assert.Equal(t, "https://example.com", opened) | |||
| 133 | + | assert.Equal(t, 1, ended) | |||
| 134 | + | assert.Empty(t, wv.currentVimPageToken()) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func TestWebViewHandleVimPageResultRejectsStaleOrForgedTokens(t *testing.T) { | |||
| 138 | + | var opened string | |||
| 139 | + | ended := 0 | |||
| 140 | + | wv := newVimResultWebView(&opened, &ended) | |||
| 141 | + | wv.vimPageToken, wv.vimPageKind = "current", dto.VimPageHintFollowNew | |||
| 142 | + | ||||
| 143 | + | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageEnd, Token: "previous"}) | |||
| 144 | + | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageOpenNew, Token: "forged", URL: "https://example.com"}) | |||
| 145 | + | ||||
| 146 | + | assert.Empty(t, opened) | |||
| 147 | + | assert.Zero(t, ended) | |||
| 148 | + | assert.Equal(t, "current", wv.currentVimPageToken()) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestWebViewHandleVimPageResultRejectsDisallowedURL(t *testing.T) { | |||
| 152 | + | var opened string | |||
| 153 | + | ended := 0 | |||
| 154 | + | wv := newVimResultWebView(&opened, &ended) | |||
| 155 | + | wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageHintFollowNew | |||
| 156 | + | ||||
| 157 | + | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageOpenNew, Token: "tok", URL: "file:///etc/passwd"}) | |||
| 158 | + | ||||
| 159 | + | assert.Empty(t, opened) | |||
| 160 | + | assert.Equal(t, 1, ended, "the interaction still ends so key capture is released") | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func TestWebViewEndVimPageInteractionOnNavigation(t *testing.T) { | |||
| 164 | + | var opened string | |||
| 165 | + | ended := 0 | |||
| 166 | + | wv := newVimResultWebView(&opened, &ended) | |||
| 167 | + | wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageHintFollowNew | |||
| 168 | + | ||||
| 169 | + | wv.endVimPageInteractionOnNavigation() | |||
| 170 | + | wv.endVimPageInteractionOnNavigation() | |||
| 171 | + | ||||
| 172 | + | assert.Equal(t, 1, ended) | |||
| 173 | + | assert.Empty(t, wv.currentVimPageToken()) | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func TestSchemeHandler_APIVimPageForwardsValidPayloads(t *testing.T) { | |||
| 177 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 178 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 179 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 180 | + | ||||
| 181 | + | tests := []struct { | |||
| 182 | + | name string | |||
| 183 | + | body string | |||
| 184 | + | wantStatus int | |||
| 185 | + | wantCalled bool | |||
| 186 | + | }{ | |||
| 187 | + | {"valid", `{"type":"copy","token":"tok","text":"yanked"}`, http.StatusOK, true}, | |||
| 188 | + | {"missing token", `{"type":"copy","text":"yanked"}`, http.StatusBadRequest, false}, | |||
| 189 | + | } | |||
| 190 | + | for _, tt := range tests { | |||
| 191 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 192 | + | h := newTestDumbSchemeHandler(t) | |||
| 193 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 194 | + | var received vimPageBridgePayload | |||
| 195 | + | called := false | |||
| 196 | + | h.onVimPage = func(_ purecef.Browser, payload vimPageBridgePayload) { | |||
| 197 | + | called = true | |||
| 198 | + | received = payload | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | request := cefmocks.NewMockRequest(t) | |||
| 202 | + | encoded := base64.StdEncoding.EncodeToString([]byte(tt.body)) | |||
| 203 | + | request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once() | |||
| 204 | + | response := cefmocks.NewMockResponse(t) | |||
| 205 | + | response.EXPECT().SetStatus(int32(tt.wantStatus)).Once() | |||
| 206 | + | response.EXPECT().SetStatusText(http.StatusText(tt.wantStatus)).Once() | |||
| 207 | + | response.EXPECT().SetMimeType("application/json").Once() | |||
| 208 | + | expectAPIResponseHeaders(response) | |||
| 209 | + | ||||
| 210 | + | handler := h.handleAPI(browser, http.MethodPost, "/api/vim-page", request) | |||
| 211 | + | require.NotNil(t, handler) | |||
| 212 | + | var length int64 | |||
| 213 | + | handler.GetResponseHeaders(response, &length, 0) | |||
| 214 | + | ||||
| 215 | + | assert.Equal(t, tt.wantCalled, called) | |||
| 216 | + | if tt.wantCalled { | |||
| 217 | + | assert.Equal(t, vimPageBridgePayload{Type: "copy", Token: "tok", Text: "yanked"}, received) | |||
| 218 | + | } | |||
| 219 | + | }) | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | func TestWebViewHandleVimPageResultRejectsTypeNotMatchingInteraction(t *testing.T) { | |||
| 224 | + | var opened string | |||
| 225 | + | ended := 0 | |||
| 226 | + | wv := newVimResultWebView(&opened, &ended) | |||
| 227 | + | wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageYankObject | |||
| 228 | + | ||||
| 229 | + | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageOpenNew, Token: "tok", URL: "https://example.com"}) | |||
| 230 | + | ||||
| 231 | + | assert.Empty(t, opened) | |||
| 232 | + | assert.Empty(t, wv.currentVimPageToken(), "the token is still spent") | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func TestWebViewVimPageEndedCallbackSkippedAfterRearm(t *testing.T) { | |||
| 236 | + | var opened string | |||
| 237 | + | ended := 0 | |||
| 238 | + | wv := newVimResultWebView(&opened, &ended) | |||
| 239 | + | _, err := wv.armVimPageInteraction(dto.VimPageHintFollow) | |||
| 240 | + | require.NoError(t, err) | |||
| 241 | + | _, generation := wv.disarmVimPageInteraction() | |||
| 242 | + | _, err = wv.armVimPageInteraction(dto.VimPageHintFollow) | |||
| 243 | + | require.NoError(t, err) | |||
| 244 | + | ||||
| 245 | + | wv.notifyVimPageInteractionEnded(generation) | |||
| 246 | + | ||||
| 247 | + | assert.Zero(t, ended) | |||
| 248 | + | } | |||
M internal/infrastructure/cef/webview.go +18 -11
| @@ -262,17 +262,24 @@ type WebView struct { | |||
| 262 | 262 | // (for example https://dumber.invalid/history). The two forms are updated | |
| 263 | 263 | // together through setCommittedURLsLocked; never compare one form against | |
| 264 | 264 | // the other. | |
| 265 | - | uri string | |
| 266 | - | committedURL string | |
| 267 | - | title string | |
| 268 | - | progress float64 | |
| 269 | - | canGoBack bool | |
| 270 | - | canGoFwd bool | |
| 271 | - | isLoading bool | |
| 272 | - | selectedText string | |
| 273 | - | focusedEditable bool | |
| 274 | - | inputAttached bool | |
| 275 | - | bridgeNonce string | |
| 265 | + | uri string | |
| 266 | + | committedURL string | |
| 267 | + | title string | |
| 268 | + | progress float64 | |
| 269 | + | canGoBack bool | |
| 270 | + | canGoFwd bool | |
| 271 | + | isLoading bool | |
| 272 | + | selectedText string | |
| 273 | + | focusedEditable bool | |
| 274 | + | inputAttached bool | |
| 275 | + | bridgeNonce string | |
| 276 | + | // vimPageToken is the secret for the armed Vim page interaction; results | |
| 277 | + | // carrying any other token are rejected. Empty when none is armed. | |
| 278 | + | vimPageToken string | |
| 279 | + | vimPageKind dto.VimPageInteractionKind | |
| 280 | + | // vimPageGeneration increments on every arm so deferred end callbacks | |
| 281 | + | // from an older interaction cannot release a newer one's key capture. | |
| 282 | + | vimPageGeneration uint64 | |
| 276 | 283 | selectionDebounceTimer stoppableTimer | |
| 277 | 284 | selectionDebounceSeq uint64 | |
| 278 | 285 | selectionDebounceDelay *time.Duration | |
| @@ -262,17 +262,24 @@ type WebView struct { | |||||
| 262 | // (for example https://dumber.invalid/history). The two forms are updated | 262 | // (for example https://dumber.invalid/history). The two forms are updated | ||
| 263 | // together through setCommittedURLsLocked; never compare one form against | 263 | // together through setCommittedURLsLocked; never compare one form against | ||
| 264 | // the other. | 264 | // the other. | ||
| 265 | - | uri string | 265 | + | uri string |
| 266 | - | committedURL string | 266 | + | committedURL string |
| 267 | - | title string | 267 | + | title string |
| 268 | - | progress float64 | 268 | + | progress float64 |
| 269 | - | canGoBack bool | 269 | + | canGoBack bool |
| 270 | - | canGoFwd bool | 270 | + | canGoFwd bool |
| 271 | - | isLoading bool | 271 | + | isLoading bool |
| 272 | - | selectedText string | 272 | + | selectedText string |
| 273 | - | focusedEditable bool | 273 | + | focusedEditable bool |
| 274 | - | inputAttached bool | 274 | + | inputAttached bool |
| 275 | - | bridgeNonce string | 275 | + | bridgeNonce string |
| 276 | + | // vimPageToken is the secret for the armed Vim page interaction; results | |||
| 277 | + | // carrying any other token are rejected. Empty when none is armed. | |||
| 278 | + | vimPageToken string | |||
| 279 | + | vimPageKind dto.VimPageInteractionKind | |||
| 280 | + | // vimPageGeneration increments on every arm so deferred end callbacks | |||
| 281 | + | // from an older interaction cannot release a newer one's key capture. | |||
| 282 | + | vimPageGeneration uint64 | |||
| 276 | selectionDebounceTimer stoppableTimer | 283 | selectionDebounceTimer stoppableTimer | ||
| 277 | selectionDebounceSeq uint64 | 284 | selectionDebounceSeq uint64 | ||
| 278 | selectionDebounceDelay *time.Duration | 285 | selectionDebounceDelay *time.Duration | ||
M internal/infrastructure/cef/webview_handlers.go +1 -0
| @@ -536,6 +536,7 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec | |||
| 536 | 536 | // A new document replaces the page that asked for any open JS dialog. | |
| 537 | 537 | h.wv.cancelJSDialogs() | |
| 538 | 538 | h.wv.invalidateScrollMotion() | |
| 539 | + | h.wv.endVimPageInteractionOnNavigation() | |
| 539 | 540 | h.wv.mu.Lock() | |
| 540 | 541 | h.wv.documentSeq++ | |
| 541 | 542 | h.wv.faviconSourcePendingToken = 0 | |
| @@ -536,6 +536,7 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec | |||||
| 536 | // A new document replaces the page that asked for any open JS dialog. | 536 | // A new document replaces the page that asked for any open JS dialog. | ||
| 537 | h.wv.cancelJSDialogs() | 537 | h.wv.cancelJSDialogs() | ||
| 538 | h.wv.invalidateScrollMotion() | 538 | h.wv.invalidateScrollMotion() | ||
| 539 | + | h.wv.endVimPageInteractionOnNavigation() | |||
| 539 | h.wv.mu.Lock() | 540 | h.wv.mu.Lock() | ||
| 540 | h.wv.documentSeq++ | 541 | h.wv.documentSeq++ | ||
| 541 | h.wv.faviconSourcePendingToken = 0 | 542 | h.wv.faviconSourcePendingToken = 0 | ||
M internal/infrastructure/config/defaults.go +8 -0
| @@ -370,6 +370,14 @@ func DefaultConfig() *Config { | |||
| 370 | 370 | "list-next": {Keys: []string{"]l"}, Desc: "Jump to next list"}, | |
| 371 | 371 | "outline": {Keys: []string{"gO"}, Desc: "Show page outline"}, | |
| 372 | 372 | "yank-section": {Keys: []string{"yah"}, Desc: "Yank current section"}, | |
| 373 | + | "yank-paragraph": {Keys: []string{"yap"}, Desc: "Yank first visible paragraph"}, | |
| 374 | + | "yank-code": {Keys: []string{"yac"}, Desc: "Yank first visible code block"}, | |
| 375 | + | "yank-table": {Keys: []string{"yat"}, Desc: "Yank first visible table"}, | |
| 376 | + | "yank-list": {Keys: []string{"yal"}, Desc: "Yank first visible list"}, | |
| 377 | + | "hint-follow": {Keys: []string{"f"}, Desc: "Show hints and follow a link or control"}, | |
| 378 | + | "hint-follow-new": {Keys: []string{"F"}, Desc: "Show hints and open a link in a new pane"}, | |
| 379 | + | "hint-yank-url": {Keys: []string{"yf"}, Desc: "Show hints and yank a link URL"}, | |
| 380 | + | "visual": {Keys: []string{"v"}, Desc: "Start visual selection (motions extend, y yanks)"}, | |
| 373 | 381 | "half-page-down": {Keys: []string{"<C-d>"}, Desc: "Scroll half page down"}, | |
| 374 | 382 | "half-page-up": {Keys: []string{"<C-u>"}, Desc: "Scroll half page up"}, | |
| 375 | 383 | }, | |
| @@ -370,6 +370,14 @@ func DefaultConfig() *Config { | |||||
| 370 | "list-next": {Keys: []string{"]l"}, Desc: "Jump to next list"}, | 370 | "list-next": {Keys: []string{"]l"}, Desc: "Jump to next list"}, | ||
| 371 | "outline": {Keys: []string{"gO"}, Desc: "Show page outline"}, | 371 | "outline": {Keys: []string{"gO"}, Desc: "Show page outline"}, | ||
| 372 | "yank-section": {Keys: []string{"yah"}, Desc: "Yank current section"}, | 372 | "yank-section": {Keys: []string{"yah"}, Desc: "Yank current section"}, | ||
| 373 | + | "yank-paragraph": {Keys: []string{"yap"}, Desc: "Yank first visible paragraph"}, | |||
| 374 | + | "yank-code": {Keys: []string{"yac"}, Desc: "Yank first visible code block"}, | |||
| 375 | + | "yank-table": {Keys: []string{"yat"}, Desc: "Yank first visible table"}, | |||
| 376 | + | "yank-list": {Keys: []string{"yal"}, Desc: "Yank first visible list"}, | |||
| 377 | + | "hint-follow": {Keys: []string{"f"}, Desc: "Show hints and follow a link or control"}, | |||
| 378 | + | "hint-follow-new": {Keys: []string{"F"}, Desc: "Show hints and open a link in a new pane"}, | |||
| 379 | + | "hint-yank-url": {Keys: []string{"yf"}, Desc: "Show hints and yank a link URL"}, | |||
| 380 | + | "visual": {Keys: []string{"v"}, Desc: "Start visual selection (motions extend, y yanks)"}, | |||
| 373 | "half-page-down": {Keys: []string{"<C-d>"}, Desc: "Scroll half page down"}, | 381 | "half-page-down": {Keys: []string{"<C-d>"}, Desc: "Scroll half page down"}, | ||
| 374 | "half-page-up": {Keys: []string{"<C-u>"}, Desc: "Scroll half page up"}, | 382 | "half-page-up": {Keys: []string{"<C-u>"}, Desc: "Scroll half page up"}, | ||
| 375 | }, | 383 | }, | ||
M internal/infrastructure/config/vim_mode_config_test.go +27 -14
| @@ -20,18 +20,26 @@ var legacyVimModeActions = []string{ | |||
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | var sequenceVimModeActions = map[string]string{ | |
| 23 | - | "heading-next": "]]", | |
| 24 | - | "heading-prev": "[[", | |
| 25 | - | "focus-input": "gi", | |
| 26 | - | "code-next": "]c", | |
| 27 | - | "code-prev": "[c", | |
| 28 | - | "table-next": "]t", | |
| 29 | - | "image-next": "]i", | |
| 30 | - | "list-next": "]l", | |
| 31 | - | "outline": "gO", | |
| 32 | - | "yank-section": "yah", | |
| 33 | - | "half-page-down": "<C-d>", | |
| 34 | - | "half-page-up": "<C-u>", | |
| 23 | + | "heading-next": "]]", | |
| 24 | + | "heading-prev": "[[", | |
| 25 | + | "focus-input": "gi", | |
| 26 | + | "code-next": "]c", | |
| 27 | + | "code-prev": "[c", | |
| 28 | + | "table-next": "]t", | |
| 29 | + | "image-next": "]i", | |
| 30 | + | "list-next": "]l", | |
| 31 | + | "outline": "gO", | |
| 32 | + | "yank-section": "yah", | |
| 33 | + | "yank-paragraph": "yap", | |
| 34 | + | "yank-code": "yac", | |
| 35 | + | "yank-table": "yat", | |
| 36 | + | "yank-list": "yal", | |
| 37 | + | "hint-yank-url": "yf", | |
| 38 | + | "half-page-down": "<C-d>", | |
| 39 | + | "half-page-up": "<C-u>", | |
| 40 | + | "hint-follow": "f", | |
| 41 | + | "hint-follow-new": "F", | |
| 42 | + | "visual": "v", | |
| 35 | 43 | } | |
| 36 | 44 | ||
| 37 | 45 | func TestDefaultConfig_VimModeDefaults(t *testing.T) { | |
| @@ -66,7 +74,7 @@ func TestVimModeSequenceDefaults(t *testing.T) { | |||
| 66 | 74 | vimMode := cfg.Workspace.VimMode | |
| 67 | 75 | ||
| 68 | 76 | assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds) | |
| 69 | - | require.Len(t, sequenceVimModeActions, 12) | |
| 77 | + | require.Len(t, sequenceVimModeActions, 20) | |
| 70 | 78 | ||
| 71 | 79 | for action, key := range sequenceVimModeActions { | |
| 72 | 80 | requireActionBinding(t, vimMode.Actions, action, []string{key}) | |
| @@ -129,7 +137,12 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) { | |||
| 129 | 137 | assert.Equal(t, "yank-section", bindings["yah"]) | |
| 130 | 138 | assert.Equal(t, "half-page-down", bindings["<C-d>"]) | |
| 131 | 139 | assert.Equal(t, "half-page-up", bindings["<C-u>"]) | |
| 132 | - | assert.Len(t, bindings, 20) | |
| 140 | + | assert.Equal(t, "yank-code", bindings["yac"]) | |
| 141 | + | assert.Equal(t, "hint-follow", bindings["f"]) | |
| 142 | + | assert.Equal(t, "hint-follow-new", bindings["F"]) | |
| 143 | + | assert.Equal(t, "hint-yank-url", bindings["yf"]) | |
| 144 | + | assert.Equal(t, "visual", bindings["v"]) | |
| 145 | + | assert.Len(t, bindings, 28) | |
| 133 | 146 | } | |
| 134 | 147 | ||
| 135 | 148 | func TestValidateVimMode_NonNegativeTimeout(t *testing.T) { | |
| @@ -20,18 +20,26 @@ var legacyVimModeActions = []string{ | |||||
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
| 22 | var sequenceVimModeActions = map[string]string{ | 22 | var sequenceVimModeActions = map[string]string{ | ||
| 23 | - | "heading-next": "]]", | 23 | + | "heading-next": "]]", |
| 24 | - | "heading-prev": "[[", | 24 | + | "heading-prev": "[[", |
| 25 | - | "focus-input": "gi", | 25 | + | "focus-input": "gi", |
| 26 | - | "code-next": "]c", | 26 | + | "code-next": "]c", |
| 27 | - | "code-prev": "[c", | 27 | + | "code-prev": "[c", |
| 28 | - | "table-next": "]t", | 28 | + | "table-next": "]t", |
| 29 | - | "image-next": "]i", | 29 | + | "image-next": "]i", |
| 30 | - | "list-next": "]l", | 30 | + | "list-next": "]l", |
| 31 | - | "outline": "gO", | 31 | + | "outline": "gO", |
| 32 | - | "yank-section": "yah", | 32 | + | "yank-section": "yah", |
| 33 | - | "half-page-down": "<C-d>", | 33 | + | "yank-paragraph": "yap", |
| 34 | - | "half-page-up": "<C-u>", | 34 | + | "yank-code": "yac", |
| 35 | + | "yank-table": "yat", | |||
| 36 | + | "yank-list": "yal", | |||
| 37 | + | "hint-yank-url": "yf", | |||
| 38 | + | "half-page-down": "<C-d>", | |||
| 39 | + | "half-page-up": "<C-u>", | |||
| 40 | + | "hint-follow": "f", | |||
| 41 | + | "hint-follow-new": "F", | |||
| 42 | + | "visual": "v", | |||
| 35 | } | 43 | } | ||
| 36 | 44 | ||||
| 37 | func TestDefaultConfig_VimModeDefaults(t *testing.T) { | 45 | func TestDefaultConfig_VimModeDefaults(t *testing.T) { | ||
| @@ -66,7 +74,7 @@ func TestVimModeSequenceDefaults(t *testing.T) { | |||||
| 66 | vimMode := cfg.Workspace.VimMode | 74 | vimMode := cfg.Workspace.VimMode | ||
| 67 | 75 | ||||
| 68 | assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds) | 76 | assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds) | ||
| 69 | - | require.Len(t, sequenceVimModeActions, 12) | 77 | + | require.Len(t, sequenceVimModeActions, 20) |
| 70 | 78 | ||||
| 71 | for action, key := range sequenceVimModeActions { | 79 | for action, key := range sequenceVimModeActions { | ||
| 72 | requireActionBinding(t, vimMode.Actions, action, []string{key}) | 80 | requireActionBinding(t, vimMode.Actions, action, []string{key}) | ||
| @@ -129,7 +137,12 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) { | |||||
| 129 | assert.Equal(t, "yank-section", bindings["yah"]) | 137 | assert.Equal(t, "yank-section", bindings["yah"]) | ||
| 130 | assert.Equal(t, "half-page-down", bindings["<C-d>"]) | 138 | assert.Equal(t, "half-page-down", bindings["<C-d>"]) | ||
| 131 | assert.Equal(t, "half-page-up", bindings["<C-u>"]) | 139 | assert.Equal(t, "half-page-up", bindings["<C-u>"]) | ||
| 132 | - | assert.Len(t, bindings, 20) | 140 | + | assert.Equal(t, "yank-code", bindings["yac"]) |
| 141 | + | assert.Equal(t, "hint-follow", bindings["f"]) | |||
| 142 | + | assert.Equal(t, "hint-follow-new", bindings["F"]) | |||
| 143 | + | assert.Equal(t, "hint-yank-url", bindings["yf"]) | |||
| 144 | + | assert.Equal(t, "visual", bindings["v"]) | |||
| 145 | + | assert.Len(t, bindings, 28) | |||
| 133 | } | 146 | } | ||
| 134 | 147 | ||||
| 135 | func TestValidateVimMode_NonNegativeTimeout(t *testing.T) { | 148 | func TestValidateVimMode_NonNegativeTimeout(t *testing.T) { | ||
M internal/ui/app.go +57 -1
| @@ -2221,13 +2221,17 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, | |||
| 2221 | 2221 | if navigationUC == nil { | |
| 2222 | 2222 | return | |
| 2223 | 2223 | } | |
| 2224 | - | if err := navigationUC.Execute(ctx, wv, action, count, a.vimNavigationHighlightColor()); err != nil { | |
| 2224 | + | outcome, err := navigationUC.Execute(ctx, wv, action, count, a.vimNavigationHighlightColor()) | |
| 2225 | + | if err != nil { | |
| 2225 | 2226 | logging.FromContext(ctx).Debug(). | |
| 2226 | 2227 | Err(err). | |
| 2227 | 2228 | Str("action", action). | |
| 2228 | 2229 | Msg("vim navigation unavailable") | |
| 2229 | 2230 | return | |
| 2230 | 2231 | } | |
| 2232 | + | if outcome.CapturePageKeys { | |
| 2233 | + | a.captureVimPageKeys(ctx, bw, wv) | |
| 2234 | + | } | |
| 2231 | 2235 | if bw != nil && (action == "heading-next" || action == "heading-prev") { | |
| 2232 | 2236 | if _, ok := wv.(port.SemanticNavigable); ok { | |
| 2233 | 2237 | if bw.vimNavigationHighlightedWebViewIDs == nil { | |
| @@ -2242,6 +2246,55 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, | |||
| 2242 | 2246 | } | |
| 2243 | 2247 | } | |
| 2244 | 2248 | ||
| 2249 | + | // captureVimPageKeys routes this window's Vim Mode keys to the in-page | |
| 2250 | + | // interaction running in wv until the page reports its end. | |
| 2251 | + | func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port.WebView) { | |
| 2252 | + | if bw == nil || bw.keyboardHandler == nil || wv == nil { | |
| 2253 | + | return | |
| 2254 | + | } | |
| 2255 | + | bw.vimPageInteractionWebView = wv | |
| 2256 | + | bw.keyboardHandler.SetPageKeyCapture(func(key string) { | |
| 2257 | + | if err := a.vimNavigationUseCase().SendPageKey(ctx, wv, key); err != nil { | |
| 2258 | + | logging.FromContext(ctx).Debug().Err(err).Msg("vim page key forwarding failed") | |
| 2259 | + | a.endVimPageInteraction(ctx, bw) | |
| 2260 | + | } | |
| 2261 | + | }) | |
| 2262 | + | } | |
| 2263 | + | ||
| 2264 | + | // handleVimPageInteractionEnded releases key capture when the page reports | |
| 2265 | + | // that its hint or visual interaction finished. | |
| 2266 | + | func (a *App) handleVimPageInteractionEnded(paneID entity.PaneID) { | |
| 2267 | + | bw := a.browserWindowForAnyPane(paneID) | |
| 2268 | + | if bw == nil || bw.vimPageInteractionWebView == nil || a.contentCoord == nil { | |
| 2269 | + | return | |
| 2270 | + | } | |
| 2271 | + | if a.contentCoord.GetWebView(paneID) != bw.vimPageInteractionWebView { | |
| 2272 | + | return | |
| 2273 | + | } | |
| 2274 | + | bw.vimPageInteractionWebView = nil | |
| 2275 | + | if bw.keyboardHandler != nil { | |
| 2276 | + | bw.keyboardHandler.ClearPageKeyCapture() | |
| 2277 | + | } | |
| 2278 | + | } | |
| 2279 | + | ||
| 2280 | + | // endVimPageInteraction cancels any in-page interaction owned by this window. | |
| 2281 | + | func (a *App) endVimPageInteraction(ctx context.Context, bw *browserWindow) { | |
| 2282 | + | if bw == nil { | |
| 2283 | + | return | |
| 2284 | + | } | |
| 2285 | + | wv := bw.vimPageInteractionWebView | |
| 2286 | + | bw.vimPageInteractionWebView = nil | |
| 2287 | + | if bw.keyboardHandler != nil { | |
| 2288 | + | bw.keyboardHandler.ClearPageKeyCapture() | |
| 2289 | + | } | |
| 2290 | + | if wv == nil { | |
| 2291 | + | return | |
| 2292 | + | } | |
| 2293 | + | if err := a.vimNavigationUseCase().CancelPageInteraction(ctx, wv); err != nil { | |
| 2294 | + | logging.FromContext(ctx).Debug().Err(err).Msg("failed to cancel vim page interaction") | |
| 2295 | + | } | |
| 2296 | + | } | |
| 2297 | + | ||
| 2245 | 2298 | func (a *App) clearVimNavigationHighlight(ctx context.Context, bw *browserWindow) { | |
| 2246 | 2299 | if bw == nil { | |
| 2247 | 2300 | return | |
| @@ -3423,6 +3476,9 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 3423 | 3476 | a.contentCoord.SetOnEditableFocusChanged(func(paneID entity.PaneID, editable bool) { | |
| 3424 | 3477 | a.handlePageEditableFocusChanged(ctx, paneID, editable) | |
| 3425 | 3478 | }) | |
| 3479 | + | a.contentCoord.SetOnVimPageInteractionEnded(func(paneID entity.PaneID) { | |
| 3480 | + | a.handleVimPageInteractionEnded(paneID) | |
| 3481 | + | }) | |
| 3426 | 3482 | ||
| 3427 | 3483 | // JS dialogs are shown in the owning pane's overlay, wherever that pane lives. | |
| 3428 | 3484 | a.contentCoord.SetPaneViewResolver(a.paneViewForPane) | |
| @@ -2221,13 +2221,17 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, | |||||
| 2221 | if navigationUC == nil { | 2221 | if navigationUC == nil { | ||
| 2222 | return | 2222 | return | ||
| 2223 | } | 2223 | } | ||
| 2224 | - | if err := navigationUC.Execute(ctx, wv, action, count, a.vimNavigationHighlightColor()); err != nil { | 2224 | + | outcome, err := navigationUC.Execute(ctx, wv, action, count, a.vimNavigationHighlightColor()) |
| 2225 | + | if err != nil { | |||
| 2225 | logging.FromContext(ctx).Debug(). | 2226 | logging.FromContext(ctx).Debug(). | ||
| 2226 | Err(err). | 2227 | Err(err). | ||
| 2227 | Str("action", action). | 2228 | Str("action", action). | ||
| 2228 | Msg("vim navigation unavailable") | 2229 | Msg("vim navigation unavailable") | ||
| 2229 | return | 2230 | return | ||
| 2230 | } | 2231 | } | ||
| 2232 | + | if outcome.CapturePageKeys { | |||
| 2233 | + | a.captureVimPageKeys(ctx, bw, wv) | |||
| 2234 | + | } | |||
| 2231 | if bw != nil && (action == "heading-next" || action == "heading-prev") { | 2235 | if bw != nil && (action == "heading-next" || action == "heading-prev") { | ||
| 2232 | if _, ok := wv.(port.SemanticNavigable); ok { | 2236 | if _, ok := wv.(port.SemanticNavigable); ok { | ||
| 2233 | if bw.vimNavigationHighlightedWebViewIDs == nil { | 2237 | if bw.vimNavigationHighlightedWebViewIDs == nil { | ||
| @@ -2242,6 +2246,55 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, | |||||
| 2242 | } | 2246 | } | ||
| 2243 | } | 2247 | } | ||
| 2244 | 2248 | ||||
| 2249 | + | // captureVimPageKeys routes this window's Vim Mode keys to the in-page | |||
| 2250 | + | // interaction running in wv until the page reports its end. | |||
| 2251 | + | func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port.WebView) { | |||
| 2252 | + | if bw == nil || bw.keyboardHandler == nil || wv == nil { | |||
| 2253 | + | return | |||
| 2254 | + | } | |||
| 2255 | + | bw.vimPageInteractionWebView = wv | |||
| 2256 | + | bw.keyboardHandler.SetPageKeyCapture(func(key string) { | |||
| 2257 | + | if err := a.vimNavigationUseCase().SendPageKey(ctx, wv, key); err != nil { | |||
| 2258 | + | logging.FromContext(ctx).Debug().Err(err).Msg("vim page key forwarding failed") | |||
| 2259 | + | a.endVimPageInteraction(ctx, bw) | |||
| 2260 | + | } | |||
| 2261 | + | }) | |||
| 2262 | + | } | |||
| 2263 | + | ||||
| 2264 | + | // handleVimPageInteractionEnded releases key capture when the page reports | |||
| 2265 | + | // that its hint or visual interaction finished. | |||
| 2266 | + | func (a *App) handleVimPageInteractionEnded(paneID entity.PaneID) { | |||
| 2267 | + | bw := a.browserWindowForAnyPane(paneID) | |||
| 2268 | + | if bw == nil || bw.vimPageInteractionWebView == nil || a.contentCoord == nil { | |||
| 2269 | + | return | |||
| 2270 | + | } | |||
| 2271 | + | if a.contentCoord.GetWebView(paneID) != bw.vimPageInteractionWebView { | |||
| 2272 | + | return | |||
| 2273 | + | } | |||
| 2274 | + | bw.vimPageInteractionWebView = nil | |||
| 2275 | + | if bw.keyboardHandler != nil { | |||
| 2276 | + | bw.keyboardHandler.ClearPageKeyCapture() | |||
| 2277 | + | } | |||
| 2278 | + | } | |||
| 2279 | + | ||||
| 2280 | + | // endVimPageInteraction cancels any in-page interaction owned by this window. | |||
| 2281 | + | func (a *App) endVimPageInteraction(ctx context.Context, bw *browserWindow) { | |||
| 2282 | + | if bw == nil { | |||
| 2283 | + | return | |||
| 2284 | + | } | |||
| 2285 | + | wv := bw.vimPageInteractionWebView | |||
| 2286 | + | bw.vimPageInteractionWebView = nil | |||
| 2287 | + | if bw.keyboardHandler != nil { | |||
| 2288 | + | bw.keyboardHandler.ClearPageKeyCapture() | |||
| 2289 | + | } | |||
| 2290 | + | if wv == nil { | |||
| 2291 | + | return | |||
| 2292 | + | } | |||
| 2293 | + | if err := a.vimNavigationUseCase().CancelPageInteraction(ctx, wv); err != nil { | |||
| 2294 | + | logging.FromContext(ctx).Debug().Err(err).Msg("failed to cancel vim page interaction") | |||
| 2295 | + | } | |||
| 2296 | + | } | |||
| 2297 | + | ||||
| 2245 | func (a *App) clearVimNavigationHighlight(ctx context.Context, bw *browserWindow) { | 2298 | func (a *App) clearVimNavigationHighlight(ctx context.Context, bw *browserWindow) { | ||
| 2246 | if bw == nil { | 2299 | if bw == nil { | ||
| 2247 | return | 2300 | return | ||
| @@ -3423,6 +3476,9 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 3423 | a.contentCoord.SetOnEditableFocusChanged(func(paneID entity.PaneID, editable bool) { | 3476 | a.contentCoord.SetOnEditableFocusChanged(func(paneID entity.PaneID, editable bool) { | ||
| 3424 | a.handlePageEditableFocusChanged(ctx, paneID, editable) | 3477 | a.handlePageEditableFocusChanged(ctx, paneID, editable) | ||
| 3425 | }) | 3478 | }) | ||
| 3479 | + | a.contentCoord.SetOnVimPageInteractionEnded(func(paneID entity.PaneID) { | |||
| 3480 | + | a.handleVimPageInteractionEnded(paneID) | |||
| 3481 | + | }) | |||
| 3426 | 3482 | ||||
| 3427 | // JS dialogs are shown in the owning pane's overlay, wherever that pane lives. | 3483 | // JS dialogs are shown in the owning pane's overlay, wherever that pane lives. | ||
| 3428 | a.contentCoord.SetPaneViewResolver(a.paneViewForPane) | 3484 | a.contentCoord.SetPaneViewResolver(a.paneViewForPane) | ||
M internal/ui/browser_window.go +4 -0
| @@ -59,6 +59,9 @@ type browserWindow struct { | |||
| 59 | 59 | // navigation target that must be cleared when this window leaves Vim Mode. | |
| 60 | 60 | vimNavigationHighlightedWebViews []port.WebView | |
| 61 | 61 | vimNavigationHighlightedWebViewIDs map[port.WebViewID]struct{} | |
| 62 | + | // vimPageInteractionWebView is the WebView running link hints or visual | |
| 63 | + | // selection while this window's keys are captured for the page. | |
| 64 | + | vimPageInteractionWebView port.WebView | |
| 62 | 65 | } | |
| 63 | 66 | ||
| 64 | 67 | func (bw *browserWindow) detachInputForDestroy() { | |
| @@ -121,6 +124,7 @@ func (bw *browserWindow) clearShellState() { | |||
| 121 | 124 | bw.vimModePaneID = "" | |
| 122 | 125 | bw.vimNavigationHighlightedWebViews = nil | |
| 123 | 126 | bw.vimNavigationHighlightedWebViewIDs = nil | |
| 127 | + | bw.vimPageInteractionWebView = nil | |
| 124 | 128 | } | |
| 125 | 129 | ||
| 126 | 130 | func (bw *browserWindow) initChrome(ctx context.Context, a *App) { | |
| @@ -59,6 +59,9 @@ type browserWindow struct { | |||||
| 59 | // navigation target that must be cleared when this window leaves Vim Mode. | 59 | // navigation target that must be cleared when this window leaves Vim Mode. | ||
| 60 | vimNavigationHighlightedWebViews []port.WebView | 60 | vimNavigationHighlightedWebViews []port.WebView | ||
| 61 | vimNavigationHighlightedWebViewIDs map[port.WebViewID]struct{} | 61 | vimNavigationHighlightedWebViewIDs map[port.WebViewID]struct{} | ||
| 62 | + | // vimPageInteractionWebView is the WebView running link hints or visual | |||
| 63 | + | // selection while this window's keys are captured for the page. | |||
| 64 | + | vimPageInteractionWebView port.WebView | |||
| 62 | } | 65 | } | ||
| 63 | 66 | ||||
| 64 | func (bw *browserWindow) detachInputForDestroy() { | 67 | func (bw *browserWindow) detachInputForDestroy() { | ||
| @@ -121,6 +124,7 @@ func (bw *browserWindow) clearShellState() { | |||||
| 121 | bw.vimModePaneID = "" | 124 | bw.vimModePaneID = "" | ||
| 122 | bw.vimNavigationHighlightedWebViews = nil | 125 | bw.vimNavigationHighlightedWebViews = nil | ||
| 123 | bw.vimNavigationHighlightedWebViewIDs = nil | 126 | bw.vimNavigationHighlightedWebViewIDs = nil | ||
| 127 | + | bw.vimPageInteractionWebView = nil | |||
| 124 | } | 128 | } | ||
| 125 | 129 | ||||
| 126 | func (bw *browserWindow) initChrome(ctx context.Context, a *App) { | 130 | func (bw *browserWindow) initChrome(ctx context.Context, a *App) { | ||
M internal/ui/coordinator/content/callbacks.go +6 -0
| @@ -187,6 +187,12 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P | |||
| 187 | 187 | } | |
| 188 | 188 | } | |
| 189 | 189 | ||
| 190 | + | callbacks.OnVimPageInteractionEnded = func() { | |
| 191 | + | if c.onVimPageInteractionEnded != nil { | |
| 192 | + | c.onVimPageInteractionEnded(paneID) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 190 | 196 | // Add popup create handler if popup handling is configured | |
| 191 | 197 | callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv) | |
| 192 | 198 | ||
| @@ -187,6 +187,12 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P | |||||
| 187 | } | 187 | } | ||
| 188 | } | 188 | } | ||
| 189 | 189 | ||||
| 190 | + | callbacks.OnVimPageInteractionEnded = func() { | |||
| 191 | + | if c.onVimPageInteractionEnded != nil { | |||
| 192 | + | c.onVimPageInteractionEnded(paneID) | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | ||||
| 190 | // Add popup create handler if popup handling is configured | 196 | // Add popup create handler if popup handling is configured | ||
| 191 | callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv) | 197 | callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv) | ||
| 192 | 198 | ||||
M internal/ui/coordinator/content/coordinator.go +8 -0
| @@ -121,6 +121,8 @@ type Coordinator struct { | |||
| 121 | 121 | ||
| 122 | 122 | // Callback when a page editable target gains or loses focus. | |
| 123 | 123 | onEditableFocusChanged func(paneID entity.PaneID, editable bool) | |
| 124 | + | // onVimPageInteractionEnded fires when a page finishes link hints or visual selection. | |
| 125 | + | onVimPageInteractionEnded func(paneID entity.PaneID) | |
| 124 | 126 | ||
| 125 | 127 | // Callback for first load_started event (triggers deferred initialization) | |
| 126 | 128 | onFirstLoadStarted func() | |
| @@ -277,6 +279,12 @@ func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, ed | |||
| 277 | 279 | c.onEditableFocusChanged = fn | |
| 278 | 280 | } | |
| 279 | 281 | ||
| 282 | + | // SetOnVimPageInteractionEnded sets the callback for the end of an in-page | |
| 283 | + | // Vim interaction (link hints or visual selection). | |
| 284 | + | func (c *Coordinator) SetOnVimPageInteractionEnded(fn func(paneID entity.PaneID)) { | |
| 285 | + | c.onVimPageInteractionEnded = fn | |
| 286 | + | } | |
| 287 | + | ||
| 280 | 288 | // SetOnFirstLoadStarted sets the callback for when the first navigation starts. | |
| 281 | 289 | // This is used to trigger deferred initialization after the initial load_uri() | |
| 282 | 290 | // has been processed by the GTK main loop. | |
| @@ -121,6 +121,8 @@ type Coordinator struct { | |||||
| 121 | 121 | ||||
| 122 | // Callback when a page editable target gains or loses focus. | 122 | // Callback when a page editable target gains or loses focus. | ||
| 123 | onEditableFocusChanged func(paneID entity.PaneID, editable bool) | 123 | onEditableFocusChanged func(paneID entity.PaneID, editable bool) | ||
| 124 | + | // onVimPageInteractionEnded fires when a page finishes link hints or visual selection. | |||
| 125 | + | onVimPageInteractionEnded func(paneID entity.PaneID) | |||
| 124 | 126 | ||||
| 125 | // Callback for first load_started event (triggers deferred initialization) | 127 | // Callback for first load_started event (triggers deferred initialization) | ||
| 126 | onFirstLoadStarted func() | 128 | onFirstLoadStarted func() | ||
| @@ -277,6 +279,12 @@ func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, ed | |||||
| 277 | c.onEditableFocusChanged = fn | 279 | c.onEditableFocusChanged = fn | ||
| 278 | } | 280 | } | ||
| 279 | 281 | ||||
| 282 | + | // SetOnVimPageInteractionEnded sets the callback for the end of an in-page | |||
| 283 | + | // Vim interaction (link hints or visual selection). | |||
| 284 | + | func (c *Coordinator) SetOnVimPageInteractionEnded(fn func(paneID entity.PaneID)) { | |||
| 285 | + | c.onVimPageInteractionEnded = fn | |||
| 286 | + | } | |||
| 287 | + | ||||
| 280 | // SetOnFirstLoadStarted sets the callback for when the first navigation starts. | 288 | // SetOnFirstLoadStarted sets the callback for when the first navigation starts. | ||
| 281 | // This is used to trigger deferred initialization after the initial load_uri() | 289 | // This is used to trigger deferred initialization after the initial load_uri() | ||
| 282 | // has been processed by the GTK main loop. | 290 | // has been processed by the GTK main loop. | ||
M internal/ui/input/keyboard.go +10 -0
| @@ -121,6 +121,10 @@ type KeyboardHandler struct { | |||
| 121 | 121 | vimScrollNow func() time.Time | |
| 122 | 122 | ||
| 123 | 123 | seq vimModeSequenceState | |
| 124 | + | ||
| 125 | + | // pageKeyCapture forwards Vim Mode keys to an in-page interaction (link | |
| 126 | + | // hints or visual selection) while it is active. | |
| 127 | + | pageKeyCapture PageKeyForwarder | |
| 124 | 128 | } | |
| 125 | 129 | ||
| 126 | 130 | // NewKeyboardHandler creates a new keyboard handler. | |
| @@ -218,6 +222,7 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) { | |||
| 218 | 222 | h.setControllerPhase(gtk.PhaseCaptureValue) | |
| 219 | 223 | } | |
| 220 | 224 | if to != ModeVim { | |
| 225 | + | h.ClearPageKeyCapture() | |
| 221 | 226 | h.stopVimScrollRepeat() | |
| 222 | 227 | // Silent reset under ModalState lock: no pending callback (avoids Mode() re-entry). | |
| 223 | 228 | h.resetPendingSequence(false) | |
| @@ -407,6 +412,11 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 407 | 412 | } | |
| 408 | 413 | } | |
| 409 | 414 | ||
| 415 | + | // An in-page Vim interaction owns every key, including Escape and Enter. | |
| 416 | + | if h.forwardPageKey(mode, keyval, state) { | |
| 417 | + | return true | |
| 418 | + | } | |
| 419 | + | ||
| 410 | 420 | if h.handleGuaranteedModalExit(mode, keyval, modifiers) { | |
| 411 | 421 | return true | |
| 412 | 422 | } | |
| @@ -121,6 +121,10 @@ type KeyboardHandler struct { | |||||
| 121 | vimScrollNow func() time.Time | 121 | vimScrollNow func() time.Time | ||
| 122 | 122 | ||||
| 123 | seq vimModeSequenceState | 123 | seq vimModeSequenceState | ||
| 124 | + | ||||
| 125 | + | // pageKeyCapture forwards Vim Mode keys to an in-page interaction (link | |||
| 126 | + | // hints or visual selection) while it is active. | |||
| 127 | + | pageKeyCapture PageKeyForwarder | |||
| 124 | } | 128 | } | ||
| 125 | 129 | ||||
| 126 | // NewKeyboardHandler creates a new keyboard handler. | 130 | // NewKeyboardHandler creates a new keyboard handler. | ||
| @@ -218,6 +222,7 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) { | |||||
| 218 | h.setControllerPhase(gtk.PhaseCaptureValue) | 222 | h.setControllerPhase(gtk.PhaseCaptureValue) | ||
| 219 | } | 223 | } | ||
| 220 | if to != ModeVim { | 224 | if to != ModeVim { | ||
| 225 | + | h.ClearPageKeyCapture() | |||
| 221 | h.stopVimScrollRepeat() | 226 | h.stopVimScrollRepeat() | ||
| 222 | // Silent reset under ModalState lock: no pending callback (avoids Mode() re-entry). | 227 | // Silent reset under ModalState lock: no pending callback (avoids Mode() re-entry). | ||
| 223 | h.resetPendingSequence(false) | 228 | h.resetPendingSequence(false) | ||
| @@ -407,6 +412,11 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||||
| 407 | } | 412 | } | ||
| 408 | } | 413 | } | ||
| 409 | 414 | ||||
| 415 | + | // An in-page Vim interaction owns every key, including Escape and Enter. | |||
| 416 | + | if h.forwardPageKey(mode, keyval, state) { | |||
| 417 | + | return true | |||
| 418 | + | } | |||
| 419 | + | ||||
| 410 | if h.handleGuaranteedModalExit(mode, keyval, modifiers) { | 420 | if h.handleGuaranteedModalExit(mode, keyval, modifiers) { | ||
| 411 | return true | 421 | return true | ||
| 412 | } | 422 | } | ||
A internal/ui/input/vim_page_capture.go +78 -0
| @@ -0,0 +1,78 @@ | |||
| 1 | + | package input | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/dumber/internal/domain/vimkeys" | |
| 5 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // PageKeyForwarder receives canonical vimkeys strings ("j", "J", "<Escape>") | |
| 9 | + | // while an in-page Vim interaction owns keyboard input. | |
| 10 | + | type PageKeyForwarder func(key string) | |
| 11 | + | ||
| 12 | + | // SetPageKeyCapture routes Vim Mode keys to fn until ClearPageKeyCapture or | |
| 13 | + | // Vim Mode exits. Pending sequences are discarded so they cannot complete | |
| 14 | + | // against keys meant for the page. | |
| 15 | + | func (h *KeyboardHandler) SetPageKeyCapture(fn PageKeyForwarder) { | |
| 16 | + | h.ResetPendingSequence() | |
| 17 | + | h.mu.Lock() | |
| 18 | + | h.pageKeyCapture = fn | |
| 19 | + | h.mu.Unlock() | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // ClearPageKeyCapture returns key handling to Vim Mode bindings. | |
| 23 | + | func (h *KeyboardHandler) ClearPageKeyCapture() { | |
| 24 | + | h.mu.Lock() | |
| 25 | + | h.pageKeyCapture = nil | |
| 26 | + | h.mu.Unlock() | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // PageKeyCaptureActive reports whether an in-page interaction owns keys. | |
| 30 | + | func (h *KeyboardHandler) PageKeyCaptureActive() bool { | |
| 31 | + | h.mu.RLock() | |
| 32 | + | defer h.mu.RUnlock() | |
| 33 | + | return h.pageKeyCapture != nil | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // forwardPageKey sends a Vim Mode key to the active page capture. Escape is | |
| 37 | + | // forwarded and also releases the capture locally so a page that never | |
| 38 | + | // reports completion cannot trap the keyboard. | |
| 39 | + | func (h *KeyboardHandler) forwardPageKey(mode Mode, keyval uint, state gdk.ModifierType) bool { | |
| 40 | + | if mode != ModeVim { | |
| 41 | + | return false | |
| 42 | + | } | |
| 43 | + | h.mu.RLock() | |
| 44 | + | forward := h.pageKeyCapture | |
| 45 | + | h.mu.RUnlock() | |
| 46 | + | if forward == nil { | |
| 47 | + | return false | |
| 48 | + | } | |
| 49 | + | if isModifierKeyval(keyval) { | |
| 50 | + | return true | |
| 51 | + | } | |
| 52 | + | key, ok := KeyvalToVimKey(keyval, state) | |
| 53 | + | if !ok { | |
| 54 | + | return true | |
| 55 | + | } | |
| 56 | + | if key.Sym == "Esc" { | |
| 57 | + | // Any Escape ends the interaction, so the page must see a plain one. | |
| 58 | + | key.Mods = 0 | |
| 59 | + | h.ClearPageKeyCapture() | |
| 60 | + | } | |
| 61 | + | forward(vimkeys.Sequence{key}.String()) | |
| 62 | + | // Captured keys bypass dispatchAction, so keep Vim Mode's timeout alive. | |
| 63 | + | h.modal.ResetTimeout(h.ctx) | |
| 64 | + | return true | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func isModifierKeyval(keyval uint) bool { | |
| 68 | + | switch keyval { | |
| 69 | + | case uint(gdk.KEY_Shift_L), uint(gdk.KEY_Shift_R), | |
| 70 | + | uint(gdk.KEY_Control_L), uint(gdk.KEY_Control_R), | |
| 71 | + | uint(gdk.KEY_Alt_L), uint(gdk.KEY_Alt_R), | |
| 72 | + | uint(gdk.KEY_Super_L), uint(gdk.KEY_Super_R), | |
| 73 | + | uint(gdk.KEY_Caps_Lock), uint(gdk.KEY_ISO_Level3_Shift): | |
| 74 | + | return true | |
| 75 | + | default: | |
| 76 | + | return false | |
| 77 | + | } | |
| 78 | + | } | |
| @@ -0,0 +1,78 @@ | |||||
| 1 | + | package input | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/dumber/internal/domain/vimkeys" | |||
| 5 | + | "github.com/bnema/puregotk/v4/gdk" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // PageKeyForwarder receives canonical vimkeys strings ("j", "J", "<Escape>") | |||
| 9 | + | // while an in-page Vim interaction owns keyboard input. | |||
| 10 | + | type PageKeyForwarder func(key string) | |||
| 11 | + | ||||
| 12 | + | // SetPageKeyCapture routes Vim Mode keys to fn until ClearPageKeyCapture or | |||
| 13 | + | // Vim Mode exits. Pending sequences are discarded so they cannot complete | |||
| 14 | + | // against keys meant for the page. | |||
| 15 | + | func (h *KeyboardHandler) SetPageKeyCapture(fn PageKeyForwarder) { | |||
| 16 | + | h.ResetPendingSequence() | |||
| 17 | + | h.mu.Lock() | |||
| 18 | + | h.pageKeyCapture = fn | |||
| 19 | + | h.mu.Unlock() | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // ClearPageKeyCapture returns key handling to Vim Mode bindings. | |||
| 23 | + | func (h *KeyboardHandler) ClearPageKeyCapture() { | |||
| 24 | + | h.mu.Lock() | |||
| 25 | + | h.pageKeyCapture = nil | |||
| 26 | + | h.mu.Unlock() | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // PageKeyCaptureActive reports whether an in-page interaction owns keys. | |||
| 30 | + | func (h *KeyboardHandler) PageKeyCaptureActive() bool { | |||
| 31 | + | h.mu.RLock() | |||
| 32 | + | defer h.mu.RUnlock() | |||
| 33 | + | return h.pageKeyCapture != nil | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // forwardPageKey sends a Vim Mode key to the active page capture. Escape is | |||
| 37 | + | // forwarded and also releases the capture locally so a page that never | |||
| 38 | + | // reports completion cannot trap the keyboard. | |||
| 39 | + | func (h *KeyboardHandler) forwardPageKey(mode Mode, keyval uint, state gdk.ModifierType) bool { | |||
| 40 | + | if mode != ModeVim { | |||
| 41 | + | return false | |||
| 42 | + | } | |||
| 43 | + | h.mu.RLock() | |||
| 44 | + | forward := h.pageKeyCapture | |||
| 45 | + | h.mu.RUnlock() | |||
| 46 | + | if forward == nil { | |||
| 47 | + | return false | |||
| 48 | + | } | |||
| 49 | + | if isModifierKeyval(keyval) { | |||
| 50 | + | return true | |||
| 51 | + | } | |||
| 52 | + | key, ok := KeyvalToVimKey(keyval, state) | |||
| 53 | + | if !ok { | |||
| 54 | + | return true | |||
| 55 | + | } | |||
| 56 | + | if key.Sym == "Esc" { | |||
| 57 | + | // Any Escape ends the interaction, so the page must see a plain one. | |||
| 58 | + | key.Mods = 0 | |||
| 59 | + | h.ClearPageKeyCapture() | |||
| 60 | + | } | |||
| 61 | + | forward(vimkeys.Sequence{key}.String()) | |||
| 62 | + | // Captured keys bypass dispatchAction, so keep Vim Mode's timeout alive. | |||
| 63 | + | h.modal.ResetTimeout(h.ctx) | |||
| 64 | + | return true | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func isModifierKeyval(keyval uint) bool { | |||
| 68 | + | switch keyval { | |||
| 69 | + | case uint(gdk.KEY_Shift_L), uint(gdk.KEY_Shift_R), | |||
| 70 | + | uint(gdk.KEY_Control_L), uint(gdk.KEY_Control_R), | |||
| 71 | + | uint(gdk.KEY_Alt_L), uint(gdk.KEY_Alt_R), | |||
| 72 | + | uint(gdk.KEY_Super_L), uint(gdk.KEY_Super_R), | |||
| 73 | + | uint(gdk.KEY_Caps_Lock), uint(gdk.KEY_ISO_Level3_Shift): | |||
| 74 | + | return true | |||
| 75 | + | default: | |||
| 76 | + | return false | |||
| 77 | + | } | |||
| 78 | + | } | |||
A internal/ui/input/vim_page_capture_test.go +70 -0
| @@ -0,0 +1,70 @@ | |||
| 1 | + | package input | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestPageKeyCaptureForwardsVimModeKeys(t *testing.T) { | |
| 15 | + | ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{ | |
| 16 | + | "vim-scroll-down": {Keys: []string{"j"}}, | |
| 17 | + | "yank-section": {Keys: []string{"yah"}}, | |
| 18 | + | }) | |
| 19 | + | h := NewKeyboardHandler(context.Background(), ws, newTestSession()) | |
| 20 | + | actions := captureSequenceActions(h) | |
| 21 | + | enterVimMode(t, h) | |
| 22 | + | ||
| 23 | + | var forwarded []string | |
| 24 | + | h.SetPageKeyCapture(func(key string) { forwarded = append(forwarded, key) }) | |
| 25 | + | ||
| 26 | + | require.True(t, h.handleKeyPress(uint('y'), 0, 0)) | |
| 27 | + | require.True(t, h.handleKeyPress(uint('J'), 0, gdk.ShiftMaskValue)) | |
| 28 | + | require.True(t, h.handleKeyPress(uint(gdk.KEY_Shift_L), 0, 0)) | |
| 29 | + | require.True(t, h.handleKeyPress(uint(gdk.KEY_Return), 0, 0)) | |
| 30 | + | ||
| 31 | + | assert.Equal(t, []string{"y", "J", "<Return>"}, forwarded) | |
| 32 | + | assert.Empty(t, *actions, "captured keys must not reach Vim bindings") | |
| 33 | + | assert.Equal(t, ModeVim, h.Mode(), "Enter is forwarded instead of confirming Vim Mode") | |
| 34 | + | assert.Empty(t, h.PendingSequence()) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestPageKeyCaptureEscapeReleasesCaptureAndStaysInVimMode(t *testing.T) { | |
| 38 | + | h := NewKeyboardHandler(context.Background(), vimModeSequenceWorkspace(nil), newTestSession()) | |
| 39 | + | enterVimMode(t, h) | |
| 40 | + | var forwarded []string | |
| 41 | + | h.SetPageKeyCapture(func(key string) { forwarded = append(forwarded, key) }) | |
| 42 | + | ||
| 43 | + | require.True(t, h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0)) | |
| 44 | + | ||
| 45 | + | assert.Equal(t, []string{"<Escape>"}, forwarded) | |
| 46 | + | assert.False(t, h.PageKeyCaptureActive()) | |
| 47 | + | assert.Equal(t, ModeVim, h.Mode()) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestPageKeyCaptureClearedWhenLeavingVimMode(t *testing.T) { | |
| 51 | + | h := NewKeyboardHandler(context.Background(), vimModeSequenceWorkspace(nil), newTestSession()) | |
| 52 | + | enterVimMode(t, h) | |
| 53 | + | h.SetPageKeyCapture(func(string) {}) | |
| 54 | + | ||
| 55 | + | h.ExitMode() | |
| 56 | + | ||
| 57 | + | assert.False(t, h.PageKeyCaptureActive()) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestPageKeyCaptureModifiedEscapeForwardsPlainEscape(t *testing.T) { | |
| 61 | + | h := NewKeyboardHandler(context.Background(), vimModeSequenceWorkspace(nil), newTestSession()) | |
| 62 | + | enterVimMode(t, h) | |
| 63 | + | var forwarded []string | |
| 64 | + | h.SetPageKeyCapture(func(key string) { forwarded = append(forwarded, key) }) | |
| 65 | + | ||
| 66 | + | require.True(t, h.handleKeyPress(uint(gdk.KEY_Escape), 0, gdk.ShiftMaskValue)) | |
| 67 | + | ||
| 68 | + | assert.Equal(t, []string{"<Escape>"}, forwarded) | |
| 69 | + | assert.False(t, h.PageKeyCaptureActive()) | |
| 70 | + | } | |
| @@ -0,0 +1,70 @@ | |||||
| 1 | + | package input | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/puregotk/v4/gdk" | |||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestPageKeyCaptureForwardsVimModeKeys(t *testing.T) { | |||
| 15 | + | ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{ | |||
| 16 | + | "vim-scroll-down": {Keys: []string{"j"}}, | |||
| 17 | + | "yank-section": {Keys: []string{"yah"}}, | |||
| 18 | + | }) | |||
| 19 | + | h := NewKeyboardHandler(context.Background(), ws, newTestSession()) | |||
| 20 | + | actions := captureSequenceActions(h) | |||
| 21 | + | enterVimMode(t, h) | |||
| 22 | + | ||||
| 23 | + | var forwarded []string | |||
| 24 | + | h.SetPageKeyCapture(func(key string) { forwarded = append(forwarded, key) }) | |||
| 25 | + | ||||
| 26 | + | require.True(t, h.handleKeyPress(uint('y'), 0, 0)) | |||
| 27 | + | require.True(t, h.handleKeyPress(uint('J'), 0, gdk.ShiftMaskValue)) | |||
| 28 | + | require.True(t, h.handleKeyPress(uint(gdk.KEY_Shift_L), 0, 0)) | |||
| 29 | + | require.True(t, h.handleKeyPress(uint(gdk.KEY_Return), 0, 0)) | |||
| 30 | + | ||||
| 31 | + | assert.Equal(t, []string{"y", "J", "<Return>"}, forwarded) | |||
| 32 | + | assert.Empty(t, *actions, "captured keys must not reach Vim bindings") | |||
| 33 | + | assert.Equal(t, ModeVim, h.Mode(), "Enter is forwarded instead of confirming Vim Mode") | |||
| 34 | + | assert.Empty(t, h.PendingSequence()) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestPageKeyCaptureEscapeReleasesCaptureAndStaysInVimMode(t *testing.T) { | |||
| 38 | + | h := NewKeyboardHandler(context.Background(), vimModeSequenceWorkspace(nil), newTestSession()) | |||
| 39 | + | enterVimMode(t, h) | |||
| 40 | + | var forwarded []string | |||
| 41 | + | h.SetPageKeyCapture(func(key string) { forwarded = append(forwarded, key) }) | |||
| 42 | + | ||||
| 43 | + | require.True(t, h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0)) | |||
| 44 | + | ||||
| 45 | + | assert.Equal(t, []string{"<Escape>"}, forwarded) | |||
| 46 | + | assert.False(t, h.PageKeyCaptureActive()) | |||
| 47 | + | assert.Equal(t, ModeVim, h.Mode()) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestPageKeyCaptureClearedWhenLeavingVimMode(t *testing.T) { | |||
| 51 | + | h := NewKeyboardHandler(context.Background(), vimModeSequenceWorkspace(nil), newTestSession()) | |||
| 52 | + | enterVimMode(t, h) | |||
| 53 | + | h.SetPageKeyCapture(func(string) {}) | |||
| 54 | + | ||||
| 55 | + | h.ExitMode() | |||
| 56 | + | ||||
| 57 | + | assert.False(t, h.PageKeyCaptureActive()) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestPageKeyCaptureModifiedEscapeForwardsPlainEscape(t *testing.T) { | |||
| 61 | + | h := NewKeyboardHandler(context.Background(), vimModeSequenceWorkspace(nil), newTestSession()) | |||
| 62 | + | enterVimMode(t, h) | |||
| 63 | + | var forwarded []string | |||
| 64 | + | h.SetPageKeyCapture(func(key string) { forwarded = append(forwarded, key) }) | |||
| 65 | + | ||||
| 66 | + | require.True(t, h.handleKeyPress(uint(gdk.KEY_Escape), 0, gdk.ShiftMaskValue)) | |||
| 67 | + | ||||
| 68 | + | assert.Equal(t, []string{"<Escape>"}, forwarded) | |||
| 69 | + | assert.False(t, h.PageKeyCaptureActive()) | |||
| 70 | + | } | |||
M internal/ui/input/vimkeys_dispatch.go +18 -0
| @@ -35,6 +35,19 @@ func trieOwnsBinding(raw string) (vimkeys.Sequence, bool) { | |||
| 35 | 35 | return nil, false | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | + | // trieOnlyActionBinding owns a single-chord binding when its action has no | |
| 39 | + | // legacy shortcut mapping and must therefore complete through the trie. | |
| 40 | + | func trieOnlyActionBinding(action, raw string) (vimkeys.Sequence, bool) { | |
| 41 | + | if mapModeConfigAction(action, "vim") != "" { | |
| 42 | + | return nil, false | |
| 43 | + | } | |
| 44 | + | seq, err := vimkeys.ParseBinding(raw) | |
| 45 | + | if err != nil || len(seq) != 1 || isCountStartKey(seq[0]) { | |
| 46 | + | return nil, false | |
| 47 | + | } | |
| 48 | + | return seq, true | |
| 49 | + | } | |
| 50 | + | ||
| 38 | 51 | // buildVimModeTrie inserts trie-owned Vim mode bindings. Action names are | |
| 39 | 52 | // sorted so the first sorted name wins on Insert conflicts (validation already | |
| 40 | 53 | // rejects duplicates). Ownership is marked only after a successful Insert. | |
| @@ -57,6 +70,11 @@ func buildVimModeTrie(cfg *entity.VimModeConfig) (*vimkeys.Trie, map[string]bool | |||
| 57 | 70 | sort.Strings(keys) | |
| 58 | 71 | for _, key := range keys { | |
| 59 | 72 | seq, ok := trieOwnsBinding(key) | |
| 73 | + | if !ok { | |
| 74 | + | // Single chords for actions without a legacy Action (for example | |
| 75 | + | // "f" or "v") can only be dispatched through the sequence trie. | |
| 76 | + | seq, ok = trieOnlyActionBinding(action, key) | |
| 77 | + | } | |
| 60 | 78 | if !ok { | |
| 61 | 79 | continue | |
| 62 | 80 | } | |
| @@ -35,6 +35,19 @@ func trieOwnsBinding(raw string) (vimkeys.Sequence, bool) { | |||||
| 35 | return nil, false | 35 | return nil, false | ||
| 36 | } | 36 | } | ||
| 37 | 37 | ||||
| 38 | + | // trieOnlyActionBinding owns a single-chord binding when its action has no | |||
| 39 | + | // legacy shortcut mapping and must therefore complete through the trie. | |||
| 40 | + | func trieOnlyActionBinding(action, raw string) (vimkeys.Sequence, bool) { | |||
| 41 | + | if mapModeConfigAction(action, "vim") != "" { | |||
| 42 | + | return nil, false | |||
| 43 | + | } | |||
| 44 | + | seq, err := vimkeys.ParseBinding(raw) | |||
| 45 | + | if err != nil || len(seq) != 1 || isCountStartKey(seq[0]) { | |||
| 46 | + | return nil, false | |||
| 47 | + | } | |||
| 48 | + | return seq, true | |||
| 49 | + | } | |||
| 50 | + | ||||
| 38 | // buildVimModeTrie inserts trie-owned Vim mode bindings. Action names are | 51 | // buildVimModeTrie inserts trie-owned Vim mode bindings. Action names are | ||
| 39 | // sorted so the first sorted name wins on Insert conflicts (validation already | 52 | // sorted so the first sorted name wins on Insert conflicts (validation already | ||
| 40 | // rejects duplicates). Ownership is marked only after a successful Insert. | 53 | // rejects duplicates). Ownership is marked only after a successful Insert. | ||
| @@ -57,6 +70,11 @@ func buildVimModeTrie(cfg *entity.VimModeConfig) (*vimkeys.Trie, map[string]bool | |||||
| 57 | sort.Strings(keys) | 70 | sort.Strings(keys) | ||
| 58 | for _, key := range keys { | 71 | for _, key := range keys { | ||
| 59 | seq, ok := trieOwnsBinding(key) | 72 | seq, ok := trieOwnsBinding(key) | ||
| 73 | + | if !ok { | |||
| 74 | + | // Single chords for actions without a legacy Action (for example | |||
| 75 | + | // "f" or "v") can only be dispatched through the sequence trie. | |||
| 76 | + | seq, ok = trieOnlyActionBinding(action, key) | |||
| 77 | + | } | |||
| 60 | if !ok { | 78 | if !ok { | ||
| 61 | continue | 79 | continue | ||
| 62 | } | 80 | } | ||
M internal/ui/input/vimkeys_dispatch_test.go +6 -0
| @@ -161,6 +161,9 @@ func TestBuildVimModeTrie(t *testing.T) { | |||
| 161 | 161 | "yank-section": {Keys: []string{"yah"}}, | |
| 162 | 162 | "outline": {Keys: []string{"gO"}}, | |
| 163 | 163 | "half-page-down": {Keys: []string{"<C-d>"}}, | |
| 164 | + | "hint-follow": {Keys: []string{"f"}}, | |
| 165 | + | "hint-follow-new": {Keys: []string{"F"}}, | |
| 166 | + | "visual": {Keys: []string{"v"}}, | |
| 164 | 167 | "confirm": {Keys: []string{"enter"}}, | |
| 165 | 168 | "cancel": {Keys: []string{"escape"}}, | |
| 166 | 169 | }, | |
| @@ -177,6 +180,9 @@ func TestBuildVimModeTrie(t *testing.T) { | |||
| 177 | 180 | "yah": true, | |
| 178 | 181 | "gO": true, | |
| 179 | 182 | "<C-d>": true, | |
| 183 | + | "f": true, | |
| 184 | + | "F": true, | |
| 185 | + | "v": true, | |
| 180 | 186 | } | |
| 181 | 187 | for key, want := range wantOwned { | |
| 182 | 188 | if owned[key] != want { | |
| @@ -161,6 +161,9 @@ func TestBuildVimModeTrie(t *testing.T) { | |||||
| 161 | "yank-section": {Keys: []string{"yah"}}, | 161 | "yank-section": {Keys: []string{"yah"}}, | ||
| 162 | "outline": {Keys: []string{"gO"}}, | 162 | "outline": {Keys: []string{"gO"}}, | ||
| 163 | "half-page-down": {Keys: []string{"<C-d>"}}, | 163 | "half-page-down": {Keys: []string{"<C-d>"}}, | ||
| 164 | + | "hint-follow": {Keys: []string{"f"}}, | |||
| 165 | + | "hint-follow-new": {Keys: []string{"F"}}, | |||
| 166 | + | "visual": {Keys: []string{"v"}}, | |||
| 164 | "confirm": {Keys: []string{"enter"}}, | 167 | "confirm": {Keys: []string{"enter"}}, | ||
| 165 | "cancel": {Keys: []string{"escape"}}, | 168 | "cancel": {Keys: []string{"escape"}}, | ||
| 166 | }, | 169 | }, | ||
| @@ -177,6 +180,9 @@ func TestBuildVimModeTrie(t *testing.T) { | |||||
| 177 | "yah": true, | 180 | "yah": true, | ||
| 178 | "gO": true, | 181 | "gO": true, | ||
| 179 | "<C-d>": true, | 182 | "<C-d>": true, | ||
| 183 | + | "f": true, | |||
| 184 | + | "F": true, | |||
| 185 | + | "v": true, | |||
| 180 | } | 186 | } | ||
| 181 | for key, want := range wantOwned { | 187 | for key, want := range wantOwned { | ||
| 182 | if owned[key] != want { | 188 | if owned[key] != want { | ||
M internal/ui/mode_frame.go +15 -2
| @@ -618,6 +618,19 @@ func (f *modeFrame) sizeLegendColumns(columns uint, width int) { | |||
| 618 | 618 | } | |
| 619 | 619 | } | |
| 620 | 620 | ||
| 621 | + | // vimLegendGroup returns the legend group of a Vim Mode action, or "" when the | |
| 622 | + | // action belongs to a generic group. | |
| 623 | + | func vimLegendGroup(name string) string { | |
| 624 | + | switch { | |
| 625 | + | case strings.HasPrefix(name, "hint-") || strings.HasPrefix(name, "yank-") || name == "visual": | |
| 626 | + | return "SELECT" | |
| 627 | + | case strings.Contains(name, "next") || strings.Contains(name, "prev") || name == "outline": | |
| 628 | + | return "JUMP" | |
| 629 | + | default: | |
| 630 | + | return "" | |
| 631 | + | } | |
| 632 | + | } | |
| 633 | + | ||
| 621 | 634 | func modeLegendGroup(mode input.Mode, name string) string { | |
| 622 | 635 | switch { | |
| 623 | 636 | case name == "confirm" || name == "cancel": | |
| @@ -632,8 +645,8 @@ func modeLegendGroup(mode input.Mode, name string) string { | |||
| 632 | 645 | return "RESIZE" | |
| 633 | 646 | case strings.HasPrefix(name, "vim-scroll-") || strings.HasPrefix(name, "half-page-"): | |
| 634 | 647 | return "SCROLL" | |
| 635 | - | case mode == input.ModeVim && (strings.Contains(name, "next") || strings.Contains(name, "prev") || name == "outline"): | |
| 636 | - | return "JUMP" | |
| 648 | + | case mode == input.ModeVim && vimLegendGroup(name) != "": | |
| 649 | + | return vimLegendGroup(name) | |
| 637 | 650 | case (mode == input.ModeTab || mode == input.ModePrefix) && (name == "next-tab" || name == "previous-tab"): | |
| 638 | 651 | return "SWITCH" | |
| 639 | 652 | default: | |
| @@ -618,6 +618,19 @@ func (f *modeFrame) sizeLegendColumns(columns uint, width int) { | |||||
| 618 | } | 618 | } | ||
| 619 | } | 619 | } | ||
| 620 | 620 | ||||
| 621 | + | // vimLegendGroup returns the legend group of a Vim Mode action, or "" when the | |||
| 622 | + | // action belongs to a generic group. | |||
| 623 | + | func vimLegendGroup(name string) string { | |||
| 624 | + | switch { | |||
| 625 | + | case strings.HasPrefix(name, "hint-") || strings.HasPrefix(name, "yank-") || name == "visual": | |||
| 626 | + | return "SELECT" | |||
| 627 | + | case strings.Contains(name, "next") || strings.Contains(name, "prev") || name == "outline": | |||
| 628 | + | return "JUMP" | |||
| 629 | + | default: | |||
| 630 | + | return "" | |||
| 631 | + | } | |||
| 632 | + | } | |||
| 633 | + | ||||
| 621 | func modeLegendGroup(mode input.Mode, name string) string { | 634 | func modeLegendGroup(mode input.Mode, name string) string { | ||
| 622 | switch { | 635 | switch { | ||
| 623 | case name == "confirm" || name == "cancel": | 636 | case name == "confirm" || name == "cancel": | ||
| @@ -632,8 +645,8 @@ func modeLegendGroup(mode input.Mode, name string) string { | |||||
| 632 | return "RESIZE" | 645 | return "RESIZE" | ||
| 633 | case strings.HasPrefix(name, "vim-scroll-") || strings.HasPrefix(name, "half-page-"): | 646 | case strings.HasPrefix(name, "vim-scroll-") || strings.HasPrefix(name, "half-page-"): | ||
| 634 | return "SCROLL" | 647 | return "SCROLL" | ||
| 635 | - | case mode == input.ModeVim && (strings.Contains(name, "next") || strings.Contains(name, "prev") || name == "outline"): | 648 | + | case mode == input.ModeVim && vimLegendGroup(name) != "": |
| 636 | - | return "JUMP" | 649 | + | return vimLegendGroup(name) |
| 637 | case (mode == input.ModeTab || mode == input.ModePrefix) && (name == "next-tab" || name == "previous-tab"): | 650 | case (mode == input.ModeTab || mode == input.ModePrefix) && (name == "next-tab" || name == "previous-tab"): | ||
| 638 | return "SWITCH" | 651 | return "SWITCH" | ||
| 639 | default: | 652 | default: | ||
M internal/ui/mode_frame_legend.go +1 -1
| @@ -49,7 +49,7 @@ func (f *modeFrame) build(actions map[string]entity.ActionBinding) { | |||
| 49 | 49 | group := modeLegendGroup(f.mode, name) | |
| 50 | 50 | groups[group] = append(groups[group], name) | |
| 51 | 51 | } | |
| 52 | - | order := []string{"SPLIT", "FOCUS", "SCROLL", "RESIZE", "SWITCH", "JUMP", "MANAGE", "MODES", "EXIT"} | |
| 52 | + | order := []string{"SPLIT", "FOCUS", "SCROLL", "RESIZE", "SWITCH", "JUMP", "SELECT", "MANAGE", "MODES", "EXIT"} | |
| 53 | 53 | index := 0 | |
| 54 | 54 | for _, groupName := range order { | |
| 55 | 55 | names := groups[groupName] | |
| @@ -49,7 +49,7 @@ func (f *modeFrame) build(actions map[string]entity.ActionBinding) { | |||||
| 49 | group := modeLegendGroup(f.mode, name) | 49 | group := modeLegendGroup(f.mode, name) | ||
| 50 | groups[group] = append(groups[group], name) | 50 | groups[group] = append(groups[group], name) | ||
| 51 | } | 51 | } | ||
| 52 | - | order := []string{"SPLIT", "FOCUS", "SCROLL", "RESIZE", "SWITCH", "JUMP", "MANAGE", "MODES", "EXIT"} | 52 | + | order := []string{"SPLIT", "FOCUS", "SCROLL", "RESIZE", "SWITCH", "JUMP", "SELECT", "MANAGE", "MODES", "EXIT"} |
| 53 | index := 0 | 53 | index := 0 | ||
| 54 | for _, groupName := range order { | 54 | for _, groupName := range order { | ||
| 55 | names := groups[groupName] | 55 | names := groups[groupName] | ||
M internal/ui/mode_frame_test.go +3 -0
| @@ -109,6 +109,9 @@ func TestModeLegendActionsUseActiveConfiguration(t *testing.T) { | |||
| 109 | 109 | require.Equal(t, "→", displayLegendKey("arrowright")) | |
| 110 | 110 | require.Equal(t, "SPLIT", modeLegendGroup(input.ModePane, "split-right")) | |
| 111 | 111 | require.Equal(t, "JUMP", modeLegendGroup(input.ModeVim, "heading-next")) | |
| 112 | + | require.Equal(t, "SELECT", modeLegendGroup(input.ModeVim, "hint-follow-new")) | |
| 113 | + | require.Equal(t, "SELECT", modeLegendGroup(input.ModeVim, "yank-code")) | |
| 114 | + | require.Equal(t, "SELECT", modeLegendGroup(input.ModeVim, "visual")) | |
| 112 | 115 | } | |
| 113 | 116 | ||
| 114 | 117 | func TestModeFramePendingSequenceFiltersBindings(t *testing.T) { | |
| @@ -109,6 +109,9 @@ func TestModeLegendActionsUseActiveConfiguration(t *testing.T) { | |||||
| 109 | require.Equal(t, "→", displayLegendKey("arrowright")) | 109 | require.Equal(t, "→", displayLegendKey("arrowright")) | ||
| 110 | require.Equal(t, "SPLIT", modeLegendGroup(input.ModePane, "split-right")) | 110 | require.Equal(t, "SPLIT", modeLegendGroup(input.ModePane, "split-right")) | ||
| 111 | require.Equal(t, "JUMP", modeLegendGroup(input.ModeVim, "heading-next")) | 111 | require.Equal(t, "JUMP", modeLegendGroup(input.ModeVim, "heading-next")) | ||
| 112 | + | require.Equal(t, "SELECT", modeLegendGroup(input.ModeVim, "hint-follow-new")) | |||
| 113 | + | require.Equal(t, "SELECT", modeLegendGroup(input.ModeVim, "yank-code")) | |||
| 114 | + | require.Equal(t, "SELECT", modeLegendGroup(input.ModeVim, "visual")) | |||
| 112 | } | 115 | } | ||
| 113 | 116 | ||||
| 114 | func TestModeFramePendingSequenceFiltersBindings(t *testing.T) { | 117 | func TestModeFramePendingSequenceFiltersBindings(t *testing.T) { | ||
M internal/ui/vim_mode.go +3 -0
| @@ -117,6 +117,7 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to | |||
| 117 | 117 | a.enableAccessibilityForVimMode(ctx, bw) | |
| 118 | 118 | } | |
| 119 | 119 | if from == input.ModeVim && to != input.ModeVim { | |
| 120 | + | a.endVimPageInteraction(ctx, bw) | |
| 120 | 121 | a.clearVimNavigationHighlight(ctx, bw) | |
| 121 | 122 | } | |
| 122 | 123 | ||
| @@ -142,6 +143,8 @@ func (a *App) transferVimModeOwnershipToPane(ctx context.Context, bw *browserWin | |||
| 142 | 143 | if bw.vimModePaneID == "" || bw.vimModePaneID == newPaneID { | |
| 143 | 144 | return | |
| 144 | 145 | } | |
| 146 | + | // A page interaction belongs to the previous owner's WebView. | |
| 147 | + | a.endVimPageInteraction(ctx, bw) | |
| 145 | 148 | ||
| 146 | 149 | // Check if this window is actually in vim mode. | |
| 147 | 150 | if bw.keyboardHandler == nil || bw.keyboardHandler.Mode() != input.ModeVim { | |
| @@ -117,6 +117,7 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to | |||||
| 117 | a.enableAccessibilityForVimMode(ctx, bw) | 117 | a.enableAccessibilityForVimMode(ctx, bw) | ||
| 118 | } | 118 | } | ||
| 119 | if from == input.ModeVim && to != input.ModeVim { | 119 | if from == input.ModeVim && to != input.ModeVim { | ||
| 120 | + | a.endVimPageInteraction(ctx, bw) | |||
| 120 | a.clearVimNavigationHighlight(ctx, bw) | 121 | a.clearVimNavigationHighlight(ctx, bw) | ||
| 121 | } | 122 | } | ||
| 122 | 123 | ||||
| @@ -142,6 +143,8 @@ func (a *App) transferVimModeOwnershipToPane(ctx context.Context, bw *browserWin | |||||
| 142 | if bw.vimModePaneID == "" || bw.vimModePaneID == newPaneID { | 143 | if bw.vimModePaneID == "" || bw.vimModePaneID == newPaneID { | ||
| 143 | return | 144 | return | ||
| 144 | } | 145 | } | ||
| 146 | + | // A page interaction belongs to the previous owner's WebView. | |||
| 147 | + | a.endVimPageInteraction(ctx, bw) | |||
| 145 | 148 | ||||
| 146 | // Check if this window is actually in vim mode. | 149 | // Check if this window is actually in vim mode. | ||
| 147 | if bw.keyboardHandler == nil || bw.keyboardHandler.Mode() != input.ModeVim { | 150 | if bw.keyboardHandler == nil || bw.keyboardHandler.Mode() != input.ModeVim { | ||