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

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