detached 1982d1a

fix(ui): harden JS dialog popup lifecycle, focus and sizing

Answer an open dialog when its PaneView is cleaned up, only take focus in the
active pane (and restore it only if the dialog had it), let focused buttons
handle Enter themselves, truncate the origin from the left, cap page text,
clip and size the overlay with the modal helpers, and disconnect handlers on
Destroy. Resolve panes via the app resolver only.
commit
1982d1a1ca8fcf5fe939b5ae42e27740567c637a
author
brice · · 2026-10-03 07:02 UTC
committer
brice · · 2026-10-03 07:50 UTC
parent
26ad11f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +275 -114
M internal/ui/app.go +5 -7
@@ -3426,18 +3426,16 @@ func (a *App) initCoordinators(ctx context.Context) {
34263426
34273427 // JS dialogs are shown in the owning pane's overlay, wherever that pane lives.
34283428 a.contentCoord.SetPaneViewResolver(a.paneViewForPane)
3429+ a.contentCoord.SetUIScaleProvider(func() float64 {
3430+ return a.runtimeConfigSnapshot().UI.DefaultUIScale
3431+ })
34293432
34303433 // Hide loading skeleton once the WebView paints
34313434 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
34323435 // The WebView can be shown while its pane is in a background tab, so scan
34333436 // all WorkspaceViews rather than only the active one.
3434- for _, wsView := range a.workspaceViews {
3435- if wsView == nil {
3436- continue
3437- }
3438- if pv := wsView.GetPaneView(paneID); pv != nil {
3439- pv.HideLoadingSkeleton()
3440- }
3437+ if pv := a.paneViewForPane(paneID); pv != nil {
3438+ pv.HideLoadingSkeleton()
34413439 }
34423440 if a.deps != nil && a.deps.OnFirstWebViewShown != nil {
34433441 a.firstWebViewShownOnce.Do(func() {
@@ -3426,18 +3426,16 @@ func (a *App) initCoordinators(ctx context.Context) {
3426 3426
3427 // JS dialogs are shown in the owning pane's overlay, wherever that pane lives.3427 // JS dialogs are shown in the owning pane's overlay, wherever that pane lives.
3428 a.contentCoord.SetPaneViewResolver(a.paneViewForPane)3428 a.contentCoord.SetPaneViewResolver(a.paneViewForPane)
3429+ a.contentCoord.SetUIScaleProvider(func() float64 {
3430+ return a.runtimeConfigSnapshot().UI.DefaultUIScale
3431+ })
3429 3432
3430 // Hide loading skeleton once the WebView paints3433 // Hide loading skeleton once the WebView paints
3431 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {3434 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
3432 // The WebView can be shown while its pane is in a background tab, so scan3435 // The WebView can be shown while its pane is in a background tab, so scan
3433 // all WorkspaceViews rather than only the active one.3436 // all WorkspaceViews rather than only the active one.
3434- for _, wsView := range a.workspaceViews {3437+ if pv := a.paneViewForPane(paneID); pv != nil {
3435- if wsView == nil {3438+ pv.HideLoadingSkeleton()
3436- continue
3437- }
3438- if pv := wsView.GetPaneView(paneID); pv != nil {
3439- pv.HideLoadingSkeleton()
3440- }
3441 }3439 }
3442 if a.deps != nil && a.deps.OnFirstWebViewShown != nil {3440 if a.deps != nil && a.deps.OnFirstWebViewShown != nil {
3443 a.firstWebViewShownOnce.Do(func() {3441 a.firstWebViewShownOnce.Do(func() {
M internal/ui/component/jsdialog_popup.go +166 -62
@@ -10,15 +10,30 @@ import (
1010 "github.com/bnema/puregotk/v4/pango"
1111
1212 "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/ui/layout"
1314 )
1415
1516 const (
1617 jsDialogMaxBodyHeight = 240
1718 jsDialogMaxWidthChars = 60
1819 jsDialogMaxHostChars = 80
19- jsDialogDialogWidth = 420
20+ // jsDialogMaxTextRunes caps page-controlled text (message, prompt default)
21+ // so a hostile page can't make GTK lay out megabytes of text.
22+ jsDialogMaxTextRunes = 3000
23+ // jsDialogBeforeUnloadText is Dumber's own beforeunload text: CEF always
24+ // passes a fixed string, and browsers no longer show page text here.
25+ jsDialogBeforeUnloadText = "Changes you made may not be saved."
2026 )
2127
28+// JSDialogSizeDefaults sizes the dialog box relative to its pane.
29+var JSDialogSizeDefaults = ModalSizeConfig{
30+ WidthPct: 0.9,
31+ MaxWidth: 420,
32+ TopMarginPct: 0.3,
33+ FallbackWidth: 420,
34+ FallbackHeight: 300,
35+}
36+
2237 // jsDialogContent is the plain-text content of a JS dialog.
2338 type jsDialogContent struct {
2439 Heading string
@@ -39,11 +54,21 @@ func jsDialogOriginLabel(origin string) string {
3954 u, err := url.Parse(origin)
4055 if err != nil || u.Host == "" {
4156 if err == nil && u.Scheme != "" {
42- return truncateRunes(u.Scheme+":", jsDialogMaxHostChars)
57+ return truncateRunesLeft(u.Scheme+":", jsDialogMaxHostChars)
4358 }
4459 return "This page"
4560 }
46- return truncateRunes(u.Host, jsDialogMaxHostChars)
61+ // Keep the end of the host: the registrable domain is what identifies it.
62+ return truncateRunesLeft(u.Host, jsDialogMaxHostChars)
63+}
64+
65+// truncateRunesLeft keeps the last n runes, prefixing "…" when it cut.
66+func truncateRunesLeft(s string, n int) string {
67+ r := []rune(s)
68+ if len(r) <= n {
69+ return s
70+ }
71+ return "…" + string(r[len(r)-n:])
4772 }
4873
4974 func truncateRunes(s string, n int) string {
@@ -58,16 +83,14 @@ func truncateRunes(s string, n int) string {
5883 // All strings are plain text (never Pango markup).
5984 func buildJSDialogContent(req port.JSDialogRequest) jsDialogContent {
6085 says := jsDialogOriginLabel(req.Origin) + " says:"
86+ msg := truncateRunes(req.Message, jsDialogMaxTextRunes)
6187 switch req.Type {
6288 case port.JSDialogConfirm:
63- return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK", CancelLabel: "Cancel"}
89+ return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK", CancelLabel: "Cancel"}
6490 case port.JSDialogPrompt:
65- return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK", CancelLabel: "Cancel", ShowInput: true}
91+ return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK", CancelLabel: "Cancel", ShowInput: true}
6692 case port.JSDialogBeforeUnload:
67- body := strings.TrimSpace(req.Message)
68- if body == "" {
69- body = "Changes you made may not be saved."
70- }
93+ body := jsDialogBeforeUnloadText
7194 heading := "Leave page?"
7295 if req.IsReload {
7396 heading = "Reload page?"
@@ -79,7 +102,7 @@ func buildJSDialogContent(req port.JSDialogRequest) jsDialogContent {
79102 CancelLabel: "Stay",
80103 }
81104 default:
82- return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK"}
105+ return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK"}
83106 }
84107 }
85108
@@ -97,17 +120,30 @@ type JSDialogPopup struct {
97120 btnOK *gtk.Button
98121 btnCancel *gtk.Button
99122
100- mu sync.Mutex
101- visible bool
102- respond func(ok bool, input string)
103- onHidden func()
123+ parent layout.OverlayWidget
124+ controller *gtk.EventControllerKey
125+ signals []popupSignal
126+
127+ mu sync.Mutex
128+ visible bool
129+ wantFocus bool // this dialog should own keyboard focus (pane is active)
130+ destroyed bool
131+ respond func(ok bool, input string)
132+ onHidden func(hadFocus bool)
104133
105134 retainedCallbacks []any
106135 }
107136
137+// popupSignal remembers a connected handler so Destroy can disconnect it.
138+type popupSignal struct {
139+ obj interface{ DisconnectSignal(uint) }
140+ id uint
141+}
142+
108143 // NewJSDialogPopup creates the popup (hidden). Returns nil if widget creation fails.
109-func NewJSDialogPopup() *JSDialogPopup {
110- p := &JSDialogPopup{}
144+// parent is the pane overlay used to size the dialog.
145+func NewJSDialogPopup(parent layout.OverlayWidget) *JSDialogPopup {
146+ p := &JSDialogPopup{parent: parent}
111147 if err := p.createWidgets(); err != nil {
112148 return nil
113149 }
@@ -123,25 +159,40 @@ func (p *JSDialogPopup) Widget() *gtk.Widget {
123159 return &p.outerBox.Widget
124160 }
125161
126-// SetOnHidden sets a callback run after the popup closes (e.g. to restore focus).
127-func (p *JSDialogPopup) SetOnHidden(fn func()) {
162+// SetOnHidden sets (or with nil clears) a callback run after the popup closes. hadFocus reports
163+// whether keyboard focus was inside the dialog when it closed, so the owner
164+// only restores focus when the dialog had taken it.
165+func (p *JSDialogPopup) SetOnHidden(fn func(hadFocus bool)) {
128166 p.mu.Lock()
129167 p.onHidden = fn
130168 p.mu.Unlock()
131169 }
132170
133171 // 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)
172+// A dialog already visible is dismissed as canceled first. Keyboard focus is
173+// only taken when focus is true (the pane is active); otherwise RequestFocus
174+// grabs it when the pane becomes active.
175+func (p *JSDialogPopup) Show(req port.JSDialogRequest, respond func(ok bool, input string), focus bool, uiScale float64) {
176+ p.Dismiss(false)
137177
138178 content := buildJSDialogContent(req)
139179
140180 p.mu.Lock()
181+ if p.destroyed {
182+ p.mu.Unlock()
183+ if respond != nil {
184+ respond(false, "")
185+ }
186+ return
187+ }
141188 p.visible = true
189+ p.wantFocus = focus
142190 p.respond = respond
143191 p.mu.Unlock()
144192
193+ width, _ := CalculateModalDimensionsWithScale(p.parent, JSDialogSizeDefaults, uiScale)
194+ p.mainBox.SetSizeRequest(width, -1)
195+
145196 p.headingLabel.SetText(content.Heading)
146197 p.bodyLabel.SetText(content.Body)
147198 p.bodyLabel.SetVisible(content.Body != "")
@@ -152,18 +203,38 @@ func (p *JSDialogPopup) Show(req port.JSDialogRequest, respond func(ok bool, inp
152203 }
153204 p.input.SetVisible(content.ShowInput)
154205 if content.ShowInput {
155- p.input.SetText(req.DefaultPrompt)
206+ p.input.SetText(truncateRunes(req.DefaultPrompt, jsDialogMaxTextRunes))
156207 } else {
157208 p.input.SetText("")
158209 }
159210
160211 p.outerBox.SetVisible(true)
161- if content.ShowInput {
212+ if focus {
213+ p.grabDefaultFocus()
214+ }
215+}
216+
217+// RequestFocus marks the dialog as the focus owner and grabs focus if shown.
218+// Called when the owning pane becomes active.
219+func (p *JSDialogPopup) RequestFocus() {
220+ p.mu.Lock()
221+ if !p.visible || p.destroyed {
222+ p.mu.Unlock()
223+ return
224+ }
225+ p.wantFocus = true
226+ p.mu.Unlock()
227+ p.grabDefaultFocus()
228+}
229+
230+// grabDefaultFocus focuses the entry (prompt) or the OK button.
231+func (p *JSDialogPopup) grabDefaultFocus() {
232+ if p.input.GetVisible() {
162233 p.input.GrabFocus()
163234 p.input.SelectRegion(0, -1)
164- } else {
165- p.btnOK.GrabFocus()
235+ return
166236 }
237+ p.btnOK.GrabFocus()
167238 }
168239
169240 // Hide closes the popup without invoking the response callback. It is used when
@@ -172,52 +243,68 @@ func (p *JSDialogPopup) Hide() {
172243 if p == nil {
173244 return
174245 }
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- }
246+ p.close(nil)
188247 }
189248
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
249+// Dismiss closes the popup and answers with ok. No-op when hidden.
250+func (p *JSDialogPopup) Dismiss(ok bool) {
251+ p.close(&ok)
195252 }
196253
197-// dismiss closes the popup and answers. Safe to call when hidden (no-op).
198-func (p *JSDialogPopup) dismiss(ok bool) {
254+// close hides the popup. When answer is non-nil the response callback is run.
255+func (p *JSDialogPopup) close(answer *bool) {
199256 p.mu.Lock()
200257 if !p.visible {
201258 p.mu.Unlock()
202259 return
203260 }
204261 p.visible = false
262+ p.wantFocus = false
205263 respond := p.respond
206264 p.respond = nil
207265 hidden := p.onHidden
208266 p.mu.Unlock()
209267
268+ hadFocus := p.outerBox.GetFocusChild() != nil
210269 text := ""
211- if ok && p.input.GetVisible() {
270+ if answer != nil && *answer && p.input.GetVisible() {
212271 text = p.input.GetText()
213272 }
214273 p.outerBox.SetVisible(false)
215- if respond != nil {
216- respond(ok, text)
274+ if answer != nil && respond != nil {
275+ respond(*answer, text)
217276 }
218277 if hidden != nil {
219- hidden()
278+ hidden(hadFocus)
279+ }
280+}
281+
282+// Destroy disconnects every handler and drops callbacks. The popup must not be
283+// used afterwards. Call after the dialog was dismissed.
284+func (p *JSDialogPopup) Destroy() {
285+ if p == nil {
286+ return
220287 }
288+ p.mu.Lock()
289+ if p.destroyed {
290+ p.mu.Unlock()
291+ return
292+ }
293+ p.destroyed = true
294+ p.visible = false
295+ p.respond = nil
296+ p.onHidden = nil
297+ p.mu.Unlock()
298+
299+ for _, s := range p.signals {
300+ s.obj.DisconnectSignal(s.id)
301+ }
302+ p.signals = nil
303+ if p.controller != nil && p.outerBox != nil {
304+ p.outerBox.RemoveController(&p.controller.EventController)
305+ p.controller = nil
306+ }
307+ p.retainedCallbacks = nil
221308 }
222309
223310 func (p *JSDialogPopup) createWidgets() error {
@@ -241,7 +328,13 @@ func (p *JSDialogPopup) createWidgets() error {
241328 return errNilWidget("jsDialogEntry")
242329 }
243330 p.input.AddCssClass("jsdialog-entry")
331+ p.input.SetMaxLength(jsDialogMaxTextRunes)
244332 p.input.SetVisible(false)
333+ // Enter in the entry = OK. Focused buttons activate themselves, so Enter on
334+ // Cancel/Stay still cancels.
335+ activateCb := func(gtk.Entry) { p.Dismiss(true) }
336+ p.retainedCallbacks = append(p.retainedCallbacks, activateCb)
337+ p.signals = append(p.signals, popupSignal{p.input, p.input.ConnectActivate(&activateCb)})
245338
246339 btnRow, err := p.createButtonRow()
247340 if err != nil {
@@ -276,7 +369,6 @@ func (p *JSDialogPopup) createContainers() error {
276369 p.mainBox.AddCssClass("permission-popup-container")
277370 p.mainBox.SetHalign(gtk.AlignCenterValue)
278371 p.mainBox.SetValign(gtk.AlignCenterValue)
279- p.mainBox.SetSizeRequest(jsDialogDialogWidth, -1)
280372 return nil
281373 }
282374
@@ -319,18 +411,21 @@ func (p *JSDialogPopup) createButtonRow() (*gtk.Box, error) {
319411 p.btnOK.AddCssClass("permission-popup-btn")
320412 p.btnOK.AddCssClass("permission-popup-btn-allow")
321413
322- cancelCb := func(_ gtk.Button) { p.dismiss(false) }
323- okCb := func(_ gtk.Button) { p.dismiss(true) }
414+ cancelCb := func(_ gtk.Button) { p.Dismiss(false) }
415+ okCb := func(_ gtk.Button) { p.Dismiss(true) }
324416 p.retainedCallbacks = append(p.retainedCallbacks, cancelCb, okCb)
325- p.btnCancel.ConnectClicked(&cancelCb)
326- p.btnOK.ConnectClicked(&okCb)
417+ p.signals = append(p.signals,
418+ popupSignal{p.btnCancel, p.btnCancel.ConnectClicked(&cancelCb)},
419+ popupSignal{p.btnOK, p.btnOK.ConnectClicked(&okCb)},
420+ )
327421
328422 btnRow.Append(&p.btnCancel.Widget)
329423 btnRow.Append(&p.btnOK.Widget)
330424 return btnRow, nil
331425 }
332426
333-// attachKeyController maps Enter to OK and Escape to Cancel.
427+// attachKeyController maps Escape to Cancel (capture phase, so the page never
428+// sees it). Enter is not handled here: see the entry's activate handler.
334429 func (p *JSDialogPopup) attachKeyController() {
335430 controller := gtk.NewEventControllerKey()
336431 if controller == nil {
@@ -338,17 +433,26 @@ func (p *JSDialogPopup) attachKeyController() {
338433 }
339434 controller.SetPropagationPhase(gtk.PhaseCaptureValue)
340435 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)
436+ if keyval == uint(gdk.KEY_Escape) {
437+ p.Dismiss(false)
347438 return true
348439 }
349440 return false
350441 }
351442 p.retainedCallbacks = append(p.retainedCallbacks, keyPressedCb)
352- controller.ConnectKeyPressed(&keyPressedCb)
443+ p.signals = append(p.signals, popupSignal{controller, controller.ConnectKeyPressed(&keyPressedCb)})
353444 p.outerBox.AddController(&controller.EventController)
445+ p.controller = controller
446+
447+ // A dialog shown while its tab was in the background gets focus once mapped.
448+ mapCb := func(gtk.Widget) {
449+ p.mu.Lock()
450+ want := p.visible && p.wantFocus
451+ p.mu.Unlock()
452+ if want {
453+ p.grabDefaultFocus()
454+ }
455+ }
456+ p.retainedCallbacks = append(p.retainedCallbacks, mapCb)
457+ p.signals = append(p.signals, popupSignal{&p.outerBox.Widget, p.outerBox.ConnectMap(&mapCb)})
354458 }
@@ -10,15 +10,30 @@ import (
10 "github.com/bnema/puregotk/v4/pango"10 "github.com/bnema/puregotk/v4/pango"
11 11
12 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/ui/layout"
13 )14 )
14 15
15 const (16 const (
16 jsDialogMaxBodyHeight = 24017 jsDialogMaxBodyHeight = 240
17 jsDialogMaxWidthChars = 6018 jsDialogMaxWidthChars = 60
18 jsDialogMaxHostChars = 8019 jsDialogMaxHostChars = 80
19- jsDialogDialogWidth = 42020+ // jsDialogMaxTextRunes caps page-controlled text (message, prompt default)
21+ // so a hostile page can't make GTK lay out megabytes of text.
22+ jsDialogMaxTextRunes = 3000
23+ // jsDialogBeforeUnloadText is Dumber's own beforeunload text: CEF always
24+ // passes a fixed string, and browsers no longer show page text here.
25+ jsDialogBeforeUnloadText = "Changes you made may not be saved."
20 )26 )
21 27
28+// JSDialogSizeDefaults sizes the dialog box relative to its pane.
29+var JSDialogSizeDefaults = ModalSizeConfig{
30+ WidthPct: 0.9,
31+ MaxWidth: 420,
32+ TopMarginPct: 0.3,
33+ FallbackWidth: 420,
34+ FallbackHeight: 300,
35+}
36+
22 // jsDialogContent is the plain-text content of a JS dialog.37 // jsDialogContent is the plain-text content of a JS dialog.
23 type jsDialogContent struct {38 type jsDialogContent struct {
24 Heading string39 Heading string
@@ -39,11 +54,21 @@ func jsDialogOriginLabel(origin string) string {
39 u, err := url.Parse(origin)54 u, err := url.Parse(origin)
40 if err != nil || u.Host == "" {55 if err != nil || u.Host == "" {
41 if err == nil && u.Scheme != "" {56 if err == nil && u.Scheme != "" {
42- return truncateRunes(u.Scheme+":", jsDialogMaxHostChars)57+ return truncateRunesLeft(u.Scheme+":", jsDialogMaxHostChars)
43 }58 }
44 return "This page"59 return "This page"
45 }60 }
46- return truncateRunes(u.Host, jsDialogMaxHostChars)61+ // Keep the end of the host: the registrable domain is what identifies it.
62+ return truncateRunesLeft(u.Host, jsDialogMaxHostChars)
63+}
64+
65+// truncateRunesLeft keeps the last n runes, prefixing "…" when it cut.
66+func truncateRunesLeft(s string, n int) string {
67+ r := []rune(s)
68+ if len(r) <= n {
69+ return s
70+ }
71+ return "…" + string(r[len(r)-n:])
47 }72 }
48 73
49 func truncateRunes(s string, n int) string {74 func truncateRunes(s string, n int) string {
@@ -58,16 +83,14 @@ func truncateRunes(s string, n int) string {
58 // All strings are plain text (never Pango markup).83 // All strings are plain text (never Pango markup).
59 func buildJSDialogContent(req port.JSDialogRequest) jsDialogContent {84 func buildJSDialogContent(req port.JSDialogRequest) jsDialogContent {
60 says := jsDialogOriginLabel(req.Origin) + " says:"85 says := jsDialogOriginLabel(req.Origin) + " says:"
86+ msg := truncateRunes(req.Message, jsDialogMaxTextRunes)
61 switch req.Type {87 switch req.Type {
62 case port.JSDialogConfirm:88 case port.JSDialogConfirm:
63- return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK", CancelLabel: "Cancel"}89+ return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK", CancelLabel: "Cancel"}
64 case port.JSDialogPrompt:90 case port.JSDialogPrompt:
65- return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK", CancelLabel: "Cancel", ShowInput: true}91+ return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK", CancelLabel: "Cancel", ShowInput: true}
66 case port.JSDialogBeforeUnload:92 case port.JSDialogBeforeUnload:
67- body := strings.TrimSpace(req.Message)93+ body := jsDialogBeforeUnloadText
68- if body == "" {
69- body = "Changes you made may not be saved."
70- }
71 heading := "Leave page?"94 heading := "Leave page?"
72 if req.IsReload {95 if req.IsReload {
73 heading = "Reload page?"96 heading = "Reload page?"
@@ -79,7 +102,7 @@ func buildJSDialogContent(req port.JSDialogRequest) jsDialogContent {
79 CancelLabel: "Stay",102 CancelLabel: "Stay",
80 }103 }
81 default:104 default:
82- return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK"}105+ return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK"}
83 }106 }
84 }107 }
85 108
@@ -97,17 +120,30 @@ type JSDialogPopup struct {
97 btnOK *gtk.Button120 btnOK *gtk.Button
98 btnCancel *gtk.Button121 btnCancel *gtk.Button
99 122
100- mu sync.Mutex123+ parent layout.OverlayWidget
101- visible bool124+ controller *gtk.EventControllerKey
102- respond func(ok bool, input string)125+ signals []popupSignal
103- onHidden func()126+
127+ mu sync.Mutex
128+ visible bool
129+ wantFocus bool // this dialog should own keyboard focus (pane is active)
130+ destroyed bool
131+ respond func(ok bool, input string)
132+ onHidden func(hadFocus bool)
104 133
105 retainedCallbacks []any134 retainedCallbacks []any
106 }135 }
107 136
137+// popupSignal remembers a connected handler so Destroy can disconnect it.
138+type popupSignal struct {
139+ obj interface{ DisconnectSignal(uint) }
140+ id uint
141+}
142+
108 // NewJSDialogPopup creates the popup (hidden). Returns nil if widget creation fails.143 // NewJSDialogPopup creates the popup (hidden). Returns nil if widget creation fails.
109-func NewJSDialogPopup() *JSDialogPopup {144+// parent is the pane overlay used to size the dialog.
110- p := &JSDialogPopup{}145+func NewJSDialogPopup(parent layout.OverlayWidget) *JSDialogPopup {
146+ p := &JSDialogPopup{parent: parent}
111 if err := p.createWidgets(); err != nil {147 if err := p.createWidgets(); err != nil {
112 return nil148 return nil
113 }149 }
@@ -123,25 +159,40 @@ func (p *JSDialogPopup) Widget() *gtk.Widget {
123 return &p.outerBox.Widget159 return &p.outerBox.Widget
124 }160 }
125 161
126-// SetOnHidden sets a callback run after the popup closes (e.g. to restore focus).162+// SetOnHidden sets (or with nil clears) a callback run after the popup closes. hadFocus reports
127-func (p *JSDialogPopup) SetOnHidden(fn func()) {163+// whether keyboard focus was inside the dialog when it closed, so the owner
164+// only restores focus when the dialog had taken it.
165+func (p *JSDialogPopup) SetOnHidden(fn func(hadFocus bool)) {
128 p.mu.Lock()166 p.mu.Lock()
129 p.onHidden = fn167 p.onHidden = fn
130 p.mu.Unlock()168 p.mu.Unlock()
131 }169 }
132 170
133 // Show displays the dialog. respond is invoked once with the user's answer.171 // Show displays the dialog. respond is invoked once with the user's answer.
134-// A dialog already visible is dismissed as canceled first.172+// A dialog already visible is dismissed as canceled first. Keyboard focus is
135-func (p *JSDialogPopup) Show(req port.JSDialogRequest, respond func(ok bool, input string)) {173+// only taken when focus is true (the pane is active); otherwise RequestFocus
136- p.dismiss(false)174+// grabs it when the pane becomes active.
175+func (p *JSDialogPopup) Show(req port.JSDialogRequest, respond func(ok bool, input string), focus bool, uiScale float64) {
176+ p.Dismiss(false)
137 177
138 content := buildJSDialogContent(req)178 content := buildJSDialogContent(req)
139 179
140 p.mu.Lock()180 p.mu.Lock()
181+ if p.destroyed {
182+ p.mu.Unlock()
183+ if respond != nil {
184+ respond(false, "")
185+ }
186+ return
187+ }
141 p.visible = true188 p.visible = true
189+ p.wantFocus = focus
142 p.respond = respond190 p.respond = respond
143 p.mu.Unlock()191 p.mu.Unlock()
144 192
193+ width, _ := CalculateModalDimensionsWithScale(p.parent, JSDialogSizeDefaults, uiScale)
194+ p.mainBox.SetSizeRequest(width, -1)
195+
145 p.headingLabel.SetText(content.Heading)196 p.headingLabel.SetText(content.Heading)
146 p.bodyLabel.SetText(content.Body)197 p.bodyLabel.SetText(content.Body)
147 p.bodyLabel.SetVisible(content.Body != "")198 p.bodyLabel.SetVisible(content.Body != "")
@@ -152,18 +203,38 @@ func (p *JSDialogPopup) Show(req port.JSDialogRequest, respond func(ok bool, inp
152 }203 }
153 p.input.SetVisible(content.ShowInput)204 p.input.SetVisible(content.ShowInput)
154 if content.ShowInput {205 if content.ShowInput {
155- p.input.SetText(req.DefaultPrompt)206+ p.input.SetText(truncateRunes(req.DefaultPrompt, jsDialogMaxTextRunes))
156 } else {207 } else {
157 p.input.SetText("")208 p.input.SetText("")
158 }209 }
159 210
160 p.outerBox.SetVisible(true)211 p.outerBox.SetVisible(true)
161- if content.ShowInput {212+ if focus {
213+ p.grabDefaultFocus()
214+ }
215+}
216+
217+// RequestFocus marks the dialog as the focus owner and grabs focus if shown.
218+// Called when the owning pane becomes active.
219+func (p *JSDialogPopup) RequestFocus() {
220+ p.mu.Lock()
221+ if !p.visible || p.destroyed {
222+ p.mu.Unlock()
223+ return
224+ }
225+ p.wantFocus = true
226+ p.mu.Unlock()
227+ p.grabDefaultFocus()
228+}
229+
230+// grabDefaultFocus focuses the entry (prompt) or the OK button.
231+func (p *JSDialogPopup) grabDefaultFocus() {
232+ if p.input.GetVisible() {
162 p.input.GrabFocus()233 p.input.GrabFocus()
163 p.input.SelectRegion(0, -1)234 p.input.SelectRegion(0, -1)
164- } else {235+ return
165- p.btnOK.GrabFocus()
166 }236 }
237+ p.btnOK.GrabFocus()
167 }238 }
168 239
169 // Hide closes the popup without invoking the response callback. It is used when240 // Hide closes the popup without invoking the response callback. It is used when
@@ -172,52 +243,68 @@ func (p *JSDialogPopup) Hide() {
172 if p == nil {243 if p == nil {
173 return244 return
174 }245 }
175- p.mu.Lock()246+ p.close(nil)
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 }247 }
189 248
190-// IsVisible reports whether a dialog is displayed.249+// Dismiss closes the popup and answers with ok. No-op when hidden.
191-func (p *JSDialogPopup) IsVisible() bool {250+func (p *JSDialogPopup) Dismiss(ok bool) {
192- p.mu.Lock()251+ p.close(&ok)
193- defer p.mu.Unlock()
194- return p.visible
195 }252 }
196 253
197-// dismiss closes the popup and answers. Safe to call when hidden (no-op).254+// close hides the popup. When answer is non-nil the response callback is run.
198-func (p *JSDialogPopup) dismiss(ok bool) {255+func (p *JSDialogPopup) close(answer *bool) {
199 p.mu.Lock()256 p.mu.Lock()
200 if !p.visible {257 if !p.visible {
201 p.mu.Unlock()258 p.mu.Unlock()
202 return259 return
203 }260 }
204 p.visible = false261 p.visible = false
262+ p.wantFocus = false
205 respond := p.respond263 respond := p.respond
206 p.respond = nil264 p.respond = nil
207 hidden := p.onHidden265 hidden := p.onHidden
208 p.mu.Unlock()266 p.mu.Unlock()
209 267
268+ hadFocus := p.outerBox.GetFocusChild() != nil
210 text := ""269 text := ""
211- if ok && p.input.GetVisible() {270+ if answer != nil && *answer && p.input.GetVisible() {
212 text = p.input.GetText()271 text = p.input.GetText()
213 }272 }
214 p.outerBox.SetVisible(false)273 p.outerBox.SetVisible(false)
215- if respond != nil {274+ if answer != nil && respond != nil {
216- respond(ok, text)275+ respond(*answer, text)
217 }276 }
218 if hidden != nil {277 if hidden != nil {
219- hidden()278+ hidden(hadFocus)
279+ }
280+}
281+
282+// Destroy disconnects every handler and drops callbacks. The popup must not be
283+// used afterwards. Call after the dialog was dismissed.
284+func (p *JSDialogPopup) Destroy() {
285+ if p == nil {
286+ return
220 }287 }
288+ p.mu.Lock()
289+ if p.destroyed {
290+ p.mu.Unlock()
291+ return
292+ }
293+ p.destroyed = true
294+ p.visible = false
295+ p.respond = nil
296+ p.onHidden = nil
297+ p.mu.Unlock()
298+
299+ for _, s := range p.signals {
300+ s.obj.DisconnectSignal(s.id)
301+ }
302+ p.signals = nil
303+ if p.controller != nil && p.outerBox != nil {
304+ p.outerBox.RemoveController(&p.controller.EventController)
305+ p.controller = nil
306+ }
307+ p.retainedCallbacks = nil
221 }308 }
222 309
223 func (p *JSDialogPopup) createWidgets() error {310 func (p *JSDialogPopup) createWidgets() error {
@@ -241,7 +328,13 @@ func (p *JSDialogPopup) createWidgets() error {
241 return errNilWidget("jsDialogEntry")328 return errNilWidget("jsDialogEntry")
242 }329 }
243 p.input.AddCssClass("jsdialog-entry")330 p.input.AddCssClass("jsdialog-entry")
331+ p.input.SetMaxLength(jsDialogMaxTextRunes)
244 p.input.SetVisible(false)332 p.input.SetVisible(false)
333+ // Enter in the entry = OK. Focused buttons activate themselves, so Enter on
334+ // Cancel/Stay still cancels.
335+ activateCb := func(gtk.Entry) { p.Dismiss(true) }
336+ p.retainedCallbacks = append(p.retainedCallbacks, activateCb)
337+ p.signals = append(p.signals, popupSignal{p.input, p.input.ConnectActivate(&activateCb)})
245 338
246 btnRow, err := p.createButtonRow()339 btnRow, err := p.createButtonRow()
247 if err != nil {340 if err != nil {
@@ -276,7 +369,6 @@ func (p *JSDialogPopup) createContainers() error {
276 p.mainBox.AddCssClass("permission-popup-container")369 p.mainBox.AddCssClass("permission-popup-container")
277 p.mainBox.SetHalign(gtk.AlignCenterValue)370 p.mainBox.SetHalign(gtk.AlignCenterValue)
278 p.mainBox.SetValign(gtk.AlignCenterValue)371 p.mainBox.SetValign(gtk.AlignCenterValue)
279- p.mainBox.SetSizeRequest(jsDialogDialogWidth, -1)
280 return nil372 return nil
281 }373 }
282 374
@@ -319,18 +411,21 @@ func (p *JSDialogPopup) createButtonRow() (*gtk.Box, error) {
319 p.btnOK.AddCssClass("permission-popup-btn")411 p.btnOK.AddCssClass("permission-popup-btn")
320 p.btnOK.AddCssClass("permission-popup-btn-allow")412 p.btnOK.AddCssClass("permission-popup-btn-allow")
321 413
322- cancelCb := func(_ gtk.Button) { p.dismiss(false) }414+ cancelCb := func(_ gtk.Button) { p.Dismiss(false) }
323- okCb := func(_ gtk.Button) { p.dismiss(true) }415+ okCb := func(_ gtk.Button) { p.Dismiss(true) }
324 p.retainedCallbacks = append(p.retainedCallbacks, cancelCb, okCb)416 p.retainedCallbacks = append(p.retainedCallbacks, cancelCb, okCb)
325- p.btnCancel.ConnectClicked(&cancelCb)417+ p.signals = append(p.signals,
326- p.btnOK.ConnectClicked(&okCb)418+ popupSignal{p.btnCancel, p.btnCancel.ConnectClicked(&cancelCb)},
419+ popupSignal{p.btnOK, p.btnOK.ConnectClicked(&okCb)},
420+ )
327 421
328 btnRow.Append(&p.btnCancel.Widget)422 btnRow.Append(&p.btnCancel.Widget)
329 btnRow.Append(&p.btnOK.Widget)423 btnRow.Append(&p.btnOK.Widget)
330 return btnRow, nil424 return btnRow, nil
331 }425 }
332 426
333-// attachKeyController maps Enter to OK and Escape to Cancel.427+// attachKeyController maps Escape to Cancel (capture phase, so the page never
428+// sees it). Enter is not handled here: see the entry's activate handler.
334 func (p *JSDialogPopup) attachKeyController() {429 func (p *JSDialogPopup) attachKeyController() {
335 controller := gtk.NewEventControllerKey()430 controller := gtk.NewEventControllerKey()
336 if controller == nil {431 if controller == nil {
@@ -338,17 +433,26 @@ func (p *JSDialogPopup) attachKeyController() {
338 }433 }
339 controller.SetPropagationPhase(gtk.PhaseCaptureValue)434 controller.SetPropagationPhase(gtk.PhaseCaptureValue)
340 keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {435 keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
341- switch keyval {436+ if keyval == uint(gdk.KEY_Escape) {
342- case uint(gdk.KEY_Escape):437+ p.Dismiss(false)
343- p.dismiss(false)
344- return true
345- case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
346- p.dismiss(true)
347 return true438 return true
348 }439 }
349 return false440 return false
350 }441 }
351 p.retainedCallbacks = append(p.retainedCallbacks, keyPressedCb)442 p.retainedCallbacks = append(p.retainedCallbacks, keyPressedCb)
352- controller.ConnectKeyPressed(&keyPressedCb)443+ p.signals = append(p.signals, popupSignal{controller, controller.ConnectKeyPressed(&keyPressedCb)})
353 p.outerBox.AddController(&controller.EventController)444 p.outerBox.AddController(&controller.EventController)
445+ p.controller = controller
446+
447+ // A dialog shown while its tab was in the background gets focus once mapped.
448+ mapCb := func(gtk.Widget) {
449+ p.mu.Lock()
450+ want := p.visible && p.wantFocus
451+ p.mu.Unlock()
452+ if want {
453+ p.grabDefaultFocus()
454+ }
455+ }
456+ p.retainedCallbacks = append(p.retainedCallbacks, mapCb)
457+ p.signals = append(p.signals, popupSignal{&p.outerBox.Widget, p.outerBox.ConnectMap(&mapCb)})
354 }458 }
M internal/ui/component/pane_view.go +82 -30
@@ -10,6 +10,7 @@ import (
1010 "github.com/bnema/dumber/internal/logging"
1111 "github.com/bnema/dumber/internal/ui/input"
1212 "github.com/bnema/dumber/internal/ui/layout"
13+ "github.com/bnema/puregotk/v4/gtk"
1314 )
1415
1516 // CSS class applied to the active pane's border overlay.
@@ -18,19 +19,20 @@ const activePaneClass = "pane-active"
1819 // PaneView is a container for a single WebView with active state indication.
1920 // It uses an overlay to display a border around the active pane.
2021 type PaneView struct {
21- ctx context.Context
22- factory layout.WidgetFactory
23- overlay layout.OverlayWidget
24- webViewWidget layout.Widget // The actual WebView widget
25- borderBox layout.BoxWidget // Border overlay for active indication
26- progressBar *ProgressBar // Loading progress indicator
27- toaster *Toaster // Toast notification overlay
28- linkStatus *LinkStatusOverlay // Link hover URL overlay
29- 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
32- paneID entity.PaneID
33- isActive bool
22+ ctx context.Context
23+ factory layout.WidgetFactory
24+ overlay layout.OverlayWidget
25+ webViewWidget layout.Widget // The actual WebView widget
26+ borderBox layout.BoxWidget // Border overlay for active indication
27+ progressBar *ProgressBar // Loading progress indicator
28+ toaster *Toaster // Toast notification overlay
29+ linkStatus *LinkStatusOverlay // Link hover URL overlay
30+ loading *LoadingSkeleton // Placeholder shown until WebView paints
31+ jsDialog jsDialogView // JS alert/confirm/prompt/beforeunload overlay
32+ jsDialogWidget layout.Widget // overlay wrapper of jsDialog, for removal
33+ newJSDialog func(layout.OverlayWidget) jsDialogView
34+ paneID entity.PaneID
35+ isActive bool
3436
3537 // Vim Mode input ownership; its border and pulse belong to modeFrame.
3638 vimMode bool
@@ -104,9 +106,8 @@ func NewPaneView(ctx context.Context, factory layout.WidgetFactory, paneID entit
104106 // Active panes display a visual border indicator.
105107 func (pv *PaneView) SetActive(active bool) {
106108 pv.mu.Lock()
107- defer pv.mu.Unlock()
108-
109109 if pv.isActive == active {
110+ pv.mu.Unlock()
110111 return
111112 }
112113
@@ -117,6 +118,13 @@ func (pv *PaneView) SetActive(active bool) {
117118 } else {
118119 pv.borderBox.RemoveCssClass(activePaneClass)
119120 }
121+ popup := pv.jsDialog
122+ pv.mu.Unlock()
123+
124+ // A dialog raised while this pane was inactive takes focus now.
125+ if active && popup != nil {
126+ popup.RequestFocus()
127+ }
120128 }
121129
122130 // IsActive returns whether this pane is currently active.
@@ -550,8 +558,28 @@ func (pv *PaneView) IsVimMode() bool {
550558 // After calling Cleanup, the PaneView should not be reused.
551559 func (pv *PaneView) Cleanup() {
552560 pv.mu.Lock()
553- defer pv.mu.Unlock()
561+ // The WebView may outlive this view (workspace rebuild), so an open JS
562+ // dialog must be answered, not dropped. The hidden callback re-locks
563+ // pv.mu, so detach it here and dismiss after unlocking.
564+ popup, popupWidget := pv.jsDialog, pv.jsDialogWidget
565+ if popup != nil {
566+ popup.SetOnHidden(nil)
567+ }
568+ pv.jsDialog, pv.jsDialogWidget = nil, nil
569+ pv.cleanupLocked()
570+ pv.mu.Unlock()
554571
572+ if popup != nil {
573+ popup.Dismiss(false)
574+ popup.Destroy()
575+ if popupWidget != nil {
576+ pv.overlay.RemoveOverlay(popupWidget)
577+ }
578+ }
579+}
580+
581+// cleanupLocked releases everything except the JS dialog. Caller holds pv.mu.
582+func (pv *PaneView) cleanupLocked() {
555583 // Clear callbacks to prevent use-after-free
556584 pv.onFocusIn = nil
557585 pv.onFocusOut = nil
@@ -588,29 +616,50 @@ func (pv *PaneView) Cleanup() {
588616 pv.overlay.RemoveOverlay(pv.linkStatus.Widget())
589617 pv.linkStatus = nil
590618 }
591- if pv.jsDialogWidth != nil {
592- pv.overlay.RemoveOverlay(pv.jsDialogWidth)
593- pv.jsDialogWidth = nil
594- pv.jsDialog = nil
619+}
620+
621+// jsDialogView is the JS dialog popup as seen by PaneView (a seam for tests).
622+type jsDialogView interface {
623+ Widget() *gtk.Widget
624+ Show(req port.JSDialogRequest, respond func(ok bool, input string), focus bool, uiScale float64)
625+ Hide()
626+ Dismiss(ok bool)
627+ RequestFocus()
628+ SetOnHidden(fn func(hadFocus bool))
629+ Destroy()
630+}
631+
632+func newJSDialogPopupView(parent layout.OverlayWidget) jsDialogView {
633+ if p := NewJSDialogPopup(parent); p != nil {
634+ return p
595635 }
636+ return nil
596637 }
597638
598639 // ensureJSDialog creates the JS dialog popup lazily on first use.
599640 // Must be called with write lock held.
600-func (pv *PaneView) ensureJSDialog() *JSDialogPopup {
641+func (pv *PaneView) ensureJSDialog() jsDialogView {
601642 if pv.jsDialog != nil {
602643 return pv.jsDialog
603644 }
604- popup := NewJSDialogPopup()
645+ create := pv.newJSDialog
646+ if create == nil {
647+ create = newJSDialogPopupView
648+ }
649+ popup := create(pv.overlay)
605650 if popup == nil {
606651 return nil
607652 }
608653 w := pv.factory.WrapWidget(popup.Widget())
609654 pv.overlay.AddOverlay(w)
610- pv.overlay.SetClipOverlay(w, false)
655+ // Clip so the scrim stays inside the pane; it must not affect layout.
656+ pv.overlay.SetClipOverlay(w, true)
611657 pv.overlay.SetMeasureOverlay(w, false)
612- popup.SetOnHidden(func() {
613- // Return keyboard focus to the page once the dialog is gone.
658+ popup.SetOnHidden(func(hadFocus bool) {
659+ // Give focus back to the page only if the dialog had taken it.
660+ if !hadFocus {
661+ return
662+ }
614663 pv.mu.RLock()
615664 wvw := pv.webViewWidget
616665 pv.mu.RUnlock()
@@ -619,22 +668,25 @@ func (pv *PaneView) ensureJSDialog() *JSDialogPopup {
619668 }
620669 })
621670 pv.jsDialog = popup
622- pv.jsDialogWidth = w
671+ pv.jsDialogWidget = w
623672 return popup
624673 }
625674
626675 // ShowJSDialog displays a JavaScript dialog scoped to this pane. The dialog
627676 // 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 {
677+// in a background tab it becomes visible when that tab is shown, and it only
678+// takes keyboard focus while the pane is active (otherwise when it becomes
679+// active). respond is invoked once with the user's answer. Returns false if the
680+// popup is unavailable.
681+func (pv *PaneView) ShowJSDialog(req port.JSDialogRequest, respond func(ok bool, input string), uiScale float64) bool {
631682 pv.mu.Lock()
632683 popup := pv.ensureJSDialog()
684+ focus := pv.isActive
633685 pv.mu.Unlock()
634686 if popup == nil {
635687 return false
636688 }
637- popup.Show(req, respond)
689+ popup.Show(req, respond, focus, uiScale)
638690 return true
639691 }
640692
@@ -10,6 +10,7 @@ import (
10 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
11 "github.com/bnema/dumber/internal/ui/input"11 "github.com/bnema/dumber/internal/ui/input"
12 "github.com/bnema/dumber/internal/ui/layout"12 "github.com/bnema/dumber/internal/ui/layout"
13+ "github.com/bnema/puregotk/v4/gtk"
13 )14 )
14 15
15 // CSS class applied to the active pane's border overlay.16 // CSS class applied to the active pane's border overlay.
@@ -18,19 +19,20 @@ const activePaneClass = "pane-active"
18 // PaneView is a container for a single WebView with active state indication.19 // PaneView is a container for a single WebView with active state indication.
19 // It uses an overlay to display a border around the active pane.20 // It uses an overlay to display a border around the active pane.
20 type PaneView struct {21 type PaneView struct {
21- ctx context.Context22+ ctx context.Context
22- factory layout.WidgetFactory23+ factory layout.WidgetFactory
23- overlay layout.OverlayWidget24+ overlay layout.OverlayWidget
24- webViewWidget layout.Widget // The actual WebView widget25+ webViewWidget layout.Widget // The actual WebView widget
25- borderBox layout.BoxWidget // Border overlay for active indication26+ borderBox layout.BoxWidget // Border overlay for active indication
26- progressBar *ProgressBar // Loading progress indicator27+ progressBar *ProgressBar // Loading progress indicator
27- toaster *Toaster // Toast notification overlay28+ toaster *Toaster // Toast notification overlay
28- linkStatus *LinkStatusOverlay // Link hover URL overlay29+ linkStatus *LinkStatusOverlay // Link hover URL overlay
29- loading *LoadingSkeleton // Placeholder shown until WebView paints30+ loading *LoadingSkeleton // Placeholder shown until WebView paints
30- jsDialog *JSDialogPopup // JS alert/confirm/prompt/beforeunload overlay31+ jsDialog jsDialogView // JS alert/confirm/prompt/beforeunload overlay
31- jsDialogWidth layout.Widget // overlay wrapper of jsDialog, for removal32+ jsDialogWidget layout.Widget // overlay wrapper of jsDialog, for removal
32- paneID entity.PaneID33+ newJSDialog func(layout.OverlayWidget) jsDialogView
33- isActive bool34+ paneID entity.PaneID
35+ isActive bool
34 36
35 // Vim Mode input ownership; its border and pulse belong to modeFrame.37 // Vim Mode input ownership; its border and pulse belong to modeFrame.
36 vimMode bool38 vimMode bool
@@ -104,9 +106,8 @@ func NewPaneView(ctx context.Context, factory layout.WidgetFactory, paneID entit
104 // Active panes display a visual border indicator.106 // Active panes display a visual border indicator.
105 func (pv *PaneView) SetActive(active bool) {107 func (pv *PaneView) SetActive(active bool) {
106 pv.mu.Lock()108 pv.mu.Lock()
107- defer pv.mu.Unlock()
108-
109 if pv.isActive == active {109 if pv.isActive == active {
110+ pv.mu.Unlock()
110 return111 return
111 }112 }
112 113
@@ -117,6 +118,13 @@ func (pv *PaneView) SetActive(active bool) {
117 } else {118 } else {
118 pv.borderBox.RemoveCssClass(activePaneClass)119 pv.borderBox.RemoveCssClass(activePaneClass)
119 }120 }
121+ popup := pv.jsDialog
122+ pv.mu.Unlock()
123+
124+ // A dialog raised while this pane was inactive takes focus now.
125+ if active && popup != nil {
126+ popup.RequestFocus()
127+ }
120 }128 }
121 129
122 // IsActive returns whether this pane is currently active.130 // IsActive returns whether this pane is currently active.
@@ -550,8 +558,28 @@ func (pv *PaneView) IsVimMode() bool {
550 // After calling Cleanup, the PaneView should not be reused.558 // After calling Cleanup, the PaneView should not be reused.
551 func (pv *PaneView) Cleanup() {559 func (pv *PaneView) Cleanup() {
552 pv.mu.Lock()560 pv.mu.Lock()
553- defer pv.mu.Unlock()561+ // The WebView may outlive this view (workspace rebuild), so an open JS
562+ // dialog must be answered, not dropped. The hidden callback re-locks
563+ // pv.mu, so detach it here and dismiss after unlocking.
564+ popup, popupWidget := pv.jsDialog, pv.jsDialogWidget
565+ if popup != nil {
566+ popup.SetOnHidden(nil)
567+ }
568+ pv.jsDialog, pv.jsDialogWidget = nil, nil
569+ pv.cleanupLocked()
570+ pv.mu.Unlock()
554 571
572+ if popup != nil {
573+ popup.Dismiss(false)
574+ popup.Destroy()
575+ if popupWidget != nil {
576+ pv.overlay.RemoveOverlay(popupWidget)
577+ }
578+ }
579+}
580+
581+// cleanupLocked releases everything except the JS dialog. Caller holds pv.mu.
582+func (pv *PaneView) cleanupLocked() {
555 // Clear callbacks to prevent use-after-free583 // Clear callbacks to prevent use-after-free
556 pv.onFocusIn = nil584 pv.onFocusIn = nil
557 pv.onFocusOut = nil585 pv.onFocusOut = nil
@@ -588,29 +616,50 @@ func (pv *PaneView) Cleanup() {
588 pv.overlay.RemoveOverlay(pv.linkStatus.Widget())616 pv.overlay.RemoveOverlay(pv.linkStatus.Widget())
589 pv.linkStatus = nil617 pv.linkStatus = nil
590 }618 }
591- if pv.jsDialogWidth != nil {619+}
592- pv.overlay.RemoveOverlay(pv.jsDialogWidth)620+
593- pv.jsDialogWidth = nil621+// jsDialogView is the JS dialog popup as seen by PaneView (a seam for tests).
594- pv.jsDialog = nil622+type jsDialogView interface {
623+ Widget() *gtk.Widget
624+ Show(req port.JSDialogRequest, respond func(ok bool, input string), focus bool, uiScale float64)
625+ Hide()
626+ Dismiss(ok bool)
627+ RequestFocus()
628+ SetOnHidden(fn func(hadFocus bool))
629+ Destroy()
630+}
631+
632+func newJSDialogPopupView(parent layout.OverlayWidget) jsDialogView {
633+ if p := NewJSDialogPopup(parent); p != nil {
634+ return p
595 }635 }
636+ return nil
596 }637 }
597 638
598 // ensureJSDialog creates the JS dialog popup lazily on first use.639 // ensureJSDialog creates the JS dialog popup lazily on first use.
599 // Must be called with write lock held.640 // Must be called with write lock held.
600-func (pv *PaneView) ensureJSDialog() *JSDialogPopup {641+func (pv *PaneView) ensureJSDialog() jsDialogView {
601 if pv.jsDialog != nil {642 if pv.jsDialog != nil {
602 return pv.jsDialog643 return pv.jsDialog
603 }644 }
604- popup := NewJSDialogPopup()645+ create := pv.newJSDialog
646+ if create == nil {
647+ create = newJSDialogPopupView
648+ }
649+ popup := create(pv.overlay)
605 if popup == nil {650 if popup == nil {
606 return nil651 return nil
607 }652 }
608 w := pv.factory.WrapWidget(popup.Widget())653 w := pv.factory.WrapWidget(popup.Widget())
609 pv.overlay.AddOverlay(w)654 pv.overlay.AddOverlay(w)
610- pv.overlay.SetClipOverlay(w, false)655+ // Clip so the scrim stays inside the pane; it must not affect layout.
656+ pv.overlay.SetClipOverlay(w, true)
611 pv.overlay.SetMeasureOverlay(w, false)657 pv.overlay.SetMeasureOverlay(w, false)
612- popup.SetOnHidden(func() {658+ popup.SetOnHidden(func(hadFocus bool) {
613- // Return keyboard focus to the page once the dialog is gone.659+ // Give focus back to the page only if the dialog had taken it.
660+ if !hadFocus {
661+ return
662+ }
614 pv.mu.RLock()663 pv.mu.RLock()
615 wvw := pv.webViewWidget664 wvw := pv.webViewWidget
616 pv.mu.RUnlock()665 pv.mu.RUnlock()
@@ -619,22 +668,25 @@ func (pv *PaneView) ensureJSDialog() *JSDialogPopup {
619 }668 }
620 })669 })
621 pv.jsDialog = popup670 pv.jsDialog = popup
622- pv.jsDialogWidth = w671+ pv.jsDialogWidget = w
623 return popup672 return popup
624 }673 }
625 674
626 // ShowJSDialog displays a JavaScript dialog scoped to this pane. The dialog675 // 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 is676 // 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 is677+// in a background tab it becomes visible when that tab is shown, and it only
629-// invoked once with the user's answer. Returns false if the popup is unavailable.678+// takes keyboard focus while the pane is active (otherwise when it becomes
630-func (pv *PaneView) ShowJSDialog(req port.JSDialogRequest, respond func(ok bool, input string)) bool {679+// active). respond is invoked once with the user's answer. Returns false if the
680+// popup is unavailable.
681+func (pv *PaneView) ShowJSDialog(req port.JSDialogRequest, respond func(ok bool, input string), uiScale float64) bool {
631 pv.mu.Lock()682 pv.mu.Lock()
632 popup := pv.ensureJSDialog()683 popup := pv.ensureJSDialog()
684+ focus := pv.isActive
633 pv.mu.Unlock()685 pv.mu.Unlock()
634 if popup == nil {686 if popup == nil {
635 return false687 return false
636 }688 }
637- popup.Show(req, respond)689+ popup.Show(req, respond, focus, uiScale)
638 return true690 return true
639 }691 }
640 692
M internal/ui/coordinator/content/callbacks.go +14 -14
@@ -296,7 +296,7 @@ func (c *Coordinator) setupJSDialogCallbacks(paneID entity.PaneID, callbacks *po
296296 if pv == nil {
297297 return false
298298 }
299- return pv.ShowJSDialog(req, respond)
299+ return pv.ShowJSDialog(req, respond, c.jsDialogUIScale())
300300 }
301301 callbacks.OnJSDialogReset = func() {
302302 if pv := c.paneViewForJSDialog(paneID); pv != nil {
@@ -305,20 +305,20 @@ func (c *Coordinator) setupJSDialogCallbacks(paneID entity.PaneID, callbacks *po
305305 }
306306 }
307307
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- }
308+// jsDialogUIScale returns the current UI scale (1 when no provider is set).
309+func (c *Coordinator) jsDialogUIScale() float64 {
310+ if c.uiScaleProvider == nil {
311+ return 1
316312 }
317- if c.getActiveWS == nil {
313+ return c.uiScaleProvider()
314+}
315+
316+// paneViewForJSDialog resolves the PaneView that owns paneID's WebView through
317+// the app-provided resolver (any tab/window). The dialog is shown inside that
318+// pane's overlay even if the pane is in a background tab and never blocks others.
319+func (c *Coordinator) paneViewForJSDialog(paneID entity.PaneID) *component.PaneView {
320+ if c.paneViewResolver == nil {
318321 return nil
319322 }
320- if _, wsView := c.getActiveWS(); wsView != nil {
321- return wsView.GetPaneView(paneID)
322- }
323- return nil
323+ return c.paneViewResolver(paneID)
324324 }
@@ -296,7 +296,7 @@ func (c *Coordinator) setupJSDialogCallbacks(paneID entity.PaneID, callbacks *po
296 if pv == nil {296 if pv == nil {
297 return false297 return false
298 }298 }
299- return pv.ShowJSDialog(req, respond)299+ return pv.ShowJSDialog(req, respond, c.jsDialogUIScale())
300 }300 }
301 callbacks.OnJSDialogReset = func() {301 callbacks.OnJSDialogReset = func() {
302 if pv := c.paneViewForJSDialog(paneID); pv != nil {302 if pv := c.paneViewForJSDialog(paneID); pv != nil {
@@ -305,20 +305,20 @@ func (c *Coordinator) setupJSDialogCallbacks(paneID entity.PaneID, callbacks *po
305 }305 }
306 }306 }
307 307
308-// paneViewForJSDialog resolves the PaneView that owns paneID's WebView. The308+// jsDialogUIScale returns the current UI scale (1 when no provider is set).
309-// dialog is shown inside that pane's overlay even if the pane is in a309+func (c *Coordinator) jsDialogUIScale() float64 {
310-// background tab (it appears when the tab is shown) and never blocks others.310+ if c.uiScaleProvider == nil {
311-func (c *Coordinator) paneViewForJSDialog(paneID entity.PaneID) *component.PaneView {311+ return 1
312- if c.paneViewResolver != nil {
313- if pv := c.paneViewResolver(paneID); pv != nil {
314- return pv
315- }
316 }312 }
317- if c.getActiveWS == nil {313+ return c.uiScaleProvider()
314+}
315+
316+// paneViewForJSDialog resolves the PaneView that owns paneID's WebView through
317+// the app-provided resolver (any tab/window). The dialog is shown inside that
318+// pane's overlay even if the pane is in a background tab and never blocks others.
319+func (c *Coordinator) paneViewForJSDialog(paneID entity.PaneID) *component.PaneView {
320+ if c.paneViewResolver == nil {
318 return nil321 return nil
319 }322 }
320- if _, wsView := c.getActiveWS(); wsView != nil {323+ return c.paneViewResolver(paneID)
321- return wsView.GetPaneView(paneID)
322- }
323- return nil
324 }324 }
M internal/ui/coordinator/content/coordinator.go +8 -1
@@ -47,8 +47,10 @@ type Coordinator struct {
4747 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
4848
4949 // 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.
50+ // background panes). JS dialogs are not shown while it is nil.
5151 paneViewResolver func(paneID entity.PaneID) *component.PaneView
52+ // Current UI scale, used to size JS dialogs.
53+ uiScaleProvider func() float64
5254
5355 // Callback when title changes (for history persistence)
5456 onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)
@@ -255,6 +257,11 @@ func (c *Coordinator) SetPaneViewResolver(fn func(paneID entity.PaneID) *compone
255257 c.paneViewResolver = fn
256258 }
257259
260+// SetUIScaleProvider sets the source of the current UI scale.
261+func (c *Coordinator) SetUIScaleProvider(fn func() float64) {
262+ c.uiScaleProvider = fn
263+}
264+
258265 // SetOnEditableFocusChanged sets the callback for page editable focus changes.
259266 func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) {
260267 c.onEditableFocusChanged = fn
@@ -47,8 +47,10 @@ type Coordinator struct {
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 from49 // 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.50+ // background panes). JS dialogs are not shown while it is nil.
51 paneViewResolver func(paneID entity.PaneID) *component.PaneView51 paneViewResolver func(paneID entity.PaneID) *component.PaneView
52+ // Current UI scale, used to size JS dialogs.
53+ uiScaleProvider func() float64
52 54
53 // Callback when title changes (for history persistence)55 // Callback when title changes (for history persistence)
54 onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)56 onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)
@@ -255,6 +257,11 @@ func (c *Coordinator) SetPaneViewResolver(fn func(paneID entity.PaneID) *compone
255 c.paneViewResolver = fn257 c.paneViewResolver = fn
256 }258 }
257 259
260+// SetUIScaleProvider sets the source of the current UI scale.
261+func (c *Coordinator) SetUIScaleProvider(fn func() float64) {
262+ c.uiScaleProvider = fn
263+}
264+
258 // SetOnEditableFocusChanged sets the callback for page editable focus changes.265 // SetOnEditableFocusChanged sets the callback for page editable focus changes.
259 func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) {266 func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) {
260 c.onEditableFocusChanged = fn267 c.onEditableFocusChanged = fn

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