detached b856ec1

fix(vimkeys): round trip modified hyphen keys

commit
b856ec1c4f15764151ed94d3e7f0eaf370e8f710
author
brice · · 2026-07-29 05:31 UTC
committer
bnema · · 2026-08-14 02:42 UTC
parent
c67625f
signature
unsigned
2 files · +107 -27
M internal/domain/vimkeys/parse.go +42 -27
@@ -163,41 +163,56 @@ func buildLegacyChordKey(s string, parts []string) (Key, error) {
163163 }
164164
165165 func parseAngleToken(inner string) (Key, error) {
166- if strings.Contains(inner, "-") {
167- parts := strings.Split(inner, "-")
168- if len(parts) < 2 {
169- return Key{}, ErrBadBinding
170- }
171- symPart := strings.TrimSpace(parts[len(parts)-1])
172- if symPart == "" {
173- return Key{}, ErrBadBinding
174- }
175-
176- var mods Mods
177- for _, modPart := range parts[:len(parts)-1] {
178- modPart = strings.TrimSpace(modPart)
179- if modPart == "" {
180- return Key{}, ErrBadBinding
181- }
182- partMods, err := modsFromCompactModString(modPart)
183- if err != nil {
184- return Key{}, err
185- }
186- mods |= partMods
187- }
188-
189- sym, err := symFromToken(symPart, true)
166+ if !strings.Contains(inner, "-") {
167+ sym, err := symFromAlias(inner)
190168 if err != nil {
191169 return Key{}, err
192170 }
193- return Key{Sym: sym, Mods: mods}, nil
171+ return Key{Sym: sym}, nil
194172 }
195173
196- sym, err := symFromAlias(inner)
174+ mods, symPart, err := splitAngleModsAndSym(inner)
175+ if err != nil {
176+ return Key{}, err
177+ }
178+
179+ sym, err := symFromToken(symPart, true)
197180 if err != nil {
198181 return Key{}, err
199182 }
200- return Key{Sym: sym}, nil
183+ return Key{Sym: sym, Mods: mods}, nil
184+}
185+
186+func splitAngleModsAndSym(inner string) (Mods, string, error) {
187+ var modInner, symPart string
188+ if strings.HasSuffix(inner, "--") {
189+ modInner = inner[:len(inner)-2]
190+ symPart = "-"
191+ } else {
192+ lastDash := strings.LastIndexByte(inner, '-')
193+ if lastDash < 0 {
194+ return 0, "", ErrBadBinding
195+ }
196+ modInner = inner[:lastDash]
197+ symPart = inner[lastDash+1:]
198+ }
199+ if symPart == "" || modInner == "" {
200+ return 0, "", ErrBadBinding
201+ }
202+
203+ var mods Mods
204+ for _, modPart := range strings.Split(modInner, "-") {
205+ modPart = strings.TrimSpace(modPart)
206+ if modPart == "" {
207+ return 0, "", ErrBadBinding
208+ }
209+ partMods, err := modsFromCompactModString(modPart)
210+ if err != nil {
211+ return 0, "", err
212+ }
213+ mods |= partMods
214+ }
215+ return mods, symPart, nil
201216 }
202217
203218 func modsFromCompactModString(modPart string) (Mods, error) {
@@ -163,41 +163,56 @@ func buildLegacyChordKey(s string, parts []string) (Key, error) {
163 }163 }
164 164
165 func parseAngleToken(inner string) (Key, error) {165 func parseAngleToken(inner string) (Key, error) {
166- if strings.Contains(inner, "-") {166+ if !strings.Contains(inner, "-") {
167- parts := strings.Split(inner, "-")167+ sym, err := symFromAlias(inner)
168- if len(parts) < 2 {
169- return Key{}, ErrBadBinding
170- }
171- symPart := strings.TrimSpace(parts[len(parts)-1])
172- if symPart == "" {
173- return Key{}, ErrBadBinding
174- }
175-
176- var mods Mods
177- for _, modPart := range parts[:len(parts)-1] {
178- modPart = strings.TrimSpace(modPart)
179- if modPart == "" {
180- return Key{}, ErrBadBinding
181- }
182- partMods, err := modsFromCompactModString(modPart)
183- if err != nil {
184- return Key{}, err
185- }
186- mods |= partMods
187- }
188-
189- sym, err := symFromToken(symPart, true)
190 if err != nil {168 if err != nil {
191 return Key{}, err169 return Key{}, err
192 }170 }
193- return Key{Sym: sym, Mods: mods}, nil171+ return Key{Sym: sym}, nil
194 }172 }
195 173
196- sym, err := symFromAlias(inner)174+ mods, symPart, err := splitAngleModsAndSym(inner)
175+ if err != nil {
176+ return Key{}, err
177+ }
178+
179+ sym, err := symFromToken(symPart, true)
197 if err != nil {180 if err != nil {
198 return Key{}, err181 return Key{}, err
199 }182 }
200- return Key{Sym: sym}, nil183+ return Key{Sym: sym, Mods: mods}, nil
184+}
185+
186+func splitAngleModsAndSym(inner string) (Mods, string, error) {
187+ var modInner, symPart string
188+ if strings.HasSuffix(inner, "--") {
189+ modInner = inner[:len(inner)-2]
190+ symPart = "-"
191+ } else {
192+ lastDash := strings.LastIndexByte(inner, '-')
193+ if lastDash < 0 {
194+ return 0, "", ErrBadBinding
195+ }
196+ modInner = inner[:lastDash]
197+ symPart = inner[lastDash+1:]
198+ }
199+ if symPart == "" || modInner == "" {
200+ return 0, "", ErrBadBinding
201+ }
202+
203+ var mods Mods
204+ for _, modPart := range strings.Split(modInner, "-") {
205+ modPart = strings.TrimSpace(modPart)
206+ if modPart == "" {
207+ return 0, "", ErrBadBinding
208+ }
209+ partMods, err := modsFromCompactModString(modPart)
210+ if err != nil {
211+ return 0, "", err
212+ }
213+ mods |= partMods
214+ }
215+ return mods, symPart, nil
201 }216 }
202 217
203 func modsFromCompactModString(modPart string) (Mods, error) {218 func modsFromCompactModString(modPart string) (Mods, error) {
M internal/domain/vimkeys/parse_test.go +65 -0
@@ -13,6 +13,7 @@ func TestParseBinding_SingleKeys(t *testing.T) {
1313 {input: "j", want: Sequence{{Sym: "j"}}},
1414 {input: "shift+j", want: Sequence{{Sym: "j", Mods: ModShift}}},
1515 {input: "ctrl++", want: Sequence{{Sym: "+", Mods: ModCtrl}}},
16+ {input: "ctrl+-", want: Sequence{{Sym: "-", Mods: ModCtrl}}},
1617 {input: "<C-d>", want: Sequence{{Sym: "d", Mods: ModCtrl}}},
1718 {input: "<CR>", want: Sequence{{Sym: "CR"}}},
1819 {input: "<Return>", want: Sequence{{Sym: "CR"}}},
@@ -110,6 +111,8 @@ func TestParseBinding_CanonicalString(t *testing.T) {
110111 {input: "j", want: "j"},
111112 {input: "shift+j", want: "J"},
112113 {input: "ctrl++", want: "<C-+>"},
114+ {input: "ctrl+-", want: "<C-->"},
115+ {input: "<C-->", want: "<C-->"},
113116 {input: "<C-d>", want: "<C-d>"},
114117 {input: "<CR>", want: "<Return>"},
115118 {input: "<Return>", want: "<Return>"},
@@ -322,6 +325,61 @@ func TestParseBinding_MixedAngleSequences(t *testing.T) {
322325 }
323326 }
324327
328+func TestParseBinding_CtrlHyphenRoundTrip(t *testing.T) {
329+ tests := []struct {
330+ input string
331+ want Sequence
332+ wantString string
333+ }{
334+ {
335+ input: "ctrl+-",
336+ want: Sequence{{Sym: "-", Mods: ModCtrl}},
337+ wantString: "<C-->",
338+ },
339+ {
340+ input: "<C-->",
341+ want: Sequence{{Sym: "-", Mods: ModCtrl}},
342+ wantString: "<C-->",
343+ },
344+ {
345+ input: "<C-S-->",
346+ want: Sequence{{Sym: "-", Mods: ModCtrl | ModShift}},
347+ wantString: "<C-S-->",
348+ },
349+ {
350+ input: "<C-A-->",
351+ want: Sequence{{Sym: "-", Mods: ModCtrl | ModAlt}},
352+ wantString: "<C-A-->",
353+ },
354+ {
355+ input: "<C-S-A-->",
356+ want: Sequence{{Sym: "-", Mods: ModCtrl | ModShift | ModAlt}},
357+ wantString: "<C-S-A-->",
358+ },
359+ }
360+ for _, tt := range tests {
361+ t.Run(tt.input, func(t *testing.T) {
362+ first, err := ParseBinding(tt.input)
363+ if err != nil {
364+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
365+ }
366+ if !first.Equal(tt.want) {
367+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, first, tt.want)
368+ }
369+ if got := first.String(); got != tt.wantString {
370+ t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.wantString)
371+ }
372+ second, err := ParseBinding(first.String())
373+ if err != nil {
374+ t.Fatalf("ParseBinding(%q) from canonical %q error = %v", tt.input, first.String(), err)
375+ }
376+ if !first.Equal(second) {
377+ t.Fatalf("round trip %q -> %q -> %v, want %v", tt.input, first.String(), second, first)
378+ }
379+ })
380+ }
381+}
382+
325383 func TestParseBinding_MultiModifierRoundTrip(t *testing.T) {
326384 input := "<C-S-a>"
327385 first, err := ParseBinding(input)
@@ -352,6 +410,10 @@ func TestParseBinding_RoundTrip(t *testing.T) {
352410 "j",
353411 "shift+j",
354412 "ctrl++",
413+ "ctrl+-",
414+ "<C-->",
415+ "<C-S-->",
416+ "<C-A-->",
355417 "]]",
356418 "yah",
357419 "gO",
@@ -438,6 +500,9 @@ func TestParseBinding_DeterministicRoundTripProperty(t *testing.T) {
438500 {{Sym: "Delete", Mods: ModAlt}},
439501 {{Sym: "<"}},
440502 {{Sym: "+"}},
503+ {{Sym: "-", Mods: ModCtrl}},
504+ {{Sym: "-", Mods: ModCtrl | ModShift}},
505+ {{Sym: "-", Mods: ModCtrl | ModAlt}},
441506 {{Sym: "!"}},
442507 {{Sym: "@"}},
443508 {{Sym: "#"}},
@@ -13,6 +13,7 @@ func TestParseBinding_SingleKeys(t *testing.T) {
13 {input: "j", want: Sequence{{Sym: "j"}}},13 {input: "j", want: Sequence{{Sym: "j"}}},
14 {input: "shift+j", want: Sequence{{Sym: "j", Mods: ModShift}}},14 {input: "shift+j", want: Sequence{{Sym: "j", Mods: ModShift}}},
15 {input: "ctrl++", want: Sequence{{Sym: "+", Mods: ModCtrl}}},15 {input: "ctrl++", want: Sequence{{Sym: "+", Mods: ModCtrl}}},
16+ {input: "ctrl+-", want: Sequence{{Sym: "-", Mods: ModCtrl}}},
16 {input: "<C-d>", want: Sequence{{Sym: "d", Mods: ModCtrl}}},17 {input: "<C-d>", want: Sequence{{Sym: "d", Mods: ModCtrl}}},
17 {input: "<CR>", want: Sequence{{Sym: "CR"}}},18 {input: "<CR>", want: Sequence{{Sym: "CR"}}},
18 {input: "<Return>", want: Sequence{{Sym: "CR"}}},19 {input: "<Return>", want: Sequence{{Sym: "CR"}}},
@@ -110,6 +111,8 @@ func TestParseBinding_CanonicalString(t *testing.T) {
110 {input: "j", want: "j"},111 {input: "j", want: "j"},
111 {input: "shift+j", want: "J"},112 {input: "shift+j", want: "J"},
112 {input: "ctrl++", want: "<C-+>"},113 {input: "ctrl++", want: "<C-+>"},
114+ {input: "ctrl+-", want: "<C-->"},
115+ {input: "<C-->", want: "<C-->"},
113 {input: "<C-d>", want: "<C-d>"},116 {input: "<C-d>", want: "<C-d>"},
114 {input: "<CR>", want: "<Return>"},117 {input: "<CR>", want: "<Return>"},
115 {input: "<Return>", want: "<Return>"},118 {input: "<Return>", want: "<Return>"},
@@ -322,6 +325,61 @@ func TestParseBinding_MixedAngleSequences(t *testing.T) {
322 }325 }
323 }326 }
324 327
328+func TestParseBinding_CtrlHyphenRoundTrip(t *testing.T) {
329+ tests := []struct {
330+ input string
331+ want Sequence
332+ wantString string
333+ }{
334+ {
335+ input: "ctrl+-",
336+ want: Sequence{{Sym: "-", Mods: ModCtrl}},
337+ wantString: "<C-->",
338+ },
339+ {
340+ input: "<C-->",
341+ want: Sequence{{Sym: "-", Mods: ModCtrl}},
342+ wantString: "<C-->",
343+ },
344+ {
345+ input: "<C-S-->",
346+ want: Sequence{{Sym: "-", Mods: ModCtrl | ModShift}},
347+ wantString: "<C-S-->",
348+ },
349+ {
350+ input: "<C-A-->",
351+ want: Sequence{{Sym: "-", Mods: ModCtrl | ModAlt}},
352+ wantString: "<C-A-->",
353+ },
354+ {
355+ input: "<C-S-A-->",
356+ want: Sequence{{Sym: "-", Mods: ModCtrl | ModShift | ModAlt}},
357+ wantString: "<C-S-A-->",
358+ },
359+ }
360+ for _, tt := range tests {
361+ t.Run(tt.input, func(t *testing.T) {
362+ first, err := ParseBinding(tt.input)
363+ if err != nil {
364+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
365+ }
366+ if !first.Equal(tt.want) {
367+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, first, tt.want)
368+ }
369+ if got := first.String(); got != tt.wantString {
370+ t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.wantString)
371+ }
372+ second, err := ParseBinding(first.String())
373+ if err != nil {
374+ t.Fatalf("ParseBinding(%q) from canonical %q error = %v", tt.input, first.String(), err)
375+ }
376+ if !first.Equal(second) {
377+ t.Fatalf("round trip %q -> %q -> %v, want %v", tt.input, first.String(), second, first)
378+ }
379+ })
380+ }
381+}
382+
325 func TestParseBinding_MultiModifierRoundTrip(t *testing.T) {383 func TestParseBinding_MultiModifierRoundTrip(t *testing.T) {
326 input := "<C-S-a>"384 input := "<C-S-a>"
327 first, err := ParseBinding(input)385 first, err := ParseBinding(input)
@@ -352,6 +410,10 @@ func TestParseBinding_RoundTrip(t *testing.T) {
352 "j",410 "j",
353 "shift+j",411 "shift+j",
354 "ctrl++",412 "ctrl++",
413+ "ctrl+-",
414+ "<C-->",
415+ "<C-S-->",
416+ "<C-A-->",
355 "]]",417 "]]",
356 "yah",418 "yah",
357 "gO",419 "gO",
@@ -438,6 +500,9 @@ func TestParseBinding_DeterministicRoundTripProperty(t *testing.T) {
438 {{Sym: "Delete", Mods: ModAlt}},500 {{Sym: "Delete", Mods: ModAlt}},
439 {{Sym: "<"}},501 {{Sym: "<"}},
440 {{Sym: "+"}},502 {{Sym: "+"}},
503+ {{Sym: "-", Mods: ModCtrl}},
504+ {{Sym: "-", Mods: ModCtrl | ModShift}},
505+ {{Sym: "-", Mods: ModCtrl | ModAlt}},
441 {{Sym: "!"}},506 {{Sym: "!"}},
442 {{Sym: "@"}},507 {{Sym: "@"}},
443 {{Sym: "#"}},508 {{Sym: "#"}},

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