detached e7d570f

fix(vimkeys): preserve canonical matcher invariants

commit
e7d570f43127650750fec989a96e923daf0b2975
author
brice · · 2026-07-28 17:42 UTC
committer
bnema · · 2026-08-14 02:42 UTC
parent
7d5dafe
signature
unsigned
4 files · +147 -52
M internal/domain/vimkeys/matcher.go +2 -2
@@ -108,10 +108,10 @@ func (m *Matcher) feedSequenceKey(key Key) Result {
108108
109109 exact := m.node.hasAction
110110 children := len(m.node.children) > 0
111+ m.ambiguous = exact && children
111112
112113 switch {
113- case exact && children:
114- m.ambiguous = true
114+ case m.ambiguous:
115115 return Result{Kind: ResultPending, Count: normalizedCount(m.count), Pending: m.Pending()}
116116 case exact:
117117 result := Result{
@@ -108,10 +108,10 @@ func (m *Matcher) feedSequenceKey(key Key) Result {
108 108
109 exact := m.node.hasAction109 exact := m.node.hasAction
110 children := len(m.node.children) > 0110 children := len(m.node.children) > 0
111+ m.ambiguous = exact && children
111 112
112 switch {113 switch {
113- case exact && children:114+ case m.ambiguous:
114- m.ambiguous = true
115 return Result{Kind: ResultPending, Count: normalizedCount(m.count), Pending: m.Pending()}115 return Result{Kind: ResultPending, Count: normalizedCount(m.count), Pending: m.Pending()}
116 case exact:116 case exact:
117 result := Result{117 result := Result{
M internal/domain/vimkeys/matcher_test.go +34 -0
@@ -235,6 +235,40 @@ func TestMatcher_AutomaticReset(t *testing.T) {
235235 }
236236 }
237237
238+func TestMatcher_AmbiguityClearsOnTransition(t *testing.T) {
239+ trie := NewTrie()
240+ for _, binding := range []struct {
241+ seq Sequence
242+ action string
243+ }{
244+ {seq: Sequence{{Sym: "]"}}, action: "single-bracket"},
245+ {seq: Sequence{{Sym: "]"}, {Sym: "]"}, {Sym: "x"}}, action: "triple-bracket"},
246+ } {
247+ if err := trie.Insert(binding.seq, binding.action); err != nil {
248+ t.Fatalf("Insert(%v) error = %v", binding.seq, err)
249+ }
250+ }
251+
252+ m := NewMatcher(trie)
253+ m.Reset()
254+
255+ result := m.Feed(Key{Sym: "]"})
256+ if result.Kind != ResultPending {
257+ t.Fatalf("after ] Feed kind = %v, want %v", result.Kind, ResultPending)
258+ }
259+ if !m.Ambiguous() {
260+ t.Fatal("Ambiguous() after ] = false, want true")
261+ }
262+
263+ result = m.Feed(Key{Sym: "]"})
264+ if result.Kind != ResultPending {
265+ t.Fatalf("after ]] Feed kind = %v, want %v", result.Kind, ResultPending)
266+ }
267+ if m.Ambiguous() {
268+ t.Fatal("Ambiguous() after ]] = true, want false")
269+ }
270+}
271+
238272 func TestMatcher_AmbiguousShortLong(t *testing.T) {
239273 m := NewMatcher(testTrie(t))
240274
@@ -235,6 +235,40 @@ func TestMatcher_AutomaticReset(t *testing.T) {
235 }235 }
236 }236 }
237 237
238+func TestMatcher_AmbiguityClearsOnTransition(t *testing.T) {
239+ trie := NewTrie()
240+ for _, binding := range []struct {
241+ seq Sequence
242+ action string
243+ }{
244+ {seq: Sequence{{Sym: "]"}}, action: "single-bracket"},
245+ {seq: Sequence{{Sym: "]"}, {Sym: "]"}, {Sym: "x"}}, action: "triple-bracket"},
246+ } {
247+ if err := trie.Insert(binding.seq, binding.action); err != nil {
248+ t.Fatalf("Insert(%v) error = %v", binding.seq, err)
249+ }
250+ }
251+
252+ m := NewMatcher(trie)
253+ m.Reset()
254+
255+ result := m.Feed(Key{Sym: "]"})
256+ if result.Kind != ResultPending {
257+ t.Fatalf("after ] Feed kind = %v, want %v", result.Kind, ResultPending)
258+ }
259+ if !m.Ambiguous() {
260+ t.Fatal("Ambiguous() after ] = false, want true")
261+ }
262+
263+ result = m.Feed(Key{Sym: "]"})
264+ if result.Kind != ResultPending {
265+ t.Fatalf("after ]] Feed kind = %v, want %v", result.Kind, ResultPending)
266+ }
267+ if m.Ambiguous() {
268+ t.Fatal("Ambiguous() after ]] = true, want false")
269+ }
270+}
271+
238 func TestMatcher_AmbiguousShortLong(t *testing.T) {272 func TestMatcher_AmbiguousShortLong(t *testing.T) {
239 m := NewMatcher(testTrie(t))273 m := NewMatcher(testTrie(t))
240 274
M internal/domain/vimkeys/parse.go +56 -50
@@ -22,9 +22,6 @@ func ParseBinding(s string) (Sequence, error) {
2222 return nil, ErrEmptyBinding
2323 }
2424
25- if strings.HasPrefix(s, "<") {
26- return parseAngleBinding(s)
27- }
2825 if looksLikeChordAttempt(s) {
2926 if !isLegacyChord(s) {
3027 return nil, ErrBadBinding
@@ -138,46 +135,35 @@ func parseLegacyChord(s string) (Key, error) {
138135 return Key{Sym: sym, Mods: mods}, nil
139136 }
140137
141-func parseAngleBinding(s string) (Sequence, error) {
142- if !strings.HasPrefix(s, "<") || !strings.HasSuffix(s, ">") || len(s) < 3 {
143- return nil, ErrBadBinding
144- }
145- inner := s[1 : len(s)-1]
146- if inner == "" {
147- return nil, ErrBadBinding
148- }
149-
150- key, err := parseAngleToken(inner)
151- if err != nil {
152- return nil, err
153- }
154- return Sequence{key}, nil
155-}
156-
157138 func parseAngleToken(inner string) (Key, error) {
158139 if strings.Contains(inner, "-") {
159140 parts := strings.Split(inner, "-")
160- if len(parts) != 2 {
141+ if len(parts) < 2 {
161142 return Key{}, ErrBadBinding
162143 }
163- modPart := strings.TrimSpace(parts[0])
164- symPart := strings.TrimSpace(parts[1])
165- if modPart == "" || symPart == "" {
144+ symPart := strings.TrimSpace(parts[len(parts)-1])
145+ if symPart == "" {
166146 return Key{}, ErrBadBinding
167147 }
168148
169149 var mods Mods
170- for _, ch := range modPart {
171- switch ch {
172- case 'C', 'c':
173- mods |= ModCtrl
174- case 'S', 's':
175- mods |= ModShift
176- case 'A', 'a':
177- mods |= ModAlt
178- default:
150+ for _, modPart := range parts[:len(parts)-1] {
151+ modPart = strings.TrimSpace(modPart)
152+ if modPart == "" {
179153 return Key{}, ErrBadBinding
180154 }
155+ for _, ch := range modPart {
156+ switch ch {
157+ case 'C', 'c':
158+ mods |= ModCtrl
159+ case 'S', 's':
160+ mods |= ModShift
161+ case 'A', 'a':
162+ mods |= ModAlt
163+ default:
164+ return Key{}, ErrBadBinding
165+ }
166+ }
181167 }
182168
183169 sym, err := symFromToken(symPart, true)
@@ -199,6 +185,15 @@ var sequenceSpecialNames = []string{"Space", "Esc", "CR", "lt", "Plus"}
199185 func parseRawSequence(s string) (Sequence, error) {
200186 seq := make(Sequence, 0, len(s))
201187 for i := 0; i < len(s); {
188+ if s[i] == '<' {
189+ key, width, err := parseAngleAt(s, i)
190+ if err != nil {
191+ return nil, err
192+ }
193+ seq = append(seq, key)
194+ i += width
195+ continue
196+ }
202197 if key, width, ok := tryParseSpecialAt(s, i); ok {
203198 seq = append(seq, key)
204199 i += width
@@ -221,6 +216,26 @@ func parseRawSequence(s string) (Sequence, error) {
221216 return seq, nil
222217 }
223218
219+func parseAngleAt(s string, i int) (Key, int, error) {
220+ if i >= len(s) || s[i] != '<' {
221+ return Key{}, 0, ErrBadBinding
222+ }
223+ closeIdx := strings.IndexByte(s[i+1:], '>')
224+ if closeIdx < 0 {
225+ return Key{}, 0, ErrBadBinding
226+ }
227+ closeIdx += i + 1
228+ inner := s[i+1 : closeIdx]
229+ if inner == "" {
230+ return Key{}, 0, ErrBadBinding
231+ }
232+ key, err := parseAngleToken(inner)
233+ if err != nil {
234+ return Key{}, 0, err
235+ }
236+ return key, closeIdx - i + 1, nil
237+}
238+
224239 func tryParseSpecialAt(s string, i int) (Key, int, bool) {
225240 for _, name := range sequenceSpecialNames {
226241 if !strings.HasPrefix(s[i:], name) {
@@ -248,25 +263,16 @@ func tryParseCanonicalAt(s string, i int) (Key, int, bool) {
248263 return Key{}, 0, false
249264 }
250265
251- end := i
252- for end < len(s) {
253- nextDash := strings.IndexByte(s[end:], '-')
254- if nextDash < 0 {
255- end = len(s)
256- break
257- }
258- nextDash += end
259- if nextDash+1 >= len(s) {
260- end = len(s)
261- break
262- }
263- switch s[nextDash+1] {
264- case 'C', 'S', 'A':
265- end = nextDash
266- default:
267- end = len(s)
266+ end := len(s)
267+ nextDash := strings.IndexByte(s[i:], '-')
268+ if nextDash >= 0 {
269+ nextDash += i
270+ if nextDash+1 < len(s) {
271+ switch s[nextDash+1] {
272+ case 'C', 'S', 'A':
273+ end = nextDash
274+ }
268275 }
269- break
270276 }
271277
272278 token := s[i:end]
@@ -22,9 +22,6 @@ func ParseBinding(s string) (Sequence, error) {
22 return nil, ErrEmptyBinding22 return nil, ErrEmptyBinding
23 }23 }
24 24
25- if strings.HasPrefix(s, "<") {
26- return parseAngleBinding(s)
27- }
28 if looksLikeChordAttempt(s) {25 if looksLikeChordAttempt(s) {
29 if !isLegacyChord(s) {26 if !isLegacyChord(s) {
30 return nil, ErrBadBinding27 return nil, ErrBadBinding
@@ -138,46 +135,35 @@ func parseLegacyChord(s string) (Key, error) {
138 return Key{Sym: sym, Mods: mods}, nil135 return Key{Sym: sym, Mods: mods}, nil
139 }136 }
140 137
141-func parseAngleBinding(s string) (Sequence, error) {
142- if !strings.HasPrefix(s, "<") || !strings.HasSuffix(s, ">") || len(s) < 3 {
143- return nil, ErrBadBinding
144- }
145- inner := s[1 : len(s)-1]
146- if inner == "" {
147- return nil, ErrBadBinding
148- }
149-
150- key, err := parseAngleToken(inner)
151- if err != nil {
152- return nil, err
153- }
154- return Sequence{key}, nil
155-}
156-
157 func parseAngleToken(inner string) (Key, error) {138 func parseAngleToken(inner string) (Key, error) {
158 if strings.Contains(inner, "-") {139 if strings.Contains(inner, "-") {
159 parts := strings.Split(inner, "-")140 parts := strings.Split(inner, "-")
160- if len(parts) != 2 {141+ if len(parts) < 2 {
161 return Key{}, ErrBadBinding142 return Key{}, ErrBadBinding
162 }143 }
163- modPart := strings.TrimSpace(parts[0])144+ symPart := strings.TrimSpace(parts[len(parts)-1])
164- symPart := strings.TrimSpace(parts[1])145+ if symPart == "" {
165- if modPart == "" || symPart == "" {
166 return Key{}, ErrBadBinding146 return Key{}, ErrBadBinding
167 }147 }
168 148
169 var mods Mods149 var mods Mods
170- for _, ch := range modPart {150+ for _, modPart := range parts[:len(parts)-1] {
171- switch ch {151+ modPart = strings.TrimSpace(modPart)
172- case 'C', 'c':152+ if modPart == "" {
173- mods |= ModCtrl
174- case 'S', 's':
175- mods |= ModShift
176- case 'A', 'a':
177- mods |= ModAlt
178- default:
179 return Key{}, ErrBadBinding153 return Key{}, ErrBadBinding
180 }154 }
155+ for _, ch := range modPart {
156+ switch ch {
157+ case 'C', 'c':
158+ mods |= ModCtrl
159+ case 'S', 's':
160+ mods |= ModShift
161+ case 'A', 'a':
162+ mods |= ModAlt
163+ default:
164+ return Key{}, ErrBadBinding
165+ }
166+ }
181 }167 }
182 168
183 sym, err := symFromToken(symPart, true)169 sym, err := symFromToken(symPart, true)
@@ -199,6 +185,15 @@ var sequenceSpecialNames = []string{"Space", "Esc", "CR", "lt", "Plus"}
199 func parseRawSequence(s string) (Sequence, error) {185 func parseRawSequence(s string) (Sequence, error) {
200 seq := make(Sequence, 0, len(s))186 seq := make(Sequence, 0, len(s))
201 for i := 0; i < len(s); {187 for i := 0; i < len(s); {
188+ if s[i] == '<' {
189+ key, width, err := parseAngleAt(s, i)
190+ if err != nil {
191+ return nil, err
192+ }
193+ seq = append(seq, key)
194+ i += width
195+ continue
196+ }
202 if key, width, ok := tryParseSpecialAt(s, i); ok {197 if key, width, ok := tryParseSpecialAt(s, i); ok {
203 seq = append(seq, key)198 seq = append(seq, key)
204 i += width199 i += width
@@ -221,6 +216,26 @@ func parseRawSequence(s string) (Sequence, error) {
221 return seq, nil216 return seq, nil
222 }217 }
223 218
219+func parseAngleAt(s string, i int) (Key, int, error) {
220+ if i >= len(s) || s[i] != '<' {
221+ return Key{}, 0, ErrBadBinding
222+ }
223+ closeIdx := strings.IndexByte(s[i+1:], '>')
224+ if closeIdx < 0 {
225+ return Key{}, 0, ErrBadBinding
226+ }
227+ closeIdx += i + 1
228+ inner := s[i+1 : closeIdx]
229+ if inner == "" {
230+ return Key{}, 0, ErrBadBinding
231+ }
232+ key, err := parseAngleToken(inner)
233+ if err != nil {
234+ return Key{}, 0, err
235+ }
236+ return key, closeIdx - i + 1, nil
237+}
238+
224 func tryParseSpecialAt(s string, i int) (Key, int, bool) {239 func tryParseSpecialAt(s string, i int) (Key, int, bool) {
225 for _, name := range sequenceSpecialNames {240 for _, name := range sequenceSpecialNames {
226 if !strings.HasPrefix(s[i:], name) {241 if !strings.HasPrefix(s[i:], name) {
@@ -248,25 +263,16 @@ func tryParseCanonicalAt(s string, i int) (Key, int, bool) {
248 return Key{}, 0, false263 return Key{}, 0, false
249 }264 }
250 265
251- end := i266+ end := len(s)
252- for end < len(s) {267+ nextDash := strings.IndexByte(s[i:], '-')
253- nextDash := strings.IndexByte(s[end:], '-')268+ if nextDash >= 0 {
254- if nextDash < 0 {269+ nextDash += i
255- end = len(s)270+ if nextDash+1 < len(s) {
256- break271+ switch s[nextDash+1] {
257- }272+ case 'C', 'S', 'A':
258- nextDash += end273+ end = nextDash
259- if nextDash+1 >= len(s) {274+ }
260- end = len(s)
261- break
262- }
263- switch s[nextDash+1] {
264- case 'C', 'S', 'A':
265- end = nextDash
266- default:
267- end = len(s)
268 }275 }
269- break
270 }276 }
271 277
272 token := s[i:end]278 token := s[i:end]
M internal/domain/vimkeys/parse_test.go +55 -0
@@ -236,6 +236,58 @@ func TestParseBinding_PrintableGlyphs(t *testing.T) {
236236 }
237237 }
238238
239+func TestParseBinding_MixedAngleSequences(t *testing.T) {
240+ tests := []struct {
241+ input string
242+ want Sequence
243+ }{
244+ {
245+ input: "<C-d>j",
246+ want: Sequence{{Sym: "d", Mods: ModCtrl}, {Sym: "j"}},
247+ },
248+ {
249+ input: "<Escape>j",
250+ want: Sequence{{Sym: "Esc"}, {Sym: "j"}},
251+ },
252+ }
253+ for _, tt := range tests {
254+ t.Run(tt.input, func(t *testing.T) {
255+ got, err := ParseBinding(tt.input)
256+ if err != nil {
257+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
258+ }
259+ if !got.Equal(tt.want) {
260+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
261+ }
262+ })
263+ }
264+}
265+
266+func TestParseBinding_MultiModifierRoundTrip(t *testing.T) {
267+ input := "<C-S-a>"
268+ first, err := ParseBinding(input)
269+ if err != nil {
270+ t.Fatalf("ParseBinding(%q) error = %v", input, err)
271+ }
272+ want := Sequence{{Sym: "a", Mods: ModCtrl | ModShift}}
273+ if !first.Equal(want) {
274+ t.Fatalf("ParseBinding(%q) = %v, want %v", input, first, want)
275+ }
276+
277+ canonical := first.String()
278+ if canonical != "<C-S-a>" {
279+ t.Fatalf("String() = %q, want %q", canonical, "<C-S-a>")
280+ }
281+
282+ second, err := ParseBinding(canonical)
283+ if err != nil {
284+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
285+ }
286+ if !first.Equal(second) {
287+ t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, second, first)
288+ }
289+}
290+
239291 func TestParseBinding_RoundTrip(t *testing.T) {
240292 inputs := []string{
241293 "j",
@@ -249,6 +301,9 @@ func TestParseBinding_RoundTrip(t *testing.T) {
249301 "<Esc>",
250302 "<Space>",
251303 "<lt>",
304+ "<C-d>j",
305+ "<Escape>j",
306+ "<C-S-a>",
252307 }
253308 for _, input := range inputs {
254309 t.Run(input, func(t *testing.T) {
@@ -236,6 +236,58 @@ func TestParseBinding_PrintableGlyphs(t *testing.T) {
236 }236 }
237 }237 }
238 238
239+func TestParseBinding_MixedAngleSequences(t *testing.T) {
240+ tests := []struct {
241+ input string
242+ want Sequence
243+ }{
244+ {
245+ input: "<C-d>j",
246+ want: Sequence{{Sym: "d", Mods: ModCtrl}, {Sym: "j"}},
247+ },
248+ {
249+ input: "<Escape>j",
250+ want: Sequence{{Sym: "Esc"}, {Sym: "j"}},
251+ },
252+ }
253+ for _, tt := range tests {
254+ t.Run(tt.input, func(t *testing.T) {
255+ got, err := ParseBinding(tt.input)
256+ if err != nil {
257+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
258+ }
259+ if !got.Equal(tt.want) {
260+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
261+ }
262+ })
263+ }
264+}
265+
266+func TestParseBinding_MultiModifierRoundTrip(t *testing.T) {
267+ input := "<C-S-a>"
268+ first, err := ParseBinding(input)
269+ if err != nil {
270+ t.Fatalf("ParseBinding(%q) error = %v", input, err)
271+ }
272+ want := Sequence{{Sym: "a", Mods: ModCtrl | ModShift}}
273+ if !first.Equal(want) {
274+ t.Fatalf("ParseBinding(%q) = %v, want %v", input, first, want)
275+ }
276+
277+ canonical := first.String()
278+ if canonical != "<C-S-a>" {
279+ t.Fatalf("String() = %q, want %q", canonical, "<C-S-a>")
280+ }
281+
282+ second, err := ParseBinding(canonical)
283+ if err != nil {
284+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
285+ }
286+ if !first.Equal(second) {
287+ t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, second, first)
288+ }
289+}
290+
239 func TestParseBinding_RoundTrip(t *testing.T) {291 func TestParseBinding_RoundTrip(t *testing.T) {
240 inputs := []string{292 inputs := []string{
241 "j",293 "j",
@@ -249,6 +301,9 @@ func TestParseBinding_RoundTrip(t *testing.T) {
249 "<Esc>",301 "<Esc>",
250 "<Space>",302 "<Space>",
251 "<lt>",303 "<lt>",
304+ "<C-d>j",
305+ "<Escape>j",
306+ "<C-S-a>",
252 }307 }
253 for _, input := range inputs {308 for _, input := range inputs {
254 t.Run(input, func(t *testing.T) {309 t.Run(input, func(t *testing.T) {

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