detached a86e62f

fix(ui): release vim page capture without re-entering the modal lock

ExitMode runs the mode-change callback under the modal write lock, and
releasing the page capture read the mode back through that lock, freezing
the app on every non-page exit. The release now takes the target state from
its caller. The Vim Mode toggle shortcut is never forwarded to the page, so
it always leaves Vim Mode and releases the capture.
commit
a86e62f931f99e2a9333007257299855d97e1a6f
author
brice · · 2026-10-04 16:02 UTC
committer
brice · · 2026-10-10 08:27 UTC
parent
2237867
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +104 -12
M internal/ui/app.go +11 -8
@@ -2258,7 +2258,7 @@ func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port
22582258 bw.keyboardHandler.SetPageKeyCapture(func(key string) {
22592259 if err := a.vimNavigationUseCase().SendPageKey(ctx, wv, key); err != nil {
22602260 logging.FromContext(ctx).Debug().Err(err).Msg("vim page key forwarding failed")
2261- a.endVimPageInteraction(ctx, bw)
2261+ a.endVimPageInteraction(ctx, bw, a.vimModeActiveForBrowserWindow(bw))
22622262 }
22632263 })
22642264 a.showVimModeToast(ctx, bw, label)
@@ -2272,9 +2272,11 @@ func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port
22722272 }
22732273
22742274 // releaseVimPageKeys returns this window's keys to Vim Mode bindings and
2275-// restores the plain indicator and legend when the window is still in Vim
2276-// Mode. It reports the WebView that owned the interaction, if any.
2277-func (a *App) releaseVimPageKeys(ctx context.Context, bw *browserWindow, reason string) port.WebView {
2275+// restores the plain indicator and legend when stillVim reports the window
2276+// remains in Vim Mode. The caller supplies it because the mode-change callback
2277+// runs under the modal lock, where reading the mode back would deadlock.
2278+// It reports the WebView that owned the interaction, if any.
2279+func (a *App) releaseVimPageKeys(ctx context.Context, bw *browserWindow, reason string, stillVim bool) port.WebView {
22782280 wv := bw.vimPageInteractionWebView
22792281 bw.vimPageInteractionWebView = nil
22802282 if bw.keyboardHandler != nil {
@@ -2283,7 +2285,7 @@ func (a *App) releaseVimPageKeys(ctx context.Context, bw *browserWindow, reason
22832285 if wv == nil {
22842286 return nil
22852287 }
2286- if bw.keyboardHandler != nil && bw.keyboardHandler.Mode() == input.ModeVim {
2288+ if stillVim {
22872289 a.showVimModeToast(ctx, bw, "")
22882290 if bw.modeFrame != nil {
22892291 bw.modeFrame.setSuspended(false)
@@ -2306,7 +2308,7 @@ func (a *App) handleVimPageInteractionEnded(ctx context.Context, paneID entity.P
23062308 if a.contentCoord.GetWebView(paneID) != bw.vimPageInteractionWebView {
23072309 return
23082310 }
2309- a.releaseVimPageKeys(ctx, bw, "page-reported-end")
2311+ a.releaseVimPageKeys(ctx, bw, "page-reported-end", a.vimModeActiveForBrowserWindow(bw))
23102312 }
23112313
23122314 // handleVimPageModeChanged repaints the indicator when the page moves its
@@ -2365,11 +2367,12 @@ func vimPageInteractionLabel(action string) string {
23652367 }
23662368
23672369 // endVimPageInteraction cancels any in-page interaction owned by this window.
2368-func (a *App) endVimPageInteraction(ctx context.Context, bw *browserWindow) {
2370+// stillVim reports whether the window stays in Vim Mode (see releaseVimPageKeys).
2371+func (a *App) endVimPageInteraction(ctx context.Context, bw *browserWindow, stillVim bool) {
23692372 if bw == nil {
23702373 return
23712374 }
2372- wv := a.releaseVimPageKeys(ctx, bw, "canceled")
2375+ wv := a.releaseVimPageKeys(ctx, bw, "canceled", stillVim)
23732376 if wv == nil {
23742377 return
23752378 }
@@ -2258,7 +2258,7 @@ func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port
2258 bw.keyboardHandler.SetPageKeyCapture(func(key string) {2258 bw.keyboardHandler.SetPageKeyCapture(func(key string) {
2259 if err := a.vimNavigationUseCase().SendPageKey(ctx, wv, key); err != nil {2259 if err := a.vimNavigationUseCase().SendPageKey(ctx, wv, key); err != nil {
2260 logging.FromContext(ctx).Debug().Err(err).Msg("vim page key forwarding failed")2260 logging.FromContext(ctx).Debug().Err(err).Msg("vim page key forwarding failed")
2261- a.endVimPageInteraction(ctx, bw)2261+ a.endVimPageInteraction(ctx, bw, a.vimModeActiveForBrowserWindow(bw))
2262 }2262 }
2263 })2263 })
2264 a.showVimModeToast(ctx, bw, label)2264 a.showVimModeToast(ctx, bw, label)
@@ -2272,9 +2272,11 @@ func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port
2272 }2272 }
2273 2273
2274 // releaseVimPageKeys returns this window's keys to Vim Mode bindings and2274 // releaseVimPageKeys returns this window's keys to Vim Mode bindings and
2275-// restores the plain indicator and legend when the window is still in Vim2275+// restores the plain indicator and legend when stillVim reports the window
2276-// Mode. It reports the WebView that owned the interaction, if any.2276+// remains in Vim Mode. The caller supplies it because the mode-change callback
2277-func (a *App) releaseVimPageKeys(ctx context.Context, bw *browserWindow, reason string) port.WebView {2277+// runs under the modal lock, where reading the mode back would deadlock.
2278+// It reports the WebView that owned the interaction, if any.
2279+func (a *App) releaseVimPageKeys(ctx context.Context, bw *browserWindow, reason string, stillVim bool) port.WebView {
2278 wv := bw.vimPageInteractionWebView2280 wv := bw.vimPageInteractionWebView
2279 bw.vimPageInteractionWebView = nil2281 bw.vimPageInteractionWebView = nil
2280 if bw.keyboardHandler != nil {2282 if bw.keyboardHandler != nil {
@@ -2283,7 +2285,7 @@ func (a *App) releaseVimPageKeys(ctx context.Context, bw *browserWindow, reason
2283 if wv == nil {2285 if wv == nil {
2284 return nil2286 return nil
2285 }2287 }
2286- if bw.keyboardHandler != nil && bw.keyboardHandler.Mode() == input.ModeVim {2288+ if stillVim {
2287 a.showVimModeToast(ctx, bw, "")2289 a.showVimModeToast(ctx, bw, "")
2288 if bw.modeFrame != nil {2290 if bw.modeFrame != nil {
2289 bw.modeFrame.setSuspended(false)2291 bw.modeFrame.setSuspended(false)
@@ -2306,7 +2308,7 @@ func (a *App) handleVimPageInteractionEnded(ctx context.Context, paneID entity.P
2306 if a.contentCoord.GetWebView(paneID) != bw.vimPageInteractionWebView {2308 if a.contentCoord.GetWebView(paneID) != bw.vimPageInteractionWebView {
2307 return2309 return
2308 }2310 }
2309- a.releaseVimPageKeys(ctx, bw, "page-reported-end")2311+ a.releaseVimPageKeys(ctx, bw, "page-reported-end", a.vimModeActiveForBrowserWindow(bw))
2310 }2312 }
2311 2313
2312 // handleVimPageModeChanged repaints the indicator when the page moves its2314 // handleVimPageModeChanged repaints the indicator when the page moves its
@@ -2365,11 +2367,12 @@ func vimPageInteractionLabel(action string) string {
2365 }2367 }
2366 2368
2367 // endVimPageInteraction cancels any in-page interaction owned by this window.2369 // endVimPageInteraction cancels any in-page interaction owned by this window.
2368-func (a *App) endVimPageInteraction(ctx context.Context, bw *browserWindow) {2370+// stillVim reports whether the window stays in Vim Mode (see releaseVimPageKeys).
2371+func (a *App) endVimPageInteraction(ctx context.Context, bw *browserWindow, stillVim bool) {
2369 if bw == nil {2372 if bw == nil {
2370 return2373 return
2371 }2374 }
2372- wv := a.releaseVimPageKeys(ctx, bw, "canceled")2375+ wv := a.releaseVimPageKeys(ctx, bw, "canceled", stillVim)
2373 if wv == nil {2376 if wv == nil {
2374 return2377 return
2375 }2378 }
M internal/ui/app_vim_page_test.go +36 -1
@@ -3,6 +3,7 @@ package ui
33 import (
44 "context"
55 "testing"
6+ "time"
67
78 "github.com/stretchr/testify/mock"
89 "github.com/stretchr/testify/require"
@@ -84,7 +85,7 @@ func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) {
8485 require.True(t, frame.suspended, "legend must be suspended while the page captures keys")
8586
8687 wv.EXPECT().ID().Return(1).Maybe()
87- app.endVimPageInteraction(context.Background(), bw)
88+ app.endVimPageInteraction(context.Background(), bw, true)
8889 require.False(t, frame.suspended, "legend must be restored when the interaction ends")
8990 require.False(t, bw.keyboardHandler.PageKeyCaptureActive())
9091 }
@@ -121,3 +122,37 @@ func TestVimPageModeChangeIgnoredWithoutInteraction(t *testing.T) {
121122
122123 require.Empty(t, *shown, "a late mode report must not repaint the indicator once the interaction ended")
123124 }
125+
126+// exitModeWithin runs fn and fails the test if it does not return in time,
127+// so a lock re-entry deadlock shows up as a failure instead of a hung run.
128+func exitModeWithin(t *testing.T, fn func()) {
129+ t.Helper()
130+ done := make(chan struct{})
131+ go func() {
132+ defer close(done)
133+ fn()
134+ }()
135+ select {
136+ case <-done:
137+ case <-time.After(3 * time.Second):
138+ t.Fatal("ExitMode did not return: mode-change callback re-entered the modal lock")
139+ }
140+}
141+
142+// ExitMode runs the mode-change callback under the modal lock. Releasing the
143+// page capture from that callback must not read the mode back through the lock.
144+func TestExitModeWithPageCaptureDoesNotDeadlock(t *testing.T) {
145+ app, bw, _ := newVimPageTestWindow(t)
146+ bw.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
147+ app.handleModeChange(context.Background(), bw, from, to)
148+ })
149+ wv := portmocks.NewMockWebView(t)
150+ wv.EXPECT().ID().Return(1).Maybe()
151+ app.captureVimPageKeys(context.Background(), bw, wv, "VISUAL")
152+
153+ exitModeWithin(t, bw.keyboardHandler.ExitMode)
154+
155+ require.False(t, bw.keyboardHandler.PageKeyCaptureActive())
156+ require.Nil(t, bw.vimPageInteractionWebView)
157+ require.Equal(t, input.ModeNormal, bw.keyboardHandler.Mode())
158+}
@@ -3,6 +3,7 @@ package ui
3 import (3 import (
4 "context"4 "context"
5 "testing"5 "testing"
6+ "time"
6 7
7 "github.com/stretchr/testify/mock"8 "github.com/stretchr/testify/mock"
8 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
@@ -84,7 +85,7 @@ func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) {
84 require.True(t, frame.suspended, "legend must be suspended while the page captures keys")85 require.True(t, frame.suspended, "legend must be suspended while the page captures keys")
85 86
86 wv.EXPECT().ID().Return(1).Maybe()87 wv.EXPECT().ID().Return(1).Maybe()
87- app.endVimPageInteraction(context.Background(), bw)88+ app.endVimPageInteraction(context.Background(), bw, true)
88 require.False(t, frame.suspended, "legend must be restored when the interaction ends")89 require.False(t, frame.suspended, "legend must be restored when the interaction ends")
89 require.False(t, bw.keyboardHandler.PageKeyCaptureActive())90 require.False(t, bw.keyboardHandler.PageKeyCaptureActive())
90 }91 }
@@ -121,3 +122,37 @@ func TestVimPageModeChangeIgnoredWithoutInteraction(t *testing.T) {
121 122
122 require.Empty(t, *shown, "a late mode report must not repaint the indicator once the interaction ended")123 require.Empty(t, *shown, "a late mode report must not repaint the indicator once the interaction ended")
123 }124 }
125+
126+// exitModeWithin runs fn and fails the test if it does not return in time,
127+// so a lock re-entry deadlock shows up as a failure instead of a hung run.
128+func exitModeWithin(t *testing.T, fn func()) {
129+ t.Helper()
130+ done := make(chan struct{})
131+ go func() {
132+ defer close(done)
133+ fn()
134+ }()
135+ select {
136+ case <-done:
137+ case <-time.After(3 * time.Second):
138+ t.Fatal("ExitMode did not return: mode-change callback re-entered the modal lock")
139+ }
140+}
141+
142+// ExitMode runs the mode-change callback under the modal lock. Releasing the
143+// page capture from that callback must not read the mode back through the lock.
144+func TestExitModeWithPageCaptureDoesNotDeadlock(t *testing.T) {
145+ app, bw, _ := newVimPageTestWindow(t)
146+ bw.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
147+ app.handleModeChange(context.Background(), bw, from, to)
148+ })
149+ wv := portmocks.NewMockWebView(t)
150+ wv.EXPECT().ID().Return(1).Maybe()
151+ app.captureVimPageKeys(context.Background(), bw, wv, "VISUAL")
152+
153+ exitModeWithin(t, bw.keyboardHandler.ExitMode)
154+
155+ require.False(t, bw.keyboardHandler.PageKeyCaptureActive())
156+ require.Nil(t, bw.vimPageInteractionWebView)
157+ require.Equal(t, input.ModeNormal, bw.keyboardHandler.Mode())
158+}
M internal/ui/input/vim_page_capture.go +21 -1
@@ -35,7 +35,23 @@ func (h *KeyboardHandler) PageKeyCaptureActive() bool {
3535 return h.pageKeyCapture != nil
3636 }
3737
38-// forwardPageKey sends a Vim Mode key to the active page capture. Escape is
38+// isVimModeToggleKey reports whether the key is the configured Vim Mode
39+// activation shortcut. While Vim Mode is active it exits the mode, so it must
40+// stay with Go even when a page interaction owns every other key.
41+func (h *KeyboardHandler) isVimModeToggleKey(keyval uint, state gdk.ModifierType) bool {
42+ h.mu.RLock()
43+ shortcuts := h.shortcuts
44+ h.mu.RUnlock()
45+ if shortcuts == nil {
46+ return false
47+ }
48+ binding := KeyBinding{Keyval: normalizeKeyval(keyval), Modifiers: Modifier(state) & modifierMask}
49+ action, found := shortcuts.Lookup(binding, ModeVim)
50+ return found && action == ActionEnterVimMode
51+}
52+
53+// forwardPageKey sends a Vim Mode key to the active page capture, except the
54+// Vim Mode toggle shortcut, which always falls through. Escape is
3955 // forwarded like any other key: the page decides whether it steps back (visual
4056 // to caret) or ends the interaction, and reports the end so the capture is
4157 // released. A page that cannot be reached ends the capture through the
@@ -53,6 +69,10 @@ func (h *KeyboardHandler) forwardPageKey(mode Mode, keyval uint, state gdk.Modif
5369 if isModifierKeyval(keyval) {
5470 return true
5571 }
72+ if h.isVimModeToggleKey(keyval, state) {
73+ // Escape hatch: a page can never swallow the shortcut that leaves Vim Mode.
74+ return false
75+ }
5676 key, ok := KeyvalToVimKey(keyval, state)
5777 if !ok {
5878 return true
@@ -35,7 +35,23 @@ func (h *KeyboardHandler) PageKeyCaptureActive() bool {
35 return h.pageKeyCapture != nil35 return h.pageKeyCapture != nil
36 }36 }
37 37
38-// forwardPageKey sends a Vim Mode key to the active page capture. Escape is38+// isVimModeToggleKey reports whether the key is the configured Vim Mode
39+// activation shortcut. While Vim Mode is active it exits the mode, so it must
40+// stay with Go even when a page interaction owns every other key.
41+func (h *KeyboardHandler) isVimModeToggleKey(keyval uint, state gdk.ModifierType) bool {
42+ h.mu.RLock()
43+ shortcuts := h.shortcuts
44+ h.mu.RUnlock()
45+ if shortcuts == nil {
46+ return false
47+ }
48+ binding := KeyBinding{Keyval: normalizeKeyval(keyval), Modifiers: Modifier(state) & modifierMask}
49+ action, found := shortcuts.Lookup(binding, ModeVim)
50+ return found && action == ActionEnterVimMode
51+}
52+
53+// forwardPageKey sends a Vim Mode key to the active page capture, except the
54+// Vim Mode toggle shortcut, which always falls through. Escape is
39 // forwarded like any other key: the page decides whether it steps back (visual55 // forwarded like any other key: the page decides whether it steps back (visual
40 // to caret) or ends the interaction, and reports the end so the capture is56 // to caret) or ends the interaction, and reports the end so the capture is
41 // released. A page that cannot be reached ends the capture through the57 // released. A page that cannot be reached ends the capture through the
@@ -53,6 +69,10 @@ func (h *KeyboardHandler) forwardPageKey(mode Mode, keyval uint, state gdk.Modif
53 if isModifierKeyval(keyval) {69 if isModifierKeyval(keyval) {
54 return true70 return true
55 }71 }
72+ if h.isVimModeToggleKey(keyval, state) {
73+ // Escape hatch: a page can never swallow the shortcut that leaves Vim Mode.
74+ return false
75+ }
56 key, ok := KeyvalToVimKey(keyval, state)76 key, ok := KeyvalToVimKey(keyval, state)
57 if !ok {77 if !ok {
58 return true78 return true
M internal/ui/input/vim_page_capture_test.go +32 -0
@@ -84,3 +84,35 @@ func TestSetPageKeyCaptureDoesNotNotifyPendingListeners(t *testing.T) {
8484 assert.Empty(t, *pending)
8585 assert.Empty(t, h.PendingSequence())
8686 }
87+
88+// The Vim Mode toggle shortcut is the escape hatch from a page interaction:
89+// it must reach Go, leave Vim Mode and release the capture, never the page.
90+func TestPageKeyCaptureLeavesVimModeToggleShortcutToGo(t *testing.T) {
91+ h := NewKeyboardHandler(context.Background(), vimModeSequenceWorkspace(nil), newTestSession())
92+ enterVimMode(t, h)
93+ var forwarded []string
94+ h.SetPageKeyCapture(func(key string) { forwarded = append(forwarded, key) })
95+ h.handleKeyRelease(uint('y')) // the activation press is over; this is a new press
96+
97+ require.True(t, h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue))
98+
99+ assert.Empty(t, forwarded, "the toggle shortcut must not be forwarded to the page")
100+ assert.Equal(t, ModeNormal, h.Mode())
101+ assert.False(t, h.PageKeyCaptureActive(), "leaving Vim Mode releases the capture")
102+}
103+
104+func TestPageKeyCaptureHonorsConfiguredToggleShortcut(t *testing.T) {
105+ ws := vimModeSequenceWorkspace(nil)
106+ ws.VimMode.ActivationShortcut = "ctrl+g"
107+ h := NewKeyboardHandler(context.Background(), ws, newTestSession())
108+ h.EnterVimMode()
109+ var forwarded []string
110+ h.SetPageKeyCapture(func(key string) { forwarded = append(forwarded, key) })
111+
112+ require.True(t, h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue), "ctrl+y is an ordinary key now")
113+ assert.Equal(t, []string{"<C-y>"}, forwarded)
114+ require.True(t, h.handleKeyPress(uint('g'), 0, gdk.ControlMaskValue))
115+
116+ assert.Equal(t, []string{"<C-y>"}, forwarded)
117+ assert.Equal(t, ModeNormal, h.Mode())
118+}
@@ -84,3 +84,35 @@ func TestSetPageKeyCaptureDoesNotNotifyPendingListeners(t *testing.T) {
84 assert.Empty(t, *pending)84 assert.Empty(t, *pending)
85 assert.Empty(t, h.PendingSequence())85 assert.Empty(t, h.PendingSequence())
86 }86 }
87+
88+// The Vim Mode toggle shortcut is the escape hatch from a page interaction:
89+// it must reach Go, leave Vim Mode and release the capture, never the page.
90+func TestPageKeyCaptureLeavesVimModeToggleShortcutToGo(t *testing.T) {
91+ h := NewKeyboardHandler(context.Background(), vimModeSequenceWorkspace(nil), newTestSession())
92+ enterVimMode(t, h)
93+ var forwarded []string
94+ h.SetPageKeyCapture(func(key string) { forwarded = append(forwarded, key) })
95+ h.handleKeyRelease(uint('y')) // the activation press is over; this is a new press
96+
97+ require.True(t, h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue))
98+
99+ assert.Empty(t, forwarded, "the toggle shortcut must not be forwarded to the page")
100+ assert.Equal(t, ModeNormal, h.Mode())
101+ assert.False(t, h.PageKeyCaptureActive(), "leaving Vim Mode releases the capture")
102+}
103+
104+func TestPageKeyCaptureHonorsConfiguredToggleShortcut(t *testing.T) {
105+ ws := vimModeSequenceWorkspace(nil)
106+ ws.VimMode.ActivationShortcut = "ctrl+g"
107+ h := NewKeyboardHandler(context.Background(), ws, newTestSession())
108+ h.EnterVimMode()
109+ var forwarded []string
110+ h.SetPageKeyCapture(func(key string) { forwarded = append(forwarded, key) })
111+
112+ require.True(t, h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue), "ctrl+y is an ordinary key now")
113+ assert.Equal(t, []string{"<C-y>"}, forwarded)
114+ require.True(t, h.handleKeyPress(uint('g'), 0, gdk.ControlMaskValue))
115+
116+ assert.Equal(t, []string{"<C-y>"}, forwarded)
117+ assert.Equal(t, ModeNormal, h.Mode())
118+}
M internal/ui/vim_mode.go +4 -2
@@ -117,7 +117,9 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to
117117 a.enableAccessibilityForVimMode(ctx, bw)
118118 }
119119 if from == input.ModeVim && to != input.ModeVim {
120- a.endVimPageInteraction(ctx, bw)
120+ // Runs under the modal lock: the window is leaving Vim Mode, so do not
121+ // read the mode back.
122+ a.endVimPageInteraction(ctx, bw, false)
121123 a.clearVimNavigationHighlight(ctx, bw)
122124 }
123125
@@ -144,7 +146,7 @@ func (a *App) transferVimModeOwnershipToPane(ctx context.Context, bw *browserWin
144146 return
145147 }
146148 // A page interaction belongs to the previous owner's WebView.
147- a.endVimPageInteraction(ctx, bw)
149+ a.endVimPageInteraction(ctx, bw, a.vimModeActiveForBrowserWindow(bw))
148150
149151 // Check if this window is actually in vim mode.
150152 if bw.keyboardHandler == nil || bw.keyboardHandler.Mode() != input.ModeVim {
@@ -117,7 +117,9 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to
117 a.enableAccessibilityForVimMode(ctx, bw)117 a.enableAccessibilityForVimMode(ctx, bw)
118 }118 }
119 if from == input.ModeVim && to != input.ModeVim {119 if from == input.ModeVim && to != input.ModeVim {
120- a.endVimPageInteraction(ctx, bw)120+ // Runs under the modal lock: the window is leaving Vim Mode, so do not
121+ // read the mode back.
122+ a.endVimPageInteraction(ctx, bw, false)
121 a.clearVimNavigationHighlight(ctx, bw)123 a.clearVimNavigationHighlight(ctx, bw)
122 }124 }
123 125
@@ -144,7 +146,7 @@ func (a *App) transferVimModeOwnershipToPane(ctx context.Context, bw *browserWin
144 return146 return
145 }147 }
146 // A page interaction belongs to the previous owner's WebView.148 // A page interaction belongs to the previous owner's WebView.
147- a.endVimPageInteraction(ctx, bw)149+ a.endVimPageInteraction(ctx, bw, a.vimModeActiveForBrowserWindow(bw))
148 150
149 // Check if this window is actually in vim mode.151 // Check if this window is actually in vim mode.
150 if bw.keyboardHandler == nil || bw.keyboardHandler.Mode() != input.ModeVim {152 if bw.keyboardHandler == nil || bw.keyboardHandler.Mode() != input.ModeVim {

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close