v0.15.0 9006957

workspace_popup.go RawHistoryFolder

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

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