detached 8556458

fix(input): treat uppercase single-letter shortcuts as Shift+letter

commit
85564584974026cc4ad3d81930f65cc051465a78
author
brice · · 2025-12-27 15:49 UTC
parent
cb3ee8d
signature
unsigned
2 files · +46 -10
M internal/ui/input/shortcuts.go +30 -9
@@ -134,6 +134,9 @@ const (
134134 ActionClosePane Action = "close_pane"
135135 ActionStackPane Action = "stack_pane"
136136
137+ ActionMovePaneToTab Action = "move_pane_to_tab"
138+ ActionMovePaneToNextTab Action = "move_pane_to_next_tab"
139+
137140 ActionConsumeOrExpelLeft Action = "consume_or_expel_left"
138141 ActionConsumeOrExpelRight Action = "consume_or_expel_right"
139142 ActionConsumeOrExpelUp Action = "consume_or_expel_up"
@@ -429,12 +432,14 @@ var configActionToAction = map[string]Action{
429432 "rename-tab": ActionRenameTab,
430433
431434 // Pane actions
432- "split-right": ActionSplitRight,
433- "split-left": ActionSplitLeft,
434- "split-up": ActionSplitUp,
435- "split-down": ActionSplitDown,
436- "close-pane": ActionClosePane,
437- "stack-pane": ActionStackPane,
435+ "split-right": ActionSplitRight,
436+ "split-left": ActionSplitLeft,
437+ "split-up": ActionSplitUp,
438+ "split-down": ActionSplitDown,
439+ "close-pane": ActionClosePane,
440+ "stack-pane": ActionStackPane,
441+ "move-pane-to-tab": ActionMovePaneToTab,
442+ "move-pane-to-next-tab": ActionMovePaneToNextTab,
438443
439444 "consume-or-expel-left": ActionConsumeOrExpelLeft,
440445 "consume-or-expel-right": ActionConsumeOrExpelRight,
@@ -484,7 +489,10 @@ func ParseKeyString(s string) (KeyBinding, bool) {
484489 return KeyBinding{}, false
485490 }
486491
487- s = strings.ToLower(strings.TrimSpace(s))
492+ s = strings.TrimSpace(s)
493+ if s == "" {
494+ return KeyBinding{}, false
495+ }
488496 if s == "+" {
489497 return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true
490498 }
@@ -499,7 +507,9 @@ func ParseKeyString(s string) (KeyBinding, bool) {
499507 if part == "" {
500508 continue
501509 }
502- switch part {
510+
511+ lower := strings.ToLower(part)
512+ switch lower {
503513 case "ctrl", "control":
504514 modifiers |= ModCtrl
505515 case "shift":
@@ -523,6 +533,12 @@ func ParseKeyString(s string) (KeyBinding, bool) {
523533 return KeyBinding{}, false
524534 }
525535
536+ // Treat uppercase single-letter keys as Shift+<letter>.
537+ if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
538+ modifiers |= ModShift
539+ keyPart = strings.ToLower(keyPart)
540+ }
541+
526542 keyval, ok := stringToKeyval(keyPart)
527543 if !ok {
528544 return KeyBinding{}, false
@@ -536,7 +552,11 @@ func ParseKeyString(s string) (KeyBinding, bool) {
536552
537553 // stringToKeyval converts a key name to its GDK keyval.
538554 func stringToKeyval(s string) (uint, bool) {
539- if keyval, ok := keyvalByName[s]; ok {
555+ if s == "" {
556+ return 0, false
557+ }
558+
559+ if keyval, ok := keyvalByName[strings.ToLower(s)]; ok {
540560 return keyval, true
541561 }
542562
@@ -588,6 +608,7 @@ func ShouldAutoExitMode(action Action) bool {
588608 case ActionNewTab, ActionCloseTab, ActionRenameTab,
589609 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
590610 ActionClosePane, ActionStackPane,
611+ ActionMovePaneToTab, ActionMovePaneToNextTab,
591612 ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown,
592613 ActionOpenSessionManager:
593614 return true
@@ -134,6 +134,9 @@ const (
134 ActionClosePane Action = "close_pane"134 ActionClosePane Action = "close_pane"
135 ActionStackPane Action = "stack_pane"135 ActionStackPane Action = "stack_pane"
136 136
137+ ActionMovePaneToTab Action = "move_pane_to_tab"
138+ ActionMovePaneToNextTab Action = "move_pane_to_next_tab"
139+
137 ActionConsumeOrExpelLeft Action = "consume_or_expel_left"140 ActionConsumeOrExpelLeft Action = "consume_or_expel_left"
138 ActionConsumeOrExpelRight Action = "consume_or_expel_right"141 ActionConsumeOrExpelRight Action = "consume_or_expel_right"
139 ActionConsumeOrExpelUp Action = "consume_or_expel_up"142 ActionConsumeOrExpelUp Action = "consume_or_expel_up"
@@ -429,12 +432,14 @@ var configActionToAction = map[string]Action{
429 "rename-tab": ActionRenameTab,432 "rename-tab": ActionRenameTab,
430 433
431 // Pane actions434 // Pane actions
432- "split-right": ActionSplitRight,435+ "split-right": ActionSplitRight,
433- "split-left": ActionSplitLeft,436+ "split-left": ActionSplitLeft,
434- "split-up": ActionSplitUp,437+ "split-up": ActionSplitUp,
435- "split-down": ActionSplitDown,438+ "split-down": ActionSplitDown,
436- "close-pane": ActionClosePane,439+ "close-pane": ActionClosePane,
437- "stack-pane": ActionStackPane,440+ "stack-pane": ActionStackPane,
441+ "move-pane-to-tab": ActionMovePaneToTab,
442+ "move-pane-to-next-tab": ActionMovePaneToNextTab,
438 443
439 "consume-or-expel-left": ActionConsumeOrExpelLeft,444 "consume-or-expel-left": ActionConsumeOrExpelLeft,
440 "consume-or-expel-right": ActionConsumeOrExpelRight,445 "consume-or-expel-right": ActionConsumeOrExpelRight,
@@ -484,7 +489,10 @@ func ParseKeyString(s string) (KeyBinding, bool) {
484 return KeyBinding{}, false489 return KeyBinding{}, false
485 }490 }
486 491
487- s = strings.ToLower(strings.TrimSpace(s))492+ s = strings.TrimSpace(s)
493+ if s == "" {
494+ return KeyBinding{}, false
495+ }
488 if s == "+" {496 if s == "+" {
489 return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true497 return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true
490 }498 }
@@ -499,7 +507,9 @@ func ParseKeyString(s string) (KeyBinding, bool) {
499 if part == "" {507 if part == "" {
500 continue508 continue
501 }509 }
502- switch part {510+
511+ lower := strings.ToLower(part)
512+ switch lower {
503 case "ctrl", "control":513 case "ctrl", "control":
504 modifiers |= ModCtrl514 modifiers |= ModCtrl
505 case "shift":515 case "shift":
@@ -523,6 +533,12 @@ func ParseKeyString(s string) (KeyBinding, bool) {
523 return KeyBinding{}, false533 return KeyBinding{}, false
524 }534 }
525 535
536+ // Treat uppercase single-letter keys as Shift+<letter>.
537+ if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
538+ modifiers |= ModShift
539+ keyPart = strings.ToLower(keyPart)
540+ }
541+
526 keyval, ok := stringToKeyval(keyPart)542 keyval, ok := stringToKeyval(keyPart)
527 if !ok {543 if !ok {
528 return KeyBinding{}, false544 return KeyBinding{}, false
@@ -536,7 +552,11 @@ func ParseKeyString(s string) (KeyBinding, bool) {
536 552
537 // stringToKeyval converts a key name to its GDK keyval.553 // stringToKeyval converts a key name to its GDK keyval.
538 func stringToKeyval(s string) (uint, bool) {554 func stringToKeyval(s string) (uint, bool) {
539- if keyval, ok := keyvalByName[s]; ok {555+ if s == "" {
556+ return 0, false
557+ }
558+
559+ if keyval, ok := keyvalByName[strings.ToLower(s)]; ok {
540 return keyval, true560 return keyval, true
541 }561 }
542 562
@@ -588,6 +608,7 @@ func ShouldAutoExitMode(action Action) bool {
588 case ActionNewTab, ActionCloseTab, ActionRenameTab,608 case ActionNewTab, ActionCloseTab, ActionRenameTab,
589 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,609 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
590 ActionClosePane, ActionStackPane,610 ActionClosePane, ActionStackPane,
611+ ActionMovePaneToTab, ActionMovePaneToNextTab,
591 ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown,612 ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown,
592 ActionOpenSessionManager:613 ActionOpenSessionManager:
593 return true614 return true
M internal/ui/input/shortcuts_test.go +16 -1
@@ -207,9 +207,11 @@ func TestParseKeyString_CaseInsensitive(t *testing.T) {
207207 input1 string
208208 input2 string
209209 }{
210- {"ctrl+t", "CTRL+T"},
210+ // Modifiers and named keys are case-insensitive.
211+ {"ctrl+t", "CTRL+t"},
211212 {"Escape", "escape"},
212213 {"Tab", "TAB"},
214+ // If shift is explicitly present, the key letter case shouldn't matter.
213215 {"Ctrl+Shift+T", "ctrl+shift+t"},
214216 }
215217
@@ -230,6 +232,17 @@ func TestParseKeyString_CaseInsensitive(t *testing.T) {
230232 }
231233 }
232234
235+func TestParseKeyString_UppercaseSingleLetterAddsShift(t *testing.T) {
236+ got1, ok1 := ParseKeyString("M")
237+ got2, ok2 := ParseKeyString("shift+m")
238+ if !ok1 || !ok2 {
239+ t.Fatalf("ParseKeyString should succeed for M and shift+m")
240+ }
241+ if got1 != got2 {
242+ t.Fatalf("ParseKeyString uppercase should imply shift: got %v want %v", got1, got2)
243+ }
244+}
245+
233246 func TestShortcutSet_Lookup(t *testing.T) {
234247 set := &ShortcutSet{
235248 Global: ShortcutTable{
@@ -336,6 +349,8 @@ func TestShouldAutoExitMode(t *testing.T) {
336349 ActionSplitDown,
337350 ActionClosePane,
338351 ActionStackPane,
352+ ActionMovePaneToTab,
353+ ActionMovePaneToNextTab,
339354 }
340355
341356 stayActions := []Action{
@@ -207,9 +207,11 @@ func TestParseKeyString_CaseInsensitive(t *testing.T) {
207 input1 string207 input1 string
208 input2 string208 input2 string
209 }{209 }{
210- {"ctrl+t", "CTRL+T"},210+ // Modifiers and named keys are case-insensitive.
211+ {"ctrl+t", "CTRL+t"},
211 {"Escape", "escape"},212 {"Escape", "escape"},
212 {"Tab", "TAB"},213 {"Tab", "TAB"},
214+ // If shift is explicitly present, the key letter case shouldn't matter.
213 {"Ctrl+Shift+T", "ctrl+shift+t"},215 {"Ctrl+Shift+T", "ctrl+shift+t"},
214 }216 }
215 217
@@ -230,6 +232,17 @@ func TestParseKeyString_CaseInsensitive(t *testing.T) {
230 }232 }
231 }233 }
232 234
235+func TestParseKeyString_UppercaseSingleLetterAddsShift(t *testing.T) {
236+ got1, ok1 := ParseKeyString("M")
237+ got2, ok2 := ParseKeyString("shift+m")
238+ if !ok1 || !ok2 {
239+ t.Fatalf("ParseKeyString should succeed for M and shift+m")
240+ }
241+ if got1 != got2 {
242+ t.Fatalf("ParseKeyString uppercase should imply shift: got %v want %v", got1, got2)
243+ }
244+}
245+
233 func TestShortcutSet_Lookup(t *testing.T) {246 func TestShortcutSet_Lookup(t *testing.T) {
234 set := &ShortcutSet{247 set := &ShortcutSet{
235 Global: ShortcutTable{248 Global: ShortcutTable{
@@ -336,6 +349,8 @@ func TestShouldAutoExitMode(t *testing.T) {
336 ActionSplitDown,349 ActionSplitDown,
337 ActionClosePane,350 ActionClosePane,
338 ActionStackPane,351 ActionStackPane,
352+ ActionMovePaneToTab,
353+ ActionMovePaneToNextTab,
339 }354 }
340 355
341 stayActions := []Action{356 stayActions := []Action{

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