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

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