context_menu.go RawHistoryFolder

1package cef
2
3import (
4	"context"
5	"fmt"
6	"strings"
7
8	purecef "github.com/bnema/purego-cef/cef"
9
10	"github.com/bnema/dumber/internal/application/port"
11	"github.com/bnema/dumber/internal/logging"
12	"github.com/bnema/puregotk/v4/gtk"
13)
14
15// ContextMenuRenderer renders normalized context menu items for the CEF adapter.
16// It is implemented by the GTK menu adapter and injected by bootstrap so this
17// adapter does not depend on a sibling infrastructure adapter.
18type ContextMenuRenderer interface {
19	Show(items []port.MenuItem, anchor *gtk.Widget, x, y int32, onSelect func(port.MenuItem), onClose func())
20}
21
22// ===========================================================================
23// ContextMenuHandler (implemented on handlerSet)
24// ===========================================================================
25
26func (h *handlerSet) OnBeforeContextMenu(_ purecef.Browser, _ purecef.Frame, _ purecef.ContextMenuParams, _ purecef.MenuModel) {
27}
28
29//nolint:gocyclo // CEF callback coordinates menu extraction, rendering, and native fallback in one synchronous call.
30func (h *handlerSet) RunContextMenu(
31	_ purecef.Browser, _ purecef.Frame,
32	params purecef.ContextMenuParams, model purecef.MenuModel,
33	callback purecef.RunContextMenuCallback,
34) int32 {
35	if h == nil || callback == nil || model == nil {
36		if callback != nil {
37			callback.Cancel()
38		}
39		// Returning 1 tells CEF we handled the menu, so cancel suppresses the default menu.
40		return 1
41	}
42	if h.wv == nil || h.wv.engine == nil || h.wv.engine.ctxMenuBuilder == nil {
43		// Returning 0 lets CEF show its native menu when no custom builder is wired.
44		return 0
45	}
46
47	menuContext := buildMenuContext(h.wv, params)
48	items := h.wv.engine.ctxMenuBuilder.Build(context.Background(), menuContext)
49	if len(items) == 0 {
50		return 0
51	}
52
53	// Snapshot the command IDs from CEF — model/params pointers are only valid during this call.
54	count := model.GetCount()
55	if count == 0 {
56		callback.Cancel()
57		return 1
58	}
59	commandIDsByLabel := make(map[string]int32, count)
60	for i := range count {
61		t := model.GetTypeAt(i)
62		if t == purecef.MenuItemTypeMenuitemtypeSeparator {
63			continue
64		}
65		if t == purecef.MenuItemTypeMenuitemtypeCommand {
66			label := strings.ToLower(strings.TrimSpace(model.GetLabelAt(i)))
67			if label != "" {
68				commandIDsByLabel[label] = model.GetCommandIDAt(i)
69			}
70		}
71	}
72	commandIDByAction := make(map[port.MenuAction]int32, len(items))
73	for _, item := range items {
74		if cmdID, ok := lookupContextMenuCommandID(item.Action, item.Label, commandIDsByLabel); ok {
75			commandIDByAction[item.Action] = cmdID
76		}
77	}
78	if h.wv == nil || h.wv.viewBridge == nil {
79		return 0
80	}
81
82	x, y := contextMenuAnchorPosition(params, h.wv.viewBridgeScale(), h.wv.osrBackingScaleFactor())
83	executor := h.contextMenuExecutor()
84	wv := h.wv
85	wv.runOnGTK(func() {
86		if wv.viewBridge == nil || wv.viewBridge.Widget() == nil {
87			callback.Cancel()
88			return
89		}
90		widget := wv.viewBridge.Widget()
91		logContextMenuPopupRequest(h, widget, params, x, y)
92		renderer := wv.engine.contextMenuRenderer()
93		if renderer == nil {
94			callback.Cancel()
95			return
96		}
97		renderer.Show(
98			items,
99			widget,
100			x,
101			y,
102			func(item port.MenuItem) {
103				var copiedCallback func(string)
104				if wv.engine != nil {
105					copiedCallback = wv.engine.notifyClipboardCopied
106				}
107				dispatchContextMenuSelection(wv.ctx, executor, callback, copiedCallback, commandIDByAction, item, menuContext)
108			},
109			func() {
110				callback.Cancel()
111			},
112		)
113	})
114	return 1
115}
116
117func (h *handlerSet) contextMenuExecutor() port.ContextMenuActionExecutor {
118	if h == nil || h.wv == nil || h.wv.engine == nil || h.wv.engine.ctxMenuExecutorFactory == nil {
119		return nil
120	}
121	return h.wv.engine.ctxMenuExecutorFactory.NewContextMenuActionExecutor(
122		h.wv.engine.clipboard,
123		h.wv.engine.resolver,
124		nil,
125		&cefMenuDelegator{wv: h.wv},
126	)
127}
128
129func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale, backingScale float64) (int32, int32) {
130	if params == nil {
131		return 0, 0
132	}
133	if backingScale <= 1 {
134		return params.GetXcoord(), params.GetYcoord()
135	}
136	return deviceToLogicalCoord(params.GetXcoord(), scale), deviceToLogicalCoord(params.GetYcoord(), scale)
137}
138
139func logContextMenuPopupRequest(
140	h *handlerSet,
141	widget *gtk.Widget,
142	params purecef.ContextMenuParams,
143	x, y int32,
144) {
145	if h == nil || h.wv == nil || h.wv.ctx == nil || widget == nil {
146		return
147	}
148	rawX, rawY := contextMenuRawPosition(params)
149	parent := widget.GetParent()
150	logging.FromContext(h.wv.ctx).Debug().
151		Int32("raw_x", rawX).
152		Int32("raw_y", rawY).
153		Int32("popup_x", x).
154		Int32("popup_y", y).
155		Float64("scale", h.wv.viewBridgeScale()).
156		Int("anchor_width", widget.GetAllocatedWidth()).
157		Int("anchor_height", widget.GetAllocatedHeight()).
158		Int("parent_width", cefWidgetAllocatedWidth(parent)).
159		Int("parent_height", cefWidgetAllocatedHeight(parent)).
160		Msg("cef: context menu popup request")
161}
162
163func contextMenuRawPosition(params purecef.ContextMenuParams) (int32, int32) {
164	if params == nil {
165		return 0, 0
166	}
167	return params.GetXcoord(), params.GetYcoord()
168}
169
170func cefWidgetAllocatedWidth(widget *gtk.Widget) int {
171	if widget == nil {
172		return 0
173	}
174	return widget.GetAllocatedWidth()
175}
176
177func cefWidgetAllocatedHeight(widget *gtk.Widget) int {
178	if widget == nil {
179		return 0
180	}
181	return widget.GetAllocatedHeight()
182}
183
184func dispatchContextMenuSelection(
185	ctx context.Context,
186	executor port.ContextMenuActionExecutor,
187	callback purecef.RunContextMenuCallback,
188	copiedCallback func(text string),
189	commandIDByAction map[port.MenuAction]int32,
190	item port.MenuItem,
191	menuContext port.MenuContext,
192) {
193	log := logging.FromContext(ctx)
194	if callback == nil {
195		return
196	}
197	log.Debug().Str("action", string(item.Action)).Str("label", item.Label).Msg("cef: context menu item selected")
198	if item.Action == port.MenuActionCopySelection && menuContext.SelectionText == "" {
199		if cmdID, ok := commandIDByAction[item.Action]; ok {
200			log.Debug().Str("action", string(item.Action)).Int32("command_id", cmdID).Msg("cef: continuing native context menu command")
201			callback.Cont(cmdID, 0)
202			return
203		}
204		log.Warn().Str("action", string(item.Action)).Msg("cef: no matching native context menu command")
205		callback.Cancel()
206		return
207	}
208	if shouldExecuteDirectCEFAction(item.Action) && executor != nil {
209		if err := executor.ExecuteMenuAction(ctx, item.Action, menuContext); err != nil {
210			log.Warn().Err(err).Str("action", string(item.Action)).Msg("cef: context menu action failed")
211			callback.Cancel()
212			return
213		}
214		if copiedCallback != nil {
215			if copiedText := contextMenuCopiedText(item.Action, menuContext); copiedText != "" {
216				copiedCallback(copiedText)
217			}
218		}
219		log.Debug().Str("action", string(item.Action)).Msg("cef: context menu action executed directly")
220		callback.Cancel()
221		return
222	}
223	if cmdID, ok := commandIDByAction[item.Action]; ok {
224		log.Debug().Str("action", string(item.Action)).Int32("command_id", cmdID).Msg("cef: continuing native context menu command")
225		callback.Cont(cmdID, 0)
226		return
227	}
228	log.Warn().Str("action", string(item.Action)).Msg("cef: no matching native context menu command")
229	callback.Cancel()
230}
231
232func contextMenuCopiedText(action port.MenuAction, menuContext port.MenuContext) string {
233	switch action {
234	case port.MenuActionCopySelection:
235		return menuContext.SelectionText
236	case port.MenuActionCopyLink:
237		return menuContext.LinkURI
238	default:
239		return ""
240	}
241}
242
243func shouldExecuteDirectCEFAction(action port.MenuAction) bool {
244	switch action {
245	case port.MenuActionBack,
246		port.MenuActionForward,
247		port.MenuActionReload,
248		port.MenuActionOpenLinkNewTab,
249		port.MenuActionCopyLink,
250		port.MenuActionCopyImage,
251		port.MenuActionInspectElement,
252		port.MenuActionCopySelection:
253		return true
254	default:
255		return false
256	}
257}
258
259type cefMenuDelegator struct {
260	wv *WebView
261}
262
263func (d *cefMenuDelegator) DelegateMenuAction(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error {
264	if d == nil || d.wv == nil {
265		return fmt.Errorf("cef menu delegator: webview not available")
266	}
267	switch action {
268	case port.MenuActionBack:
269		return d.wv.GoBack(ctx)
270	case port.MenuActionForward:
271		return d.wv.GoForward(ctx)
272	case port.MenuActionReload:
273		return d.wv.Reload(ctx)
274	case port.MenuActionOpenLinkNewTab:
275		if menuContext.LinkURI == "" {
276			return fmt.Errorf("open link in new tab: link URI not available")
277		}
278		d.wv.mu.RLock()
279		cb := d.wv.callbacks
280		d.wv.mu.RUnlock()
281		if cb == nil || cb.OnLinkMiddleClick == nil {
282			return fmt.Errorf("open link in new tab: middle-click handler not available")
283		}
284		cb.OnLinkMiddleClick(menuContext.LinkURI)
285		return nil
286	case port.MenuActionInspectElement:
287		d.wv.OpenDevTools()
288		return nil
289	case port.MenuActionCopySelection:
290		d.wv.RunJavaScript(ctx, "document.execCommand('copy');")
291		return nil
292	default:
293		return fmt.Errorf("cef menu delegator: unsupported action %s", action)
294	}
295}
296
297func lookupContextMenuCommandID(action port.MenuAction, label string, commandIDsByLabel map[string]int32) (int32, bool) {
298	for _, candidate := range contextMenuActionLabels(action, label) {
299		if cmdID, ok := commandIDsByLabel[strings.ToLower(candidate)]; ok {
300			return cmdID, true
301		}
302	}
303	return 0, false
304}
305
306func contextMenuActionLabels(action port.MenuAction, label string) []string {
307	labels := []string{label}
308	switch action {
309	case port.MenuActionBack:
310		labels = append(labels, "Back")
311	case port.MenuActionForward:
312		labels = append(labels, "Forward")
313	case port.MenuActionReload:
314		labels = append(labels, "Reload")
315	case port.MenuActionOpenLinkNewTab:
316		labels = append(labels, "Open Link in New Tab", "Open link in new tab")
317	case port.MenuActionCopyLink:
318		labels = append(labels, "Copy Link", "Copy link address")
319	case port.MenuActionCopyImage:
320		labels = append(labels, "Copy Image", "Copy image")
321	case port.MenuActionSaveImage:
322		labels = append(labels, "Save Image", "Save image as...")
323	case port.MenuActionInspectElement:
324		labels = append(labels, "Inspect Element", "Inspect")
325	case port.MenuActionCopySelection:
326		labels = append(labels, "Copy Selection", "Copy")
327	}
328	return labels
329}
330
331func buildMenuContext(wv *WebView, params purecef.ContextMenuParams) port.MenuContext {
332	menuContext := port.MenuContext{}
333	if wv != nil {
334		menuContext.CanGoBack = wv.CanGoBack()
335		menuContext.CanGoForward = wv.CanGoForward()
336		menuContext.PageURI = wv.URI()
337	}
338	if params == nil {
339		return menuContext
340	}
341
342	if pageURI := params.GetPageURL(); pageURI != "" {
343		menuContext.PageURI = pageURI
344	}
345	menuContext.LinkURI = params.GetLinkURL()
346	if params.HasImageContents() {
347		menuContext.ImageURI = params.GetSourceURL()
348	}
349	menuContext.SelectionText = params.GetSelectionText()
350	menuContext.HasSelection = strings.TrimSpace(menuContext.SelectionText) != ""
351	menuContext.IsEditable = params.IsEditable()
352	menuContext.X = int(params.GetXcoord())
353	menuContext.Y = int(params.GetYcoord())
354	return menuContext
355}
356
357func (h *handlerSet) OnContextMenuCommand(
358	_ purecef.Browser, _ purecef.Frame, _ purecef.ContextMenuParams,
359	_ int32, _ purecef.EventFlags,
360) int32 {
361	return 0
362}
363
364func (h *handlerSet) OnContextMenuDismissed(_ purecef.Browser, _ purecef.Frame) {}
365
366func (h *handlerSet) RunQuickMenu(
367	_ purecef.Browser, _ purecef.Frame, _ *purecef.Point, _ *purecef.Size,
368	_ purecef.QuickMenuEditStateFlags, _ purecef.RunQuickMenuCallback,
369) int32 {
370	return 0
371}
372
373func (h *handlerSet) OnQuickMenuCommand(_ purecef.Browser, _ purecef.Frame, _ int32, _ purecef.EventFlags) int32 {
374	return 0
375}
376
377func (h *handlerSet) OnQuickMenuDismissed(_ purecef.Browser, _ purecef.Frame) {}

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