v0.33.2 57f21d6

vim_mode.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"time"
6
7	"github.com/bnema/dumber/internal/application/usecase"
8	"github.com/bnema/dumber/internal/domain/entity"
9	"github.com/bnema/dumber/internal/logging"
10	"github.com/bnema/dumber/internal/ui/input"
11)
12
13func (a *App) vimModePolicy() *usecase.VimModePolicyUseCase {
14	if a == nil {
15		return usecase.NewVimModePolicyUseCase()
16	}
17	if a.vimModePolicyUC == nil {
18		a.vimModePolicyUC = usecase.NewVimModePolicyUseCase()
19	}
20	return a.vimModePolicyUC
21}
22
23func (a *App) pageEditableFocused(paneID entity.PaneID) bool {
24	return a != nil && a.vimMode.editableFocused(paneID)
25}
26
27func (a *App) setPageEditableFocused(paneID entity.PaneID, editable bool) {
28	if a != nil {
29		a.vimMode.setEditableFocused(paneID, editable)
30	}
31}
32
33func (a *App) clearPageEditableFocusState(paneID entity.PaneID) {
34	a.setPageEditableFocused(paneID, false)
35}
36
37func (a *App) vimModeActiveForBrowserWindow(bw *browserWindow) bool {
38	return bw != nil && bw.keyboardHandler != nil && bw.keyboardHandler.Mode() == input.ModeVim
39}
40
41func (a *App) applyVimModePolicyTransition(_ context.Context, bw *browserWindow, transition usecase.VimModePolicyTransition) {
42	if transition != usecase.VimModePolicyTransitionExit || bw == nil || bw.keyboardHandler == nil {
43		return
44	}
45	if bw.keyboardHandler.Mode() != input.ModeVim {
46		return
47	}
48	bw.keyboardHandler.ExitMode()
49}
50
51func (a *App) handleVimModeFocusTrigger(ctx context.Context, bw *browserWindow, trigger usecase.VimModePolicyTrigger) {
52	if bw == nil {
53		return
54	}
55	transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{
56		Trigger:       trigger,
57		VimModeActive: a.vimModeActiveForBrowserWindow(bw),
58	})
59	a.applyVimModePolicyTransition(ctx, bw, transition)
60}
61
62func (a *App) handleVimModeTabSwitch(_ context.Context, bw *browserWindow) {
63	if bw == nil || bw.tabs == nil || !a.vimModeActiveForBrowserWindow(bw) {
64		return
65	}
66	transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{
67		Trigger:                 usecase.VimModePolicyTriggerContextChanged,
68		VimModeActive:           true,
69		PreserveOnContextChange: false,
70	})
71	if transition != usecase.VimModePolicyTransitionExit {
72		return
73	}
74	if prevTabID := bw.tabs.PreviousActiveTabID; prevTabID != "" {
75		if prevView := a.workspaceViews[prevTabID]; prevView != nil {
76			if pv := prevView.GetPaneView(bw.vimModePaneID); pv != nil {
77				pv.SetVimMode(false)
78			}
79		}
80	}
81	bw.vimModePaneID = ""
82	bw.keyboardHandler.ExitMode()
83}
84
85func (a *App) handlePageEditableFocusChanged(ctx context.Context, paneID entity.PaneID, editable bool) {
86	if paneID == "" {
87		return
88	}
89	a.setPageEditableFocused(paneID, editable)
90
91	bw := a.browserWindowForAnyPane(paneID)
92	if bw == nil {
93		return
94	}
95	ws := a.activeWorkspaceForBrowserWindow(bw)
96	activeContext := ws != nil && ws.ActivePaneID == paneID && a.lastFocusedBrowserWindow() == bw
97	transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{
98		Trigger:              usecase.VimModePolicyTriggerPageEditableFocusChanged,
99		VimModeActive:        a.vimModeActiveForBrowserWindow(bw),
100		PageEditableFocused:  editable,
101		EventInActiveContext: activeContext,
102	})
103	a.applyVimModePolicyTransition(ctx, bw, transition)
104}
105
106func (*App) shouldBypassVimModeActivation(_ *browserWindow) bool {
107	return false
108}
109
110// handleModeChange is called when the input mode changes for a specific browser window.
111func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to input.Mode) {
112	log := logging.FromContext(ctx)
113	log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
114
115	if to == input.ModeVim && from != input.ModeVim {
116		a.enableAccessibilityForVimMode(ctx, bw)
117	}
118	if from == input.ModeVim && to != input.ModeVim {
119		// Runs under the modal lock: the window is leaving Vim Mode, so do not
120		// read the mode back.
121		a.endVimPageInteraction(ctx, bw, false)
122		a.clearVimNavigationHighlight(ctx, bw)
123	}
124
125	// Handle pane-local Vim Mode visual ownership.
126	// Entering Vim Mode accents the active pane; leaving removes the accent.
127	a.handleVimModeOwnership(ctx, bw, to, from)
128
129	// Retarget the border and reconcile the mode toaster (indicator + legend).
130	a.updateModeFrame(bw, to)
131}
132
133// transferVimModeOwnershipToPane transfers the pane-local Vim Mode accent
134// from the current owning pane to another pane in the same browser window.
135// The transfer only happens while that window is in Vim Mode.
136func (a *App) transferVimModeOwnershipToPane(ctx context.Context, bw *browserWindow, newPaneID entity.PaneID) {
137	if bw == nil {
138		return
139	}
140
141	// Only transfer if vim mode is currently active and the owner is changing.
142	if bw.vimModePaneID == "" || bw.vimModePaneID == newPaneID {
143		return
144	}
145	// A page interaction belongs to the previous owner's WebView.
146	a.endVimPageInteraction(ctx, bw, a.vimModeActiveForBrowserWindow(bw))
147
148	// Check if this window is actually in vim mode.
149	if bw.keyboardHandler == nil || bw.keyboardHandler.Mode() != input.ModeVim {
150		// Vim mode not active on this window; just clear stale ownership.
151		bw.vimModePaneID = ""
152		return
153	}
154
155	wsView := a.activeWorkspaceViewForBrowserWindow(bw)
156	if wsView == nil {
157		return
158	}
159
160	transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{
161		Trigger:                 usecase.VimModePolicyTriggerContextChanged,
162		VimModeActive:           true,
163		PreserveOnContextChange: !a.pageEditableFocused(newPaneID),
164	})
165	if transition == usecase.VimModePolicyTransitionExit {
166		a.applyVimModePolicyTransition(ctx, bw, transition)
167		return
168	}
169
170	oldPaneID := bw.vimModePaneID
171
172	// Deactivate the old owning pane.
173	if oldPV := wsView.GetPaneView(oldPaneID); oldPV != nil {
174		oldPV.SetVimMode(false)
175	}
176
177	// Activate the new pane.
178	if newPV := wsView.GetPaneView(newPaneID); newPV != nil {
179		newPV.SetVimMode(true)
180	}
181	bw.vimModePaneID = newPaneID
182
183	logging.FromContext(ctx).Debug().
184		Str("window_id", bw.id).
185		Str("old_pane_id", string(oldPaneID)).
186		Str("new_pane_id", string(newPaneID)).
187		Msg("vim mode ownership transferred")
188}
189
190// handleVimModeOwnership manages the pane-local Vim Mode accent and pulse
191// owner for a specific browser window when input modes change.
192func (a *App) handleVimModeOwnership(ctx context.Context, bw *browserWindow, to, from input.Mode) {
193	if bw == nil {
194		return
195	}
196
197	ws := a.activeWorkspaceForBrowserWindow(bw)
198	wsView := a.activeWorkspaceViewForBrowserWindow(bw)
199	if ws == nil || wsView == nil {
200		// If there's no workspace yet (startup), just track the intent.
201		if to != input.ModeVim && from == input.ModeVim {
202			bw.vimModePaneID = ""
203		}
204		return
205	}
206
207	if to == input.ModeVim && from != input.ModeVim {
208		// Entering vim mode: activate on the current active pane of this window.
209		paneID := ws.ActivePaneID
210		if pv := wsView.GetPaneView(paneID); pv != nil {
211			pv.SetVimMode(true)
212			bw.vimModePaneID = paneID
213			logging.FromContext(ctx).Debug().
214				Str("window_id", bw.id).
215				Str("pane_id", string(paneID)).
216				Msg("vim mode activated on pane")
217		}
218	} else if from == input.ModeVim && to != input.ModeVim {
219		// Leaving Vim Mode: deactivate the pane that owns the visual accent.
220		a.clearVimModeOwnership(ctx, bw)
221	}
222}
223
224// triggerVimModePulse triggers a pane-local vim mode pulse on the
225// last-focused browser window's owning pane. This is called by the
226// keyboard dispatcher after a page scroll action. The bw used is the
227// window that was active when the action was dispatched (via
228// dispatchBrowserWindowAction which calls activateBrowserWindow first).
229// If no pane is currently in vim mode, the pulse is a no-op.
230//
231// Pulses are debounced at vimModePulseInterval to prevent excessive GTK CSS
232// class churn during held-key repeats. Smooth scroll cadence is owned by the
233// Vim Mode repeater and backend scroll path; pulse feedback is deliberately
234// slower so indicator/overlay animation work does not become part of the
235// scrolling critical path.
236func (a *App) triggerVimModePulse(_ context.Context, fast bool) {
237	// Debounce: skip if called again too soon during a continuous hold.
238	// We still serialize the timestamp update so repeated dispatcher calls do
239	// not restart CSS animations at scroll cadence.
240	if !a.vimMode.allowPulse(time.Now()) {
241		return
242	}
243
244	bw := a.lastFocusedBrowserWindow()
245	if bw == nil || bw.vimModePaneID == "" {
246		return
247	}
248	if bw.modeFrame != nil {
249		bw.modeFrame.pulse(fast)
250	}
251}
252
253// clearVimModeOwnership deactivates vim mode on the owning pane for a
254// specific browser window and resets its tracked pane ID.
255// Also resets the pulse debounce timer so the first pulse on re-entry
256// is never skipped.
257func (a *App) clearVimModeOwnership(ctx context.Context, bw *browserWindow) {
258	if bw == nil || bw.vimModePaneID == "" {
259		return
260	}
261
262	wsView := a.activeWorkspaceViewForBrowserWindow(bw)
263	if wsView != nil {
264		if pv := wsView.GetPaneView(bw.vimModePaneID); pv != nil {
265			pv.SetVimMode(false)
266			logging.FromContext(ctx).Debug().
267				Str("window_id", bw.id).
268				Str("pane_id", string(bw.vimModePaneID)).
269				Msg("vim mode deactivated on pane")
270		}
271	}
272	bw.vimModePaneID = ""
273
274	a.vimMode.resetPulse()
275}
276
277// bindVimModeSequenceToaster wires pending Vim-sequence text onto this
278// window's modeToaster only (never appToaster / pane toasters).
279func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow) {
280	if a == nil || bw == nil || bw.keyboardHandler == nil {
281		return
282	}
283	bw.keyboardHandler.SetOnPendingSequenceChange(func(pending string) {
284		a.showPendingSequence(ctx, bw, pending)
285	})
286	logging.FromContext(ctx).Debug().
287		Str("window_id", bw.id).
288		Msg("vim mode pending sequence toaster bound")
289}
290
291// showPendingSequence shows "VIM MODE · pending" on the per-window modeToaster
292// while a sequence is incomplete, and restores stable "VIM MODE" when pending
293// clears. Mode exit is handled by handleModeChange/updateModeFrame;
294// silent reset under ModalState avoids stale pending callbacks.
295func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pending string) {
296	if a == nil || bw == nil {
297		return
298	}
299	if bw.vimPageInteractionWebView != nil {
300		// The page owns the keys: the indicator shows the interaction's
301		// sub-mode, and the legend is suspended. Nothing to repaint.
302		return
303	}
304	if bw.modeToaster == nil {
305		return
306	}
307
308	bw.modeToaster.setPending(pending)
309	logging.FromContext(ctx).Debug().
310		Str("window_id", bw.id).
311		Str("pending", pending).
312		Msg("vim mode pending toaster updated")
313}
314
315// showVimModeToast shows "VIM MODE" with an optional " · detail" suffix on the
316// per-window mode toaster.
317func (a *App) showVimModeToast(_ context.Context, bw *browserWindow, detail string) {
318	if bw == nil || bw.modeToaster == nil {
319		return
320	}
321	bw.modeToaster.setDetail(detail)
322}

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