v0.27.2 5960dd8

keyboard.go RawHistoryFolder

1package dispatcher
2
3import (
4	"context"
5	"fmt"
6
7	"github.com/bnema/dumber/internal/application/usecase"
8	"github.com/bnema/dumber/internal/domain/entity"
9	domainurl "github.com/bnema/dumber/internal/domain/url"
10	"github.com/bnema/dumber/internal/infrastructure/config"
11	"github.com/bnema/dumber/internal/logging"
12	"github.com/bnema/dumber/internal/ui/component"
13	"github.com/bnema/dumber/internal/ui/coordinator"
14	"github.com/bnema/dumber/internal/ui/input"
15	"github.com/jwijenbergh/puregotk/v4/glib"
16)
17
18// KeyboardDispatcher routes keyboard actions to appropriate coordinators.
19type KeyboardDispatcher struct {
20	tabCoord         *coordinator.TabCoordinator
21	wsCoord          *coordinator.WorkspaceCoordinator
22	navCoord         *coordinator.NavigationCoordinator
23	zoomUC           *usecase.ManageZoomUseCase
24	copyURLUC        *usecase.CopyURLUseCase
25	actionHandlers   map[input.Action]func(ctx context.Context) error
26	onQuit           func()
27	onFindOpen       func(ctx context.Context) error
28	onFindNext       func(ctx context.Context) error
29	onFindPrev       func(ctx context.Context) error
30	onFindClose      func(ctx context.Context) error
31	onSessionOpen    func(ctx context.Context) error
32	onMovePaneToTab  func(ctx context.Context) error
33	onMovePaneToNext func(ctx context.Context) error
34	onToggleFloating func(ctx context.Context) error
35	onOpenFloating   func(ctx context.Context, target input.FloatingProfileTarget) error
36}
37
38// NewKeyboardDispatcher creates a new KeyboardDispatcher.
39func NewKeyboardDispatcher(
40	ctx context.Context,
41	tabCoord *coordinator.TabCoordinator,
42	wsCoord *coordinator.WorkspaceCoordinator,
43	navCoord *coordinator.NavigationCoordinator,
44	zoomUC *usecase.ManageZoomUseCase,
45	copyURLUC *usecase.CopyURLUseCase,
46) *KeyboardDispatcher {
47	log := logging.FromContext(ctx)
48	log.Debug().Msg("creating keyboard dispatcher")
49
50	dispatcher := &KeyboardDispatcher{
51		tabCoord:  tabCoord,
52		wsCoord:   wsCoord,
53		navCoord:  navCoord,
54		zoomUC:    zoomUC,
55		copyURLUC: copyURLUC,
56	}
57	dispatcher.initActionHandlers()
58	return dispatcher
59}
60
61// SetOnQuit sets the callback for quit action.
62func (d *KeyboardDispatcher) SetOnQuit(fn func()) {
63	d.onQuit = fn
64}
65
66// SetOnFindOpen sets the callback for opening the find bar.
67func (d *KeyboardDispatcher) SetOnFindOpen(fn func(ctx context.Context) error) {
68	d.onFindOpen = fn
69}
70
71// SetOnFindNext sets the callback for the next match action.
72func (d *KeyboardDispatcher) SetOnFindNext(fn func(ctx context.Context) error) {
73	d.onFindNext = fn
74}
75
76// SetOnFindPrev sets the callback for the previous match action.
77func (d *KeyboardDispatcher) SetOnFindPrev(fn func(ctx context.Context) error) {
78	d.onFindPrev = fn
79}
80
81// SetOnFindClose sets the callback for closing the find bar.
82func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error) {
83	d.onFindClose = fn
84}
85
86// SetOnSessionOpen sets the callback for opening the session manager.
87func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context) error) {
88	d.onSessionOpen = fn
89}
90
91func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context) error) {
92	d.onMovePaneToTab = fn
93}
94
95func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context) error) {
96	d.onMovePaneToNext = fn
97}
98
99func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) {
100	d.onToggleFloating = fn
101}
102
103func (d *KeyboardDispatcher) SetOnOpenFloatingURL(fn func(ctx context.Context, url string) error) {
104	if fn == nil {
105		d.onOpenFloating = nil
106		return
107	}
108	d.onOpenFloating = func(ctx context.Context, target input.FloatingProfileTarget) error {
109		return fn(ctx, target.URL)
110	}
111}
112
113func (d *KeyboardDispatcher) SetOnOpenFloatingTarget(fn func(ctx context.Context, target input.FloatingProfileTarget) error) {
114	d.onOpenFloating = fn
115}
116
117func (d *KeyboardDispatcher) initActionHandlers() {
118	const (
119		firstTabIndex   = 0
120		secondTabIndex  = 1
121		thirdTabIndex   = 2
122		fourthTabIndex  = 3
123		fifthTabIndex   = 4
124		sixthTabIndex   = 5
125		seventhTabIndex = 6
126		eighthTabIndex  = 7
127		ninthTabIndex   = 8
128		tenthTabIndex   = 9
129	)
130	d.actionHandlers = map[input.Action]func(ctx context.Context) error{
131		// Tab actions
132		input.ActionNewTab: func(ctx context.Context) error {
133			cfg := config.Get()
134			_, err := d.tabCoord.Create(ctx, domainurl.Normalize(cfg.Workspace.NewPaneURL))
135			return err
136		},
137		input.ActionCloseTab:         d.tabCoord.Close,
138		input.ActionNextTab:          d.tabCoord.SwitchNext,
139		input.ActionPreviousTab:      d.tabCoord.SwitchPrev,
140		input.ActionSwitchLastTab:    d.tabCoord.SwitchToLastActive,
141		input.ActionSwitchTabIndex1:  func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, firstTabIndex) },
142		input.ActionSwitchTabIndex2:  func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, secondTabIndex) },
143		input.ActionSwitchTabIndex3:  func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, thirdTabIndex) },
144		input.ActionSwitchTabIndex4:  func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, fourthTabIndex) },
145		input.ActionSwitchTabIndex5:  func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, fifthTabIndex) },
146		input.ActionSwitchTabIndex6:  func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, sixthTabIndex) },
147		input.ActionSwitchTabIndex7:  func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, seventhTabIndex) },
148		input.ActionSwitchTabIndex8:  func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, eighthTabIndex) },
149		input.ActionSwitchTabIndex9:  func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, ninthTabIndex) },
150		input.ActionSwitchTabIndex10: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, tenthTabIndex) },
151		input.ActionRenameTab: func(ctx context.Context) error {
152			return d.logNoop(ctx, "rename tab action (not yet implemented)")
153		},
154		// Pane actions
155		input.ActionSplitRight: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitRight) },
156		input.ActionSplitLeft:  func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitLeft) },
157		input.ActionSplitUp:    func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitUp) },
158		input.ActionSplitDown:  func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) },
159		input.ActionClosePane:  d.wsCoord.ClosePane,
160		input.ActionStackPane:  d.wsCoord.StackPane,
161		input.ActionMovePaneToTab: func(ctx context.Context) error {
162			return d.handleMovePaneToTab(ctx)
163		},
164		input.ActionMovePaneToNextTab: func(ctx context.Context) error {
165			return d.handleMovePaneToNextTab(ctx)
166		},
167		input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {
168			return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)
169		},
170		input.ActionConsumeOrExpelRight: func(ctx context.Context) error {
171			return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelRight)
172		},
173		input.ActionConsumeOrExpelUp: func(ctx context.Context) error {
174			return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelUp)
175		},
176		input.ActionConsumeOrExpelDown: func(ctx context.Context) error {
177			return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelDown)
178		},
179		input.ActionFocusRight: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavRight) },
180		input.ActionFocusLeft:  func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) },
181		input.ActionFocusUp:    func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) },
182		input.ActionFocusDown:  func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavDown) },
183		// Resize actions
184		input.ActionResizeIncreaseLeft:  func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseLeft) },
185		input.ActionResizeIncreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseRight) },
186		input.ActionResizeIncreaseUp:    func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseUp) },
187		input.ActionResizeIncreaseDown:  func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseDown) },
188		input.ActionResizeDecreaseLeft:  func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseLeft) },
189		input.ActionResizeDecreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseRight) },
190		input.ActionResizeDecreaseUp:    func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseUp) },
191		input.ActionResizeDecreaseDown:  func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseDown) },
192		input.ActionResizeIncrease:      func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncrease) },
193		input.ActionResizeDecrease:      func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecrease) },
194		// Stack navigation
195		input.ActionStackNavUp:   func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") },
196		input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") },
197		// Navigation
198		input.ActionGoBack:     d.navCoord.GoBack,
199		input.ActionGoForward:  d.navCoord.GoForward,
200		input.ActionReload:     d.navCoord.Reload,
201		input.ActionHardReload: d.navCoord.HardReload,
202		input.ActionPrintPage:  d.navCoord.PrintPage,
203		// Zoom actions
204		input.ActionZoomIn:    func(ctx context.Context) error { return d.handleZoom(ctx, "in") },
205		input.ActionZoomOut:   func(ctx context.Context) error { return d.handleZoom(ctx, "out") },
206		input.ActionZoomReset: func(ctx context.Context) error { return d.handleZoom(ctx, "reset") },
207		// UI
208		input.ActionOpenOmnibox:  d.navCoord.OpenOmnibox,
209		input.ActionOpenFind:     d.handleFindOpen,
210		input.ActionFindNext:     d.handleFindNext,
211		input.ActionFindPrev:     d.handleFindPrev,
212		input.ActionCloseFind:    d.handleFindClose,
213		input.ActionOpenDevTools: d.navCoord.OpenDevTools,
214		input.ActionToggleFloatingPane: func(ctx context.Context) error {
215			if d.onToggleFloating != nil {
216				return d.onToggleFloating(ctx)
217			}
218			return d.logNoop(ctx, "toggle floating pane action (no handler)")
219		},
220		input.ActionToggleFullscreen: func(ctx context.Context) error {
221			return d.logNoop(ctx, "toggle fullscreen action (not yet implemented)")
222		},
223		// Clipboard
224		input.ActionCopyURL: d.handleCopyURL,
225		// Session management
226		input.ActionOpenSessionManager: d.handleSessionOpen,
227		// Application
228		input.ActionQuit: d.handleQuit,
229	}
230}
231
232// Dispatch routes a keyboard action to the appropriate coordinator.
233func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error {
234	log := logging.FromContext(ctx)
235	log.Debug().Str("action", string(action)).Msg("dispatching keyboard action")
236
237	if target, ok := input.ParseFloatingProfileTarget(action); ok {
238		if d.onOpenFloating != nil {
239			return d.onOpenFloating(ctx, target)
240		}
241		log.Debug().Str("url", target.URL).Msg("floating profile action ignored (no handler)")
242		return nil
243	}
244
245	if handler, ok := d.actionHandlers[action]; ok {
246		return handler(ctx)
247	}
248
249	log.Warn().Str("action", string(action)).Msg("unhandled keyboard action")
250	return nil
251}
252
253func (d *KeyboardDispatcher) handleFindOpen(ctx context.Context) error {
254	if d.onFindOpen != nil {
255		return d.onFindOpen(ctx)
256	}
257	logging.FromContext(ctx).Debug().Msg("find open action (no handler)")
258	return nil
259}
260
261func (d *KeyboardDispatcher) handleFindNext(ctx context.Context) error {
262	if d.onFindNext != nil {
263		return d.onFindNext(ctx)
264	}
265	logging.FromContext(ctx).Debug().Msg("find next action (no handler)")
266	return nil
267}
268
269func (d *KeyboardDispatcher) handleFindPrev(ctx context.Context) error {
270	if d.onFindPrev != nil {
271		return d.onFindPrev(ctx)
272	}
273	logging.FromContext(ctx).Debug().Msg("find prev action (no handler)")
274	return nil
275}
276
277func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error {
278	if d.onFindClose != nil {
279		return d.onFindClose(ctx)
280	}
281	logging.FromContext(ctx).Debug().Msg("find close action (no handler)")
282	return nil
283}
284
285func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
286	if d.onSessionOpen != nil {
287		return d.onSessionOpen(ctx)
288	}
289	logging.FromContext(ctx).Debug().Msg("session open action (no handler)")
290	return nil
291}
292
293func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error {
294	if d.onMovePaneToTab != nil {
295		return d.onMovePaneToTab(ctx)
296	}
297	logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)")
298	return nil
299}
300
301func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error {
302	if d.onMovePaneToNext != nil {
303		return d.onMovePaneToNext(ctx)
304	}
305	logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)")
306	return nil
307}
308
309func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
310	if ctx == nil {
311		return fmt.Errorf("missing context")
312	}
313	logging.FromContext(ctx).Debug().Msg(message)
314	return nil
315}
316
317func (d *KeyboardDispatcher) handleQuit(ctx context.Context) error {
318	if d.onQuit != nil {
319		d.onQuit()
320	}
321	if ctx == nil {
322		return fmt.Errorf("missing context")
323	}
324	return nil
325}
326
327// handleZoom processes zoom in/out/reset actions for the active WebView.
328func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) error {
329	log := logging.FromContext(ctx)
330
331	if d.zoomUC == nil {
332		log.Warn().Msg("zoom use case not available")
333		return nil
334	}
335
336	wv := d.navCoord.ActiveWebView(ctx)
337	if wv == nil {
338		log.Debug().Msg("no active webview for zoom")
339		return nil
340	}
341
342	domain, err := usecase.ExtractDomain(wv.URI())
343	if err != nil {
344		log.Debug().Str("uri", wv.URI()).Msg("cannot extract domain for zoom")
345		return nil
346	}
347
348	current := wv.GetZoomLevel()
349	var newZoom *entity.ZoomLevel
350
351	switch action {
352	case "in":
353		newZoom, err = d.zoomUC.ZoomIn(ctx, domain, current)
354	case "out":
355		newZoom, err = d.zoomUC.ZoomOut(ctx, domain, current)
356	case "reset":
357		err = d.zoomUC.ResetZoom(ctx, domain)
358		if err == nil {
359			newZoom = entity.NewZoomLevel(domain, d.zoomUC.DefaultZoom())
360		}
361	}
362
363	if err != nil {
364		log.Error().Err(err).Str("action", action).Msg("zoom action failed")
365		return err
366	}
367
368	if newZoom != nil {
369		if setErr := wv.SetZoomLevel(ctx, newZoom.ZoomFactor); setErr != nil {
370			log.Error().Err(setErr).Float64("zoom", newZoom.ZoomFactor).Msg("failed to apply zoom")
371			return setErr
372		}
373
374		// Notify omnibox to update zoom indicator
375		d.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor)
376
377		// Show zoom toast on the active pane
378		zoomPercent := int(newZoom.ZoomFactor * 100)
379		d.wsCoord.ShowZoomToast(ctx, zoomPercent)
380
381		log.Debug().
382			Str("domain", domain).
383			Str("action", action).
384			Float64("zoom", newZoom.ZoomFactor).
385			Msg("zoom applied")
386	}
387
388	return nil
389}
390
391// handleCopyURL copies the active pane's URL to clipboard.
392func (d *KeyboardDispatcher) handleCopyURL(ctx context.Context) error {
393	log := logging.FromContext(ctx)
394
395	if d.copyURLUC == nil {
396		log.Warn().Msg("copy URL use case not available")
397		return nil
398	}
399
400	wv := d.navCoord.ActiveWebView(ctx)
401	if wv == nil {
402		log.Debug().Msg("no active webview for copy URL")
403		return nil
404	}
405
406	uri := wv.URI()
407	if uri == "" {
408		log.Debug().Msg("active webview has empty URI")
409		return nil
410	}
411
412	// Copy URL in background goroutine
413	go func() {
414		if err := d.copyURLUC.Copy(ctx, uri); err != nil {
415			log.Error().Err(err).Str("uri", uri).Msg("copy URL failed")
416			return
417		}
418
419		// Show toast on GTK main thread
420		cb := glib.SourceFunc(func(_ uintptr) bool {
421			d.wsCoord.ShowToastOnActivePane(ctx, "URL copied", component.ToastSuccess)
422			return false
423		})
424		glib.IdleAdd(&cb, 0)
425	}()
426
427	return nil
428}

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