webview_viewport.go RawHistoryFolder

1package cef
2
3import (
4	"context"
5	"strings"
6	"time"
7
8	purecef "github.com/bnema/purego-cef/cef"
9	"github.com/bnema/puregotk/v4/gobject"
10	"github.com/bnema/puregotk/v4/gtk"
11
12	"github.com/bnema/dumber/internal/logging"
13	"github.com/bnema/dumber/internal/shared/syncdispatch"
14)
15
16type viewportSyncBrowserHost interface {
17	resizeNotifiableBrowserHost
18	NotifyScreenInfoChanged()
19	WasHidden(int32)
20}
21
22type pendingBrowserCreateObservedSizeBridge interface {
23	Size() (int32, int32)
24	RefreshObservedSizeOnGTKThread() (int32, int32)
25}
26
27// viewportWidgetVisibility is the effective GTK state of a widget in its
28// hierarchy. Gtk.Widget.IsVisible already includes ancestor visibility; mapped
29// is checked separately because a visible widget can still be unmapped.
30type viewportWidgetVisibility struct {
31	visible bool
32	mapped  bool
33}
34
35func effectiveViewportVisibility(states []viewportWidgetVisibility) bool {
36	if len(states) == 0 {
37		return false
38	}
39	for _, state := range states {
40		if !state.visible || !state.mapped {
41			return false
42		}
43	}
44	return true
45}
46
47func effectiveViewportWidgetVisibility(widget *gtk.Widget) bool {
48	if widget == nil {
49		return false
50	}
51	// IsVisible is GTK's ancestor-aware visibility predicate. Pairing it with
52	// GetMapped makes hidden/unmapped ancestors effective CEF-hidden states.
53	return effectiveViewportVisibility([]viewportWidgetVisibility{{
54		visible: widget.IsVisible(),
55		mapped:  widget.GetMapped(),
56	}})
57}
58
59const (
60	// pendingBrowserCreateObservedSizeRetryDelay spaces retries while waiting
61	// for the GTK bridge to observe the widget's allocated size.
62	pendingBrowserCreateObservedSizeRetryDelay = 16 * time.Millisecond
63	// pendingBrowserCreateObservedSizeMaxRetries caps observed-size retries
64	// before browser creation proceeds despite a remaining size mismatch.
65	pendingBrowserCreateObservedSizeMaxRetries = 8
66)
67
68type pendingBrowserCreateObservedSizeRetryAction uint8
69
70const (
71	pendingBrowserCreateObservedSizeRetryUnavailable pendingBrowserCreateObservedSizeRetryAction = iota
72	pendingBrowserCreateObservedSizeRetryAlreadyScheduled
73	pendingBrowserCreateObservedSizeRetryScheduled
74	pendingBrowserCreateObservedSizeRetryProceedWithoutDelay
75)
76
77// SyncViewport requests an explicit viewport resync for CEF OSR browsers.
78//
79// This is a hardening path for UI lifecycle transitions that may not emit a
80// fresh size change (reparenting, stacked visibility toggles, floating pane
81// updates, popup show, sibling promotion after close, etc.). Calls are
82// coalesced and executed on the GTK thread before notifying the CEF host.
83func (wv *WebView) SyncViewport(ctx context.Context, reason string) {
84	if wv == nil || wv.destroyed.Load() {
85		return
86	}
87	reason = strings.TrimSpace(reason)
88	if reason == "" {
89		reason = "manual"
90	}
91
92	wv.viewportSyncMu.Lock()
93	wv.viewportSyncReason = reason
94	if wv.viewportSyncPending {
95		wv.viewportSyncMu.Unlock()
96		return
97	}
98	wv.viewportSyncPending = true
99	wv.viewportSyncMu.Unlock()
100
101	wv.runOnGTK(func() {
102		wv.syncViewportOnGTK(wv.viewportSyncContext(ctx))
103	})
104}
105
106func (wv *WebView) viewportSyncContext(ctx context.Context) context.Context {
107	if ctx != nil {
108		return ctx
109	}
110	if wv != nil && wv.ctx != nil {
111		return wv.ctx
112	}
113	return context.Background()
114}
115
116func (wv *WebView) takeViewportSyncReason() string {
117	wv.viewportSyncMu.Lock()
118	defer wv.viewportSyncMu.Unlock()
119	reason := wv.viewportSyncReason
120	wv.viewportSyncReason = ""
121	wv.viewportSyncPending = false
122	return reason
123}
124
125func (wv *WebView) syncViewportOnGTK(ctx context.Context) {
126	if wv == nil {
127		return
128	}
129	reason := wv.takeViewportSyncReason()
130	ctx = wv.viewportSyncContext(ctx)
131	if wv.syncViewportNowOnGTK(ctx, reason) {
132		wv.scheduleResizeRepaintPulse(ctx, reason)
133	}
134}
135
136func (wv *WebView) syncViewportNowOnGTK(ctx context.Context, reason string) bool {
137	if wv == nil {
138		return false
139	}
140	reason = strings.TrimSpace(reason)
141	if reason == "" {
142		reason = "manual"
143	}
144	if wv.destroyed.Load() || wv.viewBridge == nil {
145		return false
146	}
147
148	if wv.tryStartPendingBrowserCreateOnGTKThread(ctx, reason) {
149		return false
150	}
151
152	wv.mu.RLock()
153	host := wv.host
154	wv.mu.RUnlock()
155	if host == nil {
156		logging.FromContext(ctx).Debug().
157			Uint64("webview_id", uint64(wv.id)).
158			Str("reason", reason).
159			Msg("cef: viewport sync skipped; browser host not ready")
160		return false
161	}
162
163	widget := wv.viewBridge.Widget()
164	visible := effectiveViewportWidgetVisibility(widget)
165	wv.notifyViewportSyncOnCEFUIThread(host, visible)
166	wv.syncZoomForBackingScaleOnCEFUIThread(host, reason)
167	logging.FromContext(ctx).Debug().
168		Uint64("webview_id", uint64(wv.id)).
169		Bool("visible", visible).
170		Str("reason", reason).
171		Msg("cef: viewport sync requested")
172	return true
173}
174
175func (wv *WebView) syncResizeViewportOnGTK(ctx context.Context, reason string) bool {
176	if !wv.syncViewportNowOnGTK(ctx, reason) {
177		return false
178	}
179	wv.scheduleResizeRepaintPulse(ctx, reason)
180	return true
181}
182
183// resizeViewportResyncDelaysMs re-run the full viewport resize notification
184// shortly after a GTK size change.
185var resizeViewportResyncDelaysMs = [...]int64{16, 48}
186
187// resizeRepaintOnlyDelaysMs keep requesting refresh frames until CEF's OSR
188// video capturer adopts the new frame size. Until then captured frames are
189// letterboxed; Invalidate forces a fresh capture instead of waiting for the
190// capturer's idle refresh timer (up to ~1s on static pages). These pulses skip
191// NotifyScreenInfoChanged, which would reallocate the surface ID each time.
192var resizeRepaintOnlyDelaysMs = [...]int64{96, 160, 250, 400}
193
194func (wv *WebView) scheduleResizeRepaintPulse(ctx context.Context, reason string) {
195	if wv == nil || wv.destroyed.Load() {
196		return
197	}
198	wv.mu.RLock()
199	host := wv.host
200	wv.mu.RUnlock()
201	if host == nil {
202		return
203	}
204	seq := wv.viewportResizePulseSeq.Add(1)
205	for _, delayMs := range resizeViewportResyncDelaysMs {
206		wv.postResizePulse(ctx, host, seq, delayMs, reason, true)
207	}
208	for _, delayMs := range resizeRepaintOnlyDelaysMs {
209		wv.postResizePulse(ctx, host, seq, delayMs, reason, false)
210	}
211}
212
213func (wv *WebView) postResizePulse(
214	ctx context.Context,
215	host viewportSyncBrowserHost,
216	seq uint64,
217	delayMs int64,
218	reason string,
219	resync bool,
220) {
221	task := cefNewTask(cefTaskFunc(func() {
222		if wv == nil || wv.destroyed.Load() || wv.viewportResizePulseSeq.Load() != seq {
223			return
224		}
225		wv.mu.RLock()
226		currentHost := wv.host
227		wv.mu.RUnlock()
228		if currentHost != host {
229			return
230		}
231		if resync {
232			notifyBrowserViewportResize(host)
233		} else {
234			host.Invalidate(purecef.PaintElementTypePetView)
235		}
236		logging.FromContext(ctx).Debug().
237			Uint64("webview_id", uint64(wv.id)).
238			Int64("delay_ms", delayMs).
239			Bool("resync", resync).
240			Str("reason", reason).
241			Msg("cef: delayed resize viewport sync")
242	}))
243	if task == nil {
244		return
245	}
246	cefPostDelayedTask(purecef.ThreadIDTidUi, task, delayMs)
247}
248
249func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context, reason string) bool {
250	if wv == nil || wv.destroyed.Load() || wv.factory == nil || wv.viewBridge == nil {
251		return false
252	}
253
254	wv.mu.RLock()
255	hasHost := wv.host != nil
256	pending := wv.pendingCreate != nil
257	wv.mu.RUnlock()
258	if hasHost || !pending {
259		return false
260	}
261
262	widget := wv.viewBridge.Widget()
263	if widget == nil {
264		return false
265	}
266	width := int32(widget.GetAllocatedWidth())
267	height := int32(widget.GetAllocatedHeight())
268	if width <= 0 {
269		width = int32(widget.GetWidth())
270	}
271	if height <= 0 {
272		height = int32(widget.GetHeight())
273	}
274	if width <= 0 || height <= 0 {
275		logging.FromContext(ctx).Debug().
276			Uint64("webview_id", uint64(wv.id)).
277			Int32("width", width).
278			Int32("height", height).
279			Str("reason", reason).
280			Msg("cef: viewport sync skipped pending browser creation; widget has no allocation yet")
281		return false
282	}
283
284	observedWidth, observedHeight, observedReady := pendingBrowserCreateObservedSize(wv.viewBridge, width, height)
285	if !observedReady {
286		widget.QueueAllocate()
287		widget.QueueResize()
288		attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(ctx, reason)
289		switch action {
290		case pendingBrowserCreateObservedSizeRetryUnavailable:
291			return false
292		case pendingBrowserCreateObservedSizeRetryAlreadyScheduled:
293			logging.FromContext(ctx).Debug().
294				Uint64("webview_id", uint64(wv.id)).
295				Int32("observed_width", observedWidth).
296				Int32("observed_height", observedHeight).
297				Int32("width", width).
298				Int32("height", height).
299				Int("attempt", attempt).
300				Str("reason", reason).
301				Msg("cef: pending browser creation retry already scheduled while awaiting bridge size")
302			return false
303		case pendingBrowserCreateObservedSizeRetryScheduled:
304			logging.FromContext(ctx).Debug().
305				Uint64("webview_id", uint64(wv.id)).
306				Int32("observed_width", observedWidth).
307				Int32("observed_height", observedHeight).
308				Int32("width", width).
309				Int32("height", height).
310				Int("attempt", attempt).
311				Int("max_attempts", pendingBrowserCreateObservedSizeMaxRetries).
312				Str("reason", reason).
313				Msg("cef: delaying pending browser creation until bridge observes widget size")
314			return false
315		case pendingBrowserCreateObservedSizeRetryProceedWithoutDelay:
316			// Creating the browser here is intentional: after bounded retries we
317			// fall back to the current GTK allocation rather than stalling popup
318			// creation.
319			logging.FromContext(ctx).Debug().
320				Uint64("webview_id", uint64(wv.id)).
321				Int32("observed_width", observedWidth).
322				Int32("observed_height", observedHeight).
323				Int32("width", width).
324				Int32("height", height).
325				Int("attempt", attempt).
326				Str("reason", reason).
327				Msg("cef: bridge size observation did not settle before browser creation; continuing")
328		}
329	}
330
331	if wv.shouldDeferPendingBrowserCreateFromViewportSync() {
332		logging.FromContext(ctx).Debug().
333			Uint64("webview_id", uint64(wv.id)).
334			Int32("width", width).
335			Int32("height", height).
336			Str("reason", reason).
337			Msg("cef: viewport sync deferred pending browser creation while awaiting native popup attach")
338		return false
339	}
340
341	if err := wv.viewBridge.PrepareOnGTKThread(); err != nil {
342		logging.FromContext(ctx).Debug().
343			Err(err).
344			Uint64("webview_id", uint64(wv.id)).
345			Int32("width", width).
346			Int32("height", height).
347			Str("reason", reason).
348			Msg("cef: viewport sync could not prepare bridge for pending browser creation")
349		return false
350	}
351
352	wv.markInitialBrowserCreateResizeHandled()
353	wv.factory.postPendingBrowserCreate(ctx, wv, width, height)
354	logging.FromContext(ctx).Debug().
355		Uint64("webview_id", uint64(wv.id)).
356		Int32("width", width).
357		Int32("height", height).
358		Str("reason", reason).
359		Msg("cef: viewport sync nudged pending browser creation")
360	return true
361}
362
363func (wv *WebView) shouldDeferPendingBrowserCreateFromViewportSync() bool {
364	return wv != nil && wv.awaitsNativePopupAttachment()
365}
366
367func pendingBrowserCreateObservedSize(
368	bridge pendingBrowserCreateObservedSizeBridge,
369	allocatedWidth, allocatedHeight int32,
370) (observedWidth, observedHeight int32, ready bool) {
371	if bridge == nil {
372		return 0, 0, false
373	}
374	observedWidth, observedHeight = bridge.Size()
375	if observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight) {
376		return observedWidth, observedHeight, true
377	}
378	observedWidth, observedHeight = bridge.RefreshObservedSizeOnGTKThread()
379	return observedWidth, observedHeight, observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight)
380}
381
382// observedViewportSizeReady reports whether observedWidth/observedHeight match
383// allocatedWidth/allocatedHeight within a ±1px tolerance.
384//
385// Fallback observed sizes (<=1) and non-positive allocated dimensions are
386// treated as not ready.
387func observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight int32) bool {
388	if allocatedWidth <= 0 || allocatedHeight <= 0 {
389		return false
390	}
391	if observedWidth <= 1 || observedHeight <= 1 {
392		return false
393	}
394	return abs32(observedWidth-allocatedWidth) <= 1 && abs32(observedHeight-allocatedHeight) <= 1
395}
396
397// abs32 returns the absolute value of v.
398func abs32(v int32) int32 {
399	if v < 0 {
400		return -v
401	}
402	return v
403}
404
405// preparePendingBrowserCreateObservedSizeRetry updates the observed-size retry
406// state machine for a pending browser create.
407//
408// It returns the current attempt count and the resulting action. Scheduling is
409// coalesced so only one delayed retry is pending at a time, while the max-retry
410// path proceeds without leaving the scheduled flag set.
411func (wv *WebView) preparePendingBrowserCreateObservedSizeRetry(
412	ctx context.Context,
413	reason string,
414) (attempt int, action pendingBrowserCreateObservedSizeRetryAction) {
415	if wv == nil {
416		return 0, pendingBrowserCreateObservedSizeRetryUnavailable
417	}
418
419	shouldSchedule := false
420	wv.mu.Lock()
421	if wv.pendingCreate == nil {
422		wv.mu.Unlock()
423		return 0, pendingBrowserCreateObservedSizeRetryUnavailable
424	}
425	if wv.pendingCreate.observedSizeRetryScheduled {
426		attempt = wv.pendingCreate.observedSizeRetries
427		wv.mu.Unlock()
428		return attempt, pendingBrowserCreateObservedSizeRetryAlreadyScheduled
429	}
430	wv.pendingCreate.observedSizeRetries++
431	attempt = wv.pendingCreate.observedSizeRetries
432	if attempt <= pendingBrowserCreateObservedSizeMaxRetries {
433		wv.pendingCreate.observedSizeRetryScheduled = true
434		shouldSchedule = true
435	}
436	wv.mu.Unlock()
437
438	if !shouldSchedule {
439		return attempt, pendingBrowserCreateObservedSizeRetryProceedWithoutDelay
440	}
441	wv.schedulePendingBrowserCreateObservedSizeRetry(ctx, reason)
442	return attempt, pendingBrowserCreateObservedSizeRetryScheduled
443}
444
445// clearPendingBrowserCreateObservedSizeRetry clears the scheduled-retry flag
446// so a future observed-size reservation can schedule another attempt.
447func (wv *WebView) clearPendingBrowserCreateObservedSizeRetry() {
448	if wv == nil {
449		return
450	}
451	wv.mu.Lock()
452	defer wv.mu.Unlock()
453	if wv.pendingCreate != nil {
454		wv.pendingCreate.observedSizeRetryScheduled = false
455	}
456}
457
458// schedulePendingBrowserCreateObservedSizeRetry schedules a delayed viewport
459// sync retry using ctx after pendingBrowserCreateObservedSizeRetryDelay.
460//
461// It clears the scheduled flag before re-invoking syncViewportNowOnGTK with a
462// retry-specific suffix appended to reason.
463func (wv *WebView) schedulePendingBrowserCreateObservedSizeRetry(ctx context.Context, reason string) {
464	if wv == nil || wv.destroyed.Load() {
465		return
466	}
467	cefScheduleAfter(pendingBrowserCreateObservedSizeRetryDelay, func() {
468		if wv == nil || wv.destroyed.Load() {
469			return
470		}
471		wv.clearPendingBrowserCreateObservedSizeRetry()
472		wv.runOnGTK(func() {
473			wv.syncViewportNowOnGTK(ctx, reason+"-await-observed-size")
474		})
475	})
476}
477
478func (wv *WebView) installViewportSyncHooks() syncdispatch.SyncDispatchResult {
479	if wv == nil || wv.viewBridge == nil {
480		return syncdispatch.SyncDispatchResult{Label: "cef.install_viewport_sync_hooks", Status: syncdispatch.SyncDispatchInline}
481	}
482	return wv.runOnGTKSyncLabel("cef.install_viewport_sync_hooks", func() {
483		wv.installViewportSyncHooksOnGTKThread()
484	})
485}
486
487func (wv *WebView) installViewportSyncHooksOnGTKThread() {
488	if wv == nil || wv.viewBridge == nil || wv.viewportMapFunc != nil {
489		return
490	}
491	widget := wv.viewBridge.Widget()
492	if widget == nil {
493		return
494	}
495
496	wv.viewportMapFunc = func(gtk.Widget) {
497		wv.SyncViewport(wv.ctx, "gtk-map")
498	}
499	wv.viewportShowFunc = func(gtk.Widget) {
500		wv.SyncViewport(wv.ctx, "gtk-show")
501	}
502	wv.viewportHideFunc = func(gtk.Widget) {
503		wv.SyncViewport(wv.ctx, "gtk-hide")
504	}
505	wv.viewportUnmapFunc = func(gtk.Widget) {
506		wv.SyncViewport(wv.ctx, "gtk-unmap")
507	}
508	wv.viewportRealizeFunc = func(gtk.Widget) {
509		wv.SyncViewport(wv.ctx, "gtk-realize")
510	}
511
512	wv.viewportMapSignalID = widget.ConnectMap(&wv.viewportMapFunc)
513	wv.viewportShowSignalID = widget.ConnectShow(&wv.viewportShowFunc)
514	wv.viewportHideSignalID = widget.ConnectHide(&wv.viewportHideFunc)
515	wv.viewportUnmapSignalID = widget.ConnectUnmap(&wv.viewportUnmapFunc)
516	wv.viewportRealizeSignalID = widget.ConnectRealize(&wv.viewportRealizeFunc)
517}
518
519func (wv *WebView) disconnectViewportSyncHooksOnGTKThread() {
520	if wv == nil || wv.viewBridge == nil {
521		return
522	}
523	widget := wv.viewBridge.Widget()
524	if widget != nil {
525		widgetPtr := widget.GoPointer()
526		if widgetPtr != 0 {
527			obj := gobject.ObjectNewFromInternalPtr(widgetPtr)
528			if wv.viewportMapSignalID != 0 {
529				gobject.SignalHandlerDisconnect(obj, wv.viewportMapSignalID)
530				wv.viewportMapSignalID = 0
531			}
532			if wv.viewportShowSignalID != 0 {
533				gobject.SignalHandlerDisconnect(obj, wv.viewportShowSignalID)
534				wv.viewportShowSignalID = 0
535			}
536			if wv.viewportHideSignalID != 0 {
537				gobject.SignalHandlerDisconnect(obj, wv.viewportHideSignalID)
538				wv.viewportHideSignalID = 0
539			}
540			if wv.viewportUnmapSignalID != 0 {
541				gobject.SignalHandlerDisconnect(obj, wv.viewportUnmapSignalID)
542				wv.viewportUnmapSignalID = 0
543			}
544			if wv.viewportRealizeSignalID != 0 {
545				gobject.SignalHandlerDisconnect(obj, wv.viewportRealizeSignalID)
546				wv.viewportRealizeSignalID = 0
547			}
548		}
549	}
550	wv.viewportMapFunc = nil
551	wv.viewportShowFunc = nil
552	wv.viewportHideFunc = nil
553	wv.viewportUnmapFunc = nil
554	wv.viewportRealizeFunc = nil
555}
556
557func (wv *WebView) notifyViewportSyncOnCEFUIThread(host viewportSyncBrowserHost, visible bool) {
558	if host == nil {
559		return
560	}
561	task := cefNewTask(cefTaskFunc(func() {
562		if wv == nil || wv.destroyed.Load() {
563			return
564		}
565		wv.mu.RLock()
566		currentHost := wv.host
567		wv.mu.RUnlock()
568		if currentHost != host {
569			return
570		}
571		wv.applyEffectiveVisibility(host, visible)
572		notifyBrowserViewportResize(host)
573	}))
574	if task == nil {
575		return
576	}
577	cefPostDelayedTask(purecef.ThreadIDTidUi, task, 0)
578}
579
580func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost, reason string) {
581	if host == nil || wv == nil {
582		return
583	}
584	compensation := wv.pageZoomCompensation()
585	if !wv.shouldReapplyZoomForCompensation(compensation) {
586		return
587	}
588	task := cefNewTask(cefTaskFunc(func() {
589		if wv == nil || wv.destroyed.Load() {
590			return
591		}
592		wv.mu.RLock()
593		currentHost := wv.host
594		wv.mu.RUnlock()
595		if currentHost != host {
596			return
597		}
598		// Re-check on execution: a newer output scale may have superseded the
599		// compensation captured when this task was scheduled.
600		if !wv.shouldReapplyZoomForCompensation(wv.pageZoomCompensation()) {
601			return
602		}
603		wv.reapplyCurrentZoomForBackingScale(reason)
604	}))
605	if task == nil {
606		return
607	}
608	cefPostDelayedTask(purecef.ThreadIDTidUi, task, 0)
609}
610
611// applyEffectiveVisibility reports the initial effective visibility and later
612// transitions exactly once. CEF OSR begins hidden, so even an initially hidden
613// GTK widget must be explicitly reported to keep both lifecycles aligned.
614func (wv *WebView) applyEffectiveVisibility(host viewportSyncBrowserHost, visible bool) {
615	if wv == nil || host == nil || wv.destroyed.Load() {
616		return
617	}
618	wv.mu.Lock()
619	if wv.effectiveVisibilityKnown && wv.effectiveVisible == visible {
620		wv.mu.Unlock()
621		return
622	}
623	wv.effectiveVisibilityKnown = true
624	wv.effectiveVisible = visible
625	wv.mu.Unlock()
626	if visible {
627		host.WasHidden(0)
628		return
629	}
630	// The hidden transition retires scroll motion: a re-shown page never
631	// resumes old motion, and delayed cleanup cannot cancel a newer visible
632	// session because cleanup is epoch-scoped.
633	wv.invalidateScrollMotion()
634	host.WasHidden(1)
635}
636
637func notifyBrowserViewportSync(host viewportSyncBrowserHost, visible bool) {
638	if host == nil {
639		return
640	}
641	if visible {
642		host.WasHidden(0)
643	}
644	notifyBrowserViewportResize(host)
645}
646
647func notifyBrowserViewportResize(host viewportSyncBrowserHost) {
648	if host == nil {
649		return
650	}
651	host.NotifyScreenInfoChanged()
652	notifyBrowserResize(host)
653}

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