v0.22.0 a85da87

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

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