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

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