detached a11aeca

feat(vim): navigate live headings

commit
a11aecaec91d849fdcfda79517038cc7d7b1a236
author
brice · · 2026-08-14 10:37 UTC
parent
0a69a74
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +257 -0
M internal/application/port/webview.go +31 -0
@@ -444,3 +444,34 @@ type PageScrollCanceler interface {
444444 type AccessibilityEnabler interface {
445445 EnableAccessibility()
446446 }
447+
448+// SemanticNavigationTarget identifies a visible document target that Vim Mode
449+// can navigate without coupling the application layer to a browser engine.
450+type SemanticNavigationTarget int
451+
452+const (
453+ SemanticNavigationTargetHeading SemanticNavigationTarget = iota
454+)
455+
456+// SemanticNavigationDirection identifies the strict document-order direction
457+// for an optional semantic navigation request.
458+type SemanticNavigationDirection int
459+
460+const (
461+ SemanticNavigationBackward SemanticNavigationDirection = -1
462+ SemanticNavigationForward SemanticNavigationDirection = 1
463+)
464+
465+// SemanticNavigationRequest describes one Vim structural movement. Count uses
466+// Vim semantics: adapters treat values less than one as one.
467+type SemanticNavigationRequest struct {
468+ Target SemanticNavigationTarget
469+ Direction SemanticNavigationDirection
470+ Count int
471+}
472+
473+// SemanticNavigable is an optional WebView capability for visible structural
474+// navigation. Unsupported engines safely leave it unimplemented.
475+type SemanticNavigable interface {
476+ NavigateSemantic(ctx context.Context, request SemanticNavigationRequest) error
477+}
@@ -444,3 +444,34 @@ type PageScrollCanceler interface {
444 type AccessibilityEnabler interface {444 type AccessibilityEnabler interface {
445 EnableAccessibility()445 EnableAccessibility()
446 }446 }
447+
448+// SemanticNavigationTarget identifies a visible document target that Vim Mode
449+// can navigate without coupling the application layer to a browser engine.
450+type SemanticNavigationTarget int
451+
452+const (
453+ SemanticNavigationTargetHeading SemanticNavigationTarget = iota
454+)
455+
456+// SemanticNavigationDirection identifies the strict document-order direction
457+// for an optional semantic navigation request.
458+type SemanticNavigationDirection int
459+
460+const (
461+ SemanticNavigationBackward SemanticNavigationDirection = -1
462+ SemanticNavigationForward SemanticNavigationDirection = 1
463+)
464+
465+// SemanticNavigationRequest describes one Vim structural movement. Count uses
466+// Vim semantics: adapters treat values less than one as one.
467+type SemanticNavigationRequest struct {
468+ Target SemanticNavigationTarget
469+ Direction SemanticNavigationDirection
470+ Count int
471+}
472+
473+// SemanticNavigable is an optional WebView capability for visible structural
474+// navigation. Unsupported engines safely leave it unimplemented.
475+type SemanticNavigable interface {
476+ NavigateSemantic(ctx context.Context, request SemanticNavigationRequest) error
477+}
A internal/infrastructure/cef/semantic_navigation.go +72 -0
@@ -0,0 +1,72 @@
1+package cef
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strconv"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+)
10+
11+// NavigateSemantic performs the initial live Vim structural navigation path.
12+// It deliberately uses only stable HTML semantics and a static script: CEF's
13+// raw accessibility payload is not yet a documented application contract.
14+func (wv *WebView) NavigateSemantic(ctx context.Context, request port.SemanticNavigationRequest) error {
15+ if wv == nil || wv.destroyed.Load() {
16+ return errDestroyed
17+ }
18+ if request.Target != port.SemanticNavigationTargetHeading {
19+ return fmt.Errorf("unsupported semantic navigation target: %d", request.Target)
20+ }
21+ if request.Direction != port.SemanticNavigationForward && request.Direction != port.SemanticNavigationBackward {
22+ return fmt.Errorf("unsupported semantic navigation direction: %d", request.Direction)
23+ }
24+
25+ count := request.Count
26+ if count < 1 {
27+ count = 1
28+ }
29+ wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count))
30+ return nil
31+}
32+
33+func headingNavigationScript(direction, count int) string {
34+ return `(() => {
35+ const stateKey = "__dumberVimHeadingTarget";
36+ const className = "dumber-vim-heading-target";
37+ const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6"))
38+ .filter((heading) => heading.getClientRects().length > 0);
39+ if (headings.length === 0) return;
40+
41+ const prior = window[stateKey];
42+ let index = headings.indexOf(prior);
43+ if (index < 0) {
44+ const firstAtOrAfterViewportTop = headings.findIndex(
45+ (heading) => heading.getBoundingClientRect().top >= 0,
46+ );
47+ if (` + strconv.Itoa(direction) + ` > 0) {
48+ index = firstAtOrAfterViewportTop - 1;
49+ } else {
50+ index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length;
51+ }
52+ }
53+
54+ const next = index + (` + strconv.Itoa(direction) + ` * ` + strconv.Itoa(count) + `);
55+ if (next < 0 || next >= headings.length) return;
56+ const target = headings[next];
57+
58+ const previous = document.querySelector("." + className);
59+ if (previous && previous !== target) previous.classList.remove(className);
60+ if (!document.getElementById("dumber-vim-heading-target-style")) {
61+ // This visual cue is best-effort: strict page CSP can reject a DOM style
62+ // element, but target selection and scrolling must stay independent of it.
63+ const style = document.createElement("style");
64+ style.id = "dumber-vim-heading-target-style";
65+ style.textContent = "." + className + " { outline: 3px solid #fbbf24 !important; outline-offset: 5px !important; border-radius: 3px !important; }";
66+ document.documentElement.appendChild(style);
67+ }
68+ window[stateKey] = target;
69+ target.classList.add(className);
70+ target.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" });
71+})();`
72+}
@@ -0,0 +1,72 @@
1+package cef
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strconv"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+)
10+
11+// NavigateSemantic performs the initial live Vim structural navigation path.
12+// It deliberately uses only stable HTML semantics and a static script: CEF's
13+// raw accessibility payload is not yet a documented application contract.
14+func (wv *WebView) NavigateSemantic(ctx context.Context, request port.SemanticNavigationRequest) error {
15+ if wv == nil || wv.destroyed.Load() {
16+ return errDestroyed
17+ }
18+ if request.Target != port.SemanticNavigationTargetHeading {
19+ return fmt.Errorf("unsupported semantic navigation target: %d", request.Target)
20+ }
21+ if request.Direction != port.SemanticNavigationForward && request.Direction != port.SemanticNavigationBackward {
22+ return fmt.Errorf("unsupported semantic navigation direction: %d", request.Direction)
23+ }
24+
25+ count := request.Count
26+ if count < 1 {
27+ count = 1
28+ }
29+ wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count))
30+ return nil
31+}
32+
33+func headingNavigationScript(direction, count int) string {
34+ return `(() => {
35+ const stateKey = "__dumberVimHeadingTarget";
36+ const className = "dumber-vim-heading-target";
37+ const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6"))
38+ .filter((heading) => heading.getClientRects().length > 0);
39+ if (headings.length === 0) return;
40+
41+ const prior = window[stateKey];
42+ let index = headings.indexOf(prior);
43+ if (index < 0) {
44+ const firstAtOrAfterViewportTop = headings.findIndex(
45+ (heading) => heading.getBoundingClientRect().top >= 0,
46+ );
47+ if (` + strconv.Itoa(direction) + ` > 0) {
48+ index = firstAtOrAfterViewportTop - 1;
49+ } else {
50+ index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length;
51+ }
52+ }
53+
54+ const next = index + (` + strconv.Itoa(direction) + ` * ` + strconv.Itoa(count) + `);
55+ if (next < 0 || next >= headings.length) return;
56+ const target = headings[next];
57+
58+ const previous = document.querySelector("." + className);
59+ if (previous && previous !== target) previous.classList.remove(className);
60+ if (!document.getElementById("dumber-vim-heading-target-style")) {
61+ // This visual cue is best-effort: strict page CSP can reject a DOM style
62+ // element, but target selection and scrolling must stay independent of it.
63+ const style = document.createElement("style");
64+ style.id = "dumber-vim-heading-target-style";
65+ style.textContent = "." + className + " { outline: 3px solid #fbbf24 !important; outline-offset: 5px !important; border-radius: 3px !important; }";
66+ document.documentElement.appendChild(style);
67+ }
68+ window[stateKey] = target;
69+ target.classList.add(className);
70+ target.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" });
71+})();`
72+}
A internal/infrastructure/cef/semantic_navigation_test.go +77 -0
@@ -0,0 +1,77 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+)
14+
15+func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testing.T) {
16+ browser := cefmocks.NewMockBrowser(t)
17+ frame := cefmocks.NewMockFrame(t)
18+ browser.EXPECT().GetMainFrame().Return(frame).Once()
19+ frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t,
20+ "document.querySelectorAll(\"h1,h2,h3,h4,h5,h6\")",
21+ "firstAtOrAfterViewportTop",
22+ "if (next < 0 || next >= headings.length) return;",
23+ "previous && previous !== target",
24+ "scrollIntoView",
25+ "window[stateKey] = target",
26+ "1 * 3",
27+ )), "", int32(0)).Once()
28+
29+ wv := &WebView{browser: browser}
30+ err := wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{
31+ Target: port.SemanticNavigationTargetHeading,
32+ Direction: port.SemanticNavigationForward,
33+ Count: 3,
34+ })
35+ require.NoError(t, err)
36+}
37+
38+func TestWebViewNavigateSemantic_NormalizesCountAndRejectsUnsupportedRequests(t *testing.T) {
39+ forward := headingNavigationScript(1, 1)
40+ backward := headingNavigationScript(-1, 1)
41+ assert.Contains(t, forward, "index = firstAtOrAfterViewportTop - 1")
42+ assert.Contains(t, backward, "index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length")
43+ assert.Contains(t, backward, "-1 * 1")
44+ assert.NotContains(t, backward, "window.innerHeight")
45+
46+ wv := &WebView{}
47+ err := wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{
48+ Target: port.SemanticNavigationTargetHeading,
49+ Direction: port.SemanticNavigationForward,
50+ Count: 0,
51+ })
52+ require.NoError(t, err)
53+
54+ err = wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{
55+ Target: port.SemanticNavigationTargetHeading + 1,
56+ Direction: port.SemanticNavigationForward,
57+ })
58+ require.Error(t, err)
59+
60+ err = wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{
61+ Target: port.SemanticNavigationTargetHeading,
62+ Direction: 0,
63+ })
64+ require.Error(t, err)
65+}
66+
67+func mockStringContaining(t *testing.T, fragments ...string) func(string) bool {
68+ t.Helper()
69+ return func(script string) bool {
70+ for _, fragment := range fragments {
71+ if !assert.Contains(t, script, fragment) {
72+ return false
73+ }
74+ }
75+ return true
76+ }
77+}
@@ -0,0 +1,77 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+)
14+
15+func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testing.T) {
16+ browser := cefmocks.NewMockBrowser(t)
17+ frame := cefmocks.NewMockFrame(t)
18+ browser.EXPECT().GetMainFrame().Return(frame).Once()
19+ frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t,
20+ "document.querySelectorAll(\"h1,h2,h3,h4,h5,h6\")",
21+ "firstAtOrAfterViewportTop",
22+ "if (next < 0 || next >= headings.length) return;",
23+ "previous && previous !== target",
24+ "scrollIntoView",
25+ "window[stateKey] = target",
26+ "1 * 3",
27+ )), "", int32(0)).Once()
28+
29+ wv := &WebView{browser: browser}
30+ err := wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{
31+ Target: port.SemanticNavigationTargetHeading,
32+ Direction: port.SemanticNavigationForward,
33+ Count: 3,
34+ })
35+ require.NoError(t, err)
36+}
37+
38+func TestWebViewNavigateSemantic_NormalizesCountAndRejectsUnsupportedRequests(t *testing.T) {
39+ forward := headingNavigationScript(1, 1)
40+ backward := headingNavigationScript(-1, 1)
41+ assert.Contains(t, forward, "index = firstAtOrAfterViewportTop - 1")
42+ assert.Contains(t, backward, "index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length")
43+ assert.Contains(t, backward, "-1 * 1")
44+ assert.NotContains(t, backward, "window.innerHeight")
45+
46+ wv := &WebView{}
47+ err := wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{
48+ Target: port.SemanticNavigationTargetHeading,
49+ Direction: port.SemanticNavigationForward,
50+ Count: 0,
51+ })
52+ require.NoError(t, err)
53+
54+ err = wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{
55+ Target: port.SemanticNavigationTargetHeading + 1,
56+ Direction: port.SemanticNavigationForward,
57+ })
58+ require.Error(t, err)
59+
60+ err = wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{
61+ Target: port.SemanticNavigationTargetHeading,
62+ Direction: 0,
63+ })
64+ require.Error(t, err)
65+}
66+
67+func mockStringContaining(t *testing.T, fragments ...string) func(string) bool {
68+ t.Helper()
69+ return func(script string) bool {
70+ for _, fragment := range fragments {
71+ if !assert.Contains(t, script, fragment) {
72+ return false
73+ }
74+ }
75+ return true
76+ }
77+}
M internal/ui/app.go +36 -0
@@ -1101,6 +1101,9 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
11011101 a.handleModeChange(ctx, bw, from, to)
11021102 })
11031103 a.bindVimModeSequenceToaster(ctx, bw)
1104+ bw.keyboardHandler.SetOnSequenceAction(func(action string, count int) {
1105+ a.navigateVimSequenceAction(ctx, bw, action, count)
1106+ })
11041107 bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute {
11051108 if bw.sessionManager != nil && bw.sessionManager.IsVisible() {
11061109 return input.RoutePassToWidget
@@ -2160,6 +2163,39 @@ func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, p
21602163 return paneID, a.contentCoord.GetWebView(paneID)
21612164 }
21622165
2166+func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) {
2167+ var request port.SemanticNavigationRequest
2168+ switch action {
2169+ case "heading-next":
2170+ request = port.SemanticNavigationRequest{
2171+ Target: port.SemanticNavigationTargetHeading,
2172+ Direction: port.SemanticNavigationForward,
2173+ Count: count,
2174+ }
2175+ case "heading-prev":
2176+ request = port.SemanticNavigationRequest{
2177+ Target: port.SemanticNavigationTargetHeading,
2178+ Direction: port.SemanticNavigationBackward,
2179+ Count: count,
2180+ }
2181+ default:
2182+ return
2183+ }
2184+
2185+ paneID, wv := a.activeWebViewForBrowserWindow(bw)
2186+ navigator, ok := wv.(port.SemanticNavigable)
2187+ if !ok {
2188+ return
2189+ }
2190+ if err := navigator.NavigateSemantic(ctx, request); err != nil {
2191+ logging.FromContext(ctx).Debug().
2192+ Err(err).
2193+ Str("action", action).
2194+ Str("pane_id", string(paneID)).
2195+ Msg("vim semantic navigation unavailable")
2196+ }
2197+}
2198+
21632199 func (a *App) enableAccessibilityForVimMode(ctx context.Context, bw *browserWindow) {
21642200 paneID, wv := a.activeWebViewForBrowserWindow(bw)
21652201 if wv == nil {
@@ -1101,6 +1101,9 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
1101 a.handleModeChange(ctx, bw, from, to)1101 a.handleModeChange(ctx, bw, from, to)
1102 })1102 })
1103 a.bindVimModeSequenceToaster(ctx, bw)1103 a.bindVimModeSequenceToaster(ctx, bw)
1104+ bw.keyboardHandler.SetOnSequenceAction(func(action string, count int) {
1105+ a.navigateVimSequenceAction(ctx, bw, action, count)
1106+ })
1104 bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute {1107 bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute {
1105 if bw.sessionManager != nil && bw.sessionManager.IsVisible() {1108 if bw.sessionManager != nil && bw.sessionManager.IsVisible() {
1106 return input.RoutePassToWidget1109 return input.RoutePassToWidget
@@ -2160,6 +2163,39 @@ func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, p
2160 return paneID, a.contentCoord.GetWebView(paneID)2163 return paneID, a.contentCoord.GetWebView(paneID)
2161 }2164 }
2162 2165
2166+func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) {
2167+ var request port.SemanticNavigationRequest
2168+ switch action {
2169+ case "heading-next":
2170+ request = port.SemanticNavigationRequest{
2171+ Target: port.SemanticNavigationTargetHeading,
2172+ Direction: port.SemanticNavigationForward,
2173+ Count: count,
2174+ }
2175+ case "heading-prev":
2176+ request = port.SemanticNavigationRequest{
2177+ Target: port.SemanticNavigationTargetHeading,
2178+ Direction: port.SemanticNavigationBackward,
2179+ Count: count,
2180+ }
2181+ default:
2182+ return
2183+ }
2184+
2185+ paneID, wv := a.activeWebViewForBrowserWindow(bw)
2186+ navigator, ok := wv.(port.SemanticNavigable)
2187+ if !ok {
2188+ return
2189+ }
2190+ if err := navigator.NavigateSemantic(ctx, request); err != nil {
2191+ logging.FromContext(ctx).Debug().
2192+ Err(err).
2193+ Str("action", action).
2194+ Str("pane_id", string(paneID)).
2195+ Msg("vim semantic navigation unavailable")
2196+ }
2197+}
2198+
2163 func (a *App) enableAccessibilityForVimMode(ctx context.Context, bw *browserWindow) {2199 func (a *App) enableAccessibilityForVimMode(ctx context.Context, bw *browserWindow) {
2164 paneID, wv := a.activeWebViewForBrowserWindow(bw)2200 paneID, wv := a.activeWebViewForBrowserWindow(bw)
2165 if wv == nil {2201 if wv == nil {
M internal/ui/app_vim_mode_test.go +41 -0
@@ -173,6 +173,23 @@ func (wv *accessibilityEnablingWebView) EnableAccessibility() {
173173 wv.enableAccessibilityCalls++
174174 }
175175
176+type semanticNavigatingWebView struct {
177+ *portmocks.MockWebView
178+ requests []port.SemanticNavigationRequest
179+}
180+
181+func newSemanticNavigatingWebView(t *testing.T, id port.WebViewID) *semanticNavigatingWebView {
182+ t.Helper()
183+ wv := portmocks.NewMockWebView(t)
184+ wv.EXPECT().ID().Return(id).Once()
185+ return &semanticNavigatingWebView{MockWebView: wv}
186+}
187+
188+func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request port.SemanticNavigationRequest) error {
189+ wv.requests = append(wv.requests, request)
190+ return nil
191+}
192+
176193 func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) {
177194 t.Helper()
178195 paneID := entity.PaneID("pane-a")
@@ -416,6 +433,30 @@ func TestVimMode_Enter_AccessibilityNoopsWhenActiveWebViewMissing(t *testing.T)
416433 app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
417434 }
418435
436+func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) {
437+ app, bw, paneID := newVimModeAccessibilityFixture(t)
438+ wv := newSemanticNavigatingWebView(t, 4)
439+ app.contentCoord.RegisterPopupWebView(paneID, wv)
440+
441+ app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 3)
442+ app.navigateVimSequenceAction(context.Background(), bw, "heading-prev", 0)
443+ app.navigateVimSequenceAction(context.Background(), bw, "code-next", 1)
444+
445+ assert.Equal(t, []port.SemanticNavigationRequest{
446+ {Target: port.SemanticNavigationTargetHeading, Direction: port.SemanticNavigationForward, Count: 3},
447+ {Target: port.SemanticNavigationTargetHeading, Direction: port.SemanticNavigationBackward, Count: 0},
448+ }, wv.requests)
449+}
450+
451+func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) {
452+ app, bw, paneID := newVimModeAccessibilityFixture(t)
453+ wv := portmocks.NewMockWebView(t)
454+ wv.EXPECT().ID().Return(port.WebViewID(5)).Once()
455+ app.contentCoord.RegisterPopupWebView(paneID, wv)
456+
457+ app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
458+}
459+
419460 func TestVimMode_AccessibilityOnlyEnabledOnEnteringVimMode(t *testing.T) {
420461 app, bw, paneID := newVimModeAccessibilityFixture(t)
421462 wv := newAccessibilityEnablingWebView(t, 3)
@@ -173,6 +173,23 @@ func (wv *accessibilityEnablingWebView) EnableAccessibility() {
173 wv.enableAccessibilityCalls++173 wv.enableAccessibilityCalls++
174 }174 }
175 175
176+type semanticNavigatingWebView struct {
177+ *portmocks.MockWebView
178+ requests []port.SemanticNavigationRequest
179+}
180+
181+func newSemanticNavigatingWebView(t *testing.T, id port.WebViewID) *semanticNavigatingWebView {
182+ t.Helper()
183+ wv := portmocks.NewMockWebView(t)
184+ wv.EXPECT().ID().Return(id).Once()
185+ return &semanticNavigatingWebView{MockWebView: wv}
186+}
187+
188+func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request port.SemanticNavigationRequest) error {
189+ wv.requests = append(wv.requests, request)
190+ return nil
191+}
192+
176 func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) {193 func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) {
177 t.Helper()194 t.Helper()
178 paneID := entity.PaneID("pane-a")195 paneID := entity.PaneID("pane-a")
@@ -416,6 +433,30 @@ func TestVimMode_Enter_AccessibilityNoopsWhenActiveWebViewMissing(t *testing.T)
416 app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)433 app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
417 }434 }
418 435
436+func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) {
437+ app, bw, paneID := newVimModeAccessibilityFixture(t)
438+ wv := newSemanticNavigatingWebView(t, 4)
439+ app.contentCoord.RegisterPopupWebView(paneID, wv)
440+
441+ app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 3)
442+ app.navigateVimSequenceAction(context.Background(), bw, "heading-prev", 0)
443+ app.navigateVimSequenceAction(context.Background(), bw, "code-next", 1)
444+
445+ assert.Equal(t, []port.SemanticNavigationRequest{
446+ {Target: port.SemanticNavigationTargetHeading, Direction: port.SemanticNavigationForward, Count: 3},
447+ {Target: port.SemanticNavigationTargetHeading, Direction: port.SemanticNavigationBackward, Count: 0},
448+ }, wv.requests)
449+}
450+
451+func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) {
452+ app, bw, paneID := newVimModeAccessibilityFixture(t)
453+ wv := portmocks.NewMockWebView(t)
454+ wv.EXPECT().ID().Return(port.WebViewID(5)).Once()
455+ app.contentCoord.RegisterPopupWebView(paneID, wv)
456+
457+ app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
458+}
459+
419 func TestVimMode_AccessibilityOnlyEnabledOnEnteringVimMode(t *testing.T) {460 func TestVimMode_AccessibilityOnlyEnabledOnEnteringVimMode(t *testing.T) {
420 app, bw, paneID := newVimModeAccessibilityFixture(t)461 app, bw, paneID := newVimModeAccessibilityFixture(t)
421 wv := newAccessibilityEnablingWebView(t, 3)462 wv := newAccessibilityEnablingWebView(t, 3)

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