vim_page.go RawHistoryFolder

1package cef
2
3import (
4	"context"
5	"crypto/subtle"
6	_ "embed"
7	"encoding/base64"
8	"encoding/json"
9	"errors"
10	"fmt"
11	"net/url"
12	"regexp"
13	"strings"
14
15	purecef "github.com/bnema/purego-cef/cef"
16
17	"github.com/bnema/dumber/internal/application/dto"
18	"github.com/bnema/dumber/internal/application/port"
19	"github.com/bnema/dumber/internal/logging"
20)
21
22var _ port.VimPageInteractor = (*WebView)(nil)
23
24//go:embed vim_page_runtime.js
25var vimPageRuntimeTemplateJS string
26
27var (
28	errVimPageTokenUnavailable = errors.New("vim page: interaction token unavailable")
29	errVimPagePayloadTooLarge  = errors.New("vim page: payload too large")
30	// vimPageAccentColor admits the CSS hex colors the runtime paints with;
31	// anything else is replaced by the runtime default before reaching JS.
32	vimPageAccentColor = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`)
33)
34
35// vimPageBridgePayload is one result reported by the page runtime. Token is
36// the per-interaction secret issued by StartVimPageInteraction.
37type vimPageBridgePayload struct {
38	Type  string `json:"type"`
39	Token string `json:"token"`
40	Text  string `json:"text,omitempty"`
41	URL   string `json:"url,omitempty"`
42	// Reason explains an early end (for example no visible targets).
43	Reason string `json:"reason,omitempty"`
44	// Mode names the sub-mode of a "mode" message.
45	Mode string `json:"mode,omitempty"`
46}
47
48const (
49	vimPageMessageCopy    = "copy"
50	vimPageMessageMode    = "mode"
51	vimPageMessageOpenNew = "open-new"
52	vimPageMessageEnd     = "end"
53)
54
55func decodeVimPageBridgePayload(body []byte) (vimPageBridgePayload, error) {
56	var payload vimPageBridgePayload
57	if len(body) > maxClipboardBytes {
58		return payload, errVimPagePayloadTooLarge
59	}
60	if err := json.Unmarshal(body, &payload); err != nil {
61		return payload, err
62	}
63	if payload.Token == "" {
64		return payload, errors.New("missing token")
65	}
66	switch payload.Type {
67	case vimPageMessageCopy:
68		if payload.Text == "" {
69			return payload, errors.New("missing text")
70		}
71	case vimPageMessageOpenNew:
72		if payload.URL == "" {
73			return payload, errors.New("missing url")
74		}
75	case vimPageMessageMode:
76		if !dto.VimPageMode(payload.Mode).Valid() {
77			return payload, fmt.Errorf("unknown mode %q", payload.Mode)
78		}
79	case vimPageMessageEnd:
80	default:
81		return payload, fmt.Errorf("unknown type %q", payload.Type)
82	}
83	return payload, nil
84}
85
86// isNavigableVimHintURL keeps hint-opened panes to web documents with a host
87// so a page cannot smuggle javascript: or internal URLs through the bridge.
88// file: targets are allowed only from a page that is itself a file: document,
89// matching Chromium's own http(s)-to-file navigation block.
90func isNavigableVimHintURL(raw, sourceURI string) bool {
91	parsed, err := url.Parse(raw)
92	if err != nil {
93		return false
94	}
95	switch strings.ToLower(parsed.Scheme) {
96	case "http", "https":
97		return parsed.Host != ""
98	case "file":
99		source, err := url.Parse(sourceURI)
100		return err == nil && strings.EqualFold(source.Scheme, "file")
101	default:
102		return false
103	}
104}
105
106func vimPageKindName(kind dto.VimPageInteractionKind) (string, bool) {
107	switch kind {
108	case dto.VimPageHintFollow:
109		return "hint-follow", true
110	case dto.VimPageHintFollowNew:
111		return "hint-follow-new", true
112	case dto.VimPageHintYankURL:
113		return "hint-yank-url", true
114	case dto.VimPageVisual:
115		return "visual", true
116	case dto.VimPageYankObject:
117		return "yank", true
118	default:
119		return "", false
120	}
121}
122
123func vimPageObjectName(object dto.VimPageTextObject) (string, bool) {
124	switch object {
125	case dto.VimPageTextObjectSection:
126		return "section", true
127	case dto.VimPageTextObjectParagraph:
128		return "paragraph", true
129	case dto.VimPageTextObjectCode:
130		return "code", true
131	case dto.VimPageTextObjectTable:
132		return "table", true
133	case dto.VimPageTextObjectList:
134		return "list", true
135	default:
136		return "", false
137	}
138}
139
140func vimPageRequestJSON(request dto.VimPageInteractionRequest) (string, error) {
141	kind, ok := vimPageKindName(request.Kind)
142	if !ok {
143		return "", fmt.Errorf("unsupported vim page interaction: %d", request.Kind)
144	}
145	object := ""
146	if request.Kind == dto.VimPageYankObject {
147		if object, ok = vimPageObjectName(request.Object); !ok {
148			return "", fmt.Errorf("unsupported vim text object: %d", request.Object)
149		}
150	}
151	color := request.HighlightColor
152	if !vimPageAccentColor.MatchString(color) {
153		color = ""
154	}
155	encoded, err := json.Marshal(struct {
156		Kind   string `json:"kind"`
157		Object string `json:"object,omitempty"`
158		Color  string `json:"color,omitempty"`
159	}{Kind: kind, Object: object, Color: color})
160	if err != nil {
161		return "", err
162	}
163	return string(encoded), nil
164}
165
166// jsString renders s as a JavaScript string literal (JSON is valid JS).
167func jsString(s string) string {
168	encoded, _ := json.Marshal(s)
169	return string(encoded)
170}
171
172// vimPageStartScript installs the runtime when absent and starts an
173// interaction. The token is a per-interaction secret, not the shared bridge
174// nonce, so a page that intercepts the call only learns a token valid for the
175// interaction the user just started.
176func vimPageStartScript(token, request string) string {
177	return vimPageRuntimeTemplateJS + "\nwindow.__dumberVimPage.start(" + jsString(token) + ", " + request + ");"
178}
179
180// vimPageCallScript invokes key or cancel on the installed runtime without
181// shipping it again. When the runtime is gone (the document was replaced), it
182// reports the end of the token so Go releases the key capture.
183func vimPageCallScript(method, token string, args ...string) string {
184	call := append([]string{jsString(token)}, args...)
185	return "window.__dumberVimPage ? window.__dumberVimPage." + method + "(" + strings.Join(call, ", ") + ") : " + vimPageEndScript(token)
186}
187
188// vimPageEndScript posts an end message for token over the same bridge the
189// runtime uses.
190func vimPageEndScript(token string) string {
191	body, _ := json.Marshal(map[string]string{"token": token, "type": vimPageMessageEnd})
192	return `window.fetch("dumb:///api/vim-page", {method: "POST", headers: {"X-Dumber-Body": ` +
193		jsString(base64.StdEncoding.EncodeToString(body)) + `}}).catch(() => {});`
194}
195
196// armVimPageInteraction issues a fresh token for a new interaction and
197// invalidates any previous one, so late results from it are rejected.
198func (wv *WebView) armVimPageInteraction(kind dto.VimPageInteractionKind) (string, error) {
199	token := newBridgeNonce()
200	if token == "" {
201		return "", errVimPageTokenUnavailable
202	}
203	wv.mu.Lock()
204	wv.vimPageToken = token
205	wv.vimPageKind = kind
206	wv.vimPageCopyAllowance = 0
207	wv.vimPageGeneration++
208	wv.mu.Unlock()
209	return token, nil
210}
211
212func (wv *WebView) currentVimPageToken() string {
213	wv.mu.RLock()
214	defer wv.mu.RUnlock()
215	return wv.vimPageToken
216}
217
218// disarmVimPageInteraction clears the active token. It reports whether a
219// key-capturing interaction was active and the generation it belonged to.
220func (wv *WebView) disarmVimPageInteraction() (bool, uint64) {
221	wv.mu.Lock()
222	defer wv.mu.Unlock()
223	captured := wv.vimPageToken != "" && wv.vimPageKind.CapturesKeys()
224	wv.vimPageToken = ""
225	wv.vimPageKind = 0
226	wv.vimPageCopyAllowance = 0
227	return captured, wv.vimPageGeneration
228}
229
230// notifyVimPageInteractionEnded runs the ended callback on GTK unless a newer
231// interaction was armed in the meantime (keys queued ahead of the idle).
232func (wv *WebView) notifyVimPageInteractionEnded(generation uint64) {
233	wv.mu.RLock()
234	cb := wv.callbacks
235	wv.mu.RUnlock()
236	if cb == nil || cb.OnVimPageInteractionEnded == nil {
237		return
238	}
239	ended := cb.OnVimPageInteractionEnded
240	wv.runOnGTK(func() {
241		wv.mu.RLock()
242		current := wv.vimPageGeneration
243		wv.mu.RUnlock()
244		if current == generation {
245			ended()
246		}
247	})
248}
249
250// vimPageResultEndsInteraction reports whether a result spends the token.
251// The caret and visual flow is long-lived: its mode changes and copies leave
252// the interaction running so copies can be chained. Copies stay bounded by
253// the user's own y/Enter presses (see vimPageCopyAllowance), not by the
254// token. Every other result is terminal, so a token grants exactly one.
255func vimPageResultEndsInteraction(kind dto.VimPageInteractionKind, messageType string) bool {
256	if kind != dto.VimPageVisual {
257		return true
258	}
259	return messageType != vimPageMessageMode && messageType != vimPageMessageCopy
260}
261
262// notifyVimPageModeChanged reports a sub-mode change on GTK unless a newer
263// interaction was armed in the meantime.
264func (wv *WebView) notifyVimPageModeChanged(generation uint64, mode dto.VimPageMode) {
265	wv.mu.RLock()
266	cb := wv.callbacks
267	wv.mu.RUnlock()
268	if cb == nil || cb.OnVimPageModeChanged == nil {
269		return
270	}
271	changed := cb.OnVimPageModeChanged
272	wv.runOnGTK(func() {
273		wv.mu.RLock()
274		current := wv.vimPageGeneration
275		wv.mu.RUnlock()
276		if current == generation {
277			changed(mode)
278		}
279	})
280}
281
282// vimPageResultAllowed ties each result type to the interaction the user
283// started, so a hijacked token only yields the kind of result requested.
284func vimPageResultAllowed(kind dto.VimPageInteractionKind, messageType string) bool {
285	switch messageType {
286	case vimPageMessageEnd:
287		return true
288	case vimPageMessageOpenNew:
289		return kind == dto.VimPageHintFollow || kind == dto.VimPageHintFollowNew
290	case vimPageMessageCopy:
291		return kind == dto.VimPageYankObject || kind == dto.VimPageHintYankURL || kind == dto.VimPageVisual
292	case vimPageMessageMode:
293		return kind == dto.VimPageVisual
294	default:
295		return false
296	}
297}
298
299// StartVimPageInteraction starts link hints, visual selection, or a text-object yank.
300func (wv *WebView) StartVimPageInteraction(_ context.Context, request dto.VimPageInteractionRequest) error {
301	if wv == nil || wv.destroyed.Load() {
302		return errDestroyed
303	}
304	encoded, err := vimPageRequestJSON(request)
305	if err != nil {
306		return err
307	}
308	token, err := wv.armVimPageInteraction(request.Kind)
309	if err != nil {
310		return err
311	}
312	if err := wv.scheduleJavaScript(vimPageStartScript(token, encoded)); err != nil {
313		wv.disarmVimPageInteraction()
314		return err
315	}
316	return nil
317}
318
319// vimPageKeyRequestsCopy reports whether a forwarded key may make a visual
320// selection copy: y and Enter yank in the caret and visual flow.
321func vimPageKeyRequestsCopy(key string) bool {
322	return key == "y" || key == "<Return>"
323}
324
325// grantVimPageCopyAllowance records that the user pressed a copy key for the
326// armed visual interaction, so one visual copy result will be accepted. The
327// allowance is capped at one: y/Enter presses the page ignores (caret or hint
328// phase, empty selection) must not bank copies for a page holding the token.
329func (wv *WebView) grantVimPageCopyAllowance(token, key string) {
330	if !vimPageKeyRequestsCopy(key) {
331		return
332	}
333	wv.mu.Lock()
334	if wv.vimPageToken == token && wv.vimPageKind == dto.VimPageVisual {
335		wv.vimPageCopyAllowance = 1
336	}
337	wv.mu.Unlock()
338}
339
340// SendVimPageKey forwards one canonical key to the active page interaction.
341func (wv *WebView) SendVimPageKey(_ context.Context, key string) error {
342	if wv == nil || wv.destroyed.Load() {
343		return errDestroyed
344	}
345	token := wv.currentVimPageToken()
346	if key == "" || token == "" {
347		return nil
348	}
349	wv.grantVimPageCopyAllowance(token, key)
350	return wv.scheduleJavaScript(vimPageCallScript("key", token, jsString(key)))
351}
352
353// CancelVimPageInteraction removes any hint overlay or visual selection.
354func (wv *WebView) CancelVimPageInteraction(_ context.Context) error {
355	if wv == nil || wv.destroyed.Load() {
356		return errDestroyed
357	}
358	token := wv.currentVimPageToken()
359	wv.disarmVimPageInteraction()
360	if token == "" {
361		return nil
362	}
363	return wv.scheduleJavaScript(vimPageCallScript("cancel", token))
364}
365
366// endVimPageInteractionOnNavigation drops an interaction whose document is
367// being replaced; its runtime can no longer report the end itself.
368func (wv *WebView) endVimPageInteractionOnNavigation() {
369	if captured, generation := wv.disarmVimPageInteraction(); captured {
370		wv.notifyVimPageInteractionEnded(generation)
371	}
372}
373
374// handleVimPageBridge routes one runtime result from the trusted bridge.
375func (e *Engine) handleVimPageBridge(browser purecef.Browser, payload vimPageBridgePayload) {
376	e.withBridgeSourceWebView(browser, "", "vim-page", func(wv *WebView) {
377		wv.handleVimPageResult(payload)
378	})
379}
380
381// spendVimPageResultLocked validates the token of one result and applies its
382// effect on the armed state: a terminal result disarms the interaction, and a
383// visual copy spends one allowance. A visual copy is non-terminal, so the
384// token alone would let a page that read it write the clipboard repeatedly;
385// each accepted copy needs a y/Enter key the user pressed. wv.mu must be held.
386func (wv *WebView) spendVimPageResultLocked(payload vimPageBridgePayload) (valid, ends, copyDenied bool) {
387	armed := wv.vimPageToken
388	valid = armed != "" && subtle.ConstantTimeCompare([]byte(armed), []byte(payload.Token)) == 1
389	if !valid {
390		return false, false, false
391	}
392	kind := wv.vimPageKind
393	if payload.Type == vimPageMessageCopy && kind == dto.VimPageVisual {
394		if wv.vimPageCopyAllowance <= 0 {
395			return true, false, true
396		}
397		wv.vimPageCopyAllowance--
398	}
399	ends = vimPageResultEndsInteraction(kind, payload.Type)
400	if ends {
401		wv.vimPageToken = ""
402		wv.vimPageKind = 0
403		wv.vimPageCopyAllowance = 0
404	}
405	return true, ends, false
406}
407
408// handleVimPageResult accepts a result only for the interaction the user
409// armed; results from stale or forged interactions are dropped.
410func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) {
411	if wv == nil || wv.destroyed.Load() {
412		return
413	}
414	wv.mu.Lock()
415	cb := wv.callbacks
416	sourceURI := wv.uri
417	kind := wv.vimPageKind
418	generation := wv.vimPageGeneration
419	valid, ends, copyDenied := wv.spendVimPageResultLocked(payload)
420	wv.mu.Unlock()
421	if !valid {
422		logging.FromContext(wv.ctx).Debug().Str("type", payload.Type).Msg("cef: vim page result rejected — stale or unknown token")
423		return
424	}
425	if copyDenied {
426		logging.FromContext(wv.ctx).Debug().Msg("cef: vim page copy rejected — no user copy key pending")
427		return
428	}
429
430	if ends {
431		logging.FromContext(wv.ctx).Debug().
432			Str("type", payload.Type).
433			Str("reason", payload.Reason).
434			Msg("cef: vim page interaction finished")
435		if kind.CapturesKeys() {
436			wv.notifyVimPageInteractionEnded(generation)
437		}
438	}
439	if !vimPageResultAllowed(kind, payload.Type) {
440		logging.FromContext(wv.ctx).Debug().Str("type", payload.Type).Msg("cef: vim page result rejected — not allowed for interaction")
441		return
442	}
443
444	switch payload.Type {
445	case vimPageMessageMode:
446		wv.notifyVimPageModeChanged(generation, dto.VimPageMode(payload.Mode))
447	case vimPageMessageCopy:
448		// Results arrive on the CEF IO thread; the clipboard write belongs on GTK.
449		if engine := wv.engine; engine != nil {
450			id, text := wv.id, payload.Text
451			wv.runOnGTK(func() { engine.handleExplicitClipboardBridgeText(id, "copy", text) })
452		}
453	case vimPageMessageOpenNew:
454		if !isNavigableVimHintURL(payload.URL, sourceURI) {
455			logging.FromContext(wv.ctx).Debug().Msg("cef: vim hint open rejected — unsupported url")
456			return
457		}
458		if cb == nil || cb.OnLinkMiddleClick == nil {
459			logging.FromContext(wv.ctx).Debug().Msg("cef: vim hint open skipped — handler not available")
460			return
461		}
462		target := payload.URL
463		wv.runOnGTK(func() { cb.OnLinkMiddleClick(target) })
464	}
465}

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