v0.15.0 9006957

window_shortcuts.go RawHistoryFolder

1package browser
2
3import (
4	"fmt"
5	"sync"
6	"time"
7
8	"github.com/bnema/dumber/internal/app/messaging"
9	"github.com/bnema/dumber/internal/logging"
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		logging.Error(fmt.Sprintf("[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		logging.Error(fmt.Sprintf("[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		logging.Debug(fmt.Sprintf("[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			logging.Debug(fmt.Sprintf("[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			logging.Debug(fmt.Sprintf("[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				logging.Debug(fmt.Sprintf("[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		// IMPORTANT: If rename is in progress, let ALL keys through to the Entry widget
191		// These keys need to be checked here so they can propagate when tab mode is not active
192		if h.isTabModeActive() {
193			// Check if rename is in progress - if so, don't intercept ANY keys
194			// This allows the Entry widget to receive all keyboard input
195			if h.isRenameInProgress() {
196				return false // Let all keys propagate to Entry widget
197			}
198
199			handled := false
200			action := ""
201
202			// Check for no modifiers (plain key press)
203			if !state.Has(gdk.ControlMask) && !state.Has(gdk.AltMask) {
204				switch keyval {
205				case gdkKeyN, gdkKeyC:
206					action = "new-tab"
207					handled = true
208				case gdkKeyX:
209					action = "close-tab"
210					handled = true
211				case gdkKeyL:
212					action = "next-tab"
213					handled = true
214				case gdkKeyH:
215					action = "previous-tab"
216					handled = true
217				case gdkKeyR:
218					action = "rename-tab"
219					handled = true
220				case gdkKeyTab:
221					action = "next-tab"
222					handled = true
223				case gdkKeyReturn:
224					action = "confirm"
225					handled = true
226				case gdkKeyEscape:
227					action = "cancel"
228					handled = true
229				}
230			}
231
232			// Handle Shift+Tab for previous tab in tab mode
233			if keyval == gdkKeyTab && state.Has(gdk.ShiftMask) && !state.Has(gdk.ControlMask) {
234				action = "previous-tab"
235				handled = true
236			}
237
238			if handled {
239				logging.Debug(fmt.Sprintf("[window-shortcuts] Tab mode action: %s", action))
240				h.handleTabModeAction(action)
241				return true // Consume event
242			}
243		}
244
245		// Let all other keys propagate to WebView's keyboard bridge
246		// This includes Ctrl+P, Ctrl+T, and all pane mode action keys
247		return false
248	})
249
250	gtkWindow.AddController(keyController)
251	logging.Debug(fmt.Sprintf("[window-shortcuts] Registered capture phase shortcuts: Ctrl+Tab, Alt+number"))
252
253	return nil
254}
255
256func (h *WindowShortcutHandler) handleOmniboxToggle() {
257	h.handleUIToggle(&h.lastOmniboxToggle, "omnibox", "omnibox-nav-toggle")
258}
259
260func (h *WindowShortcutHandler) handleFindToggle() {
261	h.handleUIToggle(&h.lastFindToggle, "find", "omnibox-find-toggle")
262}
263
264func (h *WindowShortcutHandler) handleCopyURL() {
265	h.mu.Lock()
266	defer h.mu.Unlock()
267
268	if time.Since(h.lastCopyURL) < 100*time.Millisecond {
269		return
270	}
271	h.lastCopyURL = time.Now()
272
273	if h.app.activePane == nil || h.app.activePane.webView == nil {
274		logging.Debug(fmt.Sprintf("[window-shortcuts] No active pane for copy URL"))
275		return
276	}
277
278	logging.Debug(fmt.Sprintf("[window-shortcuts] Copy URL -> pane %p", h.app.activePane.webView))
279
280	// Execute copy URL script directly on active pane
281	script := `
282		(async function() {
283			const toast = (message, type) => {
284				document.dispatchEvent(new CustomEvent('dumber:toast', {
285					detail: { message, duration: 2000, type }
286				}));
287			};
288
289			try {
290				const url = window.location.href;
291				await navigator.clipboard.writeText(url);
292				console.log('🔗 Window shortcut copied URL:', url);
293				toast('URL copied to clipboard!', 'success');
294			} catch (error) {
295				console.error('❌ Failed to copy URL:', error);
296				toast('Failed to copy URL', 'error');
297			}
298		})();
299	`
300
301	if err := h.app.activePane.webView.InjectScript(script); err != nil {
302		logging.Error(fmt.Sprintf("[window-shortcuts] Failed to inject copy URL script: %v", err))
303	}
304}
305
306func (h *WindowShortcutHandler) handleDevTools() {
307	h.mu.Lock()
308	defer h.mu.Unlock()
309
310	if time.Since(h.lastDevTools) < 200*time.Millisecond {
311		return
312	}
313	h.lastDevTools = time.Now()
314
315	if h.app.activePane == nil || h.app.activePane.webView == nil {
316		logging.Debug(fmt.Sprintf("[window-shortcuts] No active pane for devtools"))
317		return
318	}
319
320	logging.Debug(fmt.Sprintf("[window-shortcuts] DevTools -> pane %p", h.app.activePane.webView))
321
322	if err := h.app.activePane.webView.ShowDevTools(); err != nil {
323		logging.Error(fmt.Sprintf("[window-shortcuts] Failed to show devtools: %v", err))
324	}
325}
326
327func (h *WindowShortcutHandler) handlePrint() {
328	h.mu.Lock()
329	defer h.mu.Unlock()
330
331	if time.Since(h.lastPrint) < 200*time.Millisecond {
332		return
333	}
334	h.lastPrint = time.Now()
335
336	if h.app.activePane == nil || h.app.activePane.webView == nil {
337		logging.Debug(fmt.Sprintf("[window-shortcuts] No active pane for print"))
338		return
339	}
340
341	logging.Debug(fmt.Sprintf("[window-shortcuts] Print -> pane %p", h.app.activePane.webView))
342
343	if err := h.app.activePane.webView.ShowPrintDialog(); err != nil {
344		logging.Error(fmt.Sprintf("[window-shortcuts] Failed to show print dialog: %v", err))
345	}
346}
347
348func (h *WindowShortcutHandler) handleReload() {
349	h.mu.Lock()
350	defer h.mu.Unlock()
351
352	if h.app.activePane == nil || h.app.activePane.webView == nil {
353		logging.Debug(fmt.Sprintf("[window-shortcuts] No active pane for reload"))
354		return
355	}
356
357	logging.Debug(fmt.Sprintf("[window-shortcuts] Reload page -> pane %p", h.app.activePane.webView))
358
359	if err := h.app.activePane.webView.Reload(); err != nil {
360		logging.Error(fmt.Sprintf("[window-shortcuts] Failed to reload page: %v", err))
361	}
362}
363
364func (h *WindowShortcutHandler) handleHardReload() {
365	h.mu.Lock()
366	defer h.mu.Unlock()
367
368	if h.app.activePane == nil || h.app.activePane.webView == nil {
369		logging.Debug(fmt.Sprintf("[window-shortcuts] No active pane for hard reload"))
370		return
371	}
372
373	logging.Debug(fmt.Sprintf("[window-shortcuts] Hard reload (bypass cache) -> pane %p", h.app.activePane.webView))
374
375	if err := h.app.activePane.webView.ReloadBypassCache(); err != nil {
376		logging.Error(fmt.Sprintf("[window-shortcuts] Failed to hard reload page: %v", err))
377	}
378}
379
380// handleZoomIn increases zoom level by 10%
381func (h *WindowShortcutHandler) handleZoomIn() {
382	h.handleZoom("in", 1.1)
383}
384
385// handleZoomOut decreases zoom level by 10%
386func (h *WindowShortcutHandler) handleZoomOut() {
387	h.handleZoom("out", 1.0/1.1)
388}
389
390// handleZoomReset resets zoom to 100%
391func (h *WindowShortcutHandler) handleZoomReset() {
392	h.handleZoom("reset", 1.0)
393}
394
395// handleZoom applies zoom changes to the active pane
396func (h *WindowShortcutHandler) handleZoom(action string, multiplier float64) {
397	h.mu.Lock()
398	defer h.mu.Unlock()
399
400	if h.app.activePane == nil || h.app.activePane.webView == nil {
401		logging.Debug(fmt.Sprintf("[window-shortcuts] No active pane for zoom %s", action))
402		return
403	}
404
405	activeWebView := h.app.activePane.webView
406	logging.Debug(fmt.Sprintf("[window-shortcuts] Zoom %s -> pane %p", action, activeWebView))
407
408	// Get current zoom level
409	currentZoom := activeWebView.GetZoom()
410
411	// Calculate new zoom level
412	var newZoom float64
413	if action == "reset" {
414		newZoom = 1.0
415	} else {
416		newZoom = currentZoom * multiplier
417		// Apply zoom limits
418		if newZoom < 0.25 {
419			newZoom = 0.25
420		}
421		if newZoom > 5.0 {
422			newZoom = 5.0
423		}
424	}
425
426	logging.Debug(fmt.Sprintf("[window-shortcuts] Zoom %s: %.2f -> %.2f", action, currentZoom, newZoom))
427
428	// Apply zoom to active pane
429	if err := activeWebView.SetZoom(newZoom); err != nil {
430		logging.Error(fmt.Sprintf("[window-shortcuts] Failed to set zoom: %v", err))
431		return
432	}
433
434	// Ensure GUI/toast component is loaded before showing toast
435	h.ensureGUIInActivePane("toast")
436
437	// Show zoom toast notification
438	toastScript := fmt.Sprintf(`
439		(function() {
440			try {
441				if (typeof window.__dumber_showZoomToast === 'function') {
442					window.__dumber_showZoomToast(%f);
443				}
444			} catch (e) {
445				console.error('[window-shortcuts] Failed to show zoom toast:', e);
446			}
447		})();
448	`, newZoom)
449
450	if err := activeWebView.InjectScript(toastScript); err != nil {
451		logging.Error(fmt.Sprintf("[window-shortcuts] Failed to show zoom toast: %v", err))
452	}
453}
454
455func (h *WindowShortcutHandler) ensureGUIInActivePane(component string) {
456	if h.app.activePane == nil {
457		return
458	}
459
460	pane := h.app.activePane
461	if !pane.HasGUI() {
462		logging.Debug(fmt.Sprintf("[window-shortcuts] Injecting GUI into pane %s for %s", pane.ID(), component))
463		if h.app.workspace != nil {
464			h.app.workspace.ensureGUIInPane(pane)
465		}
466	}
467
468	// Ensure specific component is available
469	if !pane.HasGUIComponent(component) {
470		logging.Debug(fmt.Sprintf("[window-shortcuts] Ensuring %s component in pane %s", component, pane.ID()))
471		pane.SetGUIComponent(component, true)
472	}
473}
474
475func (h *WindowShortcutHandler) getWebViewId(pane *BrowserPane) string {
476	if pane == nil || pane.webView == nil {
477		return "unknown"
478	}
479	return pane.webView.IDString()
480}
481
482func (h *WindowShortcutHandler) handleUIToggle(lastToggle *time.Time, featureName, action string) {
483	h.mu.Lock()
484	defer h.mu.Unlock()
485
486	if time.Since(*lastToggle) < 50*time.Millisecond {
487		logging.Debug(fmt.Sprintf("[window-shortcuts] %s toggle debounced", featureName))
488		return
489	}
490	*lastToggle = time.Now()
491
492	if h.app.activePane == nil || h.app.activePane.webView == nil {
493		logging.Debug(fmt.Sprintf("[window-shortcuts] No active pane for %s", featureName))
494		return
495	}
496
497	logging.Debug(fmt.Sprintf("[window-shortcuts] %s toggle -> pane %p", featureName, h.app.activePane.webView))
498
499	h.ensureGUIInActivePane("omnibox")
500
501	if err := h.app.activePane.webView.DispatchCustomEvent("dumber:ui:shortcut", map[string]any{
502		"action":    action,
503		"webviewId": h.getWebViewId(h.app.activePane),
504		"timestamp": time.Now().UnixMilli(),
505		"source":    "window-global",
506	}); err != nil {
507		logging.Error(fmt.Sprintf("[window-shortcuts] Failed to dispatch %s toggle: %v", featureName, err))
508	}
509}
510
511func (h *WindowShortcutHandler) handleWorkspaceNavigation(direction string) {
512	if h.app == nil || h.app.workspace == nil {
513		logging.Debug(fmt.Sprintf("[window-shortcuts] No workspace for navigation"))
514		return
515	}
516
517	logging.Debug(fmt.Sprintf("[window-shortcuts] Workspace navigation: %s", direction))
518
519	if h.app.workspace.FocusNeighbor(direction) {
520		logging.Debug(fmt.Sprintf("[window-shortcuts] Workspace navigation %s successful", direction))
521	} else {
522		logging.Debug(fmt.Sprintf("[window-shortcuts] Workspace navigation %s failed", direction))
523	}
524}
525
526func (h *WindowShortcutHandler) handleClosePane() {
527	if h.app == nil || h.app.workspace == nil {
528		logging.Debug(fmt.Sprintf("[window-shortcuts] No workspace for close pane"))
529		return
530	}
531
532	// Find the currently active WebView
533	var activeWebView *webkit.WebView
534	logging.Debug(fmt.Sprintf("[window-shortcuts] Searching for active WebView among %d WebViews", len(h.app.workspace.viewToNode)))
535	for webView := range h.app.workspace.viewToNode {
536		if webView != nil {
537			isActive := webView.IsActive()
538			logging.Debug(fmt.Sprintf("[window-shortcuts] WebView %d: IsActive=%t", webView.ID(), isActive))
539			if isActive {
540				activeWebView = webView
541				logging.Debug(fmt.Sprintf("[window-shortcuts] Found active WebView: %d", webView.ID()))
542				break
543			}
544		}
545	}
546
547	if activeWebView == nil {
548		logging.Debug(fmt.Sprintf("[window-shortcuts] No active WebView found, using workspace closeCurrentPane"))
549		h.app.workspace.closeCurrentPane()
550		return
551	}
552
553	// Check if the active WebView is a popup
554	node := h.app.workspace.viewToNode[activeWebView]
555	if node != nil && node.isPopup {
556		logging.Debug(fmt.Sprintf("[window-shortcuts] Closing popup via OnWorkspaceMessage"))
557		// Use the proper close-popup message for popups
558		msg := messaging.Message{
559			Event:     "close-popup",
560			WebViewID: activeWebView.IDString(),
561			Reason:    "user-ctrl-w",
562		}
563		h.app.workspace.OnWorkspaceMessage(activeWebView, msg)
564	} else {
565		logging.Debug(fmt.Sprintf("[window-shortcuts] Closing regular pane via workspace closeCurrentPane"))
566		// Use regular close for non-popup panes
567		h.app.workspace.closeCurrentPane()
568	}
569}
570
571// handlePaneMode enters pane mode for modal pane management.
572func (h *WindowShortcutHandler) handlePaneMode() {
573	if h.app == nil || h.app.workspace == nil {
574		logging.Debug(fmt.Sprintf("[window-shortcuts] Cannot enter pane mode: workspace not available"))
575		return
576	}
577
578	logging.Debug(fmt.Sprintf("[window-shortcuts] Entering pane mode"))
579	h.app.workspace.EnterPaneMode()
580}
581
582// handleTabMode enters tab mode for modal tab management.
583func (h *WindowShortcutHandler) handleTabMode() {
584	if h.app == nil || h.app.tabManager == nil {
585		logging.Debug(fmt.Sprintf("[window-shortcuts] Cannot enter tab mode: tab manager not available"))
586		return
587	}
588
589	logging.Debug(fmt.Sprintf("[window-shortcuts] Entering tab mode"))
590	h.app.tabManager.EnterTabMode()
591}
592
593// handleNextTab switches to the next tab.
594func (h *WindowShortcutHandler) handleNextTab() {
595	if h.app == nil || h.app.tabManager == nil {
596		logging.Debug(fmt.Sprintf("[window-shortcuts] Cannot switch tab: tab manager not available"))
597		return
598	}
599
600	logging.Debug(fmt.Sprintf("[window-shortcuts] Switching to next tab"))
601	if err := h.app.tabManager.NextTab(); err != nil {
602		logging.Error(fmt.Sprintf("[window-shortcuts] Failed to switch to next tab: %v", err))
603	}
604}
605
606// handlePrevTab switches to the previous tab.
607func (h *WindowShortcutHandler) handlePrevTab() {
608	if h.app == nil || h.app.tabManager == nil {
609		logging.Debug(fmt.Sprintf("[window-shortcuts] Cannot switch tab: tab manager not available"))
610		return
611	}
612
613	logging.Debug(fmt.Sprintf("[window-shortcuts] Switching to previous tab"))
614	if err := h.app.tabManager.PreviousTab(); err != nil {
615		logging.Error(fmt.Sprintf("[window-shortcuts] Failed to switch to previous tab: %v", err))
616	}
617}
618
619// handleDirectTabSwitch switches to a specific tab by index (0-based).
620func (h *WindowShortcutHandler) handleDirectTabSwitch(index int) {
621	if h.app == nil || h.app.tabManager == nil {
622		logging.Debug(fmt.Sprintf("[window-shortcuts] Cannot switch tab: tab manager not available"))
623		return
624	}
625
626	logging.Debug(fmt.Sprintf("[window-shortcuts] Direct tab switch to index %d", index))
627	if err := h.app.tabManager.SwitchToTab(index); err != nil {
628		logging.Error(fmt.Sprintf("[window-shortcuts] Failed to switch to tab %d: %v", index, err))
629	}
630}
631
632// isTabModeActive checks if tab mode is currently active
633func (h *WindowShortcutHandler) isTabModeActive() bool {
634	if h.app == nil || h.app.tabManager == nil {
635		return false
636	}
637	return h.app.tabManager.IsTabModeActive()
638}
639
640// isRenameInProgress checks if a tab rename is currently in progress
641func (h *WindowShortcutHandler) isRenameInProgress() bool {
642	if h.app == nil || h.app.tabManager == nil {
643		return false
644	}
645	return h.app.tabManager.IsRenameInProgress()
646}
647
648// handleTabModeAction handles tab mode action keys (n, x, l, h, etc.)
649// Only processes actions when tab mode is active
650func (h *WindowShortcutHandler) handleTabModeAction(action string) {
651	if h.app == nil || h.app.tabManager == nil {
652		return
653	}
654
655	// Tab mode is active, handle the action
656	logging.Debug(fmt.Sprintf("[window-shortcuts] Tab mode action: %s", action))
657	h.app.tabManager.HandleTabAction(action)
658}
659
660// Cleanup releases resources
661func (h *WindowShortcutHandler) Cleanup() {
662	// WindowShortcuts doesn't require explicit cleanup
663	h.shortcuts = nil
664}
665
666// Error definitions
667var (
668	ErrFailedToInitialize = fmt.Errorf("failed to initialize window shortcuts")
669)

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