shortcuts.go RawHistoryFolder

1// Package input provides keyboard event handling and modal input mode management.
2package input
3
4import (
5	"context"
6	"maps"
7	"slices"
8	"sort"
9	"strings"
10
11	"github.com/bnema/dumber/internal/domain/entity"
12	"github.com/bnema/dumber/internal/domain/vimkeys"
13	"github.com/bnema/dumber/internal/logging"
14	"github.com/bnema/puregotk/v4/gdk"
15)
16
17// Modifier represents keyboard modifier flags.
18type Modifier uint
19
20const (
21	// ModNone indicates no modifier is pressed.
22	ModNone Modifier = 0
23	// ModShift indicates the Shift key is pressed.
24	ModShift Modifier = Modifier(gdk.ShiftMaskValue)
25	// ModCtrl indicates the Control key is pressed.
26	ModCtrl Modifier = Modifier(gdk.ControlMaskValue)
27	// ModAlt indicates the Alt key is pressed.
28	ModAlt Modifier = Modifier(gdk.AltMaskValue)
29)
30
31// modifierMask filters out non-standard modifiers from GDK state.
32const modifierMask = ModCtrl | ModShift | ModAlt
33
34var keyvalByName = map[string]uint{
35	"escape":     uint(gdk.KEY_Escape),
36	"esc":        uint(gdk.KEY_Escape),
37	"return":     uint(gdk.KEY_Return),
38	"enter":      uint(gdk.KEY_Return),
39	"tab":        uint(gdk.KEY_Tab),
40	"space":      uint(gdk.KEY_space),
41	"backspace":  uint(gdk.KEY_BackSpace),
42	"delete":     uint(gdk.KEY_Delete),
43	"del":        uint(gdk.KEY_Delete),
44	"home":       uint(gdk.KEY_Home),
45	"end":        uint(gdk.KEY_End),
46	"pageup":     uint(gdk.KEY_Page_Up),
47	"page_up":    uint(gdk.KEY_Page_Up),
48	"pagedown":   uint(gdk.KEY_Page_Down),
49	"page_down":  uint(gdk.KEY_Page_Down),
50	"left":       uint(gdk.KEY_Left),
51	"arrowleft":  uint(gdk.KEY_Left),
52	"right":      uint(gdk.KEY_Right),
53	"arrowright": uint(gdk.KEY_Right),
54	"up":         uint(gdk.KEY_Up),
55	"arrowup":    uint(gdk.KEY_Up),
56	"down":       uint(gdk.KEY_Down),
57	"arrowdown":  uint(gdk.KEY_Down),
58	"f1":         uint(gdk.KEY_F1),
59	"f2":         uint(gdk.KEY_F2),
60	"f3":         uint(gdk.KEY_F3),
61	"f4":         uint(gdk.KEY_F4),
62	"f5":         uint(gdk.KEY_F5),
63	"f6":         uint(gdk.KEY_F6),
64	"f7":         uint(gdk.KEY_F7),
65	"f8":         uint(gdk.KEY_F8),
66	"f9":         uint(gdk.KEY_F9),
67	"f10":        uint(gdk.KEY_F10),
68	"f11":        uint(gdk.KEY_F11),
69	"f12":        uint(gdk.KEY_F12),
70	"plus":       uint(gdk.KEY_plus),
71	"+":          uint(gdk.KEY_plus),
72	"minus":      uint(gdk.KEY_minus),
73	"-":          uint(gdk.KEY_minus),
74	"equal":      uint(gdk.KEY_equal),
75	"=":          uint(gdk.KEY_equal),
76	"0":          uint(gdk.KEY_0),
77	"1":          uint(gdk.KEY_1),
78	"2":          uint(gdk.KEY_2),
79	"3":          uint(gdk.KEY_3),
80	"4":          uint(gdk.KEY_4),
81	"5":          uint(gdk.KEY_5),
82	"6":          uint(gdk.KEY_6),
83	"7":          uint(gdk.KEY_7),
84	"8":          uint(gdk.KEY_8),
85	"9":          uint(gdk.KEY_9),
86
87	"bracketleft":  uint(gdk.KEY_bracketleft),
88	"bracketright": uint(gdk.KEY_bracketright),
89	"[":            uint(gdk.KEY_bracketleft),
90	"]":            uint(gdk.KEY_bracketright),
91	"braceleft":    uint(gdk.KEY_braceleft),
92	"braceright":   uint(gdk.KEY_braceright),
93	"{":            uint(gdk.KEY_braceleft),
94	"}":            uint(gdk.KEY_braceright),
95}
96
97// KeyBinding represents a single key combination.
98type KeyBinding struct {
99	Keyval    uint     // GDK key value (e.g., gdk.KEY_t)
100	Modifiers Modifier // Combined modifiers
101}
102
103// Action represents what happens when a shortcut is triggered.
104type Action string
105
106// Predefined actions for the keyboard system.
107const (
108	// Mode management
109	ActionEnterTabMode     Action = "enter_tab_mode"
110	ActionEnterPaneMode    Action = "enter_pane_mode"
111	ActionEnterSessionMode Action = "enter_session_mode"
112	ActionEnterResizeMode  Action = "enter_resize_mode"
113	ActionEnterPrefixMode  Action = "enter_prefix_mode"
114	ActionExitMode         Action = "exit_mode"
115
116	// Tab actions (global and modal)
117	ActionNewTab           Action = "new_tab"
118	ActionCloseTab         Action = "close_tab"
119	ActionNextTab          Action = "next_tab"
120	ActionPreviousTab      Action = "previous_tab"
121	ActionRenameTab        Action = "rename_tab"
122	ActionSwitchLastTab    Action = "switch_last_tab" // Alt+Tab style switching
123	ActionSwitchTabIndex1  Action = "switch_tab_1"
124	ActionSwitchTabIndex2  Action = "switch_tab_2"
125	ActionSwitchTabIndex3  Action = "switch_tab_3"
126	ActionSwitchTabIndex4  Action = "switch_tab_4"
127	ActionSwitchTabIndex5  Action = "switch_tab_5"
128	ActionSwitchTabIndex6  Action = "switch_tab_6"
129	ActionSwitchTabIndex7  Action = "switch_tab_7"
130	ActionSwitchTabIndex8  Action = "switch_tab_8"
131	ActionSwitchTabIndex9  Action = "switch_tab_9"
132	ActionSwitchTabIndex10 Action = "switch_tab_10"
133
134	// Pane actions (modal)
135	ActionSplitRight Action = "split_right"
136	ActionSplitLeft  Action = "split_left"
137	ActionSplitUp    Action = "split_up"
138	ActionSplitDown  Action = "split_down"
139	ActionClosePane  Action = "close_pane"
140	ActionStackPane  Action = "stack_pane"
141
142	ActionMovePaneToTab     Action = "move_pane_to_tab"
143	ActionMovePaneToNextTab Action = "move_pane_to_next_tab"
144	ActionEjectPaneToWindow Action = "eject_pane_to_window"
145
146	ActionConsumeOrExpelLeft  Action = "consume_or_expel_left"
147	ActionConsumeOrExpelRight Action = "consume_or_expel_right"
148	ActionConsumeOrExpelUp    Action = "consume_or_expel_up"
149	ActionConsumeOrExpelDown  Action = "consume_or_expel_down"
150
151	// Pane focus navigation
152	ActionFocusRight Action = "focus_right"
153	ActionFocusLeft  Action = "focus_left"
154	ActionFocusUp    Action = "focus_up"
155	ActionFocusDown  Action = "focus_down"
156
157	// Resize actions (modal)
158	ActionResizeIncreaseLeft  Action = "resize_increase_left"
159	ActionResizeIncreaseRight Action = "resize_increase_right"
160	ActionResizeIncreaseUp    Action = "resize_increase_up"
161	ActionResizeIncreaseDown  Action = "resize_increase_down"
162	ActionResizeDecreaseLeft  Action = "resize_decrease_left"
163	ActionResizeDecreaseRight Action = "resize_decrease_right"
164	ActionResizeDecreaseUp    Action = "resize_decrease_up"
165	ActionResizeDecreaseDown  Action = "resize_decrease_down"
166	ActionResizeIncrease      Action = "resize_increase"
167	ActionResizeDecrease      Action = "resize_decrease"
168
169	// Stack navigation (within stacked panes)
170	ActionStackNavUp   Action = "stack_nav_up"
171	ActionStackNavDown Action = "stack_nav_down"
172
173	// Page navigation
174	ActionGoBack     Action = "go_back"
175	ActionGoForward  Action = "go_forward"
176	ActionReload     Action = "reload"
177	ActionHardReload Action = "hard_reload"
178	ActionStop       Action = "stop"
179	ActionPrintPage  Action = "print_page"
180
181	// Zoom
182	ActionZoomIn    Action = "zoom_in"
183	ActionZoomOut   Action = "zoom_out"
184	ActionZoomReset Action = "zoom_reset"
185
186	// UI
187	ActionOpenOmnibox               Action = "open_omnibox"
188	ActionOpenFind                  Action = "open_find"
189	ActionFindNext                  Action = "find_next"
190	ActionFindPrev                  Action = "find_prev"
191	ActionCloseFind                 Action = "close_find"
192	ActionOpenDevTools              Action = "open_devtools"
193	ActionToggleFullscreen          Action = "toggle_fullscreen"
194	ActionToggleFloatingPane        Action = "toggle_floating_pane"
195	ActionToggleHistorySystemView   Action = "toggle_history_systemview"
196	ActionToggleFavoritesSidebar    Action = "toggle_favorites_sidebar"
197	ActionToggleCurrentPageFavorite Action = "toggle_current_page_favorite"
198	ActionToggleConfigSystemView    Action = "toggle_config_systemview"
199
200	// Clipboard
201	ActionCopyURL Action = "copy_url"
202
203	// Session management
204	ActionOpenSessionManager Action = "open_session_manager"
205
206	// Application
207	ActionQuit Action = "quit"
208
209	// Vim mode actions
210	ActionEnterVimMode      Action = "enter_vim_mode"
211	ActionVimConfirm        Action = "vim_confirm"
212	ActionVimScrollLeft     Action = "vim_scroll_left"
213	ActionVimScrollDown     Action = "vim_scroll_down"
214	ActionVimScrollUp       Action = "vim_scroll_up"
215	ActionVimScrollRight    Action = "vim_scroll_right"
216	ActionVimScrollDownFast Action = "vim_scroll_down_fast"
217	ActionVimScrollUpFast   Action = "vim_scroll_up_fast"
218)
219
220const floatingProfileActionPrefix = "open_floating_profile:"
221const floatingProfileActionSeparator = "\u241F"
222
223// ShortcutTable maps KeyBinding to Action.
224type ShortcutTable map[KeyBinding]Action
225
226// ShortcutSet holds all shortcut tables organized by context.
227type ShortcutSet struct {
228	// Global shortcuts are always active regardless of mode.
229	Global ShortcutTable
230	// TabMode shortcuts are only active in tab mode.
231	TabMode ShortcutTable
232	// PaneMode shortcuts are only active in pane mode.
233	PaneMode ShortcutTable
234	// SessionMode shortcuts are only active in session mode.
235	SessionMode ShortcutTable
236	// ResizeMode shortcuts are only active in resize mode.
237	ResizeMode ShortcutTable
238	// VimMode shortcuts are only active in vim mode.
239	VimMode ShortcutTable
240	// PrefixMode shortcuts are one-shot actions after the tmux-style prefix.
241	PrefixMode ShortcutTable
242	// vimModeSequences holds multi-key Vim mode bindings.
243	vimModeSequences *vimkeys.Trie
244}
245
246// NewShortcutSet creates a ShortcutSet from workspace and session configuration.
247// workspace may be nil (workspace-specific shortcuts will be empty).
248// session may be nil (session mode shortcuts will be empty).
249func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) *ShortcutSet {
250	log := logging.FromContext(ctx)
251	set := &ShortcutSet{
252		Global:      make(ShortcutTable),
253		TabMode:     make(ShortcutTable),
254		PaneMode:    make(ShortcutTable),
255		SessionMode: make(ShortcutTable),
256		ResizeMode:  make(ShortcutTable),
257		VimMode:     make(ShortcutTable),
258		PrefixMode:  make(ShortcutTable),
259	}
260
261	set.buildGlobalShortcutsFromParts(ctx, workspace, session)
262	if workspace != nil {
263		set.buildTabModeShortcuts(ctx, workspace)
264		set.buildPaneModeShortcuts(ctx, workspace)
265		set.buildResizeModeShortcuts(ctx, workspace)
266		set.buildVimModeShortcuts(ctx, workspace)
267		set.buildModeShortcuts(ctx, workspace.PrefixMode.GetKeyBindings(), set.PrefixMode, "prefix")
268	}
269	if session != nil {
270		set.buildSessionModeShortcuts(ctx, session)
271	}
272
273	log.Debug().
274		Int("global", len(set.Global)).
275		Int("tab", len(set.TabMode)).
276		Int("pane", len(set.PaneMode)).
277		Int("resize", len(set.ResizeMode)).
278		Int("session", len(set.SessionMode)).
279		Int("vim", len(set.VimMode)).
280		Int("prefix", len(set.PrefixMode)).
281		Msg("shortcuts registered")
282
283	return set
284}
285
286// buildGlobalShortcutsFromParts populates global shortcuts from workspace and session configs.
287func (s *ShortcutSet) buildGlobalShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
288	s.registerBuiltinShortcutFallbacks(workspace)
289	s.registerActivationShortcutsFromParts(ctx, workspace, session)
290	s.registerConfiguredShortcuts(workspace)
291	// Alt+1-9, Alt+0, and Alt+Tab are registered by GlobalShortcutHandler;
292	// Alt+Shift+Tab stays here as a fallback binding.
293	s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab
294	s.registerFloatingProfileShortcutsFromWorkspace(ctx, workspace)
295}
296
297// buildTabModeShortcuts populates tab mode shortcuts from config.
298func (s *ShortcutSet) buildTabModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
299	s.buildModeShortcuts(ctx, cfg.TabMode.GetKeyBindings(), s.TabMode, "tab")
300}
301
302// buildPaneModeShortcuts populates pane mode shortcuts from config.
303func (s *ShortcutSet) buildPaneModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
304	s.buildModeShortcuts(ctx, cfg.PaneMode.GetKeyBindings(), s.PaneMode, "pane")
305}
306
307// buildSessionModeShortcuts populates session mode shortcuts from config.
308func (s *ShortcutSet) buildSessionModeShortcuts(ctx context.Context, cfg *entity.SessionConfig) {
309	s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session")
310}
311
312// buildResizeModeShortcuts populates resize mode shortcuts from config.
313func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
314	s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")
315}
316
317// buildVimModeShortcuts populates Vim mode shortcuts from config.
318// Trie-owned multi-key bindings are excluded from the legacy table.
319func (s *ShortcutSet) buildVimModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
320	log := logging.FromContext(ctx)
321	trie, owned := buildVimModeTrie(&cfg.VimMode)
322	s.vimModeSequences = trie
323	legacy := filterLegacyVimModeBindings(cfg.VimMode.GetKeyBindings(), owned)
324	log.Debug().
325		Int("owned", len(owned)).
326		Int("legacy", len(legacy)).
327		Msg("vim mode sequence trie built")
328	s.buildModeShortcuts(ctx, legacy, s.VimMode, "vim")
329	if binding, ok := ParseKeyString(cfg.VimMode.ActivationShortcut); ok {
330		s.VimMode[binding] = ActionEnterVimMode
331		log.Trace().
332			Str("shortcut", cfg.VimMode.ActivationShortcut).
333			Uint("keyval", binding.Keyval).
334			Uint("mod", uint(binding.Modifiers)).
335			Msg("vim mode toggle registered in vim mode table")
336	} else {
337		log.Warn().Str("shortcut", cfg.VimMode.ActivationShortcut).Msg("failed to parse vim mode activation shortcut for vim mode table")
338	}
339}
340
341// registerActivationShortcutsFromParts registers mode activation shortcuts.
342// The zellij keymap binds one shortcut per mode; the tmux keymap binds a single
343// one-shot prefix instead of the pane, tab, resize, and session activations.
344// Vim Mode keeps its own activation in both keymaps.
345func (s *ShortcutSet) registerActivationShortcutsFromParts(
346	ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig,
347) {
348	if workspace != nil {
349		s.registerActivation(ctx, workspace.VimMode.ActivationShortcut, ActionEnterVimMode)
350		if workspace.Keymap == entity.KeymapTmux {
351			s.registerActivation(ctx, workspace.PrefixMode.ActivationShortcut, ActionEnterPrefixMode)
352			return
353		}
354		s.registerActivation(ctx, workspace.TabMode.ActivationShortcut, ActionEnterTabMode)
355		s.registerActivation(ctx, workspace.PaneMode.ActivationShortcut, ActionEnterPaneMode)
356		s.registerActivation(ctx, workspace.ResizeMode.ActivationShortcut, ActionEnterResizeMode)
357	}
358	if session != nil {
359		s.registerActivation(ctx, session.SessionMode.ActivationShortcut, ActionEnterSessionMode)
360	}
361}
362
363func (s *ShortcutSet) registerActivation(ctx context.Context, shortcut string, action Action) {
364	log := logging.FromContext(ctx)
365	binding, ok := ParseKeyString(shortcut)
366	if !ok {
367		log.Warn().Str("shortcut", shortcut).Str("action", string(action)).Msg("failed to parse mode activation shortcut")
368		return
369	}
370	s.Global[binding] = action
371	log.Trace().
372		Str("shortcut", shortcut).
373		Str("action", string(action)).
374		Uint("keyval", binding.Keyval).
375		Uint("mod", uint(binding.Modifiers)).
376		Msg("mode activation registered")
377}
378
379// registerBuiltinShortcutFallbacks binds built-in browser shortcuts whose
380// action is missing from the configured global shortcuts, so configs written
381// before these actions became configurable keep working until migrated.
382func (s *ShortcutSet) registerBuiltinShortcutFallbacks(cfg *entity.WorkspaceConfig) {
383	for name, binding := range entity.BuiltinGlobalShortcuts() {
384		if cfg != nil {
385			if _, configured := cfg.Shortcuts.Actions[name]; configured {
386				continue
387			}
388		}
389		action, ok := configActionToAction[name]
390		if !ok {
391			continue
392		}
393		for _, key := range binding.Keys {
394			if kb, ok := ParseKeyString(key); ok {
395				s.Global[kb] = action
396			}
397		}
398	}
399}
400
401func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
402	if cfg == nil {
403		return
404	}
405	for _, actionName := range orderedGlobalShortcutActions(cfg.Shortcuts.Actions) {
406		action, ok := configActionToAction[actionName]
407		if !ok {
408			continue
409		}
410		for _, keyStr := range cfg.Shortcuts.Actions[actionName].Keys {
411			if binding, ok := ParseKeyString(keyStr); ok {
412				s.Global[binding] = action
413			}
414		}
415	}
416}
417
418// orderedGlobalShortcutActions returns action names in registration order:
419// sorted, with built-in browser actions last so they win a shared key, as
420// they did when they were hard-coded. A stable order keeps the winner of a
421// key conflict the same across restarts.
422func orderedGlobalShortcutActions(actions map[string]entity.ActionBinding) []string {
423	builtins := entity.BuiltinGlobalShortcuts()
424	names := slices.Sorted(maps.Keys(actions))
425	slices.SortStableFunc(names, func(a, b string) int {
426		_, aBuiltin := builtins[a]
427		_, bBuiltin := builtins[b]
428		switch {
429		case aBuiltin == bBuiltin:
430			return 0
431		case aBuiltin:
432			return 1
433		default:
434			return -1
435		}
436	})
437	return names
438}
439
440func (s *ShortcutSet) registerFloatingProfileShortcutsFromWorkspace(ctx context.Context, workspace *entity.WorkspaceConfig) {
441	occupied := make(map[KeyBinding]Action, len(s.Global))
442	maps.Copy(occupied, s.Global)
443
444	for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
445		s.Global[shortcut.Binding] = shortcut.Action
446	}
447}
448
449type floatingProfileShortcut struct {
450	Binding KeyBinding
451	Action  Action
452}
453
454// collectFloatingProfileShortcutsFromWorkspace collects floating profile shortcuts from a workspace config.
455func collectFloatingProfileShortcutsFromWorkspace(
456	ctx context.Context,
457	workspace *entity.WorkspaceConfig,
458	occupied map[KeyBinding]Action,
459) []floatingProfileShortcut {
460	if workspace == nil {
461		return nil
462	}
463
464	log := logging.FromContext(ctx)
465	reserveGlobalOnlyShortcutBindings(occupied)
466	result := make([]floatingProfileShortcut, 0)
467	profileNames := make([]string, 0, len(workspace.FloatingPane.Profiles))
468	for profileName := range workspace.FloatingPane.Profiles {
469		profileNames = append(profileNames, profileName)
470	}
471	sort.Strings(profileNames)
472
473	for _, profileName := range profileNames {
474		profile := workspace.FloatingPane.Profiles[profileName]
475		url := strings.TrimSpace(profile.URL)
476		if url == "" {
477			log.Warn().Str("profile", profileName).Msg("floating profile missing URL, skipping")
478			continue
479		}
480
481		action := NewFloatingProfileAction(profileName, url)
482		keys := append([]string(nil), profile.Keys...)
483		sort.Strings(keys)
484		for _, keyStr := range keys {
485			binding, ok := ParseKeyString(keyStr)
486			if !ok {
487				log.Warn().Str("profile", profileName).Str("shortcut", keyStr).Msg("failed to parse floating profile shortcut")
488				continue
489			}
490
491			if existing, exists := occupied[binding]; exists {
492				log.Warn().
493					Str("profile", profileName).
494					Str("shortcut", keyStr).
495					Str("existing_action", string(existing)).
496					Msg("floating profile shortcut conflicts with existing global shortcut, skipping")
497				continue
498			}
499
500			occupied[binding] = action
501			result = append(result, floatingProfileShortcut{Binding: binding, Action: action})
502		}
503	}
504
505	return result
506}
507
508func reserveGlobalOnlyShortcutBindings(occupied map[KeyBinding]Action) {
509	if occupied == nil {
510		return
511	}
512
513	tabIndexActions := []Action{
514		ActionSwitchTabIndex1,
515		ActionSwitchTabIndex2,
516		ActionSwitchTabIndex3,
517		ActionSwitchTabIndex4,
518		ActionSwitchTabIndex5,
519		ActionSwitchTabIndex6,
520		ActionSwitchTabIndex7,
521		ActionSwitchTabIndex8,
522		ActionSwitchTabIndex9,
523	}
524
525	// Assumes gdk.KEY_1..gdk.KEY_9 are contiguous (ASCII '1'..'9').
526	// If that ever changes on a platform, map digits explicitly.
527	for i, action := range tabIndexActions {
528		binding := KeyBinding{Keyval: uint(gdk.KEY_1) + uint(i), Modifiers: ModAlt}
529		if _, exists := occupied[binding]; exists {
530			continue
531		}
532		occupied[binding] = action
533	}
534
535	altZero := KeyBinding{Keyval: uint(gdk.KEY_0), Modifiers: ModAlt}
536	if _, exists := occupied[altZero]; !exists {
537		occupied[altZero] = ActionSwitchTabIndex10
538	}
539
540	altTab := KeyBinding{Keyval: uint(gdk.KEY_Tab), Modifiers: ModAlt}
541	if _, exists := occupied[altTab]; !exists {
542		occupied[altTab] = ActionSwitchLastTab
543	}
544}
545
546func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[string]string, dest map[KeyBinding]Action, mode string) {
547	log := logging.FromContext(ctx)
548	var registered, parseErrors, unknownActions int
549	for key, configAction := range bindings {
550		if binding, ok := ParseKeyString(key); ok {
551			if action := mapModeConfigAction(configAction, mode); action != "" {
552				dest[binding] = action
553				registered++
554				log.Trace().
555					Str("key", key).
556					Str("configAction", configAction).
557					Str("action", string(action)).
558					Uint("keyval", binding.Keyval).
559					Msg(mode + " mode shortcut registered")
560			} else {
561				unknownActions++
562				log.Warn().Str("key", key).Str("configAction", configAction).Msg("unknown config action in " + mode + " mode")
563			}
564		} else {
565			parseErrors++
566			log.Warn().Str("key", key).Msg("failed to parse " + mode + " mode key")
567		}
568	}
569	log.Debug().
570		Int("registered", registered).
571		Int("parseErrors", parseErrors).
572		Int("unknownActions", unknownActions).
573		Msg(mode + " mode shortcuts built")
574}
575
576var configActionToAction = map[string]Action{
577	"toggle_floating_pane":         ActionToggleFloatingPane,
578	"toggle-floating-pane":         ActionToggleFloatingPane,
579	"toggle_history_systemview":    ActionToggleHistorySystemView,
580	"toggle-history-systemview":    ActionToggleHistorySystemView,
581	"toggle_favorites_sidebar":     ActionToggleFavoritesSidebar,
582	"toggle-favorites-sidebar":     ActionToggleFavoritesSidebar,
583	"toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
584	"toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
585	"toggle_config_systemview":     ActionToggleConfigSystemView,
586	"toggle-config-systemview":     ActionToggleConfigSystemView,
587
588	// Tab actions
589	"new_tab":      ActionNewTab,
590	"new-tab":      ActionNewTab,
591	"close_tab":    ActionCloseTab,
592	"close-tab":    ActionCloseTab,
593	"next_tab":     ActionNextTab,
594	"next-tab":     ActionNextTab,
595	"previous_tab": ActionPreviousTab,
596	"previous-tab": ActionPreviousTab,
597	"rename_tab":   ActionRenameTab,
598	"rename-tab":   ActionRenameTab,
599
600	// Pane actions
601	"split_right":           ActionSplitRight,
602	"split-right":           ActionSplitRight,
603	"split_left":            ActionSplitLeft,
604	"split-left":            ActionSplitLeft,
605	"split_up":              ActionSplitUp,
606	"split-up":              ActionSplitUp,
607	"split_down":            ActionSplitDown,
608	"split-down":            ActionSplitDown,
609	"close_pane":            ActionClosePane,
610	"close-pane":            ActionClosePane,
611	"stack_pane":            ActionStackPane,
612	"stack-pane":            ActionStackPane,
613	"move_pane_to_tab":      ActionMovePaneToTab,
614	"move-pane-to-tab":      ActionMovePaneToTab,
615	"move_pane_to_next_tab": ActionMovePaneToNextTab,
616	"move-pane-to-next-tab": ActionMovePaneToNextTab,
617	"eject_pane_to_window":  ActionEjectPaneToWindow,
618	"eject-pane-to-window":  ActionEjectPaneToWindow,
619
620	"consume_or_expel_left":  ActionConsumeOrExpelLeft,
621	"consume-or-expel-left":  ActionConsumeOrExpelLeft,
622	"consume_or_expel_right": ActionConsumeOrExpelRight,
623	"consume-or-expel-right": ActionConsumeOrExpelRight,
624	"consume_or_expel_up":    ActionConsumeOrExpelUp,
625	"consume-or-expel-up":    ActionConsumeOrExpelUp,
626	"consume_or_expel_down":  ActionConsumeOrExpelDown,
627	"consume-or-expel-down":  ActionConsumeOrExpelDown,
628
629	// Focus navigation
630	"focus-right": ActionFocusRight,
631	"focus-left":  ActionFocusLeft,
632	"focus-up":    ActionFocusUp,
633	"focus-down":  ActionFocusDown,
634
635	// Stack navigation
636	"stack-nav-up":   ActionStackNavUp,
637	"stack-up":       ActionStackNavUp,
638	"stack-nav-down": ActionStackNavDown,
639	"stack-down":     ActionStackNavDown,
640
641	// Resize actions
642	"resize-increase-left":  ActionResizeIncreaseLeft,
643	"resize-increase-right": ActionResizeIncreaseRight,
644	"resize-increase-up":    ActionResizeIncreaseUp,
645	"resize-increase-down":  ActionResizeIncreaseDown,
646	"resize-decrease-left":  ActionResizeDecreaseLeft,
647	"resize-decrease-right": ActionResizeDecreaseRight,
648	"resize-decrease-up":    ActionResizeDecreaseUp,
649	"resize-decrease-down":  ActionResizeDecreaseDown,
650	"resize-increase":       ActionResizeIncrease,
651	"resize-decrease":       ActionResizeDecrease,
652
653	// Session actions
654	"session-manager": ActionOpenSessionManager,
655
656	// Browser actions
657	"open-omnibox":      ActionOpenOmnibox,
658	"open-find":         ActionOpenFind,
659	"find-next":         ActionFindNext,
660	"find-prev":         ActionFindPrev,
661	"reload":            ActionReload,
662	"hard-reload":       ActionHardReload,
663	"open-devtools":     ActionOpenDevTools,
664	"go-back":           ActionGoBack,
665	"go-forward":        ActionGoForward,
666	"zoom-in":           ActionZoomIn,
667	"zoom-out":          ActionZoomOut,
668	"zoom-reset":        ActionZoomReset,
669	"quit":              ActionQuit,
670	"toggle-fullscreen": ActionToggleFullscreen,
671	"copy-url":          ActionCopyURL,
672	"print-page":        ActionPrintPage,
673
674	// Mode activation (used by the tmux prefix)
675	"enter-pane-mode":    ActionEnterPaneMode,
676	"enter-tab-mode":     ActionEnterTabMode,
677	"enter-resize-mode":  ActionEnterResizeMode,
678	"enter-session-mode": ActionEnterSessionMode,
679	"enter-vim-mode":     ActionEnterVimMode,
680
681	// Vim mode scroll actions
682	"vim_scroll_left":      ActionVimScrollLeft,
683	"vim-scroll-left":      ActionVimScrollLeft,
684	"vim_scroll_down":      ActionVimScrollDown,
685	"vim-scroll-down":      ActionVimScrollDown,
686	"vim_scroll_up":        ActionVimScrollUp,
687	"vim-scroll-up":        ActionVimScrollUp,
688	"vim_scroll_right":     ActionVimScrollRight,
689	"vim-scroll-right":     ActionVimScrollRight,
690	"vim_scroll_down_fast": ActionVimScrollDownFast,
691	"vim-scroll-down-fast": ActionVimScrollDownFast,
692	"vim_scroll_up_fast":   ActionVimScrollUpFast,
693	"vim-scroll-up-fast":   ActionVimScrollUpFast,
694}
695
696// FloatingProfileTarget carries the session identity and URL for a floating profile action.
697type FloatingProfileTarget struct {
698	SessionID string
699	URL       string
700}
701
702// NewFloatingProfileAction builds an action value carrying a floating profile session and URL.
703func NewFloatingProfileAction(profileName, url string) Action {
704	sessionID := strings.TrimSpace(profileName)
705	trimmedURL := strings.TrimSpace(url)
706	if sessionID == "" {
707		sessionID = trimmedURL
708	}
709	return Action(floatingProfileActionPrefix + sessionID + floatingProfileActionSeparator + trimmedURL)
710}
711
712// ParseFloatingProfileTarget extracts the session identity and target URL.
713func ParseFloatingProfileTarget(action Action) (FloatingProfileTarget, bool) {
714	value := string(action)
715	if !strings.HasPrefix(value, floatingProfileActionPrefix) {
716		return FloatingProfileTarget{}, false
717	}
718
719	payload := strings.TrimPrefix(value, floatingProfileActionPrefix)
720	sessionID := ""
721	url := payload
722	if parts := strings.SplitN(payload, floatingProfileActionSeparator, 2); len(parts) == 2 {
723		sessionID = strings.TrimSpace(parts[0])
724		url = parts[1]
725	}
726
727	url = strings.TrimSpace(url)
728	if url == "" {
729		return FloatingProfileTarget{}, false
730	}
731	if sessionID == "" {
732		sessionID = url
733	}
734
735	return FloatingProfileTarget{SessionID: sessionID, URL: url}, true
736}
737
738// ParseFloatingProfileAction extracts the target URL from a floating profile action.
739func ParseFloatingProfileAction(action Action) (string, bool) {
740	target, ok := ParseFloatingProfileTarget(action)
741	if !ok {
742		return "", false
743	}
744	return target.URL, true
745}
746
747func mapModeConfigAction(configAction, mode string) Action {
748	if mode == "vim" && configAction == "confirm" {
749		return ActionVimConfirm
750	}
751	return mapConfigAction(configAction)
752}
753
754// mapConfigAction maps config action names to Action constants.
755func mapConfigAction(configAction string) Action {
756	if configAction == "cancel" || configAction == "confirm" {
757		return ActionExitMode
758	}
759	return configActionToAction[configAction]
760}
761
762// ParseKeyString converts a config key string like "ctrl+t" to a KeyBinding.
763// Returns false if the string cannot be parsed.
764func ParseKeyString(s string) (KeyBinding, bool) {
765	if s == "" {
766		return KeyBinding{}, false
767	}
768
769	s = strings.TrimSpace(s)
770	if s == "" {
771		return KeyBinding{}, false
772	}
773	if s == "+" {
774		return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true
775	}
776
777	parts := strings.Split(s, "+")
778
779	var modifiers Modifier
780	var keyPart string
781
782	for _, part := range parts {
783		part = strings.TrimSpace(part)
784		if part == "" {
785			continue
786		}
787
788		lower := strings.ToLower(part)
789		switch lower {
790		case "ctrl", "control":
791			modifiers |= ModCtrl
792		case "shift":
793			modifiers |= ModShift
794		case "alt":
795			modifiers |= ModAlt
796		default:
797			if keyPart != "" {
798				return KeyBinding{}, false
799			}
800			keyPart = part
801		}
802	}
803
804	// Allow parsing "ctrl++" / "alt+shift++" where the key is "+".
805	if keyPart == "" && strings.HasSuffix(s, "++") {
806		keyPart = "+"
807	}
808
809	if keyPart == "" {
810		return KeyBinding{}, false
811	}
812
813	// Treat uppercase single-letter keys as Shift+<letter>.
814	if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
815		modifiers |= ModShift
816		keyPart = strings.ToLower(keyPart)
817	}
818
819	keyval, ok := stringToKeyval(keyPart)
820	if !ok {
821		return KeyBinding{}, false
822	}
823
824	return KeyBinding{
825		Keyval:    keyval,
826		Modifiers: modifiers,
827	}, true
828}
829
830// stringToKeyval converts a key name to its GDK keyval.
831func stringToKeyval(s string) (uint, bool) {
832	if s == "" {
833		return 0, false
834	}
835
836	if keyval, ok := keyvalByName[strings.ToLower(s)]; ok {
837		return keyval, true
838	}
839
840	// Single printable ASCII keys (a-z, symbols such as %, ", &).
841	// GDK keyvals for printable ASCII match their character codes.
842	if len(s) == 1 && s[0] > ' ' && s[0] <= '~' {
843		return uint(s[0]), true
844	}
845
846	return 0, false
847}
848
849// Lookup finds an action for the given key binding in the appropriate table.
850// It first checks the mode-specific table, then falls back to global unless
851// the current mode is Vim Mode. Vim Mode intentionally stays self-contained
852// so app-global shortcuts do not interrupt held Vim-style scrolling.
853func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
854	// Normalize the binding modifiers
855	binding.Modifiers &= modifierMask
856
857	// Check mode-specific table first
858	var modeTable ShortcutTable
859	switch mode {
860	case ModeTab:
861		modeTable = s.TabMode
862	case ModePane:
863		modeTable = s.PaneMode
864	case ModeResize:
865		modeTable = s.ResizeMode
866	case ModeSession:
867		modeTable = s.SessionMode
868	case ModeVim:
869		modeTable = s.VimMode
870	case ModePrefix:
871		modeTable = s.PrefixMode
872	}
873
874	if modeTable != nil {
875		if action, ok := modeTable[binding]; ok {
876			return action, true
877		}
878		// Shifted symbols (%, ", {, ...) arrive with Shift held but are
879		// configured without it, since the symbol already implies Shift.
880		if isShiftedSymbol(binding) {
881			if action, ok := modeTable[KeyBinding{Keyval: binding.Keyval}]; ok {
882				return action, true
883			}
884		}
885		// International layouts deliver some symbols as dead keys.
886		if symbol, ok := deadKeySymbols[binding.Keyval]; ok {
887			if action, ok := modeTable[KeyBinding{Keyval: symbol}]; ok {
888				return action, true
889			}
890		}
891		if mode == ModeVim {
892			return "", false
893		}
894	}
895
896	// Fall back to global
897	if action, ok := s.Global[binding]; ok {
898		return action, true
899	}
900
901	return "", false
902}
903
904// deadKeySymbols maps dead keysyms to the ASCII symbol they stand for.
905var deadKeySymbols = map[uint]uint{
906	uint(gdk.KEY_dead_diaeresis):  '"',
907	uint(gdk.KEY_dead_acute):      '\'',
908	uint(gdk.KEY_dead_grave):      '`',
909	uint(gdk.KEY_dead_circumflex): '^',
910	uint(gdk.KEY_dead_tilde):      '~',
911}
912
913// isShiftedSymbol reports a Shift-only binding on a printable ASCII symbol.
914func isShiftedSymbol(binding KeyBinding) bool {
915	k := binding.Keyval
916	isSymbol := k > ' ' && k <= '~' && (k < 'a' || k > 'z') && (k < 'A' || k > 'Z')
917	return binding.Modifiers == ModShift && isSymbol
918}
919
920// ShouldAutoExitMode returns true if the action should cause modal mode to exit.
921func ShouldAutoExitMode(action Action) bool {
922	switch action {
923	case ActionNewTab, ActionCloseTab, ActionRenameTab,
924		ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
925		ActionClosePane, ActionStackPane,
926		ActionMovePaneToTab, ActionMovePaneToNextTab, ActionEjectPaneToWindow,
927		ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown,
928		ActionOpenSessionManager:
929		return true
930	default:
931		return false
932	}
933}

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