jsdialog_popup.go RawHistoryFolder

1package component
2
3import (
4	"net/url"
5	"strings"
6	"sync"
7	"sync/atomic"
8
9	"github.com/bnema/puregotk/v4/gdk"
10	"github.com/bnema/puregotk/v4/gtk"
11	"github.com/bnema/puregotk/v4/pango"
12
13	"github.com/bnema/dumber/internal/application/port"
14	"github.com/bnema/dumber/internal/ui/layout"
15)
16
17const (
18	jsDialogMaxBodyHeight = 240
19	jsDialogMaxWidthChars = 60
20	jsDialogMaxHostChars  = 80
21	// jsDialogMaxTextRunes caps page-controlled text (message, prompt default)
22	// so a hostile page can't make GTK lay out megabytes of text.
23	jsDialogMaxTextRunes = 3000
24	// jsDialogBeforeUnloadText is Dumber's own beforeunload text: CEF always
25	// passes a fixed string, and browsers no longer show page text here.
26	jsDialogBeforeUnloadText = "Changes you made may not be saved."
27)
28
29// JSDialogSizeDefaults sizes the dialog box relative to its pane.
30var JSDialogSizeDefaults = ModalSizeConfig{
31	WidthPct:       0.9,
32	MaxWidth:       420,
33	TopMarginPct:   0.3,
34	FallbackWidth:  420,
35	FallbackHeight: 300,
36}
37
38// jsDialogContent is the plain-text content of a JS dialog.
39type jsDialogContent struct {
40	Heading     string
41	Body        string
42	OKLabel     string
43	CancelLabel string // empty: no cancel button (alert)
44	ShowInput   bool
45}
46
47// jsDialogOriginLabel returns a short, user-facing origin ("example.com") for
48// the requesting URL. The origin is shown separately from the page-controlled
49// message so a page cannot impersonate browser UI.
50func jsDialogOriginLabel(origin string) string {
51	origin = strings.TrimSpace(origin)
52	if origin == "" {
53		return "This page"
54	}
55	u, err := url.Parse(origin)
56	// blob:/filesystem: URLs carry the real origin in the opaque part.
57	if err == nil && u.Host == "" && (u.Scheme == "blob" || u.Scheme == "filesystem") && u.Opaque != "" {
58		if inner, innerErr := url.Parse(u.Opaque); innerErr == nil && inner.Host != "" {
59			u = inner
60		}
61	}
62	if err != nil || u.Host == "" {
63		if err == nil && u.Scheme != "" {
64			return truncateRunesLeft(u.Scheme+":", jsDialogMaxHostChars)
65		}
66		return "This page"
67	}
68	// Keep the end of the host: the registrable domain is what identifies it.
69	return truncateRunesLeft(u.Host, jsDialogMaxHostChars)
70}
71
72// truncateRunesLeft keeps the last n runes, prefixing "…" when it cut.
73func truncateRunesLeft(s string, n int) string {
74	r := []rune(s)
75	if len(r) <= n {
76		return s
77	}
78	return "…" + string(r[len(r)-n:])
79}
80
81func truncateRunes(s string, n int) string {
82	r := []rune(s)
83	if len(r) <= n {
84		return s
85	}
86	return string(r[:n]) + "…"
87}
88
89// buildJSDialogContent maps a request to the dialog texts and buttons.
90// All strings are plain text (never Pango markup).
91func buildJSDialogContent(req port.JSDialogRequest) jsDialogContent {
92	says := jsDialogOriginLabel(req.Origin) + " says:"
93	msg := truncateRunes(req.Message, jsDialogMaxTextRunes)
94	switch req.Type {
95	case port.JSDialogConfirm:
96		return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK", CancelLabel: "Cancel"}
97	case port.JSDialogPrompt:
98		return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK", CancelLabel: "Cancel", ShowInput: true}
99	case port.JSDialogBeforeUnload:
100		body := jsDialogBeforeUnloadText
101		heading := "Leave page?"
102		if req.IsReload {
103			heading = "Reload page?"
104		}
105		return jsDialogContent{
106			Heading:     heading,
107			Body:        jsDialogOriginLabel(req.Origin) + "\n" + body,
108			OKLabel:     "Leave",
109			CancelLabel: "Stay",
110		}
111	default:
112		return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK"}
113	}
114}
115
116// JSDialogPopup is a per-pane overlay that renders alert/confirm/prompt and
117// beforeunload dialogs. It is owned by a PaneView, so a dialog belongs to its
118// pane's WebView and never blocks other panes. It replaces Adwaita AlertDialog
119// for the same reason as PermissionPopup (purego ConnectResponse bug).
120type JSDialogPopup struct {
121	outerBox *gtk.Box
122	mainBox  *gtk.Box
123
124	headingLabel *gtk.Label
125	bodyLabel    *gtk.Label
126	input        *gtk.Entry
127	btnOK        *gtk.Button
128	btnCancel    *gtk.Button
129
130	parent     layout.OverlayWidget
131	controller *gtk.EventControllerKey
132	focusCtrl  *gtk.EventControllerFocus
133	hasFocus   atomic.Bool // keyboard focus is inside the popup (enter/leave)
134	signals    []popupSignal
135
136	mu        sync.Mutex
137	visible   bool
138	wantFocus bool // this dialog should own keyboard focus (pane is active)
139	destroyed bool
140	respond   func(ok bool, input string)
141	onHidden  func(hadFocus bool)
142
143	retainedCallbacks []any
144}
145
146// popupSignal remembers a connected handler so Destroy can disconnect it.
147type popupSignal struct {
148	obj interface{ DisconnectSignal(uint) }
149	id  uint
150}
151
152// NewJSDialogPopup creates the popup (hidden). Returns nil if widget creation fails.
153// parent is the pane overlay used to size the dialog.
154func NewJSDialogPopup(parent layout.OverlayWidget) *JSDialogPopup {
155	p := &JSDialogPopup{parent: parent}
156	if err := p.createWidgets(); err != nil {
157		return nil
158	}
159	p.attachKeyController()
160	return p
161}
162
163// Widget returns the outer GTK widget for overlay registration.
164func (p *JSDialogPopup) Widget() *gtk.Widget {
165	if p == nil || p.outerBox == nil {
166		return nil
167	}
168	return &p.outerBox.Widget
169}
170
171// SetOnHidden sets (or with nil clears) a callback run after the popup closes. hadFocus reports
172// whether keyboard focus was inside the dialog when it closed, so the owner
173// only restores focus when the dialog had taken it.
174func (p *JSDialogPopup) SetOnHidden(fn func(hadFocus bool)) {
175	p.mu.Lock()
176	p.onHidden = fn
177	p.mu.Unlock()
178}
179
180// Show displays the dialog. respond is invoked once with the user's answer.
181// A dialog already visible is dismissed as canceled first. Keyboard focus is
182// only taken when focus is true (the pane is active); otherwise RequestFocus
183// grabs it when the pane becomes active.
184func (p *JSDialogPopup) Show(req port.JSDialogRequest, respond func(ok bool, input string), focus bool, uiScale float64) {
185	p.Dismiss(false)
186
187	content := buildJSDialogContent(req)
188
189	p.mu.Lock()
190	if p.destroyed {
191		p.mu.Unlock()
192		if respond != nil {
193			respond(false, "")
194		}
195		return
196	}
197	p.visible = true
198	p.wantFocus = focus
199	p.respond = respond
200	p.mu.Unlock()
201
202	width, _ := CalculateModalDimensionsWithScale(p.parent, JSDialogSizeDefaults, uiScale)
203	p.mainBox.SetSizeRequest(width, -1)
204
205	p.headingLabel.SetText(content.Heading)
206	p.bodyLabel.SetText(content.Body)
207	p.bodyLabel.SetVisible(content.Body != "")
208	p.btnOK.SetLabel(content.OKLabel)
209	p.btnCancel.SetVisible(content.CancelLabel != "")
210	if content.CancelLabel != "" {
211		p.btnCancel.SetLabel(content.CancelLabel)
212	}
213	p.input.SetVisible(content.ShowInput)
214	if content.ShowInput {
215		p.input.SetText(truncateRunes(req.DefaultPrompt, jsDialogMaxTextRunes))
216	} else {
217		p.input.SetText("")
218	}
219
220	p.outerBox.SetVisible(true)
221	if focus {
222		p.grabDefaultFocus()
223	}
224}
225
226// Visible reports whether a dialog is currently shown.
227func (p *JSDialogPopup) Visible() bool {
228	p.mu.Lock()
229	defer p.mu.Unlock()
230	return p.visible
231}
232
233// RequestFocus marks the dialog as the focus owner and grabs focus if shown.
234// Called when the owning pane becomes active.
235func (p *JSDialogPopup) RequestFocus() {
236	p.mu.Lock()
237	if !p.visible || p.destroyed {
238		p.mu.Unlock()
239		return
240	}
241	p.wantFocus = true
242	p.mu.Unlock()
243	p.grabDefaultFocus()
244}
245
246// grabDefaultFocus focuses the entry (prompt) or the OK button.
247func (p *JSDialogPopup) grabDefaultFocus() {
248	if p.input.GetVisible() {
249		p.input.GrabFocus()
250		p.input.SelectRegion(0, -1)
251		return
252	}
253	p.btnOK.GrabFocus()
254}
255
256// Hide closes the popup without invoking the response callback. It is used when
257// the engine canceled the dialog (navigation, WebView destruction).
258func (p *JSDialogPopup) Hide() {
259	if p == nil {
260		return
261	}
262	p.close(nil)
263}
264
265// Dismiss closes the popup and answers with ok. No-op when hidden.
266func (p *JSDialogPopup) Dismiss(ok bool) {
267	p.close(&ok)
268}
269
270// close hides the popup. When answer is non-nil the response callback is run.
271func (p *JSDialogPopup) close(answer *bool) {
272	p.mu.Lock()
273	if !p.visible {
274		p.mu.Unlock()
275		return
276	}
277	p.visible = false
278	p.wantFocus = false
279	respond := p.respond
280	p.respond = nil
281	hidden := p.onHidden
282	p.mu.Unlock()
283
284	// Read before hiding: hiding a focused widget fires "leave".
285	hadFocus := p.hasFocus.Load()
286	text := ""
287	if answer != nil && *answer && p.input.GetVisible() {
288		text = p.input.GetText()
289	}
290	p.outerBox.SetVisible(false)
291	if answer != nil && respond != nil {
292		respond(*answer, text)
293	}
294	if hidden != nil {
295		hidden(hadFocus)
296	}
297}
298
299// Destroy disconnects every handler and drops callbacks. The popup must not be
300// used afterwards. Call after the dialog was dismissed.
301func (p *JSDialogPopup) Destroy() {
302	if p == nil {
303		return
304	}
305	p.mu.Lock()
306	if p.destroyed {
307		p.mu.Unlock()
308		return
309	}
310	p.destroyed = true
311	p.visible = false
312	p.respond = nil
313	p.onHidden = nil
314	p.mu.Unlock()
315
316	for _, s := range p.signals {
317		s.obj.DisconnectSignal(s.id)
318	}
319	p.signals = nil
320	if p.controller != nil && p.outerBox != nil {
321		p.outerBox.RemoveController(&p.controller.EventController)
322		p.controller = nil
323	}
324	if p.focusCtrl != nil && p.outerBox != nil {
325		p.outerBox.RemoveController(&p.focusCtrl.EventController)
326		p.focusCtrl = nil
327	}
328	p.retainedCallbacks = nil
329}
330
331func (p *JSDialogPopup) createWidgets() error {
332	if err := p.createContainers(); err != nil {
333		return err
334	}
335	if err := p.createLabels(); err != nil {
336		return err
337	}
338	scroll := gtk.NewScrolledWindow()
339	if scroll == nil {
340		return errNilWidget("jsDialogScroll")
341	}
342	scroll.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
343	scroll.SetPropagateNaturalHeight(true)
344	scroll.SetMaxContentHeight(jsDialogMaxBodyHeight)
345	scroll.SetChild(&p.bodyLabel.Widget)
346
347	p.input = gtk.NewEntry()
348	if p.input == nil {
349		return errNilWidget("jsDialogEntry")
350	}
351	p.input.AddCssClass("jsdialog-entry")
352	p.input.SetMaxLength(jsDialogMaxTextRunes)
353	p.input.SetVisible(false)
354	// Enter in the entry = OK. Focused buttons activate themselves, so Enter on
355	// Cancel/Stay still cancels.
356	activateCb := func(gtk.Entry) { p.Dismiss(true) }
357	p.retainedCallbacks = append(p.retainedCallbacks, activateCb)
358	p.signals = append(p.signals, popupSignal{p.input, p.input.ConnectActivate(&activateCb)})
359
360	btnRow, err := p.createButtonRow()
361	if err != nil {
362		return err
363	}
364
365	p.mainBox.Append(&p.headingLabel.Widget)
366	p.mainBox.Append(&scroll.Widget)
367	p.mainBox.Append(&p.input.Widget)
368	p.mainBox.Append(&btnRow.Widget)
369	p.outerBox.Append(&p.mainBox.Widget)
370	return nil
371}
372
373func (p *JSDialogPopup) createContainers() error {
374	p.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
375	if p.outerBox == nil {
376		return errNilWidget("jsDialogOuterBox")
377	}
378	// Fill the pane so the scrim swallows pointer input meant for the page.
379	p.outerBox.AddCssClass("jsdialog-scrim")
380	p.outerBox.SetHexpand(true)
381	p.outerBox.SetVexpand(true)
382	p.outerBox.SetHalign(gtk.AlignFillValue)
383	p.outerBox.SetValign(gtk.AlignFillValue)
384	p.outerBox.SetVisible(false)
385
386	p.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
387	if p.mainBox == nil {
388		return errNilWidget("jsDialogMainBox")
389	}
390	p.mainBox.AddCssClass("permission-popup-container")
391	p.mainBox.SetHalign(gtk.AlignCenterValue)
392	p.mainBox.SetValign(gtk.AlignCenterValue)
393	return nil
394}
395
396func (p *JSDialogPopup) createLabels() error {
397	empty := ""
398	p.headingLabel = gtk.NewLabel(&empty)
399	p.bodyLabel = gtk.NewLabel(&empty)
400	if p.headingLabel == nil || p.bodyLabel == nil {
401		return errNilWidget("jsDialogLabels")
402	}
403	// Labels use SetText only: page text is never parsed as Pango markup.
404	for _, l := range []*gtk.Label{p.headingLabel, p.bodyLabel} {
405		l.SetUseMarkup(false)
406		l.SetHalign(gtk.AlignStartValue)
407		l.SetXalign(0)
408		l.SetWrap(true)
409		l.SetWrapMode(pango.WrapWordCharValue)
410		l.SetMaxWidthChars(jsDialogMaxWidthChars)
411	}
412	p.headingLabel.AddCssClass("permission-popup-heading")
413	p.bodyLabel.AddCssClass("permission-popup-body")
414	return nil
415}
416
417func (p *JSDialogPopup) createButtonRow() (*gtk.Box, error) {
418	btnRow := gtk.NewBox(gtk.OrientationHorizontalValue, buttonSpacing)
419	if btnRow == nil {
420		return nil, errNilWidget("jsDialogBtnRow")
421	}
422	btnRow.AddCssClass("permission-popup-btn-row")
423	btnRow.SetHalign(gtk.AlignEndValue)
424
425	p.btnCancel = gtk.NewButtonWithLabel("Cancel")
426	p.btnOK = gtk.NewButtonWithLabel("OK")
427	if p.btnCancel == nil || p.btnOK == nil {
428		return nil, errNilWidget("jsDialogButtons")
429	}
430	p.btnCancel.AddCssClass("permission-popup-btn")
431	p.btnCancel.AddCssClass("permission-popup-btn-deny")
432	p.btnOK.AddCssClass("permission-popup-btn")
433	p.btnOK.AddCssClass("permission-popup-btn-allow")
434
435	cancelCb := func(_ gtk.Button) { p.Dismiss(false) }
436	okCb := func(_ gtk.Button) { p.Dismiss(true) }
437	p.retainedCallbacks = append(p.retainedCallbacks, cancelCb, okCb)
438	p.signals = append(p.signals,
439		popupSignal{p.btnCancel, p.btnCancel.ConnectClicked(&cancelCb)},
440		popupSignal{p.btnOK, p.btnOK.ConnectClicked(&okCb)},
441	)
442
443	btnRow.Append(&p.btnCancel.Widget)
444	btnRow.Append(&p.btnOK.Widget)
445	return btnRow, nil
446}
447
448// attachKeyController maps Escape to Cancel (capture phase, so the page never
449// sees it). Enter is not handled here: see the entry's activate handler.
450func (p *JSDialogPopup) attachKeyController() {
451	controller := gtk.NewEventControllerKey()
452	if controller == nil {
453		return
454	}
455	controller.SetPropagationPhase(gtk.PhaseCaptureValue)
456	keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
457		if keyval == uint(gdk.KEY_Escape) {
458			p.Dismiss(false)
459			return true
460		}
461		return false
462	}
463	p.retainedCallbacks = append(p.retainedCallbacks, keyPressedCb)
464	p.signals = append(p.signals, popupSignal{controller, controller.ConnectKeyPressed(&keyPressedCb)})
465	p.outerBox.AddController(&controller.EventController)
466	p.controller = controller
467
468	// Track focus-within so close() knows whether the dialog owned focus
469	// (GetFocusChild would leak a reference).
470	if fc := gtk.NewEventControllerFocus(); fc != nil {
471		enterCb := func(gtk.EventControllerFocus) { p.hasFocus.Store(true) }
472		leaveCb := func(gtk.EventControllerFocus) { p.hasFocus.Store(false) }
473		p.retainedCallbacks = append(p.retainedCallbacks, enterCb, leaveCb)
474		p.signals = append(p.signals,
475			popupSignal{fc, fc.ConnectEnter(&enterCb)},
476			popupSignal{fc, fc.ConnectLeave(&leaveCb)},
477		)
478		p.outerBox.AddController(&fc.EventController)
479		p.focusCtrl = fc
480	}
481
482	// A dialog shown while its tab was in the background gets focus once mapped.
483	mapCb := func(gtk.Widget) {
484		p.mu.Lock()
485		want := p.visible && p.wantFocus
486		p.mu.Unlock()
487		if want {
488			p.grabDefaultFocus()
489		}
490	}
491	p.retainedCallbacks = append(p.retainedCallbacks, mapCb)
492	p.signals = append(p.signals, popupSignal{&p.outerBox.Widget, p.outerBox.ConnectMap(&mapCb)})
493}

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