v0.32.0 0617278

webview_scroll_test.go RawHistoryFolder

1package cef
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"os/exec"
8	"strings"
9	"sync"
10	"testing"
11
12	purecef "github.com/bnema/purego-cef/cef"
13	cefmocks "github.com/bnema/purego-cef/cef/mocks"
14	"github.com/stretchr/testify/mock"
15
16	"github.com/bnema/dumber/internal/application/port"
17)
18
19func TestPageScrollWheelDeltas_UseFallbackDeltasWithCEFSign(t *testing.T) {
20	tests := []struct {
21		name       string
22		req        port.PageScrollRequest
23		wantDeltaX int32
24		wantDeltaY int32
25	}{
26		{"left", port.PageScrollRequest{FallbackDX: -80}, 80, 0},
27		{"right", port.PageScrollRequest{FallbackDX: 80}, -80, 0},
28		{"up", port.PageScrollRequest{FallbackDY: -80}, 0, 80},
29		{"down", port.PageScrollRequest{FallbackDY: 80}, 0, -80},
30		{"up fast", port.PageScrollRequest{FallbackDY: -320}, 0, 320},
31		{"down fast", port.PageScrollRequest{FallbackDY: 320}, 0, -320},
32	}
33
34	for _, tt := range tests {
35		t.Run(tt.name, func(t *testing.T) {
36			gotDeltaX, gotDeltaY := pageScrollWheelDeltas(tt.req)
37			if gotDeltaX != tt.wantDeltaX || gotDeltaY != tt.wantDeltaY {
38				t.Fatalf("pageScrollWheelDeltas() = (%d, %d), want (%d, %d)", gotDeltaX, gotDeltaY, tt.wantDeltaX, tt.wantDeltaY)
39			}
40		})
41	}
42}
43
44// scrollRecorderHost wraps purecef.BrowserHost and records mouse wheel events,
45// allowing tests to verify that Vim Mode uses Chromium's native scroll path.
46type scrollRecorderHost struct {
47	purecef.BrowserHost // embedded nil — only SendMouseWheelEvent is called in these tests
48
49	mu     sync.Mutex
50	events []recordedWheelEvent
51}
52
53type recordedWheelEvent struct {
54	event          purecef.MouseEvent
55	deltaX, deltaY int32
56}
57
58func (h *scrollRecorderHost) SendMouseWheelEvent(event *purecef.MouseEvent, deltaX, deltaY int32) {
59	h.mu.Lock()
60	defer h.mu.Unlock()
61	// Record a copy so the test can inspect it after the call returns.
62	h.events = append(h.events, recordedWheelEvent{event: *event, deltaX: deltaX, deltaY: deltaY})
63}
64
65func (h *scrollRecorderHost) recordedEvents() []recordedWheelEvent {
66	h.mu.Lock()
67	defer h.mu.Unlock()
68	out := make([]recordedWheelEvent, len(h.events))
69	copy(out, h.events)
70	return out
71}
72
73func (h *scrollRecorderHost) resetRecords() {
74	h.mu.Lock()
75	defer h.mu.Unlock()
76	h.events = nil
77}
78
79func TestScrollPage_NativeWheelPath_SendsPrecisionWheelEvent(t *testing.T) {
80	host := &scrollRecorderHost{}
81	wv := &WebView{host: host}
82
83	req := port.PageScrollRequest{
84		Command:    port.PageScrollCommandDown,
85		FallbackDX: 0,
86		FallbackDY: 80,
87	}
88
89	err := wv.ScrollPage(context.Background(), req)
90	if err != nil {
91		t.Fatalf("ScrollPage() unexpected error: %v", err)
92	}
93
94	events := host.recordedEvents()
95	if len(events) != 1 {
96		t.Fatalf("expected 1 wheel event, got %d", len(events))
97	}
98	if events[0].deltaX != 0 || events[0].deltaY != -80 {
99		t.Fatalf("wheel delta = (%d, %d), want (0, -80)", events[0].deltaX, events[0].deltaY)
100	}
101	if events[0].event.Modifiers&uint32(purecef.EventFlagsEventflagPrecisionScrollingDelta) == 0 {
102		t.Fatal("wheel event should set EVENTFLAG_PRECISION_SCROLLING_DELTA")
103	}
104}
105
106func TestScrollPage_NativeWheelPath_AllCommandsUseFallbackDeltas(t *testing.T) {
107	host := &scrollRecorderHost{}
108	wv := &WebView{host: host}
109
110	commands := []struct {
111		name       string
112		req        port.PageScrollRequest
113		wantDeltaX int32
114		wantDeltaY int32
115	}{
116		{"PageScrollLeft", port.PageScrollRequest{Command: port.PageScrollCommandLeft, FallbackDX: -80}, 80, 0},
117		{"PageScrollRight", port.PageScrollRequest{Command: port.PageScrollCommandRight, FallbackDX: 80}, -80, 0},
118		{"PageScrollUp", port.PageScrollRequest{Command: port.PageScrollCommandUp, FallbackDY: -80}, 0, 80},
119		{"PageScrollDown", port.PageScrollRequest{Command: port.PageScrollCommandDown, FallbackDY: 80}, 0, -80},
120		{"PageScrollUpFast", port.PageScrollRequest{Command: port.PageScrollCommandUpFast, FallbackDY: -320}, 0, 320},
121		{"PageScrollDownFast", port.PageScrollRequest{Command: port.PageScrollCommandDownFast, FallbackDY: 320}, 0, -320},
122	}
123
124	for _, tt := range commands {
125		t.Run(tt.name, func(t *testing.T) {
126			host.resetRecords()
127
128			if err := wv.ScrollPage(context.Background(), tt.req); err != nil {
129				t.Fatalf("ScrollPage() error: %v", err)
130			}
131
132			events := host.recordedEvents()
133			if len(events) != 1 {
134				t.Fatalf("expected 1 wheel event, got %d", len(events))
135			}
136			if events[0].deltaX != tt.wantDeltaX || events[0].deltaY != tt.wantDeltaY {
137				t.Fatalf("wheel delta = (%d, %d), want (%d, %d)", events[0].deltaX, events[0].deltaY, tt.wantDeltaX, tt.wantDeltaY)
138			}
139		})
140	}
141}
142
143func TestScrollPage_ReadyBrowser_HorizontalDirectionsUseDOMSigns(t *testing.T) {
144	tests := []struct {
145		name   string
146		dx     int
147		marker string
148	}{
149		{name: "h scrolls left", dx: -80, marker: "var dx=-80,dy=0"},
150		{name: "l scrolls right", dx: 80, marker: "var dx=80,dy=0"},
151	}
152
153	for _, tt := range tests {
154		t.Run(tt.name, func(t *testing.T) {
155			host := &scrollRecorderHost{}
156			browser := cefmocks.NewMockBrowser(t)
157			frame := cefmocks.NewMockFrame(t)
158			browser.EXPECT().GetMainFrame().Return(frame).Once()
159			frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool {
160				return strings.Contains(script, tt.marker)
161			}), "", int32(0)).Once()
162
163			wv := &WebView{host: host, browser: browser}
164			if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDX: tt.dx}); err != nil {
165				t.Fatalf("ScrollPage() error: %v", err)
166			}
167			if events := host.recordedEvents(); len(events) != 0 {
168				t.Fatalf("ready browser should use DOM target resolution, got %d wheel events", len(events))
169			}
170		})
171	}
172}
173
174func TestScrollPage_ReadyBrowser_NestedBoundaryHandsOffToDocument(t *testing.T) {
175	node, err := exec.LookPath("node")
176	if err != nil {
177		t.Skip("node is required to execute the generated vim-scroll helper")
178	}
179
180	host := &scrollRecorderHost{}
181	browser := cefmocks.NewMockBrowser(t)
182	frame := cefmocks.NewMockFrame(t)
183	var pageScrollScript string
184	browser.EXPECT().GetMainFrame().Return(frame).Once()
185	frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool {
186		pageScrollScript = script
187		return true
188	}), "", int32(0)).Once()
189
190	wv := &WebView{host: host, browser: browser}
191	if scrollErr := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); scrollErr != nil {
192		t.Fatalf("ScrollPage() error: %v", scrollErr)
193	}
194	if events := host.recordedEvents(); len(events) != 0 {
195		t.Fatalf("ready browser should resolve a scrollable DOM target instead of latching a wheel event, got %d wheel events", len(events))
196	}
197
198	// The centered nested scroller can consume the first tick only. The next
199	// tick must skip its exhausted boundary and resume on the document scroller.
200	harness := fmt.Sprintf(`
201const body={parentElement:null};
202const root={scrollTop:0,scrollLeft:0,scrollHeight:3000,clientHeight:500,scrollWidth:800,clientWidth:800,parentElement:null,style:{overflowY:'auto',overflowX:'auto'}};
203const inner={scrollTop:120,scrollLeft:0,scrollHeight:300,clientHeight:100,scrollWidth:200,clientWidth:200,parentElement:body,style:{overflowY:'auto',overflowX:'auto'}};
204body.parentElement=root;
205global.document={activeElement:body,body:body,documentElement:root,scrollingElement:root,elementFromPoint:()=>inner};
206global.window={innerWidth:800,innerHeight:600,getComputedStyle:(el)=>el.style||{},scrollX:0,scrollY:0,scrollBy:()=>{},scrollTo:()=>{}};
207const tick=%q;
208eval(tick);
209eval(tick);
210process.stdout.write(inner.scrollTop+','+root.scrollTop);
211`, pageScrollScript)
212	output, err := exec.Command(node, "-e", harness).CombinedOutput()
213	if err != nil {
214		t.Fatalf("execute generated vim-scroll helper: %v\n%s", err, output)
215	}
216	if got := strings.TrimSpace(string(output)); got != "200,80" {
217		t.Fatalf("nested/document scroll offsets = %s, want 200,80", got)
218	}
219}
220
221func TestScrollPage_NativeWheelPath_ZeroDeltaDoesNotSendEvent(t *testing.T) {
222	host := &scrollRecorderHost{}
223	wv := &WebView{host: host}
224
225	if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{}); err != nil {
226		t.Fatalf("ScrollPage() error: %v", err)
227	}
228	if events := host.recordedEvents(); len(events) != 0 {
229		t.Fatalf("expected no wheel events for zero delta, got %d", len(events))
230	}
231}
232
233func TestScrollPage_NativePath_FallbackToJSWhenHostIsNil(t *testing.T) {
234	// With no host set, ScrollPage should still succeed via the JS fallback.
235	wv := &WebView{}
236
237	req := port.PageScrollRequest{
238		Command:    port.PageScrollCommandDown,
239		FallbackDX: 0,
240		FallbackDY: 80,
241	}
242
243	// Should not panic and should succeed (JS fallback is fire-and-forget
244	// via RunJavaScript, which is safe with a nil browser).
245	err := wv.ScrollPage(context.Background(), req)
246	if err != nil {
247		t.Fatalf("ScrollPage() with nil host should not return error: %v", err)
248	}
249}
250
251func TestScrollPage_CoalescesHeldLoadIntoOneBoundedCEFTask(t *testing.T) {
252	oldNewTask, oldPostTask := cefNewTask, cefPostTask
253	defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }()
254	cefNewTask = func(task purecef.Task) purecef.Task { return task }
255	var scheduled purecef.Task
256	posts := 0
257	cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 {
258		if threadID != purecef.ThreadIDTidUi {
259			t.Fatalf("thread=%v, want CEF UI", threadID)
260		}
261		posts++
262		scheduled = task
263		return 1
264	}
265
266	browser := cefmocks.NewMockBrowser(t)
267	frame := cefmocks.NewMockFrame(t)
268	browser.EXPECT().GetMainFrame().Return(frame).Once()
269	frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool {
270		return strings.Contains(script, "var dx=0,dy=3200")
271	}), "", int32(0)).Once()
272	wv := &WebView{engine: &Engine{}, browser: browser}
273
274	for range 10_000 {
275		if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil {
276			t.Fatal(err)
277		}
278	}
279	if posts != 1 {
280		t.Fatalf("CEF flush posts=%d, want exactly 1 under load", posts)
281	}
282	if scheduled == nil {
283		t.Fatal("missing scheduled flush")
284	}
285	scheduled.Execute()
286	if posts != 1 {
287		t.Fatalf("unexpected replay after drain: posts=%d", posts)
288	}
289}
290
291func TestCancelPageScroll_DropsHeldDeltaButPreservesTap(t *testing.T) {
292	oldNewTask, oldPostTask := cefNewTask, cefPostTask
293	defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }()
294	cefNewTask = func(task purecef.Task) purecef.Task { return task }
295	var scheduled purecef.Task
296	posts := 0
297	cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 {
298		posts++
299		scheduled = task
300		return 1
301	}
302
303	browser := cefmocks.NewMockBrowser(t)
304	frame := cefmocks.NewMockFrame(t)
305	browser.EXPECT().GetMainFrame().Return(frame).Once()
306	frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool {
307		return strings.Contains(script, "var dx=0,dy=80") && !strings.Contains(script, "requestAnimationFrame")
308	}), "", int32(0)).Once()
309	wv := &WebView{engine: &Engine{}, browser: browser}
310
311	if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); err != nil {
312		t.Fatal(err)
313	}
314	for range 50 {
315		if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil {
316			t.Fatal(err)
317		}
318	}
319	wv.CancelPageScroll(context.Background())
320	if posts != 1 {
321		t.Fatalf("posts before release=%d, want 1", posts)
322	}
323	scheduled.Execute()
324	if posts != 1 {
325		t.Fatalf("release must not schedule a trailing replay: posts=%d", posts)
326	}
327}
328
329func TestDrainPageScrollSynchronously_DrainsTapEnqueuedDuringExecution(t *testing.T) {
330	browser := cefmocks.NewMockBrowser(t)
331	frame := cefmocks.NewMockFrame(t)
332	browser.EXPECT().GetMainFrame().Return(frame).Twice()
333
334	wv := &WebView{browser: browser}
335	calls := 0
336	frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Run(func(_, _ string, _ int32) {
337		calls++
338		if calls == 1 {
339			if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); err != nil {
340				t.Errorf("enqueue tap during execution: %v", err)
341			}
342		}
343	}).Twice()
344
345	if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); err != nil {
346		t.Fatal(err)
347	}
348	if calls != 2 {
349		t.Fatalf("ExecuteJavaScript calls=%d, want 2", calls)
350	}
351
352	q := &wv.pageScrollQueue
353	q.mu.Lock()
354	defer q.mu.Unlock()
355	if q.flushPending || q.tapDX != 0 || q.tapDY != 0 || q.heldDX != 0 || q.heldDY != 0 {
356		t.Fatalf("queue not drained: pending=%t tap=(%d,%d) held=(%d,%d)", q.flushPending, q.tapDX, q.tapDY, q.heldDX, q.heldDY)
357	}
358}
359
360func TestCancelPageScroll_AfterDrainDropsOldHeldAndPreservesTapAndNextGesture(t *testing.T) {
361	oldNewTask, oldPostTask := cefNewTask, cefPostTask
362	defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }()
363	cefNewTask = func(task purecef.Task) purecef.Task { return task }
364	var scheduled []purecef.Task
365	cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 {
366		scheduled = append(scheduled, task)
367		return 1
368	}
369
370	drained := make(chan struct{})
371	resume := make(chan struct{})
372	var pauseOnce sync.Once
373	beforeExecute := func() {
374		pauseOnce.Do(func() {
375			close(drained)
376			<-resume
377		})
378	}
379
380	browser := cefmocks.NewMockBrowser(t)
381	frame := cefmocks.NewMockFrame(t)
382	browser.EXPECT().GetMainFrame().Return(frame).Twice()
383	var scripts []string
384	frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Run(func(script, _ string, _ int32) {
385		scripts = append(scripts, script)
386	}).Twice()
387	wv := &WebView{engine: &Engine{}, browser: browser}
388	wv.pageScrollQueue.beforeExecute = beforeExecute
389
390	if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); err != nil {
391		t.Fatal(err)
392	}
393	if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil {
394		t.Fatal(err)
395	}
396	if len(scheduled) != 1 {
397		t.Fatalf("initial pending tasks=%d, want 1", len(scheduled))
398	}
399
400	flushDone := make(chan struct{})
401	go func() {
402		defer close(flushDone)
403		scheduled[0].Execute()
404	}()
405	<-drained
406
407	// Cancel returns while the old batch is drained but has not committed to
408	// ExecuteJavaScript. Its held component must therefore be invalidated.
409	wv.CancelPageScroll(context.Background())
410	if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDX: 80}); err != nil {
411		t.Fatal(err)
412	}
413	if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDX: 80, Continuous: true}); err != nil {
414		t.Fatal(err)
415	}
416	if len(scheduled) != 1 {
417		t.Fatalf("tasks while old flush is paused=%d, want 1", len(scheduled))
418	}
419
420	close(resume)
421	<-flushDone
422	if len(scripts) != 1 || !strings.Contains(scripts[0], "var dx=0,dy=80") {
423		t.Fatalf("old generation scripts=%v, want the preserved 80px tap without held delta", scripts)
424	}
425	if len(scheduled) != 2 {
426		t.Fatalf("tasks after old flush=%d, want one successor for the new gesture", len(scheduled))
427	}
428	scheduled[1].Execute()
429	if len(scripts) != 2 || !strings.Contains(scripts[1], "var dx=160,dy=0") {
430		t.Fatalf("scripts after new gesture=%v, want a fresh 160px horizontal batch", scripts)
431	}
432	if len(scheduled) != 2 {
433		t.Fatalf("trailing tasks=%d, want 2 total", len(scheduled))
434	}
435}
436
437func TestScrollPage_AllowsAtMostOneSuccessorAfterInFlightFlush(t *testing.T) {
438	oldNewTask, oldPostTask := cefNewTask, cefPostTask
439	defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }()
440	cefNewTask = func(task purecef.Task) purecef.Task { return task }
441	var scheduled []purecef.Task
442	cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 {
443		scheduled = append(scheduled, task)
444		return 1
445	}
446
447	browser := cefmocks.NewMockBrowser(t)
448	frame := cefmocks.NewMockFrame(t)
449	browser.EXPECT().GetMainFrame().Return(frame).Twice()
450	var wv *WebView
451	frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Run(func(string, string, int32) {
452		for range 100 {
453			if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil {
454				t.Fatal(err)
455			}
456		}
457		if len(scheduled) != 1 {
458			t.Fatalf("tasks while flush in flight=%d, want 1", len(scheduled))
459		}
460	}).Once()
461	frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Once()
462	wv = &WebView{engine: &Engine{}, browser: browser}
463	if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil {
464		t.Fatal(err)
465	}
466	if len(scheduled) != 1 {
467		t.Fatalf("initial pending tasks=%d, want 1", len(scheduled))
468	}
469
470	scheduled[0].Execute()
471	if len(scheduled) != 2 {
472		t.Fatalf("successor tasks=%d, want one successor", len(scheduled))
473	}
474	scheduled[1].Execute()
475	if len(scheduled) != 2 {
476		t.Fatalf("prolonged replay tasks=%d, want 2 total", len(scheduled))
477	}
478}
479
480func TestEnsurePageScrollFlush_PostTaskFailureDrainsTapAndDropsHeld(t *testing.T) {
481	oldNewTask, oldPostTask := cefNewTask, cefPostTask
482	defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }()
483	cefNewTask = func(task purecef.Task) purecef.Task { return task }
484	cefPostTask = func(_ purecef.ThreadID, _ purecef.Task) int32 { return 0 }
485
486	browser := cefmocks.NewMockBrowser(t)
487	frame := cefmocks.NewMockFrame(t)
488	browser.EXPECT().GetMainFrame().Return(frame).Once()
489	frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool {
490		return strings.Contains(script, "var dx=0,dy=80") && !strings.Contains(script, "dy=160")
491	}), "", int32(0)).Once()
492	wv := &WebView{engine: &Engine{}, browser: browser}
493
494	if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); err != nil {
495		t.Fatal(err)
496	}
497	if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil {
498		t.Fatal(err)
499	}
500
501	q := &wv.pageScrollQueue
502	q.mu.Lock()
503	tapDX, tapDY := q.tapDX, q.tapDY
504	heldDX, heldDY := q.heldDX, q.heldDY
505	pending := q.flushPending
506	q.mu.Unlock()
507	if tapDX != 0 || tapDY != 0 {
508		t.Fatalf("tap deltas stranded after PostTask failure: tap=(%d,%d)", tapDX, tapDY)
509	}
510	if heldDX != 0 || heldDY != 0 {
511		t.Fatalf("held deltas must keep cancel-on-schedule-failure semantics: held=(%d,%d)", heldDX, heldDY)
512	}
513	if pending {
514		t.Fatal("flushPending must clear after PostTask failure drain")
515	}
516}
517
518func TestScrollPage_Destroyed_ReturnsError(t *testing.T) {
519	wv := &WebView{}
520	wv.destroyed.Store(true)
521
522	err := wv.ScrollPage(context.Background(), port.PageScrollRequest{})
523	if !errors.Is(err, errDestroyed) {
524		t.Fatalf("ScrollPage() on destroyed webview: got %v, want %v", err, errDestroyed)
525	}
526}

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