v0.20.1 d9a391f

workspace_view.go RawHistoryFolder

1// Package component provides UI components for the browser.
2package component
3
4import (
5	"context"
6	"errors"
7	"fmt"
8	"sync"
9
10	"github.com/bnema/dumber/internal/domain/entity"
11	"github.com/bnema/dumber/internal/logging"
12	"github.com/bnema/dumber/internal/ui/layout"
13	"github.com/rs/zerolog"
14)
15
16// ErrNilWorkspace is returned when attempting to set a nil workspace.
17var ErrNilWorkspace = errors.New("workspace is nil")
18
19// ErrPaneNotFound is returned when a pane ID cannot be found.
20var ErrPaneNotFound = errors.New("pane not found")
21
22// WorkspaceView is the top-level container that renders a workspace's pane tree.
23// It manages the widget tree and handles active pane state.
24type WorkspaceView struct {
25	factory      layout.WidgetFactory
26	treeRenderer *layout.TreeRenderer
27	container    layout.BoxWidget
28	overlay      layout.OverlayWidget // Wraps container for mode borders
29	rootWidget   layout.Widget        // Current root widget for removal on rebuild
30	logger       zerolog.Logger
31
32	// Mode border overlay slot
33	modeBorderWidget layout.Widget
34
35	// Omnibox lifecycle
36	omnibox       *Omnibox      // Current omnibox (nil if not shown)
37	omniboxWidget layout.Widget // Wrapped omnibox widget for overlay
38	omniboxPaneID entity.PaneID // Which pane has the omnibox
39	omniboxCfg    OmniboxConfig // Stored config for creating omniboxes
40
41	// Find bar lifecycle
42	findBar       *FindBar      // Current find bar (nil if not shown)
43	findBarWidget layout.Widget // Wrapped find bar widget for overlay
44	findBarPaneID entity.PaneID // Which pane has the find bar
45	findBarCfg    FindBarConfig // Stored config for creating find bars
46
47	workspace *entity.Workspace
48	paneViews map[entity.PaneID]*PaneView
49
50	onPaneFocused func(paneID entity.PaneID)
51
52	mu sync.RWMutex
53}
54
55// paneViewFactoryAdapter adapts WorkspaceView to the PaneViewFactory interface
56// so TreeRenderer can create PaneViews.
57type paneViewFactoryAdapter struct {
58	wv  *WorkspaceView
59	ctx context.Context
60}
61
62func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Widget {
63	if node == nil || node.Pane == nil {
64		return nil
65	}
66
67	// Create PaneView without WebView widget for now
68	// WebView will be attached later by the application layer
69	pv := NewPaneView(a.wv.factory, node.Pane.ID, nil)
70
71	// Store in map for later lookup
72	// Note: Caller (SetWorkspace) already holds the lock, so we access directly
73	a.wv.paneViews[node.Pane.ID] = pv
74
75	// Set up focus callback
76	pv.SetOnFocusIn(func(paneID entity.PaneID) {
77		a.wv.mu.RLock()
78		callback := a.wv.onPaneFocused
79		a.wv.mu.RUnlock()
80
81		if callback != nil {
82			callback(paneID)
83		}
84	})
85
86	// Set up hover callback for focus-follows-mouse
87	pv.SetOnHover(func(paneID entity.PaneID) {
88		// Skip if this pane is already active
89		if a.wv.GetActivePaneID() == paneID {
90			return
91		}
92
93		// Activate the hovered pane and grab focus
94		if err := a.wv.SetActivePaneID(paneID); err == nil {
95			a.wv.FocusPane(paneID)
96		}
97	})
98
99	// Attach hover handler with debouncing
100	pv.AttachHoverHandler(a.ctx)
101
102	return pv.Widget()
103}
104
105// NewWorkspaceView creates a new workspace view.
106func NewWorkspaceView(ctx context.Context, factory layout.WidgetFactory) *WorkspaceView {
107	log := logging.FromContext(ctx)
108
109	container := factory.NewBox(layout.OrientationVertical, 0)
110	container.SetHexpand(true)
111	container.SetVexpand(true)
112	container.SetVisible(true)
113
114	// Wrap container in overlay for mode borders
115	overlay := factory.NewOverlay()
116	overlay.SetHexpand(true)
117	overlay.SetVexpand(true)
118	overlay.SetChild(container)
119	overlay.SetVisible(true)
120
121	wv := &WorkspaceView{
122		factory:   factory,
123		container: container,
124		overlay:   overlay,
125		logger:    log.With().Str("component", "workspace-view").Logger(),
126		paneViews: make(map[entity.PaneID]*PaneView),
127	}
128
129	// Create tree renderer with our adapter as the pane view factory
130	wv.treeRenderer = layout.NewTreeRenderer(ctx, factory, &paneViewFactoryAdapter{wv: wv, ctx: ctx})
131
132	return wv
133}
134
135// SetWorkspace sets the workspace to render and builds the widget tree.
136func (wv *WorkspaceView) SetWorkspace(ctx context.Context, ws *entity.Workspace) error {
137	if ws == nil {
138		return ErrNilWorkspace
139	}
140
141	wv.mu.Lock()
142	defer wv.mu.Unlock()
143
144	// Clear previous state
145	wv.workspace = ws
146	wv.paneViews = make(map[entity.PaneID]*PaneView)
147
148	// Remove old root widget from container before building new tree
149	if wv.rootWidget != nil {
150		wv.container.Remove(wv.rootWidget)
151		wv.rootWidget = nil
152	}
153
154	// Build new tree
155	if ws.Root != nil {
156		widget, err := wv.treeRenderer.Build(ctx, ws.Root)
157		if err != nil {
158			return err
159		}
160
161		if widget != nil {
162			widget.SetVisible(true)
163			wv.container.Append(widget)
164			wv.rootWidget = widget
165		}
166	}
167
168	// Set initial active pane
169	if ws.ActivePaneID != "" {
170		if err := wv.setActivePaneIDInternal(ws.ActivePaneID); err != nil {
171			return fmt.Errorf("set initial active pane: %w", err)
172		}
173	}
174
175	// Update single-pane mode based on pane count
176	wv.updateSinglePaneModeInternal()
177
178	return nil
179}
180
181// SetActivePaneID updates which pane is visually marked as active.
182func (wv *WorkspaceView) SetActivePaneID(paneID entity.PaneID) error {
183	wv.mu.Lock()
184	defer wv.mu.Unlock()
185
186	return wv.setActivePaneIDInternal(paneID)
187}
188
189// setActivePaneIDInternal updates active pane without locking.
190// Updates the domain model as the single source of truth.
191func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error {
192	currentActiveID := wv.getActivePaneIDInternal()
193
194	wv.logger.Debug().
195		Str("from_pane", string(currentActiveID)).
196		Str("to_pane", string(paneID)).
197		Bool("omnibox_visible", wv.omnibox != nil).
198		Str("omnibox_pane", string(wv.omniboxPaneID)).
199		Msg("active pane changing")
200
201	// Destroy omnibox if active pane is changing
202	if currentActiveID != paneID && wv.omnibox != nil {
203		wv.logger.Debug().Msg("destroying omnibox due to pane change")
204		wv.hideOmniboxInternal()
205	}
206	// Destroy find bar if active pane is changing
207	if currentActiveID != paneID && wv.findBar != nil {
208		wv.hideFindBarInternal()
209	}
210
211	// Deactivate current active pane
212	if currentActiveID != "" {
213		if oldPV, ok := wv.paneViews[currentActiveID]; ok {
214			oldPV.SetActive(false)
215		}
216	}
217
218	// Activate new pane
219	newPV, ok := wv.paneViews[paneID]
220	if !ok {
221		return ErrPaneNotFound
222	}
223
224	newPV.SetActive(true)
225
226	// Update domain model - single source of truth
227	if wv.workspace != nil {
228		wv.workspace.ActivePaneID = paneID
229	}
230
231	return nil
232}
233
234// GetActivePaneID returns the ID of the currently active pane.
235// Reads from the domain model as the single source of truth.
236func (wv *WorkspaceView) GetActivePaneID() entity.PaneID {
237	wv.mu.RLock()
238	defer wv.mu.RUnlock()
239
240	return wv.getActivePaneIDInternal()
241}
242
243// getActivePaneIDInternal returns the active pane ID without locking.
244// Must be called with at least a read lock held.
245func (wv *WorkspaceView) getActivePaneIDInternal() entity.PaneID {
246	if wv.workspace == nil {
247		return ""
248	}
249	return wv.workspace.ActivePaneID
250}
251
252// GetPaneView returns the PaneView for a given pane ID.
253// Returns nil if not found.
254func (wv *WorkspaceView) GetPaneView(paneID entity.PaneID) *PaneView {
255	wv.mu.RLock()
256	defer wv.mu.RUnlock()
257
258	return wv.paneViews[paneID]
259}
260
261// GetPaneIDs returns all pane IDs in this workspace view.
262func (wv *WorkspaceView) GetPaneIDs() []entity.PaneID {
263	wv.mu.RLock()
264	defer wv.mu.RUnlock()
265
266	ids := make([]entity.PaneID, 0, len(wv.paneViews))
267	for id := range wv.paneViews {
268		ids = append(ids, id)
269	}
270	return ids
271}
272
273// PaneCount returns the number of panes in the workspace view.
274func (wv *WorkspaceView) PaneCount() int {
275	wv.mu.RLock()
276	defer wv.mu.RUnlock()
277
278	return len(wv.paneViews)
279}
280
281// singlePaneClass is used to hide the active pane border when only one pane exists.
282const singlePaneClass = "single-pane"
283
284// updateSinglePaneModeInternal adds/removes the single-pane CSS class on the container.
285// Must be called with the lock held.
286func (wv *WorkspaceView) updateSinglePaneModeInternal() {
287	if wv.container == nil {
288		return
289	}
290
291	if len(wv.paneViews) <= 1 {
292		wv.container.AddCssClass(singlePaneClass)
293	} else {
294		wv.container.RemoveCssClass(singlePaneClass)
295	}
296}
297
298// SetOnPaneFocused sets the callback for when a pane receives focus.
299func (wv *WorkspaceView) SetOnPaneFocused(fn func(paneID entity.PaneID)) {
300	wv.mu.Lock()
301	defer wv.mu.Unlock()
302
303	wv.onPaneFocused = fn
304}
305
306// Rebuild rebuilds the widget tree from the current workspace.
307// Use this after structural changes like splits or closes.
308func (wv *WorkspaceView) Rebuild(ctx context.Context) error {
309	wv.mu.RLock()
310	ws := wv.workspace
311	wv.mu.RUnlock()
312
313	if ws == nil {
314		return ErrNilWorkspace
315	}
316
317	return wv.SetWorkspace(ctx, ws)
318}
319
320// FocusPane attempts to give focus to a specific pane.
321// Returns true if focus was successfully grabbed.
322func (wv *WorkspaceView) FocusPane(paneID entity.PaneID) bool {
323	wv.mu.RLock()
324	pv, ok := wv.paneViews[paneID]
325	wv.mu.RUnlock()
326
327	if !ok || pv == nil {
328		return false
329	}
330
331	return pv.GrabFocus()
332}
333
334// SetWebViewWidget attaches a WebView widget to a specific pane.
335func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error {
336	wv.mu.RLock()
337	pv, ok := wv.paneViews[paneID]
338	wv.mu.RUnlock()
339
340	if !ok {
341		return ErrPaneNotFound
342	}
343
344	pv.SetWebViewWidget(widget)
345	return nil
346}
347
348// Widget returns the overlay widget for embedding in the UI.
349// The overlay wraps the pane container and allows mode borders to be displayed.
350func (wv *WorkspaceView) Widget() layout.Widget {
351	return wv.overlay
352}
353
354// Container returns the underlying BoxWidget for direct access.
355func (wv *WorkspaceView) Container() layout.BoxWidget {
356	return wv.container
357}
358
359// TreeRenderer returns the underlying tree renderer.
360func (wv *WorkspaceView) TreeRenderer() *layout.TreeRenderer {
361	return wv.treeRenderer
362}
363
364// Workspace returns the current workspace.
365func (wv *WorkspaceView) Workspace() *entity.Workspace {
366	wv.mu.RLock()
367	defer wv.mu.RUnlock()
368
369	return wv.workspace
370}
371
372// RegisterPaneView adds a PaneView to the tracking map without rebuilding.
373// Use this for incremental operations like stacked panes.
374func (wv *WorkspaceView) RegisterPaneView(paneID entity.PaneID, pv *PaneView) {
375	wv.mu.Lock()
376	defer wv.mu.Unlock()
377
378	wv.paneViews[paneID] = pv
379	wv.updateSinglePaneModeInternal()
380}
381
382// UnregisterPaneView removes a PaneView from the tracking map and cleans it up.
383// This must be called when closing a pane to properly release GTK resources
384// before destroying the WebView.
385func (wv *WorkspaceView) UnregisterPaneView(paneID entity.PaneID) {
386	wv.mu.Lock()
387	defer wv.mu.Unlock()
388
389	// Call Cleanup on the PaneView to unparent widgets and clear callbacks
390	// This must happen before WebView.Destroy() is called
391	if pv, ok := wv.paneViews[paneID]; ok && pv != nil {
392		pv.Cleanup()
393	}
394
395	delete(wv.paneViews, paneID)
396	wv.updateSinglePaneModeInternal()
397}
398
399// GetRootWidget returns the current root widget of the workspace.
400// This is useful for incremental operations that need to modify the tree.
401func (wv *WorkspaceView) GetRootWidget() layout.Widget {
402	wv.mu.RLock()
403	defer wv.mu.RUnlock()
404
405	return wv.rootWidget
406}
407
408// SetRootWidgetDirect replaces the root widget without rebuilding the entire tree.
409// Use this for incremental operations when converting to/from stacked panes.
410func (wv *WorkspaceView) SetRootWidgetDirect(widget layout.Widget) {
411	wv.mu.Lock()
412	defer wv.mu.Unlock()
413
414	// Remove old root if present
415	if wv.rootWidget != nil {
416		wv.container.Remove(wv.rootWidget)
417	}
418
419	// Add new root
420	if widget != nil {
421		widget.SetVisible(true)
422		wv.container.Append(widget)
423	}
424
425	wv.rootWidget = widget
426}
427
428// ClearRootWidgetRef clears the stored root widget reference without removing it.
429// Use this before SetRootWidgetDirect when the old root has already been removed
430// through other means (e.g., GTK paned operations).
431func (wv *WorkspaceView) ClearRootWidgetRef() {
432	wv.mu.Lock()
433	defer wv.mu.Unlock()
434	wv.rootWidget = nil
435}
436
437// Factory returns the widget factory used by this workspace view.
438func (wv *WorkspaceView) Factory() layout.WidgetFactory {
439	return wv.factory
440}
441
442// SetModeBorderOverlay attaches a mode border overlay widget.
443// The widget will be displayed on top of the pane container when modes are active.
444func (wv *WorkspaceView) SetModeBorderOverlay(widget layout.Widget) {
445	wv.mu.Lock()
446	defer wv.mu.Unlock()
447
448	// Remove old overlay if present
449	if wv.modeBorderWidget != nil && wv.overlay != nil {
450		wv.overlay.RemoveOverlay(wv.modeBorderWidget)
451	}
452
453	wv.modeBorderWidget = widget
454
455	// Add new overlay
456	if widget != nil && wv.overlay != nil {
457		wv.overlay.AddOverlay(widget)
458		// Don't clip or measure the overlay - it should fill the entire area
459		wv.overlay.SetClipOverlay(widget, false)
460		wv.overlay.SetMeasureOverlay(widget, false)
461	}
462}
463
464// GetPaneWidget returns the widget for a pane ID.
465// Implements focus.PaneGeometryProvider.
466func (wv *WorkspaceView) GetPaneWidget(paneID entity.PaneID) layout.Widget {
467	wv.mu.RLock()
468	defer wv.mu.RUnlock()
469
470	pv, ok := wv.paneViews[paneID]
471	if !ok || pv == nil {
472		return nil
473	}
474	return pv.Widget()
475}
476
477// GetStackContainerWidget returns the stack container widget for a stacked pane.
478// Returns nil if the pane is not in a stack.
479// Implements focus.PaneGeometryProvider.
480func (wv *WorkspaceView) GetStackContainerWidget(paneID entity.PaneID) layout.Widget {
481	if wv.treeRenderer == nil {
482		return nil
483	}
484
485	stackedView := wv.treeRenderer.GetStackedViewForPane(string(paneID))
486	if stackedView == nil {
487		return nil
488	}
489
490	return stackedView.Widget()
491}
492
493// ContainerWidget returns the container widget for relative positioning.
494// Implements focus.PaneGeometryProvider.
495func (wv *WorkspaceView) ContainerWidget() layout.Widget {
496	return wv.container
497}
498
499// SetOmniboxConfig stores the omnibox configuration for later use.
500func (wv *WorkspaceView) SetOmniboxConfig(cfg OmniboxConfig) {
501	wv.mu.Lock()
502	defer wv.mu.Unlock()
503	wv.omniboxCfg = cfg
504}
505
506// SetFindBarConfig stores the find bar configuration for later use.
507func (wv *WorkspaceView) SetFindBarConfig(cfg FindBarConfig) {
508	wv.mu.Lock()
509	defer wv.mu.Unlock()
510	wv.findBarCfg = cfg
511}
512
513// ShowOmnibox creates and shows the omnibox in the active pane.
514func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) {
515	wv.mu.Lock()
516	defer wv.mu.Unlock()
517
518	// Get the active pane view
519	activePaneID := wv.getActivePaneIDInternal()
520	pv := wv.paneViews[activePaneID]
521	if pv == nil {
522		wv.logger.Warn().Str("paneID", string(activePaneID)).Msg("cannot show omnibox: active pane not found")
523		return
524	}
525
526	// If omnibox exists but on different pane, destroy and recreate on active pane
527	if wv.omnibox != nil {
528		if wv.omniboxPaneID != activePaneID {
529			wv.logger.Debug().
530				Str("old_pane", string(wv.omniboxPaneID)).
531				Str("new_pane", string(activePaneID)).
532				Msg("omnibox pane changed, recreating")
533			wv.hideOmniboxInternal()
534		} else {
535			wv.logger.Debug().Str("pane", string(activePaneID)).Msg("showing existing omnibox")
536			wv.omnibox.Show(ctx, query)
537			return
538		}
539	}
540
541	// Create omnibox with pane-specific toast callback
542	cfg := wv.omniboxCfg
543	cfg.OnToast = func(message string) {
544		pv.ShowToast(ctx, message, ToastSuccess)
545	}
546	omnibox := NewOmnibox(ctx, cfg)
547	if omnibox == nil {
548		wv.logger.Error().Msg("failed to create omnibox")
549		return
550	}
551
552	// Set parent overlay for sizing
553	omnibox.SetParentOverlay(pv.Overlay())
554
555	// Wrap widget for layout system
556	omniboxWidget := omnibox.WidgetAsLayout(wv.factory)
557	if omniboxWidget == nil {
558		wv.logger.Error().Msg("failed to wrap omnibox widget")
559		return
560	}
561
562	// Add to pane overlay
563	pv.AddOverlayWidget(omniboxWidget)
564
565	// Store references
566	wv.omnibox = omnibox
567	wv.omniboxWidget = omniboxWidget
568	wv.omniboxPaneID = activePaneID
569
570	// Show the omnibox
571	omnibox.Show(ctx, query)
572
573	wv.logger.Debug().Str("paneID", string(activePaneID)).Msg("omnibox shown")
574}
575
576// HideOmnibox hides and destroys the current omnibox.
577func (wv *WorkspaceView) HideOmnibox() {
578	wv.mu.Lock()
579	defer wv.mu.Unlock()
580
581	wv.hideOmniboxInternal()
582}
583
584// hideOmniboxInternal destroys the omnibox without locking.
585func (wv *WorkspaceView) hideOmniboxInternal() {
586	if wv.omnibox == nil {
587		return
588	}
589
590	// Hide the omnibox first (use Background context since this is internal cleanup)
591	wv.omnibox.Hide(context.Background())
592
593	// Remove from pane overlay
594	if pv := wv.paneViews[wv.omniboxPaneID]; pv != nil && wv.omniboxWidget != nil {
595		pv.RemoveOverlayWidget(wv.omniboxWidget)
596	}
597
598	// Clear references
599	wv.omnibox = nil
600	wv.omniboxWidget = nil
601	wv.omniboxPaneID = ""
602
603	wv.logger.Debug().Msg("omnibox hidden and destroyed")
604}
605
606// ShowFindBar creates and shows the find bar in the active pane.
607func (wv *WorkspaceView) ShowFindBar(ctx context.Context) {
608	wv.mu.Lock()
609	defer wv.mu.Unlock()
610
611	// If find bar already exists, just show it
612	if wv.findBar != nil {
613		wv.findBar.Show()
614		return
615	}
616
617	activePaneID := wv.getActivePaneIDInternal()
618	pv := wv.paneViews[activePaneID]
619	if pv == nil {
620		wv.logger.Warn().Str("paneID", string(activePaneID)).Msg("cannot show find bar: active pane not found")
621		return
622	}
623
624	cfg := wv.findBarCfg
625	cfg.OnClose = func() {
626		wv.HideFindBar()
627	}
628	findBar := NewFindBar(ctx, cfg)
629	if findBar == nil {
630		wv.logger.Error().Msg("failed to create find bar")
631		return
632	}
633
634	findBarWidget := findBar.WidgetAsLayout(wv.factory)
635	if findBarWidget == nil {
636		wv.logger.Error().Msg("failed to wrap find bar widget")
637		return
638	}
639
640	pv.AddOverlayWidget(findBarWidget)
641
642	if cfg.GetFindController != nil {
643		controller := cfg.GetFindController(activePaneID)
644		findBar.SetFindController(controller)
645	}
646
647	wv.findBar = findBar
648	wv.findBarWidget = findBarWidget
649	wv.findBarPaneID = activePaneID
650
651	findBar.Show()
652
653	wv.logger.Debug().Str("paneID", string(activePaneID)).Msg("find bar shown")
654}
655
656// HideFindBar hides and destroys the current find bar.
657func (wv *WorkspaceView) HideFindBar() {
658	wv.mu.Lock()
659	defer wv.mu.Unlock()
660
661	wv.hideFindBarInternal()
662}
663
664// FindNext triggers the next match in the find bar if available.
665func (wv *WorkspaceView) FindNext() {
666	wv.mu.RLock()
667	findBar := wv.findBar
668	wv.mu.RUnlock()
669
670	if findBar != nil {
671		findBar.FindNext()
672	}
673}
674
675// FindPrevious triggers the previous match in the find bar if available.
676func (wv *WorkspaceView) FindPrevious() {
677	wv.mu.RLock()
678	findBar := wv.findBar
679	wv.mu.RUnlock()
680
681	if findBar != nil {
682		findBar.FindPrevious()
683	}
684}
685
686// IsFindBarVisible returns whether the find bar is currently visible.
687func (wv *WorkspaceView) IsFindBarVisible() bool {
688	wv.mu.RLock()
689	defer wv.mu.RUnlock()
690	return wv.findBar != nil && wv.findBar.IsVisible()
691}
692
693// hideFindBarInternal destroys the find bar without locking.
694func (wv *WorkspaceView) hideFindBarInternal() {
695	if wv.findBar == nil {
696		return
697	}
698
699	wv.findBar.Hide()
700
701	if pv := wv.paneViews[wv.findBarPaneID]; pv != nil && wv.findBarWidget != nil {
702		pv.RemoveOverlayWidget(wv.findBarWidget)
703	}
704
705	wv.findBar = nil
706	wv.findBarWidget = nil
707	wv.findBarPaneID = ""
708
709	wv.logger.Debug().Msg("find bar hidden and destroyed")
710}
711
712// GetOmnibox returns the current omnibox if visible.
713func (wv *WorkspaceView) GetOmnibox() *Omnibox {
714	wv.mu.RLock()
715	defer wv.mu.RUnlock()
716	return wv.omnibox
717}
718
719// IsOmniboxVisible returns whether the omnibox is currently visible.
720func (wv *WorkspaceView) IsOmniboxVisible() bool {
721	wv.mu.RLock()
722	defer wv.mu.RUnlock()
723	return wv.omnibox != nil && wv.omnibox.IsVisible()
724}
725
726// GetActivePaneView returns the PaneView for the current active pane.
727func (wv *WorkspaceView) GetActivePaneView() *PaneView {
728	wv.mu.RLock()
729	defer wv.mu.RUnlock()
730	return wv.paneViews[wv.getActivePaneIDInternal()]
731}

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