detached 5041353

keyboard.go RawHistoryFolder

1package dispatcher
2
3import (
4	"context"
5	"fmt"
6
7	"github.com/bnema/dumber/internal/application/port"
8	"github.com/bnema/dumber/internal/application/usecase"
9	"github.com/bnema/dumber/internal/domain/entity"
10	"github.com/bnema/dumber/internal/logging"
11	"github.com/bnema/dumber/internal/ui/component"
12	"github.com/bnema/dumber/internal/ui/coordinator"
13	"github.com/bnema/dumber/internal/ui/input"
14	"github.com/bnema/puregotk/v4/glib"
15)
16
17const (
18	// historySystemViewURL is the full-page/systemview history surface.
19	// It remains reachable by direct navigation (for example typing
20	// dumb://history in the omnibox), but Ctrl+H no longer falls back to it.
21	// Ctrl+H is reserved for the native GTK history sidebar only.
22	historySystemViewURL   = "dumb://history"
23	favoritesSystemViewURL = "dumb://favorites"
24	configSystemViewURL    = "dumb://config"
25)
26
27type KeyboardActions struct {
28	NewTab         func(context.Context) error
29	CloseTab       func(context.Context) error
30	NextTab        func(context.Context) error
31	PreviousTab    func(context.Context) error
32	SwitchLastTab  func(context.Context) error
33	SwitchTabIndex func(context.Context, int) error
34	ActiveWebView  func(context.Context) port.WebView
35}
36
37// KeyboardDispatcher routes keyboard actions to appropriate coordinators.
38type KeyboardDispatcher struct {
39	actions                  KeyboardActions
40	wsCoord                  *coordinator.WorkspaceCoordinator
41	navCoord                 *coordinator.NavigationCoordinator
42	zoomUC                   *usecase.ManageZoomUseCase
43	copyURLUC                *usecase.CopyURLUseCase
44	actionHandlers           map[input.Action]func(ctx context.Context) error
45	onQuit                   func()
46	onFindOpen               func(ctx context.Context) error
47	onFindNext               func(ctx context.Context) error
48	onFindPrev               func(ctx context.Context) error
49	onFindClose              func(ctx context.Context) error
50	activePaneID             func(ctx context.Context) entity.PaneID
51	onSessionOpen            func(ctx context.Context, paneID entity.PaneID) error
52	onMovePaneToTab          func(ctx context.Context, paneID entity.PaneID) error
53	onMovePaneToNext         func(ctx context.Context, paneID entity.PaneID) error
54	onEjectPaneToWindow      func(ctx context.Context, paneID entity.PaneID) error
55	onVimModePulse           func(ctx context.Context, fast bool)
56	onToggleHistorySidebar   func(ctx context.Context) error
57	onToggleFavoritesSidebar func(ctx context.Context) error
58	onToggleCurrentFavorite  func(ctx context.Context) error
59	onToggleFloating         func(ctx context.Context) error
60	onOpenFloating           func(ctx context.Context, target input.FloatingProfileTarget) error
61}
62
63// NewKeyboardDispatcher creates a new KeyboardDispatcher.
64func NewKeyboardDispatcher(
65	ctx context.Context,
66	wsCoord *coordinator.WorkspaceCoordinator,
67	navCoord *coordinator.NavigationCoordinator,
68	zoomUC *usecase.ManageZoomUseCase,
69	copyURLUC *usecase.CopyURLUseCase,
70	actions KeyboardActions,
71	activePaneID func(context.Context) entity.PaneID,
72) *KeyboardDispatcher {
73	log := logging.FromContext(ctx)
74	log.Debug().Msg("creating keyboard dispatcher")
75
76	dispatcher := &KeyboardDispatcher{
77		actions:      actions,
78		wsCoord:      wsCoord,
79		navCoord:     navCoord,
80		zoomUC:       zoomUC,
81		copyURLUC:    copyURLUC,
82		activePaneID: activePaneID,
83	}
84	dispatcher.initActionHandlers()
85	return dispatcher
86}
87
88// SetOnQuit sets the callback for quit action.
89func (d *KeyboardDispatcher) SetOnQuit(fn func()) {
90	d.onQuit = fn
91}
92
93// SetOnFindOpen sets the callback for opening the find bar.
94func (d *KeyboardDispatcher) SetOnFindOpen(fn func(ctx context.Context) error) {
95	d.onFindOpen = fn
96}
97
98// SetOnFindNext sets the callback for the next match action.
99func (d *KeyboardDispatcher) SetOnFindNext(fn func(ctx context.Context) error) {
100	d.onFindNext = fn
101}
102
103// SetOnFindPrev sets the callback for the previous match action.
104func (d *KeyboardDispatcher) SetOnFindPrev(fn func(ctx context.Context) error) {
105	d.onFindPrev = fn
106}
107
108// SetOnFindClose sets the callback for closing the find bar.
109func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error) {
110	d.onFindClose = fn
111}
112
113// SetOnSessionOpen sets the callback for opening the session manager.
114func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context, paneID entity.PaneID) error) {
115	d.onSessionOpen = fn
116}
117
118func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context, paneID entity.PaneID) error) {
119	d.onMovePaneToTab = fn
120}
121
122func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context, paneID entity.PaneID) error) {
123	d.onMovePaneToNext = fn
124}
125
126func (d *KeyboardDispatcher) SetOnEjectPaneToWindow(fn func(ctx context.Context, paneID entity.PaneID) error) {
127	d.onEjectPaneToWindow = fn
128}
129
130func (d *KeyboardDispatcher) SetOnToggleHistorySidebar(fn func(ctx context.Context) error) {
131	d.onToggleHistorySidebar = fn
132}
133
134func (d *KeyboardDispatcher) SetOnToggleFavoritesSidebar(fn func(ctx context.Context) error) {
135	d.onToggleFavoritesSidebar = fn
136}
137
138func (d *KeyboardDispatcher) SetOnToggleCurrentPageFavorite(fn func(ctx context.Context) error) {
139	d.onToggleCurrentFavorite = fn
140}
141
142func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) {
143	d.onToggleFloating = fn
144}
145
146func (d *KeyboardDispatcher) SetOnOpenFloatingURL(fn func(ctx context.Context, url string) error) {
147	if fn == nil {
148		d.onOpenFloating = nil
149		return
150	}
151	d.onOpenFloating = func(ctx context.Context, target input.FloatingProfileTarget) error {
152		return fn(ctx, target.URL)
153	}
154}
155
156func (d *KeyboardDispatcher) SetOnOpenFloatingTarget(fn func(ctx context.Context, target input.FloatingProfileTarget) error) {
157	d.onOpenFloating = fn
158}
159
160// SetOnVimModePulse registers a callback for pane-local Vim mode pulse
161// visual feedback triggered after a page scroll action.
162// The cb receives a bool indicating whether the pulse should be "fast"
163// (stronger/longer for up-fast/down-fast).
164// If nil, pulse callbacks are no-ops.
165func (d *KeyboardDispatcher) SetOnVimModePulse(cb func(ctx context.Context, fast bool)) {
166	d.onVimModePulse = cb
167}
168
169// triggerPulse calls the registered pulse callback if set, or no-ops otherwise.
170func (d *KeyboardDispatcher) triggerPulse(ctx context.Context, fast bool) {
171	if d.onVimModePulse != nil {
172		d.onVimModePulse(ctx, fast)
173	}
174}
175
176func (d *KeyboardDispatcher) initActionHandlers() {
177	const (
178		firstTabIndex   = 0
179		secondTabIndex  = 1
180		thirdTabIndex   = 2
181		fourthTabIndex  = 3
182		fifthTabIndex   = 4
183		sixthTabIndex   = 5
184		seventhTabIndex = 6
185		eighthTabIndex  = 7
186		ninthTabIndex   = 8
187		tenthTabIndex   = 9
188	)
189	d.actionHandlers = map[input.Action]func(ctx context.Context) error{
190		// Tab actions
191		input.ActionNewTab:   func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "new tab", d.actions.NewTab) },
192		input.ActionCloseTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "close tab", d.actions.CloseTab) },
193		input.ActionNextTab:  func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "next tab", d.actions.NextTab) },
194		input.ActionPreviousTab: func(ctx context.Context) error {
195			return d.handleKeyboardAction(ctx, "previous tab", d.actions.PreviousTab)
196		},
197		input.ActionSwitchLastTab: func(ctx context.Context) error {
198			return d.handleKeyboardAction(ctx, "switch last tab", d.actions.SwitchLastTab)
199		},
200		input.ActionSwitchTabIndex1:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, firstTabIndex) },
201		input.ActionSwitchTabIndex2:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, secondTabIndex) },
202		input.ActionSwitchTabIndex3:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, thirdTabIndex) },
203		input.ActionSwitchTabIndex4:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, fourthTabIndex) },
204		input.ActionSwitchTabIndex5:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, fifthTabIndex) },
205		input.ActionSwitchTabIndex6:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, sixthTabIndex) },
206		input.ActionSwitchTabIndex7:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, seventhTabIndex) },
207		input.ActionSwitchTabIndex8:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, eighthTabIndex) },
208		input.ActionSwitchTabIndex9:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, ninthTabIndex) },
209		input.ActionSwitchTabIndex10: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, tenthTabIndex) },
210		input.ActionRenameTab: func(ctx context.Context) error {
211			return d.logNoop(ctx, "rename tab action (not yet implemented)")
212		},
213		// Pane actions
214		input.ActionSplitRight: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitRight) },
215		input.ActionSplitLeft:  func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitLeft) },
216		input.ActionSplitUp:    func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitUp) },
217		input.ActionSplitDown:  func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) },
218		input.ActionClosePane:  d.wsCoord.ClosePane,
219		input.ActionStackPane:  d.wsCoord.StackPane,
220		input.ActionMovePaneToTab: func(ctx context.Context) error {
221			return d.handleMovePaneToTab(ctx)
222		},
223		input.ActionMovePaneToNextTab: func(ctx context.Context) error {
224			return d.handleMovePaneToNextTab(ctx)
225		},
226		input.ActionEjectPaneToWindow: func(ctx context.Context) error {
227			return d.handleEjectPaneToWindow(ctx)
228		},
229		input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {
230			return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)
231		},
232		input.ActionConsumeOrExpelRight: func(ctx context.Context) error {
233			return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelRight)
234		},
235		input.ActionConsumeOrExpelUp: func(ctx context.Context) error {
236			return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelUp)
237		},
238		input.ActionConsumeOrExpelDown: func(ctx context.Context) error {
239			return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelDown)
240		},
241		input.ActionFocusRight: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavRight) },
242		input.ActionFocusLeft:  func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) },
243		input.ActionFocusUp:    func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) },
244		input.ActionFocusDown:  func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavDown) },
245		// Resize actions
246		input.ActionResizeIncreaseLeft:  func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseLeft) },
247		input.ActionResizeIncreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseRight) },
248		input.ActionResizeIncreaseUp:    func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseUp) },
249		input.ActionResizeIncreaseDown:  func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseDown) },
250		input.ActionResizeDecreaseLeft:  func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseLeft) },
251		input.ActionResizeDecreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseRight) },
252		input.ActionResizeDecreaseUp:    func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseUp) },
253		input.ActionResizeDecreaseDown:  func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseDown) },
254		input.ActionResizeIncrease:      func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncrease) },
255		input.ActionResizeDecrease:      func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecrease) },
256		// Stack navigation
257		input.ActionStackNavUp:   func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") },
258		input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") },
259		// Navigation
260		input.ActionGoBack:     d.handleGoBack,
261		input.ActionGoForward:  d.handleGoForward,
262		input.ActionReload:     d.handleReload,
263		input.ActionHardReload: d.handleHardReload,
264		input.ActionPrintPage:  d.handlePrintPage,
265		// Zoom actions
266		input.ActionZoomIn:    func(ctx context.Context) error { return d.handleZoom(ctx, "in") },
267		input.ActionZoomOut:   func(ctx context.Context) error { return d.handleZoom(ctx, "out") },
268		input.ActionZoomReset: func(ctx context.Context) error { return d.handleZoom(ctx, "reset") },
269		// Vim mode scroll actions — single mapping drives tap pulse + command.
270		input.ActionVimScrollLeft:     d.vimScrollTapHandler(input.ActionVimScrollLeft),
271		input.ActionVimScrollRight:    d.vimScrollTapHandler(input.ActionVimScrollRight),
272		input.ActionVimScrollUp:       d.vimScrollTapHandler(input.ActionVimScrollUp),
273		input.ActionVimScrollDown:     d.vimScrollTapHandler(input.ActionVimScrollDown),
274		input.ActionVimScrollUpFast:   d.vimScrollTapHandler(input.ActionVimScrollUpFast),
275		input.ActionVimScrollDownFast: d.vimScrollTapHandler(input.ActionVimScrollDownFast),
276		// UI
277		input.ActionOpenOmnibox:  d.navCoord.OpenOmnibox,
278		input.ActionOpenFind:     d.handleFindOpen,
279		input.ActionFindNext:     d.handleFindNext,
280		input.ActionFindPrev:     d.handleFindPrev,
281		input.ActionCloseFind:    d.handleFindClose,
282		input.ActionOpenDevTools: d.handleOpenDevTools,
283		input.ActionToggleFloatingPane: func(ctx context.Context) error {
284			if d.onToggleFloating != nil {
285				return d.onToggleFloating(ctx)
286			}
287			return d.logNoop(ctx, "toggle floating pane action (no handler)")
288		},
289		// ActionToggleHistorySystemView (default Ctrl+H) is intentionally bound
290		// to the native GTK history sidebar only. The dumb://history systemview
291		// remains available through direct navigation, not shortcut fallback.
292		input.ActionToggleHistorySystemView: func(ctx context.Context) error {
293			if d.onToggleHistorySidebar == nil {
294				return fmt.Errorf("history sidebar unavailable: toggle handler not wired")
295			}
296			return d.onToggleHistorySidebar(ctx)
297		},
298		input.ActionToggleFavoritesSidebar: func(ctx context.Context) error {
299			if d.onToggleFavoritesSidebar == nil {
300				return fmt.Errorf("favorites sidebar unavailable: toggle handler not wired")
301			}
302			return d.onToggleFavoritesSidebar(ctx)
303		},
304		input.ActionToggleCurrentPageFavorite: func(ctx context.Context) error {
305			if d.onToggleCurrentFavorite == nil {
306				return fmt.Errorf("favorites unavailable: toggle current page handler not wired")
307			}
308			return d.onToggleCurrentFavorite(ctx)
309		},
310		input.ActionToggleConfigSystemView: func(ctx context.Context) error {
311			return d.wsCoord.ToggleSystemViewRight(ctx, configSystemViewURL)
312		},
313		input.ActionToggleFullscreen: func(ctx context.Context) error {
314			return d.logNoop(ctx, "toggle fullscreen action (not yet implemented)")
315		},
316		// Clipboard
317		input.ActionCopyURL: d.handleCopyURL,
318		// Session management
319		input.ActionOpenSessionManager: d.handleSessionOpen,
320		// Application
321		input.ActionQuit: d.handleQuit,
322	}
323}
324
325// DispatchVimScrollLifecycle routes autonomous held-key page scroll events.
326// Stop deliberately avoids the normal action map so it emits neither another
327// scroll tap nor visual pulse.
328func (d *KeyboardDispatcher) DispatchVimScrollLifecycle(ctx context.Context, action input.Action, phase input.VimScrollPhase) error {
329	spec, ok := vimScrollSpec(action)
330	if !ok {
331		return nil
332	}
333	return d.withActiveWebView(ctx, "page scroll lifecycle", func(wv port.WebView) error {
334		if phase == input.VimScrollStop {
335			return d.navCoord.StopPageScroll(ctx, wv)
336		}
337		return d.navCoord.ScrollWebViewContinuous(ctx, wv, spec.cmd)
338	})
339}
340
341type vimScrollActionSpec struct {
342	cmd  usecase.PageScrollCommand
343	fast bool
344}
345
346// vimScrollSpec is the single source of truth for Vim Mode action→command
347// and fast/slow pulse used by tap handlers and held lifecycle routing.
348func vimScrollSpec(action input.Action) (vimScrollActionSpec, bool) {
349	switch action {
350	case input.ActionVimScrollLeft:
351		return vimScrollActionSpec{cmd: usecase.PageScrollLeft, fast: false}, true
352	case input.ActionVimScrollRight:
353		return vimScrollActionSpec{cmd: usecase.PageScrollRight, fast: false}, true
354	case input.ActionVimScrollUp:
355		return vimScrollActionSpec{cmd: usecase.PageScrollUp, fast: false}, true
356	case input.ActionVimScrollDown:
357		return vimScrollActionSpec{cmd: usecase.PageScrollDown, fast: false}, true
358	case input.ActionVimScrollUpFast:
359		return vimScrollActionSpec{cmd: usecase.PageScrollUpFast, fast: true}, true
360	case input.ActionVimScrollDownFast:
361		return vimScrollActionSpec{cmd: usecase.PageScrollDownFast, fast: true}, true
362	default:
363		return vimScrollActionSpec{}, false
364	}
365}
366
367func (d *KeyboardDispatcher) vimScrollTapHandler(action input.Action) func(context.Context) error {
368	spec, ok := vimScrollSpec(action)
369	if !ok {
370		return func(context.Context) error { return nil }
371	}
372	label := "page scroll " + string(action)
373	return func(ctx context.Context) error {
374		return d.withActiveWebView(ctx, label, func(wv port.WebView) error {
375			if err := d.navCoord.ScrollWebView(ctx, wv, spec.cmd); err != nil {
376				return err
377			}
378			d.triggerPulse(ctx, spec.fast)
379			return nil
380		})
381	}
382}
383
384func vimScrollCommand(action input.Action) (usecase.PageScrollCommand, bool) {
385	spec, ok := vimScrollSpec(action)
386	if !ok {
387		return 0, false
388	}
389	return spec.cmd, true
390}
391
392// Dispatch routes a keyboard action to the appropriate coordinator.
393func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error {
394	log := logging.FromContext(ctx)
395	log.Debug().Str("action", string(action)).Msg("dispatching keyboard action")
396
397	if target, ok := input.ParseFloatingProfileTarget(action); ok {
398		if d.onOpenFloating != nil {
399			return d.onOpenFloating(ctx, target)
400		}
401		log.Debug().Str("url", target.URL).Msg("floating profile action ignored (no handler)")
402		return nil
403	}
404
405	if handler, ok := d.actionHandlers[action]; ok {
406		return handler(ctx)
407	}
408
409	log.Warn().Str("action", string(action)).Msg("unhandled keyboard action")
410	return nil
411}
412
413func (d *KeyboardDispatcher) handleFindOpen(ctx context.Context) error {
414	if d.onFindOpen != nil {
415		return d.onFindOpen(ctx)
416	}
417	logging.FromContext(ctx).Debug().Msg("find open action (no handler)")
418	return nil
419}
420
421func (d *KeyboardDispatcher) handleFindNext(ctx context.Context) error {
422	if d.onFindNext != nil {
423		return d.onFindNext(ctx)
424	}
425	logging.FromContext(ctx).Debug().Msg("find next action (no handler)")
426	return nil
427}
428
429func (d *KeyboardDispatcher) handleFindPrev(ctx context.Context) error {
430	if d.onFindPrev != nil {
431		return d.onFindPrev(ctx)
432	}
433	logging.FromContext(ctx).Debug().Msg("find prev action (no handler)")
434	return nil
435}
436
437func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error {
438	if d.onFindClose != nil {
439		return d.onFindClose(ctx)
440	}
441	logging.FromContext(ctx).Debug().Msg("find close action (no handler)")
442	return nil
443}
444
445func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
446	if d.onSessionOpen != nil {
447		return d.onSessionOpen(ctx, d.activePaneID(ctx))
448	}
449	logging.FromContext(ctx).Debug().Msg("session open action (no handler)")
450	return nil
451}
452
453func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error {
454	if d.onMovePaneToTab != nil {
455		return d.onMovePaneToTab(ctx, d.activePaneID(ctx))
456	}
457	logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)")
458	return nil
459}
460
461func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error {
462	if d.onMovePaneToNext != nil {
463		return d.onMovePaneToNext(ctx, d.activePaneID(ctx))
464	}
465	logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)")
466	return nil
467}
468
469func (d *KeyboardDispatcher) handleEjectPaneToWindow(ctx context.Context) error {
470	if d.onEjectPaneToWindow != nil {
471		return d.onEjectPaneToWindow(ctx, d.activePaneID(ctx))
472	}
473	logging.FromContext(ctx).Debug().Msg("eject pane to window action (no handler)")
474	return nil
475}
476
477func (d *KeyboardDispatcher) handleSwitchTabIndex(ctx context.Context, index int) error {
478	if d.actions.SwitchTabIndex == nil {
479		logging.FromContext(ctx).Debug().Int("index", index).Msg("switch tab index action ignored (no handler)")
480		return nil
481	}
482	return d.actions.SwitchTabIndex(ctx, index)
483}
484
485func (d *KeyboardDispatcher) handleKeyboardAction(ctx context.Context, name string, fn func(context.Context) error) error {
486	if fn == nil {
487		logging.FromContext(ctx).Debug().Str("action", name).Msg("keyboard action ignored (no handler)")
488		return nil
489	}
490	return fn(ctx)
491}
492
493func (d *KeyboardDispatcher) activeWebView(ctx context.Context) port.WebView {
494	if d.actions.ActiveWebView == nil {
495		logging.FromContext(ctx).Debug().Msg("active webview unavailable (no handler)")
496		return nil
497	}
498	return d.actions.ActiveWebView(ctx)
499}
500
501func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
502	if ctx == nil {
503		return fmt.Errorf("missing context")
504	}
505	logging.FromContext(ctx).Debug().Msg(message)
506	return nil
507}
508
509func (d *KeyboardDispatcher) handleQuit(ctx context.Context) error {
510	if d.onQuit != nil {
511		d.onQuit()
512	}
513	if ctx == nil {
514		return fmt.Errorf("missing context")
515	}
516	return nil
517}
518
519func (d *KeyboardDispatcher) withActiveWebView(ctx context.Context, action string, fn func(port.WebView) error) error {
520	wv := d.activeWebView(ctx)
521	if wv == nil {
522		logging.FromContext(ctx).Debug().Str("action", action).Msg("no active webview for keyboard action")
523		return nil
524	}
525	return fn(wv)
526}
527
528func (d *KeyboardDispatcher) handleReload(ctx context.Context) error {
529	return d.withActiveWebView(ctx, "reload", func(wv port.WebView) error {
530		return d.navCoord.ReloadWebView(ctx, wv, false)
531	})
532}
533
534func (d *KeyboardDispatcher) handleHardReload(ctx context.Context) error {
535	return d.withActiveWebView(ctx, "hard reload", func(wv port.WebView) error {
536		return d.navCoord.ReloadWebView(ctx, wv, true)
537	})
538}
539
540func (d *KeyboardDispatcher) handleGoBack(ctx context.Context) error {
541	return d.withActiveWebView(ctx, "go back", func(wv port.WebView) error {
542		return d.navCoord.GoBackWebView(ctx, wv)
543	})
544}
545
546func (d *KeyboardDispatcher) handleGoForward(ctx context.Context) error {
547	return d.withActiveWebView(ctx, "go forward", func(wv port.WebView) error {
548		return d.navCoord.GoForwardWebView(ctx, wv)
549	})
550}
551
552func (d *KeyboardDispatcher) handlePrintPage(ctx context.Context) error {
553	return d.withActiveWebView(ctx, "print page", func(wv port.WebView) error {
554		return d.navCoord.PrintWebView(ctx, wv)
555	})
556}
557
558func (d *KeyboardDispatcher) handleOpenDevTools(ctx context.Context) error {
559	return d.withActiveWebView(ctx, "open devtools", func(wv port.WebView) error {
560		return d.navCoord.OpenDevToolsWebView(ctx, wv)
561	})
562}
563
564// handleZoom processes zoom in/out/reset actions for the active WebView.
565func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) error {
566	log := logging.FromContext(ctx)
567
568	if d.zoomUC == nil {
569		log.Warn().Msg("zoom use case not available")
570		return nil
571	}
572
573	wv := d.activeWebView(ctx)
574	if wv == nil {
575		log.Debug().Msg("no active webview for zoom")
576		return nil
577	}
578
579	zoomKey, err := usecase.ExtractZoomKey(wv.URI())
580	if err != nil {
581		log.Debug().Str("uri", wv.URI()).Msg("cannot extract zoom key")
582		return nil
583	}
584
585	current := wv.GetZoomLevel()
586	var newZoom *entity.ZoomLevel
587
588	switch action {
589	case "in":
590		newZoom, err = d.zoomUC.ZoomIn(ctx, zoomKey, current)
591	case "out":
592		newZoom, err = d.zoomUC.ZoomOut(ctx, zoomKey, current)
593	case "reset":
594		err = d.zoomUC.ResetZoom(ctx, zoomKey)
595		if err == nil {
596			newZoom = entity.NewZoomLevel(zoomKey, d.zoomUC.DefaultZoom())
597		}
598	}
599
600	if err != nil {
601		log.Error().Err(err).Str("action", action).Msg("zoom action failed")
602		return err
603	}
604
605	if newZoom != nil {
606		if setErr := wv.SetZoomLevel(ctx, newZoom.ZoomFactor); setErr != nil {
607			log.Error().Err(setErr).Float64("zoom", newZoom.ZoomFactor).Msg("failed to apply zoom")
608			return setErr
609		}
610
611		// Notify omnibox to update zoom indicator
612		d.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor)
613
614		// Show zoom toast on the active pane
615		zoomPercent := entity.ZoomPercentage(newZoom.ZoomFactor)
616		d.wsCoord.ShowZoomToast(ctx, zoomPercent)
617
618		log.Debug().
619			Str("zoom_key", zoomKey).
620			Str("action", action).
621			Float64("zoom", newZoom.ZoomFactor).
622			Msg("zoom applied")
623	}
624
625	return nil
626}
627
628// handleCopyURL copies the active pane's URL to clipboard.
629func (d *KeyboardDispatcher) handleCopyURL(ctx context.Context) error {
630	log := logging.FromContext(ctx)
631
632	if d.copyURLUC == nil {
633		log.Warn().Msg("copy URL use case not available")
634		return nil
635	}
636
637	wv := d.activeWebView(ctx)
638	if wv == nil {
639		log.Debug().Msg("no active webview for copy URL")
640		return nil
641	}
642
643	uri := wv.URI()
644	if uri == "" {
645		log.Debug().Msg("active webview has empty URI")
646		return nil
647	}
648
649	// Copy URL in background goroutine
650	go func() {
651		if err := d.copyURLUC.Copy(ctx, uri); err != nil {
652			log.Error().Err(err).Str("uri", uri).Msg("copy URL failed")
653			return
654		}
655
656		// Show toast on GTK main thread
657		cb := glib.SourceFunc(func(_ uintptr) bool {
658			d.wsCoord.ShowToastOnActivePane(ctx, "URL copied", component.ToastSuccess)
659			return false
660		})
661		glib.IdleAdd(&cb, 0)
662	}()
663
664	return nil
665}

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