detached 3a00c38

fix(vim): clear heading highlight on mode exit

commit
3a00c387324a08655382373f02ea5200753038ba
author
brice · · 2026-08-17 06:52 UTC
parent
4733629
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +221 -18
M internal/application/port/webview.go +6 -0
@@ -465,6 +465,12 @@ type SemanticNavigable interface {
465465 NavigateSemantic(ctx context.Context, request dto.SemanticNavigationRequest) error
466466 }
467467
468+// SemanticNavigationHighlightClearer is an optional WebView capability for
469+// removing the visual target left by semantic navigation.
470+type SemanticNavigationHighlightClearer interface {
471+ ClearSemanticNavigationHighlight(ctx context.Context) error
472+}
473+
468474 // PageInputFocuser is an optional WebView capability for moving Vim Mode
469475 // input focus to the next eligible page input. With no active eligible input,
470476 // implementations focus the first one. Unsupported engines safely leave it
@@ -465,6 +465,12 @@ type SemanticNavigable interface {
465 NavigateSemantic(ctx context.Context, request dto.SemanticNavigationRequest) error465 NavigateSemantic(ctx context.Context, request dto.SemanticNavigationRequest) error
466 }466 }
467 467
468+// SemanticNavigationHighlightClearer is an optional WebView capability for
469+// removing the visual target left by semantic navigation.
470+type SemanticNavigationHighlightClearer interface {
471+ ClearSemanticNavigationHighlight(ctx context.Context) error
472+}
473+
468 // PageInputFocuser is an optional WebView capability for moving Vim Mode474 // PageInputFocuser is an optional WebView capability for moving Vim Mode
469 // input focus to the next eligible page input. With no active eligible input,475 // input focus to the next eligible page input. With no active eligible input,
470 // implementations focus the first one. Unsupported engines safely leave it476 // implementations focus the first one. Unsupported engines safely leave it
M internal/application/usecase/vim_navigation.go +13 -0
@@ -65,6 +65,19 @@ func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, actio
6565 return navigator.NavigateSemantic(ctx, request)
6666 }
6767
68+// ClearSemanticNavigationHighlight removes the visual target left by semantic
69+// navigation when the active Vim Mode session ends.
70+func (*VimNavigationUseCase) ClearSemanticNavigationHighlight(ctx context.Context, wv port.WebView) error {
71+ if wv == nil {
72+ return nil
73+ }
74+ clearer, ok := wv.(port.SemanticNavigationHighlightClearer)
75+ if !ok {
76+ return nil
77+ }
78+ return clearer.ClearSemanticNavigationHighlight(ctx)
79+}
80+
6881 // NavigatePageFocus keeps focus traversal inside a page when the active page
6982 // control would otherwise hand the host window a Tab event.
7083 func (*VimNavigationUseCase) NavigatePageFocus(wv port.WebView, backward bool) bool {
@@ -65,6 +65,19 @@ func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, actio
65 return navigator.NavigateSemantic(ctx, request)65 return navigator.NavigateSemantic(ctx, request)
66 }66 }
67 67
68+// ClearSemanticNavigationHighlight removes the visual target left by semantic
69+// navigation when the active Vim Mode session ends.
70+func (*VimNavigationUseCase) ClearSemanticNavigationHighlight(ctx context.Context, wv port.WebView) error {
71+ if wv == nil {
72+ return nil
73+ }
74+ clearer, ok := wv.(port.SemanticNavigationHighlightClearer)
75+ if !ok {
76+ return nil
77+ }
78+ return clearer.ClearSemanticNavigationHighlight(ctx)
79+}
80+
68 // NavigatePageFocus keeps focus traversal inside a page when the active page81 // NavigatePageFocus keeps focus traversal inside a page when the active page
69 // control would otherwise hand the host window a Tab event.82 // control would otherwise hand the host window a Tab event.
70 func (*VimNavigationUseCase) NavigatePageFocus(wv port.WebView, backward bool) bool {83 func (*VimNavigationUseCase) NavigatePageFocus(wv port.WebView, backward bool) bool {
M internal/application/usecase/vim_navigation_test.go +16 -3
@@ -12,9 +12,10 @@ import (
1212
1313 type vimNavigationWebView struct {
1414 *portmocks.MockWebView
15- focusNextInputCalls int
16- pageFocusBackward []bool
17- semanticRequests []dto.SemanticNavigationRequest
15+ focusNextInputCalls int
16+ pageFocusBackward []bool
17+ semanticRequests []dto.SemanticNavigationRequest
18+ clearNavigationHighlightCalls int
1819 }
1920
2021 func (wv *vimNavigationWebView) FocusNextInput() {
@@ -30,6 +31,11 @@ func (wv *vimNavigationWebView) NavigateSemantic(_ context.Context, request dto.
3031 return nil
3132 }
3233
34+func (wv *vimNavigationWebView) ClearSemanticNavigationHighlight(context.Context) error {
35+ wv.clearNavigationHighlightCalls++
36+ return nil
37+}
38+
3339 func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) {
3440 wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)}
3541 uc := NewVimNavigationUseCase()
@@ -47,6 +53,13 @@ func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) {
4753 }, wv.semanticRequests[0])
4854 }
4955
56+func TestVimNavigationUseCaseClearSemanticNavigationHighlight(t *testing.T) {
57+ wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)}
58+
59+ require.NoError(t, NewVimNavigationUseCase().ClearSemanticNavigationHighlight(context.Background(), wv))
60+ assert.Equal(t, 1, wv.clearNavigationHighlightCalls)
61+}
62+
5063 func TestVimNavigationUseCaseNavigatePageFocus(t *testing.T) {
5164 wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)}
5265
@@ -12,9 +12,10 @@ import (
12 12
13 type vimNavigationWebView struct {13 type vimNavigationWebView struct {
14 *portmocks.MockWebView14 *portmocks.MockWebView
15- focusNextInputCalls int15+ focusNextInputCalls int
16- pageFocusBackward []bool16+ pageFocusBackward []bool
17- semanticRequests []dto.SemanticNavigationRequest17+ semanticRequests []dto.SemanticNavigationRequest
18+ clearNavigationHighlightCalls int
18 }19 }
19 20
20 func (wv *vimNavigationWebView) FocusNextInput() {21 func (wv *vimNavigationWebView) FocusNextInput() {
@@ -30,6 +31,11 @@ func (wv *vimNavigationWebView) NavigateSemantic(_ context.Context, request dto.
30 return nil31 return nil
31 }32 }
32 33
34+func (wv *vimNavigationWebView) ClearSemanticNavigationHighlight(context.Context) error {
35+ wv.clearNavigationHighlightCalls++
36+ return nil
37+}
38+
33 func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) {39 func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) {
34 wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)}40 wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)}
35 uc := NewVimNavigationUseCase()41 uc := NewVimNavigationUseCase()
@@ -47,6 +53,13 @@ func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) {
47 }, wv.semanticRequests[0])53 }, wv.semanticRequests[0])
48 }54 }
49 55
56+func TestVimNavigationUseCaseClearSemanticNavigationHighlight(t *testing.T) {
57+ wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)}
58+
59+ require.NoError(t, NewVimNavigationUseCase().ClearSemanticNavigationHighlight(context.Background(), wv))
60+ assert.Equal(t, 1, wv.clearNavigationHighlightCalls)
61+}
62+
50 func TestVimNavigationUseCaseNavigatePageFocus(t *testing.T) {63 func TestVimNavigationUseCaseNavigatePageFocus(t *testing.T) {
51 wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)}64 wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)}
52 65
M internal/infrastructure/cef/semantic_navigation.go +74 -10
@@ -5,6 +5,8 @@ import (
55 "fmt"
66 "strconv"
77
8+ purecef "github.com/bnema/purego-cef/cef"
9+
810 "github.com/bnema/dumber/internal/application/dto"
911 )
1012
@@ -26,17 +28,64 @@ func (wv *WebView) NavigateSemantic(ctx context.Context, request dto.SemanticNav
2628 if count < 1 {
2729 count = 1
2830 }
29- wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count, request.HighlightColor))
31+ wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count, request.HighlightColor, wv.vimHeadingHighlightNamespace()))
3032 return nil
3133 }
3234
33-func headingNavigationScript(direction, count int, highlightColor string) string {
35+// ClearSemanticNavigationHighlight removes the visual target left by the most
36+// recent Vim semantic navigation in this WebView.
37+func (wv *WebView) ClearSemanticNavigationHighlight(_ context.Context) error {
38+ if wv == nil || wv.destroyed.Load() {
39+ return errDestroyed
40+ }
41+ return wv.scheduleJavaScript(clearHeadingNavigationHighlightScript(wv.vimHeadingHighlightNamespace()))
42+}
43+
44+func (wv *WebView) vimHeadingHighlightNamespace() string {
45+ return fmt.Sprintf("dumber-vim-heading-%p", wv)
46+}
47+
48+// scheduleJavaScript makes CEF UI-thread scheduling failures observable to
49+// callers that need to guarantee a transient UI state is cleared.
50+func (wv *WebView) scheduleJavaScript(script string) error {
51+ if wv == nil || wv.destroyed.Load() {
52+ return errDestroyed
53+ }
54+ wv.mu.RLock()
55+ browser := wv.browser
56+ wv.mu.RUnlock()
57+ if browser == nil {
58+ return fmt.Errorf("schedule JavaScript: browser unavailable")
59+ }
60+ if wv.engine == nil {
61+ frame := browser.GetMainFrame()
62+ if frame == nil {
63+ return fmt.Errorf("schedule JavaScript: main frame unavailable")
64+ }
65+ frame.ExecuteJavaScript(script, "", 0)
66+ return nil
67+ }
68+
69+ task := cefNewTask(cefTaskFunc(func() {
70+ wv.executeJavaScriptNow(script)
71+ }))
72+ if task == nil {
73+ return fmt.Errorf("schedule JavaScript: create CEF task")
74+ }
75+ if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 {
76+ return fmt.Errorf("schedule JavaScript: post CEF task: result %d", result)
77+ }
78+ return nil
79+}
80+
81+func headingNavigationScript(direction, count int, highlightColor, namespace string) string {
3482 if highlightColor == "" {
3583 highlightColor = "#fbbf24"
3684 }
3785 return `(() => {
38- const stateKey = "__dumberVimHeadingTarget";
39- const className = "dumber-vim-heading-target";
86+ const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;
87+ const targetAttribute = ` + strconv.Quote("data-"+namespace+"-target") + `;
88+ const styleAttribute = ` + strconv.Quote("data-"+namespace+"-style") + `;
4089 const highlightColor = ` + strconv.Quote(highlightColor) + `;
4190 const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6"))
4291 .filter((heading) => {
@@ -65,23 +114,38 @@ func headingNavigationScript(direction, count int, highlightColor string) string
65114 if (next < 0 || next >= headings.length) return;
66115 const target = headings[next];
67116
68- const previous = document.querySelector("." + className);
69- if (previous && previous !== target) previous.classList.remove(className);
70- let style = document.getElementById("dumber-vim-heading-target-style");
117+ const previous = document.querySelector("[" + targetAttribute + "]");
118+ if (previous && previous !== target) previous.removeAttribute(targetAttribute);
119+ let style = document.querySelector("style[" + styleAttribute + "]");
71120 if (!style) {
72121 // This visual cue is best-effort: strict page CSP can reject a DOM style
73122 // element, but target selection and scrolling must stay independent of it.
74123 style = document.createElement("style");
75- style.id = "dumber-vim-heading-target-style";
124+ style.setAttribute(styleAttribute, "");
76125 document.documentElement.appendChild(style);
77126 }
78127 // Refresh the outline on every navigation so an in-session accent change is
79128 // reflected even when the target style element already exists.
80- style.textContent = "." + className +
129+ style.textContent = "[" + targetAttribute + "]" +
81130 " { outline: 3px solid " + highlightColor +
82131 " !important; outline-offset: 5px !important; border-radius: 3px !important; }";
83132 window[stateKey] = target;
84- target.classList.add(className);
133+ target.setAttribute(targetAttribute, "");
85134 target.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" });
86135 })();`
87136 }
137+
138+func clearHeadingNavigationHighlightScript(namespace string) string {
139+ return `(() => {
140+ const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;
141+ const targetAttribute = ` + strconv.Quote("data-"+namespace+"-target") + `;
142+ const styleAttribute = ` + strconv.Quote("data-"+namespace+"-style") + `;
143+ document.querySelectorAll("[" + targetAttribute + "]").forEach((target) => {
144+ target.removeAttribute(targetAttribute);
145+ });
146+ document.querySelectorAll("style[" + styleAttribute + "]").forEach((style) => {
147+ style.remove();
148+ });
149+ delete window[stateKey];
150+})();`
151+}
@@ -5,6 +5,8 @@ import (
5 "fmt"5 "fmt"
6 "strconv"6 "strconv"
7 7
8+ purecef "github.com/bnema/purego-cef/cef"
9+
8 "github.com/bnema/dumber/internal/application/dto"10 "github.com/bnema/dumber/internal/application/dto"
9 )11 )
10 12
@@ -26,17 +28,64 @@ func (wv *WebView) NavigateSemantic(ctx context.Context, request dto.SemanticNav
26 if count < 1 {28 if count < 1 {
27 count = 129 count = 1
28 }30 }
29- wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count, request.HighlightColor))31+ wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count, request.HighlightColor, wv.vimHeadingHighlightNamespace()))
30 return nil32 return nil
31 }33 }
32 34
33-func headingNavigationScript(direction, count int, highlightColor string) string {35+// ClearSemanticNavigationHighlight removes the visual target left by the most
36+// recent Vim semantic navigation in this WebView.
37+func (wv *WebView) ClearSemanticNavigationHighlight(_ context.Context) error {
38+ if wv == nil || wv.destroyed.Load() {
39+ return errDestroyed
40+ }
41+ return wv.scheduleJavaScript(clearHeadingNavigationHighlightScript(wv.vimHeadingHighlightNamespace()))
42+}
43+
44+func (wv *WebView) vimHeadingHighlightNamespace() string {
45+ return fmt.Sprintf("dumber-vim-heading-%p", wv)
46+}
47+
48+// scheduleJavaScript makes CEF UI-thread scheduling failures observable to
49+// callers that need to guarantee a transient UI state is cleared.
50+func (wv *WebView) scheduleJavaScript(script string) error {
51+ if wv == nil || wv.destroyed.Load() {
52+ return errDestroyed
53+ }
54+ wv.mu.RLock()
55+ browser := wv.browser
56+ wv.mu.RUnlock()
57+ if browser == nil {
58+ return fmt.Errorf("schedule JavaScript: browser unavailable")
59+ }
60+ if wv.engine == nil {
61+ frame := browser.GetMainFrame()
62+ if frame == nil {
63+ return fmt.Errorf("schedule JavaScript: main frame unavailable")
64+ }
65+ frame.ExecuteJavaScript(script, "", 0)
66+ return nil
67+ }
68+
69+ task := cefNewTask(cefTaskFunc(func() {
70+ wv.executeJavaScriptNow(script)
71+ }))
72+ if task == nil {
73+ return fmt.Errorf("schedule JavaScript: create CEF task")
74+ }
75+ if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 {
76+ return fmt.Errorf("schedule JavaScript: post CEF task: result %d", result)
77+ }
78+ return nil
79+}
80+
81+func headingNavigationScript(direction, count int, highlightColor, namespace string) string {
34 if highlightColor == "" {82 if highlightColor == "" {
35 highlightColor = "#fbbf24"83 highlightColor = "#fbbf24"
36 }84 }
37 return `(() => {85 return `(() => {
38- const stateKey = "__dumberVimHeadingTarget";86+ const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;
39- const className = "dumber-vim-heading-target";87+ const targetAttribute = ` + strconv.Quote("data-"+namespace+"-target") + `;
88+ const styleAttribute = ` + strconv.Quote("data-"+namespace+"-style") + `;
40 const highlightColor = ` + strconv.Quote(highlightColor) + `;89 const highlightColor = ` + strconv.Quote(highlightColor) + `;
41 const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6"))90 const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6"))
42 .filter((heading) => {91 .filter((heading) => {
@@ -65,23 +114,38 @@ func headingNavigationScript(direction, count int, highlightColor string) string
65 if (next < 0 || next >= headings.length) return;114 if (next < 0 || next >= headings.length) return;
66 const target = headings[next];115 const target = headings[next];
67 116
68- const previous = document.querySelector("." + className);117+ const previous = document.querySelector("[" + targetAttribute + "]");
69- if (previous && previous !== target) previous.classList.remove(className);118+ if (previous && previous !== target) previous.removeAttribute(targetAttribute);
70- let style = document.getElementById("dumber-vim-heading-target-style");119+ let style = document.querySelector("style[" + styleAttribute + "]");
71 if (!style) {120 if (!style) {
72 // This visual cue is best-effort: strict page CSP can reject a DOM style121 // This visual cue is best-effort: strict page CSP can reject a DOM style
73 // element, but target selection and scrolling must stay independent of it.122 // element, but target selection and scrolling must stay independent of it.
74 style = document.createElement("style");123 style = document.createElement("style");
75- style.id = "dumber-vim-heading-target-style";124+ style.setAttribute(styleAttribute, "");
76 document.documentElement.appendChild(style);125 document.documentElement.appendChild(style);
77 }126 }
78 // Refresh the outline on every navigation so an in-session accent change is127 // Refresh the outline on every navigation so an in-session accent change is
79 // reflected even when the target style element already exists.128 // reflected even when the target style element already exists.
80- style.textContent = "." + className +129+ style.textContent = "[" + targetAttribute + "]" +
81 " { outline: 3px solid " + highlightColor +130 " { outline: 3px solid " + highlightColor +
82 " !important; outline-offset: 5px !important; border-radius: 3px !important; }";131 " !important; outline-offset: 5px !important; border-radius: 3px !important; }";
83 window[stateKey] = target;132 window[stateKey] = target;
84- target.classList.add(className);133+ target.setAttribute(targetAttribute, "");
85 target.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" });134 target.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" });
86 })();`135 })();`
87 }136 }
137+
138+func clearHeadingNavigationHighlightScript(namespace string) string {
139+ return `(() => {
140+ const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;
141+ const targetAttribute = ` + strconv.Quote("data-"+namespace+"-target") + `;
142+ const styleAttribute = ` + strconv.Quote("data-"+namespace+"-style") + `;
143+ document.querySelectorAll("[" + targetAttribute + "]").forEach((target) => {
144+ target.removeAttribute(targetAttribute);
145+ });
146+ document.querySelectorAll("style[" + styleAttribute + "]").forEach((style) => {
147+ style.remove();
148+ });
149+ delete window[stateKey];
150+})();`
151+}
M internal/infrastructure/cef/semantic_navigation_test.go +32 -4
@@ -4,6 +4,7 @@ import (
44 "context"
55 "testing"
66
7+ purecef "github.com/bnema/purego-cef/cef"
78 cefmocks "github.com/bnema/purego-cef/cef/mocks"
89 "github.com/stretchr/testify/assert"
910 "github.com/stretchr/testify/mock"
@@ -26,8 +27,9 @@ func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testin
2627 "scrollIntoView",
2728 "window[stateKey] = target",
2829 "const highlightColor = \"#22c55e\"",
29- "let style = document.getElementById(\"dumber-vim-heading-target-style\")",
30- "style.textContent = \".\" + className",
30+ "const targetAttribute = \"data-dumber-vim-heading-",
31+ "let style = document.querySelector(\"style[\" + styleAttribute + \"]\")",
32+ "style.textContent = \"[\" + targetAttribute + \"]\"",
3133 "1 * 3",
3234 )), "", int32(0)).Once()
3335
@@ -41,9 +43,35 @@ func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testin
4143 require.NoError(t, err)
4244 }
4345
46+func TestWebViewClearSemanticNavigationHighlightRemovesTargetArtifacts(t *testing.T) {
47+ browser := cefmocks.NewMockBrowser(t)
48+ frame := cefmocks.NewMockFrame(t)
49+ browser.EXPECT().GetMainFrame().Return(frame).Once()
50+ frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t,
51+ "const targetAttribute = \"data-dumber-vim-heading-",
52+ "document.querySelectorAll(\"[\" + targetAttribute + \"]\")",
53+ "target.removeAttribute(targetAttribute)",
54+ "style[\" + styleAttribute + \"]",
55+ "delete window[stateKey]",
56+ )), "", int32(0)).Once()
57+
58+ wv := &WebView{browser: browser}
59+ require.NoError(t, wv.ClearSemanticNavigationHighlight(context.Background()))
60+}
61+
62+func TestWebViewClearSemanticNavigationHighlightReportsSchedulingFailure(t *testing.T) {
63+ oldNewTask, oldPostTask := cefNewTask, cefPostTask
64+ defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }()
65+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
66+ cefPostTask = func(purecef.ThreadID, purecef.Task) int32 { return 0 }
67+
68+ wv := &WebView{engine: &Engine{}, browser: cefmocks.NewMockBrowser(t)}
69+ require.Error(t, wv.ClearSemanticNavigationHighlight(context.Background()))
70+}
71+
4472 func TestWebViewNavigateSemantic_NormalizesCountAndRejectsUnsupportedRequests(t *testing.T) {
45- forward := headingNavigationScript(1, 1, "")
46- backward := headingNavigationScript(-1, 1, "#4ade80")
73+ forward := headingNavigationScript(1, 1, "", "test")
74+ backward := headingNavigationScript(-1, 1, "#4ade80", "test")
4775 assert.Contains(t, forward, "index = firstAtOrAfterViewportTop - 1")
4876 assert.Contains(t, forward, "if (index < -1) index = -1;")
4977 assert.Contains(t, backward, "index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length")
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 6
7+ purecef "github.com/bnema/purego-cef/cef"
7 cefmocks "github.com/bnema/purego-cef/cef/mocks"8 cefmocks "github.com/bnema/purego-cef/cef/mocks"
8 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/mock"10 "github.com/stretchr/testify/mock"
@@ -26,8 +27,9 @@ func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testin
26 "scrollIntoView",27 "scrollIntoView",
27 "window[stateKey] = target",28 "window[stateKey] = target",
28 "const highlightColor = \"#22c55e\"",29 "const highlightColor = \"#22c55e\"",
29- "let style = document.getElementById(\"dumber-vim-heading-target-style\")",30+ "const targetAttribute = \"data-dumber-vim-heading-",
30- "style.textContent = \".\" + className",31+ "let style = document.querySelector(\"style[\" + styleAttribute + \"]\")",
32+ "style.textContent = \"[\" + targetAttribute + \"]\"",
31 "1 * 3",33 "1 * 3",
32 )), "", int32(0)).Once()34 )), "", int32(0)).Once()
33 35
@@ -41,9 +43,35 @@ func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testin
41 require.NoError(t, err)43 require.NoError(t, err)
42 }44 }
43 45
46+func TestWebViewClearSemanticNavigationHighlightRemovesTargetArtifacts(t *testing.T) {
47+ browser := cefmocks.NewMockBrowser(t)
48+ frame := cefmocks.NewMockFrame(t)
49+ browser.EXPECT().GetMainFrame().Return(frame).Once()
50+ frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t,
51+ "const targetAttribute = \"data-dumber-vim-heading-",
52+ "document.querySelectorAll(\"[\" + targetAttribute + \"]\")",
53+ "target.removeAttribute(targetAttribute)",
54+ "style[\" + styleAttribute + \"]",
55+ "delete window[stateKey]",
56+ )), "", int32(0)).Once()
57+
58+ wv := &WebView{browser: browser}
59+ require.NoError(t, wv.ClearSemanticNavigationHighlight(context.Background()))
60+}
61+
62+func TestWebViewClearSemanticNavigationHighlightReportsSchedulingFailure(t *testing.T) {
63+ oldNewTask, oldPostTask := cefNewTask, cefPostTask
64+ defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }()
65+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
66+ cefPostTask = func(purecef.ThreadID, purecef.Task) int32 { return 0 }
67+
68+ wv := &WebView{engine: &Engine{}, browser: cefmocks.NewMockBrowser(t)}
69+ require.Error(t, wv.ClearSemanticNavigationHighlight(context.Background()))
70+}
71+
44 func TestWebViewNavigateSemantic_NormalizesCountAndRejectsUnsupportedRequests(t *testing.T) {72 func TestWebViewNavigateSemantic_NormalizesCountAndRejectsUnsupportedRequests(t *testing.T) {
45- forward := headingNavigationScript(1, 1, "")73+ forward := headingNavigationScript(1, 1, "", "test")
46- backward := headingNavigationScript(-1, 1, "#4ade80")74+ backward := headingNavigationScript(-1, 1, "#4ade80", "test")
47 assert.Contains(t, forward, "index = firstAtOrAfterViewportTop - 1")75 assert.Contains(t, forward, "index = firstAtOrAfterViewportTop - 1")
48 assert.Contains(t, forward, "if (index < -1) index = -1;")76 assert.Contains(t, forward, "if (index < -1) index = -1;")
49 assert.Contains(t, backward, "index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length")77 assert.Contains(t, backward, "index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length")
M internal/ui/app.go +25 -0
@@ -2201,9 +2201,31 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow,
22012201 Err(err).
22022202 Str("action", action).
22032203 Msg("vim navigation unavailable")
2204+ return
2205+ }
2206+ if bw != nil && (action == "heading-next" || action == "heading-prev") {
2207+ if _, ok := wv.(port.SemanticNavigable); ok {
2208+ bw.vimNavigationHighlightedWebViews = append(bw.vimNavigationHighlightedWebViews, wv)
2209+ }
22042210 }
22052211 }
22062212
2213+func (a *App) clearVimNavigationHighlight(ctx context.Context, bw *browserWindow) {
2214+ if bw == nil {
2215+ return
2216+ }
2217+ navigationUC := a.vimNavigationUseCase()
2218+ if navigationUC == nil {
2219+ return
2220+ }
2221+ for _, wv := range bw.vimNavigationHighlightedWebViews {
2222+ if err := navigationUC.ClearSemanticNavigationHighlight(ctx, wv); err != nil {
2223+ logging.FromContext(ctx).Debug().Err(err).Msg("failed to clear vim navigation highlight")
2224+ }
2225+ }
2226+ bw.vimNavigationHighlightedWebViews = nil
2227+}
2228+
22072229 func (a *App) vimNavigationHighlightColor() string {
22082230 if a == nil || a.deps == nil || a.deps.Theme == nil {
22092231 return ""
@@ -3940,6 +3962,9 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to
39403962 if to == input.ModeVim && from != input.ModeVim {
39413963 a.enableAccessibilityForVimMode(ctx, bw)
39423964 }
3965+ if from == input.ModeVim && to != input.ModeVim {
3966+ a.clearVimNavigationHighlight(ctx, bw)
3967+ }
39433968
39443969 // Handle pane-local Vim Mode visual ownership.
39453970 // Entering Vim Mode accents the active pane; leaving removes the accent.
@@ -2201,9 +2201,31 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow,
2201 Err(err).2201 Err(err).
2202 Str("action", action).2202 Str("action", action).
2203 Msg("vim navigation unavailable")2203 Msg("vim navigation unavailable")
2204+ return
2205+ }
2206+ if bw != nil && (action == "heading-next" || action == "heading-prev") {
2207+ if _, ok := wv.(port.SemanticNavigable); ok {
2208+ bw.vimNavigationHighlightedWebViews = append(bw.vimNavigationHighlightedWebViews, wv)
2209+ }
2204 }2210 }
2205 }2211 }
2206 2212
2213+func (a *App) clearVimNavigationHighlight(ctx context.Context, bw *browserWindow) {
2214+ if bw == nil {
2215+ return
2216+ }
2217+ navigationUC := a.vimNavigationUseCase()
2218+ if navigationUC == nil {
2219+ return
2220+ }
2221+ for _, wv := range bw.vimNavigationHighlightedWebViews {
2222+ if err := navigationUC.ClearSemanticNavigationHighlight(ctx, wv); err != nil {
2223+ logging.FromContext(ctx).Debug().Err(err).Msg("failed to clear vim navigation highlight")
2224+ }
2225+ }
2226+ bw.vimNavigationHighlightedWebViews = nil
2227+}
2228+
2207 func (a *App) vimNavigationHighlightColor() string {2229 func (a *App) vimNavigationHighlightColor() string {
2208 if a == nil || a.deps == nil || a.deps.Theme == nil {2230 if a == nil || a.deps == nil || a.deps.Theme == nil {
2209 return ""2231 return ""
@@ -3940,6 +3962,9 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to
3940 if to == input.ModeVim && from != input.ModeVim {3962 if to == input.ModeVim && from != input.ModeVim {
3941 a.enableAccessibilityForVimMode(ctx, bw)3963 a.enableAccessibilityForVimMode(ctx, bw)
3942 }3964 }
3965+ if from == input.ModeVim && to != input.ModeVim {
3966+ a.clearVimNavigationHighlight(ctx, bw)
3967+ }
3943 3968
3944 // Handle pane-local Vim Mode visual ownership.3969 // Handle pane-local Vim Mode visual ownership.
3945 // Entering Vim Mode accents the active pane; leaving removes the accent.3970 // Entering Vim Mode accents the active pane; leaving removes the accent.
M internal/ui/app_vim_mode_test.go +51 -1
@@ -176,7 +176,8 @@ func (wv *accessibilityEnablingWebView) EnableAccessibility() {
176176
177177 type semanticNavigatingWebView struct {
178178 *portmocks.MockWebView
179- requests []dto.SemanticNavigationRequest
179+ requests []dto.SemanticNavigationRequest
180+ clearNavigationHighlightCalls int
180181 }
181182
182183 func newSemanticNavigatingWebView(t *testing.T, id port.WebViewID) *semanticNavigatingWebView {
@@ -191,6 +192,11 @@ func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request
191192 return nil
192193 }
193194
195+func (wv *semanticNavigatingWebView) ClearSemanticNavigationHighlight(context.Context) error {
196+ wv.clearNavigationHighlightCalls++
197+ return nil
198+}
199+
194200 type inputFocusingWebView struct {
195201 *portmocks.MockWebView
196202 focusNextInputCalls int
@@ -517,6 +523,50 @@ func TestVimMode_SequenceActionFocusesNextInput(t *testing.T) {
517523 assert.Equal(t, 1, wv.focusNextInputCalls)
518524 }
519525
526+func TestVimMode_Leave_ClearsNavigationHighlight(t *testing.T) {
527+ app, bw, paneID := newVimModeAccessibilityFixture(t)
528+ wv := newSemanticNavigatingWebView(t, 8)
529+ app.contentCoord.RegisterPopupWebView(paneID, wv)
530+ app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
531+
532+ app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal)
533+
534+ assert.Equal(t, 1, wv.clearNavigationHighlightCalls)
535+ assert.Empty(t, bw.vimNavigationHighlightedWebViews)
536+}
537+
538+func TestVimMode_Leave_ClearsNavigationHighlightAfterPaneSwitch(t *testing.T) {
539+ f := newSingleWindowVimModeFixture(t)
540+ f.app.contentCoord = contentcoord.NewCoordinator(context.Background(), nil, nil, nil, nil, nil, nil, nil)
541+ oldWebView := newSemanticNavigatingWebView(t, 9)
542+ newWebView := newSemanticNavigatingWebView(t, 10)
543+ f.app.contentCoord.RegisterPopupWebView("pane-a", oldWebView)
544+ f.app.contentCoord.RegisterPopupWebView("pane-b", newWebView)
545+
546+ f.app.navigateVimSequenceAction(context.Background(), f.bw1, "heading-next", 1)
547+ f.bw1.tabs.ActiveTab().Workspace.ActivePaneID = "pane-b"
548+ f.app.handleModeChange(context.Background(), f.bw1, input.ModeVim, input.ModeNormal)
549+
550+ assert.Equal(t, 1, oldWebView.clearNavigationHighlightCalls)
551+ assert.Equal(t, 0, newWebView.clearNavigationHighlightCalls)
552+}
553+
554+func TestVimMode_Leave_ClearsNavigationHighlightAfterTabSwitch(t *testing.T) {
555+ app, bw, paneID := newVimModeAccessibilityFixture(t)
556+ oldWebView := newSemanticNavigatingWebView(t, 11)
557+ app.contentCoord.RegisterPopupWebView(paneID, oldWebView)
558+ otherPaneID := entity.PaneID("pane-b")
559+ otherTab := entity.NewTab("tab-2", "ws-2", entity.NewPane(otherPaneID))
560+ bw.tabs.Add(otherTab)
561+ bw.tabs.SetActive("tab-1")
562+
563+ app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
564+ bw.tabs.SetActive(otherTab.ID)
565+ app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal)
566+
567+ assert.Equal(t, 1, oldWebView.clearNavigationHighlightCalls)
568+}
569+
520570 func TestVimMode_NativePageFocusNavigationUsesActiveEditableWebView(t *testing.T) {
521571 app, bw, paneID := newVimModeAccessibilityFixture(t)
522572 wv := newInputFocusingWebView(t, 7)
@@ -176,7 +176,8 @@ func (wv *accessibilityEnablingWebView) EnableAccessibility() {
176 176
177 type semanticNavigatingWebView struct {177 type semanticNavigatingWebView struct {
178 *portmocks.MockWebView178 *portmocks.MockWebView
179- requests []dto.SemanticNavigationRequest179+ requests []dto.SemanticNavigationRequest
180+ clearNavigationHighlightCalls int
180 }181 }
181 182
182 func newSemanticNavigatingWebView(t *testing.T, id port.WebViewID) *semanticNavigatingWebView {183 func newSemanticNavigatingWebView(t *testing.T, id port.WebViewID) *semanticNavigatingWebView {
@@ -191,6 +192,11 @@ func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request
191 return nil192 return nil
192 }193 }
193 194
195+func (wv *semanticNavigatingWebView) ClearSemanticNavigationHighlight(context.Context) error {
196+ wv.clearNavigationHighlightCalls++
197+ return nil
198+}
199+
194 type inputFocusingWebView struct {200 type inputFocusingWebView struct {
195 *portmocks.MockWebView201 *portmocks.MockWebView
196 focusNextInputCalls int202 focusNextInputCalls int
@@ -517,6 +523,50 @@ func TestVimMode_SequenceActionFocusesNextInput(t *testing.T) {
517 assert.Equal(t, 1, wv.focusNextInputCalls)523 assert.Equal(t, 1, wv.focusNextInputCalls)
518 }524 }
519 525
526+func TestVimMode_Leave_ClearsNavigationHighlight(t *testing.T) {
527+ app, bw, paneID := newVimModeAccessibilityFixture(t)
528+ wv := newSemanticNavigatingWebView(t, 8)
529+ app.contentCoord.RegisterPopupWebView(paneID, wv)
530+ app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
531+
532+ app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal)
533+
534+ assert.Equal(t, 1, wv.clearNavigationHighlightCalls)
535+ assert.Empty(t, bw.vimNavigationHighlightedWebViews)
536+}
537+
538+func TestVimMode_Leave_ClearsNavigationHighlightAfterPaneSwitch(t *testing.T) {
539+ f := newSingleWindowVimModeFixture(t)
540+ f.app.contentCoord = contentcoord.NewCoordinator(context.Background(), nil, nil, nil, nil, nil, nil, nil)
541+ oldWebView := newSemanticNavigatingWebView(t, 9)
542+ newWebView := newSemanticNavigatingWebView(t, 10)
543+ f.app.contentCoord.RegisterPopupWebView("pane-a", oldWebView)
544+ f.app.contentCoord.RegisterPopupWebView("pane-b", newWebView)
545+
546+ f.app.navigateVimSequenceAction(context.Background(), f.bw1, "heading-next", 1)
547+ f.bw1.tabs.ActiveTab().Workspace.ActivePaneID = "pane-b"
548+ f.app.handleModeChange(context.Background(), f.bw1, input.ModeVim, input.ModeNormal)
549+
550+ assert.Equal(t, 1, oldWebView.clearNavigationHighlightCalls)
551+ assert.Equal(t, 0, newWebView.clearNavigationHighlightCalls)
552+}
553+
554+func TestVimMode_Leave_ClearsNavigationHighlightAfterTabSwitch(t *testing.T) {
555+ app, bw, paneID := newVimModeAccessibilityFixture(t)
556+ oldWebView := newSemanticNavigatingWebView(t, 11)
557+ app.contentCoord.RegisterPopupWebView(paneID, oldWebView)
558+ otherPaneID := entity.PaneID("pane-b")
559+ otherTab := entity.NewTab("tab-2", "ws-2", entity.NewPane(otherPaneID))
560+ bw.tabs.Add(otherTab)
561+ bw.tabs.SetActive("tab-1")
562+
563+ app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
564+ bw.tabs.SetActive(otherTab.ID)
565+ app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal)
566+
567+ assert.Equal(t, 1, oldWebView.clearNavigationHighlightCalls)
568+}
569+
520 func TestVimMode_NativePageFocusNavigationUsesActiveEditableWebView(t *testing.T) {570 func TestVimMode_NativePageFocusNavigationUsesActiveEditableWebView(t *testing.T) {
521 app, bw, paneID := newVimModeAccessibilityFixture(t)571 app, bw, paneID := newVimModeAccessibilityFixture(t)
522 wv := newInputFocusingWebView(t, 7)572 wv := newInputFocusingWebView(t, 7)
M internal/ui/browser_window.go +4 -0
@@ -56,6 +56,9 @@ type browserWindow struct {
5656
5757 // vimModePaneID tracks this window's pane-local Vim Mode accent/pulse owner.
5858 vimModePaneID entity.PaneID
59+ // vimNavigationHighlightedWebViews holds every WebView with a semantic Vim
60+ // navigation target that must be cleared when this window leaves Vim Mode.
61+ vimNavigationHighlightedWebViews []port.WebView
5962 }
6063
6164 func (bw *browserWindow) detachInputForDestroy() {
@@ -113,6 +116,7 @@ func (bw *browserWindow) clearShellState() {
113116 bw.historySidebarReloader = nil
114117 bw.activeSidebarKind = nativeSidebarNone
115118 bw.vimModePaneID = ""
119+ bw.vimNavigationHighlightedWebViews = nil
116120 }
117121
118122 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
@@ -56,6 +56,9 @@ type browserWindow struct {
56 56
57 // vimModePaneID tracks this window's pane-local Vim Mode accent/pulse owner.57 // vimModePaneID tracks this window's pane-local Vim Mode accent/pulse owner.
58 vimModePaneID entity.PaneID58 vimModePaneID entity.PaneID
59+ // vimNavigationHighlightedWebViews holds every WebView with a semantic Vim
60+ // navigation target that must be cleared when this window leaves Vim Mode.
61+ vimNavigationHighlightedWebViews []port.WebView
59 }62 }
60 63
61 func (bw *browserWindow) detachInputForDestroy() {64 func (bw *browserWindow) detachInputForDestroy() {
@@ -113,6 +116,7 @@ func (bw *browserWindow) clearShellState() {
113 bw.historySidebarReloader = nil116 bw.historySidebarReloader = nil
114 bw.activeSidebarKind = nativeSidebarNone117 bw.activeSidebarKind = nativeSidebarNone
115 bw.vimModePaneID = ""118 bw.vimModePaneID = ""
119+ bw.vimNavigationHighlightedWebViews = nil
116 }120 }
117 121
118 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {122 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {

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