v0.14.0 76d1892

workspace_popup.go RawHistoryFolder

1// workspace_popup.go - Popup window handling and related pane management
2package browser
3
4import (
5	"log"
6	"strings"
7	"time"
8
9	"github.com/bnema/dumber/internal/config"
10	"github.com/bnema/dumber/pkg/webkit"
11	webkitgtk "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
12)
13
14const (
15	// Default popup window dimensions - sized for OAuth flows
16	defaultPopupMinWidth  = 500 // Minimum width for OAuth popups
17	defaultPopupMinHeight = 600 // Minimum height for OAuth popups
18)
19
20// HandlePopup is DEPRECATED and removed.
21// Popup lifecycle now uses WebKit's native create/ready-to-show/close signals.
22// See setupPopupHandling(), handlePopupReadyToShow(), and handlePopupClose() instead.
23
24// registerOAuthAutoClose sets up OAuth auto-close functionality for popups
25// Note: OAuth detection is now handled by the main-world.js injection script
26func (wm *WorkspaceManager) registerOAuthAutoClose(view *webkit.WebView, url string) {
27	log.Printf("[workspace] OAuth auto-close enabled for popup with URL: %s", url)
28	log.Printf("[workspace] OAuth detection will be handled by main-world.js injection script")
29}
30
31// insertIndependentPane inserts a new independent pane next to the source
32func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error {
33	newPane, err := wm.createPane(webView)
34	if err != nil {
35		return err
36	}
37	direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
38	if direction == "" {
39		direction = DirectionRight
40	}
41	// Use splitNode with existing pane instead of insertPopupPane
42	if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil {
43		return err
44	}
45	node := wm.viewToNode[webView]
46	if node != nil {
47		node.windowType = webkit.WindowTypeTab
48		node.isRelated = false
49	}
50	if url != "" {
51		_ = webView.LoadURL(url)
52	}
53	return nil
54}
55
56// configureRelatedPopup creates a related view and inserts it
57func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView *webkit.WebView, url string, feat *webkit.WindowFeatures) {
58	// Use the WebView that was already created and returned to WebKit
59	related := webView
60	newPane, err := wm.createPane(related)
61	if err != nil {
62		log.Printf("[workspace] failed to create related popup pane: %v", err)
63		return
64	}
65	direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
66	if direction == "" {
67		direction = DirectionRight
68	}
69	// Use splitNode with existing pane instead of insertPopupPane
70	if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil {
71		log.Printf("[workspace] failed to insert related popup pane: %v", err)
72		return
73	}
74	node := wm.viewToNode[related]
75	popupWebViewID := related.IDString()
76
77	if node != nil {
78		node.windowType = webkit.WindowTypePopup
79		node.windowFeatures = feat
80		node.isRelated = true
81		node.parentPane = sourceNode
82		node.isPopup = true
83		// Heuristic + config for auto-close intent
84		node.autoClose = wm.shouldAutoClose(url)
85
86		// Apply minimum size constraints to prevent compression
87		var width, height int
88		if feat != nil {
89			width = feat.Width
90			height = feat.Height
91		}
92		wm.applyPopupSizeConstraints(related, width, height)
93	}
94
95	// Track this popup in the parent's activePopupChildren
96	// This is critical to prevent OAuth flows from hijacking the parent pane via window.opener
97	if sourceNode != nil && popupWebViewID != "" {
98		if sourceNode.activePopupChildren == nil {
99			sourceNode.activePopupChildren = make([]string, 0)
100		}
101		sourceNode.activePopupChildren = append(sourceNode.activePopupChildren, popupWebViewID)
102		log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
103
104		// Register navigation policy handler for parent to block script-initiated navigations
105		// when it has active popup children (prevents OAuth from hijacking parent pane)
106		if sourceNode.pane != nil && sourceNode.pane.WebView() != nil {
107			sourceNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
108				// Always allow user-initiated navigations
109				if isUserGesture {
110					return true
111				}
112
113				// Block script-initiated navigations if this pane has active popup children
114				if len(sourceNode.activePopupChildren) > 0 {
115					log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(sourceNode.activePopupChildren), url)
116					return false
117				}
118
119				// Allow if no active popups
120				return true
121			})
122			log.Printf("[workspace] Registered navigation policy handler for parent pane")
123		}
124	}
125
126	// Pipe into existing auto-close flow only for popups (confirmed by detection)
127	related.RegisterCloseHandler(func() {
128		log.Printf("[workspace] Popup requesting close via window.close()")
129
130		// Remove this popup from parent's activePopupChildren
131		if sourceNode != nil && popupWebViewID != "" {
132			for i, childID := range sourceNode.activePopupChildren {
133				if childID == popupWebViewID {
134					sourceNode.activePopupChildren = append(sourceNode.activePopupChildren[:i], sourceNode.activePopupChildren[i+1:]...)
135					log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
136					break
137				}
138			}
139		}
140
141		if n := wm.viewToNode[related]; n != nil && n.isPopup {
142			time.AfterFunc(200*time.Millisecond, func() {
143				wm.scheduleIdleGuarded(func() bool {
144					if n == nil || !n.widgetValid {
145						return false
146					}
147					if err := wm.ClosePane(n); err != nil {
148						log.Printf("[workspace] Failed to close popup pane: %v", err)
149					}
150					return false
151				}, n)
152			})
153		}
154	})
155
156	// URL-based fallback: if providers don't call window.close(), auto-close on OAuth callback URLs
157	if node != nil && node.isPopup && node.autoClose {
158		wm.registerOAuthAutoClose(related, url)
159	}
160	if url != "" {
161		_ = related.LoadURL(url)
162	}
163}
164
165// applyPopupSizeConstraints applies minimum size constraints to prevent OAuth popup compression
166func (wm *WorkspaceManager) applyPopupSizeConstraints(view *webkit.WebView, width, height int) {
167	minWidth := defaultPopupMinWidth
168	minHeight := defaultPopupMinHeight
169
170	// Use provided dimensions if valid
171	if width > 0 {
172		minWidth = width
173	}
174	if height > 0 {
175		minHeight = height
176	}
177
178	// Apply to container
179	if container := view.RootWidget(); container != nil {
180		webkit.WidgetSetSizeRequest(container, minWidth, minHeight)
181		webkit.WidgetQueueResize(container)
182		log.Printf("[workspace] Applied minimum size %dx%d to popup container=%p", minWidth, minHeight, container)
183	}
184
185	// Apply to WebView widget
186	if webViewWidget := view.Widget(); webViewWidget != nil {
187		webkit.WidgetSetSizeRequest(webViewWidget, minWidth, minHeight)
188		webkit.WidgetQueueResize(webViewWidget)
189		log.Printf("[workspace] Applied minimum size %dx%d to popup webview=%p", minWidth, minHeight, webViewWidget)
190	}
191}
192
193// shouldAutoClose checks simple OAuth-like URL patterns and config flag
194func (wm *WorkspaceManager) shouldAutoClose(url string) bool {
195	log.Printf("[workspace] shouldAutoClose called for URL: %s", url)
196
197	if wm == nil || wm.app == nil || wm.app.config == nil {
198		log.Printf("[workspace] shouldAutoClose: missing config, returning true")
199		return true
200	}
201	if !wm.app.config.Workspace.Popups.OAuthAutoClose {
202		log.Printf("[workspace] shouldAutoClose: OAuthAutoClose disabled in config, returning false")
203		return false
204	}
205
206	u := strings.ToLower(url)
207	log.Printf("[workspace] shouldAutoClose: checking lowercase URL: %s", u)
208
209	// RFC 6749 compliant OAuth 2.0 URL patterns
210	oauthPatterns := []string{
211		// Standard OAuth endpoints
212		"oauth", "authorize", "authorization",
213		// Standard callback/redirect patterns
214		"callback", "redirect", "auth/callback",
215		// OpenID Connect patterns
216		"oidc", "openid",
217		// Common OAuth parameter indicators
218		"response_type=", "client_id=", "redirect_uri=", "scope=", "state=",
219		// Standard OAuth response parameters
220		"code=", "access_token=", "id_token=", "token_type=",
221		// Error response parameters
222		"error=", "error_description=",
223	}
224
225	// Check for OAuth patterns in URL
226	for _, pattern := range oauthPatterns {
227		if strings.Contains(u, pattern) {
228			log.Printf("[workspace] shouldAutoClose: MATCHED pattern '%s' in URL, returning true", pattern)
229			return true
230		}
231	}
232
233	log.Printf("[workspace] shouldAutoClose: no OAuth patterns matched, returning false")
234	return false
235}
236
237// RunOnUI schedules a function; here simply executes inline as GTK main loop is single-threaded
238func (wm *WorkspaceManager) RunOnUI(fn func()) {
239	if fn != nil {
240		fn()
241	}
242}
243
244// handleDetectedWindowType handles window type detection from smart detection path
245func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webView *webkit.WebView, url string, windowType webkit.WindowType, features *webkit.WindowFeatures) {
246	if wm.viewToNode[webView] != nil {
247		return // Already placed
248	}
249
250	log.Printf("[workspace] Smart detection result: type=%d url=%s", windowType, url)
251
252	switch windowType {
253	case webkit.WindowTypeTab:
254		// For tabs, create a NEW independent WebView (can't use the related one)
255		webkitCfg, err := wm.app.buildWebkitConfig()
256		if err != nil {
257			log.Printf("[workspace] failed to build webkit config for tab: %v", err)
258			return
259		}
260		// Tabs are embedded in workspace paned containers - no separate window needed
261		webkitCfg.CreateWindow = false
262
263		// Create independent WebView matching our standard tab insertion path
264		independentView, err := webkit.NewWebView(webkitCfg)
265		if err != nil {
266			log.Printf("[workspace] failed to create independent tab WebView: %v", err)
267			return
268		}
269
270		// The related webView was just for detection - we don't use it for tabs
271		// Insert the new independent view as a tab
272		if err := wm.insertIndependentPane(sourceNode, independentView, url); err != nil {
273			log.Printf("[workspace] Failed to insert independent pane: %v", err)
274		}
275
276	case webkit.WindowTypePopup:
277		// For popups, use the related WebView we already created
278		wm.configureRelatedPopup(sourceNode, webView, url, features)
279	default:
280		// Fallback to popup behavior
281		wm.configureRelatedPopup(sourceNode, webView, url, features)
282	}
283}
284
285// setupPopupHandling connects WebView's create signal for popup lifecycle management
286// This implements the WebKit-native popup lifecycle as described in POPUP_REFACTORING_PLAN.md
287func (wm *WorkspaceManager) setupPopupHandling(webView *webkit.WebView, parentNode *paneNode) {
288	if webView == nil || parentNode == nil {
289		return
290	}
291
292	webView.RegisterPopupCreateHandler(func(navAction *webkitgtk.NavigationAction) *webkit.WebView {
293		// Get URL from navigation action
294		req := navAction.Request()
295		if req == nil {
296			log.Printf("[workspace] create signal: no request in navigation action")
297			return nil
298		}
299		url := req.URI()
300
301		log.Printf("[workspace] create signal received for URL: %s", url)
302
303		// Check if popups are enabled in config
304		if wm.app.config != nil && !wm.app.config.Workspace.Popups.OpenInNewPane {
305			log.Printf("[workspace] Popup creation disabled in config - blocking popup")
306			return nil
307		}
308
309		// CRITICAL: Create a BARE gotk4 WebView as RELATED to parent (no wrapping, no initialization)
310		// WebKit requires the related-view property to be set during construction for proper lifecycle
311		// This ensures session/cookie sharing and proper WindowFeatures initialization
312		// All initialization (wrapping, container, scripts) happens in ready-to-show
313		log.Printf("[workspace] Step 1: Creating bare gotk4 WebView with related-view property")
314		barePopupView := webkit.NewBareRelatedWebView(webView.GtkWebView())
315		if barePopupView == nil {
316			log.Printf("[workspace] Failed to create bare related popup WebView - blocking popup")
317			return nil
318		}
319		log.Printf("[workspace] Step 1 complete: bare related gotk4 WebView created=%p (parent=%p)", barePopupView, webView.GtkWebView())
320
321		// Wrap the bare view immediately to get a proper ID
322		// BUT don't initialize it yet - that happens in ready-to-show
323		log.Printf("[workspace] Step 2: Wrapping bare view (no initialization)")
324		wrappedView := webkit.WrapBareWebView(barePopupView)
325		if wrappedView == nil {
326			log.Printf("[workspace] Failed to wrap bare popup WebView - blocking popup")
327			return nil
328		}
329
330		// Use the wrapper's ID for tracking
331		popupID := wrappedView.ID()
332		log.Printf("[workspace] Step 2 complete: wrapped view created with ID=%d", popupID)
333
334		// Store wrapped view (not initialized) for initialization in ready-to-show
335		log.Printf("[workspace] Step 3: Storing in pendingPopups map")
336		wm.pendingPopups[popupID] = &pendingPopup{
337			wrappedView: wrappedView,
338			parentView:  webView,
339			parentNode:  parentNode,
340			url:         url,
341		}
342		log.Printf("[workspace] Step 3 complete: stored in pendingPopups")
343
344		// Connect signals immediately BEFORE returning to WebKit
345		// This is safe because we're only connecting signal handlers, not accessing properties
346		// WebKit will configure WindowFeatures AFTER this callback returns, which is fine
347		log.Printf("[workspace] Step 4: Connecting signals immediately before returning to WebKit")
348
349		// Connect ready-to-show signal
350		barePopupView.ConnectReadyToShow(func() {
351			log.Printf("[workspace] ready-to-show signal received for popup ID=%d", popupID)
352			wm.handlePopupReadyToShow(popupID)
353		})
354		log.Printf("[workspace] Connected ready-to-show signal for popup ID=%d", popupID)
355
356		// Connect close handler
357		barePopupView.ConnectClose(func() {
358			log.Printf("[workspace] close signal received for popup ID=%d", popupID)
359			wm.handlePopupClose(popupID)
360		})
361		log.Printf("[workspace] Connected close signal for popup ID=%d", popupID)
362
363		log.Printf("[workspace] Step 4 complete: signals connected")
364
365		log.Printf("[workspace] Step 5: Returning wrapped view to WebKit (will extract bare view in ConnectCreate)")
366
367		// Return the wrapped view - WebKit will extract the underlying gotk4 view and configure it
368		return wrappedView
369	})
370}
371
372// handlePopupReadyToShow inserts popup pane when WebKit says it's ready
373func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
374	info, ok := wm.pendingPopups[popupID]
375	if !ok {
376		log.Printf("[workspace] ready-to-show for unknown popup ID: %d", popupID)
377		return
378	}
379	delete(wm.pendingPopups, popupID)
380
381	log.Printf("[workspace] ready-to-show received for popup ID=%d, starting full initialization", popupID)
382
383	// NOW it's safe to initialize (WebKit has configured WindowProperties)
384	// We already have the wrapped view from setupPopupHandling
385	wrappedView := info.wrappedView
386
387	// Build webkit config for initialization
388	webkitCfg, err := wm.app.buildWebkitConfig()
389	if err != nil {
390		log.Printf("[workspace] Failed to build webkit config for popup initialization: %v", err)
391		return
392	}
393	webkitCfg.CreateWindow = false // Embedded in workspace
394
395	// Initialize the wrapped view with full configuration
396	if err := wrappedView.InitializeFromBare(webkitCfg); err != nil {
397		log.Printf("[workspace] Failed to initialize popup WebView: %v", err)
398		return
399	}
400
401	log.Printf("[workspace] Successfully initialized popup WebView ID %d", wrappedView.ID())
402
403	// Create pane for the fully initialized WebView
404	popupPane, err := wm.createPane(wrappedView)
405	if err != nil {
406		log.Printf("[workspace] Failed to create popup pane: %v", err)
407		return
408	}
409
410	log.Printf("[workspace] Created popup pane, now inserting into workspace")
411
412	// Determine window type from node (set during setupPopupHandling)
413	node := wm.viewToNode[wrappedView]
414	windowType := webkit.WindowTypePopup
415	if node != nil {
416		windowType = node.windowType
417	}
418
419	// Select behavior based on window type:
420	// - WindowTypeTab (_blank links): Use BlankTargetBehavior config
421	// - WindowTypePopup (JavaScript popups): Use global Behavior config
422	var behaviorStr string
423	if windowType == webkit.WindowTypeTab {
424		behaviorStr = wm.app.config.Workspace.Popups.BlankTargetBehavior
425		log.Printf("[workspace] WindowTypeTab (_blank link) detected, using BlankTargetBehavior: %s", behaviorStr)
426	} else {
427		behaviorStr = string(wm.app.config.Workspace.Popups.Behavior)
428		log.Printf("[workspace] WindowTypePopup detected, using Behavior: %s", behaviorStr)
429	}
430
431	// Map string to PopupBehavior enum
432	var behavior config.PopupBehavior
433	switch behaviorStr {
434	case "split":
435		behavior = config.PopupBehaviorSplit
436	case "stacked":
437		behavior = config.PopupBehaviorStacked
438	case "tabbed":
439		behavior = config.PopupBehaviorTabbed
440	default:
441		behavior = config.PopupBehaviorSplit // Fallback
442	}
443
444	log.Printf("[workspace] Final behavior: %s", behavior)
445
446	switch behavior {
447	case config.PopupBehaviorSplit:
448		// Split pane behavior (default) - use splitNode with existing popup pane
449		direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
450		if direction == "" {
451			direction = DirectionRight
452		}
453		if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
454			log.Printf("[workspace] Failed to insert split popup pane: %v", err)
455			return
456		}
457
458	case config.PopupBehaviorStacked:
459		// Stacked pane behavior
460		_, err := wm.stackedPaneManager.StackPane(info.parentNode)
461		if err != nil {
462			log.Printf("[workspace] Failed to stack existing pane: %v", err)
463			return
464		}
465		// Insert the popup pane in the same container - use splitNode with existing popup pane
466		direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
467		if direction == "" {
468			direction = DirectionRight
469		}
470		if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
471			log.Printf("[workspace] Failed to insert stacked popup pane: %v", err)
472			return
473		}
474
475	case config.PopupBehaviorTabbed:
476		// Tabbed behavior - TODO: implement proper tabbed interface
477		log.Printf("[workspace] WARNING: Tabbed popup behavior not yet implemented, falling back to split")
478		direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
479		if direction == "" {
480			direction = DirectionRight
481		}
482		if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
483			log.Printf("[workspace] Failed to insert tabbed popup pane: %v", err)
484			return
485		}
486
487	case config.PopupBehaviorWindowed:
488		// Windowed behavior - opens in new workspace/window
489		// TODO: implement new window creation with GTK
490		log.Printf("[workspace] WARNING: Windowed popup behavior not yet implemented, falling back to split")
491		direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
492		if direction == "" {
493			direction = DirectionRight
494		}
495		if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
496			log.Printf("[workspace] Failed to insert windowed popup pane: %v", err)
497			return
498		}
499
500	default:
501		// Fallback to split if unknown behavior
502		log.Printf("[workspace] Unknown popup behavior '%s', falling back to split", behavior)
503		direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
504		if direction == "" {
505			direction = DirectionRight
506		}
507		if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
508			log.Printf("[workspace] Failed to insert popup pane: %v", err)
509			return
510		}
511	}
512
513	// Configure the popup node
514	// Note: windowType should already be set correctly from setupPopupHandling
515	// Don't overwrite it here - preserve WindowTypeTab for _blank links
516	node = wm.viewToNode[wrappedView]
517	if node != nil {
518		node.isRelated = true
519		node.parentPane = info.parentNode
520		node.isPopup = true // Mark as popup to prevent browser exit when parent closes
521		node.autoClose = wm.shouldAutoClose(info.url)
522		node.popupID = popupID // Store popupID for close signal lookup
523
524		// Track this popup in the parent's activePopupChildren
525		if info.parentNode != nil {
526			popupWebViewID := wrappedView.IDString()
527			if info.parentNode.activePopupChildren == nil {
528				info.parentNode.activePopupChildren = make([]string, 0)
529			}
530			info.parentNode.activePopupChildren = append(info.parentNode.activePopupChildren, popupWebViewID)
531			log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)",
532				popupWebViewID, len(info.parentNode.activePopupChildren))
533
534			// Register navigation policy handler for parent
535			if info.parentNode.pane != nil && info.parentNode.pane.WebView() != nil {
536				info.parentNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
537					if isUserGesture {
538						return true
539					}
540					if len(info.parentNode.activePopupChildren) > 0 {
541						log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s",
542							len(info.parentNode.activePopupChildren), url)
543						return false
544					}
545					return true
546				})
547			}
548		}
549
550		// URL-based auto-close for OAuth popups
551		if node.autoClose {
552			wm.registerOAuthAutoClose(wrappedView, info.url)
553		}
554	}
555
556	wm.ensureGUIInPane(popupPane)
557
558	// Widgets are already shown by splitNode - no need to show again
559	// GTK4 handles visibility automatically during reparenting
560
561	log.Printf("[workspace] Popup pane inserted and visible for URL: %s", info.url)
562}
563
564// handlePopupClose responds to WebKit's close signal
565func (wm *WorkspaceManager) handlePopupClose(popupID uint64) {
566	log.Printf("[workspace] close signal received for popup native=%d", popupID)
567
568	// Check if it's in pending (not yet shown)
569	if info, ok := wm.pendingPopups[popupID]; ok {
570		delete(wm.pendingPopups, popupID)
571		log.Printf("[workspace] Popup closed before ready-to-show, cleaning up")
572		// Just remove from pending, WebKit will handle cleanup
573		_ = info // avoid unused variable
574		return
575	}
576
577	// Find pane by popupID (stored in node during ready-to-show)
578	var targetNode *paneNode
579	for _, node := range wm.viewToNode {
580		if node != nil && node.popupID == popupID {
581			targetNode = node
582			break
583		}
584	}
585
586	if targetNode == nil {
587		log.Printf("[workspace] close signal for unknown popup ID=%d, already closed?", popupID)
588		return
589	}
590
591	// Remove from parent's activePopupChildren
592	// Find the wrapped WebView for this node to get its IDString
593	var wrappedViewID string
594	for webView, node := range wm.viewToNode {
595		if node == targetNode {
596			wrappedViewID = webView.IDString()
597			break
598		}
599	}
600
601	if targetNode.parentPane != nil && wrappedViewID != "" {
602		for i, childID := range targetNode.parentPane.activePopupChildren {
603			if childID == wrappedViewID {
604				targetNode.parentPane.activePopupChildren = append(
605					targetNode.parentPane.activePopupChildren[:i],
606					targetNode.parentPane.activePopupChildren[i+1:]...)
607				log.Printf("[workspace] Removed popup from parent's activePopupChildren (remaining: %d)",
608					len(targetNode.parentPane.activePopupChildren))
609				break
610			}
611		}
612	}
613
614	// Clean close via existing ClosePane (WebKit already stopped)
615	log.Printf("[workspace] Closing popup pane via ClosePane")
616	if err := wm.ClosePane(targetNode); err != nil {
617		log.Printf("[workspace] Error closing popup pane: %v", err)
618	}
619}

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