detached d3c8f2e

feat(ui): enable webview accessibility on vim mode entry

commit
d3c8f2e0967da7ca3889bb99a5730fd69e37ac22
author
brice · · 2026-08-14 07:44 UTC
committer
brice · · 2026-08-14 09:38 UTC
parent
2affa97
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +106 -0
M internal/ui/app.go +24 -0
@@ -2160,6 +2160,26 @@ func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, p
21602160 return paneID, a.contentCoord.GetWebView(paneID)
21612161 }
21622162
2163+func (a *App) enableAccessibilityForVimMode(ctx context.Context, bw *browserWindow) {
2164+ paneID, wv := a.activeWebViewForBrowserWindow(bw)
2165+ if wv == nil {
2166+ return
2167+ }
2168+ enabler, ok := wv.(port.AccessibilityEnabler)
2169+ if !ok {
2170+ return
2171+ }
2172+ enabler.EnableAccessibility()
2173+ windowID := ""
2174+ if bw != nil {
2175+ windowID = bw.id
2176+ }
2177+ logging.FromContext(ctx).Debug().
2178+ Str("window_id", windowID).
2179+ Str("pane_id", string(paneID)).
2180+ Msg("webview accessibility enabled for vim mode")
2181+}
2182+
21632183 // omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes
21642184 // navigation through navigate for the supplied browser window. The closure captures
21652185 // bw at creation time, so navigation targets the owning window even when another
@@ -3861,6 +3881,10 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to
38613881 a.applyResizeModeBorder(ctx, a.activeWorkspace())
38623882 }
38633883
3884+ if to == input.ModeVim && from != input.ModeVim {
3885+ a.enableAccessibilityForVimMode(ctx, bw)
3886+ }
3887+
38643888 // Handle pane-local Vim Mode visual ownership.
38653889 // Entering Vim Mode accents the active pane; leaving removes the accent.
38663890 a.handleVimModeOwnership(ctx, bw, to, from)
@@ -2160,6 +2160,26 @@ func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, p
2160 return paneID, a.contentCoord.GetWebView(paneID)2160 return paneID, a.contentCoord.GetWebView(paneID)
2161 }2161 }
2162 2162
2163+func (a *App) enableAccessibilityForVimMode(ctx context.Context, bw *browserWindow) {
2164+ paneID, wv := a.activeWebViewForBrowserWindow(bw)
2165+ if wv == nil {
2166+ return
2167+ }
2168+ enabler, ok := wv.(port.AccessibilityEnabler)
2169+ if !ok {
2170+ return
2171+ }
2172+ enabler.EnableAccessibility()
2173+ windowID := ""
2174+ if bw != nil {
2175+ windowID = bw.id
2176+ }
2177+ logging.FromContext(ctx).Debug().
2178+ Str("window_id", windowID).
2179+ Str("pane_id", string(paneID)).
2180+ Msg("webview accessibility enabled for vim mode")
2181+}
2182+
2163 // omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes2183 // omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes
2164 // navigation through navigate for the supplied browser window. The closure captures2184 // navigation through navigate for the supplied browser window. The closure captures
2165 // bw at creation time, so navigation targets the owning window even when another2185 // bw at creation time, so navigation targets the owning window even when another
@@ -3861,6 +3881,10 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to
3861 a.applyResizeModeBorder(ctx, a.activeWorkspace())3881 a.applyResizeModeBorder(ctx, a.activeWorkspace())
3862 }3882 }
3863 3883
3884+ if to == input.ModeVim && from != input.ModeVim {
3885+ a.enableAccessibilityForVimMode(ctx, bw)
3886+ }
3887+
3864 // Handle pane-local Vim Mode visual ownership.3888 // Handle pane-local Vim Mode visual ownership.
3865 // Entering Vim Mode accents the active pane; leaving removes the accent.3889 // Entering Vim Mode accents the active pane; leaving removes the accent.
3866 a.handleVimModeOwnership(ctx, bw, to, from)3890 a.handleVimModeOwnership(ctx, bw, to, from)
M internal/ui/app_vim_mode_test.go +82 -0
@@ -10,9 +10,12 @@ import (
1010 "github.com/stretchr/testify/mock"
1111 "github.com/stretchr/testify/require"
1212
13+ "github.com/bnema/dumber/internal/application/port"
14+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
1315 "github.com/bnema/dumber/internal/application/usecase"
1416 "github.com/bnema/dumber/internal/domain/entity"
1517 "github.com/bnema/dumber/internal/ui/component"
18+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
1619 "github.com/bnema/dumber/internal/ui/focus"
1720 "github.com/bnema/dumber/internal/ui/input"
1821 "github.com/bnema/dumber/internal/ui/layout"
@@ -154,6 +157,43 @@ func expectVimModeAccentHidden(overlay *mocks.MockOverlayWidget) {
154157 overlay.EXPECT().RemoveCssClass("vim-mode-active").Once()
155158 }
156159
160+type accessibilityEnablingWebView struct {
161+ *portmocks.MockWebView
162+ enableAccessibilityCalls int
163+}
164+
165+func newAccessibilityEnablingWebView(t *testing.T, id port.WebViewID) *accessibilityEnablingWebView {
166+ t.Helper()
167+ wv := portmocks.NewMockWebView(t)
168+ wv.EXPECT().ID().Return(id).Once()
169+ return &accessibilityEnablingWebView{MockWebView: wv}
170+}
171+
172+func (wv *accessibilityEnablingWebView) EnableAccessibility() {
173+ wv.enableAccessibilityCalls++
174+}
175+
176+func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) {
177+ t.Helper()
178+ paneID := entity.PaneID("pane-a")
179+ ws := &entity.Workspace{
180+ ID: "ws-1",
181+ ActivePaneID: paneID,
182+ Root: &entity.PaneNode{
183+ ID: "ws-1-root",
184+ Pane: entity.NewPane(paneID),
185+ },
186+ }
187+ tab := &entity.Tab{ID: "tab-1", Workspace: ws}
188+ tabs := entity.NewTabList()
189+ tabs.Add(tab)
190+ tabs.SetActive(tab.ID)
191+ bw := &browserWindow{id: "win-1", tabs: tabs}
192+ return &App{
193+ contentCoord: contentcoord.NewCoordinator(context.Background(), nil, nil, nil, nil, nil, nil, nil),
194+ }, bw, paneID
195+}
196+
157197 // setupWorkspaceViewMocks creates a mock-backed WorkspaceView.
158198 func setupWorkspaceViewMocks(
159199 t *testing.T,
@@ -351,6 +391,48 @@ func TestVimMode_Enter_MarksCorrectWindow(t *testing.T) {
351391 assert.False(t, f.pv1A.IsVimMode())
352392 }
353393
394+func TestVimMode_Enter_EnablesAccessibilityForSupportedActiveWebView(t *testing.T) {
395+ app, bw, paneID := newVimModeAccessibilityFixture(t)
396+ wv := newAccessibilityEnablingWebView(t, 1)
397+ app.contentCoord.RegisterPopupWebView(paneID, wv)
398+
399+ app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
400+
401+ assert.Equal(t, 1, wv.enableAccessibilityCalls)
402+}
403+
404+func TestVimMode_Enter_AccessibilityNoopsForUnsupportedActiveWebView(t *testing.T) {
405+ app, bw, paneID := newVimModeAccessibilityFixture(t)
406+ wv := portmocks.NewMockWebView(t)
407+ wv.EXPECT().ID().Return(port.WebViewID(2)).Once()
408+ app.contentCoord.RegisterPopupWebView(paneID, wv)
409+
410+ app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
411+}
412+
413+func TestVimMode_Enter_AccessibilityNoopsWhenActiveWebViewMissing(t *testing.T) {
414+ app, bw, _ := newVimModeAccessibilityFixture(t)
415+
416+ app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
417+}
418+
419+func TestVimMode_AccessibilityOnlyEnabledOnEnteringVimMode(t *testing.T) {
420+ app, bw, paneID := newVimModeAccessibilityFixture(t)
421+ wv := newAccessibilityEnablingWebView(t, 3)
422+ app.contentCoord.RegisterPopupWebView(paneID, wv)
423+
424+ app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeTab)
425+ app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal)
426+ app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeVim)
427+ assert.Equal(t, 0, wv.enableAccessibilityCalls)
428+
429+ app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
430+ assert.Equal(t, 1, wv.enableAccessibilityCalls)
431+
432+ app.handleModeChange(context.Background(), bw, input.ModePane, input.ModeVim)
433+ assert.Equal(t, 2, wv.enableAccessibilityCalls)
434+}
435+
354436 // ============================================================================
355437 // 2. Leaving Vim mode clears ownership
356438 // ============================================================================
@@ -10,9 +10,12 @@ import (
10 "github.com/stretchr/testify/mock"10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 12
13+ "github.com/bnema/dumber/internal/application/port"
14+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
13 "github.com/bnema/dumber/internal/application/usecase"15 "github.com/bnema/dumber/internal/application/usecase"
14 "github.com/bnema/dumber/internal/domain/entity"16 "github.com/bnema/dumber/internal/domain/entity"
15 "github.com/bnema/dumber/internal/ui/component"17 "github.com/bnema/dumber/internal/ui/component"
18+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
16 "github.com/bnema/dumber/internal/ui/focus"19 "github.com/bnema/dumber/internal/ui/focus"
17 "github.com/bnema/dumber/internal/ui/input"20 "github.com/bnema/dumber/internal/ui/input"
18 "github.com/bnema/dumber/internal/ui/layout"21 "github.com/bnema/dumber/internal/ui/layout"
@@ -154,6 +157,43 @@ func expectVimModeAccentHidden(overlay *mocks.MockOverlayWidget) {
154 overlay.EXPECT().RemoveCssClass("vim-mode-active").Once()157 overlay.EXPECT().RemoveCssClass("vim-mode-active").Once()
155 }158 }
156 159
160+type accessibilityEnablingWebView struct {
161+ *portmocks.MockWebView
162+ enableAccessibilityCalls int
163+}
164+
165+func newAccessibilityEnablingWebView(t *testing.T, id port.WebViewID) *accessibilityEnablingWebView {
166+ t.Helper()
167+ wv := portmocks.NewMockWebView(t)
168+ wv.EXPECT().ID().Return(id).Once()
169+ return &accessibilityEnablingWebView{MockWebView: wv}
170+}
171+
172+func (wv *accessibilityEnablingWebView) EnableAccessibility() {
173+ wv.enableAccessibilityCalls++
174+}
175+
176+func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) {
177+ t.Helper()
178+ paneID := entity.PaneID("pane-a")
179+ ws := &entity.Workspace{
180+ ID: "ws-1",
181+ ActivePaneID: paneID,
182+ Root: &entity.PaneNode{
183+ ID: "ws-1-root",
184+ Pane: entity.NewPane(paneID),
185+ },
186+ }
187+ tab := &entity.Tab{ID: "tab-1", Workspace: ws}
188+ tabs := entity.NewTabList()
189+ tabs.Add(tab)
190+ tabs.SetActive(tab.ID)
191+ bw := &browserWindow{id: "win-1", tabs: tabs}
192+ return &App{
193+ contentCoord: contentcoord.NewCoordinator(context.Background(), nil, nil, nil, nil, nil, nil, nil),
194+ }, bw, paneID
195+}
196+
157 // setupWorkspaceViewMocks creates a mock-backed WorkspaceView.197 // setupWorkspaceViewMocks creates a mock-backed WorkspaceView.
158 func setupWorkspaceViewMocks(198 func setupWorkspaceViewMocks(
159 t *testing.T,199 t *testing.T,
@@ -351,6 +391,48 @@ func TestVimMode_Enter_MarksCorrectWindow(t *testing.T) {
351 assert.False(t, f.pv1A.IsVimMode())391 assert.False(t, f.pv1A.IsVimMode())
352 }392 }
353 393
394+func TestVimMode_Enter_EnablesAccessibilityForSupportedActiveWebView(t *testing.T) {
395+ app, bw, paneID := newVimModeAccessibilityFixture(t)
396+ wv := newAccessibilityEnablingWebView(t, 1)
397+ app.contentCoord.RegisterPopupWebView(paneID, wv)
398+
399+ app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
400+
401+ assert.Equal(t, 1, wv.enableAccessibilityCalls)
402+}
403+
404+func TestVimMode_Enter_AccessibilityNoopsForUnsupportedActiveWebView(t *testing.T) {
405+ app, bw, paneID := newVimModeAccessibilityFixture(t)
406+ wv := portmocks.NewMockWebView(t)
407+ wv.EXPECT().ID().Return(port.WebViewID(2)).Once()
408+ app.contentCoord.RegisterPopupWebView(paneID, wv)
409+
410+ app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
411+}
412+
413+func TestVimMode_Enter_AccessibilityNoopsWhenActiveWebViewMissing(t *testing.T) {
414+ app, bw, _ := newVimModeAccessibilityFixture(t)
415+
416+ app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
417+}
418+
419+func TestVimMode_AccessibilityOnlyEnabledOnEnteringVimMode(t *testing.T) {
420+ app, bw, paneID := newVimModeAccessibilityFixture(t)
421+ wv := newAccessibilityEnablingWebView(t, 3)
422+ app.contentCoord.RegisterPopupWebView(paneID, wv)
423+
424+ app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeTab)
425+ app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal)
426+ app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeVim)
427+ assert.Equal(t, 0, wv.enableAccessibilityCalls)
428+
429+ app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
430+ assert.Equal(t, 1, wv.enableAccessibilityCalls)
431+
432+ app.handleModeChange(context.Background(), bw, input.ModePane, input.ModeVim)
433+ assert.Equal(t, 2, wv.enableAccessibilityCalls)
434+}
435+
354 // ============================================================================436 // ============================================================================
355 // 2. Leaving Vim mode clears ownership437 // 2. Leaving Vim mode clears ownership
356 // ============================================================================438 // ============================================================================

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