detached d5a6841

feat(cef): support JS alert/confirm/prompt/beforeunload dialogs

Implement CEF JsdialogHandler and forward dialogs to the owning pane via
port.WebViewCallbacks.OnJSDialog. A per-pane overlay (JSDialogPopup) renders
plain-text dialogs with the requesting origin; Enter=OK, Escape=Cancel.
Further dialogs are suppressed while one is open; reset/destroy resolve the
pending CEF callback exactly once.
commit
d5a684168c16f215aa7f819d7227bbc9d918785c
author
brice · · 2026-10-03 06:19 UTC
committer
brice · · 2026-10-03 07:50 UTC
parent
e8e7279
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +1056 -1
A internal/application/port/jsdialog.go +29 -0
@@ -0,0 +1,29 @@
1+package port
2+
3+// JSDialogType identifies the kind of JavaScript dialog requested by a page.
4+type JSDialogType int
5+
6+const (
7+ // JSDialogAlert is window.alert(): message + OK.
8+ JSDialogAlert JSDialogType = iota
9+ // JSDialogConfirm is window.confirm(): message + OK/Cancel.
10+ JSDialogConfirm
11+ // JSDialogPrompt is window.prompt(): message + text input + OK/Cancel.
12+ JSDialogPrompt
13+ // JSDialogBeforeUnload is the "Leave page?" confirmation (Leave/Stay).
14+ JSDialogBeforeUnload
15+)
16+
17+// JSDialogRequest describes a JavaScript dialog requested by a page.
18+// All text is untrusted page content and must be rendered as plain text.
19+type JSDialogRequest struct {
20+ Type JSDialogType
21+ // Origin is the URL of the frame that requested the dialog (best effort).
22+ Origin string
23+ // Message is the page-provided message (may be empty).
24+ Message string
25+ // DefaultPrompt is the prefilled input for JSDialogPrompt.
26+ DefaultPrompt string
27+ // IsReload is true for beforeunload triggered by a reload.
28+ IsReload bool
29+}
@@ -0,0 +1,29 @@
1+package port
2+
3+// JSDialogType identifies the kind of JavaScript dialog requested by a page.
4+type JSDialogType int
5+
6+const (
7+ // JSDialogAlert is window.alert(): message + OK.
8+ JSDialogAlert JSDialogType = iota
9+ // JSDialogConfirm is window.confirm(): message + OK/Cancel.
10+ JSDialogConfirm
11+ // JSDialogPrompt is window.prompt(): message + text input + OK/Cancel.
12+ JSDialogPrompt
13+ // JSDialogBeforeUnload is the "Leave page?" confirmation (Leave/Stay).
14+ JSDialogBeforeUnload
15+)
16+
17+// JSDialogRequest describes a JavaScript dialog requested by a page.
18+// All text is untrusted page content and must be rendered as plain text.
19+type JSDialogRequest struct {
20+ Type JSDialogType
21+ // Origin is the URL of the frame that requested the dialog (best effort).
22+ Origin string
23+ // Message is the page-provided message (may be empty).
24+ Message string
25+ // DefaultPrompt is the prefilled input for JSDialogPrompt.
26+ DefaultPrompt string
27+ // IsReload is true for beforeunload triggered by a reload.
28+ IsReload bool
29+}
M internal/application/port/webview.go +13 -0
@@ -147,6 +147,19 @@ type WebViewCallbacks struct {
147147 // OnEditableFocusChanged is called when the page focus enters or leaves an
148148 // editable target such as input, textarea, or contenteditable content.
149149 OnEditableFocusChanged func(editable bool)
150+
151+ // OnJSDialog is called when the page requests a JavaScript dialog (alert,
152+ // confirm, prompt) or the beforeunload "Leave page?" confirmation. The UI
153+ // shows the dialog scoped to this WebView and calls respond exactly once
154+ // when the user answers (ok=false for cancel/dismiss; input is only
155+ // meaningful for prompts). Return false if no dialog could be shown; the
156+ // engine then answers with a cancel itself. respond is idempotent and safe
157+ // to call from the GTK thread.
158+ OnJSDialog func(req JSDialogRequest, respond func(ok bool, input string)) bool
159+ // OnJSDialogReset is called when the engine canceled a pending dialog (page
160+ // navigation, WebView destruction). The UI must hide any dialog shown for
161+ // this WebView without invoking respond.
162+ OnJSDialogReset func()
150163 }
151164
152165 // FindOptions configures search behavior.
@@ -147,6 +147,19 @@ type WebViewCallbacks struct {
147 // OnEditableFocusChanged is called when the page focus enters or leaves an147 // OnEditableFocusChanged is called when the page focus enters or leaves an
148 // editable target such as input, textarea, or contenteditable content.148 // editable target such as input, textarea, or contenteditable content.
149 OnEditableFocusChanged func(editable bool)149 OnEditableFocusChanged func(editable bool)
150+
151+ // OnJSDialog is called when the page requests a JavaScript dialog (alert,
152+ // confirm, prompt) or the beforeunload "Leave page?" confirmation. The UI
153+ // shows the dialog scoped to this WebView and calls respond exactly once
154+ // when the user answers (ok=false for cancel/dismiss; input is only
155+ // meaningful for prompts). Return false if no dialog could be shown; the
156+ // engine then answers with a cancel itself. respond is idempotent and safe
157+ // to call from the GTK thread.
158+ OnJSDialog func(req JSDialogRequest, respond func(ok bool, input string)) bool
159+ // OnJSDialogReset is called when the engine canceled a pending dialog (page
160+ // navigation, WebView destruction). The UI must hide any dialog shown for
161+ // this WebView without invoking respond.
162+ OnJSDialogReset func()
150 }163 }
151 164
152 // FindOptions configures search behavior.165 // FindOptions configures search behavior.
A internal/infrastructure/cef/jsdialog_handler.go +235 -0
@@ -0,0 +1,235 @@
1+package cef
2+
3+import (
4+ "sync"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// CEF JavaScript dialog support (alert/confirm/prompt/beforeunload).
13+//
14+// With a nil JsdialogHandler CEF falls back to its built-in runner, which has
15+// no usable window in windowless (OSR) mode on Linux, so dialogs are never
16+// shown to the user. We therefore implement the handler, forward requests to
17+// the UI through port.WebViewCallbacks.OnJSDialog and answer the CEF callback
18+// exactly once.
19+//
20+// Anti-abuse: while a dialog is open for a WebView, further OnJsdialog requests
21+// are suppressed (suppress_message=true) instead of being queued, so a page
22+// cannot stack or spam dialogs.
23+
24+// continueCEFJSDialog resolves a CEF dialog callback. It is a seam for tests.
25+var continueCEFJSDialog = continueCEFJSDialogOnUIThread
26+
27+var _ purecef.JsdialogHandler = (*handlerSet)(nil)
28+
29+// jsDialogCall is one in-flight dialog. done is guarded by jsDialogState.mu.
30+type jsDialogCall struct {
31+ callback purecef.JsdialogCallback
32+ done bool
33+}
34+
35+// jsDialogState tracks the single pending dialog of a WebView.
36+type jsDialogState struct {
37+ mu sync.Mutex
38+ current *jsDialogCall
39+}
40+
41+// begin registers a new pending dialog; false if one is already open.
42+func (s *jsDialogState) begin(cb purecef.JsdialogCallback) (*jsDialogCall, bool) {
43+ s.mu.Lock()
44+ defer s.mu.Unlock()
45+ if s.current != nil {
46+ return nil, false
47+ }
48+ call := &jsDialogCall{callback: cb}
49+ s.current = call
50+ return call, true
51+}
52+
53+func (s *jsDialogState) isDone(call *jsDialogCall) bool {
54+ s.mu.Lock()
55+ defer s.mu.Unlock()
56+ return call.done
57+}
58+
59+// resolve answers the CEF callback exactly once; later calls are no-ops.
60+func (s *jsDialogState) resolve(call *jsDialogCall, ok bool, input string) bool {
61+ s.mu.Lock()
62+ if call == nil || call.done {
63+ s.mu.Unlock()
64+ return false
65+ }
66+ call.done = true
67+ if s.current == call {
68+ s.current = nil
69+ }
70+ s.mu.Unlock()
71+ continueCEFJSDialog(call.callback, ok, input)
72+ return true
73+}
74+
75+// cancelPending resolves the pending dialog (if any) with a cancel.
76+func (s *jsDialogState) cancelPending() bool {
77+ s.mu.Lock()
78+ call := s.current
79+ s.mu.Unlock()
80+ if call == nil {
81+ return false
82+ }
83+ return s.resolve(call, false, "")
84+}
85+
86+func (wv *WebView) jsDialogUICallbacks() *port.WebViewCallbacks {
87+ wv.mu.RLock()
88+ defer wv.mu.RUnlock()
89+ return wv.callbacks
90+}
91+
92+// cancelJSDialogs resolves any pending dialog and tells the UI to hide it.
93+func (wv *WebView) cancelJSDialogs() {
94+ if wv == nil {
95+ return
96+ }
97+ if !wv.jsDialogs.cancelPending() {
98+ return
99+ }
100+ if cb := wv.jsDialogUICallbacks(); cb != nil && cb.OnJSDialogReset != nil {
101+ wv.runOnGTK(cb.OnJSDialogReset)
102+ }
103+}
104+
105+func jsDialogTypeFromCEF(t purecef.JsdialogType) (port.JSDialogType, bool) {
106+ switch t {
107+ case purecef.JsdialogTypeJsdialogtypeAlert:
108+ return port.JSDialogAlert, true
109+ case purecef.JsdialogTypeJsdialogtypeConfirm:
110+ return port.JSDialogConfirm, true
111+ case purecef.JsdialogTypeJsdialogtypePrompt:
112+ return port.JSDialogPrompt, true
113+ default:
114+ return 0, false
115+ }
116+}
117+
118+// present shows the dialog on the GTK thread and wires the user's answer back.
119+func (wv *WebView) presentJSDialog(call *jsDialogCall, req port.JSDialogRequest, ui *port.WebViewCallbacks) {
120+ wv.runOnGTK(func() {
121+ if wv.jsDialogs.isDone(call) {
122+ return // canceled before the UI got to run
123+ }
124+ respond := func(ok bool, input string) {
125+ if req.Type != port.JSDialogPrompt {
126+ input = ""
127+ }
128+ wv.jsDialogs.resolve(call, ok, input)
129+ }
130+ if !ui.OnJSDialog(req, respond) {
131+ respond(false, "")
132+ }
133+ })
134+}
135+
136+// OnJsdialog implements purecef.JsdialogHandler.
137+func (h *handlerSet) OnJsdialog(
138+ _ purecef.Browser,
139+ originURL string,
140+ dialogType purecef.JsdialogType,
141+ messageText string,
142+ defaultPromptText string,
143+ callback purecef.JsdialogCallback,
144+ suppressMessage *int32,
145+) int32 {
146+ if h == nil || h.wv == nil || callback == nil {
147+ return 0
148+ }
149+ typ, ok := jsDialogTypeFromCEF(dialogType)
150+ if !ok {
151+ return 0
152+ }
153+ ui := h.wv.jsDialogUICallbacks()
154+ if ui == nil || ui.OnJSDialog == nil {
155+ return 0 // no UI: let CEF apply its default behavior
156+ }
157+ call, ok := h.wv.jsDialogs.begin(callback)
158+ if !ok {
159+ // A dialog is already open: suppress rather than stack/spam.
160+ if suppressMessage != nil {
161+ *suppressMessage = 1
162+ }
163+ return 0
164+ }
165+ logging.FromContext(h.wv.ctx).Debug().Int32("type", dialogType).Msg("cef: js dialog requested")
166+ h.wv.presentJSDialog(call, port.JSDialogRequest{
167+ Type: typ,
168+ Origin: originURL,
169+ Message: messageText,
170+ DefaultPrompt: defaultPromptText,
171+ }, ui)
172+ return 1
173+}
174+
175+// OnBeforeUnloadDialog implements purecef.JsdialogHandler.
176+func (h *handlerSet) OnBeforeUnloadDialog(
177+ _ purecef.Browser,
178+ messageText string,
179+ isReload int32,
180+ callback purecef.JsdialogCallback,
181+) bool {
182+ if h == nil || h.wv == nil || callback == nil {
183+ return false
184+ }
185+ ui := h.wv.jsDialogUICallbacks()
186+ if ui == nil || ui.OnJSDialog == nil {
187+ return false
188+ }
189+ call, ok := h.wv.jsDialogs.begin(callback)
190+ if !ok {
191+ // Another dialog is open: stay on the page rather than stacking.
192+ continueCEFJSDialog(callback, false, "")
193+ return true
194+ }
195+ h.wv.presentJSDialog(call, port.JSDialogRequest{
196+ Type: port.JSDialogBeforeUnload,
197+ Origin: h.wv.URI(),
198+ Message: messageText,
199+ IsReload: isReload != 0,
200+ }, ui)
201+ return true
202+}
203+
204+// OnResetDialogState implements purecef.JsdialogHandler: CEF canceled pending
205+// dialogs (navigation, close). Close the UI and resolve the callback.
206+func (h *handlerSet) OnResetDialogState(_ purecef.Browser) {
207+ if h == nil || h.wv == nil {
208+ return
209+ }
210+ h.wv.cancelJSDialogs()
211+}
212+
213+// OnDialogClosed implements purecef.JsdialogHandler. It intentionally does
214+// nothing: resolve already cleared the pending state, and a late notification
215+// must not clear a newer dialog.
216+func (h *handlerSet) OnDialogClosed(_ purecef.Browser) {}
217+
218+// continueCEFJSDialogOnUIThread answers the callback on the CEF UI thread.
219+func continueCEFJSDialogOnUIThread(callback purecef.JsdialogCallback, ok bool, input string) {
220+ if callback == nil {
221+ return
222+ }
223+ success := int32(0)
224+ if ok {
225+ success = 1
226+ }
227+ if purecef.CurrentlyOn(purecef.ThreadIDTidUi) == 1 {
228+ callback.Cont(success, input)
229+ return
230+ }
231+ task := cefNewTask(cefTaskFunc(func() { callback.Cont(success, input) }))
232+ if cefPostTask(purecef.ThreadIDTidUi, task) != 1 {
233+ callback.Cont(success, input)
234+ }
235+}
@@ -0,0 +1,235 @@
1+package cef
2+
3+import (
4+ "sync"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// CEF JavaScript dialog support (alert/confirm/prompt/beforeunload).
13+//
14+// With a nil JsdialogHandler CEF falls back to its built-in runner, which has
15+// no usable window in windowless (OSR) mode on Linux, so dialogs are never
16+// shown to the user. We therefore implement the handler, forward requests to
17+// the UI through port.WebViewCallbacks.OnJSDialog and answer the CEF callback
18+// exactly once.
19+//
20+// Anti-abuse: while a dialog is open for a WebView, further OnJsdialog requests
21+// are suppressed (suppress_message=true) instead of being queued, so a page
22+// cannot stack or spam dialogs.
23+
24+// continueCEFJSDialog resolves a CEF dialog callback. It is a seam for tests.
25+var continueCEFJSDialog = continueCEFJSDialogOnUIThread
26+
27+var _ purecef.JsdialogHandler = (*handlerSet)(nil)
28+
29+// jsDialogCall is one in-flight dialog. done is guarded by jsDialogState.mu.
30+type jsDialogCall struct {
31+ callback purecef.JsdialogCallback
32+ done bool
33+}
34+
35+// jsDialogState tracks the single pending dialog of a WebView.
36+type jsDialogState struct {
37+ mu sync.Mutex
38+ current *jsDialogCall
39+}
40+
41+// begin registers a new pending dialog; false if one is already open.
42+func (s *jsDialogState) begin(cb purecef.JsdialogCallback) (*jsDialogCall, bool) {
43+ s.mu.Lock()
44+ defer s.mu.Unlock()
45+ if s.current != nil {
46+ return nil, false
47+ }
48+ call := &jsDialogCall{callback: cb}
49+ s.current = call
50+ return call, true
51+}
52+
53+func (s *jsDialogState) isDone(call *jsDialogCall) bool {
54+ s.mu.Lock()
55+ defer s.mu.Unlock()
56+ return call.done
57+}
58+
59+// resolve answers the CEF callback exactly once; later calls are no-ops.
60+func (s *jsDialogState) resolve(call *jsDialogCall, ok bool, input string) bool {
61+ s.mu.Lock()
62+ if call == nil || call.done {
63+ s.mu.Unlock()
64+ return false
65+ }
66+ call.done = true
67+ if s.current == call {
68+ s.current = nil
69+ }
70+ s.mu.Unlock()
71+ continueCEFJSDialog(call.callback, ok, input)
72+ return true
73+}
74+
75+// cancelPending resolves the pending dialog (if any) with a cancel.
76+func (s *jsDialogState) cancelPending() bool {
77+ s.mu.Lock()
78+ call := s.current
79+ s.mu.Unlock()
80+ if call == nil {
81+ return false
82+ }
83+ return s.resolve(call, false, "")
84+}
85+
86+func (wv *WebView) jsDialogUICallbacks() *port.WebViewCallbacks {
87+ wv.mu.RLock()
88+ defer wv.mu.RUnlock()
89+ return wv.callbacks
90+}
91+
92+// cancelJSDialogs resolves any pending dialog and tells the UI to hide it.
93+func (wv *WebView) cancelJSDialogs() {
94+ if wv == nil {
95+ return
96+ }
97+ if !wv.jsDialogs.cancelPending() {
98+ return
99+ }
100+ if cb := wv.jsDialogUICallbacks(); cb != nil && cb.OnJSDialogReset != nil {
101+ wv.runOnGTK(cb.OnJSDialogReset)
102+ }
103+}
104+
105+func jsDialogTypeFromCEF(t purecef.JsdialogType) (port.JSDialogType, bool) {
106+ switch t {
107+ case purecef.JsdialogTypeJsdialogtypeAlert:
108+ return port.JSDialogAlert, true
109+ case purecef.JsdialogTypeJsdialogtypeConfirm:
110+ return port.JSDialogConfirm, true
111+ case purecef.JsdialogTypeJsdialogtypePrompt:
112+ return port.JSDialogPrompt, true
113+ default:
114+ return 0, false
115+ }
116+}
117+
118+// present shows the dialog on the GTK thread and wires the user's answer back.
119+func (wv *WebView) presentJSDialog(call *jsDialogCall, req port.JSDialogRequest, ui *port.WebViewCallbacks) {
120+ wv.runOnGTK(func() {
121+ if wv.jsDialogs.isDone(call) {
122+ return // canceled before the UI got to run
123+ }
124+ respond := func(ok bool, input string) {
125+ if req.Type != port.JSDialogPrompt {
126+ input = ""
127+ }
128+ wv.jsDialogs.resolve(call, ok, input)
129+ }
130+ if !ui.OnJSDialog(req, respond) {
131+ respond(false, "")
132+ }
133+ })
134+}
135+
136+// OnJsdialog implements purecef.JsdialogHandler.
137+func (h *handlerSet) OnJsdialog(
138+ _ purecef.Browser,
139+ originURL string,
140+ dialogType purecef.JsdialogType,
141+ messageText string,
142+ defaultPromptText string,
143+ callback purecef.JsdialogCallback,
144+ suppressMessage *int32,
145+) int32 {
146+ if h == nil || h.wv == nil || callback == nil {
147+ return 0
148+ }
149+ typ, ok := jsDialogTypeFromCEF(dialogType)
150+ if !ok {
151+ return 0
152+ }
153+ ui := h.wv.jsDialogUICallbacks()
154+ if ui == nil || ui.OnJSDialog == nil {
155+ return 0 // no UI: let CEF apply its default behavior
156+ }
157+ call, ok := h.wv.jsDialogs.begin(callback)
158+ if !ok {
159+ // A dialog is already open: suppress rather than stack/spam.
160+ if suppressMessage != nil {
161+ *suppressMessage = 1
162+ }
163+ return 0
164+ }
165+ logging.FromContext(h.wv.ctx).Debug().Int32("type", dialogType).Msg("cef: js dialog requested")
166+ h.wv.presentJSDialog(call, port.JSDialogRequest{
167+ Type: typ,
168+ Origin: originURL,
169+ Message: messageText,
170+ DefaultPrompt: defaultPromptText,
171+ }, ui)
172+ return 1
173+}
174+
175+// OnBeforeUnloadDialog implements purecef.JsdialogHandler.
176+func (h *handlerSet) OnBeforeUnloadDialog(
177+ _ purecef.Browser,
178+ messageText string,
179+ isReload int32,
180+ callback purecef.JsdialogCallback,
181+) bool {
182+ if h == nil || h.wv == nil || callback == nil {
183+ return false
184+ }
185+ ui := h.wv.jsDialogUICallbacks()
186+ if ui == nil || ui.OnJSDialog == nil {
187+ return false
188+ }
189+ call, ok := h.wv.jsDialogs.begin(callback)
190+ if !ok {
191+ // Another dialog is open: stay on the page rather than stacking.
192+ continueCEFJSDialog(callback, false, "")
193+ return true
194+ }
195+ h.wv.presentJSDialog(call, port.JSDialogRequest{
196+ Type: port.JSDialogBeforeUnload,
197+ Origin: h.wv.URI(),
198+ Message: messageText,
199+ IsReload: isReload != 0,
200+ }, ui)
201+ return true
202+}
203+
204+// OnResetDialogState implements purecef.JsdialogHandler: CEF canceled pending
205+// dialogs (navigation, close). Close the UI and resolve the callback.
206+func (h *handlerSet) OnResetDialogState(_ purecef.Browser) {
207+ if h == nil || h.wv == nil {
208+ return
209+ }
210+ h.wv.cancelJSDialogs()
211+}
212+
213+// OnDialogClosed implements purecef.JsdialogHandler. It intentionally does
214+// nothing: resolve already cleared the pending state, and a late notification
215+// must not clear a newer dialog.
216+func (h *handlerSet) OnDialogClosed(_ purecef.Browser) {}
217+
218+// continueCEFJSDialogOnUIThread answers the callback on the CEF UI thread.
219+func continueCEFJSDialogOnUIThread(callback purecef.JsdialogCallback, ok bool, input string) {
220+ if callback == nil {
221+ return
222+ }
223+ success := int32(0)
224+ if ok {
225+ success = 1
226+ }
227+ if purecef.CurrentlyOn(purecef.ThreadIDTidUi) == 1 {
228+ callback.Cont(success, input)
229+ return
230+ }
231+ task := cefNewTask(cefTaskFunc(func() { callback.Cont(success, input) }))
232+ if cefPostTask(purecef.ThreadIDTidUi, task) != 1 {
233+ callback.Cont(success, input)
234+ }
235+}
A internal/infrastructure/cef/jsdialog_handler_test.go +242 -0
@@ -0,0 +1,242 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+)
12+
13+type stubJSDialogCallback struct {
14+ calls []jsDialogAnswer
15+}
16+
17+type jsDialogAnswer struct {
18+ ok bool
19+ input string
20+}
21+
22+func (s *stubJSDialogCallback) Cont(success int32, userInput string) {
23+ s.calls = append(s.calls, jsDialogAnswer{ok: success != 0, input: userInput})
24+}
25+
26+// useDirectJSDialogContinue answers stub callbacks inline (no CEF UI thread).
27+func useDirectJSDialogContinue(t *testing.T) {
28+ t.Helper()
29+ prev := continueCEFJSDialog
30+ continueCEFJSDialog = func(cb purecef.JsdialogCallback, ok bool, input string) {
31+ success := int32(0)
32+ if ok {
33+ success = 1
34+ }
35+ cb.Cont(success, input)
36+ }
37+ t.Cleanup(func() { continueCEFJSDialog = prev })
38+}
39+
40+type jsDialogUIRecorder struct {
41+ reqs []port.JSDialogRequest
42+ respond []func(bool, string)
43+ resets int
44+ handled bool
45+}
46+
47+func newJSDialogWebView(ui *jsDialogUIRecorder) *WebView {
48+ return &WebView{
49+ ctx: context.Background(),
50+ callbacks: &port.WebViewCallbacks{
51+ OnJSDialog: func(req port.JSDialogRequest, respond func(bool, string)) bool {
52+ ui.reqs = append(ui.reqs, req)
53+ ui.respond = append(ui.respond, respond)
54+ return ui.handled
55+ },
56+ OnJSDialogReset: func() { ui.resets++ },
57+ },
58+ }
59+}
60+
61+func TestGetJsdialogHandlerEnabled(t *testing.T) {
62+ h := &handlerSet{}
63+ require.Same(t, h, h.GetJsdialogHandler())
64+}
65+
66+func TestJSDialogTypeMapping(t *testing.T) {
67+ cases := map[purecef.JsdialogType]port.JSDialogType{
68+ purecef.JsdialogTypeJsdialogtypeAlert: port.JSDialogAlert,
69+ purecef.JsdialogTypeJsdialogtypeConfirm: port.JSDialogConfirm,
70+ purecef.JsdialogTypeJsdialogtypePrompt: port.JSDialogPrompt,
71+ }
72+ for in, want := range cases {
73+ got, ok := jsDialogTypeFromCEF(in)
74+ require.True(t, ok)
75+ require.Equal(t, want, got)
76+ }
77+ _, ok := jsDialogTypeFromCEF(purecef.JsdialogTypeJsdialogtypeNumValues)
78+ require.False(t, ok)
79+}
80+
81+func TestOnJsdialogForwardsRequestAndRespondsOnce(t *testing.T) {
82+ useDirectJSDialogContinue(t)
83+ ui := &jsDialogUIRecorder{handled: true}
84+ h := &handlerSet{wv: newJSDialogWebView(ui)}
85+ cb := &stubJSDialogCallback{}
86+ var suppress int32
87+
88+ handled := h.OnJsdialog(nil, "https://example.com/p", purecef.JsdialogTypeJsdialogtypePrompt, "Name?", "bob", cb, &suppress)
89+
90+ require.Equal(t, int32(1), handled)
91+ require.Zero(t, suppress)
92+ require.Equal(t, []port.JSDialogRequest{{
93+ Type: port.JSDialogPrompt, Origin: "https://example.com/p", Message: "Name?", DefaultPrompt: "bob",
94+ }}, ui.reqs)
95+ require.Empty(t, cb.calls)
96+
97+ ui.respond[0](true, "alice")
98+ ui.respond[0](false, "late") // ignored: exactly once
99+ require.Equal(t, []jsDialogAnswer{{ok: true, input: "alice"}}, cb.calls)
100+}
101+
102+func TestOnJsdialogDropsInputForNonPrompt(t *testing.T) {
103+ useDirectJSDialogContinue(t)
104+ ui := &jsDialogUIRecorder{handled: true}
105+ h := &handlerSet{wv: newJSDialogWebView(ui)}
106+ cb := &stubJSDialogCallback{}
107+
108+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeConfirm, "ok?", "", cb, new(int32))
109+ ui.respond[0](true, "ignored")
110+
111+ require.Equal(t, []jsDialogAnswer{{ok: true}}, cb.calls)
112+}
113+
114+func TestOnJsdialogWithoutUICallbackUsesDefault(t *testing.T) {
115+ cb := &stubJSDialogCallback{}
116+ var suppress int32
117+
118+ for _, h := range []*handlerSet{
119+ nil,
120+ {},
121+ {wv: &WebView{ctx: context.Background()}},
122+ {wv: &WebView{ctx: context.Background(), callbacks: &port.WebViewCallbacks{}}},
123+ } {
124+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, &suppress))
125+ require.False(t, h.OnBeforeUnloadDialog(nil, "m", 0, cb))
126+ }
127+ require.Zero(t, suppress)
128+ require.Empty(t, cb.calls)
129+}
130+
131+func TestOnJsdialogRejectsNilCallbackAndUnknownType(t *testing.T) {
132+ ui := &jsDialogUIRecorder{handled: true}
133+ h := &handlerSet{wv: newJSDialogWebView(ui)}
134+
135+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", nil, new(int32)))
136+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeNumValues, "m", "", &stubJSDialogCallback{}, new(int32)))
137+ require.Empty(t, ui.reqs)
138+}
139+
140+func TestOnJsdialogUINotHandledCancels(t *testing.T) {
141+ useDirectJSDialogContinue(t)
142+ ui := &jsDialogUIRecorder{handled: false}
143+ h := &handlerSet{wv: newJSDialogWebView(ui)}
144+ cb := &stubJSDialogCallback{}
145+
146+ require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeConfirm, "m", "", cb, new(int32)))
147+ require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
148+}
149+
150+func TestOnJsdialogSuppressesWhileDialogOpen(t *testing.T) {
151+ useDirectJSDialogContinue(t)
152+ ui := &jsDialogUIRecorder{handled: true}
153+ h := &handlerSet{wv: newJSDialogWebView(ui)}
154+ first := &stubJSDialogCallback{}
155+ second := &stubJSDialogCallback{}
156+ var suppress int32
157+
158+ require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32)))
159+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", second, &suppress))
160+ require.Equal(t, int32(1), suppress)
161+ require.Len(t, ui.reqs, 1)
162+ require.Empty(t, second.calls)
163+
164+ // Once answered, new dialogs are allowed again.
165+ ui.respond[0](true, "")
166+ require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "3", "", second, new(int32)))
167+ require.Len(t, ui.reqs, 2)
168+}
169+
170+func TestOnBeforeUnloadDialog(t *testing.T) {
171+ useDirectJSDialogContinue(t)
172+ ui := &jsDialogUIRecorder{handled: true}
173+ wv := newJSDialogWebView(ui)
174+ wv.uri = "https://example.com/edit"
175+ h := &handlerSet{wv: wv}
176+ cb := &stubJSDialogCallback{}
177+
178+ require.True(t, h.OnBeforeUnloadDialog(nil, "unsaved", 1, cb))
179+ require.Equal(t, []port.JSDialogRequest{{
180+ Type: port.JSDialogBeforeUnload, Origin: "https://example.com/edit", Message: "unsaved", IsReload: true,
181+ }}, ui.reqs)
182+
183+ // A second beforeunload while open is answered "stay" immediately.
184+ other := &stubJSDialogCallback{}
185+ require.True(t, h.OnBeforeUnloadDialog(nil, "again", 0, other))
186+ require.Equal(t, []jsDialogAnswer{{ok: false}}, other.calls)
187+ require.Len(t, ui.reqs, 1)
188+
189+ ui.respond[0](true, "")
190+ require.Equal(t, []jsDialogAnswer{{ok: true}}, cb.calls)
191+}
192+
193+func TestOnResetDialogStateResolvesPendingAndClosesUI(t *testing.T) {
194+ useDirectJSDialogContinue(t)
195+ ui := &jsDialogUIRecorder{handled: true}
196+ h := &handlerSet{wv: newJSDialogWebView(ui)}
197+ cb := &stubJSDialogCallback{}
198+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypePrompt, "m", "d", cb, new(int32))
199+
200+ h.OnResetDialogState(nil)
201+
202+ require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
203+ require.Equal(t, 1, ui.resets)
204+
205+ // A late UI answer after reset must not resolve twice.
206+ ui.respond[0](true, "late")
207+ require.Len(t, cb.calls, 1)
208+
209+ // Reset with nothing pending is a no-op and does not touch the UI.
210+ h.OnResetDialogState(nil)
211+ require.Equal(t, 1, ui.resets)
212+ h.OnDialogClosed(nil)
213+}
214+
215+func TestDestroyCancelsPendingJSDialog(t *testing.T) {
216+ useDirectJSDialogContinue(t)
217+ ui := &jsDialogUIRecorder{handled: true}
218+ wv := newJSDialogWebView(ui)
219+ h := &handlerSet{wv: wv}
220+ cb := &stubJSDialogCallback{}
221+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, new(int32))
222+
223+ wv.cancelJSDialogs()
224+
225+ require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
226+ require.Equal(t, 1, ui.resets)
227+}
228+
229+func TestJSDialogCancelledBeforeUIRunsIsNotShown(t *testing.T) {
230+ useDirectJSDialogContinue(t)
231+ ui := &jsDialogUIRecorder{handled: true}
232+ wv := newJSDialogWebView(ui)
233+ cb := &stubJSDialogCallback{}
234+ call, ok := wv.jsDialogs.begin(cb)
235+ require.True(t, ok)
236+ wv.jsDialogs.resolve(call, false, "")
237+
238+ wv.presentJSDialog(call, port.JSDialogRequest{}, wv.callbacks)
239+
240+ require.Empty(t, ui.reqs)
241+ require.Len(t, cb.calls, 1)
242+}
@@ -0,0 +1,242 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+)
12+
13+type stubJSDialogCallback struct {
14+ calls []jsDialogAnswer
15+}
16+
17+type jsDialogAnswer struct {
18+ ok bool
19+ input string
20+}
21+
22+func (s *stubJSDialogCallback) Cont(success int32, userInput string) {
23+ s.calls = append(s.calls, jsDialogAnswer{ok: success != 0, input: userInput})
24+}
25+
26+// useDirectJSDialogContinue answers stub callbacks inline (no CEF UI thread).
27+func useDirectJSDialogContinue(t *testing.T) {
28+ t.Helper()
29+ prev := continueCEFJSDialog
30+ continueCEFJSDialog = func(cb purecef.JsdialogCallback, ok bool, input string) {
31+ success := int32(0)
32+ if ok {
33+ success = 1
34+ }
35+ cb.Cont(success, input)
36+ }
37+ t.Cleanup(func() { continueCEFJSDialog = prev })
38+}
39+
40+type jsDialogUIRecorder struct {
41+ reqs []port.JSDialogRequest
42+ respond []func(bool, string)
43+ resets int
44+ handled bool
45+}
46+
47+func newJSDialogWebView(ui *jsDialogUIRecorder) *WebView {
48+ return &WebView{
49+ ctx: context.Background(),
50+ callbacks: &port.WebViewCallbacks{
51+ OnJSDialog: func(req port.JSDialogRequest, respond func(bool, string)) bool {
52+ ui.reqs = append(ui.reqs, req)
53+ ui.respond = append(ui.respond, respond)
54+ return ui.handled
55+ },
56+ OnJSDialogReset: func() { ui.resets++ },
57+ },
58+ }
59+}
60+
61+func TestGetJsdialogHandlerEnabled(t *testing.T) {
62+ h := &handlerSet{}
63+ require.Same(t, h, h.GetJsdialogHandler())
64+}
65+
66+func TestJSDialogTypeMapping(t *testing.T) {
67+ cases := map[purecef.JsdialogType]port.JSDialogType{
68+ purecef.JsdialogTypeJsdialogtypeAlert: port.JSDialogAlert,
69+ purecef.JsdialogTypeJsdialogtypeConfirm: port.JSDialogConfirm,
70+ purecef.JsdialogTypeJsdialogtypePrompt: port.JSDialogPrompt,
71+ }
72+ for in, want := range cases {
73+ got, ok := jsDialogTypeFromCEF(in)
74+ require.True(t, ok)
75+ require.Equal(t, want, got)
76+ }
77+ _, ok := jsDialogTypeFromCEF(purecef.JsdialogTypeJsdialogtypeNumValues)
78+ require.False(t, ok)
79+}
80+
81+func TestOnJsdialogForwardsRequestAndRespondsOnce(t *testing.T) {
82+ useDirectJSDialogContinue(t)
83+ ui := &jsDialogUIRecorder{handled: true}
84+ h := &handlerSet{wv: newJSDialogWebView(ui)}
85+ cb := &stubJSDialogCallback{}
86+ var suppress int32
87+
88+ handled := h.OnJsdialog(nil, "https://example.com/p", purecef.JsdialogTypeJsdialogtypePrompt, "Name?", "bob", cb, &suppress)
89+
90+ require.Equal(t, int32(1), handled)
91+ require.Zero(t, suppress)
92+ require.Equal(t, []port.JSDialogRequest{{
93+ Type: port.JSDialogPrompt, Origin: "https://example.com/p", Message: "Name?", DefaultPrompt: "bob",
94+ }}, ui.reqs)
95+ require.Empty(t, cb.calls)
96+
97+ ui.respond[0](true, "alice")
98+ ui.respond[0](false, "late") // ignored: exactly once
99+ require.Equal(t, []jsDialogAnswer{{ok: true, input: "alice"}}, cb.calls)
100+}
101+
102+func TestOnJsdialogDropsInputForNonPrompt(t *testing.T) {
103+ useDirectJSDialogContinue(t)
104+ ui := &jsDialogUIRecorder{handled: true}
105+ h := &handlerSet{wv: newJSDialogWebView(ui)}
106+ cb := &stubJSDialogCallback{}
107+
108+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeConfirm, "ok?", "", cb, new(int32))
109+ ui.respond[0](true, "ignored")
110+
111+ require.Equal(t, []jsDialogAnswer{{ok: true}}, cb.calls)
112+}
113+
114+func TestOnJsdialogWithoutUICallbackUsesDefault(t *testing.T) {
115+ cb := &stubJSDialogCallback{}
116+ var suppress int32
117+
118+ for _, h := range []*handlerSet{
119+ nil,
120+ {},
121+ {wv: &WebView{ctx: context.Background()}},
122+ {wv: &WebView{ctx: context.Background(), callbacks: &port.WebViewCallbacks{}}},
123+ } {
124+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, &suppress))
125+ require.False(t, h.OnBeforeUnloadDialog(nil, "m", 0, cb))
126+ }
127+ require.Zero(t, suppress)
128+ require.Empty(t, cb.calls)
129+}
130+
131+func TestOnJsdialogRejectsNilCallbackAndUnknownType(t *testing.T) {
132+ ui := &jsDialogUIRecorder{handled: true}
133+ h := &handlerSet{wv: newJSDialogWebView(ui)}
134+
135+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", nil, new(int32)))
136+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeNumValues, "m", "", &stubJSDialogCallback{}, new(int32)))
137+ require.Empty(t, ui.reqs)
138+}
139+
140+func TestOnJsdialogUINotHandledCancels(t *testing.T) {
141+ useDirectJSDialogContinue(t)
142+ ui := &jsDialogUIRecorder{handled: false}
143+ h := &handlerSet{wv: newJSDialogWebView(ui)}
144+ cb := &stubJSDialogCallback{}
145+
146+ require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeConfirm, "m", "", cb, new(int32)))
147+ require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
148+}
149+
150+func TestOnJsdialogSuppressesWhileDialogOpen(t *testing.T) {
151+ useDirectJSDialogContinue(t)
152+ ui := &jsDialogUIRecorder{handled: true}
153+ h := &handlerSet{wv: newJSDialogWebView(ui)}
154+ first := &stubJSDialogCallback{}
155+ second := &stubJSDialogCallback{}
156+ var suppress int32
157+
158+ require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32)))
159+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", second, &suppress))
160+ require.Equal(t, int32(1), suppress)
161+ require.Len(t, ui.reqs, 1)
162+ require.Empty(t, second.calls)
163+
164+ // Once answered, new dialogs are allowed again.
165+ ui.respond[0](true, "")
166+ require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "3", "", second, new(int32)))
167+ require.Len(t, ui.reqs, 2)
168+}
169+
170+func TestOnBeforeUnloadDialog(t *testing.T) {
171+ useDirectJSDialogContinue(t)
172+ ui := &jsDialogUIRecorder{handled: true}
173+ wv := newJSDialogWebView(ui)
174+ wv.uri = "https://example.com/edit"
175+ h := &handlerSet{wv: wv}
176+ cb := &stubJSDialogCallback{}
177+
178+ require.True(t, h.OnBeforeUnloadDialog(nil, "unsaved", 1, cb))
179+ require.Equal(t, []port.JSDialogRequest{{
180+ Type: port.JSDialogBeforeUnload, Origin: "https://example.com/edit", Message: "unsaved", IsReload: true,
181+ }}, ui.reqs)
182+
183+ // A second beforeunload while open is answered "stay" immediately.
184+ other := &stubJSDialogCallback{}
185+ require.True(t, h.OnBeforeUnloadDialog(nil, "again", 0, other))
186+ require.Equal(t, []jsDialogAnswer{{ok: false}}, other.calls)
187+ require.Len(t, ui.reqs, 1)
188+
189+ ui.respond[0](true, "")
190+ require.Equal(t, []jsDialogAnswer{{ok: true}}, cb.calls)
191+}
192+
193+func TestOnResetDialogStateResolvesPendingAndClosesUI(t *testing.T) {
194+ useDirectJSDialogContinue(t)
195+ ui := &jsDialogUIRecorder{handled: true}
196+ h := &handlerSet{wv: newJSDialogWebView(ui)}
197+ cb := &stubJSDialogCallback{}
198+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypePrompt, "m", "d", cb, new(int32))
199+
200+ h.OnResetDialogState(nil)
201+
202+ require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
203+ require.Equal(t, 1, ui.resets)
204+
205+ // A late UI answer after reset must not resolve twice.
206+ ui.respond[0](true, "late")
207+ require.Len(t, cb.calls, 1)
208+
209+ // Reset with nothing pending is a no-op and does not touch the UI.
210+ h.OnResetDialogState(nil)
211+ require.Equal(t, 1, ui.resets)
212+ h.OnDialogClosed(nil)
213+}
214+
215+func TestDestroyCancelsPendingJSDialog(t *testing.T) {
216+ useDirectJSDialogContinue(t)
217+ ui := &jsDialogUIRecorder{handled: true}
218+ wv := newJSDialogWebView(ui)
219+ h := &handlerSet{wv: wv}
220+ cb := &stubJSDialogCallback{}
221+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, new(int32))
222+
223+ wv.cancelJSDialogs()
224+
225+ require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
226+ require.Equal(t, 1, ui.resets)
227+}
228+
229+func TestJSDialogCancelledBeforeUIRunsIsNotShown(t *testing.T) {
230+ useDirectJSDialogContinue(t)
231+ ui := &jsDialogUIRecorder{handled: true}
232+ wv := newJSDialogWebView(ui)
233+ cb := &stubJSDialogCallback{}
234+ call, ok := wv.jsDialogs.begin(cb)
235+ require.True(t, ok)
236+ wv.jsDialogs.resolve(call, false, "")
237+
238+ wv.presentJSDialog(call, port.JSDialogRequest{}, wv.callbacks)
239+
240+ require.Empty(t, ui.reqs)
241+ require.Len(t, cb.calls, 1)
242+}
M internal/infrastructure/cef/webview.go +2 -0
@@ -221,6 +221,7 @@ type WebView struct {
221221 // Callbacks and browsing-context state set by the UI layer.
222222 mu sync.RWMutex
223223 callbacks *port.WebViewCallbacks
224+ jsDialogs jsDialogState
224225 browsingContextDecision dto.HostDecision
225226 hasBrowsingContextDecision bool
226227 nativePopupHostAbort func()
@@ -1332,6 +1333,7 @@ func (wv *WebView) Destroy() {
13321333 // calling thread; GTK-only cleanup follows through the owning
13331334 // dispatcher without waiting for the deferred native browser close.
13341335 wv.invalidateScrollMotion()
1336+ wv.cancelJSDialogs()
13351337 wv.shutdownAccessibilityCapture()
13361338 wv.resetPageScrollQueue()
13371339 wv.syntheticPopupMu.Lock()
@@ -221,6 +221,7 @@ type WebView struct {
221 // Callbacks and browsing-context state set by the UI layer.221 // Callbacks and browsing-context state set by the UI layer.
222 mu sync.RWMutex222 mu sync.RWMutex
223 callbacks *port.WebViewCallbacks223 callbacks *port.WebViewCallbacks
224+ jsDialogs jsDialogState
224 browsingContextDecision dto.HostDecision225 browsingContextDecision dto.HostDecision
225 hasBrowsingContextDecision bool226 hasBrowsingContextDecision bool
226 nativePopupHostAbort func()227 nativePopupHostAbort func()
@@ -1332,6 +1333,7 @@ func (wv *WebView) Destroy() {
1332 // calling thread; GTK-only cleanup follows through the owning1333 // calling thread; GTK-only cleanup follows through the owning
1333 // dispatcher without waiting for the deferred native browser close.1334 // dispatcher without waiting for the deferred native browser close.
1334 wv.invalidateScrollMotion()1335 wv.invalidateScrollMotion()
1336+ wv.cancelJSDialogs()
1335 wv.shutdownAccessibilityCapture()1337 wv.shutdownAccessibilityCapture()
1336 wv.resetPageScrollQueue()1338 wv.resetPageScrollQueue()
1337 wv.syntheticPopupMu.Lock()1339 wv.syntheticPopupMu.Lock()
M internal/infrastructure/cef/webview_handlers.go +1 -1
@@ -72,7 +72,7 @@ func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h
7272 func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
7373 func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
7474 func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
75-func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }
75+func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return h }
7676 func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }
7777 func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }
7878 func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }
@@ -72,7 +72,7 @@ func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h
72 func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }72 func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
73 func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }73 func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
74 func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }74 func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
75-func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }75+func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return h }
76 func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }76 func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }
77 func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }77 func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }
78 func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }78 func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }
M internal/ui/app.go +16 -0
@@ -3424,6 +3424,9 @@ func (a *App) initCoordinators(ctx context.Context) {
34243424 a.handlePageEditableFocusChanged(ctx, paneID, editable)
34253425 })
34263426
3427+ // JS dialogs are shown in the owning pane's overlay, wherever that pane lives.
3428+ a.contentCoord.SetPaneViewResolver(a.paneViewForPane)
3429+
34273430 // Hide loading skeleton once the WebView paints
34283431 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
34293432 // The WebView can be shown while its pane is in a background tab, so scan
@@ -3589,6 +3592,19 @@ func (a *App) wireKeyboardActions() {
35893592 })
35903593 }
35913594
3595+// paneViewForPane finds a pane's view in any tab (including background tabs).
3596+func (a *App) paneViewForPane(paneID entity.PaneID) *component.PaneView {
3597+ for _, wsView := range a.workspaceViews {
3598+ if wsView == nil {
3599+ continue
3600+ }
3601+ if pv := wsView.GetPaneView(paneID); pv != nil {
3602+ return pv
3603+ }
3604+ }
3605+ return nil
3606+}
3607+
35923608 func (a *App) wireWebRTCPermissionIndicator() {
35933609 if a.contentCoord == nil {
35943610 return
@@ -3424,6 +3424,9 @@ func (a *App) initCoordinators(ctx context.Context) {
3424 a.handlePageEditableFocusChanged(ctx, paneID, editable)3424 a.handlePageEditableFocusChanged(ctx, paneID, editable)
3425 })3425 })
3426 3426
3427+ // JS dialogs are shown in the owning pane's overlay, wherever that pane lives.
3428+ a.contentCoord.SetPaneViewResolver(a.paneViewForPane)
3429+
3427 // Hide loading skeleton once the WebView paints3430 // Hide loading skeleton once the WebView paints
3428 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {3431 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
3429 // The WebView can be shown while its pane is in a background tab, so scan3432 // The WebView can be shown while its pane is in a background tab, so scan
@@ -3589,6 +3592,19 @@ func (a *App) wireKeyboardActions() {
3589 })3592 })
3590 }3593 }
3591 3594
3595+// paneViewForPane finds a pane's view in any tab (including background tabs).
3596+func (a *App) paneViewForPane(paneID entity.PaneID) *component.PaneView {
3597+ for _, wsView := range a.workspaceViews {
3598+ if wsView == nil {
3599+ continue
3600+ }
3601+ if pv := wsView.GetPaneView(paneID); pv != nil {
3602+ return pv
3603+ }
3604+ }
3605+ return nil
3606+}
3607+
3592 func (a *App) wireWebRTCPermissionIndicator() {3608 func (a *App) wireWebRTCPermissionIndicator() {
3593 if a.contentCoord == nil {3609 if a.contentCoord == nil {
3594 return3610 return
A internal/ui/component/jsdialog_popup.go +354 -0
@@ -0,0 +1,354 @@
1+package component
2+
3+import (
4+ "net/url"
5+ "strings"
6+ "sync"
7+
8+ "github.com/bnema/puregotk/v4/gdk"
9+ "github.com/bnema/puregotk/v4/gtk"
10+ "github.com/bnema/puregotk/v4/pango"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+)
14+
15+const (
16+ jsDialogMaxBodyHeight = 240
17+ jsDialogMaxWidthChars = 60
18+ jsDialogMaxHostChars = 80
19+ jsDialogDialogWidth = 420
20+)
21+
22+// jsDialogContent is the plain-text content of a JS dialog.
23+type jsDialogContent struct {
24+ Heading string
25+ Body string
26+ OKLabel string
27+ CancelLabel string // empty: no cancel button (alert)
28+ ShowInput bool
29+}
30+
31+// jsDialogOriginLabel returns a short, user-facing origin ("example.com") for
32+// the requesting URL. The origin is shown separately from the page-controlled
33+// message so a page cannot impersonate browser UI.
34+func jsDialogOriginLabel(origin string) string {
35+ origin = strings.TrimSpace(origin)
36+ if origin == "" {
37+ return "This page"
38+ }
39+ u, err := url.Parse(origin)
40+ if err != nil || u.Host == "" {
41+ if err == nil && u.Scheme != "" {
42+ return truncateRunes(u.Scheme+":", jsDialogMaxHostChars)
43+ }
44+ return "This page"
45+ }
46+ return truncateRunes(u.Host, jsDialogMaxHostChars)
47+}
48+
49+func truncateRunes(s string, n int) string {
50+ r := []rune(s)
51+ if len(r) <= n {
52+ return s
53+ }
54+ return string(r[:n]) + "…"
55+}
56+
57+// buildJSDialogContent maps a request to the dialog texts and buttons.
58+// All strings are plain text (never Pango markup).
59+func buildJSDialogContent(req port.JSDialogRequest) jsDialogContent {
60+ says := jsDialogOriginLabel(req.Origin) + " says:"
61+ switch req.Type {
62+ case port.JSDialogConfirm:
63+ return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK", CancelLabel: "Cancel"}
64+ case port.JSDialogPrompt:
65+ return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK", CancelLabel: "Cancel", ShowInput: true}
66+ case port.JSDialogBeforeUnload:
67+ body := strings.TrimSpace(req.Message)
68+ if body == "" {
69+ body = "Changes you made may not be saved."
70+ }
71+ heading := "Leave page?"
72+ if req.IsReload {
73+ heading = "Reload page?"
74+ }
75+ return jsDialogContent{
76+ Heading: heading,
77+ Body: jsDialogOriginLabel(req.Origin) + "\n" + body,
78+ OKLabel: "Leave",
79+ CancelLabel: "Stay",
80+ }
81+ default:
82+ return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK"}
83+ }
84+}
85+
86+// JSDialogPopup is a per-pane overlay that renders alert/confirm/prompt and
87+// beforeunload dialogs. It is owned by a PaneView, so a dialog belongs to its
88+// pane's WebView and never blocks other panes. It replaces Adwaita AlertDialog
89+// for the same reason as PermissionPopup (purego ConnectResponse bug).
90+type JSDialogPopup struct {
91+ outerBox *gtk.Box
92+ mainBox *gtk.Box
93+
94+ headingLabel *gtk.Label
95+ bodyLabel *gtk.Label
96+ input *gtk.Entry
97+ btnOK *gtk.Button
98+ btnCancel *gtk.Button
99+
100+ mu sync.Mutex
101+ visible bool
102+ respond func(ok bool, input string)
103+ onHidden func()
104+
105+ retainedCallbacks []any
106+}
107+
108+// NewJSDialogPopup creates the popup (hidden). Returns nil if widget creation fails.
109+func NewJSDialogPopup() *JSDialogPopup {
110+ p := &JSDialogPopup{}
111+ if err := p.createWidgets(); err != nil {
112+ return nil
113+ }
114+ p.attachKeyController()
115+ return p
116+}
117+
118+// Widget returns the outer GTK widget for overlay registration.
119+func (p *JSDialogPopup) Widget() *gtk.Widget {
120+ if p == nil || p.outerBox == nil {
121+ return nil
122+ }
123+ return &p.outerBox.Widget
124+}
125+
126+// SetOnHidden sets a callback run after the popup closes (e.g. to restore focus).
127+func (p *JSDialogPopup) SetOnHidden(fn func()) {
128+ p.mu.Lock()
129+ p.onHidden = fn
130+ p.mu.Unlock()
131+}
132+
133+// Show displays the dialog. respond is invoked once with the user's answer.
134+// A dialog already visible is dismissed as canceled first.
135+func (p *JSDialogPopup) Show(req port.JSDialogRequest, respond func(ok bool, input string)) {
136+ p.dismiss(false)
137+
138+ content := buildJSDialogContent(req)
139+
140+ p.mu.Lock()
141+ p.visible = true
142+ p.respond = respond
143+ p.mu.Unlock()
144+
145+ p.headingLabel.SetText(content.Heading)
146+ p.bodyLabel.SetText(content.Body)
147+ p.bodyLabel.SetVisible(content.Body != "")
148+ p.btnOK.SetLabel(content.OKLabel)
149+ p.btnCancel.SetVisible(content.CancelLabel != "")
150+ if content.CancelLabel != "" {
151+ p.btnCancel.SetLabel(content.CancelLabel)
152+ }
153+ p.input.SetVisible(content.ShowInput)
154+ if content.ShowInput {
155+ p.input.SetText(req.DefaultPrompt)
156+ } else {
157+ p.input.SetText("")
158+ }
159+
160+ p.outerBox.SetVisible(true)
161+ if content.ShowInput {
162+ p.input.GrabFocus()
163+ p.input.SelectRegion(0, -1)
164+ } else {
165+ p.btnOK.GrabFocus()
166+ }
167+}
168+
169+// Hide closes the popup without invoking the response callback. It is used when
170+// the engine canceled the dialog (navigation, WebView destruction).
171+func (p *JSDialogPopup) Hide() {
172+ if p == nil {
173+ return
174+ }
175+ p.mu.Lock()
176+ wasVisible := p.visible
177+ p.visible = false
178+ p.respond = nil
179+ hidden := p.onHidden
180+ p.mu.Unlock()
181+ if !wasVisible {
182+ return
183+ }
184+ p.outerBox.SetVisible(false)
185+ if hidden != nil {
186+ hidden()
187+ }
188+}
189+
190+// IsVisible reports whether a dialog is displayed.
191+func (p *JSDialogPopup) IsVisible() bool {
192+ p.mu.Lock()
193+ defer p.mu.Unlock()
194+ return p.visible
195+}
196+
197+// dismiss closes the popup and answers. Safe to call when hidden (no-op).
198+func (p *JSDialogPopup) dismiss(ok bool) {
199+ p.mu.Lock()
200+ if !p.visible {
201+ p.mu.Unlock()
202+ return
203+ }
204+ p.visible = false
205+ respond := p.respond
206+ p.respond = nil
207+ hidden := p.onHidden
208+ p.mu.Unlock()
209+
210+ text := ""
211+ if ok && p.input.GetVisible() {
212+ text = p.input.GetText()
213+ }
214+ p.outerBox.SetVisible(false)
215+ if respond != nil {
216+ respond(ok, text)
217+ }
218+ if hidden != nil {
219+ hidden()
220+ }
221+}
222+
223+func (p *JSDialogPopup) createWidgets() error {
224+ if err := p.createContainers(); err != nil {
225+ return err
226+ }
227+ if err := p.createLabels(); err != nil {
228+ return err
229+ }
230+ scroll := gtk.NewScrolledWindow()
231+ if scroll == nil {
232+ return errNilWidget("jsDialogScroll")
233+ }
234+ scroll.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
235+ scroll.SetPropagateNaturalHeight(true)
236+ scroll.SetMaxContentHeight(jsDialogMaxBodyHeight)
237+ scroll.SetChild(&p.bodyLabel.Widget)
238+
239+ p.input = gtk.NewEntry()
240+ if p.input == nil {
241+ return errNilWidget("jsDialogEntry")
242+ }
243+ p.input.AddCssClass("jsdialog-entry")
244+ p.input.SetVisible(false)
245+
246+ btnRow, err := p.createButtonRow()
247+ if err != nil {
248+ return err
249+ }
250+
251+ p.mainBox.Append(&p.headingLabel.Widget)
252+ p.mainBox.Append(&scroll.Widget)
253+ p.mainBox.Append(&p.input.Widget)
254+ p.mainBox.Append(&btnRow.Widget)
255+ p.outerBox.Append(&p.mainBox.Widget)
256+ return nil
257+}
258+
259+func (p *JSDialogPopup) createContainers() error {
260+ p.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
261+ if p.outerBox == nil {
262+ return errNilWidget("jsDialogOuterBox")
263+ }
264+ // Fill the pane so the scrim swallows pointer input meant for the page.
265+ p.outerBox.AddCssClass("jsdialog-scrim")
266+ p.outerBox.SetHexpand(true)
267+ p.outerBox.SetVexpand(true)
268+ p.outerBox.SetHalign(gtk.AlignFillValue)
269+ p.outerBox.SetValign(gtk.AlignFillValue)
270+ p.outerBox.SetVisible(false)
271+
272+ p.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
273+ if p.mainBox == nil {
274+ return errNilWidget("jsDialogMainBox")
275+ }
276+ p.mainBox.AddCssClass("permission-popup-container")
277+ p.mainBox.SetHalign(gtk.AlignCenterValue)
278+ p.mainBox.SetValign(gtk.AlignCenterValue)
279+ p.mainBox.SetSizeRequest(jsDialogDialogWidth, -1)
280+ return nil
281+}
282+
283+func (p *JSDialogPopup) createLabels() error {
284+ empty := ""
285+ p.headingLabel = gtk.NewLabel(&empty)
286+ p.bodyLabel = gtk.NewLabel(&empty)
287+ if p.headingLabel == nil || p.bodyLabel == nil {
288+ return errNilWidget("jsDialogLabels")
289+ }
290+ // Labels use SetText only: page text is never parsed as Pango markup.
291+ for _, l := range []*gtk.Label{p.headingLabel, p.bodyLabel} {
292+ l.SetUseMarkup(false)
293+ l.SetHalign(gtk.AlignStartValue)
294+ l.SetXalign(0)
295+ l.SetWrap(true)
296+ l.SetWrapMode(pango.WrapWordCharValue)
297+ l.SetMaxWidthChars(jsDialogMaxWidthChars)
298+ }
299+ p.headingLabel.AddCssClass("permission-popup-heading")
300+ p.bodyLabel.AddCssClass("permission-popup-body")
301+ return nil
302+}
303+
304+func (p *JSDialogPopup) createButtonRow() (*gtk.Box, error) {
305+ btnRow := gtk.NewBox(gtk.OrientationHorizontalValue, buttonSpacing)
306+ if btnRow == nil {
307+ return nil, errNilWidget("jsDialogBtnRow")
308+ }
309+ btnRow.AddCssClass("permission-popup-btn-row")
310+ btnRow.SetHalign(gtk.AlignEndValue)
311+
312+ p.btnCancel = gtk.NewButtonWithLabel("Cancel")
313+ p.btnOK = gtk.NewButtonWithLabel("OK")
314+ if p.btnCancel == nil || p.btnOK == nil {
315+ return nil, errNilWidget("jsDialogButtons")
316+ }
317+ p.btnCancel.AddCssClass("permission-popup-btn")
318+ p.btnCancel.AddCssClass("permission-popup-btn-deny")
319+ p.btnOK.AddCssClass("permission-popup-btn")
320+ p.btnOK.AddCssClass("permission-popup-btn-allow")
321+
322+ cancelCb := func(_ gtk.Button) { p.dismiss(false) }
323+ okCb := func(_ gtk.Button) { p.dismiss(true) }
324+ p.retainedCallbacks = append(p.retainedCallbacks, cancelCb, okCb)
325+ p.btnCancel.ConnectClicked(&cancelCb)
326+ p.btnOK.ConnectClicked(&okCb)
327+
328+ btnRow.Append(&p.btnCancel.Widget)
329+ btnRow.Append(&p.btnOK.Widget)
330+ return btnRow, nil
331+}
332+
333+// attachKeyController maps Enter to OK and Escape to Cancel.
334+func (p *JSDialogPopup) attachKeyController() {
335+ controller := gtk.NewEventControllerKey()
336+ if controller == nil {
337+ return
338+ }
339+ controller.SetPropagationPhase(gtk.PhaseCaptureValue)
340+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
341+ switch keyval {
342+ case uint(gdk.KEY_Escape):
343+ p.dismiss(false)
344+ return true
345+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
346+ p.dismiss(true)
347+ return true
348+ }
349+ return false
350+ }
351+ p.retainedCallbacks = append(p.retainedCallbacks, keyPressedCb)
352+ controller.ConnectKeyPressed(&keyPressedCb)
353+ p.outerBox.AddController(&controller.EventController)
354+}
@@ -0,0 +1,354 @@
1+package component
2+
3+import (
4+ "net/url"
5+ "strings"
6+ "sync"
7+
8+ "github.com/bnema/puregotk/v4/gdk"
9+ "github.com/bnema/puregotk/v4/gtk"
10+ "github.com/bnema/puregotk/v4/pango"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+)
14+
15+const (
16+ jsDialogMaxBodyHeight = 240
17+ jsDialogMaxWidthChars = 60
18+ jsDialogMaxHostChars = 80
19+ jsDialogDialogWidth = 420
20+)
21+
22+// jsDialogContent is the plain-text content of a JS dialog.
23+type jsDialogContent struct {
24+ Heading string
25+ Body string
26+ OKLabel string
27+ CancelLabel string // empty: no cancel button (alert)
28+ ShowInput bool
29+}
30+
31+// jsDialogOriginLabel returns a short, user-facing origin ("example.com") for
32+// the requesting URL. The origin is shown separately from the page-controlled
33+// message so a page cannot impersonate browser UI.
34+func jsDialogOriginLabel(origin string) string {
35+ origin = strings.TrimSpace(origin)
36+ if origin == "" {
37+ return "This page"
38+ }
39+ u, err := url.Parse(origin)
40+ if err != nil || u.Host == "" {
41+ if err == nil && u.Scheme != "" {
42+ return truncateRunes(u.Scheme+":", jsDialogMaxHostChars)
43+ }
44+ return "This page"
45+ }
46+ return truncateRunes(u.Host, jsDialogMaxHostChars)
47+}
48+
49+func truncateRunes(s string, n int) string {
50+ r := []rune(s)
51+ if len(r) <= n {
52+ return s
53+ }
54+ return string(r[:n]) + "…"
55+}
56+
57+// buildJSDialogContent maps a request to the dialog texts and buttons.
58+// All strings are plain text (never Pango markup).
59+func buildJSDialogContent(req port.JSDialogRequest) jsDialogContent {
60+ says := jsDialogOriginLabel(req.Origin) + " says:"
61+ switch req.Type {
62+ case port.JSDialogConfirm:
63+ return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK", CancelLabel: "Cancel"}
64+ case port.JSDialogPrompt:
65+ return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK", CancelLabel: "Cancel", ShowInput: true}
66+ case port.JSDialogBeforeUnload:
67+ body := strings.TrimSpace(req.Message)
68+ if body == "" {
69+ body = "Changes you made may not be saved."
70+ }
71+ heading := "Leave page?"
72+ if req.IsReload {
73+ heading = "Reload page?"
74+ }
75+ return jsDialogContent{
76+ Heading: heading,
77+ Body: jsDialogOriginLabel(req.Origin) + "\n" + body,
78+ OKLabel: "Leave",
79+ CancelLabel: "Stay",
80+ }
81+ default:
82+ return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK"}
83+ }
84+}
85+
86+// JSDialogPopup is a per-pane overlay that renders alert/confirm/prompt and
87+// beforeunload dialogs. It is owned by a PaneView, so a dialog belongs to its
88+// pane's WebView and never blocks other panes. It replaces Adwaita AlertDialog
89+// for the same reason as PermissionPopup (purego ConnectResponse bug).
90+type JSDialogPopup struct {
91+ outerBox *gtk.Box
92+ mainBox *gtk.Box
93+
94+ headingLabel *gtk.Label
95+ bodyLabel *gtk.Label
96+ input *gtk.Entry
97+ btnOK *gtk.Button
98+ btnCancel *gtk.Button
99+
100+ mu sync.Mutex
101+ visible bool
102+ respond func(ok bool, input string)
103+ onHidden func()
104+
105+ retainedCallbacks []any
106+}
107+
108+// NewJSDialogPopup creates the popup (hidden). Returns nil if widget creation fails.
109+func NewJSDialogPopup() *JSDialogPopup {
110+ p := &JSDialogPopup{}
111+ if err := p.createWidgets(); err != nil {
112+ return nil
113+ }
114+ p.attachKeyController()
115+ return p
116+}
117+
118+// Widget returns the outer GTK widget for overlay registration.
119+func (p *JSDialogPopup) Widget() *gtk.Widget {
120+ if p == nil || p.outerBox == nil {
121+ return nil
122+ }
123+ return &p.outerBox.Widget
124+}
125+
126+// SetOnHidden sets a callback run after the popup closes (e.g. to restore focus).
127+func (p *JSDialogPopup) SetOnHidden(fn func()) {
128+ p.mu.Lock()
129+ p.onHidden = fn
130+ p.mu.Unlock()
131+}
132+
133+// Show displays the dialog. respond is invoked once with the user's answer.
134+// A dialog already visible is dismissed as canceled first.
135+func (p *JSDialogPopup) Show(req port.JSDialogRequest, respond func(ok bool, input string)) {
136+ p.dismiss(false)
137+
138+ content := buildJSDialogContent(req)
139+
140+ p.mu.Lock()
141+ p.visible = true
142+ p.respond = respond
143+ p.mu.Unlock()
144+
145+ p.headingLabel.SetText(content.Heading)
146+ p.bodyLabel.SetText(content.Body)
147+ p.bodyLabel.SetVisible(content.Body != "")
148+ p.btnOK.SetLabel(content.OKLabel)
149+ p.btnCancel.SetVisible(content.CancelLabel != "")
150+ if content.CancelLabel != "" {
151+ p.btnCancel.SetLabel(content.CancelLabel)
152+ }
153+ p.input.SetVisible(content.ShowInput)
154+ if content.ShowInput {
155+ p.input.SetText(req.DefaultPrompt)
156+ } else {
157+ p.input.SetText("")
158+ }
159+
160+ p.outerBox.SetVisible(true)
161+ if content.ShowInput {
162+ p.input.GrabFocus()
163+ p.input.SelectRegion(0, -1)
164+ } else {
165+ p.btnOK.GrabFocus()
166+ }
167+}
168+
169+// Hide closes the popup without invoking the response callback. It is used when
170+// the engine canceled the dialog (navigation, WebView destruction).
171+func (p *JSDialogPopup) Hide() {
172+ if p == nil {
173+ return
174+ }
175+ p.mu.Lock()
176+ wasVisible := p.visible
177+ p.visible = false
178+ p.respond = nil
179+ hidden := p.onHidden
180+ p.mu.Unlock()
181+ if !wasVisible {
182+ return
183+ }
184+ p.outerBox.SetVisible(false)
185+ if hidden != nil {
186+ hidden()
187+ }
188+}
189+
190+// IsVisible reports whether a dialog is displayed.
191+func (p *JSDialogPopup) IsVisible() bool {
192+ p.mu.Lock()
193+ defer p.mu.Unlock()
194+ return p.visible
195+}
196+
197+// dismiss closes the popup and answers. Safe to call when hidden (no-op).
198+func (p *JSDialogPopup) dismiss(ok bool) {
199+ p.mu.Lock()
200+ if !p.visible {
201+ p.mu.Unlock()
202+ return
203+ }
204+ p.visible = false
205+ respond := p.respond
206+ p.respond = nil
207+ hidden := p.onHidden
208+ p.mu.Unlock()
209+
210+ text := ""
211+ if ok && p.input.GetVisible() {
212+ text = p.input.GetText()
213+ }
214+ p.outerBox.SetVisible(false)
215+ if respond != nil {
216+ respond(ok, text)
217+ }
218+ if hidden != nil {
219+ hidden()
220+ }
221+}
222+
223+func (p *JSDialogPopup) createWidgets() error {
224+ if err := p.createContainers(); err != nil {
225+ return err
226+ }
227+ if err := p.createLabels(); err != nil {
228+ return err
229+ }
230+ scroll := gtk.NewScrolledWindow()
231+ if scroll == nil {
232+ return errNilWidget("jsDialogScroll")
233+ }
234+ scroll.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
235+ scroll.SetPropagateNaturalHeight(true)
236+ scroll.SetMaxContentHeight(jsDialogMaxBodyHeight)
237+ scroll.SetChild(&p.bodyLabel.Widget)
238+
239+ p.input = gtk.NewEntry()
240+ if p.input == nil {
241+ return errNilWidget("jsDialogEntry")
242+ }
243+ p.input.AddCssClass("jsdialog-entry")
244+ p.input.SetVisible(false)
245+
246+ btnRow, err := p.createButtonRow()
247+ if err != nil {
248+ return err
249+ }
250+
251+ p.mainBox.Append(&p.headingLabel.Widget)
252+ p.mainBox.Append(&scroll.Widget)
253+ p.mainBox.Append(&p.input.Widget)
254+ p.mainBox.Append(&btnRow.Widget)
255+ p.outerBox.Append(&p.mainBox.Widget)
256+ return nil
257+}
258+
259+func (p *JSDialogPopup) createContainers() error {
260+ p.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
261+ if p.outerBox == nil {
262+ return errNilWidget("jsDialogOuterBox")
263+ }
264+ // Fill the pane so the scrim swallows pointer input meant for the page.
265+ p.outerBox.AddCssClass("jsdialog-scrim")
266+ p.outerBox.SetHexpand(true)
267+ p.outerBox.SetVexpand(true)
268+ p.outerBox.SetHalign(gtk.AlignFillValue)
269+ p.outerBox.SetValign(gtk.AlignFillValue)
270+ p.outerBox.SetVisible(false)
271+
272+ p.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
273+ if p.mainBox == nil {
274+ return errNilWidget("jsDialogMainBox")
275+ }
276+ p.mainBox.AddCssClass("permission-popup-container")
277+ p.mainBox.SetHalign(gtk.AlignCenterValue)
278+ p.mainBox.SetValign(gtk.AlignCenterValue)
279+ p.mainBox.SetSizeRequest(jsDialogDialogWidth, -1)
280+ return nil
281+}
282+
283+func (p *JSDialogPopup) createLabels() error {
284+ empty := ""
285+ p.headingLabel = gtk.NewLabel(&empty)
286+ p.bodyLabel = gtk.NewLabel(&empty)
287+ if p.headingLabel == nil || p.bodyLabel == nil {
288+ return errNilWidget("jsDialogLabels")
289+ }
290+ // Labels use SetText only: page text is never parsed as Pango markup.
291+ for _, l := range []*gtk.Label{p.headingLabel, p.bodyLabel} {
292+ l.SetUseMarkup(false)
293+ l.SetHalign(gtk.AlignStartValue)
294+ l.SetXalign(0)
295+ l.SetWrap(true)
296+ l.SetWrapMode(pango.WrapWordCharValue)
297+ l.SetMaxWidthChars(jsDialogMaxWidthChars)
298+ }
299+ p.headingLabel.AddCssClass("permission-popup-heading")
300+ p.bodyLabel.AddCssClass("permission-popup-body")
301+ return nil
302+}
303+
304+func (p *JSDialogPopup) createButtonRow() (*gtk.Box, error) {
305+ btnRow := gtk.NewBox(gtk.OrientationHorizontalValue, buttonSpacing)
306+ if btnRow == nil {
307+ return nil, errNilWidget("jsDialogBtnRow")
308+ }
309+ btnRow.AddCssClass("permission-popup-btn-row")
310+ btnRow.SetHalign(gtk.AlignEndValue)
311+
312+ p.btnCancel = gtk.NewButtonWithLabel("Cancel")
313+ p.btnOK = gtk.NewButtonWithLabel("OK")
314+ if p.btnCancel == nil || p.btnOK == nil {
315+ return nil, errNilWidget("jsDialogButtons")
316+ }
317+ p.btnCancel.AddCssClass("permission-popup-btn")
318+ p.btnCancel.AddCssClass("permission-popup-btn-deny")
319+ p.btnOK.AddCssClass("permission-popup-btn")
320+ p.btnOK.AddCssClass("permission-popup-btn-allow")
321+
322+ cancelCb := func(_ gtk.Button) { p.dismiss(false) }
323+ okCb := func(_ gtk.Button) { p.dismiss(true) }
324+ p.retainedCallbacks = append(p.retainedCallbacks, cancelCb, okCb)
325+ p.btnCancel.ConnectClicked(&cancelCb)
326+ p.btnOK.ConnectClicked(&okCb)
327+
328+ btnRow.Append(&p.btnCancel.Widget)
329+ btnRow.Append(&p.btnOK.Widget)
330+ return btnRow, nil
331+}
332+
333+// attachKeyController maps Enter to OK and Escape to Cancel.
334+func (p *JSDialogPopup) attachKeyController() {
335+ controller := gtk.NewEventControllerKey()
336+ if controller == nil {
337+ return
338+ }
339+ controller.SetPropagationPhase(gtk.PhaseCaptureValue)
340+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
341+ switch keyval {
342+ case uint(gdk.KEY_Escape):
343+ p.dismiss(false)
344+ return true
345+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
346+ p.dismiss(true)
347+ return true
348+ }
349+ return false
350+ }
351+ p.retainedCallbacks = append(p.retainedCallbacks, keyPressedCb)
352+ controller.ConnectKeyPressed(&keyPressedCb)
353+ p.outerBox.AddController(&controller.EventController)
354+}
A internal/ui/component/jsdialog_popup_test.go +48 -0
@@ -0,0 +1,48 @@
1+package component
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+func TestJSDialogOriginLabel(t *testing.T) {
13+ assert.Equal(t, "example.com", jsDialogOriginLabel("https://example.com/a?b=c"))
14+ assert.Equal(t, "example.com:8080", jsDialogOriginLabel("http://example.com:8080/"))
15+ assert.Equal(t, "This page", jsDialogOriginLabel(""))
16+ assert.Equal(t, "This page", jsDialogOriginLabel("not a url"))
17+ assert.Equal(t, "file:", jsDialogOriginLabel("file:///tmp/x.html"))
18+ long := jsDialogOriginLabel("https://" + strings.Repeat("a", 500) + ".com/")
19+ assert.LessOrEqual(t, len([]rune(long)), jsDialogMaxHostChars+1)
20+}
21+
22+func TestBuildJSDialogContent(t *testing.T) {
23+ origin := "https://example.com/x"
24+
25+ alert := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogAlert, Origin: origin, Message: "<b>hi</b>"})
26+ assert.Equal(t, "example.com says:", alert.Heading)
27+ assert.Equal(t, "<b>hi</b>", alert.Body, "message must stay plain text")
28+ assert.Equal(t, "OK", alert.OKLabel)
29+ assert.Empty(t, alert.CancelLabel)
30+ assert.False(t, alert.ShowInput)
31+
32+ confirm := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogConfirm, Origin: origin})
33+ assert.Equal(t, "Cancel", confirm.CancelLabel)
34+ assert.False(t, confirm.ShowInput)
35+
36+ prompt := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogPrompt, Origin: origin})
37+ assert.True(t, prompt.ShowInput)
38+ assert.Equal(t, "Cancel", prompt.CancelLabel)
39+
40+ leave := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogBeforeUnload, Origin: origin})
41+ assert.Equal(t, "Leave page?", leave.Heading)
42+ assert.Equal(t, "Leave", leave.OKLabel)
43+ assert.Equal(t, "Stay", leave.CancelLabel)
44+ assert.Contains(t, leave.Body, "example.com")
45+
46+ reload := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogBeforeUnload, Origin: origin, IsReload: true, Message: "x"})
47+ assert.Equal(t, "Reload page?", reload.Heading)
48+}
@@ -0,0 +1,48 @@
1+package component
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+func TestJSDialogOriginLabel(t *testing.T) {
13+ assert.Equal(t, "example.com", jsDialogOriginLabel("https://example.com/a?b=c"))
14+ assert.Equal(t, "example.com:8080", jsDialogOriginLabel("http://example.com:8080/"))
15+ assert.Equal(t, "This page", jsDialogOriginLabel(""))
16+ assert.Equal(t, "This page", jsDialogOriginLabel("not a url"))
17+ assert.Equal(t, "file:", jsDialogOriginLabel("file:///tmp/x.html"))
18+ long := jsDialogOriginLabel("https://" + strings.Repeat("a", 500) + ".com/")
19+ assert.LessOrEqual(t, len([]rune(long)), jsDialogMaxHostChars+1)
20+}
21+
22+func TestBuildJSDialogContent(t *testing.T) {
23+ origin := "https://example.com/x"
24+
25+ alert := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogAlert, Origin: origin, Message: "<b>hi</b>"})
26+ assert.Equal(t, "example.com says:", alert.Heading)
27+ assert.Equal(t, "<b>hi</b>", alert.Body, "message must stay plain text")
28+ assert.Equal(t, "OK", alert.OKLabel)
29+ assert.Empty(t, alert.CancelLabel)
30+ assert.False(t, alert.ShowInput)
31+
32+ confirm := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogConfirm, Origin: origin})
33+ assert.Equal(t, "Cancel", confirm.CancelLabel)
34+ assert.False(t, confirm.ShowInput)
35+
36+ prompt := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogPrompt, Origin: origin})
37+ assert.True(t, prompt.ShowInput)
38+ assert.Equal(t, "Cancel", prompt.CancelLabel)
39+
40+ leave := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogBeforeUnload, Origin: origin})
41+ assert.Equal(t, "Leave page?", leave.Heading)
42+ assert.Equal(t, "Leave", leave.OKLabel)
43+ assert.Equal(t, "Stay", leave.CancelLabel)
44+ assert.Contains(t, leave.Body, "example.com")
45+
46+ reload := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogBeforeUnload, Origin: origin, IsReload: true, Message: "x"})
47+ assert.Equal(t, "Reload page?", reload.Heading)
48+}
M internal/ui/component/pane_view.go +61 -0
@@ -5,6 +5,7 @@ import (
55 "context"
66 "sync"
77
8+ "github.com/bnema/dumber/internal/application/port"
89 "github.com/bnema/dumber/internal/domain/entity"
910 "github.com/bnema/dumber/internal/logging"
1011 "github.com/bnema/dumber/internal/ui/input"
@@ -26,6 +27,8 @@ type PaneView struct {
2627 toaster *Toaster // Toast notification overlay
2728 linkStatus *LinkStatusOverlay // Link hover URL overlay
2829 loading *LoadingSkeleton // Placeholder shown until WebView paints
30+ jsDialog *JSDialogPopup // JS alert/confirm/prompt/beforeunload overlay
31+ jsDialogWidth layout.Widget // overlay wrapper of jsDialog, for removal
2932 paneID entity.PaneID
3033 isActive bool
3134
@@ -585,4 +588,62 @@ func (pv *PaneView) Cleanup() {
585588 pv.overlay.RemoveOverlay(pv.linkStatus.Widget())
586589 pv.linkStatus = nil
587590 }
591+ if pv.jsDialogWidth != nil {
592+ pv.overlay.RemoveOverlay(pv.jsDialogWidth)
593+ pv.jsDialogWidth = nil
594+ pv.jsDialog = nil
595+ }
596+}
597+
598+// ensureJSDialog creates the JS dialog popup lazily on first use.
599+// Must be called with write lock held.
600+func (pv *PaneView) ensureJSDialog() *JSDialogPopup {
601+ if pv.jsDialog != nil {
602+ return pv.jsDialog
603+ }
604+ popup := NewJSDialogPopup()
605+ if popup == nil {
606+ return nil
607+ }
608+ w := pv.factory.WrapWidget(popup.Widget())
609+ pv.overlay.AddOverlay(w)
610+ pv.overlay.SetClipOverlay(w, false)
611+ pv.overlay.SetMeasureOverlay(w, false)
612+ popup.SetOnHidden(func() {
613+ // Return keyboard focus to the page once the dialog is gone.
614+ pv.mu.RLock()
615+ wvw := pv.webViewWidget
616+ pv.mu.RUnlock()
617+ if wvw != nil {
618+ wvw.GrabFocus()
619+ }
620+ })
621+ pv.jsDialog = popup
622+ pv.jsDialogWidth = w
623+ return popup
624+}
625+
626+// ShowJSDialog displays a JavaScript dialog scoped to this pane. The dialog
627+// lives in the pane's overlay, so it never blocks other panes; if the pane is
628+// in a background tab it becomes visible when that tab is shown. respond is
629+// invoked once with the user's answer. Returns false if the popup is unavailable.
630+func (pv *PaneView) ShowJSDialog(req port.JSDialogRequest, respond func(ok bool, input string)) bool {
631+ pv.mu.Lock()
632+ popup := pv.ensureJSDialog()
633+ pv.mu.Unlock()
634+ if popup == nil {
635+ return false
636+ }
637+ popup.Show(req, respond)
638+ return true
639+}
640+
641+// HideJSDialog closes any JS dialog without answering it (engine-canceled).
642+func (pv *PaneView) HideJSDialog() {
643+ pv.mu.RLock()
644+ popup := pv.jsDialog
645+ pv.mu.RUnlock()
646+ if popup != nil {
647+ popup.Hide()
648+ }
588649 }
@@ -5,6 +5,7 @@ import (
5 "context"5 "context"
6 "sync"6 "sync"
7 7
8+ "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
10 "github.com/bnema/dumber/internal/ui/input"11 "github.com/bnema/dumber/internal/ui/input"
@@ -26,6 +27,8 @@ type PaneView struct {
26 toaster *Toaster // Toast notification overlay27 toaster *Toaster // Toast notification overlay
27 linkStatus *LinkStatusOverlay // Link hover URL overlay28 linkStatus *LinkStatusOverlay // Link hover URL overlay
28 loading *LoadingSkeleton // Placeholder shown until WebView paints29 loading *LoadingSkeleton // Placeholder shown until WebView paints
30+ jsDialog *JSDialogPopup // JS alert/confirm/prompt/beforeunload overlay
31+ jsDialogWidth layout.Widget // overlay wrapper of jsDialog, for removal
29 paneID entity.PaneID32 paneID entity.PaneID
30 isActive bool33 isActive bool
31 34
@@ -585,4 +588,62 @@ func (pv *PaneView) Cleanup() {
585 pv.overlay.RemoveOverlay(pv.linkStatus.Widget())588 pv.overlay.RemoveOverlay(pv.linkStatus.Widget())
586 pv.linkStatus = nil589 pv.linkStatus = nil
587 }590 }
591+ if pv.jsDialogWidth != nil {
592+ pv.overlay.RemoveOverlay(pv.jsDialogWidth)
593+ pv.jsDialogWidth = nil
594+ pv.jsDialog = nil
595+ }
596+}
597+
598+// ensureJSDialog creates the JS dialog popup lazily on first use.
599+// Must be called with write lock held.
600+func (pv *PaneView) ensureJSDialog() *JSDialogPopup {
601+ if pv.jsDialog != nil {
602+ return pv.jsDialog
603+ }
604+ popup := NewJSDialogPopup()
605+ if popup == nil {
606+ return nil
607+ }
608+ w := pv.factory.WrapWidget(popup.Widget())
609+ pv.overlay.AddOverlay(w)
610+ pv.overlay.SetClipOverlay(w, false)
611+ pv.overlay.SetMeasureOverlay(w, false)
612+ popup.SetOnHidden(func() {
613+ // Return keyboard focus to the page once the dialog is gone.
614+ pv.mu.RLock()
615+ wvw := pv.webViewWidget
616+ pv.mu.RUnlock()
617+ if wvw != nil {
618+ wvw.GrabFocus()
619+ }
620+ })
621+ pv.jsDialog = popup
622+ pv.jsDialogWidth = w
623+ return popup
624+}
625+
626+// ShowJSDialog displays a JavaScript dialog scoped to this pane. The dialog
627+// lives in the pane's overlay, so it never blocks other panes; if the pane is
628+// in a background tab it becomes visible when that tab is shown. respond is
629+// invoked once with the user's answer. Returns false if the popup is unavailable.
630+func (pv *PaneView) ShowJSDialog(req port.JSDialogRequest, respond func(ok bool, input string)) bool {
631+ pv.mu.Lock()
632+ popup := pv.ensureJSDialog()
633+ pv.mu.Unlock()
634+ if popup == nil {
635+ return false
636+ }
637+ popup.Show(req, respond)
638+ return true
639+}
640+
641+// HideJSDialog closes any JS dialog without answering it (engine-canceled).
642+func (pv *PaneView) HideJSDialog() {
643+ pv.mu.RLock()
644+ popup := pv.jsDialog
645+ pv.mu.RUnlock()
646+ if popup != nil {
647+ popup.Hide()
648+ }
588 }649 }
M internal/ui/coordinator/content/callbacks.go +36 -0
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/dumber/internal/application/usecase"
1010 "github.com/bnema/dumber/internal/domain/entity"
1111 "github.com/bnema/dumber/internal/logging"
12+ "github.com/bnema/dumber/internal/ui/component"
1213 )
1314
1415 // internalSchemePath is the host used in dumb:// crash-page URIs.
@@ -131,6 +132,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
131132 },
132133 }
133134
135+ c.setupJSDialogCallbacks(paneID, callbacks)
134136 c.setupFaviconCallbacks(ctx, paneID, wv, callbacks)
135137
136138 // Middle-click link handler
@@ -286,3 +288,37 @@ func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.Permiss
286288 }
287289 return filtered
288290 }
291+
292+// setupJSDialogCallbacks routes JavaScript dialogs to the owning pane's overlay.
293+func (c *Coordinator) setupJSDialogCallbacks(paneID entity.PaneID, callbacks *port.WebViewCallbacks) {
294+ callbacks.OnJSDialog = func(req port.JSDialogRequest, respond func(ok bool, input string)) bool {
295+ pv := c.paneViewForJSDialog(paneID)
296+ if pv == nil {
297+ return false
298+ }
299+ return pv.ShowJSDialog(req, respond)
300+ }
301+ callbacks.OnJSDialogReset = func() {
302+ if pv := c.paneViewForJSDialog(paneID); pv != nil {
303+ pv.HideJSDialog()
304+ }
305+ }
306+}
307+
308+// paneViewForJSDialog resolves the PaneView that owns paneID's WebView. The
309+// dialog is shown inside that pane's overlay even if the pane is in a
310+// background tab (it appears when the tab is shown) and never blocks others.
311+func (c *Coordinator) paneViewForJSDialog(paneID entity.PaneID) *component.PaneView {
312+ if c.paneViewResolver != nil {
313+ if pv := c.paneViewResolver(paneID); pv != nil {
314+ return pv
315+ }
316+ }
317+ if c.getActiveWS == nil {
318+ return nil
319+ }
320+ if _, wsView := c.getActiveWS(); wsView != nil {
321+ return wsView.GetPaneView(paneID)
322+ }
323+ return nil
324+}
@@ -9,6 +9,7 @@ import (
9 "github.com/bnema/dumber/internal/application/usecase"9 "github.com/bnema/dumber/internal/application/usecase"
10 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
11 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
12+ "github.com/bnema/dumber/internal/ui/component"
12 )13 )
13 14
14 // internalSchemePath is the host used in dumb:// crash-page URIs.15 // internalSchemePath is the host used in dumb:// crash-page URIs.
@@ -131,6 +132,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
131 },132 },
132 }133 }
133 134
135+ c.setupJSDialogCallbacks(paneID, callbacks)
134 c.setupFaviconCallbacks(ctx, paneID, wv, callbacks)136 c.setupFaviconCallbacks(ctx, paneID, wv, callbacks)
135 137
136 // Middle-click link handler138 // Middle-click link handler
@@ -286,3 +288,37 @@ func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.Permiss
286 }288 }
287 return filtered289 return filtered
288 }290 }
291+
292+// setupJSDialogCallbacks routes JavaScript dialogs to the owning pane's overlay.
293+func (c *Coordinator) setupJSDialogCallbacks(paneID entity.PaneID, callbacks *port.WebViewCallbacks) {
294+ callbacks.OnJSDialog = func(req port.JSDialogRequest, respond func(ok bool, input string)) bool {
295+ pv := c.paneViewForJSDialog(paneID)
296+ if pv == nil {
297+ return false
298+ }
299+ return pv.ShowJSDialog(req, respond)
300+ }
301+ callbacks.OnJSDialogReset = func() {
302+ if pv := c.paneViewForJSDialog(paneID); pv != nil {
303+ pv.HideJSDialog()
304+ }
305+ }
306+}
307+
308+// paneViewForJSDialog resolves the PaneView that owns paneID's WebView. The
309+// dialog is shown inside that pane's overlay even if the pane is in a
310+// background tab (it appears when the tab is shown) and never blocks others.
311+func (c *Coordinator) paneViewForJSDialog(paneID entity.PaneID) *component.PaneView {
312+ if c.paneViewResolver != nil {
313+ if pv := c.paneViewResolver(paneID); pv != nil {
314+ return pv
315+ }
316+ }
317+ if c.getActiveWS == nil {
318+ return nil
319+ }
320+ if _, wsView := c.getActiveWS(); wsView != nil {
321+ return wsView.GetPaneView(paneID)
322+ }
323+ return nil
324+}
M internal/ui/coordinator/content/coordinator.go +9 -0
@@ -46,6 +46,10 @@ type Coordinator struct {
4646 // Callback to get active workspace state (avoids circular dependency)
4747 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
4848
49+ // Resolves a pane's view across all tabs/windows (JS dialogs can come from
50+ // background panes). Falls back to the active workspace view when nil.
51+ paneViewResolver func(paneID entity.PaneID) *component.PaneView
52+
4953 // Callback when title changes (for history persistence)
5054 onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)
5155
@@ -246,6 +250,11 @@ func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv port.
246250 c.onWebViewFocused = fn
247251 }
248252
253+// SetPaneViewResolver sets a resolver that finds a pane's view in any tab/window.
254+func (c *Coordinator) SetPaneViewResolver(fn func(paneID entity.PaneID) *component.PaneView) {
255+ c.paneViewResolver = fn
256+}
257+
249258 // SetOnEditableFocusChanged sets the callback for page editable focus changes.
250259 func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) {
251260 c.onEditableFocusChanged = fn
@@ -46,6 +46,10 @@ type Coordinator struct {
46 // Callback to get active workspace state (avoids circular dependency)46 // Callback to get active workspace state (avoids circular dependency)
47 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)47 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
48 48
49+ // Resolves a pane's view across all tabs/windows (JS dialogs can come from
50+ // background panes). Falls back to the active workspace view when nil.
51+ paneViewResolver func(paneID entity.PaneID) *component.PaneView
52+
49 // Callback when title changes (for history persistence)53 // Callback when title changes (for history persistence)
50 onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)54 onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)
51 55
@@ -246,6 +250,11 @@ func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv port.
246 c.onWebViewFocused = fn250 c.onWebViewFocused = fn
247 }251 }
248 252
253+// SetPaneViewResolver sets a resolver that finds a pane's view in any tab/window.
254+func (c *Coordinator) SetPaneViewResolver(fn func(paneID entity.PaneID) *component.PaneView) {
255+ c.paneViewResolver = fn
256+}
257+
249 // SetOnEditableFocusChanged sets the callback for page editable focus changes.258 // SetOnEditableFocusChanged sets the callback for page editable focus changes.
250 func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) {259 func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) {
251 c.onEditableFocusChanged = fn260 c.onEditableFocusChanged = fn
M internal/ui/theme/permission_popup_css.go +10 -0
@@ -37,6 +37,16 @@ func generatePermissionPopupCSS(p Palette) string {
3737 padding: 0.5em 0.75em;
3838 }
3939
40+/* JS dialog (alert/confirm/prompt/beforeunload): per-pane scrim + prompt entry */
41+.jsdialog-scrim {
42+ background-color: alpha(black, 0.35);
43+}
44+
45+.jsdialog-entry {
46+ margin: 0 1em 0.75em 1em;
47+ font-size: 0.8125em;
48+}
49+
4050 /* Shared button base */
4151 .permission-popup-btn {
4252 background-image: none;
@@ -37,6 +37,16 @@ func generatePermissionPopupCSS(p Palette) string {
37 padding: 0.5em 0.75em;37 padding: 0.5em 0.75em;
38 }38 }
39 39
40+/* JS dialog (alert/confirm/prompt/beforeunload): per-pane scrim + prompt entry */
41+.jsdialog-scrim {
42+ background-color: alpha(black, 0.35);
43+}
44+
45+.jsdialog-entry {
46+ margin: 0 1em 0.75em 1em;
47+ font-size: 0.8125em;
48+}
49+
40 /* Shared button base */50 /* Shared button base */
41 .permission-popup-btn {51 .permission-popup-btn {
42 background-image: none;52 background-image: none;

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