main 5041353

pane_view.go RawHistoryFolder

1// Package component provides UI components for the browser.
2package component
3
4import (
5	"context"
6	"sync"
7
8	"github.com/bnema/dumber/internal/application/port"
9	"github.com/bnema/dumber/internal/domain/entity"
10	"github.com/bnema/dumber/internal/logging"
11	"github.com/bnema/dumber/internal/ui/input"
12	"github.com/bnema/dumber/internal/ui/layout"
13	"github.com/bnema/puregotk/v4/gtk"
14)
15
16// CSS class applied to the active pane's border overlay.
17const activePaneClass = "pane-active"
18
19// PaneView is a container for a single WebView with active state indication.
20// It uses an overlay to display a border around the active pane.
21type PaneView struct {
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	cleanedUp      bool // Cleanup ran: no new JS dialog may be created
35	paneID         entity.PaneID
36	isActive       bool
37
38	// Vim Mode input ownership; its border and pulse belong to modeFrame.
39	vimMode bool
40
41	onFocusIn     func(paneID entity.PaneID)
42	onFocusOut    func(paneID entity.PaneID)
43	onHover       func(paneID entity.PaneID)
44	onMouseMotion func()
45
46	hoverHandler *input.HoverHandler
47
48	mu sync.RWMutex
49}
50
51// NewPaneView creates a new pane view container for a WebView widget.
52func NewPaneView(ctx context.Context, factory layout.WidgetFactory, paneID entity.PaneID, webViewWidget layout.Widget) *PaneView {
53	overlay := factory.NewOverlay()
54	overlay.SetHexpand(true)
55	overlay.SetVexpand(true)
56	overlay.SetVisible(true)
57	overlay.AddCssClass("pane-overlay") // Theme background prevents white flash
58
59	// Set the WebView as the main child
60	// Note: WebView may be hidden initially (see pool.go) - shown on LoadCommitted
61	if webViewWidget != nil {
62		overlay.SetChild(webViewWidget)
63	}
64
65	// Loading skeleton overlay (shown until WebView paints)
66	loading := NewLoadingSkeleton(factory)
67	loading.Widget().SetCanFocus(false)
68	loading.Widget().SetCanTarget(false)
69	overlay.AddOverlay(loading.Widget())
70	// Don't let loading affect layout
71	overlay.SetClipOverlay(loading.Widget(), false)
72	overlay.SetMeasureOverlay(loading.Widget(), false)
73
74	// Create border overlay box (used for active pane indication)
75	// This is an empty box that gets styled via CSS to show a border
76	borderBox := factory.NewBox(layout.OrientationVertical, 0)
77	borderBox.SetCanFocus(false)
78	borderBox.SetCanTarget(false) // Don't intercept pointer events - let them pass through to WebView
79	borderBox.AddCssClass("pane-border")
80	// Position border to cover entire pane
81	borderBox.SetHexpand(true)
82	borderBox.SetVexpand(true)
83	overlay.AddOverlay(borderBox)
84
85	// Don't let border affect layout
86	overlay.SetClipOverlay(borderBox, false)
87	overlay.SetMeasureOverlay(borderBox, false)
88
89	// Progress bar is created lazily on first use to avoid GTK measurement
90	// issues with the internal progress gizmo before the overlay is realized
91
92	return &PaneView{
93		ctx:           ctx,
94		factory:       factory,
95		overlay:       overlay,
96		webViewWidget: webViewWidget,
97		borderBox:     borderBox,
98		progressBar:   nil, // Created lazily in ensureProgressBar()
99		loading:       loading,
100		paneID:        paneID,
101		isActive:      false,
102		vimMode:       false,
103	}
104}
105
106// SetActive updates the active state of the pane.
107// Active panes display a visual border indicator.
108func (pv *PaneView) SetActive(active bool) {
109	pv.mu.Lock()
110	if pv.isActive == active {
111		pv.mu.Unlock()
112		return
113	}
114
115	pv.isActive = active
116
117	if active {
118		pv.borderBox.AddCssClass(activePaneClass)
119	} else {
120		pv.borderBox.RemoveCssClass(activePaneClass)
121	}
122	popup := pv.jsDialog
123	pv.mu.Unlock()
124
125	// A dialog raised while this pane was inactive takes focus now.
126	if active && popup != nil {
127		popup.RequestFocus()
128	}
129}
130
131// IsActive returns whether this pane is currently active.
132func (pv *PaneView) IsActive() bool {
133	pv.mu.RLock()
134	defer pv.mu.RUnlock()
135
136	return pv.isActive
137}
138
139// PaneID returns the ID of the pane this view represents.
140func (pv *PaneView) PaneID() entity.PaneID {
141	return pv.paneID
142}
143
144// WebViewWidget returns the underlying WebView widget.
145func (pv *PaneView) WebViewWidget() layout.Widget {
146	pv.mu.RLock()
147	defer pv.mu.RUnlock()
148
149	return pv.webViewWidget
150}
151
152// SetWebViewWidget replaces the WebView widget as not-yet-revealed content.
153// Call AttachWebViewWidget when the content coordinator knows the WebView has
154// already painted.
155func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {
156	pv.AttachWebViewWidget(widget, false)
157}
158
159// AttachWebViewWidget replaces the WebView widget and applies its explicit
160// content reveal state. GTK parentage is used only to satisfy GTK's one-parent
161// ownership rule; it must not be used to infer whether CEF has painted.
162func (pv *PaneView) AttachWebViewWidget(widget layout.Widget, revealed bool) {
163	pv.mu.Lock()
164	defer pv.mu.Unlock()
165
166	// Remove old widget from this overlay.
167	if pv.webViewWidget != nil {
168		pv.overlay.SetChild(nil)
169	}
170
171	pv.webViewWidget = widget
172	if widget == nil {
173		return
174	}
175
176	overlayAllocWidthBefore := pv.overlay.GetAllocatedWidth()
177	overlayAllocHeightBefore := pv.overlay.GetAllocatedHeight()
178	widgetAllocWidthBefore := widget.GetAllocatedWidth()
179	widgetAllocHeightBefore := widget.GetAllocatedHeight()
180	hadParent := widget.GetParent() != nil
181	wasVisible := widget.IsVisible()
182
183	// GTK4 widgets may have only one parent. Cleanup during a WorkspaceView
184	// rebuild leaves a reusable native widget unparented, which says nothing
185	// about whether its content was previously revealed.
186	if hadParent {
187		widget.Unparent()
188	}
189	pv.overlay.SetChild(widget)
190
191	if hadParent && wasVisible {
192		widget.SetVisible(true)
193	}
194	// A replacement can follow an already-revealed WebView in the same pane.
195	// Apply both states explicitly: an unrevealed current WebView must restart
196	// the skeleton rather than inheriting the previous view's hidden overlay.
197	if pv.loading != nil {
198		pv.loading.SetVisible(!revealed)
199	}
200
201	ctx := pv.ctx
202	if ctx == nil {
203		ctx = context.Background()
204	}
205	logging.FromContext(ctx).Debug().
206		Str("pane_id", string(pv.paneID)).
207		Bool("had_parent", hadParent).
208		Bool("was_visible", wasVisible).
209		Bool("revealed", revealed).
210		Int("overlay_alloc_width_before", overlayAllocWidthBefore).
211		Int("overlay_alloc_height_before", overlayAllocHeightBefore).
212		Int("widget_alloc_width_before", widgetAllocWidthBefore).
213		Int("widget_alloc_height_before", widgetAllocHeightBefore).
214		Int("overlay_alloc_width_after", pv.overlay.GetAllocatedWidth()).
215		Int("overlay_alloc_height_after", pv.overlay.GetAllocatedHeight()).
216		Int("widget_alloc_width_after", widget.GetAllocatedWidth()).
217		Int("widget_alloc_height_after", widget.GetAllocatedHeight()).
218		Msg("pane view webview widget attached")
219}
220
221// GrabFocus attempts to focus the WebView.
222// Returns true if focus was successfully grabbed.
223//
224// While a JS dialog is open it owns keyboard focus: activation paths call
225// SetActive(true) (which requests dialog focus) and then GrabFocus, which must
226// not steal focus back to the page.
227func (pv *PaneView) GrabFocus() bool {
228	pv.mu.RLock()
229	wv := pv.webViewWidget
230	popup := pv.jsDialog
231	pv.mu.RUnlock()
232
233	if popup != nil && popup.Visible() {
234		popup.RequestFocus()
235		return true
236	}
237
238	if wv == nil {
239		return false
240	}
241	return wv.GrabFocus()
242}
243
244// HasFocus returns whether the WebView currently has focus.
245func (pv *PaneView) HasFocus() bool {
246	pv.mu.RLock()
247	wv := pv.webViewWidget
248	pv.mu.RUnlock()
249
250	if wv == nil {
251		return false
252	}
253	return wv.HasFocus()
254}
255
256// SetOnFocusIn sets the callback for when the pane gains focus.
257func (pv *PaneView) SetOnFocusIn(fn func(paneID entity.PaneID)) {
258	pv.mu.Lock()
259	defer pv.mu.Unlock()
260
261	pv.onFocusIn = fn
262}
263
264// SetOnFocusOut sets the callback for when the pane loses focus.
265func (pv *PaneView) SetOnFocusOut(fn func(paneID entity.PaneID)) {
266	pv.mu.Lock()
267	defer pv.mu.Unlock()
268
269	pv.onFocusOut = fn
270}
271
272// SetOnHover sets the callback for when the pane is hovered.
273func (pv *PaneView) SetOnHover(fn func(paneID entity.PaneID)) {
274	pv.mu.Lock()
275	defer pv.mu.Unlock()
276
277	pv.onHover = fn
278}
279
280// SetOnMouseMotion sets a callback fired on intentional mouse movement within the pane.
281// Used to detect real user interaction vs synthetic GTK enter events from widget rearrangement.
282func (pv *PaneView) SetOnMouseMotion(fn func()) {
283	pv.mu.Lock()
284	defer pv.mu.Unlock()
285
286	pv.onMouseMotion = fn
287}
288
289// AttachHoverHandler creates and attaches a hover handler for focus-follows-mouse behavior.
290func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
291	pv.mu.Lock()
292	defer pv.mu.Unlock()
293
294	// Create hover handler
295	pv.hoverHandler = input.NewHoverHandler(ctx, pv.paneID)
296
297	// Wire up hover enter callback
298	pv.hoverHandler.SetOnEnter(func(paneID entity.PaneID) {
299		pv.mu.RLock()
300		callback := pv.onHover
301		pv.mu.RUnlock()
302
303		if callback != nil {
304			callback(paneID)
305		}
306	})
307
308	// Wire up mouse motion callback (intentional movement detection)
309	pv.hoverHandler.SetOnMotion(func() {
310		pv.mu.RLock()
311		callback := pv.onMouseMotion
312		pv.mu.RUnlock()
313
314		if callback != nil {
315			callback()
316		}
317	})
318
319	// Attach to overlay widget
320	gtkWidget := pv.overlay.GtkWidget()
321	if gtkWidget != nil {
322		pv.hoverHandler.AttachTo(gtkWidget)
323	}
324}
325
326// CancelPendingHover cancels any pending hover focus timer.
327// Called when keyboard navigation occurs to prevent hover from overriding keyboard focus.
328func (pv *PaneView) CancelPendingHover() {
329	pv.mu.RLock()
330	handler := pv.hoverHandler
331	pv.mu.RUnlock()
332
333	if handler != nil {
334		handler.Cancel()
335	}
336}
337
338// Widget returns the underlying overlay widget for embedding in containers.
339func (pv *PaneView) Widget() layout.Widget {
340	return pv.overlay
341}
342
343// Overlay returns the underlying overlay widget for direct access.
344func (pv *PaneView) Overlay() layout.OverlayWidget {
345	return pv.overlay
346}
347
348// Show makes the pane visible.
349func (pv *PaneView) Show() {
350	pv.overlay.Show()
351}
352
353// Hide makes the pane invisible.
354func (pv *PaneView) Hide() {
355	pv.overlay.Hide()
356}
357
358// SetVisible sets the visibility of the pane.
359func (pv *PaneView) SetVisible(visible bool) {
360	pv.overlay.SetVisible(visible)
361}
362
363// IsVisible returns whether the pane is visible.
364func (pv *PaneView) IsVisible() bool {
365	return pv.overlay.IsVisible()
366}
367
368// AddCssClass adds a CSS class to the overlay.
369func (pv *PaneView) AddCssClass(class string) {
370	pv.overlay.AddCssClass(class)
371}
372
373// RemoveCssClass removes a CSS class from the overlay.
374func (pv *PaneView) RemoveCssClass(class string) {
375	pv.overlay.RemoveCssClass(class)
376}
377
378// AddOverlayWidget adds a widget as an overlay on this pane.
379// The widget will appear above the WebView and border.
380func (pv *PaneView) AddOverlayWidget(widget layout.Widget) {
381	pv.overlay.AddOverlay(widget)
382	pv.overlay.SetClipOverlay(widget, false)
383	pv.overlay.SetMeasureOverlay(widget, false)
384}
385
386// RemoveOverlayWidget removes an overlay widget from this pane.
387func (pv *PaneView) RemoveOverlayWidget(widget layout.Widget) {
388	pv.overlay.RemoveOverlay(widget)
389}
390
391// GetContentDimensions returns the pane's allocated width and height.
392func (pv *PaneView) GetContentDimensions() (width, height int) {
393	return pv.overlay.GetAllocatedWidth(), pv.overlay.GetAllocatedHeight()
394}
395
396// ensureProgressBar creates the progress bar lazily on first use.
397// This avoids GTK measurement issues with the internal progress gizmo
398// that can occur when the widget is added before the overlay is realized.
399// Must be called with write lock held.
400func (pv *PaneView) ensureProgressBar() *ProgressBar {
401	if pv.progressBar != nil {
402		return pv.progressBar
403	}
404
405	ctx := pv.ctx
406	if ctx == nil {
407		ctx = context.Background()
408	}
409	logging.FromContext(ctx).
410		Debug().
411		Str("pane_id", string(pv.paneID)).
412		Msg("creating progress bar")
413
414	pb := NewProgressBar(pv.ctx, pv.factory)
415	pv.overlay.AddOverlay(pb.Widget())
416	pv.overlay.SetClipOverlay(pb.Widget(), false)
417	pv.overlay.SetMeasureOverlay(pb.Widget(), false)
418	pv.progressBar = pb
419	return pb
420}
421
422// SetLoadProgress updates the progress bar with the current load progress.
423// progress should be between 0.0 and 1.0.
424func (pv *PaneView) SetLoadProgress(progress float64) {
425	pv.mu.Lock()
426	pb := pv.ensureProgressBar()
427	ctx := pv.ctx
428	if ctx == nil {
429		ctx = context.Background()
430	}
431	logging.FromContext(ctx).
432		Trace().
433		Str("pane_id", string(pv.paneID)).
434		Float64("progress", progress).
435		Msg("setting load progress")
436	pv.mu.Unlock()
437
438	pb.SetProgress(progress)
439}
440
441// SetLoading shows or hides the progress bar.
442func (pv *PaneView) SetLoading(loading bool) {
443	pv.mu.Lock()
444	hadProgressBar := pv.progressBar != nil
445	pb := pv.ensureProgressBar()
446	ctx := pv.ctx
447	if ctx == nil {
448		ctx = context.Background()
449	}
450	logging.FromContext(ctx).
451		Debug().
452		Str("pane_id", string(pv.paneID)).
453		Bool("loading", loading).
454		Bool("progress_bar_existed", hadProgressBar).
455		Msg("setting loading state")
456	pv.mu.Unlock()
457
458	if loading {
459		pb.Show()
460	} else {
461		pb.HideWithReason("loading_false")
462	}
463}
464
465// HideLoadingSkeleton hides the loading skeleton overlay (if present).
466func (pv *PaneView) HideLoadingSkeleton() {
467	pv.mu.Lock()
468	loading := pv.loading
469	pv.mu.Unlock()
470
471	if loading != nil {
472		loading.SetVisible(false)
473	}
474}
475
476// ensureToaster creates the toaster lazily on first use.
477// Must be called with write lock held.
478func (pv *PaneView) ensureToaster() *Toaster {
479	if pv.toaster != nil {
480		return pv.toaster
481	}
482
483	t := NewToaster(pv.factory)
484	pv.overlay.AddOverlay(t.Widget())
485	pv.overlay.SetClipOverlay(t.Widget(), false)
486	pv.overlay.SetMeasureOverlay(t.Widget(), false)
487	pv.toaster = t
488	return t
489}
490
491// ShowToast displays a toast notification with the given message and level.
492// If a toast is already visible, updates the text and resets the dismiss timer.
493func (pv *PaneView) ShowToast(ctx context.Context, message string, level ToastLevel) {
494	pv.mu.Lock()
495	t := pv.ensureToaster()
496	pv.mu.Unlock()
497
498	t.Show(ctx, message, level)
499}
500
501// ShowZoomToast displays a zoom level toast notification.
502// Formats the zoom percentage with a % suffix.
503func (pv *PaneView) ShowZoomToast(ctx context.Context, zoomPercent int) {
504	pv.mu.Lock()
505	t := pv.ensureToaster()
506	pv.mu.Unlock()
507
508	t.ShowZoom(ctx, zoomPercent)
509}
510
511// ensureLinkStatus creates the link status overlay lazily on first use.
512// Must be called with write lock held.
513func (pv *PaneView) ensureLinkStatus() *LinkStatusOverlay {
514	if pv.linkStatus != nil {
515		return pv.linkStatus
516	}
517
518	ls := NewLinkStatusOverlay(pv.factory)
519	pv.overlay.AddOverlay(ls.Widget())
520	pv.overlay.SetClipOverlay(ls.Widget(), false)
521	pv.overlay.SetMeasureOverlay(ls.Widget(), false)
522	pv.linkStatus = ls
523	return ls
524}
525
526// ShowLinkStatus displays the link status overlay with the given URI.
527// If uri is empty, hides the overlay instead.
528func (pv *PaneView) ShowLinkStatus(uri string) {
529	pv.mu.Lock()
530	ls := pv.ensureLinkStatus()
531	pv.mu.Unlock()
532
533	ls.Show(uri)
534}
535
536// HideLinkStatus hides the link status overlay.
537func (pv *PaneView) HideLinkStatus() {
538	pv.mu.Lock()
539	ls := pv.linkStatus
540	pv.mu.Unlock()
541
542	if ls != nil {
543		ls.Hide()
544	}
545}
546
547// SetVimMode tracks the pane owning Vim navigation; modeFrame owns the border.
548func (pv *PaneView) SetVimMode(active bool) {
549	pv.mu.Lock()
550	defer pv.mu.Unlock()
551
552	if pv.vimMode == active {
553		return
554	}
555
556	pv.vimMode = active
557}
558
559// IsVimMode returns whether Vim mode is currently active on this pane.
560func (pv *PaneView) IsVimMode() bool {
561	pv.mu.RLock()
562	defer pv.mu.RUnlock()
563
564	return pv.vimMode
565}
566
567// Cleanup removes the WebView widget from the overlay and clears references.
568// This must be called before destroying the WebView to ensure proper GTK cleanup.
569// After calling Cleanup, the PaneView should not be reused.
570//
571// An open JS dialog is answered with cancel ("Stay" for beforeunload). That is
572// an accepted trade-off: WorkspaceView.Rebuild (split/close/consume/reattach)
573// cleans up views whose WebView survives, so such a dialog is canceled rather
574// than carried over to the rebuilt view. Canceling is the safe default.
575func (pv *PaneView) Cleanup() {
576	pv.mu.Lock()
577	pv.cleanedUp = true
578	// The WebView may outlive this view (workspace rebuild), so an open JS
579	// dialog must be answered, not dropped. The hidden callback re-locks
580	// pv.mu, so detach it here and dismiss after unlocking.
581	popup, popupWidget := pv.jsDialog, pv.jsDialogWidget
582	if popup != nil {
583		popup.SetOnHidden(nil)
584	}
585	pv.jsDialog, pv.jsDialogWidget = nil, nil
586	pv.cleanupLocked()
587	pv.mu.Unlock()
588
589	if popup != nil {
590		popup.Dismiss(false)
591		popup.Destroy()
592		if popupWidget != nil {
593			pv.overlay.RemoveOverlay(popupWidget)
594		}
595	}
596}
597
598// cleanupLocked releases everything except the JS dialog. Caller holds pv.mu.
599func (pv *PaneView) cleanupLocked() {
600	// Clear callbacks to prevent use-after-free
601	pv.onFocusIn = nil
602	pv.onFocusOut = nil
603	pv.onHover = nil
604	pv.onMouseMotion = nil
605
606	// Detach hover handler if present
607	if pv.hoverHandler != nil {
608		pv.hoverHandler.Detach()
609		pv.hoverHandler = nil
610	}
611
612	// Remove WebView from overlay (unparents it from GTK hierarchy)
613	if pv.webViewWidget != nil {
614		pv.overlay.SetChild(nil)
615		pv.webViewWidget = nil
616	}
617
618	// Clear other overlay children
619	if pv.loading != nil {
620		pv.overlay.RemoveOverlay(pv.loading.Widget())
621		pv.loading = nil
622	}
623	if pv.progressBar != nil {
624		pv.overlay.RemoveOverlay(pv.progressBar.Widget())
625		pv.progressBar = nil
626	}
627	if pv.toaster != nil {
628		pv.overlay.RemoveOverlay(pv.toaster.Widget())
629		pv.toaster = nil
630	}
631	if pv.linkStatus != nil {
632		pv.linkStatus.Cleanup() // Cancel pending timers before removal
633		pv.overlay.RemoveOverlay(pv.linkStatus.Widget())
634		pv.linkStatus = nil
635	}
636}
637
638// jsDialogView is the JS dialog popup as seen by PaneView (a seam for tests).
639type jsDialogView interface {
640	Widget() *gtk.Widget
641	Show(req port.JSDialogRequest, respond func(ok bool, input string), focus bool, uiScale float64)
642	Hide()
643	Dismiss(ok bool)
644	RequestFocus()
645	Visible() bool
646	SetOnHidden(fn func(hadFocus bool))
647	Destroy()
648}
649
650func newJSDialogPopupView(parent layout.OverlayWidget) jsDialogView {
651	if p := NewJSDialogPopup(parent); p != nil {
652		return p
653	}
654	return nil
655}
656
657// ensureJSDialog creates the JS dialog popup lazily on first use.
658// Must be called with write lock held.
659func (pv *PaneView) ensureJSDialog() jsDialogView {
660	if pv.jsDialog != nil {
661		return pv.jsDialog
662	}
663	create := pv.newJSDialog
664	if create == nil {
665		create = newJSDialogPopupView
666	}
667	popup := create(pv.overlay)
668	if popup == nil {
669		return nil
670	}
671	w := pv.factory.WrapWidget(popup.Widget())
672	pv.overlay.AddOverlay(w)
673	// Clip so the scrim stays inside the pane; it must not affect layout.
674	pv.overlay.SetClipOverlay(w, true)
675	pv.overlay.SetMeasureOverlay(w, false)
676	popup.SetOnHidden(func(hadFocus bool) {
677		// Give focus back to the page only if the dialog had taken it.
678		if !hadFocus {
679			return
680		}
681		pv.mu.RLock()
682		wvw := pv.webViewWidget
683		pv.mu.RUnlock()
684		if wvw != nil {
685			wvw.GrabFocus()
686		}
687	})
688	pv.jsDialog = popup
689	pv.jsDialogWidget = w
690	return popup
691}
692
693// ShowJSDialog displays a JavaScript dialog scoped to this pane. The dialog
694// lives in the pane's overlay, so it never blocks other panes; if the pane is
695// in a background tab it becomes visible when that tab is shown, and it only
696// takes keyboard focus while the pane is active (otherwise when it becomes
697// active). respond is invoked once with the user's answer. Returns false if the
698// popup is unavailable.
699func (pv *PaneView) ShowJSDialog(req port.JSDialogRequest, respond func(ok bool, input string), uiScale float64) bool {
700	pv.mu.Lock()
701	if pv.cleanedUp {
702		pv.mu.Unlock()
703		return false
704	}
705	popup := pv.ensureJSDialog()
706	focus := pv.isActive
707	pv.mu.Unlock()
708	if popup == nil {
709		return false
710	}
711	popup.Show(req, respond, focus, uiScale)
712	return true
713}
714
715// HideJSDialog closes any JS dialog without answering it (engine-canceled).
716func (pv *PaneView) HideJSDialog() {
717	pv.mu.RLock()
718	popup := pv.jsDialog
719	pv.mu.RUnlock()
720	if popup != nil {
721		popup.Hide()
722	}
723}

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