detached 75d2da2

refactor(ui): move the vim page capture lifecycle and derive labels from the outcome

Move capture, release, ended/mode handling and labels out of app.go into
vim_page.go, share one owner lookup between the ended and mode handlers,
and have the usecase outcome carry the interaction kind and initial
sub-mode so a single mapping produces every indicator label. Wrap usecase
errors with context.
commit
75d2da2fbd8d889199883724364ac7fd2062151c
author
brice · · 2026-10-04 16:12 UTC
committer
brice · · 2026-10-10 08:27 UTC
parent
dfde6a8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +270 -169
M internal/application/dto/vim_page_interaction.go +5 -0
@@ -78,4 +78,9 @@ type VimNavigationOutcome struct {
7878 // CapturePageKeys asks the UI to forward keys to the page interaction until
7979 // the page reports that the interaction ended.
8080 CapturePageKeys bool
81+ // PageKind and PageMode name the key-capturing interaction and the
82+ // sub-mode it starts in, so the UI labels it without knowing actions.
83+ // Both are zero when CapturePageKeys is false.
84+ PageKind VimPageInteractionKind
85+ PageMode VimPageMode
8186 }
@@ -78,4 +78,9 @@ type VimNavigationOutcome struct {
78 // CapturePageKeys asks the UI to forward keys to the page interaction until78 // CapturePageKeys asks the UI to forward keys to the page interaction until
79 // the page reports that the interaction ended.79 // the page reports that the interaction ended.
80 CapturePageKeys bool80 CapturePageKeys bool
81+ // PageKind and PageMode name the key-capturing interaction and the
82+ // sub-mode it starts in, so the UI labels it without knowing actions.
83+ // Both are zero when CapturePageKeys is false.
84+ PageKind VimPageInteractionKind
85+ PageMode VimPageMode
81 }86 }
M internal/application/usecase/vim_navigation.go +16 -4
@@ -3,6 +3,7 @@ package usecase
33 import (
44 "context"
55 "errors"
6+ "fmt"
67
78 "github.com/bnema/dumber/internal/application/dto"
89 "github.com/bnema/dumber/internal/application/port"
@@ -119,9 +120,14 @@ func (*VimNavigationUseCase) startPageInteraction(
119120 return dto.VimNavigationOutcome{}, errUnsupportedVimNavigationEngine
120121 }
121122 if err := interactor.StartVimPageInteraction(ctx, request); err != nil {
122- return dto.VimNavigationOutcome{}, err
123+ return dto.VimNavigationOutcome{}, fmt.Errorf("vim navigation: start page interaction: %w", err)
123124 }
124- return dto.VimNavigationOutcome{CapturePageKeys: request.Kind.CapturesKeys()}, nil
125+ if !request.Kind.CapturesKeys() {
126+ return dto.VimNavigationOutcome{}, nil
127+ }
128+ // Every key-capturing interaction opens on text or link hints; the page
129+ // reports later sub-modes itself.
130+ return dto.VimNavigationOutcome{CapturePageKeys: true, PageKind: request.Kind, PageMode: dto.VimPageModeHints}, nil
125131 }
126132
127133 // SendPageKey forwards one canonical key to the active in-page interaction.
@@ -130,7 +136,10 @@ func (*VimNavigationUseCase) SendPageKey(ctx context.Context, wv port.WebView, k
130136 if !ok {
131137 return errUnsupportedVimNavigationEngine
132138 }
133- return interactor.SendVimPageKey(ctx, key)
139+ if err := interactor.SendVimPageKey(ctx, key); err != nil {
140+ return fmt.Errorf("vim navigation: send page key: %w", err)
141+ }
142+ return nil
134143 }
135144
136145 // CancelPageInteraction ends any in-page interaction, removing its overlay or
@@ -140,7 +149,10 @@ func (*VimNavigationUseCase) CancelPageInteraction(ctx context.Context, wv port.
140149 if !ok {
141150 return nil
142151 }
143- return interactor.CancelVimPageInteraction(ctx)
152+ if err := interactor.CancelVimPageInteraction(ctx); err != nil {
153+ return fmt.Errorf("vim navigation: cancel page interaction: %w", err)
154+ }
155+ return nil
144156 }
145157
146158 // ClearSemanticNavigationHighlight removes the visual target left by semantic
@@ -3,6 +3,7 @@ package usecase
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "fmt"
6 7
7 "github.com/bnema/dumber/internal/application/dto"8 "github.com/bnema/dumber/internal/application/dto"
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
@@ -119,9 +120,14 @@ func (*VimNavigationUseCase) startPageInteraction(
119 return dto.VimNavigationOutcome{}, errUnsupportedVimNavigationEngine120 return dto.VimNavigationOutcome{}, errUnsupportedVimNavigationEngine
120 }121 }
121 if err := interactor.StartVimPageInteraction(ctx, request); err != nil {122 if err := interactor.StartVimPageInteraction(ctx, request); err != nil {
122- return dto.VimNavigationOutcome{}, err123+ return dto.VimNavigationOutcome{}, fmt.Errorf("vim navigation: start page interaction: %w", err)
123 }124 }
124- return dto.VimNavigationOutcome{CapturePageKeys: request.Kind.CapturesKeys()}, nil125+ if !request.Kind.CapturesKeys() {
126+ return dto.VimNavigationOutcome{}, nil
127+ }
128+ // Every key-capturing interaction opens on text or link hints; the page
129+ // reports later sub-modes itself.
130+ return dto.VimNavigationOutcome{CapturePageKeys: true, PageKind: request.Kind, PageMode: dto.VimPageModeHints}, nil
125 }131 }
126 132
127 // SendPageKey forwards one canonical key to the active in-page interaction.133 // SendPageKey forwards one canonical key to the active in-page interaction.
@@ -130,7 +136,10 @@ func (*VimNavigationUseCase) SendPageKey(ctx context.Context, wv port.WebView, k
130 if !ok {136 if !ok {
131 return errUnsupportedVimNavigationEngine137 return errUnsupportedVimNavigationEngine
132 }138 }
133- return interactor.SendVimPageKey(ctx, key)139+ if err := interactor.SendVimPageKey(ctx, key); err != nil {
140+ return fmt.Errorf("vim navigation: send page key: %w", err)
141+ }
142+ return nil
134 }143 }
135 144
136 // CancelPageInteraction ends any in-page interaction, removing its overlay or145 // CancelPageInteraction ends any in-page interaction, removing its overlay or
@@ -140,7 +149,10 @@ func (*VimNavigationUseCase) CancelPageInteraction(ctx context.Context, wv port.
140 if !ok {149 if !ok {
141 return nil150 return nil
142 }151 }
143- return interactor.CancelVimPageInteraction(ctx)152+ if err := interactor.CancelVimPageInteraction(ctx); err != nil {
153+ return fmt.Errorf("vim navigation: cancel page interaction: %w", err)
154+ }
155+ return nil
144 }156 }
145 157
146 // ClearSemanticNavigationHighlight removes the visual target left by semantic158 // ClearSemanticNavigationHighlight removes the visual target left by semantic
M internal/application/usecase/vim_page_interaction_test.go +24 -0
@@ -61,6 +61,13 @@ func TestVimNavigationUseCaseStartsPageInteractions(t *testing.T) {
6161
6262 require.NoError(t, err)
6363 assert.Equal(t, tt.wantCapture, outcome.CapturePageKeys)
64+ if tt.wantCapture {
65+ assert.Equal(t, dto.VimNavigationOutcome{
66+ CapturePageKeys: true, PageKind: tt.want.Kind, PageMode: dto.VimPageModeHints,
67+ }, outcome, "capturing interactions open on hints and name their kind for the label")
68+ } else {
69+ assert.Equal(t, dto.VimNavigationOutcome{}, outcome)
70+ }
6471 })
6572 }
6673 }
@@ -74,6 +81,7 @@ func TestVimNavigationUseCasePageInteractionErrorDoesNotCapture(t *testing.T) {
7481 outcome, err := NewVimNavigationUseCase().Execute(ctx, wv, "visual", 1, "")
7582
7683 require.ErrorIs(t, err, boom)
84+ require.ErrorContains(t, err, "vim navigation: start page interaction")
7785 assert.False(t, outcome.CapturePageKeys)
7886 }
7987
@@ -99,3 +107,19 @@ func TestVimNavigationUseCaseForwardsPageKeysAndCancel(t *testing.T) {
99107 require.NoError(t, uc.SendPageKey(ctx, wv, "J"))
100108 require.NoError(t, uc.CancelPageInteraction(ctx, wv))
101109 }
110+
111+func TestVimNavigationUseCaseWrapsPageKeyAndCancelErrors(t *testing.T) {
112+ ctx := context.Background()
113+ wv, interactor := newInteractiveWebView(t)
114+ boom := errors.New("boom")
115+ interactor.EXPECT().SendVimPageKey(ctx, "j").Return(boom).Once()
116+ interactor.EXPECT().CancelVimPageInteraction(ctx).Return(boom).Once()
117+ uc := NewVimNavigationUseCase()
118+
119+ err := uc.SendPageKey(ctx, wv, "j")
120+ require.ErrorIs(t, err, boom)
121+ require.ErrorContains(t, err, "vim navigation: send page key")
122+ err = uc.CancelPageInteraction(ctx, wv)
123+ require.ErrorIs(t, err, boom)
124+ require.ErrorContains(t, err, "vim navigation: cancel page interaction")
125+}
@@ -61,6 +61,13 @@ func TestVimNavigationUseCaseStartsPageInteractions(t *testing.T) {
61 61
62 require.NoError(t, err)62 require.NoError(t, err)
63 assert.Equal(t, tt.wantCapture, outcome.CapturePageKeys)63 assert.Equal(t, tt.wantCapture, outcome.CapturePageKeys)
64+ if tt.wantCapture {
65+ assert.Equal(t, dto.VimNavigationOutcome{
66+ CapturePageKeys: true, PageKind: tt.want.Kind, PageMode: dto.VimPageModeHints,
67+ }, outcome, "capturing interactions open on hints and name their kind for the label")
68+ } else {
69+ assert.Equal(t, dto.VimNavigationOutcome{}, outcome)
70+ }
64 })71 })
65 }72 }
66 }73 }
@@ -74,6 +81,7 @@ func TestVimNavigationUseCasePageInteractionErrorDoesNotCapture(t *testing.T) {
74 outcome, err := NewVimNavigationUseCase().Execute(ctx, wv, "visual", 1, "")81 outcome, err := NewVimNavigationUseCase().Execute(ctx, wv, "visual", 1, "")
75 82
76 require.ErrorIs(t, err, boom)83 require.ErrorIs(t, err, boom)
84+ require.ErrorContains(t, err, "vim navigation: start page interaction")
77 assert.False(t, outcome.CapturePageKeys)85 assert.False(t, outcome.CapturePageKeys)
78 }86 }
79 87
@@ -99,3 +107,19 @@ func TestVimNavigationUseCaseForwardsPageKeysAndCancel(t *testing.T) {
99 require.NoError(t, uc.SendPageKey(ctx, wv, "J"))107 require.NoError(t, uc.SendPageKey(ctx, wv, "J"))
100 require.NoError(t, uc.CancelPageInteraction(ctx, wv))108 require.NoError(t, uc.CancelPageInteraction(ctx, wv))
101 }109 }
110+
111+func TestVimNavigationUseCaseWrapsPageKeyAndCancelErrors(t *testing.T) {
112+ ctx := context.Background()
113+ wv, interactor := newInteractiveWebView(t)
114+ boom := errors.New("boom")
115+ interactor.EXPECT().SendVimPageKey(ctx, "j").Return(boom).Once()
116+ interactor.EXPECT().CancelVimPageInteraction(ctx).Return(boom).Once()
117+ uc := NewVimNavigationUseCase()
118+
119+ err := uc.SendPageKey(ctx, wv, "j")
120+ require.ErrorIs(t, err, boom)
121+ require.ErrorContains(t, err, "vim navigation: send page key")
122+ err = uc.CancelPageInteraction(ctx, wv)
123+ require.ErrorIs(t, err, boom)
124+ require.ErrorContains(t, err, "vim navigation: cancel page interaction")
125+}
M internal/ui/app.go +1 -136
@@ -2230,7 +2230,7 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow,
22302230 return
22312231 }
22322232 if outcome.CapturePageKeys {
2233- a.captureVimPageKeys(ctx, bw, wv, vimPageInteractionLabel(action))
2233+ a.captureVimPageKeys(ctx, bw, wv, outcome)
22342234 }
22352235 if bw != nil && (action == "heading-next" || action == "heading-prev") {
22362236 if _, ok := wv.(port.SemanticNavigable); ok {
@@ -2246,141 +2246,6 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow,
22462246 }
22472247 }
22482248
2249-// captureVimPageKeys routes this window's Vim Mode keys to the in-page
2250-// interaction running in wv until the page reports its end.
2251-func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port.WebView, label string) {
2252- if bw == nil || bw.keyboardHandler == nil || wv == nil {
2253- return
2254- }
2255- bw.vimPageInteractionWebView = wv
2256- // Capture first: it resets the pending sequence, and the toast and legend
2257- // below must reflect the sub-mode, not a cleared sequence.
2258- bw.keyboardHandler.SetPageKeyCapture(func(key string) {
2259- if err := a.vimNavigationUseCase().SendPageKey(ctx, wv, key); err != nil {
2260- logging.FromContext(ctx).Debug().Err(err).Msg("vim page key forwarding failed")
2261- a.endVimPageInteraction(ctx, bw, a.vimModeActiveForBrowserWindow(bw))
2262- }
2263- })
2264- a.showVimModeToast(ctx, bw, label)
2265- if bw.modeFrame != nil {
2266- bw.modeFrame.setSuspended(true)
2267- }
2268- logging.FromContext(ctx).Debug().
2269- Str("window_id", bw.id).
2270- Str("sub_mode", label).
2271- Msg("vim page interaction capturing keys")
2272-}
2273-
2274-// releaseVimPageKeys returns this window's keys to Vim Mode bindings and
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 {
2280- wv := bw.vimPageInteractionWebView
2281- bw.vimPageInteractionWebView = nil
2282- if bw.keyboardHandler != nil {
2283- bw.keyboardHandler.ClearPageKeyCapture()
2284- }
2285- if wv == nil {
2286- return nil
2287- }
2288- if stillVim {
2289- a.showVimModeToast(ctx, bw, "")
2290- if bw.modeFrame != nil {
2291- bw.modeFrame.setSuspended(false)
2292- }
2293- }
2294- logging.FromContext(ctx).Debug().
2295- Str("window_id", bw.id).
2296- Str("reason", reason).
2297- Msg("vim page interaction released keys")
2298- return wv
2299-}
2300-
2301-// handleVimPageInteractionEnded releases key capture when the page reports
2302-// that its hint or visual interaction finished.
2303-func (a *App) handleVimPageInteractionEnded(ctx context.Context, paneID entity.PaneID) {
2304- bw := a.browserWindowForAnyPane(paneID)
2305- if bw == nil || bw.vimPageInteractionWebView == nil || a.contentCoord == nil {
2306- return
2307- }
2308- if a.contentCoord.GetWebView(paneID) != bw.vimPageInteractionWebView {
2309- return
2310- }
2311- a.releaseVimPageKeys(ctx, bw, "page-reported-end", a.vimModeActiveForBrowserWindow(bw))
2312-}
2313-
2314-// handleVimPageModeChanged repaints the indicator when the page moves its
2315-// interaction to another sub-mode (hints, caret, visual). Key capture and the
2316-// legend suspension stay as they are.
2317-func (a *App) handleVimPageModeChanged(ctx context.Context, paneID entity.PaneID, mode dto.VimPageMode) {
2318- bw := a.browserWindowForAnyPane(paneID)
2319- if bw == nil || bw.vimPageInteractionWebView == nil || a.contentCoord == nil {
2320- return
2321- }
2322- if a.contentCoord.GetWebView(paneID) != bw.vimPageInteractionWebView {
2323- return
2324- }
2325- a.showVimPageMode(ctx, bw, mode)
2326-}
2327-
2328-// showVimPageMode paints the sub-mode label for the interaction owned by bw.
2329-func (a *App) showVimPageMode(ctx context.Context, bw *browserWindow, mode dto.VimPageMode) {
2330- label, ok := vimPageModeLabel(mode)
2331- if !ok || bw == nil || bw.vimPageInteractionWebView == nil {
2332- return
2333- }
2334- a.showVimModeToast(ctx, bw, label)
2335-}
2336-
2337-// vimPageModeLabel names a page-reported sub-mode for the Vim Mode indicator.
2338-func vimPageModeLabel(mode dto.VimPageMode) (string, bool) {
2339- switch mode {
2340- case dto.VimPageModeHints:
2341- return "HINTS", true
2342- case dto.VimPageModeCaret:
2343- return "CARET", true
2344- case dto.VimPageModeVisual:
2345- return "VISUAL", true
2346- case dto.VimPageModeVisualLine:
2347- return "VISUAL LINE", true
2348- default:
2349- return "", false
2350- }
2351-}
2352-
2353-// vimPageInteractionLabel names the key-capturing sub-mode shown in the Vim
2354-// Mode indicator so users can tell hints or visual selection are active. The
2355-// page refines the visual flow's label as it reports sub-mode changes.
2356-func vimPageInteractionLabel(action string) string {
2357- switch action {
2358- case "visual":
2359- return "HINTS"
2360- case "hint-follow-new":
2361- return "HINTS · NEW PANE"
2362- case "hint-yank-url":
2363- return "HINTS · YANK URL"
2364- default:
2365- return "HINTS"
2366- }
2367-}
2368-
2369-// endVimPageInteraction cancels any in-page interaction owned by this window.
2370-// stillVim reports whether the window stays in Vim Mode (see releaseVimPageKeys).
2371-func (a *App) endVimPageInteraction(ctx context.Context, bw *browserWindow, stillVim bool) {
2372- if bw == nil {
2373- return
2374- }
2375- wv := a.releaseVimPageKeys(ctx, bw, "canceled", stillVim)
2376- if wv == nil {
2377- return
2378- }
2379- if err := a.vimNavigationUseCase().CancelPageInteraction(ctx, wv); err != nil {
2380- logging.FromContext(ctx).Debug().Err(err).Msg("failed to cancel vim page interaction")
2381- }
2382-}
2383-
23842249 func (a *App) clearVimNavigationHighlight(ctx context.Context, bw *browserWindow) {
23852250 if bw == nil {
23862251 return
@@ -2230,7 +2230,7 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow,
2230 return2230 return
2231 }2231 }
2232 if outcome.CapturePageKeys {2232 if outcome.CapturePageKeys {
2233- a.captureVimPageKeys(ctx, bw, wv, vimPageInteractionLabel(action))2233+ a.captureVimPageKeys(ctx, bw, wv, outcome)
2234 }2234 }
2235 if bw != nil && (action == "heading-next" || action == "heading-prev") {2235 if bw != nil && (action == "heading-next" || action == "heading-prev") {
2236 if _, ok := wv.(port.SemanticNavigable); ok {2236 if _, ok := wv.(port.SemanticNavigable); ok {
@@ -2246,141 +2246,6 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow,
2246 }2246 }
2247 }2247 }
2248 2248
2249-// captureVimPageKeys routes this window's Vim Mode keys to the in-page
2250-// interaction running in wv until the page reports its end.
2251-func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port.WebView, label string) {
2252- if bw == nil || bw.keyboardHandler == nil || wv == nil {
2253- return
2254- }
2255- bw.vimPageInteractionWebView = wv
2256- // Capture first: it resets the pending sequence, and the toast and legend
2257- // below must reflect the sub-mode, not a cleared sequence.
2258- bw.keyboardHandler.SetPageKeyCapture(func(key string) {
2259- if err := a.vimNavigationUseCase().SendPageKey(ctx, wv, key); err != nil {
2260- logging.FromContext(ctx).Debug().Err(err).Msg("vim page key forwarding failed")
2261- a.endVimPageInteraction(ctx, bw, a.vimModeActiveForBrowserWindow(bw))
2262- }
2263- })
2264- a.showVimModeToast(ctx, bw, label)
2265- if bw.modeFrame != nil {
2266- bw.modeFrame.setSuspended(true)
2267- }
2268- logging.FromContext(ctx).Debug().
2269- Str("window_id", bw.id).
2270- Str("sub_mode", label).
2271- Msg("vim page interaction capturing keys")
2272-}
2273-
2274-// releaseVimPageKeys returns this window's keys to Vim Mode bindings and
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 {
2280- wv := bw.vimPageInteractionWebView
2281- bw.vimPageInteractionWebView = nil
2282- if bw.keyboardHandler != nil {
2283- bw.keyboardHandler.ClearPageKeyCapture()
2284- }
2285- if wv == nil {
2286- return nil
2287- }
2288- if stillVim {
2289- a.showVimModeToast(ctx, bw, "")
2290- if bw.modeFrame != nil {
2291- bw.modeFrame.setSuspended(false)
2292- }
2293- }
2294- logging.FromContext(ctx).Debug().
2295- Str("window_id", bw.id).
2296- Str("reason", reason).
2297- Msg("vim page interaction released keys")
2298- return wv
2299-}
2300-
2301-// handleVimPageInteractionEnded releases key capture when the page reports
2302-// that its hint or visual interaction finished.
2303-func (a *App) handleVimPageInteractionEnded(ctx context.Context, paneID entity.PaneID) {
2304- bw := a.browserWindowForAnyPane(paneID)
2305- if bw == nil || bw.vimPageInteractionWebView == nil || a.contentCoord == nil {
2306- return
2307- }
2308- if a.contentCoord.GetWebView(paneID) != bw.vimPageInteractionWebView {
2309- return
2310- }
2311- a.releaseVimPageKeys(ctx, bw, "page-reported-end", a.vimModeActiveForBrowserWindow(bw))
2312-}
2313-
2314-// handleVimPageModeChanged repaints the indicator when the page moves its
2315-// interaction to another sub-mode (hints, caret, visual). Key capture and the
2316-// legend suspension stay as they are.
2317-func (a *App) handleVimPageModeChanged(ctx context.Context, paneID entity.PaneID, mode dto.VimPageMode) {
2318- bw := a.browserWindowForAnyPane(paneID)
2319- if bw == nil || bw.vimPageInteractionWebView == nil || a.contentCoord == nil {
2320- return
2321- }
2322- if a.contentCoord.GetWebView(paneID) != bw.vimPageInteractionWebView {
2323- return
2324- }
2325- a.showVimPageMode(ctx, bw, mode)
2326-}
2327-
2328-// showVimPageMode paints the sub-mode label for the interaction owned by bw.
2329-func (a *App) showVimPageMode(ctx context.Context, bw *browserWindow, mode dto.VimPageMode) {
2330- label, ok := vimPageModeLabel(mode)
2331- if !ok || bw == nil || bw.vimPageInteractionWebView == nil {
2332- return
2333- }
2334- a.showVimModeToast(ctx, bw, label)
2335-}
2336-
2337-// vimPageModeLabel names a page-reported sub-mode for the Vim Mode indicator.
2338-func vimPageModeLabel(mode dto.VimPageMode) (string, bool) {
2339- switch mode {
2340- case dto.VimPageModeHints:
2341- return "HINTS", true
2342- case dto.VimPageModeCaret:
2343- return "CARET", true
2344- case dto.VimPageModeVisual:
2345- return "VISUAL", true
2346- case dto.VimPageModeVisualLine:
2347- return "VISUAL LINE", true
2348- default:
2349- return "", false
2350- }
2351-}
2352-
2353-// vimPageInteractionLabel names the key-capturing sub-mode shown in the Vim
2354-// Mode indicator so users can tell hints or visual selection are active. The
2355-// page refines the visual flow's label as it reports sub-mode changes.
2356-func vimPageInteractionLabel(action string) string {
2357- switch action {
2358- case "visual":
2359- return "HINTS"
2360- case "hint-follow-new":
2361- return "HINTS · NEW PANE"
2362- case "hint-yank-url":
2363- return "HINTS · YANK URL"
2364- default:
2365- return "HINTS"
2366- }
2367-}
2368-
2369-// endVimPageInteraction cancels any in-page interaction owned by this window.
2370-// stillVim reports whether the window stays in Vim Mode (see releaseVimPageKeys).
2371-func (a *App) endVimPageInteraction(ctx context.Context, bw *browserWindow, stillVim bool) {
2372- if bw == nil {
2373- return
2374- }
2375- wv := a.releaseVimPageKeys(ctx, bw, "canceled", stillVim)
2376- if wv == nil {
2377- return
2378- }
2379- if err := a.vimNavigationUseCase().CancelPageInteraction(ctx, wv); err != nil {
2380- logging.FromContext(ctx).Debug().Err(err).Msg("failed to cancel vim page interaction")
2381- }
2382-}
2383-
2384 func (a *App) clearVimNavigationHighlight(ctx context.Context, bw *browserWindow) {2249 func (a *App) clearVimNavigationHighlight(ctx context.Context, bw *browserWindow) {
2385 if bw == nil {2250 if bw == nil {
2386 return2251 return
M internal/ui/app_vim_page_test.go +64 -6
@@ -42,6 +42,12 @@ func newVimPageTestWindow(t *testing.T) (*App, *browserWindow, *[]string) {
4242 return app, bw, shown
4343 }
4444
45+var visualOutcome = hintsOutcome(dto.VimPageVisual)
46+
47+func hintsOutcome(kind dto.VimPageInteractionKind) dto.VimNavigationOutcome {
48+ return dto.VimNavigationOutcome{CapturePageKeys: true, PageKind: kind, PageMode: dto.VimPageModeHints}
49+}
50+
4551 // Starting a key-capturing interaction must leave the sub-mode label on the
4652 // toast. The capture path used to reset the pending sequence with
4753 // notification, which repainted the toast back to plain "VIM MODE" in the
@@ -50,10 +56,10 @@ func TestCaptureVimPageKeysKeepsSubModeLabelOnToast(t *testing.T) {
5056 app, bw, shown := newVimPageTestWindow(t)
5157 wv := portmocks.NewMockWebView(t)
5258
53- app.captureVimPageKeys(context.Background(), bw, wv, "VISUAL")
59+ app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
5460
5561 require.NotEmpty(t, *shown)
56- require.Equal(t, "VIM MODE · VISUAL", (*shown)[len(*shown)-1])
62+ require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1], "every interaction opens on hints")
5763 require.True(t, bw.keyboardHandler.PageKeyCaptureActive())
5864 }
5965
@@ -65,7 +71,7 @@ func TestVimPageSubModeLabelSurvivesSequenceCompletion(t *testing.T) {
6571 wv := portmocks.NewMockWebView(t)
6672
6773 app.showPendingSequence(context.Background(), bw, "")
68- app.captureVimPageKeys(context.Background(), bw, wv, "HINTS")
74+ app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintFollow))
6975 // Any pending notification emitted while the page owns the keys is noise.
7076 app.showPendingSequence(context.Background(), bw, "")
7177
@@ -81,7 +87,7 @@ func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) {
8187 bw.modeFrame = frame
8288 wv := portmocks.NewMockWebView(t)
8389
84- app.captureVimPageKeys(context.Background(), bw, wv, "VISUAL")
90+ app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
8591 require.True(t, frame.suspended, "legend must be suspended while the page captures keys")
8692
8793 wv.EXPECT().ID().Return(1).Maybe()
@@ -98,7 +104,7 @@ func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) {
98104 frame := &modeFrame{mode: input.ModeVim, visible: true}
99105 bw.modeFrame = frame
100106 wv := portmocks.NewMockWebView(t)
101- app.captureVimPageKeys(context.Background(), bw, wv, vimPageInteractionLabel("visual"))
107+ app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
102108 require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1])
103109
104110 for mode, want := range map[dto.VimPageMode]string{
@@ -148,7 +154,7 @@ func TestExitModeWithPageCaptureDoesNotDeadlock(t *testing.T) {
148154 })
149155 wv := portmocks.NewMockWebView(t)
150156 wv.EXPECT().ID().Return(1).Maybe()
151- app.captureVimPageKeys(context.Background(), bw, wv, "VISUAL")
157+ app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
152158
153159 exitModeWithin(t, bw.keyboardHandler.ExitMode)
154160
@@ -156,3 +162,55 @@ func TestExitModeWithPageCaptureDoesNotDeadlock(t *testing.T) {
156162 require.Nil(t, bw.vimPageInteractionWebView)
157163 require.Equal(t, input.ModeNormal, bw.keyboardHandler.Mode())
158164 }
165+
166+func TestVimPageModeLabel(t *testing.T) {
167+ for mode, want := range map[dto.VimPageMode]string{
168+ dto.VimPageModeHints: "HINTS",
169+ dto.VimPageModeCaret: "CARET",
170+ dto.VimPageModeVisual: "VISUAL",
171+ dto.VimPageModeVisualLine: "VISUAL LINE",
172+ } {
173+ got, ok := vimPageModeLabel(mode)
174+ require.True(t, ok, mode)
175+ require.Equal(t, want, got)
176+ }
177+ _, ok := vimPageModeLabel("bogus")
178+ require.False(t, ok)
179+}
180+
181+// One mapping drives every label: the sub-mode, plus the target of hints that
182+// do more than follow. Later page-reported modes drop the hint target.
183+func TestVimPageLabel(t *testing.T) {
184+ tests := []struct {
185+ kind dto.VimPageInteractionKind
186+ mode dto.VimPageMode
187+ want string
188+ }{
189+ {dto.VimPageVisual, dto.VimPageModeHints, "HINTS"},
190+ {dto.VimPageHintFollow, dto.VimPageModeHints, "HINTS"},
191+ {dto.VimPageHintFollowNew, dto.VimPageModeHints, "HINTS · NEW PANE"},
192+ {dto.VimPageHintYankURL, dto.VimPageModeHints, "HINTS · YANK URL"},
193+ {dto.VimPageVisual, dto.VimPageModeCaret, "CARET"},
194+ {dto.VimPageVisual, dto.VimPageModeVisual, "VISUAL"},
195+ {dto.VimPageVisual, dto.VimPageModeVisualLine, "VISUAL LINE"},
196+ }
197+ for _, tt := range tests {
198+ got, ok := vimPageLabel(tt.kind, tt.mode)
199+ require.True(t, ok)
200+ require.Equal(t, tt.want, got, "kind %d mode %s", tt.kind, tt.mode)
201+ }
202+ _, ok := vimPageLabel(dto.VimPageVisual, "bogus")
203+ require.False(t, ok)
204+}
205+
206+func TestVimPageInteractionLabelsFollowTheOutcome(t *testing.T) {
207+ app, bw, shown := newVimPageTestWindow(t)
208+ wv := portmocks.NewMockWebView(t)
209+
210+ app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintFollowNew))
211+ require.Equal(t, "VIM MODE · HINTS · NEW PANE", (*shown)[len(*shown)-1])
212+ app.releaseVimPageKeys(context.Background(), bw, "test", true)
213+
214+ app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintYankURL))
215+ require.Equal(t, "VIM MODE · HINTS · YANK URL", (*shown)[len(*shown)-1])
216+}
@@ -42,6 +42,12 @@ func newVimPageTestWindow(t *testing.T) (*App, *browserWindow, *[]string) {
42 return app, bw, shown42 return app, bw, shown
43 }43 }
44 44
45+var visualOutcome = hintsOutcome(dto.VimPageVisual)
46+
47+func hintsOutcome(kind dto.VimPageInteractionKind) dto.VimNavigationOutcome {
48+ return dto.VimNavigationOutcome{CapturePageKeys: true, PageKind: kind, PageMode: dto.VimPageModeHints}
49+}
50+
45 // Starting a key-capturing interaction must leave the sub-mode label on the51 // Starting a key-capturing interaction must leave the sub-mode label on the
46 // toast. The capture path used to reset the pending sequence with52 // toast. The capture path used to reset the pending sequence with
47 // notification, which repainted the toast back to plain "VIM MODE" in the53 // notification, which repainted the toast back to plain "VIM MODE" in the
@@ -50,10 +56,10 @@ func TestCaptureVimPageKeysKeepsSubModeLabelOnToast(t *testing.T) {
50 app, bw, shown := newVimPageTestWindow(t)56 app, bw, shown := newVimPageTestWindow(t)
51 wv := portmocks.NewMockWebView(t)57 wv := portmocks.NewMockWebView(t)
52 58
53- app.captureVimPageKeys(context.Background(), bw, wv, "VISUAL")59+ app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
54 60
55 require.NotEmpty(t, *shown)61 require.NotEmpty(t, *shown)
56- require.Equal(t, "VIM MODE · VISUAL", (*shown)[len(*shown)-1])62+ require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1], "every interaction opens on hints")
57 require.True(t, bw.keyboardHandler.PageKeyCaptureActive())63 require.True(t, bw.keyboardHandler.PageKeyCaptureActive())
58 }64 }
59 65
@@ -65,7 +71,7 @@ func TestVimPageSubModeLabelSurvivesSequenceCompletion(t *testing.T) {
65 wv := portmocks.NewMockWebView(t)71 wv := portmocks.NewMockWebView(t)
66 72
67 app.showPendingSequence(context.Background(), bw, "")73 app.showPendingSequence(context.Background(), bw, "")
68- app.captureVimPageKeys(context.Background(), bw, wv, "HINTS")74+ app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintFollow))
69 // Any pending notification emitted while the page owns the keys is noise.75 // Any pending notification emitted while the page owns the keys is noise.
70 app.showPendingSequence(context.Background(), bw, "")76 app.showPendingSequence(context.Background(), bw, "")
71 77
@@ -81,7 +87,7 @@ func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) {
81 bw.modeFrame = frame87 bw.modeFrame = frame
82 wv := portmocks.NewMockWebView(t)88 wv := portmocks.NewMockWebView(t)
83 89
84- app.captureVimPageKeys(context.Background(), bw, wv, "VISUAL")90+ app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
85 require.True(t, frame.suspended, "legend must be suspended while the page captures keys")91 require.True(t, frame.suspended, "legend must be suspended while the page captures keys")
86 92
87 wv.EXPECT().ID().Return(1).Maybe()93 wv.EXPECT().ID().Return(1).Maybe()
@@ -98,7 +104,7 @@ func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) {
98 frame := &modeFrame{mode: input.ModeVim, visible: true}104 frame := &modeFrame{mode: input.ModeVim, visible: true}
99 bw.modeFrame = frame105 bw.modeFrame = frame
100 wv := portmocks.NewMockWebView(t)106 wv := portmocks.NewMockWebView(t)
101- app.captureVimPageKeys(context.Background(), bw, wv, vimPageInteractionLabel("visual"))107+ app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
102 require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1])108 require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1])
103 109
104 for mode, want := range map[dto.VimPageMode]string{110 for mode, want := range map[dto.VimPageMode]string{
@@ -148,7 +154,7 @@ func TestExitModeWithPageCaptureDoesNotDeadlock(t *testing.T) {
148 })154 })
149 wv := portmocks.NewMockWebView(t)155 wv := portmocks.NewMockWebView(t)
150 wv.EXPECT().ID().Return(1).Maybe()156 wv.EXPECT().ID().Return(1).Maybe()
151- app.captureVimPageKeys(context.Background(), bw, wv, "VISUAL")157+ app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
152 158
153 exitModeWithin(t, bw.keyboardHandler.ExitMode)159 exitModeWithin(t, bw.keyboardHandler.ExitMode)
154 160
@@ -156,3 +162,55 @@ func TestExitModeWithPageCaptureDoesNotDeadlock(t *testing.T) {
156 require.Nil(t, bw.vimPageInteractionWebView)162 require.Nil(t, bw.vimPageInteractionWebView)
157 require.Equal(t, input.ModeNormal, bw.keyboardHandler.Mode())163 require.Equal(t, input.ModeNormal, bw.keyboardHandler.Mode())
158 }164 }
165+
166+func TestVimPageModeLabel(t *testing.T) {
167+ for mode, want := range map[dto.VimPageMode]string{
168+ dto.VimPageModeHints: "HINTS",
169+ dto.VimPageModeCaret: "CARET",
170+ dto.VimPageModeVisual: "VISUAL",
171+ dto.VimPageModeVisualLine: "VISUAL LINE",
172+ } {
173+ got, ok := vimPageModeLabel(mode)
174+ require.True(t, ok, mode)
175+ require.Equal(t, want, got)
176+ }
177+ _, ok := vimPageModeLabel("bogus")
178+ require.False(t, ok)
179+}
180+
181+// One mapping drives every label: the sub-mode, plus the target of hints that
182+// do more than follow. Later page-reported modes drop the hint target.
183+func TestVimPageLabel(t *testing.T) {
184+ tests := []struct {
185+ kind dto.VimPageInteractionKind
186+ mode dto.VimPageMode
187+ want string
188+ }{
189+ {dto.VimPageVisual, dto.VimPageModeHints, "HINTS"},
190+ {dto.VimPageHintFollow, dto.VimPageModeHints, "HINTS"},
191+ {dto.VimPageHintFollowNew, dto.VimPageModeHints, "HINTS · NEW PANE"},
192+ {dto.VimPageHintYankURL, dto.VimPageModeHints, "HINTS · YANK URL"},
193+ {dto.VimPageVisual, dto.VimPageModeCaret, "CARET"},
194+ {dto.VimPageVisual, dto.VimPageModeVisual, "VISUAL"},
195+ {dto.VimPageVisual, dto.VimPageModeVisualLine, "VISUAL LINE"},
196+ }
197+ for _, tt := range tests {
198+ got, ok := vimPageLabel(tt.kind, tt.mode)
199+ require.True(t, ok)
200+ require.Equal(t, tt.want, got, "kind %d mode %s", tt.kind, tt.mode)
201+ }
202+ _, ok := vimPageLabel(dto.VimPageVisual, "bogus")
203+ require.False(t, ok)
204+}
205+
206+func TestVimPageInteractionLabelsFollowTheOutcome(t *testing.T) {
207+ app, bw, shown := newVimPageTestWindow(t)
208+ wv := portmocks.NewMockWebView(t)
209+
210+ app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintFollowNew))
211+ require.Equal(t, "VIM MODE · HINTS · NEW PANE", (*shown)[len(*shown)-1])
212+ app.releaseVimPageKeys(context.Background(), bw, "test", true)
213+
214+ app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintYankURL))
215+ require.Equal(t, "VIM MODE · HINTS · YANK URL", (*shown)[len(*shown)-1])
216+}
M internal/ui/browser_window.go +4 -0
@@ -6,6 +6,7 @@ import (
66 "slices"
77 "sort"
88
9+ "github.com/bnema/dumber/internal/application/dto"
910 "github.com/bnema/dumber/internal/application/port"
1011 "github.com/bnema/dumber/internal/application/usecase"
1112 "github.com/bnema/dumber/internal/domain/entity"
@@ -62,6 +63,8 @@ type browserWindow struct {
6263 // vimPageInteractionWebView is the WebView running link hints or visual
6364 // selection while this window's keys are captured for the page.
6465 vimPageInteractionWebView port.WebView
66+ // vimPageInteractionKind names that interaction for its indicator label.
67+ vimPageInteractionKind dto.VimPageInteractionKind
6568 }
6669
6770 func (bw *browserWindow) detachInputForDestroy() {
@@ -125,6 +128,7 @@ func (bw *browserWindow) clearShellState() {
125128 bw.vimNavigationHighlightedWebViews = nil
126129 bw.vimNavigationHighlightedWebViewIDs = nil
127130 bw.vimPageInteractionWebView = nil
131+ bw.vimPageInteractionKind = 0
128132 }
129133
130134 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
@@ -6,6 +6,7 @@ import (
6 "slices"6 "slices"
7 "sort"7 "sort"
8 8
9+ "github.com/bnema/dumber/internal/application/dto"
9 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/application/usecase"11 "github.com/bnema/dumber/internal/application/usecase"
11 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
@@ -62,6 +63,8 @@ type browserWindow struct {
62 // vimPageInteractionWebView is the WebView running link hints or visual63 // vimPageInteractionWebView is the WebView running link hints or visual
63 // selection while this window's keys are captured for the page.64 // selection while this window's keys are captured for the page.
64 vimPageInteractionWebView port.WebView65 vimPageInteractionWebView port.WebView
66+ // vimPageInteractionKind names that interaction for its indicator label.
67+ vimPageInteractionKind dto.VimPageInteractionKind
65 }68 }
66 69
67 func (bw *browserWindow) detachInputForDestroy() {70 func (bw *browserWindow) detachInputForDestroy() {
@@ -125,6 +128,7 @@ func (bw *browserWindow) clearShellState() {
125 bw.vimNavigationHighlightedWebViews = nil128 bw.vimNavigationHighlightedWebViews = nil
126 bw.vimNavigationHighlightedWebViewIDs = nil129 bw.vimNavigationHighlightedWebViewIDs = nil
127 bw.vimPageInteractionWebView = nil130 bw.vimPageInteractionWebView = nil
131+ bw.vimPageInteractionKind = 0
128 }132 }
129 133
130 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {134 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
M internal/ui/mode_frame_test.go +0 -23
@@ -1,7 +1,6 @@
11 package ui
22
33 import (
4- "github.com/bnema/dumber/internal/application/dto"
54 "testing"
65
76 "github.com/bnema/dumber/internal/domain/entity"
@@ -157,25 +156,3 @@ func TestShouldStartLinger(t *testing.T) {
157156 require.False(t, shouldStartLinger(true, true, true, true, 15, 25, rect))
158157 require.False(t, shouldStartLinger(false, false, true, true, 15, 25, rect))
159158 }
160-
161-func TestVimPageModeLabel(t *testing.T) {
162- for mode, want := range map[dto.VimPageMode]string{
163- dto.VimPageModeHints: "HINTS",
164- dto.VimPageModeCaret: "CARET",
165- dto.VimPageModeVisual: "VISUAL",
166- dto.VimPageModeVisualLine: "VISUAL LINE",
167- } {
168- got, ok := vimPageModeLabel(mode)
169- require.True(t, ok, mode)
170- require.Equal(t, want, got)
171- }
172- _, ok := vimPageModeLabel("bogus")
173- require.False(t, ok)
174-}
175-
176-func TestVimPageInteractionLabel(t *testing.T) {
177- require.Equal(t, "HINTS", vimPageInteractionLabel("visual"), "visual starts on text-anchor hints; the page reports later states")
178- require.Equal(t, "HINTS", vimPageInteractionLabel("hint-follow"))
179- require.Equal(t, "HINTS · NEW PANE", vimPageInteractionLabel("hint-follow-new"))
180- require.Equal(t, "HINTS · YANK URL", vimPageInteractionLabel("hint-yank-url"))
181-}
@@ -1,7 +1,6 @@
1 package ui1 package ui
2 2
3 import (3 import (
4- "github.com/bnema/dumber/internal/application/dto"
5 "testing"4 "testing"
6 5
7 "github.com/bnema/dumber/internal/domain/entity"6 "github.com/bnema/dumber/internal/domain/entity"
@@ -157,25 +156,3 @@ func TestShouldStartLinger(t *testing.T) {
157 require.False(t, shouldStartLinger(true, true, true, true, 15, 25, rect))156 require.False(t, shouldStartLinger(true, true, true, true, 15, 25, rect))
158 require.False(t, shouldStartLinger(false, false, true, true, 15, 25, rect))157 require.False(t, shouldStartLinger(false, false, true, true, 15, 25, rect))
159 }158 }
160-
161-func TestVimPageModeLabel(t *testing.T) {
162- for mode, want := range map[dto.VimPageMode]string{
163- dto.VimPageModeHints: "HINTS",
164- dto.VimPageModeCaret: "CARET",
165- dto.VimPageModeVisual: "VISUAL",
166- dto.VimPageModeVisualLine: "VISUAL LINE",
167- } {
168- got, ok := vimPageModeLabel(mode)
169- require.True(t, ok, mode)
170- require.Equal(t, want, got)
171- }
172- _, ok := vimPageModeLabel("bogus")
173- require.False(t, ok)
174-}
175-
176-func TestVimPageInteractionLabel(t *testing.T) {
177- require.Equal(t, "HINTS", vimPageInteractionLabel("visual"), "visual starts on text-anchor hints; the page reports later states")
178- require.Equal(t, "HINTS", vimPageInteractionLabel("hint-follow"))
179- require.Equal(t, "HINTS · NEW PANE", vimPageInteractionLabel("hint-follow-new"))
180- require.Equal(t, "HINTS · YANK URL", vimPageInteractionLabel("hint-yank-url"))
181-}
A internal/ui/vim_page.go +156 -0
@@ -0,0 +1,156 @@
1+package ui
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// captureVimPageKeys routes this window's Vim Mode keys to the in-page
13+// interaction running in wv until the page reports its end. The outcome names
14+// the interaction and the sub-mode it starts in.
15+func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port.WebView, outcome dto.VimNavigationOutcome) {
16+ if bw == nil || bw.keyboardHandler == nil || wv == nil {
17+ return
18+ }
19+ bw.vimPageInteractionWebView = wv
20+ bw.vimPageInteractionKind = outcome.PageKind
21+ label, _ := vimPageLabel(outcome.PageKind, outcome.PageMode)
22+ // Capture first: it resets the pending sequence, and the toast and legend
23+ // below must reflect the sub-mode, not a cleared sequence.
24+ bw.keyboardHandler.SetPageKeyCapture(func(key string) {
25+ if err := a.vimNavigationUseCase().SendPageKey(ctx, wv, key); err != nil {
26+ logging.FromContext(ctx).Debug().Err(err).Msg("vim page key forwarding failed")
27+ a.endVimPageInteraction(ctx, bw, a.vimModeActiveForBrowserWindow(bw))
28+ }
29+ })
30+ a.showVimModeToast(ctx, bw, label)
31+ if bw.modeFrame != nil {
32+ bw.modeFrame.setSuspended(true)
33+ }
34+ logging.FromContext(ctx).Debug().
35+ Str("window_id", bw.id).
36+ Str("sub_mode", label).
37+ Msg("vim page interaction capturing keys")
38+}
39+
40+// releaseVimPageKeys returns this window's keys to Vim Mode bindings and
41+// restores the plain indicator and legend when stillVim reports the window
42+// remains in Vim Mode. The caller supplies it because the mode-change callback
43+// runs under the modal lock, where reading the mode back would deadlock.
44+// It reports the WebView that owned the interaction, if any.
45+func (a *App) releaseVimPageKeys(ctx context.Context, bw *browserWindow, reason string, stillVim bool) port.WebView {
46+ wv := bw.vimPageInteractionWebView
47+ bw.vimPageInteractionWebView = nil
48+ bw.vimPageInteractionKind = 0
49+ if bw.keyboardHandler != nil {
50+ bw.keyboardHandler.ClearPageKeyCapture()
51+ }
52+ if wv == nil {
53+ return nil
54+ }
55+ if stillVim {
56+ a.showVimModeToast(ctx, bw, "")
57+ if bw.modeFrame != nil {
58+ bw.modeFrame.setSuspended(false)
59+ }
60+ }
61+ logging.FromContext(ctx).Debug().
62+ Str("window_id", bw.id).
63+ Str("reason", reason).
64+ Msg("vim page interaction released keys")
65+ return wv
66+}
67+
68+// vimPageInteractionOwner returns the window whose key capture belongs to the
69+// interaction running in paneID's WebView, or nil when that pane owns none.
70+func (a *App) vimPageInteractionOwner(paneID entity.PaneID) *browserWindow {
71+ bw := a.browserWindowForAnyPane(paneID)
72+ if bw == nil || bw.vimPageInteractionWebView == nil || a.contentCoord == nil {
73+ return nil
74+ }
75+ if a.contentCoord.GetWebView(paneID) != bw.vimPageInteractionWebView {
76+ return nil
77+ }
78+ return bw
79+}
80+
81+// handleVimPageInteractionEnded releases key capture when the page reports
82+// that its hint or visual interaction finished.
83+func (a *App) handleVimPageInteractionEnded(ctx context.Context, paneID entity.PaneID) {
84+ bw := a.vimPageInteractionOwner(paneID)
85+ if bw == nil {
86+ return
87+ }
88+ a.releaseVimPageKeys(ctx, bw, "page-reported-end", a.vimModeActiveForBrowserWindow(bw))
89+}
90+
91+// handleVimPageModeChanged repaints the indicator when the page moves its
92+// interaction to another sub-mode (hints, caret, visual). Key capture and the
93+// legend suspension stay as they are.
94+func (a *App) handleVimPageModeChanged(ctx context.Context, paneID entity.PaneID, mode dto.VimPageMode) {
95+ if bw := a.vimPageInteractionOwner(paneID); bw != nil {
96+ a.showVimPageMode(ctx, bw, mode)
97+ }
98+}
99+
100+// showVimPageMode paints the sub-mode label for the interaction owned by bw.
101+func (a *App) showVimPageMode(ctx context.Context, bw *browserWindow, mode dto.VimPageMode) {
102+ if bw.vimPageInteractionWebView == nil {
103+ return
104+ }
105+ if label, ok := vimPageLabel(bw.vimPageInteractionKind, mode); ok {
106+ a.showVimModeToast(ctx, bw, label)
107+ }
108+}
109+
110+// vimPageModeLabel names a page-reported sub-mode for the Vim Mode indicator.
111+func vimPageModeLabel(mode dto.VimPageMode) (string, bool) {
112+ switch mode {
113+ case dto.VimPageModeHints:
114+ return "HINTS", true
115+ case dto.VimPageModeCaret:
116+ return "CARET", true
117+ case dto.VimPageModeVisual:
118+ return "VISUAL", true
119+ case dto.VimPageModeVisualLine:
120+ return "VISUAL LINE", true
121+ default:
122+ return "", false
123+ }
124+}
125+
126+// vimPageLabel is the single source of the indicator label: the sub-mode name,
127+// plus the target of link hints that do more than follow (new pane, yank URL).
128+func vimPageLabel(kind dto.VimPageInteractionKind, mode dto.VimPageMode) (string, bool) {
129+ label, ok := vimPageModeLabel(mode)
130+ if !ok || mode != dto.VimPageModeHints {
131+ return label, ok
132+ }
133+ switch kind {
134+ case dto.VimPageHintFollowNew:
135+ return label + " · NEW PANE", true
136+ case dto.VimPageHintYankURL:
137+ return label + " · YANK URL", true
138+ default:
139+ return label, true
140+ }
141+}
142+
143+// endVimPageInteraction cancels any in-page interaction owned by this window.
144+// stillVim reports whether the window stays in Vim Mode (see releaseVimPageKeys).
145+func (a *App) endVimPageInteraction(ctx context.Context, bw *browserWindow, stillVim bool) {
146+ if bw == nil {
147+ return
148+ }
149+ wv := a.releaseVimPageKeys(ctx, bw, "canceled", stillVim)
150+ if wv == nil {
151+ return
152+ }
153+ if err := a.vimNavigationUseCase().CancelPageInteraction(ctx, wv); err != nil {
154+ logging.FromContext(ctx).Debug().Err(err).Msg("failed to cancel vim page interaction")
155+ }
156+}
@@ -0,0 +1,156 @@
1+package ui
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// captureVimPageKeys routes this window's Vim Mode keys to the in-page
13+// interaction running in wv until the page reports its end. The outcome names
14+// the interaction and the sub-mode it starts in.
15+func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port.WebView, outcome dto.VimNavigationOutcome) {
16+ if bw == nil || bw.keyboardHandler == nil || wv == nil {
17+ return
18+ }
19+ bw.vimPageInteractionWebView = wv
20+ bw.vimPageInteractionKind = outcome.PageKind
21+ label, _ := vimPageLabel(outcome.PageKind, outcome.PageMode)
22+ // Capture first: it resets the pending sequence, and the toast and legend
23+ // below must reflect the sub-mode, not a cleared sequence.
24+ bw.keyboardHandler.SetPageKeyCapture(func(key string) {
25+ if err := a.vimNavigationUseCase().SendPageKey(ctx, wv, key); err != nil {
26+ logging.FromContext(ctx).Debug().Err(err).Msg("vim page key forwarding failed")
27+ a.endVimPageInteraction(ctx, bw, a.vimModeActiveForBrowserWindow(bw))
28+ }
29+ })
30+ a.showVimModeToast(ctx, bw, label)
31+ if bw.modeFrame != nil {
32+ bw.modeFrame.setSuspended(true)
33+ }
34+ logging.FromContext(ctx).Debug().
35+ Str("window_id", bw.id).
36+ Str("sub_mode", label).
37+ Msg("vim page interaction capturing keys")
38+}
39+
40+// releaseVimPageKeys returns this window's keys to Vim Mode bindings and
41+// restores the plain indicator and legend when stillVim reports the window
42+// remains in Vim Mode. The caller supplies it because the mode-change callback
43+// runs under the modal lock, where reading the mode back would deadlock.
44+// It reports the WebView that owned the interaction, if any.
45+func (a *App) releaseVimPageKeys(ctx context.Context, bw *browserWindow, reason string, stillVim bool) port.WebView {
46+ wv := bw.vimPageInteractionWebView
47+ bw.vimPageInteractionWebView = nil
48+ bw.vimPageInteractionKind = 0
49+ if bw.keyboardHandler != nil {
50+ bw.keyboardHandler.ClearPageKeyCapture()
51+ }
52+ if wv == nil {
53+ return nil
54+ }
55+ if stillVim {
56+ a.showVimModeToast(ctx, bw, "")
57+ if bw.modeFrame != nil {
58+ bw.modeFrame.setSuspended(false)
59+ }
60+ }
61+ logging.FromContext(ctx).Debug().
62+ Str("window_id", bw.id).
63+ Str("reason", reason).
64+ Msg("vim page interaction released keys")
65+ return wv
66+}
67+
68+// vimPageInteractionOwner returns the window whose key capture belongs to the
69+// interaction running in paneID's WebView, or nil when that pane owns none.
70+func (a *App) vimPageInteractionOwner(paneID entity.PaneID) *browserWindow {
71+ bw := a.browserWindowForAnyPane(paneID)
72+ if bw == nil || bw.vimPageInteractionWebView == nil || a.contentCoord == nil {
73+ return nil
74+ }
75+ if a.contentCoord.GetWebView(paneID) != bw.vimPageInteractionWebView {
76+ return nil
77+ }
78+ return bw
79+}
80+
81+// handleVimPageInteractionEnded releases key capture when the page reports
82+// that its hint or visual interaction finished.
83+func (a *App) handleVimPageInteractionEnded(ctx context.Context, paneID entity.PaneID) {
84+ bw := a.vimPageInteractionOwner(paneID)
85+ if bw == nil {
86+ return
87+ }
88+ a.releaseVimPageKeys(ctx, bw, "page-reported-end", a.vimModeActiveForBrowserWindow(bw))
89+}
90+
91+// handleVimPageModeChanged repaints the indicator when the page moves its
92+// interaction to another sub-mode (hints, caret, visual). Key capture and the
93+// legend suspension stay as they are.
94+func (a *App) handleVimPageModeChanged(ctx context.Context, paneID entity.PaneID, mode dto.VimPageMode) {
95+ if bw := a.vimPageInteractionOwner(paneID); bw != nil {
96+ a.showVimPageMode(ctx, bw, mode)
97+ }
98+}
99+
100+// showVimPageMode paints the sub-mode label for the interaction owned by bw.
101+func (a *App) showVimPageMode(ctx context.Context, bw *browserWindow, mode dto.VimPageMode) {
102+ if bw.vimPageInteractionWebView == nil {
103+ return
104+ }
105+ if label, ok := vimPageLabel(bw.vimPageInteractionKind, mode); ok {
106+ a.showVimModeToast(ctx, bw, label)
107+ }
108+}
109+
110+// vimPageModeLabel names a page-reported sub-mode for the Vim Mode indicator.
111+func vimPageModeLabel(mode dto.VimPageMode) (string, bool) {
112+ switch mode {
113+ case dto.VimPageModeHints:
114+ return "HINTS", true
115+ case dto.VimPageModeCaret:
116+ return "CARET", true
117+ case dto.VimPageModeVisual:
118+ return "VISUAL", true
119+ case dto.VimPageModeVisualLine:
120+ return "VISUAL LINE", true
121+ default:
122+ return "", false
123+ }
124+}
125+
126+// vimPageLabel is the single source of the indicator label: the sub-mode name,
127+// plus the target of link hints that do more than follow (new pane, yank URL).
128+func vimPageLabel(kind dto.VimPageInteractionKind, mode dto.VimPageMode) (string, bool) {
129+ label, ok := vimPageModeLabel(mode)
130+ if !ok || mode != dto.VimPageModeHints {
131+ return label, ok
132+ }
133+ switch kind {
134+ case dto.VimPageHintFollowNew:
135+ return label + " · NEW PANE", true
136+ case dto.VimPageHintYankURL:
137+ return label + " · YANK URL", true
138+ default:
139+ return label, true
140+ }
141+}
142+
143+// endVimPageInteraction cancels any in-page interaction owned by this window.
144+// stillVim reports whether the window stays in Vim Mode (see releaseVimPageKeys).
145+func (a *App) endVimPageInteraction(ctx context.Context, bw *browserWindow, stillVim bool) {
146+ if bw == nil {
147+ return
148+ }
149+ wv := a.releaseVimPageKeys(ctx, bw, "canceled", stillVim)
150+ if wv == nil {
151+ return
152+ }
153+ if err := a.vimNavigationUseCase().CancelPageInteraction(ctx, wv); err != nil {
154+ logging.FromContext(ctx).Debug().Err(err).Msg("failed to cancel vim page interaction")
155+ }
156+}

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