detached f52290e

feat(vim): focus first page input

commit
f52290ef7bac22cb67c4c0af68e8e92dfeee81ca
author
brice · · 2026-08-14 13:05 UTC
parent
04133a4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +140 -2
M internal/application/port/webview.go +7 -0
@@ -476,3 +476,10 @@ type SemanticNavigationRequest struct {
476476 type SemanticNavigable interface {
477477 NavigateSemantic(ctx context.Context, request SemanticNavigationRequest) error
478478 }
479+
480+// PageInputFocuser is an optional WebView capability for focusing the first
481+// eligible page input from Vim Mode. Unsupported engines safely leave it
482+// unimplemented.
483+type PageInputFocuser interface {
484+ FocusFirstInput()
485+}
@@ -476,3 +476,10 @@ type SemanticNavigationRequest struct {
476 type SemanticNavigable interface {476 type SemanticNavigable interface {
477 NavigateSemantic(ctx context.Context, request SemanticNavigationRequest) error477 NavigateSemantic(ctx context.Context, request SemanticNavigationRequest) error
478 }478 }
479+
480+// PageInputFocuser is an optional WebView capability for focusing the first
481+// eligible page input from Vim Mode. Unsupported engines safely leave it
482+// unimplemented.
483+type PageInputFocuser interface {
484+ FocusFirstInput()
485+}
A internal/infrastructure/cef/input_focus.go +47 -0
@@ -0,0 +1,47 @@
1+package cef
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+)
8+
9+var _ port.PageInputFocuser = (*WebView)(nil)
10+
11+// FocusFirstInput focuses the first visible, editable page input when one is
12+// available. The operation is intentionally fire-and-forget because the DOM
13+// is owned by the renderer and may change after the Vim command is handled.
14+func (wv *WebView) FocusFirstInput() {
15+ if wv == nil || wv.destroyed.Load() {
16+ return
17+ }
18+ wv.RunJavaScript(context.Background(), focusFirstInputScript())
19+}
20+
21+func focusFirstInputScript() string {
22+ return `(() => {
23+ const candidates = Array.from(document.querySelectorAll("input,textarea,[contenteditable]"));
24+ const hiddenInputTypes = new Set([
25+ "button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"
26+ ]);
27+ const isVisible = (element) => {
28+ if (!(element instanceof HTMLElement) || !element.isConnected) return false;
29+ if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return false;
30+ if (element.getClientRects().length === 0) return false;
31+ const style = window.getComputedStyle(element);
32+ return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse";
33+ };
34+ const isEligible = (element) => {
35+ if (!isVisible(element)) return false;
36+ if (element instanceof HTMLInputElement) {
37+ return !element.disabled && !element.readOnly && !hiddenInputTypes.has(element.type.toLowerCase());
38+ }
39+ if (element instanceof HTMLTextAreaElement) {
40+ return !element.disabled && !element.readOnly;
41+ }
42+ return element.isContentEditable && element.getAttribute("contenteditable") !== "false";
43+ };
44+ const target = candidates.find(isEligible);
45+ if (target) target.focus();
46+})();`
47+}
@@ -0,0 +1,47 @@
1+package cef
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+)
8+
9+var _ port.PageInputFocuser = (*WebView)(nil)
10+
11+// FocusFirstInput focuses the first visible, editable page input when one is
12+// available. The operation is intentionally fire-and-forget because the DOM
13+// is owned by the renderer and may change after the Vim command is handled.
14+func (wv *WebView) FocusFirstInput() {
15+ if wv == nil || wv.destroyed.Load() {
16+ return
17+ }
18+ wv.RunJavaScript(context.Background(), focusFirstInputScript())
19+}
20+
21+func focusFirstInputScript() string {
22+ return `(() => {
23+ const candidates = Array.from(document.querySelectorAll("input,textarea,[contenteditable]"));
24+ const hiddenInputTypes = new Set([
25+ "button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"
26+ ]);
27+ const isVisible = (element) => {
28+ if (!(element instanceof HTMLElement) || !element.isConnected) return false;
29+ if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return false;
30+ if (element.getClientRects().length === 0) return false;
31+ const style = window.getComputedStyle(element);
32+ return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse";
33+ };
34+ const isEligible = (element) => {
35+ if (!isVisible(element)) return false;
36+ if (element instanceof HTMLInputElement) {
37+ return !element.disabled && !element.readOnly && !hiddenInputTypes.has(element.type.toLowerCase());
38+ }
39+ if (element instanceof HTMLTextAreaElement) {
40+ return !element.disabled && !element.readOnly;
41+ }
42+ return element.isContentEditable && element.getAttribute("contenteditable") !== "false";
43+ };
44+ const target = candidates.find(isEligible);
45+ if (target) target.focus();
46+})();`
47+}
A internal/infrastructure/cef/input_focus_test.go +34 -0
@@ -0,0 +1,34 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
7+ "github.com/stretchr/testify/mock"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestWebViewFocusFirstInputExecutesSafeFocusScript(t *testing.T) {
12+ browser := cefmocks.NewMockBrowser(t)
13+ frame := cefmocks.NewMockFrame(t)
14+ browser.EXPECT().GetMainFrame().Return(frame).Once()
15+ frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t,
16+ `document.querySelectorAll("input,textarea,[contenteditable]")`,
17+ `element.closest("[hidden],[inert],[aria-hidden=\"true\"]")`,
18+ "getClientRects",
19+ "HTMLInputElement",
20+ "HTMLTextAreaElement",
21+ "target.focus()",
22+ )), "", int32(0)).Once()
23+
24+ (&WebView{browser: browser}).FocusFirstInput()
25+}
26+
27+func TestFocusFirstInputScriptNoOpsWithoutEligibleTarget(t *testing.T) {
28+ script := focusFirstInputScript()
29+
30+ require.Contains(t, script, "const target = candidates.find(isEligible);")
31+ require.Contains(t, script, "if (target) target.focus();")
32+ require.Contains(t, script, "!element.disabled && !element.readOnly")
33+ require.Contains(t, script, `hiddenInputTypes.has(element.type.toLowerCase())`)
34+}
@@ -0,0 +1,34 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
7+ "github.com/stretchr/testify/mock"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestWebViewFocusFirstInputExecutesSafeFocusScript(t *testing.T) {
12+ browser := cefmocks.NewMockBrowser(t)
13+ frame := cefmocks.NewMockFrame(t)
14+ browser.EXPECT().GetMainFrame().Return(frame).Once()
15+ frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t,
16+ `document.querySelectorAll("input,textarea,[contenteditable]")`,
17+ `element.closest("[hidden],[inert],[aria-hidden=\"true\"]")`,
18+ "getClientRects",
19+ "HTMLInputElement",
20+ "HTMLTextAreaElement",
21+ "target.focus()",
22+ )), "", int32(0)).Once()
23+
24+ (&WebView{browser: browser}).FocusFirstInput()
25+}
26+
27+func TestFocusFirstInputScriptNoOpsWithoutEligibleTarget(t *testing.T) {
28+ script := focusFirstInputScript()
29+
30+ require.Contains(t, script, "const target = candidates.find(isEligible);")
31+ require.Contains(t, script, "if (target) target.focus();")
32+ require.Contains(t, script, "!element.disabled && !element.readOnly")
33+ require.Contains(t, script, `hiddenInputTypes.has(element.type.toLowerCase())`)
34+}
M internal/infrastructure/config/defaults.go +1 -0
@@ -320,6 +320,7 @@ func DefaultConfig() *Config {
320320 "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"},
321321 "heading-next": {Keys: []string{"]]"}, Desc: "Jump to next heading"},
322322 "heading-prev": {Keys: []string{"[["}, Desc: "Jump to previous heading"},
323+ "focus-input": {Keys: []string{"gi"}, Desc: "Focus first page input"},
323324 "code-next": {Keys: []string{"]c"}, Desc: "Jump to next code block"},
324325 "code-prev": {Keys: []string{"[c"}, Desc: "Jump to previous code block"},
325326 "table-next": {Keys: []string{"]t"}, Desc: "Jump to next table"},
@@ -320,6 +320,7 @@ func DefaultConfig() *Config {
320 "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"},320 "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"},
321 "heading-next": {Keys: []string{"]]"}, Desc: "Jump to next heading"},321 "heading-next": {Keys: []string{"]]"}, Desc: "Jump to next heading"},
322 "heading-prev": {Keys: []string{"[["}, Desc: "Jump to previous heading"},322 "heading-prev": {Keys: []string{"[["}, Desc: "Jump to previous heading"},
323+ "focus-input": {Keys: []string{"gi"}, Desc: "Focus first page input"},
323 "code-next": {Keys: []string{"]c"}, Desc: "Jump to next code block"},324 "code-next": {Keys: []string{"]c"}, Desc: "Jump to next code block"},
324 "code-prev": {Keys: []string{"[c"}, Desc: "Jump to previous code block"},325 "code-prev": {Keys: []string{"[c"}, Desc: "Jump to previous code block"},
325 "table-next": {Keys: []string{"]t"}, Desc: "Jump to next table"},326 "table-next": {Keys: []string{"]t"}, Desc: "Jump to next table"},
M internal/infrastructure/config/vim_mode_config_test.go +4 -2
@@ -22,6 +22,7 @@ var legacyVimModeActions = []string{
2222 var sequenceVimModeActions = map[string]string{
2323 "heading-next": "]]",
2424 "heading-prev": "[[",
25+ "focus-input": "gi",
2526 "code-next": "]c",
2627 "code-prev": "[c",
2728 "table-next": "]t",
@@ -65,7 +66,7 @@ func TestVimModeSequenceDefaults(t *testing.T) {
6566 vimMode := cfg.Workspace.VimMode
6667
6768 assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
68- require.Len(t, sequenceVimModeActions, 11)
69+ require.Len(t, sequenceVimModeActions, 12)
6970
7071 for action, key := range sequenceVimModeActions {
7172 requireActionBinding(t, vimMode.Actions, action, []string{key})
@@ -118,6 +119,7 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) {
118119 assert.Equal(t, "cancel", bindings["escape"])
119120 assert.Equal(t, "heading-next", bindings["]]"])
120121 assert.Equal(t, "heading-prev", bindings["[["])
122+ assert.Equal(t, "focus-input", bindings["gi"])
121123 assert.Equal(t, "code-next", bindings["]c"])
122124 assert.Equal(t, "code-prev", bindings["[c"])
123125 assert.Equal(t, "table-next", bindings["]t"])
@@ -127,7 +129,7 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) {
127129 assert.Equal(t, "yank-section", bindings["yah"])
128130 assert.Equal(t, "half-page-down", bindings["<C-d>"])
129131 assert.Equal(t, "half-page-up", bindings["<C-u>"])
130- assert.Len(t, bindings, 19)
132+ assert.Len(t, bindings, 20)
131133 }
132134
133135 func TestValidateVimMode_NonNegativeTimeout(t *testing.T) {
@@ -22,6 +22,7 @@ var legacyVimModeActions = []string{
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 "code-next": "]c",26 "code-next": "]c",
26 "code-prev": "[c",27 "code-prev": "[c",
27 "table-next": "]t",28 "table-next": "]t",
@@ -65,7 +66,7 @@ func TestVimModeSequenceDefaults(t *testing.T) {
65 vimMode := cfg.Workspace.VimMode66 vimMode := cfg.Workspace.VimMode
66 67
67 assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)68 assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
68- require.Len(t, sequenceVimModeActions, 11)69+ require.Len(t, sequenceVimModeActions, 12)
69 70
70 for action, key := range sequenceVimModeActions {71 for action, key := range sequenceVimModeActions {
71 requireActionBinding(t, vimMode.Actions, action, []string{key})72 requireActionBinding(t, vimMode.Actions, action, []string{key})
@@ -118,6 +119,7 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) {
118 assert.Equal(t, "cancel", bindings["escape"])119 assert.Equal(t, "cancel", bindings["escape"])
119 assert.Equal(t, "heading-next", bindings["]]"])120 assert.Equal(t, "heading-next", bindings["]]"])
120 assert.Equal(t, "heading-prev", bindings["[["])121 assert.Equal(t, "heading-prev", bindings["[["])
122+ assert.Equal(t, "focus-input", bindings["gi"])
121 assert.Equal(t, "code-next", bindings["]c"])123 assert.Equal(t, "code-next", bindings["]c"])
122 assert.Equal(t, "code-prev", bindings["[c"])124 assert.Equal(t, "code-prev", bindings["[c"])
123 assert.Equal(t, "table-next", bindings["]t"])125 assert.Equal(t, "table-next", bindings["]t"])
@@ -127,7 +129,7 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) {
127 assert.Equal(t, "yank-section", bindings["yah"])129 assert.Equal(t, "yank-section", bindings["yah"])
128 assert.Equal(t, "half-page-down", bindings["<C-d>"])130 assert.Equal(t, "half-page-down", bindings["<C-d>"])
129 assert.Equal(t, "half-page-up", bindings["<C-u>"])131 assert.Equal(t, "half-page-up", bindings["<C-u>"])
130- assert.Len(t, bindings, 19)132+ assert.Len(t, bindings, 20)
131 }133 }
132 134
133 func TestValidateVimMode_NonNegativeTimeout(t *testing.T) {135 func TestValidateVimMode_NonNegativeTimeout(t *testing.T) {
M internal/ui/app.go +8 -0
@@ -2164,6 +2164,14 @@ func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, p
21642164 }
21652165
21662166 func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) {
2167+ if action == "focus-input" {
2168+ _, wv := a.activeWebViewForBrowserWindow(bw)
2169+ if focuser, ok := wv.(port.PageInputFocuser); ok {
2170+ focuser.FocusFirstInput()
2171+ }
2172+ return
2173+ }
2174+
21672175 var request port.SemanticNavigationRequest
21682176 switch action {
21692177 case "heading-next":
@@ -2164,6 +2164,14 @@ func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, p
2164 }2164 }
2165 2165
2166 func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) {2166 func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) {
2167+ if action == "focus-input" {
2168+ _, wv := a.activeWebViewForBrowserWindow(bw)
2169+ if focuser, ok := wv.(port.PageInputFocuser); ok {
2170+ focuser.FocusFirstInput()
2171+ }
2172+ return
2173+ }
2174+
2167 var request port.SemanticNavigationRequest2175 var request port.SemanticNavigationRequest
2168 switch action {2176 switch action {
2169 case "heading-next":2177 case "heading-next":
M internal/ui/app_vim_mode_test.go +27 -0
@@ -190,6 +190,22 @@ func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request
190190 return nil
191191 }
192192
193+type inputFocusingWebView struct {
194+ *portmocks.MockWebView
195+ focusFirstInputCalls int
196+}
197+
198+func newInputFocusingWebView(t *testing.T, id port.WebViewID) *inputFocusingWebView {
199+ t.Helper()
200+ wv := portmocks.NewMockWebView(t)
201+ wv.EXPECT().ID().Return(id).Once()
202+ return &inputFocusingWebView{MockWebView: wv}
203+}
204+
205+func (wv *inputFocusingWebView) FocusFirstInput() {
206+ wv.focusFirstInputCalls++
207+}
208+
193209 func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) {
194210 t.Helper()
195211 paneID := entity.PaneID("pane-a")
@@ -460,6 +476,16 @@ func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) {
460476 }, wv.requests)
461477 }
462478
479+func TestVimMode_SequenceActionFocusesFirstInput(t *testing.T) {
480+ app, bw, paneID := newVimModeAccessibilityFixture(t)
481+ wv := newInputFocusingWebView(t, 6)
482+ app.contentCoord.RegisterPopupWebView(paneID, wv)
483+
484+ app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1)
485+
486+ assert.Equal(t, 1, wv.focusFirstInputCalls)
487+}
488+
463489 func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) {
464490 app, bw, paneID := newVimModeAccessibilityFixture(t)
465491 wv := portmocks.NewMockWebView(t)
@@ -467,6 +493,7 @@ func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) {
467493 app.contentCoord.RegisterPopupWebView(paneID, wv)
468494
469495 app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
496+ app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1)
470497 }
471498
472499 func TestVimMode_AccessibilityOnlyEnabledOnEnteringVimMode(t *testing.T) {
@@ -190,6 +190,22 @@ func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request
190 return nil190 return nil
191 }191 }
192 192
193+type inputFocusingWebView struct {
194+ *portmocks.MockWebView
195+ focusFirstInputCalls int
196+}
197+
198+func newInputFocusingWebView(t *testing.T, id port.WebViewID) *inputFocusingWebView {
199+ t.Helper()
200+ wv := portmocks.NewMockWebView(t)
201+ wv.EXPECT().ID().Return(id).Once()
202+ return &inputFocusingWebView{MockWebView: wv}
203+}
204+
205+func (wv *inputFocusingWebView) FocusFirstInput() {
206+ wv.focusFirstInputCalls++
207+}
208+
193 func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) {209 func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) {
194 t.Helper()210 t.Helper()
195 paneID := entity.PaneID("pane-a")211 paneID := entity.PaneID("pane-a")
@@ -460,6 +476,16 @@ func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) {
460 }, wv.requests)476 }, wv.requests)
461 }477 }
462 478
479+func TestVimMode_SequenceActionFocusesFirstInput(t *testing.T) {
480+ app, bw, paneID := newVimModeAccessibilityFixture(t)
481+ wv := newInputFocusingWebView(t, 6)
482+ app.contentCoord.RegisterPopupWebView(paneID, wv)
483+
484+ app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1)
485+
486+ assert.Equal(t, 1, wv.focusFirstInputCalls)
487+}
488+
463 func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) {489 func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) {
464 app, bw, paneID := newVimModeAccessibilityFixture(t)490 app, bw, paneID := newVimModeAccessibilityFixture(t)
465 wv := portmocks.NewMockWebView(t)491 wv := portmocks.NewMockWebView(t)
@@ -467,6 +493,7 @@ func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) {
467 app.contentCoord.RegisterPopupWebView(paneID, wv)493 app.contentCoord.RegisterPopupWebView(paneID, wv)
468 494
469 app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)495 app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
496+ app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1)
470 }497 }
471 498
472 func TestVimMode_AccessibilityOnlyEnabledOnEnteringVimMode(t *testing.T) {499 func TestVimMode_AccessibilityOnlyEnabledOnEnteringVimMode(t *testing.T) {
M internal/ui/input/vimkeys_dispatch_test.go +12 -0
@@ -87,6 +87,7 @@ func TestNewVimModeSequenceState_NilTrie(t *testing.T) {
8787 func TestTrieOwnsBinding(t *testing.T) {
8888 cases := map[string]bool{
8989 "]]": true,
90+ "gi": true,
9091 "yah": true,
9192 "gO": true,
9293 "<C-d>": true,
@@ -156,6 +157,7 @@ func TestBuildVimModeTrie(t *testing.T) {
156157 Actions: map[string]entity.ActionBinding{
157158 "vim-scroll-down": {Keys: []string{"j"}},
158159 "heading-next": {Keys: []string{"]]"}},
160+ "focus-input": {Keys: []string{"gi"}},
159161 "yank-section": {Keys: []string{"yah"}},
160162 "outline": {Keys: []string{"gO"}},
161163 "half-page-down": {Keys: []string{"<C-d>"}},
@@ -171,6 +173,7 @@ func TestBuildVimModeTrie(t *testing.T) {
171173
172174 wantOwned := map[string]bool{
173175 "]]": true,
176+ "gi": true,
174177 "yah": true,
175178 "gO": true,
176179 "<C-d>": true,
@@ -194,6 +197,15 @@ func TestBuildVimModeTrie(t *testing.T) {
194197 if !ok || !node.Exact || node.Action != "heading-next" {
195198 t.Fatalf("Walk(]]) = (%#v, %v), want exact heading-next", node, ok)
196199 }
200+
201+ seq, err = vimkeys.ParseBinding("gi")
202+ if err != nil {
203+ t.Fatalf("ParseBinding(gi) error = %v", err)
204+ }
205+ node, ok = trie.Walk(seq)
206+ if !ok || !node.Exact || node.Action != "focus-input" {
207+ t.Fatalf("Walk(gi) = (%#v, %v), want exact focus-input", node, ok)
208+ }
197209 }
198210
199211 func TestBuildVimModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {
@@ -87,6 +87,7 @@ func TestNewVimModeSequenceState_NilTrie(t *testing.T) {
87 func TestTrieOwnsBinding(t *testing.T) {87 func TestTrieOwnsBinding(t *testing.T) {
88 cases := map[string]bool{88 cases := map[string]bool{
89 "]]": true,89 "]]": true,
90+ "gi": true,
90 "yah": true,91 "yah": true,
91 "gO": true,92 "gO": true,
92 "<C-d>": true,93 "<C-d>": true,
@@ -156,6 +157,7 @@ func TestBuildVimModeTrie(t *testing.T) {
156 Actions: map[string]entity.ActionBinding{157 Actions: map[string]entity.ActionBinding{
157 "vim-scroll-down": {Keys: []string{"j"}},158 "vim-scroll-down": {Keys: []string{"j"}},
158 "heading-next": {Keys: []string{"]]"}},159 "heading-next": {Keys: []string{"]]"}},
160+ "focus-input": {Keys: []string{"gi"}},
159 "yank-section": {Keys: []string{"yah"}},161 "yank-section": {Keys: []string{"yah"}},
160 "outline": {Keys: []string{"gO"}},162 "outline": {Keys: []string{"gO"}},
161 "half-page-down": {Keys: []string{"<C-d>"}},163 "half-page-down": {Keys: []string{"<C-d>"}},
@@ -171,6 +173,7 @@ func TestBuildVimModeTrie(t *testing.T) {
171 173
172 wantOwned := map[string]bool{174 wantOwned := map[string]bool{
173 "]]": true,175 "]]": true,
176+ "gi": true,
174 "yah": true,177 "yah": true,
175 "gO": true,178 "gO": true,
176 "<C-d>": true,179 "<C-d>": true,
@@ -194,6 +197,15 @@ func TestBuildVimModeTrie(t *testing.T) {
194 if !ok || !node.Exact || node.Action != "heading-next" {197 if !ok || !node.Exact || node.Action != "heading-next" {
195 t.Fatalf("Walk(]]) = (%#v, %v), want exact heading-next", node, ok)198 t.Fatalf("Walk(]]) = (%#v, %v), want exact heading-next", node, ok)
196 }199 }
200+
201+ seq, err = vimkeys.ParseBinding("gi")
202+ if err != nil {
203+ t.Fatalf("ParseBinding(gi) error = %v", err)
204+ }
205+ node, ok = trie.Walk(seq)
206+ if !ok || !node.Exact || node.Action != "focus-input" {
207+ t.Fatalf("Walk(gi) = (%#v, %v), want exact focus-input", node, ok)
208+ }
197 }209 }
198 210
199 func TestBuildVimModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {211 func TestBuildVimModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {

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