detached a94e48a

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
31 files · +1798 -36
M .mockery.yaml +1 -0
@@ -61,6 +61,7 @@ packages:
6161 PageNavigator: {}
6262 WebView: {}
6363 PageScrollable: {}
64+ VimPageInteractor: {}
6465 WebViewFactory: {}
6566 WebViewPool: {}
6667 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"
463463 keys = ["[["]
464464 desc = "Jump to previous heading"
465465
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+
466474 [workspace.vim_mode.actions.confirm]
467475 keys = ["enter"]
468476 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
6464
6565 `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.
6666
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+
6773 | Action | Keys |
6874 |--------|------|
6975 | Scroll left | `H` |
@@ -76,6 +82,11 @@ CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with th
7682 | Next heading | `]]` |
7783 | Previous heading | `[[` |
7884 | 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` |
7990 | Activate selected heading link and exit | `Enter` |
8091 | Exit without activation | `Escape` |
8192
@@ -225,6 +236,12 @@ keys = ["]]"]
225236 [workspace.vim_mode.actions.heading-prev]
226237 keys = ["[["]
227238
239+[workspace.vim_mode.actions.hint-follow]
240+keys = ["f"]
241+
242+[workspace.vim_mode.actions.visual]
243+keys = ["v"]
244+
228245 [workspace.shortcuts.actions.close-pane]
229246 keys = ["ctrl+w"]
230247
@@ -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.
37243724 _c.Call.Return(run)
37253725 return _c
37263726 }
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 _c3725 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 {
160160 // navigation, WebView destruction). The UI must hide any dialog shown for
161161 // this WebView without invoking respond.
162162 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()
163167 }
164168
165169 // FindOptions configures search behavior.
@@ -502,6 +506,17 @@ type PageInputFocuser interface {
502506 FocusNextInput()
503507 }
504508
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+
505520 // PageFocusNavigator is an optional WebView capability for keeping native-like
506521 // focus traversal inside the active page when the host window would otherwise
507522 // move focus outside the WebView.
@@ -160,6 +160,10 @@ type WebViewCallbacks struct {
160 // navigation, WebView destruction). The UI must hide any dialog shown for160 // 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-like520 // PageFocusNavigator is an optional WebView capability for keeping native-like
506 // focus traversal inside the active page when the host window would otherwise521 // 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 {
2323 return &VimNavigationUseCase{}
2424 }
2525
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) {
2831 if wv == nil {
29- return errors.New("vim navigation: nil webview")
32+ return dto.VimNavigationOutcome{}, errors.New("vim navigation: nil webview")
3033 }
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+}
3139
40+func executeVimNavigation(ctx context.Context, wv port.WebView, action string, count int, highlightColor string) error {
3241 switch action {
3342 case "confirm":
3443 activator, ok := wv.(port.SemanticNavigationActivator)
@@ -72,6 +81,68 @@ func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, actio
7281 return navigator.NavigateSemantic(ctx, request)
7382 }
7483
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+
75146 // ClearSemanticNavigationHighlight removes the visual target left by semantic
76147 // navigation when the active Vim Mode session ends.
77148 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 semantic146 // 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) {
4646 wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)}
4747 uc := NewVimNavigationUseCase()
4848
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)
5255
5356 assert.Equal(t, 1, wv.focusNextInputCalls)
5457 assert.Equal(t, 1, wv.activateNavigationTargetCalls)
@@ -80,6 +83,7 @@ func TestVimNavigationUseCaseNavigatePageFocus(t *testing.T) {
8083 func TestVimNavigationUseCaseIgnoresUnsupportedCapability(t *testing.T) {
8184 wv := portmocks.NewMockWebView(t)
8285
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)
8488 assert.False(t, NewVimNavigationUseCase().NavigatePageFocus(wv, true))
8589 }
@@ -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(
323323 schemeHandler.onPopupClose = eng.handlePopupBridgeClose
324324 schemeHandler.onPopupOpenerNavigate = eng.handlePopupOpenerNavigate
325325 schemeHandler.onPopupOpenerPostMessage = eng.handlePopupOpenerPostMessage
326+ schemeHandler.onVimPage = eng.handleVimPageBridge
326327 schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest
327328 return messageRouter, schemeHandler, nil
328329 }
@@ -323,6 +323,7 @@ func newEngineSchemeHandler(
323 schemeHandler.onPopupClose = eng.handlePopupBridgeClose323 schemeHandler.onPopupClose = eng.handlePopupBridgeClose
324 schemeHandler.onPopupOpenerNavigate = eng.handlePopupOpenerNavigate324 schemeHandler.onPopupOpenerNavigate = eng.handlePopupOpenerNavigate
325 schemeHandler.onPopupOpenerPostMessage = eng.handlePopupOpenerPostMessage325 schemeHandler.onPopupOpenerPostMessage = eng.handlePopupOpenerPostMessage
326+ schemeHandler.onVimPage = eng.handleVimPageBridge
326 schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest327 schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest
327 return messageRouter, schemeHandler, nil328 return messageRouter, schemeHandler, nil
328 }329 }
M internal/infrastructure/cef/scheme_handler.go +28 -0
@@ -96,6 +96,8 @@ type dumbSchemeHandler struct {
9696 onPopupClose func(browser purecef.Browser, payload rendererBridgePopupClosePayload)
9797 onPopupOpenerNavigate func(browser purecef.Browser, payload popupOpenerNavigatePayload)
9898 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)
99101
100102 // bridgeNonceValidator checks whether a bridge nonce belongs to the active
101103 // browser/navigation context that issued the request.
@@ -267,6 +269,8 @@ func (h *dumbSchemeHandler) handleAPIPost(
267269 return h.handlePopupOpenerNavigate(request, browser), true
268270 case "/api/popup-opener-post-message":
269271 return h.handlePopupOpenerPostMessage(request, browser), true
272+ case "/api/vim-page":
273+ return h.handleVimPage(request, browser), true
270274 default:
271275 return nil, false
272276 }
@@ -475,6 +479,30 @@ func handlePopupBridgeRequest[T any](
475479 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
476480 }
477481
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+
478506 func (h *dumbSchemeHandler) handlePopupOpen(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
479507 return handlePopupBridgeRequest(h, request, browser, "popup-open", decodeRendererBridgePopupOpenPayload, h.onPopupOpen)
480508 }
@@ -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 active102 // 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), true269 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), true271 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, false275 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 {
262262 // (for example https://dumber.invalid/history). The two forms are updated
263263 // together through setCommittedURLsLocked; never compare one form against
264264 // 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
276283 selectionDebounceTimer stoppableTimer
277284 selectionDebounceSeq uint64
278285 selectionDebounceDelay *time.Duration
@@ -262,17 +262,24 @@ type WebView struct {
262 // (for example https://dumber.invalid/history). The two forms are updated262 // (for example https://dumber.invalid/history). The two forms are updated
263 // together through setCommittedURLsLocked; never compare one form against263 // together through setCommittedURLsLocked; never compare one form against
264 // the other.264 // the other.
265- uri string265+ uri string
266- committedURL string266+ committedURL string
267- title string267+ title string
268- progress float64268+ progress float64
269- canGoBack bool269+ canGoBack bool
270- canGoFwd bool270+ canGoFwd bool
271- isLoading bool271+ isLoading bool
272- selectedText string272+ selectedText string
273- focusedEditable bool273+ focusedEditable bool
274- inputAttached bool274+ inputAttached bool
275- bridgeNonce string275+ 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 stoppableTimer283 selectionDebounceTimer stoppableTimer
277 selectionDebounceSeq uint64284 selectionDebounceSeq uint64
278 selectionDebounceDelay *time.Duration285 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
536536 // A new document replaces the page that asked for any open JS dialog.
537537 h.wv.cancelJSDialogs()
538538 h.wv.invalidateScrollMotion()
539+ h.wv.endVimPageInteractionOnNavigation()
539540 h.wv.mu.Lock()
540541 h.wv.documentSeq++
541542 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 = 0542 h.wv.faviconSourcePendingToken = 0
M internal/infrastructure/config/defaults.go +8 -0
@@ -370,6 +370,14 @@ func DefaultConfig() *Config {
370370 "list-next": {Keys: []string{"]l"}, Desc: "Jump to next list"},
371371 "outline": {Keys: []string{"gO"}, Desc: "Show page outline"},
372372 "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)"},
373381 "half-page-down": {Keys: []string{"<C-d>"}, Desc: "Scroll half page down"},
374382 "half-page-up": {Keys: []string{"<C-u>"}, Desc: "Scroll half page up"},
375383 },
@@ -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{
2020 }
2121
2222 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",
3543 }
3644
3745 func TestDefaultConfig_VimModeDefaults(t *testing.T) {
@@ -66,7 +74,7 @@ func TestVimModeSequenceDefaults(t *testing.T) {
6674 vimMode := cfg.Workspace.VimMode
6775
6876 assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
69- require.Len(t, sequenceVimModeActions, 12)
77+ require.Len(t, sequenceVimModeActions, 20)
7078
7179 for action, key := range sequenceVimModeActions {
7280 requireActionBinding(t, vimMode.Actions, action, []string{key})
@@ -129,7 +137,12 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) {
129137 assert.Equal(t, "yank-section", bindings["yah"])
130138 assert.Equal(t, "half-page-down", bindings["<C-d>"])
131139 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)
133146 }
134147
135148 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.VimMode74 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,
22212221 if navigationUC == nil {
22222222 return
22232223 }
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 {
22252226 logging.FromContext(ctx).Debug().
22262227 Err(err).
22272228 Str("action", action).
22282229 Msg("vim navigation unavailable")
22292230 return
22302231 }
2232+ if outcome.CapturePageKeys {
2233+ a.captureVimPageKeys(ctx, bw, wv)
2234+ }
22312235 if bw != nil && (action == "heading-next" || action == "heading-prev") {
22322236 if _, ok := wv.(port.SemanticNavigable); ok {
22332237 if bw.vimNavigationHighlightedWebViewIDs == nil {
@@ -2242,6 +2246,55 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow,
22422246 }
22432247 }
22442248
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+
22452298 func (a *App) clearVimNavigationHighlight(ctx context.Context, bw *browserWindow) {
22462299 if bw == nil {
22472300 return
@@ -3423,6 +3476,9 @@ func (a *App) initCoordinators(ctx context.Context) {
34233476 a.contentCoord.SetOnEditableFocusChanged(func(paneID entity.PaneID, editable bool) {
34243477 a.handlePageEditableFocusChanged(ctx, paneID, editable)
34253478 })
3479+ a.contentCoord.SetOnVimPageInteractionEnded(func(paneID entity.PaneID) {
3480+ a.handleVimPageInteractionEnded(paneID)
3481+ })
34263482
34273483 // JS dialogs are shown in the owning pane's overlay, wherever that pane lives.
34283484 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 return2222 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 return2230 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 return2300 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 {
5959 // navigation target that must be cleared when this window leaves Vim Mode.
6060 vimNavigationHighlightedWebViews []port.WebView
6161 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
6265 }
6366
6467 func (bw *browserWindow) detachInputForDestroy() {
@@ -121,6 +124,7 @@ func (bw *browserWindow) clearShellState() {
121124 bw.vimModePaneID = ""
122125 bw.vimNavigationHighlightedWebViews = nil
123126 bw.vimNavigationHighlightedWebViewIDs = nil
127+ bw.vimPageInteractionWebView = nil
124128 }
125129
126130 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.WebView60 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 = nil125 bw.vimNavigationHighlightedWebViews = nil
123 bw.vimNavigationHighlightedWebViewIDs = nil126 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
187187 }
188188 }
189189
190+ callbacks.OnVimPageInteractionEnded = func() {
191+ if c.onVimPageInteractionEnded != nil {
192+ c.onVimPageInteractionEnded(paneID)
193+ }
194+ }
195+
190196 // Add popup create handler if popup handling is configured
191197 callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv)
192198
@@ -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 configured196 // 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 {
121121
122122 // Callback when a page editable target gains or loses focus.
123123 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)
124126
125127 // Callback for first load_started event (triggers deferred initialization)
126128 onFirstLoadStarted func()
@@ -277,6 +279,12 @@ func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, ed
277279 c.onEditableFocusChanged = fn
278280 }
279281
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+
280288 // SetOnFirstLoadStarted sets the callback for when the first navigation starts.
281289 // This is used to trigger deferred initialization after the initial load_uri()
282290 // 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 = fn279 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 {
121121 vimScrollNow func() time.Time
122122
123123 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
124128 }
125129
126130 // NewKeyboardHandler creates a new keyboard handler.
@@ -218,6 +222,7 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
218222 h.setControllerPhase(gtk.PhaseCaptureValue)
219223 }
220224 if to != ModeVim {
225+ h.ClearPageKeyCapture()
221226 h.stopVimScrollRepeat()
222227 // Silent reset under ModalState lock: no pending callback (avoids Mode() re-entry).
223228 h.resetPendingSequence(false)
@@ -407,6 +412,11 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
407412 }
408413 }
409414
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+
410420 if h.handleGuaranteedModalExit(mode, keyval, modifiers) {
411421 return true
412422 }
@@ -121,6 +121,10 @@ type KeyboardHandler struct {
121 vimScrollNow func() time.Time121 vimScrollNow func() time.Time
122 122
123 seq vimModeSequenceState123 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 true421 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) {
3535 return nil, false
3636 }
3737
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+
3851 // buildVimModeTrie inserts trie-owned Vim mode bindings. Action names are
3952 // sorted so the first sorted name wins on Insert conflicts (validation already
4053 // rejects duplicates). Ownership is marked only after a successful Insert.
@@ -57,6 +70,11 @@ func buildVimModeTrie(cfg *entity.VimModeConfig) (*vimkeys.Trie, map[string]bool
5770 sort.Strings(keys)
5871 for _, key := range keys {
5972 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+ }
6078 if !ok {
6179 continue
6280 }
@@ -35,6 +35,19 @@ func trieOwnsBinding(raw string) (vimkeys.Sequence, bool) {
35 return nil, false35 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 are51 // buildVimModeTrie inserts trie-owned Vim mode bindings. Action names are
39 // sorted so the first sorted name wins on Insert conflicts (validation already52 // 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 continue79 continue
62 }80 }
M internal/ui/input/vimkeys_dispatch_test.go +6 -0
@@ -161,6 +161,9 @@ func TestBuildVimModeTrie(t *testing.T) {
161161 "yank-section": {Keys: []string{"yah"}},
162162 "outline": {Keys: []string{"gO"}},
163163 "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"}},
164167 "confirm": {Keys: []string{"enter"}},
165168 "cancel": {Keys: []string{"escape"}},
166169 },
@@ -177,6 +180,9 @@ func TestBuildVimModeTrie(t *testing.T) {
177180 "yah": true,
178181 "gO": true,
179182 "<C-d>": true,
183+ "f": true,
184+ "F": true,
185+ "v": true,
180186 }
181187 for key, want := range wantOwned {
182188 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) {
618618 }
619619 }
620620
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+
621634 func modeLegendGroup(mode input.Mode, name string) string {
622635 switch {
623636 case name == "confirm" || name == "cancel":
@@ -632,8 +645,8 @@ func modeLegendGroup(mode input.Mode, name string) string {
632645 return "RESIZE"
633646 case strings.HasPrefix(name, "vim-scroll-") || strings.HasPrefix(name, "half-page-"):
634647 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)
637650 case (mode == input.ModeTab || mode == input.ModePrefix) && (name == "next-tab" || name == "previous-tab"):
638651 return "SWITCH"
639652 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) {
4949 group := modeLegendGroup(f.mode, name)
5050 groups[group] = append(groups[group], name)
5151 }
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"}
5353 index := 0
5454 for _, groupName := range order {
5555 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 := 053 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) {
109109 require.Equal(t, "→", displayLegendKey("arrowright"))
110110 require.Equal(t, "SPLIT", modeLegendGroup(input.ModePane, "split-right"))
111111 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"))
112115 }
113116
114117 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
117117 a.enableAccessibilityForVimMode(ctx, bw)
118118 }
119119 if from == input.ModeVim && to != input.ModeVim {
120+ a.endVimPageInteraction(ctx, bw)
120121 a.clearVimNavigationHighlight(ctx, bw)
121122 }
122123
@@ -142,6 +143,8 @@ func (a *App) transferVimModeOwnershipToPane(ctx context.Context, bw *browserWin
142143 if bw.vimModePaneID == "" || bw.vimModePaneID == newPaneID {
143144 return
144145 }
146+ // A page interaction belongs to the previous owner's WebView.
147+ a.endVimPageInteraction(ctx, bw)
145148
146149 // Check if this window is actually in vim mode.
147150 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 return144 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 {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close