v0.11.0 dc50343

workspace_popup.go RawHistoryFolder

1// workspace_popup.go - Popup window handling and related pane management
2package browser
3
4import (
5	"fmt"
6	"log"
7	"strings"
8	"time"
9
10	"github.com/bnema/dumber/internal/app/messaging"
11	"github.com/bnema/dumber/pkg/webkit"
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 handles popup window creation requests from WebViews
21func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *webkit.WebView {
22	log.Printf("[workspace] HandlePopup called for URL: %s", url)
23
24	// Check for frame type markers added by WebKit layer
25	isBlankTarget := strings.HasSuffix(url, "#__dumber_frame_blank")
26	isPopupTarget := strings.HasSuffix(url, "#__dumber_frame_popup")
27
28	// Clean the URL by removing our markers
29	if isBlankTarget || isPopupTarget {
30		if isBlankTarget {
31			url = strings.TrimSuffix(url, "#__dumber_frame_blank")
32			log.Printf("[workspace] Detected _blank target - will create regular pane for: %s", url)
33		} else {
34			url = strings.TrimSuffix(url, "#__dumber_frame_popup")
35			log.Printf("[workspace] Detected popup target - will create popup pane for: %s", url)
36		}
37	}
38
39	if wm == nil || source == nil {
40		log.Printf("[workspace] HandlePopup: nil workspace manager or source - allowing native popup")
41		return nil
42	}
43
44	node := wm.viewToNode[source]
45	if node == nil {
46		log.Printf("[workspace] popup from unknown webview - allowing native popup")
47		return nil
48	}
49
50	// Note: HandlePopup is now obsolete - window.open is handled directly via JavaScript bypass
51
52	cfg := wm.app.config
53	if cfg == nil {
54		log.Printf("[workspace] HandlePopup: nil config - allowing native popup")
55		return nil
56	}
57
58	popCfg := cfg.Workspace.Popups
59	log.Printf("[workspace] Popup config - OpenInNewPane: %v, Placement: %s", popCfg.OpenInNewPane, popCfg.Placement)
60
61	if !popCfg.OpenInNewPane {
62		log.Printf("[workspace] Popup creation disabled in config - allowing native popup")
63		return nil
64	}
65
66	// Smart detection path: create temporary view and decide placement once type is known
67	if popCfg.EnableSmartDetection {
68		webkitCfg, err := wm.app.buildWebkitConfig()
69		if err != nil {
70			log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
71			return nil
72		}
73		webkitCfg.CreateWindow = false
74		// Create as related to avoid WindowFeatures crash; we'll decide final placement later
75		newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
76		if err != nil {
77			log.Printf("[workspace] failed to create temp WebView: %v - allowing native popup", err)
78			return nil
79		}
80
81		// Register detection callback
82		newView.OnWindowTypeDetected(func(t webkit.WindowType, feat *webkit.WindowFeatures) {
83			wm.RunOnUI(func() {
84				wm.handleDetectedWindowType(node, newView, url, t, feat)
85			})
86		})
87
88		// Fallback: if detection never fires, treat as popup as before
89		go func() {
90			time.Sleep(1500 * time.Millisecond)
91			if newView != nil {
92				wm.RunOnUI(func() {
93					if wm.viewToNode[newView] == nil { // not yet placed
94						wm.handleDetectedWindowType(node, newView, url, webkit.WindowTypePopup, nil)
95					}
96				})
97			}
98		}()
99
100		return newView
101	}
102
103	// Legacy path preserved
104	webkitCfg, err := wm.app.buildWebkitConfig()
105	if err != nil {
106		log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
107		return nil
108	}
109	webkitCfg.CreateWindow = false
110	newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
111	if err != nil {
112		log.Printf("[workspace] failed to create placeholder WebView: %v - allowing native popup", err)
113		return nil
114	}
115
116	// Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
117
118	// Create a pane for the new WebView
119	newPane, err := wm.createPane(newView)
120	if err != nil {
121		log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
122		return nil
123	}
124
125	// Determine placement direction
126	direction := strings.ToLower(popCfg.Placement)
127	if direction == "" {
128		direction = "right"
129	}
130
131	// Determine target node for splitting
132	target := node
133	if !popCfg.FollowPaneContext && wm.GetActiveNode() != nil {
134		target = wm.GetActiveNode()
135	}
136
137	// Add the popup pane to the workspace using manual pane insertion
138	if err := wm.insertPopupPane(target, newPane, direction); err != nil {
139		log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
140		return nil
141	}
142
143	// Get the new node and popup WebView ID for tracking
144	newNode := wm.viewToNode[newView]
145	popupWebViewID := newView.ID()
146
147	// Apply different behavior based on target type
148	if isBlankTarget {
149		log.Printf("[workspace] Treating _blank target as regular pane - no auto-close behavior")
150		// For _blank targets, set up parent relationship but no auto-close
151		if newNode != nil {
152			newNode.parentPane = node
153		}
154
155		// Register close handler to clean up activePopupChildren tracking
156		newView.RegisterCloseHandler(func() {
157			log.Printf("[workspace] _blank popup requesting close via window.close()")
158			// Remove this popup from parent's activePopupChildren
159			if node != nil && popupWebViewID != "" {
160				for i, childID := range node.activePopupChildren {
161					if childID == popupWebViewID {
162						node.activePopupChildren = append(node.activePopupChildren[:i], node.activePopupChildren[i+1:]...)
163						log.Printf("[workspace] Removed _blank popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(node.activePopupChildren))
164						break
165					}
166				}
167			}
168		})
169	} else {
170		log.Printf("[workspace] Treating as popup pane - applying popup-specific behavior")
171		// Mark as popup for auto-close handling (OAuth flows, etc.)
172		if newNode != nil {
173			newNode.isPopup = true
174			newNode.parentPane = node
175			log.Printf("[workspace] Marked pane as popup for auto-close handling")
176
177			// Register close handler for popup auto-close on window.close()
178			newView.RegisterCloseHandler(func() {
179				log.Printf("[workspace] Popup requesting close via window.close()")
180
181				// Remove this popup from parent's activePopupChildren
182				if node != nil && popupWebViewID != "" {
183					for i, childID := range node.activePopupChildren {
184						if childID == popupWebViewID {
185							node.activePopupChildren = append(node.activePopupChildren[:i], node.activePopupChildren[i+1:]...)
186							log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(node.activePopupChildren))
187							break
188						}
189					}
190				}
191
192				// Look up the node at close time
193				if node := wm.viewToNode[newView]; node != nil && node.isPopup {
194					log.Printf("[workspace] Closing popup pane")
195					// Brief delay to allow any final redirects to complete
196					time.AfterFunc(200*time.Millisecond, func() {
197						wm.scheduleIdleGuarded(func() bool {
198							if node == nil || !node.widgetValid {
199								return false
200							}
201							if err := wm.ClosePane(node); err != nil {
202								log.Printf("[workspace] Failed to close popup pane: %v", err)
203							}
204							return false
205						}, node)
206					})
207				} else {
208					log.Printf("[workspace] Could not find popup node for close handler")
209				}
210			})
211		} else {
212			log.Printf("[workspace] Warning: could not find node for popup WebView in viewToNode map")
213		}
214	}
215
216	// Track this popup in the parent's activePopupChildren (for both _blank and popup types)
217	// This is critical to prevent OAuth flows from hijacking the parent pane via window.opener
218	if node != nil && popupWebViewID != "" {
219		if node.activePopupChildren == nil {
220			node.activePopupChildren = make([]string, 0)
221		}
222		node.activePopupChildren = append(node.activePopupChildren, popupWebViewID)
223		log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(node.activePopupChildren))
224
225		// Register navigation policy handler for parent to block script-initiated navigations
226		// when it has active popup children (prevents OAuth from hijacking parent pane)
227		if node.pane != nil && node.pane.WebView() != nil {
228			node.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
229				// Always allow user-initiated navigations
230				if isUserGesture {
231					return true
232				}
233
234				// Block script-initiated navigations if this pane has active popup children
235				if len(node.activePopupChildren) > 0 {
236					log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(node.activePopupChildren), url)
237					return false
238				}
239
240				// Allow if no active popups
241				return true
242			})
243			log.Printf("[workspace] Registered navigation policy handler for parent pane")
244		}
245	}
246
247	// Ensure GUI components are available in the new pane
248	wm.ensureGUIInPane(newPane)
249
250	// Inject GUI components into the popup pane
251	wm.ensureGUIInPane(newPane)
252
253	// Load the URL if provided
254	if url != "" {
255		paneType := "popup"
256		if isBlankTarget {
257			paneType = "_blank target"
258		}
259		log.Printf("[webkit] LoadURL (%s): %s", paneType, url)
260		if err := newView.LoadURL(url); err != nil {
261			log.Printf("[workspace] failed to load %s URL: %v", paneType, err)
262		}
263		// Ensure the WebView is visible after loading URL
264		if err := newView.Show(); err != nil {
265			log.Printf("[workspace] failed to show popup WebView: %v", err)
266		}
267	}
268
269	if isBlankTarget {
270		log.Printf("[workspace] Created regular pane for _blank target URL: %s", url)
271	} else {
272		log.Printf("[workspace] Created popup pane for URL: %s", url)
273	}
274	return newView
275}
276
277// registerOAuthAutoClose sets up OAuth auto-close functionality for popups
278// Note: OAuth detection is now handled by the main-world.js injection script
279func (wm *WorkspaceManager) registerOAuthAutoClose(view *webkit.WebView, url string) {
280	log.Printf("[workspace] OAuth auto-close enabled for popup with URL: %s", url)
281	log.Printf("[workspace] OAuth detection will be handled by main-world.js injection script")
282}
283
284// applyWindowFeatures applies window features to a WebView based on intent
285func (wm *WorkspaceManager) applyWindowFeatures(view *webkit.WebView, intent *messaging.WindowIntent, isPopup bool) {
286	if intent == nil {
287		return
288	}
289
290	features := &webkit.WindowFeatures{}
291
292	// Apply dimensions if specified
293	if intent.Width != nil {
294		features.Width = *intent.Width
295	}
296	if intent.Height != nil {
297		features.Height = *intent.Height
298	}
299
300	// Apply visibility features based on window type
301	defaultToolbar := !isPopup
302	defaultLocation := !isPopup
303	defaultMenubar := !isPopup
304
305	if intent.Toolbar != nil {
306		features.ToolbarVisible = *intent.Toolbar
307	} else {
308		features.ToolbarVisible = defaultToolbar
309	}
310
311	if intent.Location != nil {
312		features.LocationbarVisible = *intent.Location
313	} else {
314		features.LocationbarVisible = defaultLocation
315	}
316
317	if intent.Menubar != nil {
318		features.MenubarVisible = *intent.Menubar
319	} else {
320		features.MenubarVisible = defaultMenubar
321	}
322
323	if intent.Resizable != nil {
324		features.Resizable = *intent.Resizable
325	} else {
326		features.Resizable = true // Usually resizable unless explicitly disabled
327	}
328
329	view.SetWindowFeatures(features)
330	windowTypeStr := "tab"
331	if isPopup {
332		windowTypeStr = "popup"
333	}
334	log.Printf("[workspace] Applied %s window features from intent: size=%dx%d, toolbar=%t, location=%t, menubar=%t, resizable=%t",
335		windowTypeStr, features.Width, features.Height, features.ToolbarVisible, features.LocationbarVisible, features.MenubarVisible, features.Resizable)
336}
337
338// handleIntentAsTab creates a tab pane based on window.open intent
339func (wm *WorkspaceManager) handleIntentAsTab(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
340	log.Printf("[workspace] Handling intent as tab: %s", url)
341
342	webkitCfg, err := wm.app.buildWebkitConfig()
343	if err != nil {
344		log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
345		return nil
346	}
347	webkitCfg.CreateWindow = false
348
349	newView, err := webkit.NewWebView(webkitCfg)
350	if err != nil {
351		log.Printf("[workspace] failed to create tab WebView: %v - allowing native popup", err)
352		return nil
353	}
354
355	newPane, err := wm.createPane(newView)
356	if err != nil {
357		log.Printf("[workspace] failed to create tab pane: %v - allowing native popup", err)
358		return nil
359	}
360
361	direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
362	if direction == "" {
363		direction = "right"
364	}
365
366	if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
367		log.Printf("[workspace] tab pane insertion failed: %v - allowing native popup", err)
368		return nil
369	}
370
371	node := wm.viewToNode[newView]
372	if node != nil {
373		node.windowType = webkit.WindowTypeTab
374		node.isRelated = false
375
376		// Apply window features from JavaScript intent
377		wm.applyWindowFeatures(newView, intent, false)
378	}
379
380	wm.ensureGUIInPane(newPane)
381
382	if url != "" {
383		if err := newView.LoadURL(url); err != nil {
384			log.Printf("[workspace] failed to load tab URL: %v", err)
385		}
386		if err := newView.Show(); err != nil {
387			log.Printf("[workspace] failed to show tab WebView: %v", err)
388		}
389	}
390
391	log.Printf("[workspace] Created tab pane for URL: %s", url)
392	return newView
393}
394
395// handleIntentAsPopup creates a related popup pane based on window.open intent
396func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
397	log.Printf("[workspace] Handling intent as popup: %s", url)
398
399	webkitCfg, err := wm.app.buildWebkitConfig()
400	if err != nil {
401		log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
402		return nil
403	}
404	webkitCfg.CreateWindow = false
405
406	newView, err := webkit.NewWebViewWithRelated(webkitCfg, sourceNode.pane.webView)
407	if err != nil {
408		log.Printf("[workspace] failed to create popup WebView: %v - allowing native popup", err)
409		return nil
410	}
411
412	// Log the parent-popup WebView ID relationship for OAuth auto-close
413	parentWebViewID := sourceNode.pane.webView.ID()
414	popupWebViewID := newView.ID()
415	log.Printf("[workspace] Created popup WebView: parentID=%s popupID=%s url=%s", parentWebViewID, popupWebViewID, url)
416
417	// Store popup WebView ID in parent's localStorage for OAuth callback lookup
418	storeScript := fmt.Sprintf(`
419		try {
420			const parentWebViewId = '%s';
421			const popupWebViewId = '%s';
422			const popupMapping = {
423				parentId: parentWebViewId,
424				popupId: popupWebViewId,
425				timestamp: Date.now(),
426				url: '%s'
427			};
428			localStorage.setItem('popup_mapping_' + parentWebViewId, JSON.stringify(popupMapping));
429			console.log('[workspace] Stored popup mapping:', popupMapping);
430		} catch(e) {
431			console.warn('[workspace] Failed to store popup mapping:', e);
432		}
433	`, parentWebViewID, popupWebViewID, url)
434
435	// Inject into parent WebView so it can find its popup later
436	if err := sourceNode.pane.webView.InjectScript(storeScript); err != nil {
437		log.Printf("[workspace] Failed to inject popup mapping script into parent: %v", err)
438	}
439
440	newPane, err := wm.createPane(newView)
441	if err != nil {
442		log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
443		return nil
444	}
445
446	direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
447	if direction == "" {
448		direction = "right"
449	}
450
451	if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
452		log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
453		return nil
454	}
455
456	node := wm.viewToNode[newView]
457	var requestID string
458	if node != nil {
459		node.windowType = webkit.WindowTypePopup
460		node.isRelated = true
461		node.parentPane = sourceNode
462		node.isPopup = true
463		node.autoClose = wm.shouldAutoClose(url)
464
465		// Track this popup in the parent's activePopupChildren
466		if sourceNode != nil {
467			if sourceNode.activePopupChildren == nil {
468				sourceNode.activePopupChildren = make([]string, 0)
469			}
470			sourceNode.activePopupChildren = append(sourceNode.activePopupChildren, popupWebViewID)
471			log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
472
473			// Register navigation policy handler for parent to block script-initiated navigations
474			// when it has active popup children (prevents OAuth from hijacking parent pane)
475			if sourceNode.pane != nil && sourceNode.pane.WebView() != nil {
476				sourceNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
477					// Always allow user-initiated navigations
478					if isUserGesture {
479						return true
480					}
481
482					// Block script-initiated navigations if this pane has active popup children
483					if len(sourceNode.activePopupChildren) > 0 {
484						log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(sourceNode.activePopupChildren), url)
485						return false
486					}
487
488					// Allow if no active popups
489					return true
490				})
491				log.Printf("[workspace] Registered navigation policy handler for parent pane")
492			}
493		}
494
495		// Store requestID for deduplication cleanup
496		if intent != nil {
497			requestID = intent.RequestID
498			node.requestID = requestID
499		}
500
501		// Apply window features from JavaScript intent
502		wm.applyWindowFeatures(newView, intent, true)
503
504		// Apply minimum size constraints to prevent compression
505		var width, height int
506		if intent != nil {
507			if intent.Width != nil {
508				width = *intent.Width
509			}
510			if intent.Height != nil {
511				height = *intent.Height
512			}
513		}
514		wm.applyPopupSizeConstraints(newView, width, height)
515	}
516
517	// Register close handler for popup auto-close
518	newView.RegisterCloseHandler(func() {
519		log.Printf("[workspace] Popup requesting close via window.close()")
520
521		// Clear the RequestID from deduplicator to allow new popups with same ID
522		if requestID != "" && wm.paneDeduplicator != nil {
523			wm.paneDeduplicator.ClearRequestID(requestID)
524		}
525
526		// Remove this popup from parent's activePopupChildren
527		if sourceNode != nil && popupWebViewID != "" {
528			for i, childID := range sourceNode.activePopupChildren {
529				if childID == popupWebViewID {
530					sourceNode.activePopupChildren = append(sourceNode.activePopupChildren[:i], sourceNode.activePopupChildren[i+1:]...)
531					log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
532					break
533				}
534			}
535		}
536
537		if n := wm.viewToNode[newView]; n != nil && n.isPopup {
538			time.AfterFunc(200*time.Millisecond, func() {
539				wm.scheduleIdleGuarded(func() bool {
540					if n == nil || !n.widgetValid {
541						return false
542					}
543					if err := wm.ClosePane(n); err != nil {
544						log.Printf("[workspace] Failed to close popup pane: %v", err)
545					}
546					return false
547				}, n)
548			})
549		}
550	})
551
552	// URL-based auto-close for OAuth popups
553	if node != nil && node.isPopup && node.autoClose {
554		wm.registerOAuthAutoClose(newView, url)
555	}
556
557	wm.ensureGUIInPane(newPane)
558
559	if url != "" {
560		if err := newView.LoadURL(url); err != nil {
561			log.Printf("[workspace] failed to load popup URL: %v", err)
562		}
563		if err := newView.Show(); err != nil {
564			log.Printf("[workspace] failed to show popup WebView: %v", err)
565		}
566	}
567
568	log.Printf("[workspace] Created popup pane for URL: %s", url)
569	return newView
570}
571
572// insertIndependentPane inserts a new independent pane next to the source
573func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error {
574	newPane, err := wm.createPane(webView)
575	if err != nil {
576		return err
577	}
578	direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
579	if direction == "" {
580		direction = "right"
581	}
582	if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil { // reuse insertion primitive
583		return err
584	}
585	node := wm.viewToNode[webView]
586	if node != nil {
587		node.windowType = webkit.WindowTypeTab
588		node.isRelated = false
589	}
590	if url != "" {
591		_ = webView.LoadURL(url)
592	}
593	return nil
594}
595
596// configureRelatedPopup creates a related view and inserts it
597func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView *webkit.WebView, url string, feat *webkit.WindowFeatures) {
598	// Use the WebView that was already created and returned to WebKit
599	related := webView
600	newPane, err := wm.createPane(related)
601	if err != nil {
602		log.Printf("[workspace] failed to create related popup pane: %v", err)
603		return
604	}
605	direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
606	if direction == "" {
607		direction = "right"
608	}
609	if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
610		log.Printf("[workspace] failed to insert related popup pane: %v", err)
611		return
612	}
613	node := wm.viewToNode[related]
614	popupWebViewID := related.ID()
615
616	if node != nil {
617		node.windowType = webkit.WindowTypePopup
618		node.windowFeatures = feat
619		node.isRelated = true
620		node.parentPane = sourceNode
621		node.isPopup = true
622		// Heuristic + config for auto-close intent
623		node.autoClose = wm.shouldAutoClose(url)
624
625		// Apply minimum size constraints to prevent compression
626		var width, height int
627		if feat != nil {
628			width = feat.Width
629			height = feat.Height
630		}
631		wm.applyPopupSizeConstraints(related, width, height)
632	}
633
634	// Track this popup in the parent's activePopupChildren
635	// This is critical to prevent OAuth flows from hijacking the parent pane via window.opener
636	if sourceNode != nil && popupWebViewID != "" {
637		if sourceNode.activePopupChildren == nil {
638			sourceNode.activePopupChildren = make([]string, 0)
639		}
640		sourceNode.activePopupChildren = append(sourceNode.activePopupChildren, popupWebViewID)
641		log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
642
643		// Register navigation policy handler for parent to block script-initiated navigations
644		// when it has active popup children (prevents OAuth from hijacking parent pane)
645		if sourceNode.pane != nil && sourceNode.pane.WebView() != nil {
646			sourceNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
647				// Always allow user-initiated navigations
648				if isUserGesture {
649					return true
650				}
651
652				// Block script-initiated navigations if this pane has active popup children
653				if len(sourceNode.activePopupChildren) > 0 {
654					log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(sourceNode.activePopupChildren), url)
655					return false
656				}
657
658				// Allow if no active popups
659				return true
660			})
661			log.Printf("[workspace] Registered navigation policy handler for parent pane")
662		}
663	}
664
665	// Pipe into existing auto-close flow only for popups (confirmed by detection)
666	related.RegisterCloseHandler(func() {
667		log.Printf("[workspace] Popup requesting close via window.close()")
668
669		// Remove this popup from parent's activePopupChildren
670		if sourceNode != nil && popupWebViewID != "" {
671			for i, childID := range sourceNode.activePopupChildren {
672				if childID == popupWebViewID {
673					sourceNode.activePopupChildren = append(sourceNode.activePopupChildren[:i], sourceNode.activePopupChildren[i+1:]...)
674					log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
675					break
676				}
677			}
678		}
679
680		if n := wm.viewToNode[related]; n != nil && n.isPopup {
681			time.AfterFunc(200*time.Millisecond, func() {
682				wm.scheduleIdleGuarded(func() bool {
683					if n == nil || !n.widgetValid {
684						return false
685					}
686					if err := wm.ClosePane(n); err != nil {
687						log.Printf("[workspace] Failed to close popup pane: %v", err)
688					}
689					return false
690				}, n)
691			})
692		}
693	})
694
695	// URL-based fallback: if providers don't call window.close(), auto-close on OAuth callback URLs
696	if node != nil && node.isPopup && node.autoClose {
697		wm.registerOAuthAutoClose(related, url)
698	}
699	if url != "" {
700		_ = related.LoadURL(url)
701	}
702}
703
704// applyPopupSizeConstraints applies minimum size constraints to prevent OAuth popup compression
705func (wm *WorkspaceManager) applyPopupSizeConstraints(view *webkit.WebView, width, height int) {
706	minWidth := defaultPopupMinWidth
707	minHeight := defaultPopupMinHeight
708
709	// Use provided dimensions if valid
710	if width > 0 {
711		minWidth = width
712	}
713	if height > 0 {
714		minHeight = height
715	}
716
717	// Apply to container
718	if container := view.RootWidget(); container != 0 && webkit.WidgetIsValid(container) {
719		webkit.WidgetSetSizeRequest(container, minWidth, minHeight)
720		webkit.WidgetQueueResize(container)
721		log.Printf("[workspace] Applied minimum size %dx%d to popup container=%#x", minWidth, minHeight, container)
722	}
723
724	// Apply to WebView widget
725	if webViewWidget := view.Widget(); webViewWidget != 0 && webkit.WidgetIsValid(webViewWidget) {
726		webkit.WidgetSetSizeRequest(webViewWidget, minWidth, minHeight)
727		webkit.WidgetQueueResize(webViewWidget)
728		log.Printf("[workspace] Applied minimum size %dx%d to popup webview=%#x", minWidth, minHeight, webViewWidget)
729	}
730}
731
732// shouldAutoClose checks simple OAuth-like URL patterns and config flag
733func (wm *WorkspaceManager) shouldAutoClose(url string) bool {
734	log.Printf("[workspace] shouldAutoClose called for URL: %s", url)
735
736	if wm == nil || wm.app == nil || wm.app.config == nil {
737		log.Printf("[workspace] shouldAutoClose: missing config, returning true")
738		return true
739	}
740	if !wm.app.config.Workspace.Popups.OAuthAutoClose {
741		log.Printf("[workspace] shouldAutoClose: OAuthAutoClose disabled in config, returning false")
742		return false
743	}
744
745	u := strings.ToLower(url)
746	log.Printf("[workspace] shouldAutoClose: checking lowercase URL: %s", u)
747
748	// RFC 6749 compliant OAuth 2.0 URL patterns
749	oauthPatterns := []string{
750		// Standard OAuth endpoints
751		"oauth", "authorize", "authorization",
752		// Standard callback/redirect patterns
753		"callback", "redirect", "auth/callback",
754		// OpenID Connect patterns
755		"oidc", "openid",
756		// Common OAuth parameter indicators
757		"response_type=", "client_id=", "redirect_uri=", "scope=", "state=",
758		// Standard OAuth response parameters
759		"code=", "access_token=", "id_token=", "token_type=",
760		// Error response parameters
761		"error=", "error_description=",
762	}
763
764	// Check for OAuth patterns in URL
765	for _, pattern := range oauthPatterns {
766		if strings.Contains(u, pattern) {
767			log.Printf("[workspace] shouldAutoClose: MATCHED pattern '%s' in URL, returning true", pattern)
768			return true
769		}
770	}
771
772	log.Printf("[workspace] shouldAutoClose: no OAuth patterns matched, returning false")
773	return false
774}
775
776// RunOnUI schedules a function; here simply executes inline as GTK main loop is single-threaded
777func (wm *WorkspaceManager) RunOnUI(fn func()) {
778	if fn != nil {
779		fn()
780	}
781}
782
783// handleDetectedWindowType handles window type detection from smart detection path
784func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webView *webkit.WebView, url string, windowType webkit.WindowType, features *webkit.WindowFeatures) {
785	if wm.viewToNode[webView] != nil {
786		return // Already placed
787	}
788
789	log.Printf("[workspace] Smart detection result: type=%d url=%s", windowType, url)
790
791	switch windowType {
792	case webkit.WindowTypeTab:
793		// For tabs, create a NEW independent WebView (can't use the related one)
794		webkitCfg, err := wm.app.buildWebkitConfig()
795		if err != nil {
796			log.Printf("[workspace] failed to build webkit config for tab: %v", err)
797			return
798		}
799		webkitCfg.CreateWindow = false
800
801		// Create independent WebView like handleIntentAsTab does
802		independentView, err := webkit.NewWebView(webkitCfg)
803		if err != nil {
804			log.Printf("[workspace] failed to create independent tab WebView: %v", err)
805			return
806		}
807
808		// The related webView was just for detection - we don't use it for tabs
809		// Insert the new independent view as a tab
810		if err := wm.insertIndependentPane(sourceNode, independentView, url); err != nil {
811			log.Printf("[workspace] Failed to insert independent pane: %v", err)
812		}
813
814	case webkit.WindowTypePopup:
815		// For popups, use the related WebView we already created
816		wm.configureRelatedPopup(sourceNode, webView, url, features)
817	default:
818		// Fallback to popup behavior
819		wm.configureRelatedPopup(sourceNode, webView, url, features)
820	}
821}

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