v0.14.0 76d1892

window_shortcuts.go RawHistoryFolder

1package browser
2
3import (
4	"fmt"
5	"log"
6	"sync"
7	"time"
8
9	"github.com/bnema/dumber/internal/app/messaging"
10	"github.com/bnema/dumber/pkg/webkit"
11	"github.com/diamondburned/gotk4/pkg/gdk/v4"
12	"github.com/diamondburned/gotk4/pkg/gtk/v4"
13)
14
15// WindowShortcutHandler manages global shortcuts at the window level
16type WindowShortcutHandler struct {
17	window    *webkit.Window
18	app       *BrowserApp
19	shortcuts *webkit.WindowShortcuts
20	mu        sync.Mutex
21
22	// Debounce tracking
23	lastOmniboxToggle time.Time
24	lastFindToggle    time.Time
25	lastCopyURL       time.Time
26	lastDevTools      time.Time
27	lastPrint         time.Time
28}
29
30// NewWindowShortcutHandler creates a new window-level shortcut handler
31func NewWindowShortcutHandler(window *webkit.Window, app *BrowserApp) *WindowShortcutHandler {
32	h := &WindowShortcutHandler{
33		window: window,
34		app:    app,
35	}
36
37	if err := h.initialize(); err != nil {
38		log.Printf("[window-shortcuts] Failed to initialize: %v", err)
39		return nil
40	}
41
42	return h
43}
44
45func (h *WindowShortcutHandler) initialize() error {
46	// Create WindowShortcuts manager
47	h.shortcuts = webkit.NewWindowShortcuts(h.window)
48	if h.shortcuts == nil {
49		return ErrFailedToInitialize
50	}
51
52	// Register hardware keycode-based shortcuts (for AZERTY/international keyboards)
53	if err := h.registerKeycodeShortcuts(); err != nil {
54		log.Printf("[window-shortcuts] Failed to register keycode shortcuts: %v", err)
55	}
56
57	return h.registerGlobalShortcuts()
58}
59
60func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
61	shortcuts := []struct {
62		key     string
63		handler func()
64		desc    string
65	}{
66		{"ctrl+l", h.handleOmniboxToggle, "Omnibox toggle"},
67		{"ctrl+f", h.handleFindToggle, "Find in page"},
68		{"ctrl+shift+c", h.handleCopyURL, "Copy URL"},
69		{"ctrl+shift+p", h.handlePrint, "Print page"},
70		{"F12", h.handleDevTools, "Developer tools"},
71		// Pane management: Ctrl+P handled by WebView keyboard bridge (not window shortcuts)
72		// Tab management
73		{"ctrl+t", h.handleTabMode, "Enter tab mode"},
74		// Global tab navigation (Ctrl+Tab) is handled via EventControllerKey in registerKeycodeShortcuts
75		// This prevents GTK's default focus cycling behavior
76		// NOTE: Tab mode action keys (n, c, x, l, h, r, Tab, Escape, Enter) are NOT registered
77		// as global shortcuts. They are handled by the WebView keyboard bridge which checks
78		// if tab mode is active before processing them. This prevents them from consuming
79		// keys when tab mode is not active (allowing normal typing in web pages).
80		// Direct tab switching is now handled via hardware keycodes (see registerKeycodeShortcuts)
81		// This supports all keyboard layouts (QWERTY, AZERTY, etc.)
82		// Page reload shortcuts
83		{"ctrl+r", h.handleReload, "Reload page"},
84		{"ctrl+shift+r", h.handleHardReload, "Hard reload (bypass cache)"},
85		{"F5", h.handleReload, "Reload page"},
86		{"ctrl+F5", h.handleHardReload, "Hard reload (bypass cache)"},
87		// Zoom shortcuts - global level for proper active pane targeting
88		{"ctrl+plus", h.handleZoomIn, "Zoom in"},
89		{"ctrl+equal", h.handleZoomIn, "Zoom in (=)"},
90		{"ctrl+minus", h.handleZoomOut, "Zoom out"},
91		{"ctrl+0", h.handleZoomReset, "Zoom reset"},
92		// Workspace navigation shortcuts - global level for proper active pane targeting
93		{"alt+ArrowLeft", func() { h.handleWorkspaceNavigation(DirectionLeft) }, "Navigate left pane"},
94		{"alt+ArrowRight", func() { h.handleWorkspaceNavigation(DirectionRight) }, "Navigate right pane"},
95		{"alt+ArrowUp", func() { h.handleWorkspaceNavigation(DirectionUp) }, "Navigate up pane"},
96		{"alt+ArrowDown", func() { h.handleWorkspaceNavigation(DirectionDown) }, "Navigate down pane"},
97		// Vim-style workspace navigation
98		{"alt+h", func() { h.handleWorkspaceNavigation(DirectionLeft) }, "Navigate left pane (vim)"},
99		{"alt+l", func() { h.handleWorkspaceNavigation(DirectionRight) }, "Navigate right pane (vim)"},
100		{"alt+k", func() { h.handleWorkspaceNavigation(DirectionUp) }, "Navigate up pane (vim)"},
101		{"alt+j", func() { h.handleWorkspaceNavigation(DirectionDown) }, "Navigate down pane (vim)"},
102	}
103
104	for _, shortcut := range shortcuts {
105		h.shortcuts.RegisterShortcut(shortcut.key, shortcut.handler)
106		log.Printf("[window-shortcuts] Registered global shortcut: %s (%s)",
107			shortcut.key, shortcut.desc)
108	}
109
110	// NOTE: Ctrl+P is NOT registered here - it's handled by WebView's keyboard bridge
111	// (RegisterPaneModeHandler in workspace_manager.go) which calls app.workspace.EnterPaneMode()
112
113	return nil
114}
115
116// registerKeycodeShortcuts registers keyboard shortcuts based on hardware keycodes
117// This allows Alt+number shortcuts to work on AZERTY and other keyboard layouts
118func (h *WindowShortcutHandler) registerKeycodeShortcuts() error {
119	if h.window == nil {
120		return fmt.Errorf("window is nil")
121	}
122
123	gtkWindow := h.window.AsWindow()
124	if gtkWindow == nil {
125		return fmt.Errorf("gtk window is nil")
126	}
127
128	// Create event controller for keyboard events
129	keyController := gtk.NewEventControllerKey()
130	keyController.SetPropagationPhase(gtk.PhaseCapture)
131
132	// GDK key constants
133	const (
134		gdkKeyTab    = 0xff09
135		gdkKeyEscape = 0xff1b
136		gdkKeyReturn = 0xff0d
137		gdkKeyN      = 0x006e
138		gdkKeyC      = 0x0063
139		gdkKeyX      = 0x0078
140		gdkKeyL      = 0x006c
141		gdkKeyH      = 0x0068
142		gdkKeyR      = 0x0072
143	)
144
145	// Map hardware keycodes to tab indices
146	// These keycodes are the same across all keyboard layouts
147	keycodeToTab := map[uint]int{
148		10: 0, // 1/& key
149		11: 1, // 2/é key
150		12: 2, // 3/" key
151		13: 3, // 4/' key
152		14: 4, // 5/( key
153		15: 5, // 6/- key
154		16: 6, // 7/è key
155		17: 7, // 8/_ key
156		18: 8, // 9/ç key
157		19: 9, // 0/à key
158	}
159
160	keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool {
161		// IMPORTANT: Let WebView's keyboard bridge handle pane mode action keys first
162		// During pane mode, single-letter keys (h,j,k,l,x,s,v, etc.) must reach the WebView
163		// So we only intercept specific shortcuts here and let everything else through
164
165		// Handle Ctrl+Tab for next tab
166		if keyval == gdkKeyTab && state.Has(gdk.ControlMask) && !state.Has(gdk.ShiftMask) {
167			log.Printf("[window-shortcuts] Ctrl+Tab -> next tab")
168			h.handleNextTab()
169			return true // Consume event to prevent focus cycling
170		}
171
172		// Handle Ctrl+Shift+Tab for previous tab
173		if keyval == gdkKeyTab && state.Has(gdk.ControlMask) && state.Has(gdk.ShiftMask) {
174			log.Printf("[window-shortcuts] Ctrl+Shift+Tab -> previous tab")
175			h.handlePrevTab()
176			return true // Consume event to prevent focus cycling
177		}
178
179		// Check for Alt+number (but not with Ctrl or Shift)
180		if state.Has(gdk.AltMask) && !state.Has(gdk.ControlMask) && !state.Has(gdk.ShiftMask) {
181			// Check if this is a number key
182			if tabIndex, ok := keycodeToTab[keycode]; ok {
183				log.Printf("[window-shortcuts] Hardware keycode shortcut: Alt+keycode(%d) -> tab %d", keycode, tabIndex)
184				h.handleDirectTabSwitch(tabIndex)
185				return true // Handled
186			}
187		}
188
189		// Handle tab mode action keys (only when tab mode is active)
190		// These keys need to be checked here so they can propagate when tab mode is not active
191		if h.isTabModeActive() {
192			handled := false
193			action := ""
194
195			// Check for no modifiers (plain key press)
196			if !state.Has(gdk.ControlMask) && !state.Has(gdk.AltMask) {
197				switch keyval {
198				case gdkKeyN, gdkKeyC:
199					action = "new-tab"
200					handled = true
201				case gdkKeyX:
202					action = "close-tab"
203					handled = true
204				case gdkKeyL:
205					action = "next-tab"
206					handled = true
207				case gdkKeyH:
208					action = "previous-tab"
209					handled = true
210				case gdkKeyR:
211					action = "rename-tab"
212					handled = true
213				case gdkKeyTab:
214					action = "next-tab"
215					handled = true
216				case gdkKeyReturn:
217					action = "confirm"
218					handled = true
219				case gdkKeyEscape:
220					action = "cancel"
221					handled = true
222				}
223			}
224
225			// Handle Shift+Tab for previous tab in tab mode
226			if keyval == gdkKeyTab && state.Has(gdk.ShiftMask) && !state.Has(gdk.ControlMask) {
227				action = "previous-tab"
228				handled = true
229			}
230
231			if handled {
232				log.Printf("[window-shortcuts] Tab mode action: %s", action)
233				h.handleTabModeAction(action)
234				return true // Consume event
235			}
236		}
237
238		// Let all other keys propagate to WebView's keyboard bridge
239		// This includes Ctrl+P, Ctrl+T, and all pane mode action keys
240		return false
241	})
242
243	gtkWindow.AddController(keyController)
244	log.Printf("[window-shortcuts] Registered capture phase shortcuts: Ctrl+Tab, Alt+number")
245
246	return nil
247}
248
249func (h *WindowShortcutHandler) handleOmniboxToggle() {
250	h.handleUIToggle(&h.lastOmniboxToggle, "omnibox", "omnibox-nav-toggle")
251}
252
253func (h *WindowShortcutHandler) handleFindToggle() {
254	h.handleUIToggle(&h.lastFindToggle, "find", "omnibox-find-toggle")
255}
256
257func (h *WindowShortcutHandler) handleCopyURL() {
258	h.mu.Lock()
259	defer h.mu.Unlock()
260
261	if time.Since(h.lastCopyURL) < 100*time.Millisecond {
262		return
263	}
264	h.lastCopyURL = time.Now()
265
266	if h.app.activePane == nil || h.app.activePane.webView == nil {
267		log.Printf("[window-shortcuts] No active pane for copy URL")
268		return
269	}
270
271	log.Printf("[window-shortcuts] Copy URL -> pane %p", h.app.activePane.webView)
272
273	// Execute copy URL script directly on active pane
274	script := `
275		(async function() {
276			const toast = (message, type) => {
277				document.dispatchEvent(new CustomEvent('dumber:toast', {
278					detail: { message, duration: 2000, type }
279				}));
280			};
281
282			try {
283				const url = window.location.href;
284				await navigator.clipboard.writeText(url);
285				console.log('🔗 Window shortcut copied URL:', url);
286				toast('URL copied to clipboard!', 'success');
287			} catch (error) {
288				console.error('❌ Failed to copy URL:', error);
289				toast('Failed to copy URL', 'error');
290			}
291		})();
292	`
293
294	if err := h.app.activePane.webView.InjectScript(script); err != nil {
295		log.Printf("[window-shortcuts] Failed to inject copy URL script: %v", err)
296	}
297}
298
299func (h *WindowShortcutHandler) handleDevTools() {
300	h.mu.Lock()
301	defer h.mu.Unlock()
302
303	if time.Since(h.lastDevTools) < 200*time.Millisecond {
304		return
305	}
306	h.lastDevTools = time.Now()
307
308	if h.app.activePane == nil || h.app.activePane.webView == nil {
309		log.Printf("[window-shortcuts] No active pane for devtools")
310		return
311	}
312
313	log.Printf("[window-shortcuts] DevTools -> pane %p", h.app.activePane.webView)
314
315	if err := h.app.activePane.webView.ShowDevTools(); err != nil {
316		log.Printf("[window-shortcuts] Failed to show devtools: %v", err)
317	}
318}
319
320func (h *WindowShortcutHandler) handlePrint() {
321	h.mu.Lock()
322	defer h.mu.Unlock()
323
324	if time.Since(h.lastPrint) < 200*time.Millisecond {
325		return
326	}
327	h.lastPrint = time.Now()
328
329	if h.app.activePane == nil || h.app.activePane.webView == nil {
330		log.Printf("[window-shortcuts] No active pane for print")
331		return
332	}
333
334	log.Printf("[window-shortcuts] Print -> pane %p", h.app.activePane.webView)
335
336	if err := h.app.activePane.webView.ShowPrintDialog(); err != nil {
337		log.Printf("[window-shortcuts] Failed to show print dialog: %v", err)
338	}
339}
340
341func (h *WindowShortcutHandler) handleReload() {
342	h.mu.Lock()
343	defer h.mu.Unlock()
344
345	if h.app.activePane == nil || h.app.activePane.webView == nil {
346		log.Printf("[window-shortcuts] No active pane for reload")
347		return
348	}
349
350	log.Printf("[window-shortcuts] Reload page -> pane %p", h.app.activePane.webView)
351
352	if err := h.app.activePane.webView.Reload(); err != nil {
353		log.Printf("[window-shortcuts] Failed to reload page: %v", err)
354	}
355}
356
357func (h *WindowShortcutHandler) handleHardReload() {
358	h.mu.Lock()
359	defer h.mu.Unlock()
360
361	if h.app.activePane == nil || h.app.activePane.webView == nil {
362		log.Printf("[window-shortcuts] No active pane for hard reload")
363		return
364	}
365
366	log.Printf("[window-shortcuts] Hard reload (bypass cache) -> pane %p", h.app.activePane.webView)
367
368	if err := h.app.activePane.webView.ReloadBypassCache(); err != nil {
369		log.Printf("[window-shortcuts] Failed to hard reload page: %v", err)
370	}
371}
372
373// handleZoomIn increases zoom level by 10%
374func (h *WindowShortcutHandler) handleZoomIn() {
375	h.handleZoom("in", 1.1)
376}
377
378// handleZoomOut decreases zoom level by 10%
379func (h *WindowShortcutHandler) handleZoomOut() {
380	h.handleZoom("out", 1.0/1.1)
381}
382
383// handleZoomReset resets zoom to 100%
384func (h *WindowShortcutHandler) handleZoomReset() {
385	h.handleZoom("reset", 1.0)
386}
387
388// handleZoom applies zoom changes to the active pane
389func (h *WindowShortcutHandler) handleZoom(action string, multiplier float64) {
390	h.mu.Lock()
391	defer h.mu.Unlock()
392
393	if h.app.activePane == nil || h.app.activePane.webView == nil {
394		log.Printf("[window-shortcuts] No active pane for zoom %s", action)
395		return
396	}
397
398	activeWebView := h.app.activePane.webView
399	log.Printf("[window-shortcuts] Zoom %s -> pane %p", action, activeWebView)
400
401	// Get current zoom level
402	currentZoom := activeWebView.GetZoom()
403
404	// Calculate new zoom level
405	var newZoom float64
406	if action == "reset" {
407		newZoom = 1.0
408	} else {
409		newZoom = currentZoom * multiplier
410		// Apply zoom limits
411		if newZoom < 0.25 {
412			newZoom = 0.25
413		}
414		if newZoom > 5.0 {
415			newZoom = 5.0
416		}
417	}
418
419	log.Printf("[window-shortcuts] Zoom %s: %.2f -> %.2f", action, currentZoom, newZoom)
420
421	// Apply zoom to active pane
422	if err := activeWebView.SetZoom(newZoom); err != nil {
423		log.Printf("[window-shortcuts] Failed to set zoom: %v", err)
424		return
425	}
426
427	// Ensure GUI/toast component is loaded before showing toast
428	h.ensureGUIInActivePane("toast")
429
430	// Show zoom toast notification
431	toastScript := fmt.Sprintf(`
432		(function() {
433			try {
434				if (typeof window.__dumber_showZoomToast === 'function') {
435					window.__dumber_showZoomToast(%f);
436				}
437			} catch (e) {
438				console.error('[window-shortcuts] Failed to show zoom toast:', e);
439			}
440		})();
441	`, newZoom)
442
443	if err := activeWebView.InjectScript(toastScript); err != nil {
444		log.Printf("[window-shortcuts] Failed to show zoom toast: %v", err)
445	}
446}
447
448func (h *WindowShortcutHandler) ensureGUIInActivePane(component string) {
449	if h.app.activePane == nil {
450		return
451	}
452
453	pane := h.app.activePane
454	if !pane.HasGUI() {
455		log.Printf("[window-shortcuts] Injecting GUI into pane %s for %s", pane.ID(), component)
456		if h.app.workspace != nil {
457			h.app.workspace.ensureGUIInPane(pane)
458		}
459	}
460
461	// Ensure specific component is available
462	if !pane.HasGUIComponent(component) {
463		log.Printf("[window-shortcuts] Ensuring %s component in pane %s", component, pane.ID())
464		pane.SetGUIComponent(component, true)
465	}
466}
467
468func (h *WindowShortcutHandler) getWebViewId(pane *BrowserPane) string {
469	if pane == nil || pane.webView == nil {
470		return "unknown"
471	}
472	return pane.webView.IDString()
473}
474
475func (h *WindowShortcutHandler) handleUIToggle(lastToggle *time.Time, featureName, action string) {
476	h.mu.Lock()
477	defer h.mu.Unlock()
478
479	if time.Since(*lastToggle) < 50*time.Millisecond {
480		log.Printf("[window-shortcuts] %s toggle debounced", featureName)
481		return
482	}
483	*lastToggle = time.Now()
484
485	if h.app.activePane == nil || h.app.activePane.webView == nil {
486		log.Printf("[window-shortcuts] No active pane for %s", featureName)
487		return
488	}
489
490	log.Printf("[window-shortcuts] %s toggle -> pane %p", featureName, h.app.activePane.webView)
491
492	h.ensureGUIInActivePane("omnibox")
493
494	if err := h.app.activePane.webView.DispatchCustomEvent("dumber:ui:shortcut", map[string]any{
495		"action":    action,
496		"webviewId": h.getWebViewId(h.app.activePane),
497		"timestamp": time.Now().UnixMilli(),
498		"source":    "window-global",
499	}); err != nil {
500		log.Printf("[window-shortcuts] Failed to dispatch %s toggle: %v", featureName, err)
501	}
502}
503
504func (h *WindowShortcutHandler) handleWorkspaceNavigation(direction string) {
505	if h.app == nil || h.app.workspace == nil {
506		log.Printf("[window-shortcuts] No workspace for navigation")
507		return
508	}
509
510	log.Printf("[window-shortcuts] Workspace navigation: %s", direction)
511
512	if h.app.workspace.FocusNeighbor(direction) {
513		log.Printf("[window-shortcuts] Workspace navigation %s successful", direction)
514	} else {
515		log.Printf("[window-shortcuts] Workspace navigation %s failed", direction)
516	}
517}
518
519func (h *WindowShortcutHandler) handleClosePane() {
520	if h.app == nil || h.app.workspace == nil {
521		log.Printf("[window-shortcuts] No workspace for close pane")
522		return
523	}
524
525	// Find the currently active WebView
526	var activeWebView *webkit.WebView
527	log.Printf("[window-shortcuts] Searching for active WebView among %d WebViews", len(h.app.workspace.viewToNode))
528	for webView := range h.app.workspace.viewToNode {
529		if webView != nil {
530			isActive := webView.IsActive()
531			log.Printf("[window-shortcuts] WebView %d: IsActive=%t", webView.ID(), isActive)
532			if isActive {
533				activeWebView = webView
534				log.Printf("[window-shortcuts] Found active WebView: %d", webView.ID())
535				break
536			}
537		}
538	}
539
540	if activeWebView == nil {
541		log.Printf("[window-shortcuts] No active WebView found, using workspace closeCurrentPane")
542		h.app.workspace.closeCurrentPane()
543		return
544	}
545
546	// Check if the active WebView is a popup
547	node := h.app.workspace.viewToNode[activeWebView]
548	if node != nil && node.isPopup {
549		log.Printf("[window-shortcuts] Closing popup via OnWorkspaceMessage")
550		// Use the proper close-popup message for popups
551		msg := messaging.Message{
552			Event:     "close-popup",
553			WebViewID: activeWebView.IDString(),
554			Reason:    "user-ctrl-w",
555		}
556		h.app.workspace.OnWorkspaceMessage(activeWebView, msg)
557	} else {
558		log.Printf("[window-shortcuts] Closing regular pane via workspace closeCurrentPane")
559		// Use regular close for non-popup panes
560		h.app.workspace.closeCurrentPane()
561	}
562}
563
564// handlePaneMode enters pane mode for modal pane management.
565func (h *WindowShortcutHandler) handlePaneMode() {
566	if h.app == nil || h.app.workspace == nil {
567		log.Printf("[window-shortcuts] Cannot enter pane mode: workspace not available")
568		return
569	}
570
571	log.Printf("[window-shortcuts] Entering pane mode")
572	h.app.workspace.EnterPaneMode()
573}
574
575// handleTabMode enters tab mode for modal tab management.
576func (h *WindowShortcutHandler) handleTabMode() {
577	if h.app == nil || h.app.tabManager == nil {
578		log.Printf("[window-shortcuts] Cannot enter tab mode: tab manager not available")
579		return
580	}
581
582	log.Printf("[window-shortcuts] Entering tab mode")
583	h.app.tabManager.EnterTabMode()
584}
585
586// handleNextTab switches to the next tab.
587func (h *WindowShortcutHandler) handleNextTab() {
588	if h.app == nil || h.app.tabManager == nil {
589		log.Printf("[window-shortcuts] Cannot switch tab: tab manager not available")
590		return
591	}
592
593	log.Printf("[window-shortcuts] Switching to next tab")
594	if err := h.app.tabManager.NextTab(); err != nil {
595		log.Printf("[window-shortcuts] Failed to switch to next tab: %v", err)
596	}
597}
598
599// handlePrevTab switches to the previous tab.
600func (h *WindowShortcutHandler) handlePrevTab() {
601	if h.app == nil || h.app.tabManager == nil {
602		log.Printf("[window-shortcuts] Cannot switch tab: tab manager not available")
603		return
604	}
605
606	log.Printf("[window-shortcuts] Switching to previous tab")
607	if err := h.app.tabManager.PreviousTab(); err != nil {
608		log.Printf("[window-shortcuts] Failed to switch to previous tab: %v", err)
609	}
610}
611
612// handleDirectTabSwitch switches to a specific tab by index (0-based).
613func (h *WindowShortcutHandler) handleDirectTabSwitch(index int) {
614	if h.app == nil || h.app.tabManager == nil {
615		log.Printf("[window-shortcuts] Cannot switch tab: tab manager not available")
616		return
617	}
618
619	log.Printf("[window-shortcuts] Direct tab switch to index %d", index)
620	if err := h.app.tabManager.SwitchToTab(index); err != nil {
621		log.Printf("[window-shortcuts] Failed to switch to tab %d: %v", index, err)
622	}
623}
624
625// isTabModeActive checks if tab mode is currently active
626func (h *WindowShortcutHandler) isTabModeActive() bool {
627	if h.app == nil || h.app.tabManager == nil {
628		return false
629	}
630	return h.app.tabManager.IsTabModeActive()
631}
632
633// handleTabModeAction handles tab mode action keys (n, x, l, h, etc.)
634// Only processes actions when tab mode is active
635func (h *WindowShortcutHandler) handleTabModeAction(action string) {
636	if h.app == nil || h.app.tabManager == nil {
637		return
638	}
639
640	// Tab mode is active, handle the action
641	log.Printf("[window-shortcuts] Tab mode action: %s", action)
642	h.app.tabManager.HandleTabAction(action)
643}
644
645// Cleanup releases resources
646func (h *WindowShortcutHandler) Cleanup() {
647	// WindowShortcuts doesn't require explicit cleanup
648	h.shortcuts = nil
649}
650
651// Error definitions
652var (
653	ErrFailedToInitialize = fmt.Errorf("failed to initialize window shortcuts")
654)

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