v0.29.0 21b3150

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   = "dumb://history"
19	favoritesSystemViewURL = "dumb://favorites"
20	configSystemViewURL    = "dumb://config"
21)
22
23type KeyboardActions struct {
24	NewTab         func(context.Context) error
25	CloseTab       func(context.Context) error
26	NextTab        func(context.Context) error
27	PreviousTab    func(context.Context) error
28	SwitchLastTab  func(context.Context) error
29	SwitchTabIndex func(context.Context, int) error
30	ActiveWebView  func(context.Context) port.WebView
31}
32
33// KeyboardDispatcher routes keyboard actions to appropriate coordinators.
34type KeyboardDispatcher struct {
35	actions          KeyboardActions
36	wsCoord          *coordinator.WorkspaceCoordinator
37	navCoord         *coordinator.NavigationCoordinator
38	zoomUC           *usecase.ManageZoomUseCase
39	copyURLUC        *usecase.CopyURLUseCase
40	actionHandlers   map[input.Action]func(ctx context.Context) error
41	onQuit           func()
42	onFindOpen       func(ctx context.Context) error
43	onFindNext       func(ctx context.Context) error
44	onFindPrev       func(ctx context.Context) error
45	onFindClose      func(ctx context.Context) error
46	activePaneID     func(ctx context.Context) entity.PaneID
47	onSessionOpen    func(ctx context.Context, paneID entity.PaneID) error
48	onMovePaneToTab  func(ctx context.Context, paneID entity.PaneID) error
49	onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error
50	onToggleFloating func(ctx context.Context) error
51	onOpenFloating   func(ctx context.Context, target input.FloatingProfileTarget) error
52}
53
54// NewKeyboardDispatcher creates a new KeyboardDispatcher.
55func NewKeyboardDispatcher(
56	ctx context.Context,
57	wsCoord *coordinator.WorkspaceCoordinator,
58	navCoord *coordinator.NavigationCoordinator,
59	zoomUC *usecase.ManageZoomUseCase,
60	copyURLUC *usecase.CopyURLUseCase,
61	actions KeyboardActions,
62	activePaneID func(context.Context) entity.PaneID,
63) *KeyboardDispatcher {
64	log := logging.FromContext(ctx)
65	log.Debug().Msg("creating keyboard dispatcher")
66
67	dispatcher := &KeyboardDispatcher{
68		actions:      actions,
69		wsCoord:      wsCoord,
70		navCoord:     navCoord,
71		zoomUC:       zoomUC,
72		copyURLUC:    copyURLUC,
73		activePaneID: activePaneID,
74	}
75	dispatcher.initActionHandlers()
76	return dispatcher
77}
78
79// SetOnQuit sets the callback for quit action.
80func (d *KeyboardDispatcher) SetOnQuit(fn func()) {
81	d.onQuit = fn
82}
83
84// SetOnFindOpen sets the callback for opening the find bar.
85func (d *KeyboardDispatcher) SetOnFindOpen(fn func(ctx context.Context) error) {
86	d.onFindOpen = fn
87}
88
89// SetOnFindNext sets the callback for the next match action.
90func (d *KeyboardDispatcher) SetOnFindNext(fn func(ctx context.Context) error) {
91	d.onFindNext = fn
92}
93
94// SetOnFindPrev sets the callback for the previous match action.
95func (d *KeyboardDispatcher) SetOnFindPrev(fn func(ctx context.Context) error) {
96	d.onFindPrev = fn
97}
98
99// SetOnFindClose sets the callback for closing the find bar.
100func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error) {
101	d.onFindClose = fn
102}
103
104// SetOnSessionOpen sets the callback for opening the session manager.
105func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context, paneID entity.PaneID) error) {
106	d.onSessionOpen = fn
107}
108
109func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context, paneID entity.PaneID) error) {
110	d.onMovePaneToTab = fn
111}
112
113func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context, paneID entity.PaneID) error) {
114	d.onMovePaneToNext = fn
115}
116
117func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) {
118	d.onToggleFloating = fn
119}
120
121func (d *KeyboardDispatcher) SetOnOpenFloatingURL(fn func(ctx context.Context, url string) error) {
122	if fn == nil {
123		d.onOpenFloating = nil
124		return
125	}
126	d.onOpenFloating = func(ctx context.Context, target input.FloatingProfileTarget) error {
127		return fn(ctx, target.URL)
128	}
129}
130
131func (d *KeyboardDispatcher) SetOnOpenFloatingTarget(fn func(ctx context.Context, target input.FloatingProfileTarget) error) {
132	d.onOpenFloating = fn
133}
134
135func (d *KeyboardDispatcher) initActionHandlers() {
136	const (
137		firstTabIndex   = 0
138		secondTabIndex  = 1
139		thirdTabIndex   = 2
140		fourthTabIndex  = 3
141		fifthTabIndex   = 4
142		sixthTabIndex   = 5
143		seventhTabIndex = 6
144		eighthTabIndex  = 7
145		ninthTabIndex   = 8
146		tenthTabIndex   = 9
147	)
148	d.actionHandlers = map[input.Action]func(ctx context.Context) error{
149		// Tab actions
150		input.ActionNewTab:   func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "new tab", d.actions.NewTab) },
151		input.ActionCloseTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "close tab", d.actions.CloseTab) },
152		input.ActionNextTab:  func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "next tab", d.actions.NextTab) },
153		input.ActionPreviousTab: func(ctx context.Context) error {
154			return d.handleKeyboardAction(ctx, "previous tab", d.actions.PreviousTab)
155		},
156		input.ActionSwitchLastTab: func(ctx context.Context) error {
157			return d.handleKeyboardAction(ctx, "switch last tab", d.actions.SwitchLastTab)
158		},
159		input.ActionSwitchTabIndex1:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, firstTabIndex) },
160		input.ActionSwitchTabIndex2:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, secondTabIndex) },
161		input.ActionSwitchTabIndex3:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, thirdTabIndex) },
162		input.ActionSwitchTabIndex4:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, fourthTabIndex) },
163		input.ActionSwitchTabIndex5:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, fifthTabIndex) },
164		input.ActionSwitchTabIndex6:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, sixthTabIndex) },
165		input.ActionSwitchTabIndex7:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, seventhTabIndex) },
166		input.ActionSwitchTabIndex8:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, eighthTabIndex) },
167		input.ActionSwitchTabIndex9:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, ninthTabIndex) },
168		input.ActionSwitchTabIndex10: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, tenthTabIndex) },
169		input.ActionRenameTab: func(ctx context.Context) error {
170			return d.logNoop(ctx, "rename tab action (not yet implemented)")
171		},
172		// Pane actions
173		input.ActionSplitRight: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitRight) },
174		input.ActionSplitLeft:  func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitLeft) },
175		input.ActionSplitUp:    func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitUp) },
176		input.ActionSplitDown:  func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) },
177		input.ActionClosePane:  d.wsCoord.ClosePane,
178		input.ActionStackPane:  d.wsCoord.StackPane,
179		input.ActionMovePaneToTab: func(ctx context.Context) error {
180			return d.handleMovePaneToTab(ctx)
181		},
182		input.ActionMovePaneToNextTab: func(ctx context.Context) error {
183			return d.handleMovePaneToNextTab(ctx)
184		},
185		input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {
186			return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)
187		},
188		input.ActionConsumeOrExpelRight: func(ctx context.Context) error {
189			return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelRight)
190		},
191		input.ActionConsumeOrExpelUp: func(ctx context.Context) error {
192			return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelUp)
193		},
194		input.ActionConsumeOrExpelDown: func(ctx context.Context) error {
195			return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelDown)
196		},
197		input.ActionFocusRight: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavRight) },
198		input.ActionFocusLeft:  func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) },
199		input.ActionFocusUp:    func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) },
200		input.ActionFocusDown:  func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavDown) },
201		// Resize actions
202		input.ActionResizeIncreaseLeft:  func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseLeft) },
203		input.ActionResizeIncreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseRight) },
204		input.ActionResizeIncreaseUp:    func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseUp) },
205		input.ActionResizeIncreaseDown:  func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseDown) },
206		input.ActionResizeDecreaseLeft:  func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseLeft) },
207		input.ActionResizeDecreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseRight) },
208		input.ActionResizeDecreaseUp:    func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseUp) },
209		input.ActionResizeDecreaseDown:  func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseDown) },
210		input.ActionResizeIncrease:      func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncrease) },
211		input.ActionResizeDecrease:      func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecrease) },
212		// Stack navigation
213		input.ActionStackNavUp:   func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") },
214		input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") },
215		// Navigation
216		input.ActionGoBack:     d.handleGoBack,
217		input.ActionGoForward:  d.handleGoForward,
218		input.ActionReload:     d.handleReload,
219		input.ActionHardReload: d.handleHardReload,
220		input.ActionPrintPage:  d.handlePrintPage,
221		// Zoom actions
222		input.ActionZoomIn:    func(ctx context.Context) error { return d.handleZoom(ctx, "in") },
223		input.ActionZoomOut:   func(ctx context.Context) error { return d.handleZoom(ctx, "out") },
224		input.ActionZoomReset: func(ctx context.Context) error { return d.handleZoom(ctx, "reset") },
225		// UI
226		input.ActionOpenOmnibox:  d.navCoord.OpenOmnibox,
227		input.ActionOpenFind:     d.handleFindOpen,
228		input.ActionFindNext:     d.handleFindNext,
229		input.ActionFindPrev:     d.handleFindPrev,
230		input.ActionCloseFind:    d.handleFindClose,
231		input.ActionOpenDevTools: d.handleOpenDevTools,
232		input.ActionToggleFloatingPane: func(ctx context.Context) error {
233			if d.onToggleFloating != nil {
234				return d.onToggleFloating(ctx)
235			}
236			return d.logNoop(ctx, "toggle floating pane action (no handler)")
237		},
238		input.ActionToggleHistorySystemView: func(ctx context.Context) error {
239			return d.wsCoord.ToggleSystemViewRight(ctx, historySystemViewURL)
240		},
241		input.ActionToggleFavoritesSystemView: func(ctx context.Context) error {
242			return d.wsCoord.ToggleSystemViewRight(ctx, favoritesSystemViewURL)
243		},
244		input.ActionToggleConfigSystemView: func(ctx context.Context) error {
245			return d.wsCoord.ToggleSystemViewRight(ctx, configSystemViewURL)
246		},
247		input.ActionToggleFullscreen: func(ctx context.Context) error {
248			return d.logNoop(ctx, "toggle fullscreen action (not yet implemented)")
249		},
250		// Clipboard
251		input.ActionCopyURL: d.handleCopyURL,
252		// Session management
253		input.ActionOpenSessionManager: d.handleSessionOpen,
254		// Application
255		input.ActionQuit: d.handleQuit,
256	}
257}
258
259// Dispatch routes a keyboard action to the appropriate coordinator.
260func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error {
261	log := logging.FromContext(ctx)
262	log.Debug().Str("action", string(action)).Msg("dispatching keyboard action")
263
264	if target, ok := input.ParseFloatingProfileTarget(action); ok {
265		if d.onOpenFloating != nil {
266			return d.onOpenFloating(ctx, target)
267		}
268		log.Debug().Str("url", target.URL).Msg("floating profile action ignored (no handler)")
269		return nil
270	}
271
272	if handler, ok := d.actionHandlers[action]; ok {
273		return handler(ctx)
274	}
275
276	log.Warn().Str("action", string(action)).Msg("unhandled keyboard action")
277	return nil
278}
279
280func (d *KeyboardDispatcher) handleFindOpen(ctx context.Context) error {
281	if d.onFindOpen != nil {
282		return d.onFindOpen(ctx)
283	}
284	logging.FromContext(ctx).Debug().Msg("find open action (no handler)")
285	return nil
286}
287
288func (d *KeyboardDispatcher) handleFindNext(ctx context.Context) error {
289	if d.onFindNext != nil {
290		return d.onFindNext(ctx)
291	}
292	logging.FromContext(ctx).Debug().Msg("find next action (no handler)")
293	return nil
294}
295
296func (d *KeyboardDispatcher) handleFindPrev(ctx context.Context) error {
297	if d.onFindPrev != nil {
298		return d.onFindPrev(ctx)
299	}
300	logging.FromContext(ctx).Debug().Msg("find prev action (no handler)")
301	return nil
302}
303
304func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error {
305	if d.onFindClose != nil {
306		return d.onFindClose(ctx)
307	}
308	logging.FromContext(ctx).Debug().Msg("find close action (no handler)")
309	return nil
310}
311
312func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
313	if d.onSessionOpen != nil {
314		return d.onSessionOpen(ctx, d.activePaneID(ctx))
315	}
316	logging.FromContext(ctx).Debug().Msg("session open action (no handler)")
317	return nil
318}
319
320func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error {
321	if d.onMovePaneToTab != nil {
322		return d.onMovePaneToTab(ctx, d.activePaneID(ctx))
323	}
324	logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)")
325	return nil
326}
327
328func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error {
329	if d.onMovePaneToNext != nil {
330		return d.onMovePaneToNext(ctx, d.activePaneID(ctx))
331	}
332	logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)")
333	return nil
334}
335
336func (d *KeyboardDispatcher) handleSwitchTabIndex(ctx context.Context, index int) error {
337	if d.actions.SwitchTabIndex == nil {
338		logging.FromContext(ctx).Debug().Int("index", index).Msg("switch tab index action ignored (no handler)")
339		return nil
340	}
341	return d.actions.SwitchTabIndex(ctx, index)
342}
343
344func (d *KeyboardDispatcher) handleKeyboardAction(ctx context.Context, name string, fn func(context.Context) error) error {
345	if fn == nil {
346		logging.FromContext(ctx).Debug().Str("action", name).Msg("keyboard action ignored (no handler)")
347		return nil
348	}
349	return fn(ctx)
350}
351
352func (d *KeyboardDispatcher) activeWebView(ctx context.Context) port.WebView {
353	if d.actions.ActiveWebView == nil {
354		logging.FromContext(ctx).Debug().Msg("active webview unavailable (no handler)")
355		return nil
356	}
357	return d.actions.ActiveWebView(ctx)
358}
359
360func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
361	if ctx == nil {
362		return fmt.Errorf("missing context")
363	}
364	logging.FromContext(ctx).Debug().Msg(message)
365	return nil
366}
367
368func (d *KeyboardDispatcher) handleQuit(ctx context.Context) error {
369	if d.onQuit != nil {
370		d.onQuit()
371	}
372	if ctx == nil {
373		return fmt.Errorf("missing context")
374	}
375	return nil
376}
377
378func (d *KeyboardDispatcher) withActiveWebView(ctx context.Context, action string, fn func(port.WebView) error) error {
379	wv := d.activeWebView(ctx)
380	if wv == nil {
381		logging.FromContext(ctx).Debug().Str("action", action).Msg("no active webview for keyboard action")
382		return nil
383	}
384	return fn(wv)
385}
386
387func (d *KeyboardDispatcher) handleReload(ctx context.Context) error {
388	return d.withActiveWebView(ctx, "reload", func(wv port.WebView) error {
389		return d.navCoord.ReloadWebView(ctx, wv, false)
390	})
391}
392
393func (d *KeyboardDispatcher) handleHardReload(ctx context.Context) error {
394	return d.withActiveWebView(ctx, "hard reload", func(wv port.WebView) error {
395		return d.navCoord.ReloadWebView(ctx, wv, true)
396	})
397}
398
399func (d *KeyboardDispatcher) handleGoBack(ctx context.Context) error {
400	return d.withActiveWebView(ctx, "go back", func(wv port.WebView) error {
401		return d.navCoord.GoBackWebView(ctx, wv)
402	})
403}
404
405func (d *KeyboardDispatcher) handleGoForward(ctx context.Context) error {
406	return d.withActiveWebView(ctx, "go forward", func(wv port.WebView) error {
407		return d.navCoord.GoForwardWebView(ctx, wv)
408	})
409}
410
411func (d *KeyboardDispatcher) handlePrintPage(ctx context.Context) error {
412	return d.withActiveWebView(ctx, "print page", func(wv port.WebView) error {
413		return d.navCoord.PrintWebView(ctx, wv)
414	})
415}
416
417func (d *KeyboardDispatcher) handleOpenDevTools(ctx context.Context) error {
418	return d.withActiveWebView(ctx, "open devtools", func(wv port.WebView) error {
419		return d.navCoord.OpenDevToolsWebView(ctx, wv)
420	})
421}
422
423// handleZoom processes zoom in/out/reset actions for the active WebView.
424func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) error {
425	log := logging.FromContext(ctx)
426
427	if d.zoomUC == nil {
428		log.Warn().Msg("zoom use case not available")
429		return nil
430	}
431
432	wv := d.activeWebView(ctx)
433	if wv == nil {
434		log.Debug().Msg("no active webview for zoom")
435		return nil
436	}
437
438	zoomKey, err := usecase.ExtractZoomKey(wv.URI())
439	if err != nil {
440		log.Debug().Str("uri", wv.URI()).Msg("cannot extract zoom key")
441		return nil
442	}
443
444	current := wv.GetZoomLevel()
445	var newZoom *entity.ZoomLevel
446
447	switch action {
448	case "in":
449		newZoom, err = d.zoomUC.ZoomIn(ctx, zoomKey, current)
450	case "out":
451		newZoom, err = d.zoomUC.ZoomOut(ctx, zoomKey, current)
452	case "reset":
453		err = d.zoomUC.ResetZoom(ctx, zoomKey)
454		if err == nil {
455			newZoom = entity.NewZoomLevel(zoomKey, d.zoomUC.DefaultZoom())
456		}
457	}
458
459	if err != nil {
460		log.Error().Err(err).Str("action", action).Msg("zoom action failed")
461		return err
462	}
463
464	if newZoom != nil {
465		if setErr := wv.SetZoomLevel(ctx, newZoom.ZoomFactor); setErr != nil {
466			log.Error().Err(setErr).Float64("zoom", newZoom.ZoomFactor).Msg("failed to apply zoom")
467			return setErr
468		}
469
470		// Notify omnibox to update zoom indicator
471		d.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor)
472
473		// Show zoom toast on the active pane
474		zoomPercent := int(newZoom.ZoomFactor * 100)
475		d.wsCoord.ShowZoomToast(ctx, zoomPercent)
476
477		log.Debug().
478			Str("zoom_key", zoomKey).
479			Str("action", action).
480			Float64("zoom", newZoom.ZoomFactor).
481			Msg("zoom applied")
482	}
483
484	return nil
485}
486
487// handleCopyURL copies the active pane's URL to clipboard.
488func (d *KeyboardDispatcher) handleCopyURL(ctx context.Context) error {
489	log := logging.FromContext(ctx)
490
491	if d.copyURLUC == nil {
492		log.Warn().Msg("copy URL use case not available")
493		return nil
494	}
495
496	wv := d.activeWebView(ctx)
497	if wv == nil {
498		log.Debug().Msg("no active webview for copy URL")
499		return nil
500	}
501
502	uri := wv.URI()
503	if uri == "" {
504		log.Debug().Msg("active webview has empty URI")
505		return nil
506	}
507
508	// Copy URL in background goroutine
509	go func() {
510		if err := d.copyURLUC.Copy(ctx, uri); err != nil {
511			log.Error().Err(err).Str("uri", uri).Msg("copy URL failed")
512			return
513		}
514
515		// Show toast on GTK main thread
516		cb := glib.SourceFunc(func(_ uintptr) bool {
517			d.wsCoord.ShowToastOnActivePane(ctx, "URL copied", component.ToastSuccess)
518			return false
519		})
520		glib.IdleAdd(&cb, 0)
521	}()
522
523	return nil
524}

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