detached 5041353

vim_page_test.go RawHistoryFolder

1package cef
2
3import (
4	"context"
5	"encoding/base64"
6	"net/http"
7	"regexp"
8	"strings"
9	"testing"
10
11	purecef "github.com/bnema/purego-cef/cef"
12	cefmocks "github.com/bnema/purego-cef/cef/mocks"
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/mock"
15	"github.com/stretchr/testify/require"
16
17	"github.com/bnema/dumber/internal/application/dto"
18	"github.com/bnema/dumber/internal/application/port"
19)
20
21// expectVimPageScript expects one ExecuteJavaScript call and captures the
22// script that was actually sent to the page.
23func expectVimPageScript(t *testing.T) (*WebView, *string) {
24	t.Helper()
25	browser := cefmocks.NewMockBrowser(t)
26	frame := cefmocks.NewMockFrame(t)
27	var script string
28	browser.EXPECT().GetMainFrame().Return(frame).Once()
29	frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Run(func(code, _ string, _ int32) { script = code }).Once()
30	return &WebView{browser: browser, bridgeNonce: "shared-bridge-nonce"}, &script
31}
32
33func TestWebViewStartVimPageInteractionArmsFreshTokenWithoutBridgeNonce(t *testing.T) {
34	wv, script := expectVimPageScript(t)
35
36	err := wv.StartVimPageInteraction(context.Background(), dto.VimPageInteractionRequest{
37		Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectCode, HighlightColor: "#123456",
38	})
39	require.NoError(t, err)
40	token := wv.currentVimPageToken()
41	require.NotEmpty(t, token)
42	assert.NotEqual(t, "shared-bridge-nonce", token)
43	assert.Contains(t, *script, "if (window.__dumberVimPage) return;", "start ships the runtime")
44	assert.Contains(t, *script, `dumb:///api/vim-page`)
45	assert.NotContains(t, *script, "shared-bridge-nonce")
46	assert.Contains(t, *script, `window.__dumberVimPage.start("`+token+`", {"kind":"yank","object":"code","color":"#123456"});`)
47}
48
49// Keys and cancels run on the already installed runtime: shipping the whole
50// runtime for every key press would cost tens of kilobytes per keystroke.
51func TestWebViewSendVimPageKeyDoesNotShipRuntime(t *testing.T) {
52	wv, script := expectVimPageScript(t)
53	wv.vimPageToken = "tok"
54
55	require.NoError(t, wv.SendVimPageKey(context.Background(), "\"\a"))
56	require.NoError(t, wv.SendVimPageKey(context.Background(), ""))
57
58	assert.True(t, strings.HasPrefix(*script, `window.__dumberVimPage ? window.__dumberVimPage.key("tok", "\"\u0007") : `), *script)
59	assert.NotContains(t, *script, "if (window.__dumberVimPage) return;")
60	assert.Less(t, len(*script), 1024)
61}
62
63// A document replaced between start and key leaves no runtime; the script must
64// then report the end of that token so the UI releases its key capture.
65func TestVimPageCallScriptReportsEndWhenRuntimeMissing(t *testing.T) {
66	script := vimPageCallScript("cancel", "tok")
67
68	assert.Contains(t, script, `window.__dumberVimPage ? window.__dumberVimPage.cancel("tok") : `)
69	assert.Contains(t, script, `dumb:///api/vim-page`)
70	match := regexp.MustCompile(`"X-Dumber-Body": "([^"]+)"`).FindStringSubmatch(script)
71	require.Len(t, match, 2)
72	body, err := base64.StdEncoding.DecodeString(match[1])
73	require.NoError(t, err)
74	assert.JSONEq(t, `{"token":"tok","type":"end"}`, string(body))
75}
76
77func TestWebViewSendVimPageKeyWithoutInteractionIsNoop(t *testing.T) {
78	wv := &WebView{browser: cefmocks.NewMockBrowser(t)}
79
80	require.NoError(t, wv.SendVimPageKey(context.Background(), "j"))
81}
82
83func TestWebViewCancelVimPageInteractionDisarmsToken(t *testing.T) {
84	wv, script := expectVimPageScript(t)
85	wv.vimPageToken = "tok"
86
87	require.NoError(t, wv.CancelVimPageInteraction(context.Background()))
88	assert.True(t, strings.HasPrefix(*script, `window.__dumberVimPage ? window.__dumberVimPage.cancel("tok") : `), *script)
89	assert.Empty(t, wv.currentVimPageToken())
90	require.NoError(t, wv.CancelVimPageInteraction(context.Background()))
91}
92
93func TestVimPageRequestJSONRejectsUnknownRequests(t *testing.T) {
94	_, err := vimPageRequestJSON(dto.VimPageInteractionRequest{})
95	require.Error(t, err)
96	_, err = vimPageRequestJSON(dto.VimPageInteractionRequest{Kind: dto.VimPageYankObject})
97	require.Error(t, err)
98
99	got, err := vimPageRequestJSON(dto.VimPageInteractionRequest{Kind: dto.VimPageHintFollowNew})
100	require.NoError(t, err)
101	assert.JSONEq(t, `{"kind":"hint-follow-new"}`, got)
102}
103
104// The accent reaches the page as a CSS color; only hex colors are sent.
105func TestVimPageRequestJSONValidatesAccentColor(t *testing.T) {
106	for color, want := range map[string]string{
107		"#fbbf24":                  `{"kind":"visual","color":"#fbbf24"}`,
108		"#FFF":                     `{"kind":"visual","color":"#FFF"}`,
109		"#aabbccdd":                `{"kind":"visual","color":"#aabbccdd"}`,
110		"":                         `{"kind":"visual"}`,
111		"red":                      `{"kind":"visual"}`,
112		"#12":                      `{"kind":"visual"}`,
113		"#12345":                   `{"kind":"visual"}`,
114		"#1234567":                 `{"kind":"visual"}`,
115		"#123456789":               `{"kind":"visual"}`,
116		"#abc;} body{display:none": `{"kind":"visual"}`,
117		"url(javascript:alert(1))": `{"kind":"visual"}`,
118		"#abc\"; alert(1); //":     `{"kind":"visual"}`,
119	} {
120		got, err := vimPageRequestJSON(dto.VimPageInteractionRequest{Kind: dto.VimPageVisual, HighlightColor: color})
121		require.NoError(t, err)
122		assert.JSONEq(t, want, got, "color %q", color)
123	}
124}
125
126func TestDecodeVimPageBridgePayload(t *testing.T) {
127	tests := []struct {
128		name    string
129		body    string
130		wantErr bool
131	}{
132		{"copy", `{"type":"copy","token":"t","text":"hello"}`, false},
133		{"copy empty", `{"type":"copy","token":"t"}`, true},
134		{"missing token", `{"type":"end"}`, true},
135		{"open", `{"type":"open-new","token":"t","url":"https://example.com/a"}`, false},
136		{"open empty", `{"type":"open-new","token":"t"}`, true},
137		{"end", `{"type":"end","token":"t"}`, false},
138		{"mode caret", `{"type":"mode","token":"t","mode":"caret"}`, false},
139		{"mode visual line", `{"type":"mode","token":"t","mode":"visual-line"}`, false},
140		{"mode unknown", `{"type":"mode","token":"t","mode":"insert"}`, true},
141		{"mode missing", `{"type":"mode","token":"t"}`, true},
142		{"unknown", `{"type":"eval","token":"t"}`, true},
143		{"malformed", `{`, true},
144	}
145	for _, tt := range tests {
146		t.Run(tt.name, func(t *testing.T) {
147			_, err := decodeVimPageBridgePayload([]byte(tt.body))
148			if tt.wantErr {
149				require.Error(t, err)
150				return
151			}
152			require.NoError(t, err)
153		})
154	}
155}
156
157func TestIsNavigableVimHintURL(t *testing.T) {
158	assert.True(t, isNavigableVimHintURL("https://example.com/a", "https://site.test/"))
159	assert.False(t, isNavigableVimHintURL("https:///nohost", "https://site.test/"))
160	assert.False(t, isNavigableVimHintURL("javascript:alert(1)", "https://site.test/"))
161	assert.False(t, isNavigableVimHintURL("dumb://config", "https://site.test/"))
162	assert.False(t, isNavigableVimHintURL("file:///etc/passwd", "https://site.test/"))
163	assert.True(t, isNavigableVimHintURL("file:///tmp/b.html", "file:///tmp/a.html"))
164}
165
166func newVimResultWebView(opened *string, ended *int) *WebView {
167	return &WebView{ctx: context.Background(), uri: "https://site.test/", callbacks: &port.WebViewCallbacks{
168		OnLinkMiddleClick:         func(uri string) bool { *opened = uri; return true },
169		OnVimPageInteractionEnded: func() { *ended++ },
170	}}
171}
172
173// Mode messages and copies of the caret/visual flow are not terminal: the
174// token stays armed so the user can keep moving and chain copies, and the UI
175// is told about each sub-mode without being asked to release key capture.
176func TestWebViewHandleVimPageResultVisualFlowStaysArmedAcrossModeAndCopy(t *testing.T) {
177	var opened string
178	ended := 0
179	var modes []dto.VimPageMode
180	wv := newVimResultWebView(&opened, &ended)
181	wv.callbacks.OnVimPageModeChanged = func(mode dto.VimPageMode) { modes = append(modes, mode) }
182	wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageVisual
183	wv.vimPageCopyAllowance = 1
184
185	wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "caret"})
186	wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "visual-line"})
187	wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "copied"})
188
189	assert.Equal(t, []dto.VimPageMode{dto.VimPageModeCaret, dto.VimPageModeVisualLine}, modes)
190	assert.Zero(t, ended, "mode and copy must not end the interaction")
191	assert.Equal(t, "tok", wv.currentVimPageToken())
192
193	wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageEnd, Token: "tok"})
194	assert.Equal(t, 1, ended)
195	assert.Empty(t, wv.currentVimPageToken())
196}
197
198func TestWebViewHandleVimPageResultCopyEndsHintYankAndYankObject(t *testing.T) {
199	for _, kind := range []dto.VimPageInteractionKind{dto.VimPageHintYankURL, dto.VimPageYankObject} {
200		var opened string
201		ended := 0
202		wv := newVimResultWebView(&opened, &ended)
203		wv.vimPageToken, wv.vimPageKind = "tok", kind
204
205		wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "x"})
206
207		assert.Empty(t, wv.currentVimPageToken(), "kind %d: copy stays terminal", kind)
208		assert.Equal(t, map[bool]int{true: 1, false: 0}[kind.CapturesKeys()], ended, "kind %d", kind)
209	}
210}
211
212func TestWebViewHandleVimPageResultModeRejectedForLinkHints(t *testing.T) {
213	var opened string
214	ended := 0
215	modes := 0
216	wv := newVimResultWebView(&opened, &ended)
217	wv.callbacks.OnVimPageModeChanged = func(dto.VimPageMode) { modes++ }
218	wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageHintFollow
219
220	wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "caret"})
221
222	assert.Zero(t, modes, "hint interactions never report sub-modes")
223}
224
225func TestWebViewHandleVimPageResultAcceptsArmedTokenOnce(t *testing.T) {
226	var opened string
227	ended := 0
228	wv := newVimResultWebView(&opened, &ended)
229	wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageHintFollowNew
230
231	wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageOpenNew, Token: "tok", URL: "https://example.com"})
232	wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageOpenNew, Token: "tok", URL: "https://evil.test"})
233
234	assert.Equal(t, "https://example.com", opened)
235	assert.Equal(t, 1, ended)
236	assert.Empty(t, wv.currentVimPageToken())
237}
238
239func TestWebViewHandleVimPageResultRejectsStaleOrForgedTokens(t *testing.T) {
240	var opened string
241	ended := 0
242	wv := newVimResultWebView(&opened, &ended)
243	wv.vimPageToken, wv.vimPageKind = "current", dto.VimPageHintFollowNew
244
245	wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageEnd, Token: "previous"})
246	wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageOpenNew, Token: "forged", URL: "https://example.com"})
247
248	assert.Empty(t, opened)
249	assert.Zero(t, ended)
250	assert.Equal(t, "current", wv.currentVimPageToken())
251}
252
253func TestWebViewHandleVimPageResultRejectsDisallowedURL(t *testing.T) {
254	var opened string
255	ended := 0
256	wv := newVimResultWebView(&opened, &ended)
257	wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageHintFollowNew
258
259	wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageOpenNew, Token: "tok", URL: "file:///etc/passwd"})
260
261	assert.Empty(t, opened)
262	assert.Equal(t, 1, ended, "the interaction still ends so key capture is released")
263}
264
265func TestWebViewEndVimPageInteractionOnNavigation(t *testing.T) {
266	var opened string
267	ended := 0
268	wv := newVimResultWebView(&opened, &ended)
269	wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageHintFollowNew
270
271	wv.endVimPageInteractionOnNavigation()
272	wv.endVimPageInteractionOnNavigation()
273
274	assert.Equal(t, 1, ended)
275	assert.Empty(t, wv.currentVimPageToken())
276}
277
278func TestSchemeHandler_APIVimPageForwardsValidPayloads(t *testing.T) {
279	oldNewResourceHandler := cefNewResourceHandler
280	cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
281	defer func() { cefNewResourceHandler = oldNewResourceHandler }()
282
283	tests := []struct {
284		name       string
285		body       string
286		wantStatus int
287		wantCalled bool
288	}{
289		{"valid", `{"type":"copy","token":"tok","text":"yanked"}`, http.StatusOK, true},
290		{"missing token", `{"type":"copy","text":"yanked"}`, http.StatusBadRequest, false},
291	}
292	for _, tt := range tests {
293		t.Run(tt.name, func(t *testing.T) {
294			h := newTestDumbSchemeHandler(t)
295			browser := cefmocks.NewMockBrowser(t)
296			var received vimPageBridgePayload
297			called := false
298			h.onVimPage = func(_ purecef.Browser, payload vimPageBridgePayload) {
299				called = true
300				received = payload
301			}
302
303			request := cefmocks.NewMockRequest(t)
304			encoded := base64.StdEncoding.EncodeToString([]byte(tt.body))
305			request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
306			response := cefmocks.NewMockResponse(t)
307			response.EXPECT().SetStatus(int32(tt.wantStatus)).Once()
308			response.EXPECT().SetStatusText(http.StatusText(tt.wantStatus)).Once()
309			response.EXPECT().SetMimeType("application/json").Once()
310			expectAPIResponseHeaders(response)
311
312			handler := h.handleAPI(browser, http.MethodPost, "/api/vim-page", request)
313			require.NotNil(t, handler)
314			var length int64
315			handler.GetResponseHeaders(response, &length, 0)
316
317			assert.Equal(t, tt.wantCalled, called)
318			if tt.wantCalled {
319				assert.Equal(t, vimPageBridgePayload{Type: "copy", Token: "tok", Text: "yanked"}, received)
320			}
321		})
322	}
323}
324
325func TestWebViewHandleVimPageResultRejectsTypeNotMatchingInteraction(t *testing.T) {
326	var opened string
327	ended := 0
328	wv := newVimResultWebView(&opened, &ended)
329	wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageYankObject
330
331	wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageOpenNew, Token: "tok", URL: "https://example.com"})
332
333	assert.Empty(t, opened)
334	assert.Empty(t, wv.currentVimPageToken(), "the token is still spent")
335}
336
337func TestWebViewVimPageEndedCallbackSkippedAfterRearm(t *testing.T) {
338	var opened string
339	ended := 0
340	wv := newVimResultWebView(&opened, &ended)
341	_, err := wv.armVimPageInteraction(dto.VimPageHintFollow)
342	require.NoError(t, err)
343	_, generation := wv.disarmVimPageInteraction()
344	_, err = wv.armVimPageInteraction(dto.VimPageHintFollow)
345	require.NoError(t, err)
346
347	wv.notifyVimPageInteractionEnded(generation)
348
349	assert.Zero(t, ended)
350}
351
352// handleVimPageResult runs on the CEF IO thread. The clipboard write must be
353// handed to GTK instead of running there.
354func newVimClipboardWebView(t *testing.T, kind dto.VimPageInteractionKind) (*WebView, *recordingClipboardTextOrchestrator, *[]func()) {
355	t.Helper()
356	orchestrator := &recordingClipboardTextOrchestrator{}
357	queued := &[]func(){}
358	wv := &WebView{
359		ctx: context.Background(), id: 7, uri: "https://site.test/",
360		engine:      &Engine{clipboardTextOrchestrator: orchestrator},
361		gtkDispatch: func(fn func()) { *queued = append(*queued, fn) },
362		callbacks:   &port.WebViewCallbacks{},
363	}
364	wv.vimPageToken, wv.vimPageKind = "tok", kind
365	return wv, orchestrator, queued
366}
367
368func TestWebViewHandleVimPageResultCopyRunsOnGTK(t *testing.T) {
369	wv, orchestrator, queued := newVimClipboardWebView(t, dto.VimPageYankObject)
370
371	wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "yanked"})
372
373	assert.Empty(t, orchestrator.explicit.Text, "the clipboard must not be written on the IO thread")
374	require.Len(t, *queued, 1)
375	(*queued)[0]()
376	assert.Equal(t, dto.ExplicitClipboardInput{
377		Text: "yanked", Action: "copy", SourceEngine: dto.ClipboardSourceCEF, ViewID: 7,
378	}, orchestrator.explicit)
379}
380
381// A hostile page that read the token must not be able to write the clipboard
382// again and again: visual copies are non-terminal, so each needs a y/Enter the
383// user pressed and Go forwarded.
384func TestWebViewHandleVimPageResultVisualCopyNeedsForwardedCopyKey(t *testing.T) {
385	wv, orchestrator, queued := newVimClipboardWebView(t, dto.VimPageVisual)
386	copyResult := vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "stolen"}
387
388	wv.handleVimPageResult(copyResult)
389	assert.Empty(t, *queued, "no key was forwarded: the copy is refused")
390	assert.Equal(t, "tok", wv.currentVimPageToken(), "a refused copy leaves the interaction running")
391
392	wv.grantVimPageCopyAllowance("tok", "j")
393	wv.handleVimPageResult(copyResult)
394	assert.Empty(t, *queued, "motion keys grant no copy")
395
396	wv.grantVimPageCopyAllowance("tok", "y")
397	wv.handleVimPageResult(copyResult)
398	wv.handleVimPageResult(copyResult)
399	require.Len(t, *queued, 1, "one forwarded key allows exactly one copy")
400	(*queued)[0]()
401	assert.Equal(t, "stolen", orchestrator.explicit.Text)
402
403	wv.grantVimPageCopyAllowance("tok", "<Return>")
404	wv.grantVimPageCopyAllowance("other", "y")
405	wv.handleVimPageResult(copyResult)
406	assert.Len(t, *queued, 2)
407
408	// y presses the page ignored (caret phase, empty selection) do not bank
409	// copies for later.
410	wv.grantVimPageCopyAllowance("tok", "y")
411	wv.grantVimPageCopyAllowance("tok", "y")
412	wv.grantVimPageCopyAllowance("tok", "y")
413	wv.handleVimPageResult(copyResult)
414	wv.handleVimPageResult(copyResult)
415	assert.Len(t, *queued, 3, "ignored copy keys do not accumulate allowance")
416}
417
418func TestWebViewSendVimPageKeyGrantsCopyAllowanceOnlyForVisual(t *testing.T) {
419	for kind, want := range map[dto.VimPageInteractionKind]int{dto.VimPageVisual: 1, dto.VimPageHintYankURL: 0} {
420		browser := cefmocks.NewMockBrowser(t)
421		frame := cefmocks.NewMockFrame(t)
422		browser.EXPECT().GetMainFrame().Return(frame).Times(3)
423		frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Times(3)
424		wv := &WebView{browser: browser, vimPageToken: "tok", vimPageKind: kind}
425
426		require.NoError(t, wv.SendVimPageKey(context.Background(), "y"))
427		require.NoError(t, wv.SendVimPageKey(context.Background(), "j"))
428		require.NoError(t, wv.SendVimPageKey(context.Background(), "<Return>"))
429
430		assert.Equal(t, want, wv.vimPageCopyAllowance, "kind %d", kind)
431	}
432}
433
434func TestWebViewArmVimPageInteractionResetsCopyAllowance(t *testing.T) {
435	wv := &WebView{vimPageToken: "old", vimPageKind: dto.VimPageVisual, vimPageCopyAllowance: 3}
436
437	_, err := wv.armVimPageInteraction(dto.VimPageVisual)
438	require.NoError(t, err)
439	assert.Zero(t, wv.vimPageCopyAllowance)
440	wv.vimPageCopyAllowance = 2
441	wv.disarmVimPageInteraction()
442	assert.Zero(t, wv.vimPageCopyAllowance)
443}
444
445func TestWebViewHandleVimPageResultIgnoredAfterDestroy(t *testing.T) {
446	wv, orchestrator, queued := newVimClipboardWebView(t, dto.VimPageYankObject)
447	ended := 0
448	wv.callbacks.OnVimPageInteractionEnded = func() { ended++ }
449	wv.destroyed.Store(true)
450
451	wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "late"})
452
453	assert.Empty(t, *queued)
454	assert.Empty(t, orchestrator.explicit.Text)
455	assert.Zero(t, ended)
456}
457
458func TestWebViewDestroyClearsVimPageInteraction(t *testing.T) {
459	wv := &WebView{ctx: context.Background(), vimPageToken: "tok", vimPageKind: dto.VimPageVisual, vimPageCopyAllowance: 1}
460
461	wv.Destroy()
462
463	assert.Empty(t, wv.currentVimPageToken())
464	assert.Zero(t, wv.vimPageKind)
465	assert.Zero(t, wv.vimPageCopyAllowance)
466}
467
468// A crashed renderer takes the page runtime with it, so it can never report
469// the end; the render-process-terminated handler must release the capture.
470func TestHandlerSetOnRenderProcessTerminatedEndsVimPageInteraction(t *testing.T) {
471	var opened string
472	ended := 0
473	wv := newVimResultWebView(&opened, &ended)
474	wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageVisual
475	h := &handlerSet{wv: wv}
476
477	h.OnRenderProcessTerminated(nil, purecef.TerminationStatusTsProcessCrashed, 0, "")
478
479	assert.Equal(t, 1, ended)
480	assert.Empty(t, wv.currentVimPageToken())
481}
482
483func TestSchemeHandler_APIVimPageRejectsOversizedPayloadWith413(t *testing.T) {
484	oldNewResourceHandler := cefNewResourceHandler
485	cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
486	defer func() { cefNewResourceHandler = oldNewResourceHandler }()
487
488	h := newTestDumbSchemeHandler(t)
489	h.onVimPage = func(purecef.Browser, vimPageBridgePayload) { t.Fatal("oversized payload must not reach the handler") }
490	request := cefmocks.NewMockRequest(t)
491	encoded := base64.StdEncoding.EncodeToString([]byte(`{"type":"copy","token":"t","text":"` + strings.Repeat("a", maxClipboardBytes) + `"}`))
492	request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
493	response := cefmocks.NewMockResponse(t)
494	response.EXPECT().SetStatus(int32(http.StatusRequestEntityTooLarge)).Once()
495	response.EXPECT().SetStatusText(http.StatusText(http.StatusRequestEntityTooLarge)).Once()
496	response.EXPECT().SetMimeType("application/json").Once()
497	expectAPIResponseHeaders(response)
498
499	handler := h.handleAPI(cefmocks.NewMockBrowser(t), http.MethodPost, "/api/vim-page", request)
500	require.NotNil(t, handler)
501	var length int64
502	handler.GetResponseHeaders(response, &length, 0)
503}
504
505func TestDecodeVimPageBridgePayloadOversizedIsSentinel(t *testing.T) {
506	_, err := decodeVimPageBridgePayload(make([]byte, maxClipboardBytes+1))
507	require.ErrorIs(t, err, errVimPagePayloadTooLarge)
508	_, err = decodeVimPageBridgePayload([]byte(`{`))
509	require.Error(t, err)
510	require.NotErrorIs(t, err, errVimPagePayloadTooLarge)
511}

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