main 5041353

vimkeys_dispatch.go RawHistoryFolder

1package input
2
3import (
4	"sort"
5	"sync"
6	"time"
7
8	"github.com/bnema/dumber/internal/domain/entity"
9	"github.com/bnema/dumber/internal/domain/vimkeys"
10	"github.com/bnema/puregotk/v4/gdk"
11	"github.com/rs/zerolog/log"
12)
13
14func isCountStartKey(key vimkeys.Key) bool {
15	return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '1' && key.Sym[0] <= '9'
16}
17
18// trieOwnsBinding reports whether a raw config binding belongs to the Vim
19// sequence trie rather than the legacy single-chord Vim Mode table.
20// Multi-key sequences and Vim-only chords that ParseKeyString cannot read are owned.
21func trieOwnsBinding(raw string) (vimkeys.Sequence, bool) {
22	seq, err := vimkeys.ParseBinding(raw)
23	if err != nil {
24		return nil, false
25	}
26	if len(seq) >= 2 {
27		if isCountStartKey(seq[0]) {
28			return nil, false
29		}
30		return seq, true
31	}
32	if _, ok := ParseKeyString(raw); !ok {
33		return seq, true
34	}
35	return nil, false
36}
37
38// trieOnlyActionBinding owns a single-chord binding when its action has no
39// legacy shortcut mapping and must therefore complete through the trie.
40func trieOnlyActionBinding(action, raw string) (vimkeys.Sequence, bool) {
41	if mapModeConfigAction(action, "vim") != "" {
42		return nil, false
43	}
44	seq, err := vimkeys.ParseBinding(raw)
45	if err != nil || len(seq) != 1 || isCountStartKey(seq[0]) {
46		return nil, false
47	}
48	return seq, true
49}
50
51// buildVimModeTrie inserts trie-owned Vim mode bindings. Action names are
52// sorted so the first sorted name wins on Insert conflicts (validation already
53// rejects duplicates). Ownership is marked only after a successful Insert.
54func buildVimModeTrie(cfg *entity.VimModeConfig) (*vimkeys.Trie, map[string]bool) {
55	trie := vimkeys.NewTrie()
56	owned := make(map[string]bool)
57	if cfg == nil {
58		return trie, owned
59	}
60
61	actions := make([]string, 0, len(cfg.Actions))
62	for action := range cfg.Actions {
63		actions = append(actions, action)
64	}
65	sort.Strings(actions)
66
67	for _, action := range actions {
68		binding := cfg.Actions[action]
69		keys := append([]string(nil), binding.Keys...)
70		sort.Strings(keys)
71		for _, key := range keys {
72			seq, ok := trieOwnsBinding(key)
73			if !ok {
74				// Single chords for actions without a legacy Action (for example
75				// "f" or "v") can only be dispatched through the sequence trie.
76				seq, ok = trieOnlyActionBinding(action, key)
77			}
78			if !ok {
79				continue
80			}
81			if err := trie.Insert(seq, action); err != nil {
82				log.Debug().
83					Err(err).
84					Str("key", key).
85					Str("action", action).
86					Msg("vim mode trie insert skipped")
87				continue
88			}
89			owned[key] = true
90		}
91	}
92	return trie, owned
93}
94
95// VimModeSequences returns the Vim sequence trie, if any.
96func (s *ShortcutSet) VimModeSequences() *vimkeys.Trie {
97	if s == nil {
98		return nil
99	}
100	return s.vimModeSequences
101}
102
103// filterLegacyVimModeBindings copies GetKeyBindings entries that the trie does not own.
104func filterLegacyVimModeBindings(bindings map[string]string, owned map[string]bool) map[string]string {
105	legacy := make(map[string]string, len(bindings))
106	for key, action := range bindings {
107		if owned[key] {
108			continue
109		}
110		legacy[key] = action
111	}
112	return legacy
113}
114
115// sequenceTimer is the injectable ambiguity-timeout seam.
116type sequenceTimer interface {
117	Stop() bool
118}
119
120// vimModeSequenceState holds per-handler matcher state and ambiguity timing.
121type vimModeSequenceState struct {
122	mu                   sync.Mutex
123	matcher              *vimkeys.Matcher
124	onPending            func(string)
125	onAction             func(string, int)
126	timeout              time.Duration
127	timer                sequenceTimer
128	afterFunc            func(time.Duration, func()) sequenceTimer
129	scheduleOnMainThread func(func())
130	generation           uint64
131}
132
133func newVimModeSequenceState(trie *vimkeys.Trie, timeout time.Duration) vimModeSequenceState {
134	return vimModeSequenceState{
135		matcher: vimkeys.NewMatcher(trie),
136		timeout: timeout,
137		afterFunc: func(d time.Duration, fn func()) sequenceTimer {
138			return time.AfterFunc(d, fn)
139		},
140		scheduleOnMainThread: func(fn func()) { fn() },
141	}
142}
143
144// SetOnPendingSequenceChange sets the callback for pending sequence text updates.
145func (h *KeyboardHandler) SetOnPendingSequenceChange(fn func(string)) {
146	h.seq.mu.Lock()
147	defer h.seq.mu.Unlock()
148	h.seq.onPending = fn
149}
150
151// SetOnSequenceAction sets the callback for completed sequence actions.
152func (h *KeyboardHandler) SetOnSequenceAction(fn func(string, int)) {
153	h.seq.mu.Lock()
154	defer h.seq.mu.Unlock()
155	h.seq.onAction = fn
156}
157
158// SetSequenceTimeout sets the ambiguity resolution timeout.
159func (h *KeyboardHandler) SetSequenceTimeout(d time.Duration) {
160	h.seq.mu.Lock()
161	defer h.seq.mu.Unlock()
162	h.seq.timeout = d
163}
164
165// SetSequenceMainThreadScheduler sets the main-thread dispatcher for ambiguity
166// resolution. Timer goroutines must not touch matcher state directly.
167func (h *KeyboardHandler) SetSequenceMainThreadScheduler(fn func(func())) {
168	h.seq.mu.Lock()
169	defer h.seq.mu.Unlock()
170	if fn == nil {
171		return
172	}
173	h.seq.scheduleOnMainThread = fn
174}
175
176// PendingSequence returns the current pending count+keys string.
177func (h *KeyboardHandler) PendingSequence() string {
178	h.seq.mu.Lock()
179	defer h.seq.mu.Unlock()
180	if h.seq.matcher == nil {
181		return ""
182	}
183	return h.seq.matcher.Pending()
184}
185
186// ResetPendingSequence clears matcher state and notifies pending listeners.
187func (h *KeyboardHandler) ResetPendingSequence() {
188	h.resetPendingSequence(true)
189}
190
191// resetPendingSequence clears matcher/timer state. When notify is false (mode
192// exit under ModalState lock), pending callbacks are skipped.
193func (h *KeyboardHandler) resetPendingSequence(notify bool) {
194	h.seq.mu.Lock()
195	h.stopSequenceTimerLocked()
196	h.seq.generation++
197	if h.seq.matcher != nil {
198		h.seq.matcher.Reset()
199	}
200	onPending := h.seq.onPending
201	h.seq.mu.Unlock()
202
203	if notify && onPending != nil {
204		onPending("")
205	}
206}
207
208// teardownSequenceState linearizably cancels sequence timing and clears
209// UI-retaining callbacks without notifying. Used by Detach/DetachForDestroy.
210// Lock order: call without holding h.mu (same as resetPendingSequence).
211func (h *KeyboardHandler) teardownSequenceState() {
212	h.seq.mu.Lock()
213	defer h.seq.mu.Unlock()
214	h.stopSequenceTimerLocked()
215	h.seq.generation++
216	if h.seq.matcher != nil {
217		h.seq.matcher.Reset()
218	}
219	h.seq.onPending = nil
220	h.seq.onAction = nil
221	h.seq.scheduleOnMainThread = nil
222	log.Debug().Msg("vim mode sequence state torn down without notify")
223}
224
225// InvalidateSequenceMatcher stops timing, bumps generation, and replaces the
226// matcher from the current VimModeSequences trie (hot reload).
227func (h *KeyboardHandler) InvalidateSequenceMatcher() {
228	h.mu.RLock()
229	var trie *vimkeys.Trie
230	if h.shortcuts != nil {
231		trie = h.shortcuts.VimModeSequences()
232	}
233	h.mu.RUnlock()
234
235	h.seq.mu.Lock()
236	defer h.seq.mu.Unlock()
237	h.stopSequenceTimerLocked()
238	h.seq.generation++
239	h.seq.matcher = vimkeys.NewMatcher(trie)
240	log.Debug().Msg("vim mode sequence matcher replaced")
241}
242
243// feedVimModeSequence feeds a key into the vim-mode sequence matcher.
244// Pending/Complete consume the event (true). Invalid clears and returns false
245// so the legacy shortcut path can handle the key.
246func (h *KeyboardHandler) feedVimModeSequence(keyval uint, state gdk.ModifierType) bool {
247	if h.modal.Mode() != ModeVim {
248		return false
249	}
250	key, ok := KeyvalToVimKey(keyval, state)
251	if !ok {
252		return false
253	}
254
255	h.seq.mu.Lock()
256	if h.seq.matcher == nil {
257		h.seq.mu.Unlock()
258		return false
259	}
260
261	h.stopSequenceTimerLocked()
262	h.seq.generation++
263	hadPending := h.seq.matcher.Pending() != ""
264
265	result := h.seq.matcher.Feed(key)
266	onPending := h.seq.onPending
267	onAction := h.seq.onAction
268
269	switch result.Kind {
270	case vimkeys.ResultPending:
271		pending := result.Pending
272		if pending == "" {
273			pending = h.seq.matcher.Pending()
274		}
275		ambiguous := h.seq.matcher.Ambiguous()
276		if ambiguous {
277			h.armAmbiguityTimeoutLocked()
278		}
279		h.seq.mu.Unlock()
280		if onPending != nil {
281			onPending(pending)
282		}
283		log.Debug().Str("pending", pending).Bool("ambiguous", ambiguous).Msg("vim mode sequence pending")
284		return true
285
286	case vimkeys.ResultComplete:
287		action := result.Action
288		count := result.Count
289		h.seq.mu.Unlock()
290		if onPending != nil {
291			onPending("")
292		}
293		if action == "confirm" {
294			h.confirmVimMode()
295		} else if onAction != nil {
296			onAction(action, count)
297		}
298		log.Debug().Str("action", action).Int("count", count).Msg("vim mode sequence complete")
299		return true
300
301	default: // ResultInvalid
302		h.seq.mu.Unlock()
303		if hadPending && onPending != nil {
304			onPending("")
305		}
306		log.Debug().Msg("vim mode sequence invalid; falling through to legacy")
307		return false
308	}
309}
310
311func (h *KeyboardHandler) stopSequenceTimerLocked() {
312	if h.seq.timer != nil {
313		h.seq.timer.Stop()
314		h.seq.timer = nil
315	}
316}
317
318func (h *KeyboardHandler) armAmbiguityTimeoutLocked() {
319	gen := h.seq.generation
320	timeout := h.seq.timeout
321	schedule := h.seq.scheduleOnMainThread
322	afterFunc := h.seq.afterFunc
323	if afterFunc == nil {
324		afterFunc = func(d time.Duration, fn func()) sequenceTimer {
325			return time.AfterFunc(d, fn)
326		}
327	}
328	if schedule == nil {
329		schedule = func(fn func()) { fn() }
330	}
331	h.seq.timer = afterFunc(timeout, func() {
332		schedule(func() {
333			h.resolveAmbiguity(gen)
334		})
335	})
336	log.Debug().Dur("timeout", timeout).Uint64("generation", gen).Msg("vim mode ambiguity timer armed")
337}
338
339func (h *KeyboardHandler) resolveAmbiguity(gen uint64) {
340	h.seq.mu.Lock()
341	if h.seq.generation != gen {
342		h.seq.mu.Unlock()
343		log.Debug().Uint64("generation", gen).Msg("stale vim mode ambiguity resolve ignored")
344		return
345	}
346	if h.seq.matcher == nil {
347		h.seq.mu.Unlock()
348		return
349	}
350	result, ok := h.seq.matcher.ResolveAmbiguity()
351	if !ok {
352		h.seq.mu.Unlock()
353		return
354	}
355	h.seq.timer = nil
356	h.seq.generation++
357	onPending := h.seq.onPending
358	onAction := h.seq.onAction
359	action := result.Action
360	count := result.Count
361	h.seq.mu.Unlock()
362
363	if onPending != nil {
364		onPending("")
365	}
366	if action == "confirm" {
367		h.confirmVimMode()
368	} else if onAction != nil {
369		onAction(action, count)
370	}
371	log.Debug().Str("action", action).Int("count", count).Msg("vim mode ambiguity resolved")
372}

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